++ Primo Caricamento

This commit is contained in:
2026-03-30 19:15:13 +02:00
commit 663a68d59b
47 changed files with 3561 additions and 0 deletions

48
.dockerignore Normal file
View 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
View 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
View 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
View 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
```

View 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
{
//
}

View 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.");
}
}

View 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'
));
}
}

View 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
View 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
View 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);
}
}

View 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'),
]);
});
}
}

View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
*
!.gitignore

10
bootstrap/providers.php Normal file
View 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
View 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
View 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
View 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
View 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,
];

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View 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.');
}
}

View 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,
]);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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());

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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>

View 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
View 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
View 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');
});

View File

0
storage/framework/cache/data/.gitkeep vendored Normal file
View File

View File

View File

0
storage/logs/.gitkeep Normal file
View File