26 KiB
26 KiB
UI_COMPONENTS.md - NetGesCon Laravel
Creato: 8 Luglio 2025
Ultimo aggiornamento: 8 Luglio 2025
Versione: 1.0
🎯 SCOPO DEL DOCUMENTO
Documentazione dei componenti UI, layout responsive, design system e architettura frontend per facilitare lo sviluppo dell'interfaccia utente e garantire coerenza visiva.
🎨 DESIGN SYSTEM E TEMA
Palette Colori Principale:
:root {
/* Colori Brand */
--primary: #2563eb; /* Blu principale */
--primary-dark: #1d4ed8; /* Blu scuro */
--primary-light: #3b82f6; /* Blu chiaro */
/* Colori Funzionali */
--success: #059669; /* Verde successo */
--warning: #d97706; /* Arancione warning */
--error: #dc2626; /* Rosso errore */
--info: #0284c7; /* Blu info */
/* Colori Neutri */
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
/* Background & Text */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
}
Typography Scale:
.text-xs { font-size: 0.75rem; } /* 12px */
.text-sm { font-size: 0.875rem; } /* 14px */
.text-base { font-size: 1rem; } /* 16px */
.text-lg { font-size: 1.125rem; } /* 18px */
.text-xl { font-size: 1.25rem; } /* 20px */
.text-2xl { font-size: 1.5rem; } /* 24px */
.text-3xl { font-size: 1.875rem; } /* 30px */
.text-4xl { font-size: 2.25rem; } /* 36px */
.font-light { font-weight: 300; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
Spacing System:
.space-1 { margin/padding: 0.25rem; } /* 4px */
.space-2 { margin/padding: 0.5rem; } /* 8px */
.space-3 { margin/padding: 0.75rem; } /* 12px */
.space-4 { margin/padding: 1rem; } /* 16px */
.space-5 { margin/padding: 1.25rem; } /* 20px */
.space-6 { margin/padding: 1.5rem; } /* 24px */
.space-8 { margin/padding: 2rem; } /* 32px */
.space-10 { margin/padding: 2.5rem; } /* 40px */
.space-12 { margin/padding: 3rem; } /* 48px */
📱 LAYOUT RESPONSIVE E BREAKPOINT
Breakpoint Standard:
/* Mobile First Approach */
.container {
width: 100%;
margin: 0 auto;
padding: 0 1rem;
}
/* Small devices (landscape phones, 576px and up) */
@media (min-width: 576px) {
.container { max-width: 540px; }
}
/* Medium devices (tablets, 768px and up) */
@media (min-width: 768px) {
.container { max-width: 720px; }
.sidebar { display: block; }
}
/* Large devices (desktops, 992px and up) */
@media (min-width: 992px) {
.container { max-width: 960px; }
}
/* Extra large devices (large desktops, 1200px and up) */
@media (min-width: 1200px) {
.container { max-width: 1140px; }
}
/* XXL devices (larger desktops, 1400px and up) */
@media (min-width: 1400px) {
.container { max-width: 1320px; }
}
Grid System:
<!-- Grid Base 12 Colonne -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div class="col-span-1">Colonna 1</div>
<div class="col-span-1">Colonna 2</div>
<div class="col-span-1">Colonna 3</div>
</div>
<!-- Layout Dashboard -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-3">Sidebar</div>
<div class="lg:col-span-9">Main Content</div>
</div>
🧩 COMPONENTI BASE
Button Component:
<!-- Vue Component: BaseButton.vue -->
<template>
<button
:class="buttonClasses"
:disabled="loading || disabled"
@click="$emit('click', $event)"
>
<span v-if="loading" class="animate-spin">⏳</span>
<span v-if="icon && !loading" class="mr-2">{{ icon }}</span>
<slot />
</button>
</template>
<script>
export default {
name: 'BaseButton',
props: {
variant: {
type: String,
default: 'primary',
validator: v => ['primary', 'secondary', 'success', 'warning', 'error'].includes(v)
},
size: {
type: String,
default: 'md',
validator: v => ['xs', 'sm', 'md', 'lg', 'xl'].includes(v)
},
loading: Boolean,
disabled: Boolean,
icon: String
},
computed: {
buttonClasses() {
return [
'btn',
`btn-${this.variant}`,
`btn-${this.size}`,
{
'opacity-50 cursor-not-allowed': this.disabled || this.loading
}
];
}
}
}
</script>
<style scoped>
.btn {
@apply font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-primary text-white hover:bg-primary-dark focus:ring-primary;
}
.btn-secondary {
@apply bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-500;
}
.btn-xs { @apply px-2 py-1 text-xs; }
.btn-sm { @apply px-3 py-1.5 text-sm; }
.btn-md { @apply px-4 py-2 text-base; }
.btn-lg { @apply px-6 py-3 text-lg; }
.btn-xl { @apply px-8 py-4 text-xl; }
</style>
Input Component:
<!-- Vue Component: BaseInput.vue -->
<template>
<div class="form-group">
<label v-if="label" :for="id" class="form-label">
{{ label }}
<span v-if="required" class="text-error">*</span>
</label>
<div class="relative">
<input
:id="id"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:class="inputClasses"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
/>
<div v-if="icon" class="absolute inset-y-0 left-0 pl-3 flex items-center">
<span class="text-gray-400">{{ icon }}</span>
</div>
</div>
<div v-if="error" class="form-error">{{ error }}</div>
<div v-else-if="help" class="form-help">{{ help }}</div>
</div>
</template>
<script>
export default {
name: 'BaseInput',
props: {
modelValue: [String, Number],
type: { type: String, default: 'text' },
label: String,
placeholder: String,
error: String,
help: String,
icon: String,
required: Boolean,
disabled: Boolean,
id: String
},
computed: {
inputClasses() {
return [
'form-control',
{
'pl-10': this.icon,
'border-error focus:border-error focus:ring-error': this.error,
'opacity-50 cursor-not-allowed': this.disabled
}
];
}
}
}
</script>
<style scoped>
.form-group {
@apply mb-4;
}
.form-label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
.form-control {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-all;
}
.form-error {
@apply mt-1 text-sm text-error;
}
.form-help {
@apply mt-1 text-sm text-gray-500;
}
</style>
Card Component:
<!-- Vue Component: BaseCard.vue -->
<template>
<div :class="cardClasses">
<div v-if="$slots.header || title" class="card-header">
<slot name="header">
<h3 class="card-title">{{ title }}</h3>
</slot>
</div>
<div class="card-body">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<script>
export default {
name: 'BaseCard',
props: {
title: String,
shadow: {
type: String,
default: 'md',
validator: v => ['none', 'sm', 'md', 'lg', 'xl'].includes(v)
},
border: Boolean,
hover: Boolean
},
computed: {
cardClasses() {
return [
'card',
`shadow-${this.shadow}`,
{
'border border-gray-200': this.border,
'hover:shadow-lg transition-shadow': this.hover
}
];
}
}
}
</script>
<style scoped>
.card {
@apply bg-white rounded-lg overflow-hidden;
}
.card-header {
@apply px-6 py-4 border-b border-gray-200 bg-gray-50;
}
.card-title {
@apply text-lg font-semibold text-gray-900;
}
.card-body {
@apply p-6;
}
.card-footer {
@apply px-6 py-4 border-t border-gray-200 bg-gray-50;
}
</style>
🏗️ LAYOUT PRINCIPAL
App Layout:
<!-- Vue Component: AppLayout.vue -->
<template>
<div class="app-layout">
<!-- Header -->
<AppHeader
:user="currentUser"
:amministratore="currentAmministratore"
@toggle-sidebar="sidebarOpen = !sidebarOpen"
/>
<!-- Sidebar -->
<AppSidebar
:open="sidebarOpen"
:navigation="navigationItems"
@close="sidebarOpen = false"
/>
<!-- Main Content -->
<main class="main-content" :class="{ 'sidebar-open': sidebarOpen }">
<!-- Breadcrumb -->
<AppBreadcrumb v-if="breadcrumb" :items="breadcrumb" />
<!-- Page Content -->
<div class="page-content">
<slot />
</div>
</main>
<!-- Footer -->
<AppFooter />
<!-- Toast Notifications -->
<AppToast />
<!-- Loading Overlay -->
<AppLoading v-if="$store.state.loading" />
</div>
</template>
<script>
export default {
name: 'AppLayout',
data() {
return {
sidebarOpen: false
};
},
computed: {
currentUser() {
return this.$store.state.auth.user;
},
currentAmministratore() {
return this.$store.state.auth.amministratore;
},
navigationItems() {
return [
{
name: 'Dashboard',
route: 'dashboard',
icon: '📊',
permission: 'dashboard.view'
},
{
name: 'Stabili',
route: 'stabili.index',
icon: '🏢',
permission: 'stabili.view',
children: [
{ name: 'Lista Stabili', route: 'stabili.index' },
{ name: 'Nuovo Stabile', route: 'stabili.create' }
]
},
{
name: 'Anagrafica',
route: 'anagrafica.index',
icon: '👥',
permission: 'anagrafica.view'
},
{
name: 'Contabilità',
route: 'contabilita.index',
icon: '💰',
permission: 'contabilita.view'
},
{
name: 'Report',
route: 'report.index',
icon: '📈',
permission: 'report.view'
}
];
},
breadcrumb() {
return this.$route.meta.breadcrumb;
}
}
}
</script>
<style scoped>
.app-layout {
@apply min-h-screen bg-gray-50;
}
.main-content {
@apply ml-0 transition-all duration-300 ease-in-out;
margin-top: 64px; /* Header height */
}
@media (min-width: 768px) {
.main-content {
@apply ml-64; /* Sidebar width */
}
.main-content.sidebar-open {
@apply ml-64;
}
}
.page-content {
@apply p-6;
}
</style>
Sidebar Component:
<!-- Vue Component: AppSidebar.vue -->
<template>
<aside class="sidebar" :class="{ 'open': open }">
<!-- Sidebar Header -->
<div class="sidebar-header">
<div class="flex items-center">
<img src="/logo.png" alt="NetGesCon" class="w-8 h-8" />
<span class="ml-2 text-xl font-bold text-white">NetGesCon</span>
</div>
<button @click="$emit('close')" class="sidebar-close md:hidden">
✕
</button>
</div>
<!-- Navigation -->
<nav class="sidebar-nav">
<SidebarItem
v-for="item in navigation"
:key="item.name"
:item="item"
:level="0"
/>
</nav>
<!-- Sidebar Footer -->
<div class="sidebar-footer">
<div class="text-xs text-gray-400">
{{ currentAmministratore?.nome_completo }}
</div>
</div>
</aside>
<!-- Overlay per mobile -->
<div
v-if="open"
class="sidebar-overlay md:hidden"
@click="$emit('close')"
></div>
</template>
<script>
export default {
name: 'AppSidebar',
props: {
open: Boolean,
navigation: Array
},
computed: {
currentAmministratore() {
return this.$store.state.auth.amministratore;
}
}
}
</script>
<style scoped>
.sidebar {
@apply fixed top-0 left-0 z-40 w-64 h-screen bg-gray-800 transform -translate-x-full transition-transform md:translate-x-0;
}
.sidebar.open {
@apply translate-x-0;
}
.sidebar-header {
@apply flex items-center justify-between p-4 border-b border-gray-700;
}
.sidebar-close {
@apply text-white hover:text-gray-300;
}
.sidebar-nav {
@apply flex-1 px-2 py-4 space-y-1 overflow-y-auto;
}
.sidebar-footer {
@apply p-4 border-t border-gray-700;
}
.sidebar-overlay {
@apply fixed inset-0 z-30 bg-black bg-opacity-50;
}
</style>
📄 PAGINE SPECIFICHE
Dashboard Page:
<!-- Vue Component: DashboardPage.vue -->
<template>
<div class="dashboard">
<!-- Header Statistiche -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Stabili Gestiti"
:value="statistics.stabili_totali"
icon="🏢"
color="primary"
/>
<StatsCard
title="Unità Immobiliari"
:value="statistics.unita_totali"
icon="🏘️"
color="success"
/>
<StatsCard
title="Saldo Anno"
:value="formatCurrency(statistics.saldo_anno)"
icon="💰"
color="warning"
/>
<StatsCard
title="Contratti Attivi"
:value="statistics.contratti_attivi"
icon="📄"
color="info"
/>
</div>
<!-- Charts e Grafici -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<BaseCard title="Trend Finanziario">
<FinancialChart :data="chartData.financial" />
</BaseCard>
<BaseCard title="Distribuzione Tipologie">
<PieChart :data="chartData.properties" />
</BaseCard>
</div>
<!-- Tabelle Recenti -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<BaseCard title="Ultimi Movimenti" class="hover">
<RecentMovements :movements="recentMovements" />
<template #footer>
<RouterLink to="/contabilita" class="text-primary hover:text-primary-dark">
Vedi tutti →
</RouterLink>
</template>
</BaseCard>
<BaseCard title="Contratti in Scadenza" class="hover">
<ExpiringContracts :contracts="expiringContracts" />
<template #footer>
<RouterLink to="/locazioni" class="text-primary hover:text-primary-dark">
Gestisci →
</RouterLink>
</template>
</BaseCard>
</div>
</div>
</template>
<script>
export default {
name: 'DashboardPage',
data() {
return {
statistics: {},
chartData: {},
recentMovements: [],
expiringContracts: []
};
},
async created() {
await this.loadDashboardData();
},
methods: {
async loadDashboardData() {
try {
const response = await this.$api.get('/dashboard/statistiche');
this.statistics = response.data.data;
this.chartData = response.data.charts;
this.recentMovements = response.data.recent_movements;
this.expiringContracts = response.data.expiring_contracts;
} catch (error) {
this.$toast.error('Errore nel caricamento dei dati dashboard');
}
},
formatCurrency(value) {
return new Intl.NumberFormat('it-IT', {
style: 'currency',
currency: 'EUR'
}).format(value);
}
}
}
</script>
Lista Stabili Page:
<!-- Vue Component: StabiliListPage.vue -->
<template>
<div class="stabili-list">
<!-- Header con Filtri -->
<div class="page-header">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Gestione Stabili</h1>
<BaseButton @click="$router.push('/stabili/create')" variant="primary">
➕ Nuovo Stabile
</BaseButton>
</div>
<!-- Filtri -->
<div class="filters-bar bg-white p-4 rounded-lg shadow mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<BaseInput
v-model="filters.search"
placeholder="Cerca per denominazione..."
icon="🔍"
@input="debouncedSearch"
/>
<BaseSelect
v-model="filters.citta"
:options="cittaOptions"
placeholder="Filtra per città"
/>
<BaseSelect
v-model="filters.ascensore"
:options="ascensoreOptions"
placeholder="Con/Senza ascensore"
/>
<BaseButton @click="resetFilters" variant="secondary">
🔄 Reset
</BaseButton>
</div>
</div>
</div>
<!-- Lista Stabili -->
<div class="stabili-grid">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<StabileCard
v-for="stabile in stabili"
:key="stabile.id"
:stabile="stabile"
@edit="editStabile"
@delete="deleteStabile"
@view="viewStabile"
/>
</div>
</div>
<!-- Pagination -->
<BasePagination
v-if="pagination.total > pagination.per_page"
:current-page="pagination.current_page"
:total-pages="pagination.last_page"
:total="pagination.total"
@page-change="changePage"
/>
<!-- Loading -->
<div v-if="loading" class="text-center py-8">
<span class="text-gray-500">Caricamento stabili...</span>
</div>
<!-- Empty State -->
<div v-else-if="stabili.length === 0" class="empty-state text-center py-12">
<div class="text-6xl mb-4">🏢</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Nessuno stabile trovato</h3>
<p class="text-gray-500 mb-4">Inizia creando il tuo primo stabile.</p>
<BaseButton @click="$router.push('/stabili/create')" variant="primary">
Crea Primo Stabile
</BaseButton>
</div>
</div>
</template>
<script>
import { debounce } from 'lodash';
export default {
name: 'StabiliListPage',
data() {
return {
stabili: [],
pagination: {},
filters: {
search: '',
citta: '',
ascensore: ''
},
loading: false,
cittaOptions: [],
ascensoreOptions: [
{ value: '', text: 'Tutti' },
{ value: 'true', text: 'Con ascensore' },
{ value: 'false', text: 'Senza ascensore' }
]
};
},
created() {
this.loadStabili();
this.loadCittaOptions();
this.debouncedSearch = debounce(this.loadStabili, 300);
},
methods: {
async loadStabili(page = 1) {
this.loading = true;
try {
const params = {
page,
...this.filters
};
const response = await this.$api.get('/stabili', { params });
this.stabili = response.data.data;
this.pagination = response.data.meta;
} catch (error) {
this.$toast.error('Errore nel caricamento degli stabili');
} finally {
this.loading = false;
}
},
async loadCittaOptions() {
try {
const response = await this.$api.get('/stabili/citta-list');
this.cittaOptions = response.data.data.map(citta => ({
value: citta,
text: citta
}));
} catch (error) {
console.error('Errore caricamento città:', error);
}
},
changePage(page) {
this.loadStabili(page);
},
resetFilters() {
this.filters = {
search: '',
citta: '',
ascensore: ''
};
this.loadStabili();
},
editStabile(stabile) {
this.$router.push(`/stabili/${stabile.id}/edit`);
},
viewStabile(stabile) {
this.$router.push(`/stabili/${stabile.id}`);
},
async deleteStabile(stabile) {
if (confirm(`Sei sicuro di voler eliminare "${stabile.denominazione}"?`)) {
try {
await this.$api.delete(`/stabili/${stabile.id}`);
this.$toast.success('Stabile eliminato con successo');
this.loadStabili();
} catch (error) {
this.$toast.error('Errore nell\'eliminazione dello stabile');
}
}
}
}
}
</script>
🔧 UTILITIES E HELPERS
Vue Composables:
// composables/useApi.js
import { ref, reactive } from 'vue';
import { useToast } from './useToast';
export function useApi() {
const loading = ref(false);
const error = ref(null);
const toast = useToast();
const request = async (method, url, data = null) => {
loading.value = true;
error.value = null;
try {
const response = await axios[method](url, data);
return response.data;
} catch (err) {
error.value = err.response?.data?.message || 'Errore di rete';
toast.error(error.value);
throw err;
} finally {
loading.value = false;
}
};
return {
loading: readonly(loading),
error: readonly(error),
get: (url) => request('get', url),
post: (url, data) => request('post', url, data),
put: (url, data) => request('put', url, data),
delete: (url) => request('delete', url)
};
}
// composables/usePermissions.js
export function usePermissions() {
const user = computed(() => store.state.auth.user);
const hasPermission = (permission) => {
return user.value?.permissions?.includes(permission) || false;
};
const hasRole = (role) => {
return user.value?.role === role;
};
const canAccess = (requiredPermissions) => {
if (!Array.isArray(requiredPermissions)) {
return hasPermission(requiredPermissions);
}
return requiredPermissions.some(permission => hasPermission(permission));
};
return {
hasPermission,
hasRole,
canAccess
};
}
Utility Functions:
// utils/formatters.js
export const formatCurrency = (value) => {
return new Intl.NumberFormat('it-IT', {
style: 'currency',
currency: 'EUR'
}).format(value);
};
export const formatDate = (date, options = {}) => {
const defaultOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
};
return new Intl.DateTimeFormat('it-IT', {
...defaultOptions,
...options
}).format(new Date(date));
};
export const formatPercentage = (value, decimals = 2) => {
return `${parseFloat(value).toFixed(decimals)}%`;
};
// utils/validators.js
export const validateCodiceFiscale = (cf) => {
const regex = /^[A-Z]{6}[0-9]{2}[A-Z][0-9]{2}[A-Z][0-9]{3}[A-Z]$/;
return regex.test(cf.toUpperCase());
};
export const validatePartitaIva = (piva) => {
const regex = /^[0-9]{11}$/;
return regex.test(piva.replace(/\D/g, ''));
};
export const validateEmail = (email) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
🎯 RIFERIMENTI INCROCIATI
- DATABASE_SCHEMA.md: ↗️ Struttura dati per binding componenti
- DATA_ARCHITECTURE.md: ↗️ Modelli Eloquent per props e computed
- API_ENDPOINTS.md: ↗️ Endpoint per chiamate AJAX e form submission
- PROGRESS_LOG.md: ↗️ Stato implementazione componenti e pagine
- DEVELOPMENT_IDEAS.md: (DA CREARE) ↗️ Idee UI/UX e miglioramenti frontend
Documento creato: 8 Luglio 2025 - Guida completa UI/UX NetGesCon