📋 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
995 lines
27 KiB
Markdown
995 lines
27 KiB
Markdown
# 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*
|