Compare commits

..

3 Commits

8 changed files with 708 additions and 183 deletions

View File

@ -1,8 +1,6 @@
<?php <?php
namespace App\Filament\Pages\Strumenti; namespace App\Filament\Pages\Strumenti;
use Carbon\Carbon;
use App\Models\Documento; use App\Models\Documento;
use App\Models\MovimentoContabile; use App\Models\MovimentoContabile;
use App\Models\Stabile; use App\Models\Stabile;
@ -10,13 +8,14 @@
use App\Services\Documenti\PdfTextExtractionService; use App\Services\Documenti\PdfTextExtractionService;
use App\Support\StabileContext; use App\Support\StabileContext;
use BackedEnum; use BackedEnum;
use Carbon\Carbon;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\BadgeColumn;
@ -30,12 +29,16 @@
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Attributes\Url;
use UnitEnum; use UnitEnum;
class DocumentiArchivio extends Page implements HasTable class DocumentiArchivio extends Page implements HasTable
{ {
use InteractsWithTable; use InteractsWithTable;
#[Url( as : 'tab')]
public string $hubTab = 'archivio-digitale';
protected static ?string $navigationLabel = 'Documenti (PDF)'; protected static ?string $navigationLabel = 'Documenti (PDF)';
protected static ?string $title = 'Documenti (PDF)'; protected static ?string $title = 'Documenti (PDF)';
@ -48,20 +51,26 @@ class DocumentiArchivio extends Page implements HasTable
protected static ?string $slug = 'strumenti/documenti'; protected static ?string $slug = 'strumenti/documenti';
protected string $view = 'filament.pages.gescon.table'; protected string $view = 'filament.pages.strumenti.documenti-archivio';
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user instanceof User return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
} }
public function mount(): void public function mount(): void
{ {
$this->hubTab = $this->normalizeHubTab($this->hubTab);
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
} }
public function selectHubTab(string $tab): void
{
$this->hubTab = $this->normalizeHubTab($tab);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
@ -79,8 +88,8 @@ protected function getHeaderActions(): array
->form([ ->form([
Select::make('stabile_id') Select::make('stabile_id')
->label('Stabile') ->label('Stabile')
->options(fn () => $this->getStabiliOptions()) ->options(fn() => $this->getStabiliOptions())
->default(fn () => $this->getDefaultStabileId()) ->default(fn() => $this->getDefaultStabileId())
->searchable() ->searchable()
->required(), ->required(),
TextInput::make('faldone') TextInput::make('faldone')
@ -114,7 +123,7 @@ protected function getTableQuery(): Builder
$allowedIds = StabileContext::accessibleStabili($user) $allowedIds = StabileContext::accessibleStabili($user)
->pluck('id') ->pluck('id')
->map(fn ($v) => (int) $v) ->map(fn($v) => (int) $v)
->values(); ->values();
if ($allowedIds->isEmpty()) { if ($allowedIds->isEmpty()) {
@ -141,7 +150,7 @@ public function table(Table $table): Table
BadgeColumn::make('tipo_documento') BadgeColumn::make('tipo_documento')
->label('Categoria') ->label('Categoria')
->toggleable() ->toggleable()
->formatStateUsing(fn ($state, Documento $record) => $state ?: ($record->tipologia ?: '—')), ->formatStateUsing(fn($state, Documento $record) => $state ?: ($record->tipologia ?: '—')),
TextColumn::make('descrizione') TextColumn::make('descrizione')
->label('Descrizione') ->label('Descrizione')
->limit(50) ->limit(50)
@ -180,46 +189,130 @@ public function table(Table $table): Table
->options($this->getCategorieOptions()), ->options($this->getCategorieOptions()),
]) ])
->actions([ ->actions([
Action::make('visualizza_pdf')
->label('Vedi PDF')
->icon('heroicon-o-eye')
->visible(fn(Documento $record) => $this->hasPdf($record))
->modalHeading(fn(Documento $record): string => trim((string) ($record->nome ?: 'Documento PDF')))
->modalWidth('7xl')
->modalContent(function (Documento $record) {
$openUrl = route('filament.documenti.download', $record);
return view('filament.modals.pdf-viewer', [
'url' => $openUrl . '?inline=1',
'openUrl' => $openUrl,
'title' => trim((string) ($record->nome ?: 'Documento PDF')),
]);
})
->modalSubmitAction(false)
->modalCancelActionLabel('Chiudi'),
Action::make('scarica_pdf') Action::make('scarica_pdf')
->label('PDF') ->label('PDF')
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->url(fn (Documento $record) => route('filament.documenti.download', $record), true) ->url(fn(Documento $record) => route('filament.documenti.download', $record), true)
->visible(fn (Documento $record) => $this->hasPdf($record)), ->visible(fn(Documento $record) => $this->hasPdf($record)),
Action::make('etichetta_11354') Action::make('etichetta_11354')
->label('Etichetta 11354') ->label('Etichetta 11354')
->icon('heroicon-o-tag') ->icon('heroicon-o-tag')
->url(fn (Documento $record) => route('filament.documenti.etichetta', $record) . '?format=11354&dymo_fix=0&dymo_rot=0&dymo_dx=0&dymo_dy=3&autoprint=1', false), ->url(fn(Documento $record) => route('filament.documenti.etichetta', $record) . '?format=11354&dymo_fix=0&dymo_rot=0&dymo_dx=0&dymo_dy=3&autoprint=1', false),
Action::make('etichetta_11354_driver_verticale') Action::make('etichetta_11354_driver_verticale')
->label('Etichetta 11354 (driver verticale)') ->label('Etichetta 11354 (driver verticale)')
->icon('heroicon-o-tag') ->icon('heroicon-o-tag')
->url(fn (Documento $record) => route('filament.documenti.etichetta', $record) . '?format=11354&dymo_fix=1&dymo_rot=-90&dymo_dx=0&dymo_dy=0&autoprint=1', false), ->url(fn(Documento $record) => route('filament.documenti.etichetta', $record) . '?format=11354&dymo_fix=1&dymo_rot=-90&dymo_dx=0&dymo_dy=0&autoprint=1', false),
Action::make('etichetta_99014') Action::make('etichetta_99014')
->label('Etichetta 99014') ->label('Etichetta 99014')
->icon('heroicon-o-tag') ->icon('heroicon-o-tag')
->url(fn (Documento $record) => route('filament.documenti.etichetta', $record) . '?format=99014&autoprint=1', false), ->url(fn(Documento $record) => route('filament.documenti.etichetta', $record) . '?format=99014&autoprint=1', false),
]); ]);
} }
private function getCategorieOptions(): array private function getCategorieOptions(): array
{ {
return [ return [
'contratto' => 'Contratti (CONTR)', 'contratto' => 'Contratti (CONTR)',
'assicurazione' => 'Assicurazioni (ASSIC)', 'assicurazione' => 'Assicurazioni (ASSIC)',
'certificato' => 'Certificati (CERT)', 'certificato' => 'Certificati (CERT)',
'preventivo' => 'Preventivi (PREV)', 'preventivo' => 'Preventivi (PREV)',
'fattura' => 'Fatture (FATT)', 'fattura' => 'Fatture (FATT)',
'verbale' => 'Verbali (VERB)', 'verbale' => 'Verbali (VERB)',
'altro' => 'Altri (DOC)', 'altro' => 'Altri (DOC)',
]; ];
} }
public function getHubStatsProperty(): array
{
$query = $this->getTableQuery();
return [
'documenti' => (clone $query)->count(),
'archiviati' => (clone $query)->where('archiviato', true)->count(),
'in_scadenza' => (clone $query)->whereNotNull('data_scadenza')->whereDate('data_scadenza', '<=', now()->addDays(30)->toDateString())->count(),
'template_drive' => count($this->getDriveTemplateFoldersProperty()),
];
}
/** @return array<int, string> */
public function getDriveTemplateFoldersProperty(): array
{
$items = config('netgescon.google.drive_template_folders', []);
$yearRoot = trim((string) config('netgescon.google.drive_template_year_root', ''));
$yearModel = trim((string) config('netgescon.google.drive_template_year_model', ''));
$folders = is_array($items)
? array_values(array_filter(array_map('strval', $items), fn(string $item) : bool => trim($item) !== ''))
: [];
if ($yearRoot !== '' && $yearModel !== '') {
foreach ($folders as $folder) {
$folders[] = $yearRoot . '/' . $yearModel . '/' . $folder;
}
}
return array_values(array_unique($folders));
}
/** @return array<int, string> */
public function getAudienceGroupsProperty(): array
{
return [
'Condomini',
'Inquilini',
'Fornitori',
'Collaboratori interni',
'Amministratore',
'Studio completo',
];
}
/** @return array<int, string> */
public function getArchivioFisicoTypesProperty(): array
{
return ['Faldone', 'Scatola', 'Cartella', 'Busta'];
}
public function getMnemonicCodeHintProperty(): ?string
{
$stabile = $this->resolveActiveStabile();
if (! $stabile instanceof Stabile) {
return null;
}
$label = Str::upper((string) ($stabile->denominazione ?? 'STABILE'));
$letters = preg_replace('/[^A-Z]/', '', Str::ascii($label)) ?: 'STB';
$digits = preg_replace('/\D/', '', $label);
$prefix = str_pad(substr($letters, 0, 3), 3, 'X');
$suffix = str_pad(substr($digits !== '' ? $digits : (string) $stabile->id, -5), 5, '0', STR_PAD_LEFT);
return $prefix . $suffix;
}
private function getDocumentoFormSchema(): array private function getDocumentoFormSchema(): array
{ {
return [ return [
Select::make('stabile_id') Select::make('stabile_id')
->label('Stabile') ->label('Stabile')
->options(fn () => $this->getStabiliOptions()) ->options(fn() => $this->getStabiliOptions())
->default(fn () => $this->getDefaultStabileId()) ->default(fn() => $this->getDefaultStabileId())
->searchable() ->searchable()
->required(), ->required(),
Select::make('categoria') Select::make('categoria')
@ -251,7 +344,7 @@ private function getDocumentoFormSchema(): array
->nullable(), ->nullable(),
Select::make('movimento_contabile_id') Select::make('movimento_contabile_id')
->label('Movimento contabile (opzionale)') ->label('Movimento contabile (opzionale)')
->options(fn (callable $get) => $this->getMovimentiOptions((int) ($get('stabile_id') ?: 0))) ->options(fn(callable $get) => $this->getMovimentiOptions((int) ($get('stabile_id') ?: 0)))
->searchable() ->searchable()
->nullable(), ->nullable(),
TextInput::make('faldone') TextInput::make('faldone')
@ -279,15 +372,15 @@ private function getStabiliOptions(): array
->orderBy('denominazione') ->orderBy('denominazione')
->limit(500) ->limit(500)
->get(['id', 'denominazione']) ->get(['id', 'denominazione'])
->mapWithKeys(fn (Stabile $s) => [(string) $s->id => (($s->denominazione ?: ('Stabile #' . $s->id)))]) ->mapWithKeys(fn(Stabile $s) => [(string) $s->id => (($s->denominazione ?: ('Stabile #' . $s->id)))])
->all(); ->all();
} }
$allowed = StabileContext::accessibleStabili($user); $allowed = StabileContext::accessibleStabili($user);
return $allowed return $allowed
->sortBy(fn (Stabile $s) => $s->denominazione) ->sortBy(fn(Stabile $s) => $s->denominazione)
->take(500) ->take(500)
->mapWithKeys(fn (Stabile $s) => [(string) $s->id => (($s->denominazione ?: ('Stabile #' . $s->id)))]) ->mapWithKeys(fn(Stabile $s) => [(string) $s->id => (($s->denominazione ?: ('Stabile #' . $s->id)))])
->all(); ->all();
} }
@ -306,6 +399,23 @@ private function getDefaultStabileId(): ?int
return StabileContext::accessibleStabili($user)->first()?->id; return StabileContext::accessibleStabili($user)->first()?->id;
} }
private function normalizeHubTab(string $tab): string
{
$allowed = ['archivio-digitale', 'template-drive', 'archivio-fisico', 'permessi'];
return in_array($tab, $allowed, true) ? $tab : 'archivio-digitale';
}
private function resolveActiveStabile(): ?Stabile
{
$user = Auth::user();
if (! $user instanceof User) {
return null;
}
return StabileContext::getActiveStabile($user);
}
private function getMovimentiOptions(int $stabileId): array private function getMovimentiOptions(int $stabileId): array
{ {
if ($stabileId <= 0) { if ($stabileId <= 0) {
@ -352,14 +462,14 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void
} }
$categoria = (string) ($data['categoria'] ?? 'altro'); $categoria = (string) ($data['categoria'] ?? 'altro');
$titolo = (string) ($data['titolo'] ?? ''); $titolo = (string) ($data['titolo'] ?? '');
if (trim($titolo) === '') { if (trim($titolo) === '') {
Notification::make()->title('Titolo obbligatorio')->danger()->send(); Notification::make()->title('Titolo obbligatorio')->danger()->send();
return; return;
} }
$dataDocumento = $data['data_documento'] ?? null; $dataDocumento = $data['data_documento'] ?? null;
$year = null; $year = null;
if ($dataDocumento instanceof \DateTimeInterface) { if ($dataDocumento instanceof \DateTimeInterface) {
$year = (int) $dataDocumento->format('Y'); $year = (int) $dataDocumento->format('Y');
} elseif (is_string($dataDocumento) && trim($dataDocumento) !== '') { } elseif (is_string($dataDocumento) && trim($dataDocumento) !== '') {
@ -397,7 +507,7 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void
// Rinomina il file su storage in modo ordinato // Rinomina il file su storage in modo ordinato
$safeTitle = Str::slug($titolo); $safeTitle = Str::slug($titolo);
$finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year; $finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year;
$finalName = $protocollo . '_' . ($safeTitle !== '' ? $safeTitle : 'documento') . '.pdf'; $finalName = $protocollo . '_' . ($safeTitle !== '' ? $safeTitle : 'documento') . '.pdf';
$finalPath = $finalDir . '/' . $finalName; $finalPath = $finalDir . '/' . $finalName;
@ -420,7 +530,7 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void
// ignore // ignore
} }
$note = ''; $note = '';
$faldone = trim((string) ($data['faldone'] ?? '')); $faldone = trim((string) ($data['faldone'] ?? ''));
if ($faldone !== '') { if ($faldone !== '') {
$note = 'Faldone: ' . $faldone; $note = 'Faldone: ' . $faldone;
@ -429,29 +539,29 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void
$tipologia = $this->mapCategoriaToTipologia($categoria); $tipologia = $this->mapCategoriaToTipologia($categoria);
$doc = Documento::query()->create([ $doc = Documento::query()->create([
'documentable_type' => null, 'documentable_type' => null,
'documentable_id' => null, 'documentable_id' => null,
'stabile_id' => $stabileId, 'stabile_id' => $stabileId,
'utente_id' => (int) $user->id, 'utente_id' => (int) $user->id,
'nome' => $titolo, 'nome' => $titolo,
'descrizione' => (string) ($data['descrizione'] ?? ''), 'descrizione' => (string) ($data['descrizione'] ?? ''),
'fornitore' => (string) ($data['fornitore'] ?? ''), 'fornitore' => (string) ($data['fornitore'] ?? ''),
'tipologia' => $tipologia, 'tipologia' => $tipologia,
'tipo_documento' => $categoria, 'tipo_documento' => $categoria,
'data_documento' => $dataDocumento, 'data_documento' => $dataDocumento,
'data_scadenza' => $data['data_scadenza'] ?? null, 'data_scadenza' => $data['data_scadenza'] ?? null,
'importo_collegato' => $data['importo'] ?? null, 'importo_collegato' => $data['importo'] ?? null,
'movimento_contabile_id' => $data['movimento_contabile_id'] ?? null, 'movimento_contabile_id' => $data['movimento_contabile_id'] ?? null,
'numero_protocollo' => $protocollo, 'numero_protocollo' => $protocollo,
'nome_file' => $finalName, 'nome_file' => $finalName,
'mime_type' => 'application/pdf', 'mime_type' => 'application/pdf',
'path_file' => $finalPath, 'path_file' => $finalPath,
'percorso_file' => $finalPath, 'percorso_file' => $finalPath,
'estensione' => 'pdf', 'estensione' => 'pdf',
'dimensione_file' => $size, 'dimensione_file' => $size,
'data_upload' => now(), 'data_upload' => now(),
'note' => $note, 'note' => $note,
'is_demo' => $isDemo, 'is_demo' => $isDemo,
]); ]);
// Estrai testo per ricerca (best-effort) // Estrai testo per ricerca (best-effort)
@ -476,13 +586,13 @@ private function createDocumentoDemoContrattoAscensori(int $stabileId, string $f
return; return;
} }
$year = 2024; $year = 2024;
$protocollo = $this->generateProtocollo('contratto', $year); $protocollo = $this->generateProtocollo('contratto', $year);
$titolo = 'Contratto Manutenzione Ascensori 2024'; $titolo = 'Contratto Manutenzione Ascensori 2024';
$descrizione = 'Contratto annuale per manutenzione impianti ascensore con Acme Elevators Srl.'; $descrizione = 'Contratto annuale per manutenzione impianti ascensore con Acme Elevators Srl.';
$html = '<h1>' . e($titolo) . '</h1>'; $html = '<h1>' . e($titolo) . '</h1>';
$html .= '<p><strong>Protocollo:</strong> ' . e($protocollo) . '</p>'; $html .= '<p><strong>Protocollo:</strong> ' . e($protocollo) . '</p>';
$html .= '<p>' . e($descrizione) . '</p>'; $html .= '<p>' . e($descrizione) . '</p>';
$html .= '<hr>'; $html .= '<hr>';
@ -498,7 +608,7 @@ private function createDocumentoDemoContrattoAscensori(int $stabileId, string $f
$dompdf->render(); $dompdf->render();
$pdfBytes = $dompdf->output(); $pdfBytes = $dompdf->output();
$finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year; $finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year;
$finalName = $protocollo . '_contratto_ascensori_2024.pdf'; $finalName = $protocollo . '_contratto_ascensori_2024.pdf';
$finalPath = $finalDir . '/' . $finalName; $finalPath = $finalDir . '/' . $finalName;
@ -511,7 +621,7 @@ private function createDocumentoDemoContrattoAscensori(int $stabileId, string $f
// ignore // ignore
} }
$note = ''; $note = '';
$faldone = trim($faldone); $faldone = trim($faldone);
if ($faldone !== '') { if ($faldone !== '') {
$note = 'Faldone: ' . $faldone; $note = 'Faldone: ' . $faldone;
@ -520,26 +630,26 @@ private function createDocumentoDemoContrattoAscensori(int $stabileId, string $f
} }
$doc = Documento::query()->create([ $doc = Documento::query()->create([
'stabile_id' => $stabileId, 'stabile_id' => $stabileId,
'utente_id' => (int) $user->id, 'utente_id' => (int) $user->id,
'nome' => $titolo, 'nome' => $titolo,
'descrizione' => $descrizione, 'descrizione' => $descrizione,
'fornitore' => 'Acme Elevators Srl', 'fornitore' => 'Acme Elevators Srl',
'tipologia' => 'contratto', 'tipologia' => 'contratto',
'tipo_documento' => 'contratto', 'tipo_documento' => 'contratto',
'data_documento' => '2023-12-15', 'data_documento' => '2023-12-15',
'data_scadenza' => '2024-12-31', 'data_scadenza' => '2024-12-31',
'importo_collegato' => 2400.00, 'importo_collegato' => 2400.00,
'numero_protocollo' => $protocollo, 'numero_protocollo' => $protocollo,
'nome_file' => $finalName, 'nome_file' => $finalName,
'mime_type' => 'application/pdf', 'mime_type' => 'application/pdf',
'path_file' => $finalPath, 'path_file' => $finalPath,
'percorso_file' => $finalPath, 'percorso_file' => $finalPath,
'estensione' => 'pdf', 'estensione' => 'pdf',
'dimensione_file' => $size, 'dimensione_file' => $size,
'data_upload' => now(), 'data_upload' => now(),
'note' => $note, 'note' => $note,
'is_demo' => true, 'is_demo' => true,
]); ]);
try { try {
@ -558,16 +668,16 @@ private function createDocumentoDemoContrattoAscensori(int $stabileId, string $f
private function generateProtocollo(string $categoria, int $year): string private function generateProtocollo(string $categoria, int $year): string
{ {
$categoria = strtolower(trim($categoria)); $categoria = strtolower(trim($categoria));
$prefissi = [ $prefissi = [
'contratto' => 'CONTR', 'contratto' => 'CONTR',
'assicurazione' => 'ASSIC', 'assicurazione' => 'ASSIC',
'certificato' => 'CERT', 'certificato' => 'CERT',
'preventivo' => 'PREV', 'preventivo' => 'PREV',
// Importante: i protocolli fattura devono essere per-anno. // Importante: i protocolli fattura devono essere per-anno.
// Usiamo FT come prefisso compatto e compatibile con l'archiviazione. // Usiamo FT come prefisso compatto e compatibile con l'archiviazione.
'fattura' => 'FT', 'fattura' => 'FT',
'verbale' => 'VERB', 'verbale' => 'VERB',
'altro' => 'DOC', 'altro' => 'DOC',
]; ];
$pref = $prefissi[$categoria] ?? 'DOC'; $pref = $prefissi[$categoria] ?? 'DOC';
@ -587,7 +697,7 @@ private function generateProtocollo(string $categoria, int $year): string
$progressivo = 1; $progressivo = 1;
if ($ultimo && is_string($ultimo->numero_protocollo)) { if ($ultimo && is_string($ultimo->numero_protocollo)) {
$parts = explode('-', $ultimo->numero_protocollo); $parts = explode('-', $ultimo->numero_protocollo);
$last = (int) (end($parts) ?: 0); $last = (int) (end($parts) ?: 0);
if ($last > 0) { if ($last > 0) {
$progressivo = $last + 1; $progressivo = $last + 1;
} }
@ -608,12 +718,12 @@ private function mapCategoriaToTipologia(string $categoria): string
// `documenti.tipologia` è un enum: manteniamoci sui valori previsti. // `documenti.tipologia` è un enum: manteniamoci sui valori previsti.
return match ($categoria) { return match ($categoria) {
'contratto' => 'contratto', 'contratto' => 'contratto',
'certificato' => 'certificato', 'certificato' => 'certificato',
'preventivo' => 'preventivo', 'preventivo' => 'preventivo',
'fattura' => 'fattura', 'fattura' => 'fattura',
'verbale' => 'verbale', 'verbale' => 'verbale',
default => 'altro', default => 'altro',
}; };
} }
} }

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Filament;
use App\Http\Controllers\Controller;
use App\Models\DocumentoStabile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DocumentoStabileDownloadController extends Controller
{
public function download(Request $request, DocumentoStabile $documentoStabile)
{
$path = is_string($documentoStabile->percorso_file) ? trim((string) $documentoStabile->percorso_file) : '';
if ($path === '') {
abort(404);
}
$selectedDisk = null;
if (Storage::disk('public')->exists($path)) {
$selectedDisk = 'public';
} elseif (Storage::disk('local')->exists($path)) {
$selectedDisk = 'local';
}
if (! $selectedDisk) {
abort(404);
}
$filename = is_string($documentoStabile->nome_originale) && trim((string) $documentoStabile->nome_originale) !== ''
? trim((string) $documentoStabile->nome_originale)
: ((is_string($documentoStabile->nome_file) && trim((string) $documentoStabile->nome_file) !== '')
? trim((string) $documentoStabile->nome_file)
: ('documento-stabile-' . (int) $documentoStabile->id . '.pdf'));
$filename = str_replace(["\r", "\n", '"'], '', $filename);
$documentoStabile->incrementaDownload();
if ($request->boolean('inline')) {
return response()->file(Storage::disk($selectedDisk)->path($path), [
'Content-Disposition' => 'inline; filename="' . $filename . '"',
]);
}
return Storage::disk($selectedDisk)->download($path, $filename);
}
}

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -29,24 +28,24 @@ class DocumentoStabile extends Model
'versione', 'versione',
'downloads', 'downloads',
'ultimo_accesso', 'ultimo_accesso',
'caricato_da' 'caricato_da',
]; ];
protected $casts = [ protected $casts = [
'data_scadenza' => 'date', 'data_scadenza' => 'date',
'pubblico' => 'boolean', 'pubblico' => 'boolean',
'protetto' => 'boolean', 'protetto' => 'boolean',
'downloads' => 'integer', 'downloads' => 'integer',
'versione' => 'integer', 'versione' => 'integer',
'dimensione' => 'integer', 'dimensione' => 'integer',
'ultimo_accesso' => 'datetime' 'ultimo_accesso' => 'datetime',
]; ];
protected $dates = [ protected $dates = [
'data_scadenza', 'data_scadenza',
'ultimo_accesso', 'ultimo_accesso',
'created_at', 'created_at',
'updated_at' 'updated_at',
]; ];
/** /**
@ -87,8 +86,8 @@ public function scopePubblici($query)
public function scopeInScadenza($query, $giorni = 30) public function scopeInScadenza($query, $giorni = 30)
{ {
return $query->whereNotNull('data_scadenza') return $query->whereNotNull('data_scadenza')
->where('data_scadenza', '<=', now()->addDays($giorni)) ->where('data_scadenza', '<=', now()->addDays($giorni))
->where('data_scadenza', '>=', now()); ->where('data_scadenza', '>=', now());
} }
/** /**
@ -97,7 +96,7 @@ public function scopeInScadenza($query, $giorni = 30)
public function scopeScaduti($query) public function scopeScaduti($query)
{ {
return $query->whereNotNull('data_scadenza') return $query->whereNotNull('data_scadenza')
->where('data_scadenza', '<', now()); ->where('data_scadenza', '<', now());
} }
/** /**
@ -106,11 +105,13 @@ public function scopeScaduti($query)
public function getDimensioneFormattataAttribute() public function getDimensioneFormattataAttribute()
{ {
$bytes = $this->dimensione; $bytes = $this->dimensione;
if ($bytes === 0) return '0 Bytes'; if ($bytes === 0) {
return '0 Bytes';
}
$k = 1024; $k = 1024;
$sizes = ['Bytes', 'KB', 'MB', 'GB']; $sizes = ['Bytes', 'KB', 'MB', 'GB'];
$i = floor(log($bytes) / log($k)); $i = floor(log($bytes) / log($k));
return number_format($bytes / pow($k, $i), 2) . ' ' . $sizes[$i]; return number_format($bytes / pow($k, $i), 2) . ' ' . $sizes[$i];
} }
@ -146,12 +147,12 @@ public function getIconaFileAttribute()
*/ */
public function getStatoScadenzaAttribute() public function getStatoScadenzaAttribute()
{ {
if (!$this->data_scadenza) { if (! $this->data_scadenza) {
return null; return null;
} }
$oggi = now()->startOfDay(); $oggi = now()->startOfDay();
$scadenza = $this->data_scadenza->startOfDay(); $scadenza = $this->data_scadenza->startOfDay();
$diffGiorni = $oggi->diffInDays($scadenza, false); $diffGiorni = $oggi->diffInDays($scadenza, false);
if ($diffGiorni < 0) { if ($diffGiorni < 0) {
@ -170,11 +171,11 @@ public function getStatoScadenzaAttribute()
*/ */
public function getUrlDownloadAttribute() public function getUrlDownloadAttribute()
{ {
if (is_string($this->percorso_file) && trim($this->percorso_file) !== '' && Storage::disk('public')->exists($this->percorso_file)) { if (! $this->fileEsiste()) {
return Storage::disk('public')->url($this->percorso_file); return null;
} }
return null; return route('filament.documenti-stabili.download', $this);
} }
/** /**
@ -182,7 +183,13 @@ public function getUrlDownloadAttribute()
*/ */
public function getUrlViewAttribute() public function getUrlViewAttribute()
{ {
return $this->url_download; $downloadUrl = $this->url_download;
if (! is_string($downloadUrl) || trim($downloadUrl) === '') {
return null;
}
return $downloadUrl . '?inline=1';
} }
/** /**
@ -200,7 +207,7 @@ public function incrementaDownload()
public function fileEsiste() public function fileEsiste()
{ {
return is_string($this->percorso_file) return is_string($this->percorso_file)
&& trim($this->percorso_file) !== '' && trim($this->percorso_file) !== ''
&& (Storage::disk('public')->exists($this->percorso_file) || Storage::disk('local')->exists($this->percorso_file)); && (Storage::disk('public')->exists($this->percorso_file) || Storage::disk('local')->exists($this->percorso_file));
} }
@ -230,14 +237,14 @@ public function eliminaFile()
public static function categorie() public static function categorie()
{ {
return [ return [
'contratti' => 'Contratti', 'contratti' => 'Contratti',
'amministrativi' => 'Documenti Amministrativi', 'amministrativi' => 'Documenti Amministrativi',
'tecnici' => 'Documenti Tecnici', 'tecnici' => 'Documenti Tecnici',
'catastali' => 'Documenti Catastali', 'catastali' => 'Documenti Catastali',
'bancari' => 'Documenti Bancari', 'bancari' => 'Documenti Bancari',
'legali' => 'Documenti Legali', 'legali' => 'Documenti Legali',
'assemblee' => 'Verbali Assemblee', 'assemblee' => 'Verbali Assemblee',
'altri' => 'Altri' 'altri' => 'Altri',
]; ];
} }
@ -247,14 +254,14 @@ public static function categorie()
public static function coloriCategorie() public static function coloriCategorie()
{ {
return [ return [
'contratti' => 'primary', 'contratti' => 'primary',
'amministrativi' => 'info', 'amministrativi' => 'info',
'tecnici' => 'warning', 'tecnici' => 'warning',
'catastali' => 'success', 'catastali' => 'success',
'bancari' => 'secondary', 'bancari' => 'secondary',
'legali' => 'danger', 'legali' => 'danger',
'assemblee' => 'dark', 'assemblee' => 'dark',
'altri' => 'light' 'altri' => 'light',
]; ];
} }

View File

@ -1,11 +1,10 @@
<?php <?php
namespace App\Services; namespace App\Services;
use Illuminate\Support\Facades\File; use App\Support\Modules\ModuleManifest;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use ZipArchive; use ZipArchive;
@ -31,7 +30,7 @@ public function __construct()
*/ */
protected function loadActiveModules() protected function loadActiveModules()
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
$this->activeModules = []; $this->activeModules = [];
return; return;
} }
@ -58,10 +57,10 @@ public function installModule($zipPath, $validateLicense = true)
// 1. Estrai e valida // 1. Estrai e valida
$extractPath = $this->extractModule($zipPath); $extractPath = $this->extractModule($zipPath);
$config = $this->validateModuleConfig($extractPath); $config = $this->validateModuleConfig($extractPath);
// 2. Verifica licenza (se richiesta) // 2. Verifica licenza (se richiesta)
if ($validateLicense && !$this->validateLicense($config)) { if ($validateLicense && ! $this->validateLicense($config)) {
throw new \Exception("Licenza non valida per il modulo {$config['name']}"); throw new \Exception("Licenza non valida per il modulo {$config['name']}");
} }
@ -97,7 +96,7 @@ public function installModule($zipPath, $validateLicense = true)
return [ return [
'success' => true, 'success' => true,
'message' => "Modulo {$config['display_name']} installato con successo", 'message' => "Modulo {$config['display_name']} installato con successo",
'module' => $config 'module' => $config,
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Errore installazione modulo: " . $e->getMessage()); Log::error("Errore installazione modulo: " . $e->getMessage());
@ -109,7 +108,7 @@ public function installModule($zipPath, $validateLicense = true)
return [ return [
'success' => false, 'success' => false,
'message' => $e->getMessage() 'message' => $e->getMessage(),
]; ];
} }
} }
@ -126,12 +125,12 @@ public function uninstallModule($moduleName, $keepData = false)
// 1. Controlla dipendenze inverse // 1. Controlla dipendenze inverse
$dependentModules = $this->getDependentModules($moduleName); $dependentModules = $this->getDependentModules($moduleName);
if (!empty($dependentModules)) { if (! empty($dependentModules)) {
throw new \Exception("Impossibile disinstallare: moduli dipendenti attivi: " . implode(', ', $dependentModules)); throw new \Exception("Impossibile disinstallare: moduli dipendenti attivi: " . implode(', ', $dependentModules));
} }
// 2. Backup prima della disinstallazione // 2. Backup prima della disinstallazione
if (!$keepData) { if (! $keepData) {
$this->createBackup($moduleName); $this->createBackup($moduleName);
} }
@ -148,7 +147,7 @@ public function uninstallModule($moduleName, $keepData = false)
$this->removeMenuItems($config); $this->removeMenuItems($config);
// 7. Esegui migrazioni rollback (se non si tengono i dati) // 7. Esegui migrazioni rollback (se non si tengono i dati)
if (!$keepData) { if (! $keepData) {
$this->rollbackMigrations($moduleName); $this->rollbackMigrations($moduleName);
} }
@ -168,14 +167,14 @@ public function uninstallModule($moduleName, $keepData = false)
return [ return [
'success' => true, 'success' => true,
'message' => "Modulo {$moduleName} disinstallato con successo" 'message' => "Modulo {$moduleName} disinstallato con successo",
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Errore disinstallazione modulo: " . $e->getMessage()); Log::error("Errore disinstallazione modulo: " . $e->getMessage());
return [ return [
'success' => false, 'success' => false,
'message' => $e->getMessage() 'message' => $e->getMessage(),
]; ];
} }
} }
@ -195,10 +194,10 @@ public function updateModule($moduleName, $zipPath)
// 2. Estrai nuova versione // 2. Estrai nuova versione
$extractPath = $this->extractModule($zipPath); $extractPath = $this->extractModule($zipPath);
$newConfig = $this->validateModuleConfig($extractPath); $newConfig = $this->validateModuleConfig($extractPath);
// 3. Verifica compatibilità versioni // 3. Verifica compatibilità versioni
if (!$this->isCompatibleVersion($oldConfig, $newConfig)) { if (! $this->isCompatibleVersion($oldConfig, $newConfig)) {
throw new \Exception("Versione non compatibile"); throw new \Exception("Versione non compatibile");
} }
@ -226,7 +225,7 @@ public function updateModule($moduleName, $zipPath)
'success' => true, 'success' => true,
'message' => "Modulo {$moduleName} aggiornato alla versione {$newConfig['version']}", 'message' => "Modulo {$moduleName} aggiornato alla versione {$newConfig['version']}",
'old_version' => $oldConfig['version'], 'old_version' => $oldConfig['version'],
'new_version' => $newConfig['version'] 'new_version' => $newConfig['version'],
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Errore aggiornamento modulo: " . $e->getMessage()); Log::error("Errore aggiornamento modulo: " . $e->getMessage());
@ -238,7 +237,7 @@ public function updateModule($moduleName, $zipPath)
return [ return [
'success' => false, 'success' => false,
'message' => $e->getMessage() 'message' => $e->getMessage(),
]; ];
} }
} }
@ -258,16 +257,45 @@ public function getActiveModules()
*/ */
public function getAllModules() public function getAllModules()
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
return collect(); return collect();
} }
return DB::table('modules')->get()->map(function ($module) { return DB::table('modules')->get()->map(function ($module) {
$module->config = json_decode($module->config, true); $config = json_decode($module->config, true);
$module->config = is_array($config) ? ModuleManifest::normalize($config) : [];
return $module; return $module;
}); });
} }
/**
* Ottieni il catalogo locale dei moduli con manifest normalizzato.
*/
public function discoverLocalModules()
{
if (! File::isDirectory($this->modulesPath)) {
return collect();
}
return collect(File::directories($this->modulesPath))
->map(function (string $modulePath) {
$moduleName = basename($modulePath);
try {
return (object) $this->getModuleConfig($moduleName);
} catch (\Throwable $e) {
Log::warning('ModuleManager: manifest locale non leggibile', [
'module' => $moduleName,
'error' => $e->getMessage(),
]);
return null;
}
})
->filter()
->values();
}
/** /**
* Verifica licenza modulo (connessione server licenze) * Verifica licenza modulo (connessione server licenze)
*/ */
@ -279,7 +307,7 @@ public function validateLicense($config)
// TODO: Implementare verifica licenza online // TODO: Implementare verifica licenza online
$licenseServer = config('netgescon.license_server'); $licenseServer = config('netgescon.license_server');
$licenseKey = config("modules.{$config['name']}.license_key"); $licenseKey = config("modules.{$config['name']}.license_key");
// Chiamata API al server licenze // Chiamata API al server licenze
// return $this->callLicenseServer($licenseServer, $config['name'], $licenseKey); // return $this->callLicenseServer($licenseServer, $config['name'], $licenseKey);
@ -293,21 +321,21 @@ public function validateLicense($config)
*/ */
public function toggleModule($moduleName) public function toggleModule($moduleName)
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
throw new \Exception('Tabella modules non disponibile'); throw new \Exception('Tabella modules non disponibile');
} }
$module = DB::table('modules')->where('name', $moduleName)->first(); $module = DB::table('modules')->where('name', $moduleName)->first();
if (!$module) { if (! $module) {
throw new \Exception("Modulo {$moduleName} non trovato"); throw new \Exception("Modulo {$moduleName} non trovato");
} }
$newStatus = $module->status === 'active' ? 'inactive' : 'active'; $newStatus = $module->status === 'active' ? 'inactive' : 'active';
DB::table('modules')->where('name', $moduleName)->update([ DB::table('modules')->where('name', $moduleName)->update([
'status' => $newStatus, 'status' => $newStatus,
'updated_at' => now() 'updated_at' => now(),
]); ]);
$this->clearModuleCache(); $this->clearModuleCache();
@ -321,10 +349,10 @@ public function toggleModule($moduleName)
private function extractModule($zipPath) private function extractModule($zipPath)
{ {
$zip = new ZipArchive; $zip = new ZipArchive;
$extractPath = storage_path('app/modules/temp/' . uniqid()); $extractPath = storage_path('app/modules/temp/' . uniqid());
if ($zip->open($zipPath) === TRUE) { if ($zip->open($zipPath) === true) {
$zip->extractTo($extractPath); $zip->extractTo($extractPath);
$zip->close(); $zip->close();
return $extractPath; return $extractPath;
@ -337,27 +365,27 @@ private function validateModuleConfig($extractPath)
{ {
$configPath = $extractPath . '/module.json'; $configPath = $extractPath . '/module.json';
if (!File::exists($configPath)) { if (! File::exists($configPath)) {
throw new \Exception("File module.json non trovato"); throw new \Exception("File module.json non trovato");
} }
$config = json_decode(File::get($configPath), true); $config = json_decode(File::get($configPath), true);
if (!$config || !isset($config['name'], $config['version'])) { if (! $config || ! isset($config['name'], $config['version'])) {
throw new \Exception("Configurazione modulo non valida"); throw new \Exception("Configurazione modulo non valida");
} }
return $config; return ModuleManifest::normalize($config);
} }
private function checkDependencies($config) private function checkDependencies($config)
{ {
if (!isset($config['dependencies'])) { if (! isset($config['dependencies'])) {
return true; return true;
} }
foreach ($config['dependencies'] as $dependency) { foreach ($config['dependencies'] as $dependency) {
if (!in_array($dependency, $this->activeModules)) { if (! in_array($dependency, $this->activeModules)) {
throw new \Exception("Dipendenza mancante: {$dependency}"); throw new \Exception("Dipendenza mancante: {$dependency}");
} }
} }
@ -367,20 +395,22 @@ private function checkDependencies($config)
private function registerModule($config) private function registerModule($config)
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
throw new \Exception('Tabella modules non disponibile'); throw new \Exception('Tabella modules non disponibile');
} }
$config = ModuleManifest::normalize(is_array($config) ? $config : []);
DB::table('modules')->updateOrInsert( DB::table('modules')->updateOrInsert(
['name' => $config['name']], ['name' => $config['name']],
[ [
'name' => $config['name'], 'name' => $config['name'],
'display_name' => $config['display_name'], 'display_name' => $config['display_name'] ?? $config['name'],
'version' => $config['version'], 'version' => $config['version'],
'status' => 'active', 'status' => 'active',
'config' => json_encode($config), 'config' => json_encode($config),
'installed_at' => now(), 'installed_at' => now(),
'updated_at' => now() 'updated_at' => now(),
] ]
); );
} }
@ -399,11 +429,16 @@ private function getModuleConfig($moduleName)
{ {
$configPath = $this->modulesPath . "/{$moduleName}/module.json"; $configPath = $this->modulesPath . "/{$moduleName}/module.json";
if (!File::exists($configPath)) { if (! File::exists($configPath)) {
throw new \Exception("Configurazione modulo {$moduleName} non trovata"); throw new \Exception("Configurazione modulo {$moduleName} non trovata");
} }
return json_decode(File::get($configPath), true); $config = json_decode(File::get($configPath), true);
if (! is_array($config)) {
throw new \Exception("Configurazione modulo {$moduleName} non valida");
}
return ModuleManifest::normalize($config);
} }
private function getModuleInfo($moduleName) private function getModuleInfo($moduleName)
@ -417,15 +452,15 @@ private function getModuleInfo($moduleName)
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::warning('ModuleManager: impossibile recuperare info modulo', [ Log::warning('ModuleManager: impossibile recuperare info modulo', [
'module' => $moduleName, 'module' => $moduleName,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return (object) array_merge($config, [ return (object) array_merge($config, [
'status' => $dbInfo->status ?? 'unknown', 'status' => $dbInfo->status ?? 'unknown',
'installed_at' => $dbInfo->installed_at ?? null, 'installed_at' => $dbInfo->installed_at ?? null,
'updated_at' => $dbInfo->updated_at ?? null 'updated_at' => $dbInfo->updated_at ?? null,
]); ]);
} }
@ -451,11 +486,13 @@ private function registerRoutes($moduleName)
} }
private function installPermissions($config) private function installPermissions($config)
{ {
if (!isset($config['permissions']) || empty($config['permissions'])) { $config = ModuleManifest::normalize(is_array($config) ? $config : []);
if (! isset($config['permissions']) || empty($config['permissions'])) {
return; return;
} }
$permissions = $config['permissions']; $permissions = $config['permissions'];
$createdPermissions = []; $createdPermissions = [];
try { try {
@ -463,10 +500,10 @@ private function installPermissions($config)
// Crea il permesso se non esiste // Crea il permesso se non esiste
$existingPermission = \Spatie\Permission\Models\Permission::where('name', $permission['name'])->first(); $existingPermission = \Spatie\Permission\Models\Permission::where('name', $permission['name'])->first();
if (!$existingPermission) { if (! $existingPermission) {
$newPermission = \Spatie\Permission\Models\Permission::create([ $newPermission = \Spatie\Permission\Models\Permission::create([
'name' => $permission['name'], 'name' => $permission['name'],
'guard_name' => $permission['guard_name'] ?? 'web', 'guard_name' => $permission['guard_name'] ?? 'web',
'description' => $permission['description'] ?? null, 'description' => $permission['description'] ?? null,
]); ]);
@ -494,7 +531,7 @@ private function assignPermissionsToRoles($permissions)
$adminRoles = ['admin', 'amministratore', 'super-admin']; $adminRoles = ['admin', 'amministratore', 'super-admin'];
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
$permName = $permission['name']; $permName = $permission['name'];
$assignToRoles = $permission['roles'] ?? $adminRoles; // Default: assegna agli admin $assignToRoles = $permission['roles'] ?? $adminRoles; // Default: assegna agli admin
foreach ($assignToRoles as $roleName) { foreach ($assignToRoles as $roleName) {
@ -502,7 +539,7 @@ private function assignPermissionsToRoles($permissions)
if ($role) { if ($role) {
// Assegna il permesso al ruolo se non ce l'ha già // Assegna il permesso al ruolo se non ce l'ha già
if (!$role->hasPermissionTo($permName)) { if (! $role->hasPermissionTo($permName)) {
$role->givePermissionTo($permName); $role->givePermissionTo($permName);
Log::info("Permesso {$permName} assegnato al ruolo {$roleName}"); Log::info("Permesso {$permName} assegnato al ruolo {$roleName}");
} }
@ -513,7 +550,7 @@ private function assignPermissionsToRoles($permissions)
private function uninstallPermissions($config) private function uninstallPermissions($config)
{ {
if (!isset($config['permissions']) || empty($config['permissions'])) { if (! isset($config['permissions']) || empty($config['permissions'])) {
return; return;
} }

View File

@ -0,0 +1,192 @@
<?php
namespace App\Support\Modules;
final class ModuleManifest
{
/**
* @param array<string, mixed> $manifest
* @return array<string, mixed>
*/
public static function normalize(array $manifest): array
{
$normalized = $manifest;
$name = trim((string) ($manifest['name'] ?? ''));
$normalized['name'] = $name;
$normalized['display_name'] = self::normalizeNullableString($manifest['display_name'] ?? null) ?? self::normalizeNullableString($manifest['title'] ?? null) ?? $name;
$normalized['description'] = self::normalizeNullableString($manifest['description'] ?? null);
$normalized['version'] = self::normalizeNullableString($manifest['version'] ?? null) ?? '0.0.0';
$normalized['author'] = self::normalizeNullableString($manifest['author'] ?? null);
$normalized['license'] = self::normalizeNullableString($manifest['license'] ?? null) ?? 'free';
$normalized['category'] = self::normalizeNullableString($manifest['category'] ?? null);
$normalized['dependencies'] = self::normalizeStringList($manifest['dependencies'] ?? []);
$normalized['optional_dependencies'] = self::normalizeStringList($manifest['optional_dependencies'] ?? []);
$normalized['hooks'] = self::normalizeStringList($manifest['hooks'] ?? []);
$normalized['features'] = self::normalizeStringList($manifest['features'] ?? []);
$normalized['permissions'] = self::normalizePermissions($manifest['permissions'] ?? []);
$normalized['menu_items'] = self::normalizeMenuItems($manifest['menu_items'] ?? []);
$normalized['requirements'] = is_array($manifest['requirements'] ?? null) ? $manifest['requirements'] : [];
$normalized['config'] = is_array($manifest['config'] ?? null) ? $manifest['config'] : [];
$normalized['manifest_version'] = (int) ($manifest['manifest_version'] ?? 1);
return $normalized;
}
/**
* @param mixed $value
*/
private static function normalizeNullableString(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$normalized = trim((string) $value);
return $normalized !== '' ? $normalized : null;
}
/**
* @param mixed $value
* @return array<int, string>
*/
private static function normalizeStringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $item) {
$item = self::normalizeNullableString($item);
if ($item !== null) {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
/**
* @param mixed $value
* @return array<int, array{name:string,display_name:string,description:?string,guard_name:string}>
*/
private static function normalizePermissions(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$permissions = [];
if (self::isAssociative($value)) {
foreach ($value as $name => $description) {
$permissionName = self::normalizeNullableString($name);
if ($permissionName === null) {
continue;
}
$display = self::normalizeNullableString($description) ?? self::humanizePermissionName($permissionName);
$permissions[] = [
'name' => $permissionName,
'display_name' => $display,
'description' => self::normalizeNullableString($description) ?? $display,
'guard_name' => 'web',
];
}
return $permissions;
}
foreach ($value as $permission) {
if (is_string($permission)) {
$permissionName = self::normalizeNullableString($permission);
if ($permissionName === null) {
continue;
}
$permissions[] = [
'name' => $permissionName,
'display_name' => self::humanizePermissionName($permissionName),
'description' => null,
'guard_name' => 'web',
];
continue;
}
if (! is_array($permission)) {
continue;
}
$permissionName = self::normalizeNullableString($permission['name'] ?? null);
if ($permissionName === null) {
continue;
}
$permissions[] = [
'name' => $permissionName,
'display_name' => self::normalizeNullableString($permission['display_name'] ?? null) ?? self::humanizePermissionName($permissionName),
'description' => self::normalizeNullableString($permission['description'] ?? null),
'guard_name' => self::normalizeNullableString($permission['guard_name'] ?? null) ?? 'web',
];
}
return $permissions;
}
/**
* @param mixed $value
* @return array<int, array<string, mixed>>
*/
private static function normalizeMenuItems(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$menuItems = [];
foreach ($value as $item) {
if (! is_array($item)) {
continue;
}
$title = self::normalizeNullableString($item['title'] ?? null);
$route = self::normalizeNullableString($item['route'] ?? null);
if ($title === null || $route === null) {
continue;
}
$menuItems[] = [
'title' => $title,
'route' => $route,
'icon' => self::normalizeNullableString($item['icon'] ?? null),
'permission' => self::normalizeNullableString($item['permission'] ?? null),
'parent' => self::normalizeNullableString($item['parent'] ?? null),
'description' => self::normalizeNullableString($item['description'] ?? null),
'order' => isset($item['order']) && is_numeric($item['order']) ? (int) $item['order'] : 0,
'badge' => $item['badge'] ?? null,
];
}
usort($menuItems, static fn(array $left, array $right): int => ($left['order'] ?? 0) <=> ($right['order'] ?? 0));
return $menuItems;
}
/**
* @param array<mixed> $value
*/
private static function isAssociative(array $value): bool
{
return array_keys($value) !== range(0, count($value) - 1);
}
private static function humanizePermissionName(string $permissionName): string
{
$label = str_replace(['.', '_', '-'], ' ', $permissionName);
return ucfirst(trim(preg_replace('/\s+/', ' ', $label) ?? $label));
}
}

View File

@ -1,7 +1,13 @@
<div class="-m-6 space-y-3 p-2"> <div class="-m-6 space-y-3 p-2">
@php
$title = isset($title) && is_string($title) && trim($title) !== '' ? trim($title) : 'Documento PDF';
$openUrl = isset($openUrl) && is_string($openUrl) && trim($openUrl) !== '' ? trim($openUrl) : $url;
@endphp
<div class="flex items-center justify-end gap-2 px-4 pt-4"> <div class="flex items-center justify-end gap-2 px-4 pt-4">
<div class="mr-auto pr-4 text-sm font-semibold text-slate-800">{{ $title }}</div>
<a <a
href="{{ $url }}" href="{{ $openUrl }}"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700" class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700"

View File

@ -0,0 +1,124 @@
<x-filament-panels::page>
@php
$tabs = [
'archivio-digitale' => 'Archivio digitale',
'template-drive' => 'Template Drive',
'archivio-fisico' => 'Archivio fisico',
'permessi' => 'Permessi',
];
$stats = $this->hubStats;
@endphp
<div class="space-y-6">
<div class="rounded-2xl border border-slate-200 bg-gradient-to-r from-amber-50 via-white to-sky-50 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">Hub Documenti</div>
<h2 class="mt-2 text-2xl font-semibold text-slate-900">Archivio digitale e fisico nello stesso flusso</h2>
<p class="mt-2 max-w-3xl text-sm text-slate-600">Questa pagina diventa la base unica per PDF, struttura Drive, contenitori fisici e regole di visibilita. Il modello resta coerente con il passaggio consegne dello stabile e con la futura gestione studio.</p>
</div>
<div class="rounded-xl border border-white/70 bg-white/80 px-4 py-3 text-right shadow-sm">
<div class="text-xs uppercase tracking-wide text-slate-500">Codice mnemonico suggerito</div>
<div class="mt-1 text-xl font-semibold text-slate-900">{{ $this->mnemonicCodeHint ?? 'STB00000' }}</div>
<div class="mt-1 text-xs text-slate-500">Formato guida per etichette, QR e archivio fisico.</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Documenti</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['documenti'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Archivio PDF censito.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Archiviati</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['archiviati'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Documenti chiusi e consolidati.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">In scadenza</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['in_scadenza'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Finestra dei prossimi 30 giorni.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Template Drive</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $stats['template_drive'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Cartelle base replicabili per stabile.</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
@foreach($tabs as $tabKey => $tabLabel)
<button
type="button"
wire:click="selectHubTab('{{ $tabKey }}')"
@class([
'rounded-full px-4 py-2 text-sm font-medium transition',
'bg-slate-900 text-white shadow-sm' => $this->hubTab === $tabKey,
'bg-white text-slate-600 ring-1 ring-inset ring-slate-300 hover:bg-slate-50' => $this->hubTab !== $tabKey,
])
>
{{ $tabLabel }}
</button>
@endforeach
</div>
@if($this->hubTab === 'archivio-digitale')
<x-filament::section>
<x-slot name="heading">Archivio digitale</x-slot>
<x-slot name="description">La tabella esistente resta il punto operativo per registrazione, OCR, stampa etichette e apertura PDF in modal.</x-slot>
{{ $this->table }}
</x-filament::section>
@elseif($this->hubTab === 'template-drive')
<x-filament::section>
<x-slot name="heading">Template cartelle Drive</x-slot>
<x-slot name="description">Questa struttura resta la base comune per archivio digitale, passaggio consegne e futura sincronizzazione Google.</x-slot>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
@foreach($this->driveTemplateFolders as $folder)
<div class="rounded-xl border bg-slate-50 px-4 py-3 text-sm text-slate-700">{{ $folder }}</div>
@endforeach
</div>
</x-filament::section>
@elseif($this->hubTab === 'archivio-fisico')
<x-filament::section>
<x-slot name="heading">Archivio fisico</x-slot>
<x-slot name="description">Prima base per faldoni, scatole, cartelle e buste collegate ai PDF. QR e NFC si innesteranno su questo schema.</x-slot>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(280px,0.8fr)]">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Contenitori previsti</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach($this->archivioFisicoTypes as $type)
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
<div class="text-sm font-semibold text-slate-900">{{ $type }}</div>
<div class="mt-1 text-xs text-slate-500">Collegabile a codice {{ $this->mnemonicCodeHint ?? 'STB00000' }} e documento digitale.</div>
</div>
@endforeach
</div>
</div>
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div class="text-sm font-semibold text-amber-900">Stato di avanzamento</div>
<div class="mt-2 text-sm text-amber-800">In questa iterazione il modello e preparato lato HUB e codifica. Il prossimo step e aggiungere entita fisiche persistenti, posizione archivio e stampa QR/etichetta.</div>
</div>
</div>
</x-filament::section>
@else
<x-filament::section>
<x-slot name="heading">Permessi e visibilita</x-slot>
<x-slot name="description">Qui prepariamo la classificazione dei documenti per audience senza mischiare condomini, inquilini, fornitori e uso interno studio.</x-slot>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
@foreach($this->audienceGroups as $group)
<div class="rounded-xl border bg-white px-4 py-3">
<div class="text-sm font-semibold text-slate-900">{{ $group }}</div>
<div class="mt-1 text-xs text-slate-500">Audience candidata per visibilita documentale, export e portali dedicati.</div>
</div>
@endforeach
</div>
</x-filament::section>
@endif
</div>
</x-filament-panels::page>

View File

@ -586,6 +586,8 @@
Route::middleware(['role:super-admin|admin|amministratore|collaboratore'])->prefix('admin-filament')->group(function () { Route::middleware(['role:super-admin|admin|amministratore|collaboratore'])->prefix('admin-filament')->group(function () {
Route::get('documenti/{documento}/download', [\App\Http\Controllers\Filament\DocumentiPrintController::class, 'download']) Route::get('documenti/{documento}/download', [\App\Http\Controllers\Filament\DocumentiPrintController::class, 'download'])
->name('filament.documenti.download'); ->name('filament.documenti.download');
Route::get('documenti-stabili/{documentoStabile}/download', [\App\Http\Controllers\Filament\DocumentoStabileDownloadController::class, 'download'])
->name('filament.documenti-stabili.download');
Route::get('documenti/{documento}/etichetta', [\App\Http\Controllers\Filament\DocumentiPrintController::class, 'etichetta']) Route::get('documenti/{documento}/etichetta', [\App\Http\Controllers\Filament\DocumentiPrintController::class, 'etichetta'])
->name('filament.documenti.etichetta'); ->name('filament.documenti.etichetta');