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

27 KiB
Raw Blame History

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