++ Primo Caricamento
This commit is contained in:
48
.dockerignore
Normal file
48
.dockerignore
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# .dockerignore: file esclusi dal "build context" di Docker
|
||||||
|
#
|
||||||
|
# Quando esegui "docker build", Docker invia l'intera directory al
|
||||||
|
# daemon. Escludere file grandi o inutili rende il build più veloce.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Dipendenze (vengono reinstallate dentro il container)
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# File di ambiente (SENSIBILI - non devono entrare nell'immagine)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Cache e log locali
|
||||||
|
storage/logs/*
|
||||||
|
storage/framework/cache/*
|
||||||
|
storage/framework/sessions/*
|
||||||
|
storage/framework/views/*
|
||||||
|
bootstrap/cache/*
|
||||||
|
|
||||||
|
# File di sviluppo
|
||||||
|
.editorconfig
|
||||||
|
.phpunit.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
phpunit.xml
|
||||||
|
phpstan.neon
|
||||||
|
|
||||||
|
# IDE e OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker stessa
|
||||||
|
docker/
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.*.yml
|
||||||
|
|
||||||
|
# Documentazione
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
53
.env.example
Normal file
53
.env.example
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# .env.example — Template pubblico delle variabili di ambiente
|
||||||
|
#
|
||||||
|
# Questo file VA committato su Git. Non contiene valori reali.
|
||||||
|
# Ogni sviluppatore copia questo file in ".env" e imposta i propri valori.
|
||||||
|
#
|
||||||
|
# Istruzioni:
|
||||||
|
# cp .env.example .env
|
||||||
|
# php artisan key:generate (oppure automatico via entrypoint Docker)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
APP_NAME="Portale Clienti"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
|
NGINX_PORT=8080
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=portale_clienti
|
||||||
|
DB_USERNAME=portale_user
|
||||||
|
DB_PASSWORD=your_password_here
|
||||||
|
DB_ROOT_PASSWORD=your_root_password_here
|
||||||
|
DB_EXTERNAL_PORT=3306
|
||||||
|
|
||||||
|
CACHE_STORE=redis
|
||||||
|
SESSION_DRIVER=redis
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PASSWORD=your_redis_password_here
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_EXTERNAL_PORT=6379
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_HOST=mailpit
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_ENCRYPTION=null
|
||||||
|
MAIL_FROM_ADDRESS="noreply@portale-clienti.it"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
QUEUE_CONNECTION=sync
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# Ambiente
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Cache applicazione
|
||||||
|
/bootstrap/cache/*.php
|
||||||
|
/storage/*.key
|
||||||
|
|
||||||
|
# Storage generato
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/public/build
|
||||||
|
|
||||||
|
# PHPUnit
|
||||||
|
/.phpunit.cache
|
||||||
|
|
||||||
|
# Database SQLite di sviluppo
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
|
||||||
|
# Composer
|
||||||
|
composer.phar
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Log
|
||||||
|
/storage/logs/*.log
|
||||||
578
README.md
Normal file
578
README.md
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
# Portale Clienti — Applicazione Laravel 11 Demo
|
||||||
|
|
||||||
|
Applicazione di esempio per la gestione clienti costruita con **Laravel 11** e **Docker**.
|
||||||
|
È pensata come punto di partenza e materiale didattico per chi si avvicina a Laravel per la prima volta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Indice
|
||||||
|
|
||||||
|
1. [Cos'è questo progetto](#cosè-questo-progetto)
|
||||||
|
2. [Pre-requisiti](#pre-requisiti)
|
||||||
|
3. [Avvio rapido](#avvio-rapido)
|
||||||
|
4. [Struttura del progetto](#struttura-del-progetto)
|
||||||
|
5. [Architettura Docker](#architettura-docker)
|
||||||
|
6. [Concetti Laravel spiegati](#concetti-laravel-spiegati)
|
||||||
|
7. [Configurazione: .env vs Impostazioni dinamiche](#configurazione-env-vs-impostazioni-dinamiche)
|
||||||
|
8. [CRUD Clienti — come funziona](#crud-clienti--come-funziona)
|
||||||
|
9. [Comandi utili](#comandi-utili)
|
||||||
|
10. [Workflow di sviluppo](#workflow-di-sviluppo)
|
||||||
|
11. [Prossimi passi](#prossimi-passi)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cos'è questo progetto
|
||||||
|
|
||||||
|
Una piccola applicazione web che permette di:
|
||||||
|
|
||||||
|
- **Visualizzare una dashboard** con statistiche sui clienti
|
||||||
|
- **Gestire clienti** — creare, modificare, consultare, eliminare (CRUD completo)
|
||||||
|
- **Configurare l'applicazione** tramite un pannello impostazioni, senza toccare il codice
|
||||||
|
|
||||||
|
Le funzionalità sono intenzionalmente semplici: l'obiettivo è mostrare i pattern fondamentali di Laravel in modo chiaro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-requisiti
|
||||||
|
|
||||||
|
Installa questi strumenti **prima** di iniziare:
|
||||||
|
|
||||||
|
| Strumento | Versione minima | Link |
|
||||||
|
|-----------|-----------------|------|
|
||||||
|
| Docker Desktop | 4.x | https://www.docker.com/products/docker-desktop |
|
||||||
|
| Git | 2.x | https://git-scm.com |
|
||||||
|
|
||||||
|
> **Non serve** installare PHP, Composer o MySQL sul tuo computer. Docker gestisce tutto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avvio rapido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clona il repository
|
||||||
|
git clone <url-repo> portale-clienti
|
||||||
|
cd portale-clienti
|
||||||
|
|
||||||
|
# 2. Copia il file di configurazione
|
||||||
|
cp .env.example .env
|
||||||
|
# Modifica .env se necessario (le password predefinite vanno bene per lo sviluppo locale)
|
||||||
|
|
||||||
|
# 3. Costruisci e avvia i container Docker
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# ✅ L'applicazione sarà disponibile su http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
> Al primo avvio, Docker:
|
||||||
|
> 1. Costruisce l'immagine PHP con tutte le dipendenze
|
||||||
|
> 2. Esegue `composer install` per installare Laravel e le librerie
|
||||||
|
> 3. Genera l'`APP_KEY` crittografica
|
||||||
|
> 4. Esegue le migration (crea le tabelle nel database)
|
||||||
|
> 5. Popola il database con dati di esempio (clienti fittizi e impostazioni di default)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struttura del progetto
|
||||||
|
|
||||||
|
```
|
||||||
|
portale-clienti/
|
||||||
|
│
|
||||||
|
├── app/ ← Codice PHP dell'applicazione
|
||||||
|
│ ├── Http/
|
||||||
|
│ │ └── Controllers/ ← Controller: ricevono richieste HTTP
|
||||||
|
│ │ ├── DashboardController.php
|
||||||
|
│ │ ├── CustomerController.php
|
||||||
|
│ │ └── SettingController.php
|
||||||
|
│ ├── Models/ ← Model: rappresentano i dati (tabelle DB)
|
||||||
|
│ │ ├── Customer.php
|
||||||
|
│ │ └── Setting.php
|
||||||
|
│ ├── Providers/
|
||||||
|
│ │ └── AppServiceProvider.php ← Configurazione avvio applicazione
|
||||||
|
│ └── Services/
|
||||||
|
│ └── SettingService.php ← Logica di business per le impostazioni
|
||||||
|
│
|
||||||
|
├── bootstrap/
|
||||||
|
│ └── app.php ← Punto di bootstrap Laravel 11
|
||||||
|
│
|
||||||
|
├── config/ ← File di configurazione
|
||||||
|
│ ├── app.php ← Config app (nome, chiave, timezone...)
|
||||||
|
│ ├── database.php ← Config connessioni DB
|
||||||
|
│ └── settings.php ← ⭐ Config impostazioni dinamiche (vedi sotto)
|
||||||
|
│
|
||||||
|
├── database/
|
||||||
|
│ ├── migrations/ ← Definizioni schema database
|
||||||
|
│ │ ├── 2024_01_01_000010_create_customers_table.php
|
||||||
|
│ │ └── 2024_01_01_000020_create_settings_table.php
|
||||||
|
│ └── seeders/ ← Dati iniziali
|
||||||
|
│ ├── DatabaseSeeder.php
|
||||||
|
│ ├── SettingSeeder.php
|
||||||
|
│ └── CustomerSeeder.php
|
||||||
|
│
|
||||||
|
├── docker/
|
||||||
|
│ ├── nginx/
|
||||||
|
│ │ └── default.conf ← Configurazione web server
|
||||||
|
│ └── php/
|
||||||
|
│ ├── Dockerfile ← Immagine PHP custom
|
||||||
|
│ ├── entrypoint.sh ← Script avvio container
|
||||||
|
│ └── php.ini ← Configurazione PHP
|
||||||
|
│
|
||||||
|
├── public/
|
||||||
|
│ └── index.php ← Front controller (unico file esposto al web)
|
||||||
|
│
|
||||||
|
├── resources/
|
||||||
|
│ └── views/ ← Template HTML (Blade)
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── app.blade.php ← Layout principale condiviso
|
||||||
|
│ ├── dashboard.blade.php
|
||||||
|
│ ├── customers/
|
||||||
|
│ │ ├── index.blade.php
|
||||||
|
│ │ ├── create.blade.php
|
||||||
|
│ │ ├── edit.blade.php
|
||||||
|
│ │ ├── show.blade.php
|
||||||
|
│ │ └── _form.blade.php ← Partial riutilizzato da create ed edit
|
||||||
|
│ └── settings/
|
||||||
|
│ └── index.blade.php
|
||||||
|
│
|
||||||
|
├── routes/
|
||||||
|
│ ├── web.php ← Route HTTP (URL → Controller)
|
||||||
|
│ └── console.php ← Comandi Artisan e scheduler
|
||||||
|
│
|
||||||
|
├── storage/ ← File generati (log, cache, sessioni, upload)
|
||||||
|
│
|
||||||
|
├── .env ← ⭐ Variabili statiche (non committare!)
|
||||||
|
├── .env.example ← Template pubblico del .env
|
||||||
|
├── composer.json ← Dipendenze PHP
|
||||||
|
└── docker-compose.yml ← Orchestrazione container
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architettura Docker
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ docker-compose.yml │
|
||||||
|
│ │
|
||||||
|
Browser ──────► │ [Nginx :8080] ──► [PHP-FPM :9000] │
|
||||||
|
:8080 │ webserver app │
|
||||||
|
│ │ │
|
||||||
|
│ [MySQL :3306] │
|
||||||
|
│ db │
|
||||||
|
│ [Redis :6379] │
|
||||||
|
│ redis │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### I 4 container
|
||||||
|
|
||||||
|
| Container | Immagine | Ruolo |
|
||||||
|
|-----------|----------|-------|
|
||||||
|
| `portale_nginx` | nginx:1.25-alpine | Web server. Serve file statici e passa le richieste PHP a FPM |
|
||||||
|
| `portale_app` | Custom (basata su php:8.2-fpm) | Esegue il codice PHP Laravel |
|
||||||
|
| `portale_db` | mysql:8.0 | Database relazionale |
|
||||||
|
| `portale_redis` | redis:7-alpine | Cache veloce per sessioni e impostazioni |
|
||||||
|
|
||||||
|
### Comunicazione tra container
|
||||||
|
|
||||||
|
I container si "parlano" usando il **nome del servizio** come hostname.
|
||||||
|
Per questo in `.env` trovi `DB_HOST=db` (non `localhost`): `db` è il nome del servizio MySQL nel `docker-compose.yml`.
|
||||||
|
|
||||||
|
### Volumes (persistenza dati)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
portale_db_data: # I dati MySQL sopravvivono al restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Senza questo volume, ogni `docker compose down` cancella il database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concetti Laravel spiegati
|
||||||
|
|
||||||
|
### 1. MVC — Model, View, Controller
|
||||||
|
|
||||||
|
Laravel usa il pattern **MVC** per separare le responsabilità:
|
||||||
|
|
||||||
|
```
|
||||||
|
Richiesta HTTP
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Route] routes/web.php
|
||||||
|
│ Mappa URL → Controller
|
||||||
|
▼
|
||||||
|
[Controller] app/Http/Controllers/CustomerController.php
|
||||||
|
│ Riceve la richiesta, chiede i dati al Model, passa tutto alla View
|
||||||
|
▼
|
||||||
|
[Model] app/Models/Customer.php
|
||||||
|
│ Rappresenta la tabella `customers`. Usa Eloquent ORM per le query.
|
||||||
|
▼
|
||||||
|
[Database] MySQL
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[View] resources/views/customers/index.blade.php
|
||||||
|
│ Template HTML con variabili PHP. Genera la risposta HTML.
|
||||||
|
▼
|
||||||
|
Risposta HTML al browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Eloquent ORM
|
||||||
|
|
||||||
|
Invece di SQL raw, usi metodi PHP fluenti:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// SQL: SELECT * FROM customers WHERE status = 'attivo' ORDER BY name LIMIT 10
|
||||||
|
$clienti = Customer::where('status', 'attivo')
|
||||||
|
->orderBy('name')
|
||||||
|
->take(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// SQL: SELECT * FROM customers WHERE id = 42
|
||||||
|
$cliente = Customer::find(42);
|
||||||
|
|
||||||
|
// SQL: INSERT INTO customers (...) VALUES (...)
|
||||||
|
$nuovo = Customer::create([
|
||||||
|
'name' => 'Mario Rossi',
|
||||||
|
'email' => 'mario@example.com',
|
||||||
|
// ...
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Migration
|
||||||
|
|
||||||
|
Le migration sono "versionamento" del database. Invece di scrivere SQL manualmente:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Crea la tabella customers con colonne ben tipizzate
|
||||||
|
Schema::create('customers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Comandi:
|
||||||
|
```bash
|
||||||
|
php artisan migrate # Esegui migration nuove
|
||||||
|
php artisan migrate:rollback # Annulla l'ultima batch
|
||||||
|
php artisan migrate:fresh # ⚠️ Cancella tutto e ricrea (solo in development!)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Blade — Template Engine
|
||||||
|
|
||||||
|
Il template engine di Laravel. Sintassi pulita e sicura:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Stampa variabile (escape automatico per sicurezza XSS) --}}
|
||||||
|
{{ $customer->name }}
|
||||||
|
|
||||||
|
{{-- Ciclo --}}
|
||||||
|
@foreach ($customers as $customer)
|
||||||
|
<tr>...</tr>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
{{-- Condizione --}}
|
||||||
|
@if ($customer->status === 'attivo')
|
||||||
|
<span class="badge bg-success">Attivo</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Include partial --}}
|
||||||
|
@include('customers._form', ['customer' => $customer])
|
||||||
|
|
||||||
|
{{-- Eredita layout --}}
|
||||||
|
@extends('layouts.app')
|
||||||
|
@section('content')
|
||||||
|
<!-- contenuto specifico della pagina -->
|
||||||
|
@endsection
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Validazione
|
||||||
|
|
||||||
|
Laravel valida i dati dei form in modo dichiarativo:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|unique:customers',
|
||||||
|
'type' => 'required|in:privato,azienda',
|
||||||
|
]);
|
||||||
|
// Se la validazione fallisce, Laravel torna automaticamente al form
|
||||||
|
// con gli errori e i valori inseriti (no codice extra necessario)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nelle view, gli errori sono disponibili con `@error('name')`:
|
||||||
|
```blade
|
||||||
|
<input type="text" name="name" class="{{ $errors->has('name') ? 'is-invalid' : '' }}">
|
||||||
|
@error('name')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Dependency Injection
|
||||||
|
|
||||||
|
Laravel può iniettare dipendenze automaticamente nel costruttore dei Controller:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class CustomerController extends Controller
|
||||||
|
{
|
||||||
|
// Laravel crea SettingService automaticamente — non serve "new SettingService()"
|
||||||
|
public function __construct(
|
||||||
|
private SettingService $settings
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$perPage = $this->settings->get('items_per_page');
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configurazione: .env vs Impostazioni dinamiche
|
||||||
|
|
||||||
|
Questo progetto separa due tipi di configurazione:
|
||||||
|
|
||||||
|
### `.env` — Configurazione STATICA (infrastruttura)
|
||||||
|
|
||||||
|
Per valori che **non cambiano nel tempo** o che **variano per ambiente** (locale, staging, produzione):
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME="Portale Clienti" # Nome app
|
||||||
|
DB_HOST=db # Host database (nome container Docker)
|
||||||
|
DB_DATABASE=portale_clienti # Nome database
|
||||||
|
DB_PASSWORD=portale_secret_pass # Password (diversa per ogni ambiente)
|
||||||
|
REDIS_HOST=redis # Cache host
|
||||||
|
```
|
||||||
|
|
||||||
|
- Cambiare questi valori richiede il **restart del container**
|
||||||
|
- **Non si committano su Git** (`.env` è in `.gitignore`)
|
||||||
|
- Si usa `.env.example` come template pubblico
|
||||||
|
|
||||||
|
### `config/settings.php` + tabella `settings` — Configurazione DINAMICA (business)
|
||||||
|
|
||||||
|
Per valori che **cambiano nel tempo** e che **l'admin modifica da pannello web**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/settings.php definisce i default e i metadati
|
||||||
|
'defaults' => [
|
||||||
|
'items_per_page' => 15, // Righe per pagina
|
||||||
|
'currency_symbol' => '€', // Simbolo valuta
|
||||||
|
'welcome_message' => 'Benvenuto!', // Messaggio dashboard
|
||||||
|
'allow_notes' => true, // Feature flag
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- I valori vengono salvati nella tabella `settings` del database
|
||||||
|
- Modificabili via interfaccia web (/settings) senza codice o restart
|
||||||
|
- SettingService li mette in cache Redis per performance
|
||||||
|
- Ideali per: valute, formati data, messaggi, feature flags, paginazione
|
||||||
|
|
||||||
|
### Leggi un'impostazione nel codice
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In un Controller
|
||||||
|
$symbol = $this->settings->get('currency_symbol', '€');
|
||||||
|
|
||||||
|
// In una View (grazie a AppServiceProvider che condivide $appSettings)
|
||||||
|
{{ $appSettings['currency_symbol'] }}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRUD Clienti — come funziona
|
||||||
|
|
||||||
|
### Route Resource
|
||||||
|
|
||||||
|
Una sola riga nel file routes genera 7 URL:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::resource('customers', CustomerController::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Metodo | URL | Azione | Controller |
|
||||||
|
|--------|-----|--------|------------|
|
||||||
|
| GET | `/customers` | Lista | `index()` |
|
||||||
|
| GET | `/customers/create` | Form nuovo | `create()` |
|
||||||
|
| POST | `/customers` | Salva nuovo | `store()` |
|
||||||
|
| GET | `/customers/{id}` | Dettaglio | `show()` |
|
||||||
|
| GET | `/customers/{id}/edit` | Form modifica | `edit()` |
|
||||||
|
| PUT | `/customers/{id}` | Aggiorna | `update()` |
|
||||||
|
| DELETE | `/customers/{id}` | Elimina | `destroy()` |
|
||||||
|
|
||||||
|
### Soft Delete
|
||||||
|
|
||||||
|
I clienti "eliminati" non vengono rimossi fisicamente dal database:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Models/Customer.php
|
||||||
|
use SoftDeletes; // Aggiunge la colonna deleted_at
|
||||||
|
|
||||||
|
// Quando elimini:
|
||||||
|
$customer->delete();
|
||||||
|
// → imposta deleted_at = now()
|
||||||
|
// → NON cancella la riga
|
||||||
|
|
||||||
|
// Le query normali ignorano i soft-deleted:
|
||||||
|
Customer::all() // non include i clienti eliminati
|
||||||
|
Customer::withTrashed()->find($id) // include anche i soft-deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandi utili
|
||||||
|
|
||||||
|
### Container Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Avvia tutti i container in background
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Avvia ricostruendo le immagini (dopo modifiche al Dockerfile)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Ferma i container (i dati rimangono)
|
||||||
|
docker compose stop
|
||||||
|
|
||||||
|
# Ferma e rimuove i container (i dati rimangono nel volume)
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Ferma, rimuove container E volumi (⚠️ cancella il database!)
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
# Vedi i log in tempo reale
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Vedi i log solo del container PHP
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Artisan (dentro il container)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Entra nel container PHP
|
||||||
|
docker compose exec app bash
|
||||||
|
|
||||||
|
# Da dentro il container, tutti i comandi artisan:
|
||||||
|
php artisan route:list # Lista tutte le route registrate
|
||||||
|
php artisan migrate # Esegui migration nuove
|
||||||
|
php artisan migrate:fresh --seed # Ricrea DB con dati di esempio
|
||||||
|
php artisan db:seed # Aggiungi solo i dati (senza ricreare tabelle)
|
||||||
|
php artisan cache:clear # Svuota la cache
|
||||||
|
php artisan config:clear # Svuota cache configurazione
|
||||||
|
php artisan tinker # REPL interattivo (prova codice PHP/Laravel)
|
||||||
|
|
||||||
|
# Scorciatoia senza entrare nel container:
|
||||||
|
docker compose exec app php artisan route:list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tinker — REPL interattivo
|
||||||
|
|
||||||
|
Tinker è uno strumento potentissimo per esplorare e testare:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan tinker
|
||||||
|
|
||||||
|
# Dentro Tinker:
|
||||||
|
>>> Customer::count() # Quanti clienti ci sono?
|
||||||
|
>>> Customer::active()->get() # Clienti attivi
|
||||||
|
>>> Customer::find(1) # Cliente con ID 1
|
||||||
|
>>> Customer::create(['name' => 'Test', 'email' => 'test@test.it', 'type' => 'azienda', 'status' => 'attivo'])
|
||||||
|
>>> app(\App\Services\SettingService::class)->get('currency_symbol')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow di sviluppo
|
||||||
|
|
||||||
|
### Modificare il codice
|
||||||
|
|
||||||
|
I file del progetto sono **montati come volume** nel container Docker:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- .:/var/www # directory locale → /var/www nel container
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo significa che puoi **modificare i file sul tuo computer** con il tuo editor preferito, e le modifiche sono **immediatamente visibili** nel browser (no rebuild necessario per PHP/Blade).
|
||||||
|
|
||||||
|
### Aggiungere una colonna al database
|
||||||
|
|
||||||
|
1. Crea la migration:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan make:migration add_website_to_customers
|
||||||
|
```
|
||||||
|
2. Modifica il file creato in `database/migrations/`
|
||||||
|
3. Esegui la migration:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan migrate
|
||||||
|
```
|
||||||
|
4. Aggiorna il Model (aggiungi il campo in `$fillable`)
|
||||||
|
5. Aggiorna le view per mostrare/editare il campo
|
||||||
|
|
||||||
|
### Aggiungere una nuova sezione (es. Contratti)
|
||||||
|
|
||||||
|
1. **Migration**: `php artisan make:migration create_contracts_table`
|
||||||
|
2. **Model**: `php artisan make:model Contract`
|
||||||
|
3. **Controller**: aggiungi i metodi CRUD
|
||||||
|
4. **Route**: aggiungi `Route::resource('contracts', ContractController::class)`
|
||||||
|
5. **Views**: crea `resources/views/contracts/`
|
||||||
|
6. **Sidebar**: aggiungi il link in `resources/views/layouts/app.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prossimi passi
|
||||||
|
|
||||||
|
Suggerimenti per espandere questo progetto:
|
||||||
|
|
||||||
|
### Livello base
|
||||||
|
- [ ] Aggiungere l'autenticazione con `php artisan breeze:install`
|
||||||
|
- [ ] Aggiungere il campo "website" ai clienti
|
||||||
|
- [ ] Esportare i clienti in CSV
|
||||||
|
|
||||||
|
### Livello intermedio
|
||||||
|
- [ ] Aggiungere la gestione contratti (relazione 1-N con Customer)
|
||||||
|
- [ ] Upload logo aziendale (Laravel Storage + S3)
|
||||||
|
- [ ] Inviare email al cliente con Laravel Mail
|
||||||
|
- [ ] Test automatici con PestPHP
|
||||||
|
|
||||||
|
### Livello avanzato
|
||||||
|
- [ ] API REST con Laravel Sanctum
|
||||||
|
- [ ] Job asincroni per import massivo clienti
|
||||||
|
- [ ] Real-time con Laravel Broadcasting + Pusher
|
||||||
|
- [ ] Deploy in produzione su AWS/DigitalOcean
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risoluzione problemi
|
||||||
|
|
||||||
|
### "Permissions denied" su storage/
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app chmod -R 775 storage bootstrap/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Il database non si connette
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Controlla che il container MySQL sia avviato e sano
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Attendi che sia "healthy" (può impiegare 20-30 secondi al primo avvio)
|
||||||
|
docker compose logs db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Svuota tutta la cache
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php artisan optimize:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Riparti da zero (⚠️ cancella tutti i dati)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
16
app/Http/Controllers/Controller.php
Normal file
16
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Controller base — tutti i controller del progetto estendono questa classe
|
||||||
|
//
|
||||||
|
// In Laravel 11 il controller base è praticamente vuoto: serve solo come
|
||||||
|
// punto di estensione comune, nel caso in cui in futuro tu voglia aggiungere
|
||||||
|
// metodi o middleware condivisi tra tutti i controller.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
146
app/Http/Controllers/CustomerController.php
Normal file
146
app/Http/Controllers/CustomerController.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CustomerController — CRUD completo per la gestione clienti
|
||||||
|
//
|
||||||
|
// CRUD = Create, Read, Update, Delete
|
||||||
|
//
|
||||||
|
// Laravel usa la convenzione "Resource Controller": 7 metodi standard
|
||||||
|
// che corrispondono alle operazioni CRUD via HTTP:
|
||||||
|
//
|
||||||
|
// index() → GET /customers → lista clienti
|
||||||
|
// create() → GET /customers/create → form nuovo cliente
|
||||||
|
// store() → POST /customers → salva nuovo cliente
|
||||||
|
// show() → GET /customers/{id} → dettaglio cliente
|
||||||
|
// edit() → GET /customers/{id}/edit → form modifica
|
||||||
|
// update() → PUT /customers/{id} → aggiorna cliente
|
||||||
|
// destroy() → DELETE /customers/{id} → elimina cliente
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\SettingService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CustomerController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SettingService $settings
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Lista clienti ─────────────────────────────────────────────────────
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
// Recupera il numero di elementi per pagina dalle impostazioni dinamiche
|
||||||
|
$perPage = $this->settings->get('items_per_page', 15);
|
||||||
|
|
||||||
|
// Costruisce la query con filtri opzionali dalla URL
|
||||||
|
// Es: /customers?search=mario&type=privato&status=attivo
|
||||||
|
$query = Customer::query();
|
||||||
|
|
||||||
|
if ($search = $request->input('search')) {
|
||||||
|
$query->search($search); // Usa lo scope definito nel Model
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type = $request->input('type')) {
|
||||||
|
$query->byType($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status = $request->input('status')) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginate() divide i risultati in pagine e genera automaticamente
|
||||||
|
// i link prev/next, passati alla view come $customers->links()
|
||||||
|
$customers = $query->latest()->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
|
return view('customers.index', compact('customers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form nuovo cliente ────────────────────────────────────────────────
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return view('customers.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Salva nuovo cliente ───────────────────────────────────────────────
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
// Validazione: se fallisce, Laravel reindirizza automaticamente
|
||||||
|
// al form precedente con gli errori e i valori inseriti.
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|unique:customers,email',
|
||||||
|
'phone' => 'nullable|string|max:50',
|
||||||
|
'city' => 'nullable|string|max:100',
|
||||||
|
'address' => 'nullable|string|max:255',
|
||||||
|
'vat_number' => 'nullable|string|max:20',
|
||||||
|
'fiscal_code' => 'nullable|string|max:20',
|
||||||
|
'type' => 'required|in:privato,azienda',
|
||||||
|
'status' => 'required|in:attivo,inattivo,prospect',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'contract_value' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$customer = Customer::create($validated);
|
||||||
|
|
||||||
|
// redirect() rimanda il browser a un'altra pagina
|
||||||
|
// with('success', ...) aggiunge un messaggio flash (mostrato una volta)
|
||||||
|
return redirect()
|
||||||
|
->route('customers.show', $customer)
|
||||||
|
->with('success', "Cliente \"{$customer->name}\" creato con successo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dettaglio cliente ─────────────────────────────────────────────────
|
||||||
|
// Route Model Binding: Laravel trova automaticamente il Customer dall'URL
|
||||||
|
// Non serve scrivere: $customer = Customer::findOrFail($id);
|
||||||
|
public function show(Customer $customer)
|
||||||
|
{
|
||||||
|
return view('customers.show', compact('customer'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form modifica cliente ─────────────────────────────────────────────
|
||||||
|
public function edit(Customer $customer)
|
||||||
|
{
|
||||||
|
return view('customers.edit', compact('customer'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aggiorna cliente ─────────────────────────────────────────────────
|
||||||
|
public function update(Request $request, Customer $customer)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
// unique: ignora il record corrente (altrimenti failerebbe sempre)
|
||||||
|
'email' => "required|email|unique:customers,email,{$customer->id}",
|
||||||
|
'phone' => 'nullable|string|max:50',
|
||||||
|
'city' => 'nullable|string|max:100',
|
||||||
|
'address' => 'nullable|string|max:255',
|
||||||
|
'vat_number' => 'nullable|string|max:20',
|
||||||
|
'fiscal_code' => 'nullable|string|max:20',
|
||||||
|
'type' => 'required|in:privato,azienda',
|
||||||
|
'status' => 'required|in:attivo,inattivo,prospect',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'contract_value' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$customer->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('customers.show', $customer)
|
||||||
|
->with('success', "Cliente \"{$customer->name}\" aggiornato.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Elimina cliente (soft delete) ────────────────────────────────────
|
||||||
|
// Grazie a SoftDeletes nel Model, il record non viene cancellato:
|
||||||
|
// viene impostato `deleted_at` e non compare più nelle query normali.
|
||||||
|
public function destroy(Customer $customer)
|
||||||
|
{
|
||||||
|
$name = $customer->name;
|
||||||
|
$customer->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('customers.index')
|
||||||
|
->with('success', "Cliente \"{$name}\" eliminato.");
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Http/Controllers/DashboardController.php
Normal file
61
app/Http/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// DashboardController — gestisce la pagina principale del portale
|
||||||
|
//
|
||||||
|
// Un Controller riceve la richiesta HTTP, recupera i dati necessari
|
||||||
|
// e li passa alla View (template Blade) per la visualizzazione.
|
||||||
|
//
|
||||||
|
// Flusso di una richiesta:
|
||||||
|
// Browser → routes/web.php → Controller → View → risposta HTML
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\SettingService;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
// Dependency Injection: Laravel istanzia SettingService automaticamente
|
||||||
|
public function __construct(
|
||||||
|
private SettingService $settings
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Corrisponde alla route: GET /
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
// Statistiche aggregate sui clienti
|
||||||
|
$stats = [
|
||||||
|
'total' => Customer::count(),
|
||||||
|
'active' => Customer::active()->count(),
|
||||||
|
'prospect' => Customer::where('status', 'prospect')->count(),
|
||||||
|
'inactive' => Customer::where('status', 'inattivo')->count(),
|
||||||
|
// Somma contratti clienti attivi
|
||||||
|
'total_contract_value' => Customer::active()->sum('contract_value'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ultimi 5 clienti aggiunti (per "Attività recente")
|
||||||
|
$recentCustomers = Customer::latest()->take(5)->get();
|
||||||
|
|
||||||
|
// Clienti per città (top 5 - per widget grafico)
|
||||||
|
$byCity = Customer::selectRaw('city, count(*) as total')
|
||||||
|
->groupBy('city')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->take(5)
|
||||||
|
->pluck('total', 'city')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Messaggio di benvenuto dinamico (da impostazioni)
|
||||||
|
$welcomeMessage = $this->settings->get('welcome_message');
|
||||||
|
|
||||||
|
// compact() è una shorthand PHP per creare un array associativo
|
||||||
|
// equivalente a: ['stats' => $stats, 'recentCustomers' => $recentCustomers, ...]
|
||||||
|
return view('dashboard', compact(
|
||||||
|
'stats',
|
||||||
|
'recentCustomers',
|
||||||
|
'byCity',
|
||||||
|
'welcomeMessage'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Controllers/SettingController.php
Normal file
68
app/Http/Controllers/SettingController.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SettingController — pannello impostazioni dinamiche
|
||||||
|
//
|
||||||
|
// Permette all'admin di modificare le impostazioni dell'applicazione
|
||||||
|
// senza toccare il codice o riavviare i container.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Services\SettingService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SettingController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SettingService $settings
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Mostra il pannello impostazioni ──────────────────────────────────
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
// Tutte le impostazioni correnti dal servizio
|
||||||
|
$current = $this->settings->all();
|
||||||
|
|
||||||
|
// Configurazione per la UI (label, gruppi, tipi)
|
||||||
|
$config = config('settings');
|
||||||
|
|
||||||
|
return view('settings.index', compact('current', 'config'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Salva le modifiche ────────────────────────────────────────────────
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$types = config('settings.types', []);
|
||||||
|
$defaults = config('settings.defaults', []);
|
||||||
|
|
||||||
|
// Costruisce le regole di validazione dinamicamente
|
||||||
|
// in base ai tipi definiti in config/settings.php
|
||||||
|
$rules = [];
|
||||||
|
foreach ($defaults as $key => $default) {
|
||||||
|
$type = $types[$key] ?? 'string';
|
||||||
|
$rules[$key] = match ($type) {
|
||||||
|
'integer' => 'nullable|integer|min:1',
|
||||||
|
'boolean' => 'nullable|boolean',
|
||||||
|
'string' => 'nullable|string|max:255',
|
||||||
|
'text' => 'nullable|string',
|
||||||
|
default => 'nullable|string',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
|
// I checkbox non inviati dal form hanno valore null → false per i boolean
|
||||||
|
foreach ($types as $key => $type) {
|
||||||
|
if ($type === 'boolean' && ! array_key_exists($key, $validated)) {
|
||||||
|
$validated[$key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settings->setMany($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', 'Impostazioni salvate con successo. La cache è stata aggiornata.');
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Models/Customer.php
Normal file
107
app/Models/Customer.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Model Customer — rappresenta un cliente nel database
|
||||||
|
//
|
||||||
|
// In Laravel, un Model è una classe PHP che:
|
||||||
|
// 1. Mappa una tabella del DB (per default: nome classe pluralizzato → "customers")
|
||||||
|
// 2. Permette di leggere, creare, aggiornare e cancellare record (CRUD)
|
||||||
|
// 3. Definisce le relazioni con altri Model
|
||||||
|
//
|
||||||
|
// Usa Eloquent ORM: invece di scrivere SQL raw, usi metodi PHP come:
|
||||||
|
// Customer::all() → SELECT * FROM customers
|
||||||
|
// Customer::find(1) → SELECT * FROM customers WHERE id=1
|
||||||
|
// Customer::where('city', 'Roma') → SELECT * FROM customers WHERE city='Roma'
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Customer extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes; // Soft delete: non cancella davvero dal DB, imposta deleted_at
|
||||||
|
|
||||||
|
// ─── Tabella del database ──────────────────────────────────────────────
|
||||||
|
// Non è necessario specificarlo se il nome è il plurale della classe
|
||||||
|
// (Customer → customers). Lo specifichiamo esplicitamente per chiarezza.
|
||||||
|
protected $table = 'customers';
|
||||||
|
|
||||||
|
// ─── Campi modificabili (mass assignment) ─────────────────────────────
|
||||||
|
// Per sicurezza, Laravel blocca l'aggiornamento di massa di tutti i campi.
|
||||||
|
// Solo i campi elencati qui possono essere modificati con create() o fill().
|
||||||
|
// Alternativa: usa $guarded = [] per permettere tutto (meno sicuro).
|
||||||
|
protected $fillable = [
|
||||||
|
'name', // Ragione sociale o nome completo
|
||||||
|
'email', // Email principale di contatto
|
||||||
|
'phone', // Telefono
|
||||||
|
'city', // Città
|
||||||
|
'address', // Indirizzo completo
|
||||||
|
'vat_number', // Partita IVA
|
||||||
|
'fiscal_code', // Codice fiscale
|
||||||
|
'type', // 'privato' o 'azienda'
|
||||||
|
'status', // 'attivo', 'inattivo', 'prospect'
|
||||||
|
'notes', // Note libere
|
||||||
|
'contract_value', // Valore contratto annuo
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Cast automatici ──────────────────────────────────────────────────
|
||||||
|
// Eloquent converte automaticamente il tipo quando leggi/scrivi il campo.
|
||||||
|
// 'decimal:2' → restituisce un float con 2 decimali
|
||||||
|
// 'boolean' → converte 0/1 del DB in true/false PHP
|
||||||
|
protected $casts = [
|
||||||
|
'contract_value' => 'decimal:2',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
'deleted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Scope: filtri riutilizzabili ─────────────────────────────────────
|
||||||
|
// Un "scope" è un metodo che aggiunge condizioni alla query.
|
||||||
|
// Uso: Customer::active()->get()
|
||||||
|
// invece di: Customer::where('status', 'attivo')->get()
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', 'attivo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByType($query, string $type)
|
||||||
|
{
|
||||||
|
return $query->where('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSearch($query, string $term)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) use ($term) {
|
||||||
|
$q->where('name', 'like', "%{$term}%")
|
||||||
|
->orWhere('email', 'like', "%{$term}%")
|
||||||
|
->orWhere('city', 'like', "%{$term}%")
|
||||||
|
->orWhere('vat_number', 'like', "%{$term}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Accessor: trasforma il valore quando lo leggi ───────────────────
|
||||||
|
// Uso: $customer->badge_color → restituisce il colore Bootstrap
|
||||||
|
public function getBadgeColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
'attivo' => 'success',
|
||||||
|
'inattivo' => 'secondary',
|
||||||
|
'prospect' => 'warning',
|
||||||
|
default => 'light',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'privato' => 'Privato',
|
||||||
|
'azienda' => 'Azienda',
|
||||||
|
default => 'N/D',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Models/Setting.php
Normal file
44
app/Models/Setting.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Model Setting — gestisce le impostazioni dinamiche dell'applicazione
|
||||||
|
//
|
||||||
|
// Ogni riga della tabella `settings` è una coppia chiave-valore.
|
||||||
|
// Esempio:
|
||||||
|
// key: "items_per_page" value: "15"
|
||||||
|
// key: "currency_symbol" value: "€"
|
||||||
|
//
|
||||||
|
// I valori sono sempre stringhe nel DB. SettingService gestisce il cast
|
||||||
|
// al tipo corretto (int, bool, string, ecc.) basandosi su config/settings.php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Setting extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'settings';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'key',
|
||||||
|
'value',
|
||||||
|
'label', // Nome leggibile mostrato nel pannello admin
|
||||||
|
'group', // Gruppo di appartenenza (es. "Azienda", "Visualizzazione")
|
||||||
|
'type', // Tipo dato: string, integer, boolean, text
|
||||||
|
];
|
||||||
|
|
||||||
|
// Non usiamo timestamps per le impostazioni (semplifica la tabella)
|
||||||
|
public $timestamps = true;
|
||||||
|
|
||||||
|
// ─── Scope: cerca per chiave ──────────────────────────────────────────
|
||||||
|
public function scopeForKey($query, string $key)
|
||||||
|
{
|
||||||
|
return $query->where('key', $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeInGroup($query, string $group)
|
||||||
|
{
|
||||||
|
return $query->where('group', $group);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Providers/AppServiceProvider.php
Normal file
49
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// AppServiceProvider — Service Provider principale dell'applicazione
|
||||||
|
//
|
||||||
|
// Cos'è un Service Provider?
|
||||||
|
// È una classe che viene caricata all'avvio di Laravel. Serve per:
|
||||||
|
// - Registrare binding nel Service Container (IoC container)
|
||||||
|
// - Eseguire codice di inizializzazione
|
||||||
|
// - Condividere dati con tutte le view
|
||||||
|
//
|
||||||
|
// register(): invocato PRIMA del boot. Registra binding nel container.
|
||||||
|
// boot(): invocato DOPO tutti i register. Qui tutto è disponibile.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Services\SettingService;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
// Registra SettingService come Singleton:
|
||||||
|
// una sola istanza per tutta la durata della richiesta.
|
||||||
|
// Questo evita di creare più istanze (e più connessioni Redis)
|
||||||
|
$this->app->singleton(SettingService::class, fn () => new SettingService());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Condivide le impostazioni di base con TUTTE le view Blade.
|
||||||
|
// Così nelle view puoi usare $appSettings senza doverta passare
|
||||||
|
// esplicitamente da ogni controller.
|
||||||
|
View::composer('*', function ($view) {
|
||||||
|
/** @var SettingService $settings */
|
||||||
|
$settings = app(SettingService::class);
|
||||||
|
|
||||||
|
$view->with('appSettings', [
|
||||||
|
'company_name' => $settings->get('company_name'),
|
||||||
|
'currency_symbol' => $settings->get('currency_symbol'),
|
||||||
|
'theme_color' => $settings->get('theme_color'),
|
||||||
|
'support_email' => $settings->get('support_email'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
135
app/Services/SettingService.php
Normal file
135
app/Services/SettingService.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SettingService — Servizio per le impostazioni dinamiche
|
||||||
|
//
|
||||||
|
// Questo service è il punto unico di accesso alle impostazioni.
|
||||||
|
// Gestisce:
|
||||||
|
// 1. Lettura con fallback ai default di config/settings.php
|
||||||
|
// 2. Cache Redis per performance (evita query ad ogni richiesta)
|
||||||
|
// 3. Cast dei valori al tipo corretto (string → int, "1" → true, ecc.)
|
||||||
|
// 4. Scrittura e invalidazione cache
|
||||||
|
//
|
||||||
|
// DEPENDENCY INJECTION:
|
||||||
|
// Laravel inietta automaticamente questo service quando lo dichiari
|
||||||
|
// nel costruttore di un Controller:
|
||||||
|
//
|
||||||
|
// public function __construct(private SettingService $settings) {}
|
||||||
|
//
|
||||||
|
// Poi lo usi con: $this->settings->get('items_per_page')
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class SettingService
|
||||||
|
{
|
||||||
|
private const CACHE_KEY = 'app_settings_all';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Leggi un'impostazione ─────────────────────────────────────────────
|
||||||
|
// $key → chiave dell'impostazione (es. 'items_per_page')
|
||||||
|
// $default → valore di ritorno se la chiave non esiste
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$all = $this->all();
|
||||||
|
|
||||||
|
if (isset($all[$key])) {
|
||||||
|
return $all[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prova i default di config/settings.php
|
||||||
|
$configDefault = config("settings.defaults.{$key}");
|
||||||
|
|
||||||
|
return $configDefault ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Leggi tutte le impostazioni (con cache) ─────────────────────────
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
$ttl = config('settings.cache_ttl_minutes', 60) * 60;
|
||||||
|
|
||||||
|
// Cache::remember: se la chiave è in cache, la restituisce;
|
||||||
|
// altrimenti esegue la closure, salva il risultato e lo restituisce.
|
||||||
|
return Cache::remember(self::CACHE_KEY, $ttl, function () {
|
||||||
|
return $this->loadFromDatabase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scrivi un'impostazione ───────────────────────────────────────────
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => $key],
|
||||||
|
['value' => $this->serialize($value)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalida la cache: al prossimo accesso verrà riletta dal DB
|
||||||
|
$this->clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scrivi più impostazioni in una volta ─────────────────────────────
|
||||||
|
public function setMany(array $settings): void
|
||||||
|
{
|
||||||
|
foreach ($settings as $key => $value) {
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => $key],
|
||||||
|
['value' => $this->serialize($value)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Svuota la cache delle impostazioni ───────────────────────────────
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget(self::CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Leggi dal database e applica i cast ──────────────────────────────
|
||||||
|
private function loadFromDatabase(): array
|
||||||
|
{
|
||||||
|
$types = config('settings.types', []);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach (Setting::all() as $setting) {
|
||||||
|
$type = $types[$setting->key] ?? 'string';
|
||||||
|
$result[$setting->key] = $this->cast($setting->value, $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Converti il valore al tipo corretto ──────────────────────────────
|
||||||
|
// Necessario perché nel DB tutto è stringa VARCHAR.
|
||||||
|
private function cast(mixed $value, string $type): mixed
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'integer' => (int) $value,
|
||||||
|
'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'float' => (float) $value,
|
||||||
|
'json' => json_decode($value, true),
|
||||||
|
default => (string) $value, // 'string', 'text'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Serializza per il salvataggio in DB ──────────────────────────────
|
||||||
|
private function serialize(mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
return json_encode($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
artisan
Normal file
27
artisan
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// artisan — CLI di Laravel
|
||||||
|
//
|
||||||
|
// Questo file è il punto di accesso ai comandi Laravel da terminale.
|
||||||
|
// Esempi di utilizzo:
|
||||||
|
// php artisan migrate → esegue le migration
|
||||||
|
// php artisan make:controller → crea un controller
|
||||||
|
// php artisan db:seed → esegue i seeder
|
||||||
|
// php artisan route:list → mostra tutte le route
|
||||||
|
// php artisan tinker → REPL interattivo PHP+Laravel
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Carica l'autoloader di Composer (tutte le dipendenze)
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Avvia l'applicazione Laravel e gestisce il comando CLI
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
31
bootstrap/app.php
Normal file
31
bootstrap/app.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// bootstrap/app.php — Punto di bootstrap dell'applicazione Laravel 11
|
||||||
|
//
|
||||||
|
// Questo file crea e configura l'istanza principale dell'applicazione.
|
||||||
|
// In Laravel 11 è più snello: la configurazione è centralizzata qui,
|
||||||
|
// invece di essere sparsa in molteplici Service Provider.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
// File delle route web (richieste HTTP dal browser)
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
// File dei comandi Artisan personalizzati
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
// Endpoint di health-check (usato da Docker/load balancer)
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
// Qui puoi aggiungere middleware globali o per gruppo.
|
||||||
|
// Esempio: $middleware->web(append: [MioMiddleware::class]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
// Qui puoi personalizzare la gestione degli errori.
|
||||||
|
// Esempio: $exceptions->report(fn (Exception $e) => ...);
|
||||||
|
})
|
||||||
|
->create();
|
||||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
10
bootstrap/providers.php
Normal file
10
bootstrap/providers.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// bootstrap/providers.php
|
||||||
|
// Lista dei Service Provider dell'applicazione.
|
||||||
|
// Un Service Provider è una classe che "registra" funzionalità in Laravel
|
||||||
|
// (binding nel container, event listener, comandi artisan, ecc.)
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
];
|
||||||
60
composer.json
Normal file
60
composer.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "pyconetwork/portale-clienti",
|
||||||
|
"description": "Portale di gestione clienti - applicazione demo Laravel 11",
|
||||||
|
"keywords": ["laravel", "crm", "clienti", "portale"],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"laravel/framework": "^11.0",
|
||||||
|
"laravel/tinker": "^2.9"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/pail": "^1.1",
|
||||||
|
"laravel/pint": "^1.13",
|
||||||
|
"laravel/sail": "^1.26",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.1",
|
||||||
|
"phpunit/phpunit": "^11.0.1"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-install-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
54
config/app.php
Normal file
54
config/app.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// config/app.php — Configurazione principale dell'applicazione
|
||||||
|
//
|
||||||
|
// Nota: la maggior parte dei valori viene letta dal .env tramite env().
|
||||||
|
// La funzione env('CHIAVE', 'default') legge la variabile di ambiente;
|
||||||
|
// se non esiste, usa il valore di default.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// ─── Identità ──────────────────────────────────────────────────────────
|
||||||
|
'name' => env('APP_NAME', 'Portale Clienti'),
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
// debug=true mostra la stack trace completa in caso di errore.
|
||||||
|
// In produzione DEVE essere false!
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
// ─── Localizzazione ───────────────────────────────────────────────────
|
||||||
|
// timezone: usato da Carbon (date/ore) e dai log
|
||||||
|
'timezone' => 'Europe/Rome',
|
||||||
|
|
||||||
|
// locale: lingua usata per traduzioni e formattazione
|
||||||
|
'locale' => 'it',
|
||||||
|
'fallback_locale' => 'en',
|
||||||
|
|
||||||
|
// faker_locale: usato nei seeder per generare dati finti in italiano
|
||||||
|
'faker_locale' => 'it_IT',
|
||||||
|
|
||||||
|
// ─── Ciclo di vita richiesta ───────────────────────────────────────────
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
// 'store' => 'redis', // Alternativa: usa Redis per cluster multi-server
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Cifratura ────────────────────────────────────────────────────────
|
||||||
|
// cipher: algoritmo usato per cifrare cookie e sessioni
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
// key: chiave segreta, generata da `php artisan key:generate`
|
||||||
|
// MAI condividere questa chiave!
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
// previous_keys: chiavi precedenti (per rotazione senza invalidare sessioni)
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
84
config/database.php
Normal file
84
config/database.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// config/database.php — Configurazione database
|
||||||
|
//
|
||||||
|
// Supporta più connessioni contemporaneamente. Il default è MySQL.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// Connessione di default (usata da tutti i Model, a meno che non sia specificata)
|
||||||
|
'default' => env('DB_CONNECTION', 'mysql'),
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
// ── SQLite: utile per test rapidi ────────────────────────────────────
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── MySQL: usato in questo progetto ──────────────────────────────────
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
// host: nel Docker è il nome del servizio nel docker-compose (es. "db")
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
// options TLS: scommentare in produzione con SSL
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Migrations ──────────────────────────────────────────────────────────
|
||||||
|
// Tabella che traccia quali migration sono già state eseguite
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Redis ────────────────────────────────────────────────────────────────
|
||||||
|
'redis' => [
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', 'portale_'), // Prefisso chiavi
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'), // DB separato per la cache
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
90
config/settings.php
Normal file
90
config/settings.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// config/settings.php — Impostazioni dinamiche dell'applicazione
|
||||||
|
//
|
||||||
|
// DIFFERENZA FONDAMENTALE rispetto al .env:
|
||||||
|
//
|
||||||
|
// .env / config/*.php → configurazione STATICA (infrastruttura, connessioni)
|
||||||
|
// Cambia raramente. Richiede restart del container.
|
||||||
|
//
|
||||||
|
// settings (questo file + tabella DB)
|
||||||
|
// → configurazione DINAMICA (business logic)
|
||||||
|
// Modificabile dall'admin via pannello web,
|
||||||
|
// senza restart. Cambia nel tempo.
|
||||||
|
//
|
||||||
|
// COME FUNZIONA:
|
||||||
|
// 1. I valori di default sono qui (fallback se la tabella DB è vuota)
|
||||||
|
// 2. Al primo avvio, il SettingSeeder popola la tabella `settings`
|
||||||
|
// 3. SettingService::get('chiave') legge dalla tabella (con cache Redis)
|
||||||
|
// 4. Il pannello Impostazioni permette di modificarli via web
|
||||||
|
//
|
||||||
|
// ESEMPIO D'USO in un Controller o View:
|
||||||
|
// $perPage = app(SettingService::class)->get('items_per_page');
|
||||||
|
// // oppure tramite helper globale (se definito):
|
||||||
|
// $valuta = setting('currency_symbol');
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// ─── Valori di default ─────────────────────────────────────────────────
|
||||||
|
// Usati come fallback se la chiave non esiste nella tabella DB.
|
||||||
|
// Modificare questi valori richiede un deploy; per cambiamenti frequenti
|
||||||
|
// usa direttamente il pannello impostazioni.
|
||||||
|
'defaults' => [
|
||||||
|
'company_name' => 'La Mia Azienda',
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'currency_symbol' => '€',
|
||||||
|
'date_format' => 'd/m/Y',
|
||||||
|
'items_per_page' => 15,
|
||||||
|
'welcome_message' => 'Benvenuto nel Portale Clienti!',
|
||||||
|
'allow_notes' => true,
|
||||||
|
'theme_color' => '#0d6efd',
|
||||||
|
'support_email' => 'supporto@example.com',
|
||||||
|
'max_file_size_mb' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Descrizioni leggibili ─────────────────────────────────────────────
|
||||||
|
// Visualizzate nel pannello impostazioni come label/tooltip.
|
||||||
|
'descriptions' => [
|
||||||
|
'company_name' => 'Nome della tua azienda (mostrato in header e report)',
|
||||||
|
'currency' => 'Codice valuta ISO 4217 (es. EUR, USD, GBP)',
|
||||||
|
'currency_symbol' => 'Simbolo valuta visualizzato nei prezzi (es. €, $)',
|
||||||
|
'date_format' => 'Formato date (sintassi PHP: d/m/Y, Y-m-d, ecc.)',
|
||||||
|
'items_per_page' => 'Numero di righe nelle tabelle paginate',
|
||||||
|
'welcome_message' => 'Messaggio mostrato nella dashboard',
|
||||||
|
'allow_notes' => 'Abilita il campo note nelle schede cliente',
|
||||||
|
'theme_color' => 'Colore principale dell\'interfaccia (hex, es. #0d6efd)',
|
||||||
|
'support_email' => 'Email mostrata ai clienti per richieste di supporto',
|
||||||
|
'max_file_size_mb' => 'Dimensione massima upload allegati (in MB)',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Tipi di dato ──────────────────────────────────────────────────────
|
||||||
|
// Usati da SettingService per convertire i valori al tipo corretto.
|
||||||
|
// La tabella DB salva tutto come stringa; il tipo definisce il cast.
|
||||||
|
'types' => [
|
||||||
|
'company_name' => 'string',
|
||||||
|
'currency' => 'string',
|
||||||
|
'currency_symbol' => 'string',
|
||||||
|
'date_format' => 'string',
|
||||||
|
'items_per_page' => 'integer',
|
||||||
|
'welcome_message' => 'text',
|
||||||
|
'allow_notes' => 'boolean',
|
||||||
|
'theme_color' => 'string',
|
||||||
|
'support_email' => 'string',
|
||||||
|
'max_file_size_mb' => 'integer',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Gruppi (per organizzare il pannello impostazioni) ─────────────────
|
||||||
|
'groups' => [
|
||||||
|
'Azienda' => ['company_name', 'support_email'],
|
||||||
|
'Visualizzazione' => ['currency', 'currency_symbol', 'date_format',
|
||||||
|
'items_per_page', 'theme_color', 'welcome_message'],
|
||||||
|
'Funzionalità' => ['allow_notes', 'max_file_size_mb'],
|
||||||
|
],
|
||||||
|
|
||||||
|
// ─── Cache ─────────────────────────────────────────────────────────────
|
||||||
|
// Durata cache (minuti). Le impostazioni vengono lette dal DB una volta
|
||||||
|
// e poi servite dalla cache per evitare query ad ogni richiesta.
|
||||||
|
'cache_ttl_minutes' => 60,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Migration: crea la tabella customers
|
||||||
|
//
|
||||||
|
// Cos'è una migration?
|
||||||
|
// È un file PHP che descrive modifiche al database in forma di codice.
|
||||||
|
// Invece di scrivere SQL manualmente, usi il fluent Schema Builder di Laravel.
|
||||||
|
//
|
||||||
|
// Vantaggi:
|
||||||
|
// - Versioning: le migration sono versionabili su Git come il codice
|
||||||
|
// - Reversibilità: ogni migration ha un metodo down() per annullare
|
||||||
|
// - Collaborazione: tutti i dev usano lo stesso DB schema
|
||||||
|
//
|
||||||
|
// Esecuzione:
|
||||||
|
// php artisan migrate → esegue up() delle migration non ancora eseguite
|
||||||
|
// php artisan migrate:rollback → esegue down() dell'ultima batch
|
||||||
|
// php artisan migrate:fresh → CANCELLA tutto e ricrea (solo in development!)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
// up(): eseguita da "php artisan migrate" → crea/modifica strutture
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('customers', function (Blueprint $table) {
|
||||||
|
// id(): crea colonna "id" bigint UNSIGNED AUTO_INCREMENT PRIMARY KEY
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Dati anagrafici
|
||||||
|
$table->string('name'); // Ragione sociale / nome
|
||||||
|
$table->string('email')->unique(); // Email (univoca)
|
||||||
|
$table->string('phone', 50)->nullable(); // Telefono
|
||||||
|
$table->string('city', 100)->nullable(); // Città
|
||||||
|
$table->string('address')->nullable(); // Indirizzo completo
|
||||||
|
$table->string('vat_number', 20)->nullable(); // Partita IVA
|
||||||
|
$table->string('fiscal_code', 20)->nullable(); // Codice fiscale
|
||||||
|
|
||||||
|
// Tipo cliente: "privato" o "azienda"
|
||||||
|
// enum() limita i valori accettati a livello DB
|
||||||
|
$table->enum('type', ['privato', 'azienda'])->default('azienda');
|
||||||
|
|
||||||
|
// Stato nel ciclo di vita commerciale
|
||||||
|
$table->enum('status', ['attivo', 'inattivo', 'prospect'])->default('prospect');
|
||||||
|
|
||||||
|
// Dati commerciali
|
||||||
|
$table->decimal('contract_value', 10, 2)->default(0); // Valore contratto annuo
|
||||||
|
$table->text('notes')->nullable(); // Note libere
|
||||||
|
|
||||||
|
// timestamps(): crea created_at e updated_at (gestiti automaticamente da Eloquent)
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// softDeletes(): aggiunge colonna deleted_at per il Soft Delete
|
||||||
|
// I clienti "eliminati" hanno deleted_at valorizzato, non sono rimossi dal DB
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
// Indici per velocizzare le ricerche più comuni
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('type');
|
||||||
|
$table->index('city');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// down(): eseguita da "php artisan migrate:rollback" → annulla la migration
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('customers');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Migration: crea la tabella settings
|
||||||
|
//
|
||||||
|
// La tabella `settings` implementa il pattern "key-value store":
|
||||||
|
// ogni riga è un'impostazione dell'applicazione modificabile via Admin.
|
||||||
|
//
|
||||||
|
// Schema:
|
||||||
|
// id → chiave primaria
|
||||||
|
// key → identificatore univoco dell'impostazione (es. "items_per_page")
|
||||||
|
// value → valore come stringa (il tipo è gestito da SettingService)
|
||||||
|
// label → nome leggibile per l'interfaccia admin
|
||||||
|
// group → raggruppamento per il pannello (es. "Azienda", "Visualizzazione")
|
||||||
|
// type → tipo dato per il cast corretto (string, integer, boolean, text)
|
||||||
|
// timestamps → created_at e updated_at
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('settings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Chiave univoca: identifica univocamente l'impostazione
|
||||||
|
$table->string('key')->unique();
|
||||||
|
|
||||||
|
// Valore come testo (può essere lungo, es. messaggi)
|
||||||
|
$table->text('value')->nullable();
|
||||||
|
|
||||||
|
// Metadati per il pannello admin
|
||||||
|
$table->string('label')->nullable(); // Nome leggibile
|
||||||
|
$table->string('group')->default('Generale'); // Gruppo
|
||||||
|
$table->string('type')->default('string'); // Tipo PHP
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Indice sul gruppo per caricare le impostazioni per sezione
|
||||||
|
$table->index('group');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
53
database/seeders/CustomerSeeder.php
Normal file
53
database/seeders/CustomerSeeder.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CustomerSeeder — Genera clienti di esempio per lo sviluppo
|
||||||
|
//
|
||||||
|
// Usa Faker (libreria inclusa in Laravel) per generare dati realistici
|
||||||
|
// in italiano. Questi dati servono per testare l'interfaccia.
|
||||||
|
//
|
||||||
|
// ⚠️ Non eseguire in produzione! Solo per sviluppo e staging.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use Faker\Factory as Faker;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class CustomerSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Faker con locale italiano: nomi, città, ecc. italiani
|
||||||
|
$faker = Faker::create('it_IT');
|
||||||
|
|
||||||
|
$types = ['privato', 'azienda'];
|
||||||
|
$statuses = ['attivo', 'inattivo', 'prospect'];
|
||||||
|
$cities = ['Roma', 'Milano', 'Napoli', 'Torino', 'Bologna', 'Firenze', 'Venezia', 'Genova', 'Palermo', 'Bari'];
|
||||||
|
|
||||||
|
// Crea 30 clienti di esempio
|
||||||
|
for ($i = 0; $i < 30; $i++) {
|
||||||
|
$type = $faker->randomElement($types);
|
||||||
|
$name = $type === 'azienda'
|
||||||
|
? $faker->company()
|
||||||
|
: $faker->name();
|
||||||
|
|
||||||
|
Customer::create([
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $faker->unique()->safeEmail(),
|
||||||
|
'phone' => $faker->phoneNumber(),
|
||||||
|
'city' => $faker->randomElement($cities),
|
||||||
|
'address' => $faker->streetAddress(),
|
||||||
|
'vat_number' => $type === 'azienda' ? 'IT' . $faker->numerify('###########') : null,
|
||||||
|
'fiscal_code' => $faker->numerify('??????????##??##??###?'),
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $faker->randomElement($statuses),
|
||||||
|
'contract_value' => $faker->randomFloat(2, 500, 50000),
|
||||||
|
'notes' => $faker->optional(0.4)->paragraph(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info('✓ 30 clienti di esempio inseriti.');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
database/seeders/DatabaseSeeder.php
Normal file
31
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// DatabaseSeeder — Entry point di tutti i seeder
|
||||||
|
//
|
||||||
|
// Un Seeder popola il database con dati iniziali o di test.
|
||||||
|
// Il DatabaseSeeder è il punto di ingresso; chiama gli altri seeder
|
||||||
|
// nell'ordine corretto (rispettando le foreign key).
|
||||||
|
//
|
||||||
|
// Esecuzione:
|
||||||
|
// php artisan db:seed → esegue solo DatabaseSeeder
|
||||||
|
// php artisan db:seed --class=CustomerSeeder → esegue solo quello
|
||||||
|
// php artisan migrate:fresh --seed → ricrea tutto e semina
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// L'ordine conta: settings prima (nessuna dipendenza),
|
||||||
|
// poi customers (dipende da nulla, ma logicamente dopo la config)
|
||||||
|
$this->call([
|
||||||
|
SettingSeeder::class,
|
||||||
|
CustomerSeeder::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
database/seeders/SettingSeeder.php
Normal file
50
database/seeders/SettingSeeder.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SettingSeeder — Popola la tabella settings con i valori di default
|
||||||
|
//
|
||||||
|
// Questo seeder legge i default da config/settings.php e li inserisce
|
||||||
|
// nel database al primo avvio.
|
||||||
|
//
|
||||||
|
// È idempotente: puoi eseguirlo più volte senza duplicati (usa updateOrCreate)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class SettingSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$defaults = config('settings.defaults', []);
|
||||||
|
$descriptions = config('settings.descriptions', []);
|
||||||
|
$types = config('settings.types', []);
|
||||||
|
$groups = config('settings.groups', []);
|
||||||
|
|
||||||
|
// Costruisce una mappa chiave → gruppo
|
||||||
|
$keyToGroup = [];
|
||||||
|
foreach ($groups as $groupName => $keys) {
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$keyToGroup[$key] = $groupName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($defaults as $key => $value) {
|
||||||
|
// updateOrCreate: se esiste già una riga con quell'key, la aggiorna;
|
||||||
|
// altrimenti la crea. Perfetto per rieseguire il seeder in sicurezza.
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => $key],
|
||||||
|
[
|
||||||
|
'value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value,
|
||||||
|
'label' => $descriptions[$key] ?? $key,
|
||||||
|
'group' => $keyToGroup[$key] ?? 'Generale',
|
||||||
|
'type' => $types[$key] ?? 'string',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info('✓ Impostazioni di default inserite nella tabella settings.');
|
||||||
|
}
|
||||||
|
}
|
||||||
93
docker-compose.yml
Normal file
93
docker-compose.yml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PHP-FPM: esegue il codice Laravel
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/php/Dockerfile
|
||||||
|
container_name: portale_app
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /var/www
|
||||||
|
volumes:
|
||||||
|
- .:/var/www
|
||||||
|
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/custom.ini
|
||||||
|
environment:
|
||||||
|
# Queste variabili sovrascrivono quelle del .env all'interno del container.
|
||||||
|
# In produzione usa Docker Secrets o un vault; qui usiamo env_file per semplicità.
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- portale_network
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# NGINX: web server che fa da "proxy" verso PHP
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
webserver:
|
||||||
|
image: nginx:1.25-alpine
|
||||||
|
container_name: portale_nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${NGINX_PORT:-8080}:80" # Accessibile su http://localhost:8080
|
||||||
|
volumes:
|
||||||
|
- .:/var/www
|
||||||
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
networks:
|
||||||
|
- portale_network
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# MySQL: database relazionale
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: portale_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${DB_DATABASE}
|
||||||
|
MYSQL_USER: ${DB_USERNAME}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "${DB_EXTERNAL_PORT:-3306}:3306" # Esposto per accesso con GUI (TablePlus, DBeaver…)
|
||||||
|
volumes:
|
||||||
|
- portale_db_data:/var/lib/mysql # Volume persistente: i dati non si perdono al restart
|
||||||
|
networks:
|
||||||
|
- portale_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Redis: cache e sessioni veloci
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: portale_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "${REDIS_EXTERNAL_PORT:-6379}:6379"
|
||||||
|
networks:
|
||||||
|
- portale_network
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Volumi nominati: Docker gestisce la persistenza
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
volumes:
|
||||||
|
portale_db_data:
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Rete interna: i container si parlano per nome
|
||||||
|
# (es. il PHP si connette al DB con host "db")
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
networks:
|
||||||
|
portale_network:
|
||||||
|
driver: bridge
|
||||||
60
docker/nginx/default.conf
Normal file
60
docker/nginx/default.conf
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# La root punta a public/ di Laravel, NON alla root del progetto.
|
||||||
|
# Motivo: public/ è l'unica cartella esposta al web.
|
||||||
|
# Il resto del codice (app/, config/, .env…) è fuori dalla webroot = sicuro.
|
||||||
|
root /var/www/public;
|
||||||
|
index index.php index.html;
|
||||||
|
|
||||||
|
# Charset e log
|
||||||
|
charset utf-8;
|
||||||
|
access_log /var/log/nginx/portale_access.log;
|
||||||
|
error_log /var/log/nginx/portale_error.log;
|
||||||
|
|
||||||
|
# Dimensione massima upload (per allegati cliente, loghi, ecc.)
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Regola principale: "try_files"
|
||||||
|
# Per ogni richiesta, Nginx prova nell'ordine:
|
||||||
|
# 1. $uri → cerca il file esatto (es. /css/app.css)
|
||||||
|
# 2. $uri/ → cerca come directory
|
||||||
|
# 3. /index.php?$query_string → passa tutto a Laravel (front controller)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Gestione file PHP: passa le richieste a PHP-FPM
|
||||||
|
# "app:9000" → nome del container PHP nel docker-compose + porta FPM
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass app:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
|
||||||
|
# SCRIPT_FILENAME: percorso assoluto del file PHP da eseguire
|
||||||
|
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
|
||||||
|
# Timeout generosi per operazioni lunghe (import, report, ecc.)
|
||||||
|
fastcgi_read_timeout 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Blocca accesso ai file nascosti (es. .env, .git, .htaccess)
|
||||||
|
# IMPORTANTE: non esporre mai il .env al web!
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
location ~ /\.(?!well-known).* {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache browser per asset statici (performance)
|
||||||
|
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
docker/php/Dockerfile
Normal file
93
docker/php/Dockerfile
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# Immagine base: PHP 8.2 in modalità FPM (FastCGI Process Manager)
|
||||||
|
# FPM è la modalità usata in produzione con Nginx.
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
FROM php:8.2-fpm
|
||||||
|
|
||||||
|
# Argomenti di build (puoi sovrascriverli con --build-arg)
|
||||||
|
ARG USER_ID=1000
|
||||||
|
ARG GROUP_ID=1000
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# 1. Dipendenze di sistema e estensioni PHP necessarie a Laravel
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
# Strumenti base
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
unzip \
|
||||||
|
zip \
|
||||||
|
# Librerie per le estensioni PHP
|
||||||
|
libpng-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libzip-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libredis-dev \
|
||||||
|
# Per generazione immagini/PDF (opzionale ma comune)
|
||||||
|
libfreetype6-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
# Pulizia cache apt per ridurre dimensione immagine
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# 2. Estensioni PHP
|
||||||
|
# docker-php-ext-install → estensioni native PHP
|
||||||
|
# pecl install → estensioni da PECL (es. Redis)
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install \
|
||||||
|
pdo_mysql \
|
||||||
|
mbstring \
|
||||||
|
exif \
|
||||||
|
pcntl \
|
||||||
|
bcmath \
|
||||||
|
gd \
|
||||||
|
zip \
|
||||||
|
xml \
|
||||||
|
opcache
|
||||||
|
|
||||||
|
# Estensione Redis via PECL
|
||||||
|
RUN pecl install redis \
|
||||||
|
&& docker-php-ext-enable redis
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# 3. Composer: gestore dipendenze PHP
|
||||||
|
# Lo copiamo dall'immagine ufficiale di Composer (multi-stage)
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# 4. Utente non-root per sicurezza
|
||||||
|
# Usiamo lo stesso UID/GID del tuo utente host per evitare
|
||||||
|
# problemi di permessi sui file montati con volume.
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
RUN groupmod -o -g ${GROUP_ID} www-data \
|
||||||
|
&& usermod -o -u ${USER_ID} -g www-data www-data
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# 5. Directory di lavoro e permessi
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
WORKDIR /var/www
|
||||||
|
|
||||||
|
RUN mkdir -p \
|
||||||
|
storage/app/public \
|
||||||
|
storage/framework/cache/data \
|
||||||
|
storage/framework/sessions \
|
||||||
|
storage/framework/testing \
|
||||||
|
storage/framework/views \
|
||||||
|
storage/logs \
|
||||||
|
bootstrap/cache \
|
||||||
|
&& chown -R www-data:www-data /var/www
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
# 6. Script di avvio
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
USER www-data
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
112
docker/php/entrypoint.sh
Normal file
112
docker/php/entrypoint.sh
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Entrypoint: eseguito ad ogni avvio del container.
|
||||||
|
# Si occupa di preparare l'applicazione la prima volta e ad ogni restart.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -e # Interrompi se un comando fallisce
|
||||||
|
|
||||||
|
echo "────────────────────────────────────────"
|
||||||
|
echo " Avvio Portale Clienti"
|
||||||
|
echo "────────────────────────────────────────"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 1. Installa le dipendenze PHP con Composer
|
||||||
|
# --no-interaction → niente domande interattive
|
||||||
|
# --optimize-autoloader → più veloce in produzione
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
if [ ! -d "vendor" ]; then
|
||||||
|
echo "→ vendor/ non trovata. Eseguo composer install..."
|
||||||
|
composer install --no-interaction --optimize-autoloader
|
||||||
|
else
|
||||||
|
echo "→ vendor/ già presente. Salto composer install."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 2. Genera APP_KEY se non è impostata
|
||||||
|
# La chiave è usata per cifrare sessioni e cookie.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
if grep -q "^APP_KEY=$" .env 2>/dev/null || ! grep -q "^APP_KEY=" .env 2>/dev/null; then
|
||||||
|
echo "→ APP_KEY non trovata. La genero..."
|
||||||
|
php artisan key:generate --no-interaction
|
||||||
|
else
|
||||||
|
echo "→ APP_KEY già impostata."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 3. Permessi sulle cartelle di storage
|
||||||
|
# Laravel deve poter scrivere qui (log, cache, sessioni…)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
echo "→ Imposto permessi su storage/ e bootstrap/cache/"
|
||||||
|
chmod -R 775 storage bootstrap/cache
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 4. Crea il link symbolico storage → public/storage
|
||||||
|
# Necessario per servire file caricati dall'utente
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
if [ ! -L "public/storage" ]; then
|
||||||
|
echo "→ Creo symlink public/storage → storage/app/public"
|
||||||
|
php artisan storage:link --no-interaction
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 5. Attende che il database sia pronto
|
||||||
|
# MySQL impiega qualche secondo ad avviarsi.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
echo "→ Attendo che il database sia disponibile..."
|
||||||
|
MAX_TRIES=30
|
||||||
|
TRIES=0
|
||||||
|
until php artisan db:monitor 2>/dev/null || [ $TRIES -ge $MAX_TRIES ]; do
|
||||||
|
echo " Database non pronto, riprovo tra 2s... ($((TRIES+1))/$MAX_TRIES)"
|
||||||
|
sleep 2
|
||||||
|
TRIES=$((TRIES+1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $TRIES -ge $MAX_TRIES ]; then
|
||||||
|
echo "⚠ Database non raggiungibile dopo $MAX_TRIES tentativi."
|
||||||
|
echo " Continuo comunque (potresti essere in modalità manutenzione)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 6. Esegui le migration
|
||||||
|
# --force → necessario in ambiente non-TTY (es. Docker)
|
||||||
|
# Le migration vengono eseguite solo se ci sono nuove
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
echo "→ Eseguo le migration del database..."
|
||||||
|
php artisan migrate --force --no-interaction
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 7. Esegui i seeder solo al primo avvio
|
||||||
|
# Controlla se la tabella settings è vuota
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
SETTINGS_COUNT=$(php artisan tinker --execute="echo \App\Models\Setting::count();" 2>/dev/null | tail -1 || echo "0")
|
||||||
|
if [ "$SETTINGS_COUNT" = "0" ]; then
|
||||||
|
echo "→ Tabella settings vuota. Eseguo i seeder..."
|
||||||
|
php artisan db:seed --force --no-interaction
|
||||||
|
else
|
||||||
|
echo "→ Dati già presenti. Salto i seeder."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 8. Ottimizzazioni (cache config, routes, views)
|
||||||
|
# In development potrebbe essere più utile saltare questo,
|
||||||
|
# in produzione invece migliora le performance.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
if [ "${APP_ENV}" = "production" ]; then
|
||||||
|
echo "→ Ambiente produzione: ottimizzazione cache..."
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
php artisan view:cache
|
||||||
|
else
|
||||||
|
echo "→ Ambiente development: svuoto cache esistente..."
|
||||||
|
php artisan config:clear
|
||||||
|
php artisan route:clear
|
||||||
|
php artisan view:clear
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "────────────────────────────────────────"
|
||||||
|
echo " Portale Clienti pronto!"
|
||||||
|
echo " URL: http://localhost:${NGINX_PORT:-8080}"
|
||||||
|
echo "────────────────────────────────────────"
|
||||||
|
|
||||||
|
# Avvia il processo principale (php-fpm)
|
||||||
|
exec "$@"
|
||||||
34
docker/php/php.ini
Normal file
34
docker/php/php.ini
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
; ─────────────────────────────────────────────────────────────
|
||||||
|
; Configurazione PHP personalizzata per il Portale Clienti
|
||||||
|
; Questo file sovrascrive i default di PHP dentro il container.
|
||||||
|
; ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[PHP]
|
||||||
|
; Dimensione massima memoria per richiesta (utile per import grandi)
|
||||||
|
memory_limit = 256M
|
||||||
|
|
||||||
|
; Dimensione massima upload file
|
||||||
|
upload_max_filesize = 50M
|
||||||
|
post_max_size = 50M
|
||||||
|
|
||||||
|
; Timeout di esecuzione (secondi) - aumentare per job lunghi
|
||||||
|
max_execution_time = 300
|
||||||
|
|
||||||
|
; Mostra errori: ON in development, OFF in production
|
||||||
|
; (in Docker gestiamo questo tramite APP_DEBUG nel .env)
|
||||||
|
display_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
error_log = /var/www/storage/logs/php_errors.log
|
||||||
|
|
||||||
|
[Date]
|
||||||
|
; Fuso orario di default
|
||||||
|
date.timezone = "Europe/Rome"
|
||||||
|
|
||||||
|
[opcache]
|
||||||
|
; OPcache: compila PHP in bytecode → applicazione più veloce
|
||||||
|
opcache.enable = 1
|
||||||
|
opcache.memory_consumption = 128
|
||||||
|
opcache.interned_strings_buffer = 8
|
||||||
|
opcache.max_accelerated_files = 10000
|
||||||
|
opcache.revalidate_freq = 0 ; In dev: 0 = ricompila sempre | In prod: 60
|
||||||
|
opcache.save_comments = 1
|
||||||
28
public/index.php
Normal file
28
public/index.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// public/index.php — Front Controller
|
||||||
|
//
|
||||||
|
// Questo è l'UNICO file PHP esposto al web tramite Nginx.
|
||||||
|
// Ogni richiesta HTTP passa da qui. Laravel poi smista tutto
|
||||||
|
// verso il controller corretto in base alla route.
|
||||||
|
//
|
||||||
|
// Nota di sicurezza: le cartelle app/, config/, .env, ecc.
|
||||||
|
// si trovano FUORI da public/ e non sono mai accessibili dal browser.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Controllo modalità manutenzione
|
||||||
|
// Se esiste questo file, Laravel mostra la pagina di manutenzione
|
||||||
|
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||||
|
require $maintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carica tutte le dipendenze installate da Composer
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Avvia Laravel e gestisce la richiesta HTTP
|
||||||
|
(require_once __DIR__.'/../bootstrap/app.php')
|
||||||
|
->handleRequest(Request::capture());
|
||||||
189
resources/views/customers/_form.blade.php
Normal file
189
resources/views/customers/_form.blade.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
{{--
|
||||||
|
resources/views/customers/_form.blade.php — Partial del form cliente
|
||||||
|
|
||||||
|
Il prefisso underscore (_form) è una convenzione per indicare "partial view":
|
||||||
|
frammenti di HTML riutilizzati in più pagine tramite @include().
|
||||||
|
|
||||||
|
Questa tecnica evita di duplicare il form identico in create.blade.php e edit.blade.php.
|
||||||
|
|
||||||
|
La variabile $customer viene passata da edit.blade.php. In create.blade.php
|
||||||
|
non esiste, quindi usiamo old() come fallback per ripopolare dopo errore.
|
||||||
|
--}}
|
||||||
|
|
||||||
|
{{-- ─── Sezione 1: Informazioni principali ──────────────────────────────────── --}}
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="text-muted fw-semibold text-uppercase small letter-spacing-1 mb-3">
|
||||||
|
<i class="bi bi-person me-1"></i>Informazioni principali
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">
|
||||||
|
Nome / Ragione Sociale <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{--
|
||||||
|
is-invalid: classe Bootstrap che mostra il bordo rosso se c'è un errore.
|
||||||
|
$errors->has('name'): controlla se il campo ha errori di validazione.
|
||||||
|
old('name', $customer->name ?? ''): usa il valore OLD se c'è (dopo errore),
|
||||||
|
altrimenti il valore del Model (modifica), altrimenti stringa vuota (crea).
|
||||||
|
--}}
|
||||||
|
<input type="text" name="name"
|
||||||
|
class="form-control {{ $errors->has('name') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('name', $customer->name ?? '') }}"
|
||||||
|
placeholder="Es. Mario Rossi / Rossi S.r.l."
|
||||||
|
required>
|
||||||
|
{{-- Messaggio di errore specifico del campo --}}
|
||||||
|
@error('name')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">
|
||||||
|
Email <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="email" name="email"
|
||||||
|
class="form-control {{ $errors->has('email') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('email', $customer->email ?? '') }}"
|
||||||
|
placeholder="mario@esempio.it"
|
||||||
|
required>
|
||||||
|
@error('email')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Telefono</label>
|
||||||
|
<input type="tel" name="phone"
|
||||||
|
class="form-control {{ $errors->has('phone') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('phone', $customer->phone ?? '') }}"
|
||||||
|
placeholder="+39 02 1234567">
|
||||||
|
@error('phone')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">
|
||||||
|
Tipo <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="type" class="form-select {{ $errors->has('type') ? 'is-invalid' : '' }}" required>
|
||||||
|
<option value="">— Seleziona —</option>
|
||||||
|
<option value="privato" {{ old('type', $customer->type ?? '') === 'privato' ? 'selected' : '' }}>
|
||||||
|
Privato
|
||||||
|
</option>
|
||||||
|
<option value="azienda" {{ old('type', $customer->type ?? '') === 'azienda' ? 'selected' : '' }}>
|
||||||
|
Azienda
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
@error('type')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">
|
||||||
|
Stato <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="status" class="form-select {{ $errors->has('status') ? 'is-invalid' : '' }}" required>
|
||||||
|
<option value="">— Seleziona —</option>
|
||||||
|
<option value="prospect" {{ old('status', $customer->status ?? 'prospect') === 'prospect' ? 'selected' : '' }}>
|
||||||
|
Prospect
|
||||||
|
</option>
|
||||||
|
<option value="attivo" {{ old('status', $customer->status ?? '') === 'attivo' ? 'selected' : '' }}>
|
||||||
|
Attivo
|
||||||
|
</option>
|
||||||
|
<option value="inattivo" {{ old('status', $customer->status ?? '') === 'inattivo' ? 'selected' : '' }}>
|
||||||
|
Inattivo
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
@error('status')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── Sezione 2: Sede e dati fiscali ──────────────────────────────────────── --}}
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="text-muted fw-semibold text-uppercase small mb-3">
|
||||||
|
<i class="bi bi-building me-1"></i>Sede e dati fiscali
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Città</label>
|
||||||
|
<input type="text" name="city"
|
||||||
|
class="form-control {{ $errors->has('city') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('city', $customer->city ?? '') }}"
|
||||||
|
placeholder="Milano">
|
||||||
|
@error('city')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label fw-medium">Indirizzo</label>
|
||||||
|
<input type="text" name="address"
|
||||||
|
class="form-control {{ $errors->has('address') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('address', $customer->address ?? '') }}"
|
||||||
|
placeholder="Via Roma, 1 - 20100 Milano MI">
|
||||||
|
@error('address')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Partita IVA</label>
|
||||||
|
<input type="text" name="vat_number"
|
||||||
|
class="form-control {{ $errors->has('vat_number') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('vat_number', $customer->vat_number ?? '') }}"
|
||||||
|
placeholder="IT12345678901">
|
||||||
|
@error('vat_number')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Codice Fiscale</label>
|
||||||
|
<input type="text" name="fiscal_code"
|
||||||
|
class="form-control {{ $errors->has('fiscal_code') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('fiscal_code', $customer->fiscal_code ?? '') }}"
|
||||||
|
placeholder="RSSMRA80A01H501Z">
|
||||||
|
@error('fiscal_code')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Valore Contratto ({{ $appSettings['currency_symbol'] }})</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">{{ $appSettings['currency_symbol'] }}</span>
|
||||||
|
<input type="number" name="contract_value" step="0.01" min="0"
|
||||||
|
class="form-control {{ $errors->has('contract_value') ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old('contract_value', $customer->contract_value ?? '0') }}"
|
||||||
|
placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
@error('contract_value')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── Sezione 3: Note ─────────────────────────────────────────────────────── --}}
|
||||||
|
@if($appSettings['allow_notes'] ?? true)
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="text-muted fw-semibold text-uppercase small mb-3">
|
||||||
|
<i class="bi bi-sticky me-1"></i>Note
|
||||||
|
</h6>
|
||||||
|
<textarea name="notes" rows="4"
|
||||||
|
class="form-control {{ $errors->has('notes') ? 'is-invalid' : '' }}"
|
||||||
|
placeholder="Note interne, informazioni aggiuntive...">{{ old('notes', $customer->notes ?? '') }}</textarea>
|
||||||
|
@error('notes')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
49
resources/views/customers/create.blade.php
Normal file
49
resources/views/customers/create.blade.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Nuovo Cliente')
|
||||||
|
@section('page-title', 'Nuovo Cliente')
|
||||||
|
|
||||||
|
@section('page-actions')
|
||||||
|
<a href="{{ route('customers.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Torna alla lista
|
||||||
|
</a>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white border-0 pt-4 pb-0 px-4">
|
||||||
|
<h5 class="fw-semibold">
|
||||||
|
<i class="bi bi-person-plus me-2 text-primary"></i>Dati cliente
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small">I campi contrassegnati con * sono obbligatori.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
{{--
|
||||||
|
@csrf: genera un token nascosto per proteggere da attacchi CSRF
|
||||||
|
(Cross-Site Request Forgery). Laravel lo verifica automaticamente.
|
||||||
|
OBBLIGATORIO in tutti i form POST/PUT/DELETE!
|
||||||
|
--}}
|
||||||
|
<form method="POST" action="{{ route('customers.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
@include('customers._form')
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
|
||||||
|
<a href="{{ route('customers.index') }}" class="btn btn-outline-secondary">
|
||||||
|
Annulla
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Salva Cliente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
49
resources/views/customers/edit.blade.php
Normal file
49
resources/views/customers/edit.blade.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Modifica ' . $customer->name)
|
||||||
|
@section('page-title', 'Modifica Cliente')
|
||||||
|
|
||||||
|
@section('page-actions')
|
||||||
|
<a href="{{ route('customers.show', $customer) }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Torna al dettaglio
|
||||||
|
</a>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white border-0 pt-4 pb-0 px-4">
|
||||||
|
<h5 class="fw-semibold">
|
||||||
|
<i class="bi bi-pencil me-2 text-warning"></i>{{ $customer->name }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small">I campi contrassegnati con * sono obbligatori.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
{{--
|
||||||
|
@method('PUT'): poiché i browser inviano solo GET/POST,
|
||||||
|
Laravel "finge" il metodo PUT tramite un campo nascosto _method.
|
||||||
|
--}}
|
||||||
|
<form method="POST" action="{{ route('customers.update', $customer) }}">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
@include('customers._form', ['customer' => $customer])
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
|
||||||
|
<a href="{{ route('customers.show', $customer) }}" class="btn btn-outline-secondary">
|
||||||
|
Annulla
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-warning">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Salva Modifiche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
158
resources/views/customers/index.blade.php
Normal file
158
resources/views/customers/index.blade.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Clienti')
|
||||||
|
@section('page-title', 'Gestione Clienti')
|
||||||
|
|
||||||
|
@section('page-actions')
|
||||||
|
<a href="{{ route('customers.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Nuovo Cliente
|
||||||
|
</a>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
{{-- ═══ Barra filtri ════════════════════════════════════════════════════ --}}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
{{-- method="GET": i filtri vengono messi nell'URL (?search=...&type=...) --}}
|
||||||
|
<form method="GET" action="{{ route('customers.index') }}" class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold text-muted">Cerca</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" name="search"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Nome, email, città, P.IVA..."
|
||||||
|
{{-- old(): ripopola il campo con il valore precedente dopo ricarica pagina --}}
|
||||||
|
value="{{ request('search') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small fw-semibold text-muted">Tipo</label>
|
||||||
|
<select name="type" class="form-select">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
<option value="privato" {{ request('type') === 'privato' ? 'selected' : '' }}>Privato</option>
|
||||||
|
<option value="azienda" {{ request('type') === 'azienda' ? 'selected' : '' }}>Azienda</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small fw-semibold text-muted">Stato</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
<option value="attivo" {{ request('status') === 'attivo' ? 'selected' : '' }}>Attivo</option>
|
||||||
|
<option value="prospect" {{ request('status') === 'prospect' ? 'selected' : '' }}>Prospect</option>
|
||||||
|
<option value="inattivo" {{ request('status') === 'inattivo' ? 'selected' : '' }}>Inattivo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-funnel me-1"></i>Filtra
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<a href="{{ route('customers.index') }}" class="btn btn-outline-secondary w-100">
|
||||||
|
<i class="bi bi-x me-1"></i>Reset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ═══ Tabella clienti ════════════════════════════════════════════════ --}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white border-0 pt-3">
|
||||||
|
<span class="text-muted small">
|
||||||
|
{{ $customers->total() }} {{ Str::plural('cliente', $customers->total()) }} trovati
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">Cliente</th>
|
||||||
|
<th>Contatto</th>
|
||||||
|
<th>Città</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Contratto</th>
|
||||||
|
<th class="text-end pe-4">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($customers as $customer)
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<div class="fw-semibold">{{ $customer->name }}</div>
|
||||||
|
@if ($customer->vat_number)
|
||||||
|
<div class="text-muted small">P.IVA: {{ $customer->vat_number }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ $customer->email }}</div>
|
||||||
|
@if ($customer->phone)
|
||||||
|
<div class="text-muted small">{{ $customer->phone }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ $customer->city ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark border">{{ $customer->type_label }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ $customer->badge_color }}">
|
||||||
|
{{ ucfirst($customer->status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="fw-semibold">
|
||||||
|
{{ $appSettings['currency_symbol'] }}{{ number_format($customer->contract_value, 0, ',', '.') }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-4">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ route('customers.show', $customer) }}"
|
||||||
|
class="btn btn-outline-primary" title="Dettaglio">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('customers.edit', $customer) }}"
|
||||||
|
class="btn btn-outline-secondary" title="Modifica">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{{-- Form DELETE: i browser non supportano DELETE nativo,
|
||||||
|
Laravel usa il campo _method come workaround --}}
|
||||||
|
<form method="POST" action="{{ route('customers.destroy', $customer) }}"
|
||||||
|
onsubmit="return confirm('Eliminare {{ addslashes($customer->name) }}?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="Elimina">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-people display-6 d-block mb-2 opacity-25"></i>
|
||||||
|
Nessun cliente trovato.<br>
|
||||||
|
<a href="{{ route('customers.create') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Aggiungi il primo cliente
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Paginazione: generata automaticamente da Laravel --}}
|
||||||
|
@if ($customers->hasPages())
|
||||||
|
<div class="card-footer bg-white d-flex justify-content-between align-items-center">
|
||||||
|
<div class="text-muted small">
|
||||||
|
Pagina {{ $customers->currentPage() }} di {{ $customers->lastPage() }}
|
||||||
|
</div>
|
||||||
|
{{-- links() genera i bottoni prev/next con Bootstrap styling --}}
|
||||||
|
{{ $customers->links('pagination::bootstrap-5') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
153
resources/views/customers/show.blade.php
Normal file
153
resources/views/customers/show.blade.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', $customer->name)
|
||||||
|
@section('page-title', $customer->name)
|
||||||
|
|
||||||
|
@section('page-actions')
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ route('customers.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Lista
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('customers.edit', $customer) }}" class="btn btn-warning">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Modifica
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="{{ route('customers.destroy', $customer) }}"
|
||||||
|
onsubmit="return confirm('Sei sicuro di voler eliminare questo cliente?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Elimina
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
{{-- ─── Scheda principale ────────────────────────────────────────────── --}}
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white border-0 pt-4 px-4 pb-0">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
{{-- Avatar generato con iniziali --}}
|
||||||
|
<div class="rounded-circle bg-primary d-flex align-items-center justify-content-center text-white fw-bold fs-4"
|
||||||
|
style="width: 56px; height: 56px; flex-shrink: 0;">
|
||||||
|
{{ strtoupper(substr($customer->name, 0, 1)) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-1">{{ $customer->name }}</h4>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<span class="badge bg-{{ $customer->badge_color }}">{{ ucfirst($customer->status) }}</span>
|
||||||
|
<span class="badge bg-light text-dark border">{{ $customer->type_label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{{-- Contatti --}}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-semibold mb-3">Contatto</h6>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-4 text-muted fw-normal small">Email</dt>
|
||||||
|
<dd class="col-8">
|
||||||
|
<a href="mailto:{{ $customer->email }}">{{ $customer->email }}</a>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if($customer->phone)
|
||||||
|
<dt class="col-4 text-muted fw-normal small">Telefono</dt>
|
||||||
|
<dd class="col-8">
|
||||||
|
<a href="tel:{{ $customer->phone }}">{{ $customer->phone }}</a>
|
||||||
|
</dd>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($customer->city)
|
||||||
|
<dt class="col-4 text-muted fw-normal small">Città</dt>
|
||||||
|
<dd class="col-8">{{ $customer->city }}</dd>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($customer->address)
|
||||||
|
<dt class="col-4 text-muted fw-normal small">Indirizzo</dt>
|
||||||
|
<dd class="col-8">{{ $customer->address }}</dd>
|
||||||
|
@endif
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Dati fiscali --}}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-semibold mb-3">Dati fiscali</h6>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
@if($customer->vat_number)
|
||||||
|
<dt class="col-5 text-muted fw-normal small">P. IVA</dt>
|
||||||
|
<dd class="col-7 font-monospace">{{ $customer->vat_number }}</dd>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($customer->fiscal_code)
|
||||||
|
<dt class="col-5 text-muted fw-normal small">Cod. Fiscale</dt>
|
||||||
|
<dd class="col-7 font-monospace">{{ $customer->fiscal_code }}</dd>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted fw-normal small">Contratto</dt>
|
||||||
|
<dd class="col-7 fw-bold text-success fs-5">
|
||||||
|
{{ $appSettings['currency_symbol'] }}{{ number_format($customer->contract_value, 2, ',', '.') }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Note --}}
|
||||||
|
@if ($customer->notes)
|
||||||
|
<hr>
|
||||||
|
<h6 class="text-muted text-uppercase small fw-semibold mb-2">Note</h6>
|
||||||
|
<p class="mb-0 text-secondary">{{ $customer->notes }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── Sidebar informazioni ─────────────────────────────────────────── --}}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-semibold mb-3">Timeline</h6>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="text-muted small">Creato il</span>
|
||||||
|
{{-- format() usa il formato da configurazione dinamica --}}
|
||||||
|
<span class="small">{{ $customer->created_at->format($appSettings['date_format'] ?? 'd/m/Y') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="text-muted small">Aggiornato il</span>
|
||||||
|
<span class="small">{{ $customer->updated_at->format($appSettings['date_format'] ?? 'd/m/Y') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="text-muted small">ID sistema</span>
|
||||||
|
<span class="small font-monospace">#{{ $customer->id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p class="text-muted small mb-3">Azioni rapide</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{{ route('customers.edit', $customer) }}" class="btn btn-warning btn-sm">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Modifica dati
|
||||||
|
</a>
|
||||||
|
<a href="mailto:{{ $customer->email }}" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-envelope me-1"></i>Invia email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
195
resources/views/dashboard.blade.php
Normal file
195
resources/views/dashboard.blade.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{{--
|
||||||
|
resources/views/dashboard.blade.php — Dashboard principale
|
||||||
|
|
||||||
|
@extends indica da quale layout "ereditare" la struttura HTML.
|
||||||
|
@section riempie le sezioni definite con @yield nel layout.
|
||||||
|
--}}
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Dashboard')
|
||||||
|
@section('page-title', 'Dashboard')
|
||||||
|
|
||||||
|
@section('page-actions')
|
||||||
|
<a href="{{ route('customers.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Nuovo Cliente
|
||||||
|
</a>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
{{-- Messaggio di benvenuto --}}
|
||||||
|
@if ($welcomeMessage)
|
||||||
|
<div class="alert alert-info border-0 shadow-sm mb-4">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>{{ $welcomeMessage }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ═══ Statistiche ═══════════════════════════════════════════════════ --}}
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center">
|
||||||
|
<div class="rounded-3 p-3 bg-primary bg-opacity-10 me-3">
|
||||||
|
<i class="bi bi-people fs-3 text-primary"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-2 fw-bold">{{ $stats['total'] }}</div>
|
||||||
|
<div class="text-muted small">Clienti totali</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center">
|
||||||
|
<div class="rounded-3 p-3 bg-success bg-opacity-10 me-3">
|
||||||
|
<i class="bi bi-person-check fs-3 text-success"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-2 fw-bold text-success">{{ $stats['active'] }}</div>
|
||||||
|
<div class="text-muted small">Clienti attivi</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center">
|
||||||
|
<div class="rounded-3 p-3 bg-warning bg-opacity-10 me-3">
|
||||||
|
<i class="bi bi-person-exclamation fs-3 text-warning"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-2 fw-bold text-warning">{{ $stats['prospect'] }}</div>
|
||||||
|
<div class="text-muted small">Prospect</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body d-flex align-items-center">
|
||||||
|
<div class="rounded-3 p-3 bg-info bg-opacity-10 me-3">
|
||||||
|
<i class="bi bi-currency-euro fs-3 text-info"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{-- number_format: formatta il numero con separatori --}}
|
||||||
|
<div class="fs-2 fw-bold text-info">
|
||||||
|
{{ $appSettings['currency_symbol'] }}{{ number_format($stats['total_contract_value'], 0, ',', '.') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">Valore contratti</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ═══ Clienti recenti + Distribuzione per città ══════════════════════ --}}
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
{{-- Ultimi clienti aggiunti --}}
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white border-0 pt-3 pb-0">
|
||||||
|
<h5 class="fw-semibold mb-0">
|
||||||
|
<i class="bi bi-clock-history me-2 text-muted"></i>Clienti recenti
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Cliente</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Aggiunto</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{-- @forelse: come @foreach ma gestisce il caso lista vuota --}}
|
||||||
|
@forelse ($recentCustomers as $customer)
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-semibold">{{ $customer->name }}</div>
|
||||||
|
<div class="text-muted small">{{ $customer->email }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark border">
|
||||||
|
{{ $customer->type_label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{-- badge_color è l'accessor definito nel Model --}}
|
||||||
|
<span class="badge bg-{{ $customer->badge_color }}">
|
||||||
|
{{ ucfirst($customer->status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{-- diffForHumans(): "2 giorni fa", "stamattina", ecc. --}}
|
||||||
|
{{ $customer->created_at->diffForHumans() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ route('customers.show', $customer) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted py-4">
|
||||||
|
Nessun cliente ancora. <a href="{{ route('customers.create') }}">Aggiungine uno!</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white text-end border-0">
|
||||||
|
<a href="{{ route('customers.index') }}" class="btn btn-link btn-sm text-decoration-none">
|
||||||
|
Vedi tutti <i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Clienti per città --}}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-white border-0 pt-3 pb-0">
|
||||||
|
<h5 class="fw-semibold mb-0">
|
||||||
|
<i class="bi bi-geo-alt me-2 text-muted"></i>Top città
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@forelse ($byCity as $city => $count)
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="fw-medium">{{ $city ?: 'N/D' }}</span>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress flex-grow-1" style="width: 80px; height: 6px;">
|
||||||
|
@php
|
||||||
|
$maxCount = max(array_values($byCity));
|
||||||
|
$pct = $maxCount > 0 ? ($count / $maxCount) * 100 : 0;
|
||||||
|
@endphp
|
||||||
|
<div class="progress-bar" style="width: {{ $pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-primary rounded-pill">{{ $count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-muted text-center my-4">Nessun dato disponibile</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
15
resources/views/errors/404.blade.php
Normal file
15
resources/views/errors/404.blade.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Pagina non trovata')
|
||||||
|
@section('page-title', 'Pagina non trovata')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="display-1 fw-bold text-muted opacity-25">404</div>
|
||||||
|
<h2 class="mt-3">Questa pagina non esiste</h2>
|
||||||
|
<p class="text-muted">La risorsa che cerchi non è disponibile o è stata spostata.</p>
|
||||||
|
<a href="{{ route('dashboard') }}" class="btn btn-primary mt-2">
|
||||||
|
<i class="bi bi-house me-1"></i>Torna alla dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
188
resources/views/layouts/app.blade.php
Normal file
188
resources/views/layouts/app.blade.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
{{--
|
||||||
|
resources/views/layouts/app.blade.php — Layout principale dell'applicazione
|
||||||
|
|
||||||
|
Blade è il motore di template di Laravel. Permette di:
|
||||||
|
- Includere PHP in HTML con sintassi pulita: {{ $variabile }}
|
||||||
|
- Definire sezioni riutilizzabili: @yield, @section/@endsection
|
||||||
|
- Usare direttive: @if, @foreach, @include, ecc.
|
||||||
|
|
||||||
|
Questo layout è il "guscio" HTML condiviso da tutte le pagine.
|
||||||
|
Ogni pagina "estende" questo layout con @extends('layouts.app')
|
||||||
|
e riempie le sezioni con @section('content') ... @endsection
|
||||||
|
--}}
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
{{-- @yield('title') viene rimpiazzato dalla sezione 'title' di ogni view --}}
|
||||||
|
<title>@yield('title', 'Dashboard') — {{ $appSettings['company_name'] ?? config('app.name') }}</title>
|
||||||
|
|
||||||
|
{{-- Bootstrap 5 via CDN: nessun build step necessario --}}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
{{-- Bootstrap Icons --}}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
{{-- Colore tema dinamico dalle impostazioni --}}
|
||||||
|
--bs-primary: {{ $appSettings['theme_color'] ?? '#0d6efd' }};
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link {
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 2px 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link:hover,
|
||||||
|
.sidebar .nav-link.active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link i {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@stack('styles')
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
{{-- ═══════════════════════════════════════════════════════════
|
||||||
|
SIDEBAR — navigazione laterale
|
||||||
|
═══════════════════════════════════════════════════════════ --}}
|
||||||
|
<nav class="col-md-3 col-lg-2 d-md-block sidebar py-3 px-3">
|
||||||
|
|
||||||
|
{{-- Logo/Brand --}}
|
||||||
|
<div class="d-flex align-items-center mb-4 ps-2">
|
||||||
|
<i class="bi bi-people-fill text-primary fs-4 me-2"></i>
|
||||||
|
<span class="sidebar-brand">{{ $appSettings['company_name'] ?? 'Portale Clienti' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
{{-- route('dashboard') genera l'URL della route con nome 'dashboard' --}}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ request()->routeIs('dashboard') ? 'active' : '' }}"
|
||||||
|
href="{{ route('dashboard') }}">
|
||||||
|
<i class="bi bi-speedometer2 me-2"></i>Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ request()->routeIs('customers.*') ? 'active' : '' }}"
|
||||||
|
href="{{ route('customers.index') }}">
|
||||||
|
<i class="bi bi-people me-2"></i>Clienti
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ request()->routeIs('settings.*') ? 'active' : '' }}"
|
||||||
|
href="{{ route('settings.index') }}">
|
||||||
|
<i class="bi bi-gear me-2"></i>Impostazioni
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{-- Footer sidebar --}}
|
||||||
|
<div class="mt-auto pt-4 ps-2">
|
||||||
|
<small class="text-white-50">
|
||||||
|
<i class="bi bi-envelope me-1"></i>
|
||||||
|
{{ $appSettings['support_email'] ?? '' }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{-- ═══════════════════════════════════════════════════════════
|
||||||
|
CONTENUTO PRINCIPALE
|
||||||
|
═══════════════════════════════════════════════════════════ --}}
|
||||||
|
<main class="col-md-9 ms-sm-auto col-lg-10 px-4 py-4 main-content">
|
||||||
|
|
||||||
|
{{-- Header pagina --}}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h4 mb-0 fw-bold text-dark">@yield('page-title')</h1>
|
||||||
|
@yield('page-actions')
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── Messaggi flash ───────────────────────────────────────────
|
||||||
|
I messaggi flash sono impostati nel Controller con:
|
||||||
|
redirect()->with('success', 'Messaggio...')
|
||||||
|
e visualizzati automaticamente qui.
|
||||||
|
─────────────────────────────────────────────────────────────── --}}
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>{{ session('success') }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (session('error'))
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-exclamation-circle me-2"></i>{{ session('error') }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Errori di validazione globali (non associati a un campo specifico) --}}
|
||||||
|
@if ($errors->any() && !$errors->hasBag('default'))
|
||||||
|
<div class="alert alert-danger shadow-sm">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<strong>Correggi i seguenti errori:</strong>
|
||||||
|
<ul class="mb-0 mt-2">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ─── Sezione contenuto: riempita da ogni view ──────────────── --}}
|
||||||
|
@yield('content')
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Bootstrap JS --}}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
@stack('scripts')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
129
resources/views/settings/index.blade.php
Normal file
129
resources/views/settings/index.blade.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Impostazioni')
|
||||||
|
@section('page-title', 'Impostazioni Applicazione')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
{{--
|
||||||
|
Nota: queste impostazioni sono DINAMICHE — modificabili senza toccare il codice.
|
||||||
|
Sono diverse dalle variabili nel .env (che richiedono un restart del container).
|
||||||
|
|
||||||
|
SettingService salva i valori nel database e li mette in cache Redis.
|
||||||
|
Ogni modifica qui si riflette immediatamente nell'applicazione.
|
||||||
|
--}}
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
|
||||||
|
<div class="alert alert-info border-0 shadow-sm mb-4">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
Queste impostazioni si applicano immediatamente. La cache viene aggiornata ad ogni salvataggio.
|
||||||
|
Per la configurazione dell'infrastruttura (database, SMTP, ecc.) modifica il file <code>.env</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('settings.update') }}">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
{{-- ═══ Visualizza per gruppo ══════════════════════════════════════ --}}
|
||||||
|
@foreach ($config['groups'] as $groupName => $keys)
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-white border-0 pt-4 pb-0 px-4">
|
||||||
|
<h5 class="fw-semibold mb-1">
|
||||||
|
@switch($groupName)
|
||||||
|
@case('Azienda') <i class="bi bi-building me-2 text-primary"></i>@break
|
||||||
|
@case('Visualizzazione')<i class="bi bi-palette me-2 text-warning"></i>@break
|
||||||
|
@case('Funzionalità') <i class="bi bi-toggles me-2 text-success"></i>@break
|
||||||
|
@default <i class="bi bi-gear me-2 text-muted"></i>
|
||||||
|
@endswitch
|
||||||
|
{{ $groupName }}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
<div class="row g-3">
|
||||||
|
@foreach ($keys as $key)
|
||||||
|
@php
|
||||||
|
$type = $config['types'][$key] ?? 'string';
|
||||||
|
$desc = $config['descriptions'][$key] ?? $key;
|
||||||
|
$default = $config['defaults'][$key] ?? '';
|
||||||
|
$value = $current[$key] ?? $default;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="{{ $type === 'text' ? 'col-12' : 'col-md-6' }}">
|
||||||
|
<label class="form-label fw-medium" for="setting_{{ $key }}">
|
||||||
|
{{ $desc }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if ($type === 'boolean')
|
||||||
|
{{--
|
||||||
|
Checkbox: invia "1" se selezionato.
|
||||||
|
Il controller aggiunge "false" per quelli non spuntati.
|
||||||
|
--}}
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="setting_{{ $key }}" name="{{ $key }}"
|
||||||
|
value="1"
|
||||||
|
{{ $value ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label text-muted small" for="setting_{{ $key }}">
|
||||||
|
{{ $value ? 'Abilitato' : 'Disabilitato' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@elseif ($type === 'text')
|
||||||
|
<textarea id="setting_{{ $key }}" name="{{ $key }}"
|
||||||
|
rows="3" class="form-control {{ $errors->has($key) ? 'is-invalid' : '' }}">{{ old($key, $value) }}</textarea>
|
||||||
|
@error($key)<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
|
||||||
|
@elseif ($type === 'integer')
|
||||||
|
<input type="number" id="setting_{{ $key }}" name="{{ $key }}"
|
||||||
|
class="form-control {{ $errors->has($key) ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old($key, $value) }}" min="1">
|
||||||
|
@error($key)<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
|
||||||
|
@elseif ($key === 'theme_color')
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="color" class="form-control form-control-color"
|
||||||
|
style="max-width: 60px;"
|
||||||
|
id="color_preview_{{ $key }}"
|
||||||
|
value="{{ $value }}"
|
||||||
|
oninput="document.getElementById('setting_{{ $key }}').value = this.value">
|
||||||
|
<input type="text" id="setting_{{ $key }}" name="{{ $key }}"
|
||||||
|
class="form-control font-monospace {{ $errors->has($key) ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old($key, $value) }}"
|
||||||
|
placeholder="#0d6efd"
|
||||||
|
oninput="document.getElementById('color_preview_{{ $key }}').value = this.value">
|
||||||
|
</div>
|
||||||
|
@error($key)<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
|
||||||
|
@else
|
||||||
|
<input type="text" id="setting_{{ $key }}" name="{{ $key }}"
|
||||||
|
class="form-control {{ $errors->has($key) ? 'is-invalid' : '' }}"
|
||||||
|
value="{{ old($key, $value) }}">
|
||||||
|
@error($key)<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Mostra il valore default come suggerimento --}}
|
||||||
|
<div class="form-text text-muted">
|
||||||
|
Default: <code>{{ is_bool($default) ? ($default ? 'true' : 'false') : $default }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<a href="{{ route('dashboard') }}" class="btn btn-outline-secondary">Annulla</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-floppy me-1"></i>Salva Impostazioni
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
19
routes/console.php
Normal file
19
routes/console.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
// routes/console.php — Comandi Artisan personalizzati
|
||||||
|
//
|
||||||
|
// Qui puoi definire comandi CLI personalizzati per l'applicazione.
|
||||||
|
// Esempio: un comando per generare report, inviare email, ecc.
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Inspiring;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
|
// Comando di esempio: mostra una citazione ispirazionale
|
||||||
|
Artisan::command('inspire', function () {
|
||||||
|
$this->comment(Inspiring::quote());
|
||||||
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
|
// Esempio di task schedulato (cron): svuota la cache impostazioni ogni ora
|
||||||
|
// In produzione, aggiungi al crontab del server:
|
||||||
|
// * * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
Schedule::command('cache:clear')->hourly();
|
||||||
40
routes/web.php
Normal file
40
routes/web.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// routes/web.php — Definizione delle route HTTP
|
||||||
|
//
|
||||||
|
// Ogni "route" mappa un URL + metodo HTTP → a un Controller + metodo.
|
||||||
|
//
|
||||||
|
// Sintassi base:
|
||||||
|
// Route::get('/url', [Controller::class, 'metodo']);
|
||||||
|
// Route::post('/url', [Controller::class, 'metodo']);
|
||||||
|
//
|
||||||
|
// Route::resource() genera automaticamente le 7 route CRUD standard:
|
||||||
|
// GET /customers → index()
|
||||||
|
// GET /customers/create → create()
|
||||||
|
// POST /customers → store()
|
||||||
|
// GET /customers/{id} → show()
|
||||||
|
// GET /customers/{id}/edit → edit()
|
||||||
|
// PUT /customers/{id} → update()
|
||||||
|
// DELETE /customers/{id} → destroy()
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use App\Http\Controllers\CustomerController;
|
||||||
|
use App\Http\Controllers\DashboardController;
|
||||||
|
use App\Http\Controllers\SettingController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// ─── Dashboard ────────────────────────────────────────────────────────────
|
||||||
|
// ->name('dashboard') assegna un nome alla route.
|
||||||
|
// Nelle view e controller usi route('dashboard') invece dell'URL diretto.
|
||||||
|
// Vantaggio: se cambi l'URL, il codice non si rompe.
|
||||||
|
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
|
// ─── Clienti - Resource Route ─────────────────────────────────────────────
|
||||||
|
// Genera tutte le 7 route CRUD per i clienti in una riga
|
||||||
|
Route::resource('customers', CustomerController::class);
|
||||||
|
|
||||||
|
// ─── Impostazioni ─────────────────────────────────────────────────────────
|
||||||
|
Route::prefix('settings')->name('settings.')->group(function () {
|
||||||
|
Route::get('/', [SettingController::class, 'index'])->name('index');
|
||||||
|
Route::put('/', [SettingController::class, 'update'])->name('update');
|
||||||
|
});
|
||||||
0
storage/app/public/.gitkeep
Normal file
0
storage/app/public/.gitkeep
Normal file
0
storage/framework/cache/data/.gitkeep
vendored
Normal file
0
storage/framework/cache/data/.gitkeep
vendored
Normal file
0
storage/framework/sessions/.gitkeep
Normal file
0
storage/framework/sessions/.gitkeep
Normal file
0
storage/framework/views/.gitkeep
Normal file
0
storage/framework/views/.gitkeep
Normal file
0
storage/logs/.gitkeep
Normal file
0
storage/logs/.gitkeep
Normal file
Reference in New Issue
Block a user