netgescon-master/docs/02-architettura-laravel/specifiche/UI_COMPONENTS.md
Pikappa2 480e7eafbd 🎯 NETGESCON - Setup iniziale repository completo
📋 Commit iniziale con:
-  Documentazione unificata in docs/
-  Codice Laravel in netgescon-laravel/
-  Script automazione in scripts/
-  Configurazione sync rsync
-  Struttura organizzata e pulita

🔄 Versione: 2025.07.19-1644
🎯 Sistema pronto per Git distribuito
2025-07-19 16:44:47 +02:00

995 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**:
```css
: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**:
```css
.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**:
```css
.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**:
```css
/* 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**:
```html
<!-- 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**:
```html
<!-- 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**:
```html
<!-- 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**:
```html
<!-- 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**:
```html
<!-- 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**:
```html
<!-- 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**:
```html
<!-- 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**:
```html
<!-- 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**:
```javascript
// 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**:
```javascript
// 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*