Compare commits

...

2 Commits

22 changed files with 2424 additions and 364 deletions

View File

@ -4,6 +4,7 @@ # Changelog
## [Unreleased]
- Surfaced ACQUA invoices in the servizi-utenze area even when the supplier is only flagged via FE water features, compacted the supplier scheda actions into a single toolbar without page-level horizontal overflow, standardized the bank movements grid to the requested visible columns with a richer raw-data modal, and made affitti canoni start from the effective management takeover date.
- Reworked the banking operations hub so the topbar stays full-width, the sidebar stays fixed below it, the movements tab remains focused on imported/manual bank movements, and archive or gestione structure moved into a dedicated tab. The same slice also added first-pass QIF cleanup metadata for CBILL, SIA, commissions and provider hints, plus stronger bank-to-supplier matching and ACQ assignment for water invoices extracted from FE/PDF data.
- Fixed document archive visibility queries to work safely even when single-user ACL columns are not yet present in older databases, and added the missing `documenti.allowed_user_ids` schema field for per-document access control.
- Restored a cleaner two-column layout in Post-it Gestione boards and hid visual noise from calls that terminate on internal response group `601` while keeping the underlying records in the system.

View File

@ -1,29 +1,27 @@
# NetGesCon (Laravel + Filament)
## Panoramica
Gestione condominiale con moduli contabili, anagrafiche e archivi legacy. Stack principale: Laravel + Filament + Livewire.
## Requisiti
- PHP 8.3+
- Composer
- Node.js + npm
- MySQL/MariaDB
## Avvio rapido (sviluppo)
1) Crea il file `.env` da `.env.example` e configura DB/APP_URL
2) Installa dipendenze
- `composer install`
- `npm install`
3) Genera la chiave app
- `php artisan key:generate`
4) Migra il DB
- `php artisan migrate`
5) Asset Filament
- `php artisan filament:assets`
6) Avvio
- `php artisan serve`
1. Crea il file `.env` da `.env.example` e configura DB/APP_URL.
2. Installa le dipendenze: `composer install` e `npm install`.
3. Genera la chiave applicativa: `php artisan key:generate`.
4. Migra il database: `php artisan migrate`.
5. Pubblica gli asset Filament: `php artisan filament:assets`.
6. Avvia l'applicazione: `php artisan serve`.
## Distribuzione Open Source (linee guida)
- Aggiungi una licenza: `LICENSE` (MIT, Apache-2.0, AGPL, ecc.)
- Compila la documentazione essenziale:
- `README.md` (setup, requisiti, run)
@ -35,7 +33,16 @@ ## Distribuzione Open Source (linee guida)
- Versiona con tag semantici: `vX.Y.Z`
## Struttura documentazione
- `docs/` contiene i flussi, import, troubleshooting e procedure operative.
- Documento archivio e acquisizione: `docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md`
## Stato operativo recente
- Strumenti > Documenti apre ora una dashboard `Protocollo comunicazioni` con ricerca, filtri e anteprima PDF.
- Le etichette archivio fisico generano il QR in locale e supportano i formati DYMO `11354` e `99014`.
- La topbar contestuale Filament usa layout a capo, senza scrollbar orizzontale.
## Contribuire
Vedi `CONTRIBUTING.md` (da aggiungere) per le regole di collaborazione.

View File

@ -1 +1 @@
0.8.2
0.8.3

View File

@ -621,6 +621,7 @@ private function loadAffitti(): void
'nome_inquilino' => $a->nome_inquilino,
'proprietario_nome' => $a->proprietario_nome,
'inizio_contratto' => $a->inizio_contratto,
'presa_in_carico_operativa_dal' => $a->presa_in_carico_operativa_dal,
'prossima_registrazione' => $a->prossima_registrazione,
'tipo_riga' => $a->tipo_riga,
'stabile_id' => $a->stabile_id,
@ -657,12 +658,44 @@ private function buildAffittoWarnings(AffittoImmobile $affitto): array
}
}
$managementStart = $this->resolveCanoniManagementStart($affitto);
$scadutiNonPagati = AffittoCanoneDovuto::query()
->where('affitto_id', $affitto->id)
->when($managementStart, function ($query) use ($managementStart) {
$query->where(function ($inner) use ($managementStart) {
$inner->whereDate('data_scadenza', '>=', $managementStart->toDateString())
->orWhere(function ($fallback) use ($managementStart) {
$fallback->whereNull('data_scadenza')
->where(function ($monthQuery) use ($managementStart) {
$monthQuery->where('anno', '>', (int) $managementStart->year)
->orWhere(function ($sameYear) use ($managementStart) {
$sameYear->where('anno', (int) $managementStart->year)
->where('mese', '>=', (int) $managementStart->month);
});
});
});
});
})
->whereDate('data_scadenza', '<', $today->toDateString())
->sum('totale')
- AffittoCanonePagato::query()
->where('affitto_id', $affitto->id)
->when($managementStart, function ($query) use ($managementStart) {
$query->where(function ($inner) use ($managementStart) {
$inner->whereDate('data_pagamento', '>=', $managementStart->toDateString())
->orWhere(function ($fallback) use ($managementStart) {
$fallback->whereNull('data_pagamento')
->where(function ($monthQuery) use ($managementStart) {
$monthQuery->where('anno', '>', (int) $managementStart->year)
->orWhere(function ($sameYear) use ($managementStart) {
$sameYear->where('anno', (int) $managementStart->year)
->where('mese', '>=', (int) $managementStart->month);
});
});
});
});
})
->whereDate('data_pagamento', '<=', $today->toDateString())
->sum('totale');
@ -786,6 +819,7 @@ private function loadSelected(): void
'numero_registrazione_ade' => null,
'data_registrazione_ade' => null,
'inizio_contratto' => null,
'presa_in_carico_operativa_dal' => null,
'ultimo_rinnovo' => null,
'prossima_scadenza' => null,
'prossima_registrazione' => null,
@ -852,8 +886,9 @@ private function loadSelected(): void
$this->ensureRicevute($record, $dovuti);
$pagati = AffittoCanonePagato::where('affitto_id', $record->id)->orderByDesc('anno')->orderByDesc('mese')->get();
if (! empty($record->inizio_contratto)) {
$start = Carbon::parse($record->inizio_contratto)->startOfMonth();
$managementStart = $this->resolveCanoniManagementStart($record);
if ($managementStart) {
$start = $managementStart->copy()->startOfMonth();
$dovuti = $dovuti->filter(function (AffittoCanoneDovuto $row) use ($start) {
$month = Carbon::create((int) $row->anno, (int) $row->mese, 1);
return $month->gte($start);
@ -1219,11 +1254,12 @@ private function emitCanoniAutomaticiForRecord(AffittoImmobile $record): int
return 0;
}
if (empty($record->inizio_contratto)) {
$managementStart = $this->resolveCanoniManagementStart($record);
if (! $managementStart) {
return 0;
}
$start = Carbon::parse($record->inizio_contratto)->startOfMonth();
$start = $managementStart->copy()->startOfMonth();
$todayMonth = Carbon::today()->startOfMonth();
$end = $todayMonth;
@ -1406,6 +1442,31 @@ private function emitCanoniAutomaticiForRecord(AffittoImmobile $record): int
return $created;
}
private function resolveCanoniManagementStart(AffittoImmobile $record): ?Carbon
{
$dates = [];
if (! empty($record->inizio_contratto)) {
$dates[] = Carbon::parse($record->inizio_contratto)->startOfMonth();
}
if (! empty($record->presa_in_carico_operativa_dal)) {
$dates[] = Carbon::parse($record->presa_in_carico_operativa_dal)->startOfMonth();
}
if ($record->stabile && $record->stabile->latestAmministratoreTransfer?->created_at) {
$dates[] = $record->stabile->latestAmministratoreTransfer->created_at->copy()->startOfMonth();
}
if ($dates === []) {
return null;
}
usort($dates, fn(Carbon $left, Carbon $right): int => $left->getTimestamp() <=> $right->getTimestamp());
return $dates[count($dates) - 1];
}
private function resolveIstatQuotaFromClause(AffittoContrattoClausola $clausola): float
{
$content = trim((string) ($clausola->titolo ?? '') . ' ' . (string) ($clausola->testo ?? ''));

View File

@ -1429,6 +1429,32 @@ public function getAcquaFatturePerGestioneProperty(): array
->values()
->all();
if (Schema::hasTable('fornitori') && Schema::hasColumn('fornitori', 'fe_features')) {
$waterEnabledSupplierIds = Fornitore::query()
->where(function (Builder $query) use ($stabileId): Builder {
return $query->whereExists(function ($subQuery) use ($stabileId) {
$subQuery->selectRaw('1')
->from('fatture_elettroniche as fe')
->whereColumn('fe.fornitore_id', 'fornitori.id')
->where('fe.stabile_id', $stabileId);
})->orWhereExists(function ($subQuery) use ($stabileId) {
$subQuery->selectRaw('1')
->from('contabilita_fatture_fornitori as cff')
->whereColumn('cff.fornitore_id', 'fornitori.id')
->where('cff.stabile_id', $stabileId);
});
})
->whereRaw("JSON_EXTRACT(fe_features, '$.acqua.enabled') = true")
->pluck('id')
->map(fn($v) => (int) $v)
->filter(fn($v) => $v > 0)
->unique()
->values()
->all();
$fornitoreIds = array_values(array_unique(array_merge($fornitoreIds, $waterEnabledSupplierIds)));
}
if ($fornitoreIds !== []) {
$contabili = DB::table('contabilita_fatture_fornitori as f')
->leftJoin('gestioni_contabili as g', 'g.id', '=', 'f.gestione_id')

View File

@ -1305,149 +1305,6 @@ public function table(Table $table): Table
}),
])
->columns([
TextColumn::make('codice_descrizione')
->label('Codice')
->state(function (MovimentoBanca $record): string {
$descr = (string) ($record->descrizione ?? '');
$raw = strtoupper($descr);
if (str_contains($raw, 'ACCREDITO_BEU_CON_CONTABILE')) {
return 'ACCBEUCONCON';
}
if (str_contains($raw, 'COMBONMYB')) {
return 'COMBONMYB';
}
$raw = preg_replace('/[^A-Z0-9]+/', ' ', $raw) ?? $raw;
$parts = array_filter(explode(' ', $raw), fn($v) => $v !== '');
if (! empty($parts)) {
$code = '';
foreach ($parts as $p) {
$chunk = substr($p, 0, 3);
$code .= $chunk;
if (strlen($code) >= 12) {
break;
}
}
$code = substr($code, 0, 12);
if ($code !== '') {
return $code;
}
}
$raw = preg_replace('/[^A-Z0-9]+/', '_', $raw) ?? $raw;
$raw = trim($raw, '_');
$raw = preg_replace('/_+/', '_', $raw) ?? $raw;
return mb_substr($raw, 0, 32);
})
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('disp')
->label('Disp')
->state(function (MovimentoBanca $record): string {
$match = is_array($record->match_data ?? null) ? $record->match_data : [];
$disp = $match['cod_disp'] ?? null;
return is_string($disp) ? trim($disp) : '';
})
->toggleable(),
TextColumn::make('cash')
->label('Cash')
->state(function (MovimentoBanca $record): string {
$match = is_array($record->match_data ?? null) ? $record->match_data : [];
$cash = $match['cash'] ?? null;
return is_string($cash) ? trim($cash) : '';
})
->toggleable(),
TextColumn::make('mittente')
->label('Mittente')
->state(function (MovimentoBanca $record): string {
$match = is_array($record->match_data ?? null) ? $record->match_data : [];
$mitt = $match['mittente'] ?? null;
return is_string($mitt) ? trim($mitt) : '';
})
->wrap()
->toggleable(),
BadgeColumn::make('commissioni')
->label('Comm.')
->getStateUsing(function (MovimentoBanca $record): string {
$raw = strtoupper((string) ($record->descrizione ?? ''));
return str_contains($raw, 'COMBONMYB') ? 'si' : 'no';
})
->colors([
'success' => 'si',
'gray' => 'no',
])
->formatStateUsing(fn(string $state): string => $state === 'si' ? 'SI' : '—')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('disp')
->label('Disp')
->state(function (MovimentoBanca $record): string {
$match = is_array($record->match_data ?? null) ? $record->match_data : [];
$disp = $match['cod_disp'] ?? null;
return is_string($disp) ? trim($disp) : '';
})
->toggleable(),
BadgeColumn::make('da_confermare')
->label('Conferma')
->getStateUsing(fn(MovimentoBanca $record): string => ! empty($record->da_confermare) ? 'da_confermare' : 'ok')
->colors([
'warning' => 'da_confermare',
'success' => 'ok',
])
->formatStateUsing(fn(string $state): string => $state === 'da_confermare' ? 'DA CONF.' : 'OK')
->toggleable(isToggledHiddenByDefault: true),
BadgeColumn::make('stato_quadratura')
->label('Stato')
->getStateUsing(fn(MovimentoBanca $record): string => $this->resolveStatoQuadratura($record))
->colors([
'gray' => 'non_collegato',
'warning' => 'collegato_non_quadrato',
'success' => 'quadrato',
])
->formatStateUsing(function (string $state): string {
return match ($state) {
'quadrato' => 'OK',
'collegato_non_quadrato' => 'ATT',
default => 'NO',
};
})
->sortable(false),
TextColumn::make('voci_collegate')
->label('Voce spesa')
->state(function (MovimentoBanca $record): string {
if (! $record->registrazione_id || ! Schema::hasColumn('contabilita_movimenti', 'voce_spesa_id')) {
return '—';
}
static $cache = [];
$regId = (int) $record->registrazione_id;
if (array_key_exists($regId, $cache)) {
return $cache[$regId];
}
$rows = DB::table('contabilita_movimenti as m')
->leftJoin('voci_spesa as v', 'v.id', '=', 'm.voce_spesa_id')
->where('m.registrazione_id', $regId)
->whereNotNull('m.voce_spesa_id')
->select('v.codice', 'v.descrizione')
->distinct()
->limit(5)
->get();
if ($rows->isEmpty()) {
return $cache[$regId] = '—';
}
$labels = [];
foreach ($rows as $row) {
$code = trim((string) ($row->codice ?? ''));
$desc = trim((string) ($row->descrizione ?? ''));
$labels[] = $code !== '' ? ($code . ($desc !== '' ? ' · ' . $desc : '')) : ($desc !== '' ? $desc : 'Voce');
}
return $cache[$regId] = implode(' | ', array_values(array_unique($labels)));
})
->wrap()
->toggleable(),
TextColumn::make('data')
->label('Data')
->date('d/m/Y')
@ -1455,8 +1312,10 @@ public function table(Table $table): Table
TextColumn::make('valuta')
->label('Valuta')
->date('d/m/Y')
->toggleable(isToggledHiddenByDefault: true),
->date('d/m/Y'),
TextColumn::make('causale')
->label('Caus'),
TextColumn::make('descrizione')
->label('Descrizione')
@ -1466,7 +1325,8 @@ public function table(Table $table): Table
TextColumn::make('gestioneContabile.denominazione')
->label('Gestione')
->toggleable(isToggledHiddenByDefault: true),
->toggleable(isToggledHiddenByDefault: true)
->toggledHiddenByDefault(),
TextColumn::make('entrate')
->label('Entrate')
@ -1492,17 +1352,14 @@ public function table(Table $table): Table
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn($state) => '€ ' . number_format((float) $state, 2, ',', '.')),
TextColumn::make('causale')
->label('Caus.')
->toggleable(),
TextColumn::make('iban')
->label('IBAN')
->toggleable(isToggledHiddenByDefault: true),
->toggleable(isToggledHiddenByDefault: true)
->toggledHiddenByDefault(),
])
->actions([
Action::make('dettaglio_movimento')
->label('Dettaglio')
->label('Apri dati')
->icon('heroicon-o-eye')
->modalWidth('7xl')
->action(function (MovimentoBanca $record): void {

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,18 @@
<?php
namespace App\Http\Controllers\Filament;
use App\Http\Controllers\Controller;
use App\Models\Documento;
use App\Models\DocumentoArchivioFisicoItem;
use App\Models\User;
use App\Services\Documenti\DocumentoArchivioVisibilityService;
use App\Support\StabileContext;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
class DocumentiPrintController extends Controller
{
@ -54,8 +56,22 @@ public function download(Request $request, Documento $documento, DocumentoArchiv
$filename = str_replace(["\r", "\n", '"'], '', $filename);
$absolutePath = Storage::disk($selectedDisk)->path($selectedPath);
if ($request->boolean('watermark')) {
$watermarkedPath = $this->buildWatermarkedPdf($absolutePath, $documento);
if (is_string($watermarkedPath) && is_file($watermarkedPath)) {
$response = response()->download($watermarkedPath, $filename);
if ($request->boolean('inline')) {
$response->setContentDisposition('inline', $filename);
}
return $response->deleteFileAfterSend(true);
}
}
if ($request->boolean('inline')) {
return response()->file(Storage::disk($selectedDisk)->path($selectedPath), [
return response()->file($absolutePath, [
'Content-Disposition' => 'inline; filename="' . $filename . '"',
]);
}
@ -115,10 +131,10 @@ public function etichetta(Request $request, Documento $documento, DocumentoArchi
$faldone = $this->extractFaldoneFromNote((string) ($documento->note ?? ''));
}
$anno = $documento->data_documento?->format('Y') ?: now()->format('Y');
$anno = $documento->data_documento?->format('Y') ?: now()->format('Y');
$protocollo = (string) ($documento->numero_protocollo ?: ('DOC-' . $anno . '-' . str_pad((string) $documento->id, 3, '0', STR_PAD_LEFT)));
$stabileId = (int) ($documento->stabile_id ?: 0);
$stabileId = (int) ($documento->stabile_id ?: 0);
$codiceUnico = $stabileId > 0 ? ('STB-' . $stabileId . '-' . $protocollo) : $protocollo;
if ($faldone !== '') {
$codiceUnico .= '-F' . Str::upper($faldone);
@ -127,17 +143,71 @@ public function etichetta(Request $request, Documento $documento, DocumentoArchi
$openUrl = route('filament.documenti.download', $documento);
return view('filament.print.etichetta-documento', [
'documento' => $documento,
'documento' => $documento,
'format' => $format,
'dymoFix' => $dymoFix,
'dymoRot' => $dymoRot,
'dymoDx' => $dymoDx,
'dymoDy' => $dymoDy,
'faldone' => $faldone,
'codiceUnico' => $codiceUnico,
'openUrl' => $openUrl,
'anno' => $anno,
'protocollo' => $protocollo,
]);
}
public function etichettaArchivioFisico(Request $request, DocumentoArchivioFisicoItem $item)
{
$user = Auth::user();
if (! $user instanceof User || ! $this->canAccessPhysicalArchiveItem($user, $item)) {
abort(403);
}
$format = $request->query('format', '11354');
$format = in_array($format, ['11354', '99014'], true) ? $format : '11354';
$defaults = $this->getDymoDefaultsForUser($format);
$dymoFix = $request->query('dymo_fix');
if ($dymoFix === null) {
$dymoFix = (bool) ($defaults['fix'] ?? false);
} else {
$dymoFix = filter_var($dymoFix, FILTER_VALIDATE_BOOL);
}
$dymoRot = $request->has('dymo_rot')
? (int) $request->query('dymo_rot')
: (int) ($defaults['rot'] ?? 0);
$dymoDx = $request->has('dymo_dx') ? (float) $request->query('dymo_dx') : (float) ($defaults['dx'] ?? 0);
$dymoDy = $request->has('dymo_dy') ? (float) $request->query('dymo_dy') : (float) ($defaults['dy'] ?? 0);
return view('filament.print.etichetta-archivio-fisico', [
'item' => $item->load(['parent', 'children', 'stabile', 'documento']),
'format' => $format,
'dymoFix' => $dymoFix,
'dymoRot' => $dymoRot,
'dymoDx' => $dymoDx,
'dymoDy' => $dymoDy,
'faldone' => $faldone,
'codiceUnico' => $codiceUnico,
'openUrl' => $openUrl,
'anno' => $anno,
'protocollo' => $protocollo,
'publicUrl' => $item->qr_code_data,
]);
}
public function showPublicArchivioFisicoItem(Request $request, DocumentoArchivioFisicoItem $item)
{
if (! $request->hasValidSignature()) {
abort(403);
}
$chain = collect();
$current = $item;
while ($current instanceof DocumentoArchivioFisicoItem) {
$chain->prepend($current);
$current = $current->parent;
}
return view('filament.pages.strumenti.archivio-fisico-show', [
'item' => $item->load(['children.children', 'stabile', 'documento', 'parent']),
'chain' => $chain,
]);
}
@ -162,24 +232,24 @@ private function getDymoDefaultsForUser(string $format): array
return [
'fix' => false,
'rot' => 0,
'dx' => 0.0,
'dy' => 3.0,
'dx' => 0.0,
'dy' => 3.0,
];
}
return [
'fix' => false,
'rot' => 0,
'dx' => 0.0,
'dy' => 0.0,
'dx' => 0.0,
'dy' => 0.0,
];
}
return [
'fix' => (bool) ($cfg['fix'] ?? false),
'rot' => isset($cfg['rot']) ? (int) $cfg['rot'] : null,
'dx' => isset($cfg['dx']) ? (float) $cfg['dx'] : 0,
'dy' => isset($cfg['dy']) ? (float) $cfg['dy'] : 0,
'dx' => isset($cfg['dx']) ? (float) $cfg['dx'] : 0,
'dy' => isset($cfg['dy']) ? (float) $cfg['dy'] : 0,
];
}
@ -197,4 +267,96 @@ private function extractFaldoneFromNote(string $note): string
return '';
}
private function canAccessPhysicalArchiveItem(User $user, DocumentoArchivioFisicoItem $item): bool
{
if ($user->hasAnyRole(['super-admin', 'admin'])) {
return true;
}
return StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value): int => (int) $value)
->contains((int) $item->stabile_id);
}
private function buildWatermarkedPdf(string $inputPath, Documento $documento): ?string
{
$ghostscript = trim((string) shell_exec('command -v gs 2>/dev/null'));
if ($ghostscript === '' || ! is_file($inputPath)) {
return null;
}
$watermarkText = $this->buildWatermarkText($documento);
if ($watermarkText === '') {
return null;
}
$outputPath = tempnam(sys_get_temp_dir(), 'doc-wm-');
if (! is_string($outputPath) || $outputPath === '') {
return null;
}
$pdfOutputPath = $outputPath . '.pdf';
@unlink($outputPath);
$postScript = '<< /BeginPage { gsave 0.90 setgray /Helvetica-Bold findfont 14 scalefont setfont 140 90 translate 38 rotate 0 0 moveto ('
. $this->escapePostScriptString($watermarkText)
. ') show grestore } >> setpagedevice';
$process = new Process([
$ghostscript,
'-q',
'-dNOPAUSE',
'-dBATCH',
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.4',
'-o',
$pdfOutputPath,
'-c',
$postScript,
'-f',
$inputPath,
]);
$process->run();
if (! $process->isSuccessful() || ! is_file($pdfOutputPath)) {
@unlink($pdfOutputPath);
return null;
}
return $pdfOutputPath;
}
private function buildWatermarkText(Documento $documento): string
{
$archiveReference = trim((string) ($documento->archive_reference ?? ''));
$meta = is_array($documento->metadati_archivio ?? null) ? $documento->metadati_archivio : [];
$physicalParts = array_filter([
trim((string) ($meta['supporto_fisico'] ?? '')),
trim((string) ($meta['contenitore_numero'] ?? '')),
trim((string) ($meta['busta_numero'] ?? '')) !== '' ? 'B' . trim((string) $meta['busta_numero']) : null,
trim((string) ($meta['fascicolo_numero'] ?? '')) !== '' ? 'F' . trim((string) $meta['fascicolo_numero']) : null,
]);
$physicalReference = implode(' ', $physicalParts);
return trim(implode(' | ', array_filter([
$archiveReference !== '' ? 'DIG ' . $archiveReference : null,
$physicalReference !== '' ? 'FIS ' . $physicalReference : null,
])));
}
private function escapePostScriptString(string $value): string
{
$ascii = (string) Str::of($value)->ascii()->replaceMatches('/\s+/', ' ')->trim();
return str_replace(
['\\', '(', ')'],
['\\\\', '\\(', '\\)'],
$ascii,
);
}
}

View File

@ -41,6 +41,7 @@ class AffittoImmobile extends Model
'numero_registrazione_ade',
'data_registrazione_ade',
'inizio_contratto',
'presa_in_carico_operativa_dal',
'ultimo_rinnovo',
'prossima_scadenza',
'prossima_registrazione',
@ -77,6 +78,7 @@ class AffittoImmobile extends Model
'data_registrazione_ade' => 'date',
'obsoleto' => 'boolean',
'obsoleto_dal' => 'date',
'presa_in_carico_operativa_dal' => 'date',
'giorno_emissione_canone' => 'integer',
'mese_adempimenti_annuali' => 'integer',
'giorno_adempimenti_annuali' => 'integer',

View File

@ -58,6 +58,7 @@ class Documento extends Model
'estensione',
'contenuto_ocr',
'metadati_ocr',
'metadati_archivio',
'visibility_scope',
'visibility_groups',
'visibility_roles',
@ -77,6 +78,7 @@ class Documento extends Model
protected $casts = [
'xml_data' => 'array',
'metadati_ocr' => 'array',
'metadati_archivio' => 'array',
'visibility_groups' => 'array',
'visibility_roles' => 'array',
'allowed_user_ids' => 'array',

View File

@ -0,0 +1,184 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\URL;
class DocumentoArchivioFisicoItem extends Model
{
use HasFactory;
protected $table = 'documento_archivio_fisico_items';
protected $fillable = [
'stabile_id',
'parent_id',
'documento_id',
'codice_univoco',
'qr_code_data',
'nfc_reference',
'voce_numero',
'voce_titolo',
'tipo_item',
'titolo',
'note',
'data_archiviazione',
'supporto_fisico',
'modello_etichetta',
'magazzino',
'scaffale',
'ripiano',
'ubicazione_dettaglio',
'stato',
'sort_order',
'metadati',
'created_by',
'updated_by',
];
protected $casts = [
'data_archiviazione' => 'date',
'sort_order' => 'integer',
'metadati' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public const TIPI = [
'documento' => 'Documento cartaceo',
'busta_trasparente' => 'Busta trasparente con anelli da archiviare',
'cartella_tre_lembi' => 'Cartella a tre lembi',
'faldone_anelli' => 'Faldone con anelli',
'faldone_lacci' => 'Faldone con lacci',
'scatola' => 'Scatola',
];
public function stabile(): BelongsTo
{
return $this->belongsTo(Stabile::class, 'stabile_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order')->orderBy('titolo');
}
public function documento(): BelongsTo
{
return $this->belongsTo(Documento::class, 'documento_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
public function getTipoLabelAttribute(): string
{
return self::TIPI[$this->tipo_item] ?? $this->tipo_item;
}
public function getPercorsoFisicoAttribute(): string
{
$parts = [];
$current = $this;
while ($current instanceof self) {
$parts[] = trim($current->codice_univoco . ' ' . $current->titolo);
$current = $current->parent;
}
return implode(' / ', array_reverse(array_filter($parts)));
}
public function getQrCodeImageAttribute(): string
{
$data = trim((string) $this->qr_code_data);
if ($data !== '' && class_exists(\chillerlan\QRCode\QROptions::class) && class_exists(\chillerlan\QRCode\QRCode::class)) {
$options = new \chillerlan\QRCode\QROptions([
'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG,
'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M,
'addQuietzone' => true,
'quietzoneSize' => 1,
'connectPaths' => true,
]);
$svg = (new \chillerlan\QRCode\QRCode($options))->render($data);
if (str_starts_with($svg, 'data:image/svg+xml;base64,')) {
$decoded = base64_decode(substr($svg, strlen('data:image/svg+xml;base64,')), true);
if (is_string($decoded) && $decoded !== '') {
$svg = $decoded;
}
}
return str_contains($svg, '<svg')
? str_replace('<svg ', '<svg aria-label="QR archivio fisico" role="img" ', $svg)
: $svg;
}
if ($data === '') {
return '<span aria-hidden="true"></span>';
}
return '<img alt="QR archivio fisico" src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=' . rawurlencode($data) . '">';
}
public static function generateCodice(int $stabileId): string
{
$prefix = 'AF-' . $stabileId . '-';
$sequence = 1;
do {
$code = $prefix . str_pad((string) $sequence, 4, '0', STR_PAD_LEFT);
$exists = self::query()->where('codice_univoco', $code)->exists();
$sequence++;
} while ($exists);
return $code;
}
public function refreshQrCodeData(): void
{
$url = URL::temporarySignedRoute(
'public.archivio-fisico.show',
now()->addYears(5),
['item' => $this->id]
);
$this->forceFill([
'qr_code_data' => $url,
])->saveQuietly();
}
protected static function booted(): void
{
static::creating(function (self $item): void {
if (trim((string) $item->codice_univoco) === '') {
$item->codice_univoco = self::generateCodice((int) $item->stabile_id);
}
});
static::created(function (self $item): void {
if (trim((string) $item->qr_code_data) === '') {
$item->refreshQrCodeData();
}
});
}
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('documenti') || Schema::hasColumn('documenti', 'metadati_archivio')) {
return;
}
Schema::table('documenti', function (Blueprint $table): void {
$table->json('metadati_archivio')->nullable()->after('metadati_ocr');
});
}
public function down(): void
{
if (! Schema::hasTable('documenti') || ! Schema::hasColumn('documenti', 'metadati_archivio')) {
return;
}
Schema::table('documenti', function (Blueprint $table): void {
$table->dropColumn('metadati_archivio');
});
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('documento_archivio_fisico_items')) {
return;
}
Schema::create('documento_archivio_fisico_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('stabile_id')->constrained('stabili')->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('documento_archivio_fisico_items')->nullOnDelete();
$table->foreignId('documento_id')->nullable()->constrained('documenti')->nullOnDelete();
$table->string('codice_univoco', 40)->unique();
$table->text('qr_code_data')->nullable();
$table->string('nfc_reference', 120)->nullable();
$table->unsignedSmallInteger('voce_numero')->nullable();
$table->string('voce_titolo')->nullable();
$table->string('tipo_item', 40);
$table->string('titolo');
$table->text('note')->nullable();
$table->date('data_archiviazione')->nullable();
$table->string('supporto_fisico', 120)->nullable();
$table->string('modello_etichetta', 20)->nullable();
$table->string('magazzino', 120)->nullable();
$table->string('scaffale', 120)->nullable();
$table->string('ripiano', 120)->nullable();
$table->string('ubicazione_dettaglio', 255)->nullable();
$table->string('stato', 40)->default('attivo');
$table->unsignedInteger('sort_order')->default(0);
$table->json('metadati')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['stabile_id', 'tipo_item'], 'doc_arch_fisico_stabile_tipo_idx');
$table->index(['stabile_id', 'parent_id'], 'doc_arch_fisico_stabile_parent_idx');
$table->index(['stabile_id', 'data_archiviazione'], 'doc_arch_fisico_stabile_data_idx');
});
}
public function down(): void
{
Schema::dropIfExists('documento_archivio_fisico_items');
}
};

View File

@ -21,6 +21,79 @@ ## Stato attuale archivio documenti
- visibilita per ruolo, gruppo e utente singolo;
- estrazione testo best-effort per ricerca tramite `PdfTextExtractionService`.
## Dashboard protocollo comunicazioni
La pagina Strumenti > Documenti ora apre di default una prima tab dedicata al protocollo comunicazioni.
Questa vista e pensata come dashboard operativa per l'amministratore e mostra solo i documenti visibili diversi dalle fatture.
Funzioni attive:
- ricerca live su protocollo, titolo, descrizione, ente e riferimento archivio;
- filtro per categoria documento;
- vista a box con anteprima PDF incorporata;
- vista lista compatta per consultazione rapida;
- contatori rapidi su totale documenti, PDF disponibili, inserimenti del mese e categorie presenti.
Scelta implementativa:
- la tab usa i record `Documento` gia protocollati e gia filtrati dal servizio di visibilita;
- le fatture restano nella loro vista archivio digitale separata;
- non e stato aperto un sottosistema parallelo su `CommunicationMessage` per evitare doppioni funzionali in questa fase.
## Archivio fisico ed etichette
La base operativa dell'archivio cartaceo e ora utilizzabile con etichette e codici persistenti.
Capacita disponibili:
- registrazione di elementi fisici come documento, busta, cartella a tre lembi, faldone con anelli, faldone con lacci e scatola;
- collegamento facoltativo tra voce fisica e documento digitale;
- codifica univoca persistente per la movimentazione dell'archivio;
- stampa etichette nei formati `DYMO 11354` e `DYMO 99014`;
- pagina pubblica firmata per dettaglio contenitore o documento tramite QR.
Per i documenti digitali resta disponibile anche l'assegnazione dei metadati fisici principali:
- supporto fisico;
- contenitore o faldone;
- busta;
- fascicolo;
- periodo di riferimento;
- modello etichetta;
- watermark predefinito in stampa.
## QR locale sulle etichette
La generazione QR per le etichette fisiche non dipende piu da servizi remoti.
Dettagli operativi:
- viene usata la libreria gia presente nel progetto `chillerlan/php-qrcode`;
- non e stato aggiunto `simple-qrcode` per incompatibilita con il lock corrente su `bacon/bacon-qr-code`;
- il rendering finale viene convertito in SVG inline, cosi il preview di stampa resta visibile anche nel browser e il QR non esce dai margini dell'etichetta.
Questo elimina la dipendenza da URL esterni per il QR e rende piu stabile il preview di stampa.
## Stampa PDF e watermark archivio
Per i documenti PDF sono ora previste due aperture distinte dalla tab archivio:
- apertura/stampa standard del PDF;
- apertura con watermark archivio digitale e fisico, quando i riferimenti archivistici sono presenti.
Il watermark usa Ghostscript solo se disponibile lato server. In assenza del binario, il PDF continua ad aprirsi senza bloccare il flusso.
## Topbar contestuale
La topbar Filament non usa piu lo scorrimento orizzontale per stabile, gestione e anno.
La soluzione adottata e strutturale:
- i controlli ora vanno a capo;
- il contenitore topbar nasconde l'overflow orizzontale;
- il selettore riscaldamento continua a dipendere dallo stato operativo reale dello stabile.
## Indicizzazione e OCR
La base applicativa e gia predisposta per:
@ -143,6 +216,12 @@ ## Dymo LAN 11354
- spool di stampa verso stampante LAN;
- contenuti minimi: codice stabile, tipo archivio, faldone/cartella, short link o QR.
Nota stato attuale:
- l'app genera gia il layout etichetta e il QR locale;
- non e ancora stato collegato uno spool diretto verso la DYMO su IP `192.168.0.56`;
- restano quindi aperte due strade operative: stampa via macchina Windows gia configurata oppure invio diretto future-proof verso stampante di rete.
## Prossimi passi consigliati
1. test reale OCR e ricerca su 2-3 PDF campione;
@ -150,4 +229,4 @@ ## Prossimi passi consigliati
3. tabella short links con dominio `bcards.it`;
4. bridge locale PC per ACR122U;
5. prova Dymo LAN con etichetta 11354;
6. definizione modulo presenza e voto assemblea.
6. definizione modulo presenza e voto assemblea.

View File

@ -62,7 +62,8 @@ .fi-sidebar-nav-groups {
@media (min-width: 64rem) {
.fi-body.fi-body-has-navigation {
overflow: hidden !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.fi-body.fi-body-has-navigation .fi-sidebar {
@ -90,19 +91,32 @@ @media (min-width: 64rem) {
.fi-body.fi-body-has-navigation .fi-main-ctn {
margin-left: 0 !important;
width: 100% !important;
width: calc(100% - 20rem) !important;
max-width: calc(100vw - 20rem) !important;
padding-top: 4.5rem !important;
padding-left: 20rem !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
.fi-body.fi-body-has-navigation .fi-main-ctn > main {
.fi-body.fi-body-has-navigation .fi-main-ctn>main {
height: calc(100vh - 4.5rem) !important;
height: calc(100dvh - 4.5rem) !important;
overflow-y: auto !important;
overflow-x: auto !important;
min-width: 0 !important;
}
}
.fi-topbar {
overflow-x: hidden !important;
}
.fi-topbar nav,
.fi-topbar > div {
min-width: 0 !important;
}
.fi-sidebar-group-btn,
.fi-sidebar-group-dropdown-trigger-btn {
min-height: auto !important;

View File

@ -6,30 +6,9 @@
return false;
}
if ((bool) ($stabile->riscaldamento_centralizzato ?? false)) {
return true;
}
$rateRiscaldamento = $stabile->rate_riscaldamento_mesi ?? [];
if (is_string($rateRiscaldamento)) {
$rateRiscaldamento = json_decode($rateRiscaldamento, true);
}
if (is_array($rateRiscaldamento) && $rateRiscaldamento !== []) {
return true;
}
$config = $stabile->configurazione_avanzata ?? [];
if (is_string($config)) {
$config = json_decode($config, true);
}
if (is_array($config) && array_key_exists('mostra_riscaldamento', $config) && $config['mostra_riscaldamento']) {
return true;
}
return \Illuminate\Support\Facades\DB::table('gestioni_contabili')
->where('stabile_id', $stabile->id)
->where('tipo_gestione', 'riscaldamento')
->exists();
return method_exists($stabile, 'hasOperationalHeating')
? $stabile->hasOperationalHeating()
: false;
};
$stabileLabel = static function (?\App\Models\Stabile $stabile): string {
@ -60,68 +39,52 @@
$gestioneAttiva = \App\Support\GestioneContext::resolveActiveGestione($user instanceof \App\Models\User ? $user : null);
$annoGestione = \App\Support\AnnoGestioneContext::resolveActiveAnno($user instanceof \App\Models\User ? $user : null);
$anniGestione = range(max(2000, $annoGestione - 5), min(2100, $annoGestione + 3));
$labelGestione = $gestioni[$gestioneAttiva] ?? (string) $gestioneAttiva;
$haRiscaldamento = $stabileHasRiscaldamento($stabileAttivo);
@endphp
<div class="flex min-w-0 items-center gap-2 ps-3">
<x-filament::dropdown placement="bottom-start">
<x-slot name="trigger">
<button type="button" class="flex items-center gap-2">
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-800">
{{ $stabileAttivo ? ($stabileLabel($stabileAttivo) . ' · ' . $stabileAttivo->denominazione) : 'Seleziona stabile' }}
</span>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-800">
{{ $labelGestione }}
</span>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-800">
{{ $annoGestione }}
</span>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-800">
Risc: {{ $haRiscaldamento ? 'SI' : 'NO' }}
</span>
<x-filament::icon icon="heroicon-o-chevron-down" class="h-4 w-4 text-gray-500" />
</button>
</x-slot>
<div class="flex min-w-0 flex-1 flex-wrap items-end gap-2 ps-3">
<div class="flex min-w-0 flex-wrap items-end gap-2">
<form method="POST" action="{{ route('admin-filament.stabile-attivo') }}" class="min-w-[15rem]">
@csrf
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-gray-500">Stabile</label>
<select name="stabile_id" onchange="this.form.submit()" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-800 shadow-sm focus:border-amber-400 focus:outline-none">
@foreach($stabili as $stabile)
<option value="{{ $stabile->id }}" @selected((int) $stabile->id === (int) ($stabileAttivo?->id ?? 0))>
{{ $stabileLabel($stabile) }} · {{ $stabile->denominazione }}
</option>
@endforeach
</select>
</form>
<div class="w-96 p-3 space-y-3">
@if($stabili->count() > 0)
<form method="POST" action="{{ route('admin-filament.stabile-attivo') }}" class="space-y-1">
@csrf
<div class="text-xs text-gray-500">Stabile</div>
<select name="stabile_id" class="fi-input block w-full" onchange="this.form.submit()">
@foreach($stabili as $stabile)
<option value="{{ $stabile->id }}" @selected($stabileAttivo?->id === $stabile->id)>
{{ $stabileLabel($stabile) }} {{ $stabile->denominazione }}
</option>
@endforeach
</select>
</form>
@endif
<form method="POST" action="{{ route('admin-filament.gestione-attiva') }}" class="min-w-[11rem]">
@csrf
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-gray-500">Gestione</label>
<select name="gestione" onchange="this.form.submit()" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-800 shadow-sm focus:border-amber-400 focus:outline-none">
@foreach($gestioni as $gestioneKey => $gestioneLabel)
@continue($gestioneKey === 'riscaldamento' && ! $haRiscaldamento)
<option value="{{ $gestioneKey }}" @selected($gestioneKey === $gestioneAttiva)>{{ $gestioneLabel }}</option>
@endforeach
</select>
</form>
<form method="POST" action="{{ route('admin-filament.gestione-attiva') }}" class="space-y-1">
@csrf
<div class="text-xs text-gray-500">Gestione</div>
<select name="gestione" class="fi-input block w-full" onchange="this.form.submit()">
@foreach($gestioni as $key => $label)
<option value="{{ $key }}" @selected($gestioneAttiva === $key)>{{ $label }}</option>
@endforeach
</select>
</form>
<form method="POST" action="{{ route('admin-filament.anno-gestione-attivo') }}" class="min-w-[7rem]">
@csrf
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-gray-500">Anno</label>
<select name="anno" onchange="this.form.submit()" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-800 shadow-sm focus:border-amber-400 focus:outline-none">
@foreach($anniGestione as $anno)
<option value="{{ $anno }}" @selected((int) $anno === (int) $annoGestione)>{{ $anno }}</option>
@endforeach
</select>
</form>
<form method="POST" action="{{ route('admin-filament.anno-gestione-attivo') }}" class="space-y-1">
@csrf
<div class="text-xs text-gray-500">Anno gestione</div>
<select name="anno" class="fi-input block w-full" onchange="this.form.submit()">
@for($y = (int) now()->year - 2; $y <= (int) now()->year + 1; $y++)
<option value="{{ $y }}" @selected($annoGestione === $y)>{{ $y }}</option>
@endfor
</select>
</form>
</div>
</x-filament::dropdown>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-2 text-xs font-medium text-gray-800">
Risc: {{ $haRiscaldamento ? 'SI' : 'NO' }}
</span>
</div>
<livewire:filament.topbar-live-call />

View File

@ -23,10 +23,18 @@
<div class="text-gray-500">Data</div>
<div class="font-semibold">{{ optional($record->data)->format('d/m/Y') ?: '—' }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs">
<div class="text-gray-500">Valuta</div>
<div class="font-semibold">{{ optional($record->valuta)->format('d/m/Y') ?: '—' }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs">
<div class="text-gray-500">Importo</div>
<div class="font-semibold"> {{ number_format((float) $record->importo, 2, ',', '.') }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs">
<div class="text-gray-500">Causale</div>
<div class="font-semibold">{{ $record->causale ?? '—' }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs">
<div class="text-gray-500">Descrizione</div>
<div class="font-semibold">{{ $record->descrizione ?? '—' }}</div>
@ -35,6 +43,20 @@
<div class="text-gray-500">Descrizione estesa</div>
<div class="font-semibold">{{ $record->descrizione_estesa_pulita ?? $record->descrizione_estesa ?? '—' }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs">
<div class="text-gray-500">Saldo progressivo</div>
<div class="font-semibold">
@if(isset($record->saldo_progressivo) && is_numeric($record->saldo_progressivo))
{{ number_format((float) $record->saldo_progressivo, 2, ',', '.') }}
@else
@endif
</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs">
<div class="text-gray-500">File sorgente</div>
<div class="font-semibold">{{ $record->source_file ?? '—' }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs">
<div class="text-gray-500">Disp</div>
<div class="font-semibold">{{ is_array($record->match_data ?? null) ? ($record->match_data['cod_disp'] ?? '—') : '—' }}</div>
@ -47,6 +69,10 @@
<div class="text-gray-500">Mittente</div>
<div class="font-semibold">{{ is_array($record->match_data ?? null) ? ($record->match_data['mittente'] ?? '—') : '—' }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-xs md:col-span-2">
<div class="text-gray-500">Riga raw importata</div>
<div class="font-semibold break-words">{{ $record->raw_line ?? '—' }}</div>
</div>
</div>
@php
@ -63,10 +89,5 @@
</div>
@endif
<div class="flex flex-wrap gap-2">
<x-filament::button size="sm" color="primary" disabled>Conferma</x-filament::button>
<x-filament::button size="sm" color="gray" disabled>Genera prima nota</x-filament::button>
<x-filament::button size="sm" color="gray" disabled>Paga fornitore</x-filament::button>
</div>
</div>
@endif

View File

@ -7,9 +7,21 @@
'catalogo' => 'Catalogo e prodotti',
'contabilita' => 'Contabilita',
];
$catalogoActions = [
['action' => 'nuovo_prodotto', 'label' => 'Nuovo prodotto', 'color' => 'gray'],
['action' => 'estrai_prodotti_fe', 'label' => 'Estrai da FE', 'color' => 'primary'],
['action' => 'importa_listino_csv', 'label' => 'Importa listino CSV', 'color' => 'success'],
['action' => 'scarica_asset_catalogo', 'label' => 'Internalizza asset', 'color' => 'warning'],
['action' => 'genera_referral_amazon', 'label' => 'Prepara link Amazon', 'color' => 'gray'],
];
$assistenzaActions = [
['action' => 'impostazioni_fe', 'label' => 'Impostazioni FE', 'color' => 'gray'],
['action' => 'acqua_config', 'label' => 'Configura acqua', 'color' => 'gray'],
['action' => 'acqua_scan', 'label' => 'Scansiona FE acqua', 'color' => 'primary'],
];
@endphp
<div class="space-y-6" x-data="{ tab: @js($this->sectionTab) }">
<div class="space-y-6 overflow-x-hidden" x-data="{ tab: @js($this->sectionTab) }">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
@ -41,6 +53,37 @@ class="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium"
Qui restano visibili modulo fornitore e catalogo operativo. Il magazzino e la gestione completa del prodotto possono essere spostati in menu dedicati mantenendo qui il quadro sintetico e i collegamenti rapidi.
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4" x-show="tab === 'catalogo' || tab === 'assistenza'" x-cloak>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900" x-show="tab === 'catalogo'">Azioni catalogo</div>
<div class="text-sm font-semibold text-slate-900" x-show="tab === 'assistenza'">Azioni automazioni</div>
<div class="mt-1 text-xs text-slate-500" x-show="tab === 'catalogo'">Un solo punto per le azioni operative del catalogo, senza duplicarle in più riquadri.</div>
<div class="mt-1 text-xs text-slate-500" x-show="tab === 'assistenza'">FE e acqua raccolte in una toolbar compatta, così la colonna destra non spinge più il layout.</div>
</div>
<div class="flex max-w-full flex-wrap justify-end gap-2">
@foreach($catalogoActions as $action)
<x-filament::button
x-show="tab === 'catalogo'"
size="sm"
:color="$action['color']"
type="button"
wire:click="mountAction('{{ $action['action'] }}')"
>{{ $action['label'] }}</x-filament::button>
@endforeach
@foreach($assistenzaActions as $action)
<x-filament::button
x-show="tab === 'assistenza'"
size="sm"
:color="$action['color']"
type="button"
wire:click="mountAction('{{ $action['action'] }}')"
>{{ $action['label'] }}</x-filament::button>
@endforeach
</div>
</div>
</div>
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900" x-show="tab === 'assistenza'" x-cloak>
Questa tab concentra TecnoRepair, seriali e automazioni collegate al fornitore. I ticket operativi e il protocollo comunicazioni possono poi evolvere come viste dedicate, senza ricadere in una scheda unica confusa.
</div>
@ -49,8 +92,8 @@ class="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium"
Qui si prepara il perimetro economico del fornitore: pagamenti, fatture, anteprime contabili e ritenute. Preventivi, ordini e fatturazione completa possono innestarsi in questa area senza mischiarsi con catalogo o assistenza.
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<div class="grid min-w-0 grid-cols-1 gap-6 lg:grid-cols-3">
<div class="min-w-0 space-y-6 lg:col-span-2">
<div class="rounded-lg border p-4" x-show="tab === 'profilo'" x-cloak>
<div class="text-lg font-semibold">Fornitore</div>
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">
@ -261,13 +304,6 @@ class="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium"
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa listino CSV</x-filament::button>
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Internalizza asset</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Prepara link Amazon</x-filament::button>
</div>
</div>
<div class="rounded-lg border p-4" x-show="tab === 'catalogo'" x-cloak>
@ -276,13 +312,7 @@ class="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium"
<div class="text-lg font-semibold">Catalogo operativo interno</div>
<div class="mt-1 text-xs text-gray-500">Anagrafica prodotti/servizi del fornitore. I riferimenti esterni importati restano interni al gestionale e la pubblicazione usa il codice interno del catalogo.</div>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('nuovo_prodotto')">Nuovo prodotto</x-filament::button>
<x-filament::button size="sm" color="primary" type="button" wire:click="mountAction('estrai_prodotti_fe')">Estrai da FE</x-filament::button>
<x-filament::button size="sm" color="success" type="button" wire:click="mountAction('importa_listino_csv')">Importa CSV</x-filament::button>
<x-filament::button size="sm" color="warning" type="button" wire:click="mountAction('scarica_asset_catalogo')">Asset interni</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" wire:click="mountAction('genera_referral_amazon')">Amazon</x-filament::button>
</div>
<div class="rounded-md bg-slate-100 px-3 py-2 text-xs text-slate-600">Le azioni operative sono state raccolte sopra.</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
@ -457,7 +487,7 @@ class="w-full rounded-lg border-gray-300 text-sm"
</div>
</div>
<div class="space-y-6">
<div class="min-w-0 space-y-6">
@php
$features = is_array($box['fornitore_features'] ?? null) ? $box['fornitore_features'] : [];
$acqua = is_array($features['acqua'] ?? null) ? $features['acqua'] : [];
@ -537,17 +567,7 @@ class="w-full rounded-lg border-gray-300 text-sm"
</div>
</div>
<div class="mt-4 flex flex-col gap-2">
<x-filament::button type="button" color="gray" wire:click="mountAction('impostazioni_fe')">
Impostazioni FE
</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="mountAction('acqua_config')">
Configura acqua
</x-filament::button>
<x-filament::button type="button" color="primary" wire:click="mountAction('acqua_scan')">
Scansiona FE acqua
</x-filament::button>
</div>
<div class="mt-4 rounded-md bg-slate-100 px-3 py-2 text-xs text-slate-600">Le azioni FE/acqua sono state raccolte nella toolbar in alto.</div>
</div>
<div class="rounded-lg border p-4" x-show="tab === 'contabilita'" x-cloak>

View File

@ -0,0 +1,112 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Archivio fisico</title>
<style>
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background: #f8fafc;
color: #0f172a;
}
.wrap {
max-width: 920px;
margin: 0 auto;
padding: 24px 16px 40px;
}
.card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
}
.muted { color: #64748b; font-size: 13px; }
.code {
display: inline-flex;
background: #0f172a;
color: #fff;
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
.grid { display: grid; gap: 16px; }
.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.item { border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px; background: #f8fafc; }
.chain { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.pill { border: 1px solid #cbd5e1; border-radius: 999px; padding: 6px 10px; font-size: 12px; background: white; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="muted">Archivio fisico NetGescon</div>
<h1 style="margin:8px 0 10px; font-size:28px;">{{ $item->titolo }}</h1>
<div class="chain">
@foreach($chain as $node)
<span class="pill">{{ $node->codice_univoco }} · {{ $node->titolo }}</span>
@endforeach
</div>
<div class="code">{{ $item->codice_univoco }}</div>
<div class="grid cols-2" style="margin-top:16px;">
<div class="item">
<div class="muted">Tipologia</div>
<div>{{ $item->tipo_label }}</div>
</div>
<div class="item">
<div class="muted">Stabile</div>
<div>{{ $item->stabile?->denominazione ?? '—' }}</div>
</div>
<div class="item">
<div class="muted">Voce archivio</div>
<div>{{ $item->voce_numero ? 'Voce ' . $item->voce_numero . ' · ' : '' }}{{ $item->voce_titolo ?? '—' }}</div>
</div>
<div class="item">
<div class="muted">Data archiviazione</div>
<div>{{ $item->data_archiviazione?->format('d-m-Y') ?? '—' }}</div>
</div>
<div class="item">
<div class="muted">Supporto</div>
<div>{{ $item->supporto_fisico ?? '—' }}</div>
</div>
<div class="item">
<div class="muted">Ubicazione</div>
<div>{{ collect([$item->magazzino, $item->scaffale, $item->ripiano, $item->ubicazione_dettaglio])->filter()->implode(' · ') ?: '—' }}</div>
</div>
</div>
@if($item->documento)
<div class="item" style="margin-top:16px;">
<div class="muted">Documento digitale collegato</div>
<div>{{ $item->documento->nome_file ?: $item->documento->nome }}</div>
<div class="muted" style="margin-top:6px;">{{ $item->documento->archive_reference ?? 'Senza riferimento digitale' }}</div>
</div>
@endif
@if($item->note)
<div class="item" style="margin-top:16px;">
<div class="muted">Note</div>
<div>{{ $item->note }}</div>
</div>
@endif
</div>
<div class="card" style="margin-top:18px;">
<div class="muted">Contenuto diretto</div>
<div class="grid cols-2" style="margin-top:12px;">
@forelse($item->children as $child)
<div class="item">
<div class="code">{{ $child->codice_univoco }}</div>
<div style="margin-top:10px; font-weight:700;">{{ $child->titolo }}</div>
<div class="muted" style="margin-top:6px;">{{ $child->tipo_label }}</div>
</div>
@empty
<div class="muted">Nessun elemento interno registrato.</div>
@endforelse
</div>
</div>
</div>
</body>
</html>

View File

@ -1,12 +1,14 @@
<x-filament-panels::page>
@php
$tabs = [
'protocollo-comunicazioni' => 'Protocollo comunicazioni',
'archivio-digitale' => 'Archivio digitale',
'template-drive' => 'Template Drive',
'archivio-fisico' => 'Archivio fisico',
'permessi' => 'Permessi e cartelle',
];
$stats = $this->hubStats;
$protocolloStats = $this->protocolloComunicazioniStats;
@endphp
<div class="space-y-6">
@ -64,12 +66,243 @@
@endforeach
</div>
@if($this->hubTab === 'archivio-digitale')
@if($this->hubTab === 'protocollo-comunicazioni')
<x-filament::section>
<x-slot name="heading">Protocollo comunicazioni</x-slot>
<x-slot name="description">Prima tab operativa per l'amministratore: ultimi documenti protocollati, filtri rapidi e vista a box con anteprima PDF o lista compatta.</x-slot>
<div class="space-y-4">
<div class="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">Protocollo visibile</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $protocolloStats['totale'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Documenti non fattura nello stabile attivo.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Con PDF</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $protocolloStats['con_pdf'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Apribili in anteprima o stampa.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Questo mese</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $protocolloStats['questo_mese'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Nuovi inserimenti recenti.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Categorie</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ $protocolloStats['categorie'] ?? 0 }}</div>
<div class="mt-1 text-sm text-slate-500">Tipologie disponibili nei filtri.</div>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div class="grid flex-1 gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(220px,0.6fr)]">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Ricerca</label>
<input type="text" wire:model.live.debounce.300ms="protocolloSearch" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Protocollo, titolo, descrizione, ente, riferimento archivio..." />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Categoria</label>
<select wire:model.live="protocolloCategory" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Tutte le categorie</option>
@foreach($this->protocolloComunicazioniCategoryOptions as $categoryKey => $categoryLabel)
<option value="{{ $categoryKey }}">{{ $categoryLabel }}</option>
@endforeach
</select>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
wire:click="setProtocolloView('cards')"
@class([
'rounded-full px-3 py-2 text-sm font-medium transition',
'bg-slate-900 text-white' => $this->protocolloView === 'cards',
'bg-slate-100 text-slate-600 hover:bg-slate-200' => $this->protocolloView !== 'cards',
])
>
Box
</button>
<button
type="button"
wire:click="setProtocolloView('list')"
@class([
'rounded-full px-3 py-2 text-sm font-medium transition',
'bg-slate-900 text-white' => $this->protocolloView === 'list',
'bg-slate-100 text-slate-600 hover:bg-slate-200' => $this->protocolloView !== 'list',
])
>
Lista
</button>
</div>
</div>
</div>
@if($this->protocolloView === 'cards')
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
@forelse($this->protocolloComunicazioniRows as $row)
<article class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div class="aspect-[4/3] border-b border-slate-200 bg-slate-100">
@if($row['has_pdf'])
<iframe
src="{{ $row['preview_url'] }}#toolbar=0&navpanes=0&scrollbar=0"
class="h-full w-full"
loading="lazy"
title="Anteprima {{ $row['protocollo'] }}"
></iframe>
@else
<div class="flex h-full items-center justify-center px-6 text-center text-sm text-slate-500">
Anteprima PDF non disponibile per questo protocollo.
</div>
@endif
</div>
<div class="space-y-3 p-4">
<div class="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-slate-700">{{ $row['protocollo'] }}</span>
<span>{{ $row['categoria'] }}</span>
<span>{{ $row['data'] }}</span>
</div>
<div>
<h3 class="text-base font-semibold text-slate-900">{{ $row['titolo'] }}</h3>
<p class="mt-1 text-sm text-slate-600">{{ $row['descrizione'] !== '' ? $row['descrizione'] : 'Nessuna descrizione registrata.' }}</p>
</div>
<div class="space-y-1 text-xs text-slate-500">
<div>Cartella: {{ $row['cartella'] }}</div>
@if($row['fornitore'] !== '')
<div>Ente / referente: {{ $row['fornitore'] }}</div>
@endif
@if($row['archive_reference'] !== '')
<div>Rif. archivio: {{ $row['archive_reference'] }}</div>
@endif
</div>
<div class="flex flex-wrap gap-2 pt-1">
<a href="{{ $row['preview_url'] }}" target="_blank" class="inline-flex items-center rounded-lg bg-slate-900 px-3 py-2 text-xs font-medium text-white hover:bg-slate-800">Apri</a>
<a href="{{ $row['download_url'] }}" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50">Scarica</a>
@if($row['has_pdf'])
<a href="{{ $row['print_url'] }}" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50">Stampa</a>
@endif
</div>
</div>
</article>
@empty
<div class="rounded-xl border border-dashed border-slate-300 bg-white px-4 py-10 text-sm text-slate-500 md:col-span-2 2xl:col-span-3">
Nessun documento protocollato trovato con i filtri correnti.
</div>
@endforelse
</div>
@else
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div class="grid grid-cols-[160px_minmax(0,1.4fr)_180px_130px] gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div>Protocollo</div>
<div>Documento</div>
<div>Categoria / cartella</div>
<div>Azioni</div>
</div>
<div class="divide-y divide-slate-200">
@forelse($this->protocolloComunicazioniRows as $row)
<div class="grid grid-cols-[160px_minmax(0,1.4fr)_180px_130px] gap-3 px-4 py-3 text-sm text-slate-700">
<div>
<div class="font-semibold text-slate-900">{{ $row['protocollo'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $row['data'] }}</div>
</div>
<div class="min-w-0">
<div class="truncate font-semibold text-slate-900">{{ $row['titolo'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $row['descrizione'] !== '' ? $row['descrizione'] : 'Nessuna descrizione registrata.' }}</div>
@if($row['archive_reference'] !== '')
<div class="mt-1 text-xs text-sky-700">{{ $row['archive_reference'] }}</div>
@endif
</div>
<div>
<div class="font-medium text-slate-900">{{ $row['categoria'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $row['cartella'] }}</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $row['preview_url'] }}" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Apri</a>
@if($row['has_pdf'])
<a href="{{ $row['print_url'] }}" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Stampa</a>
@endif
</div>
</div>
@empty
<div class="px-4 py-8 text-sm text-slate-500">Nessun documento protocollato trovato con i filtri correnti.</div>
@endforelse
</div>
</div>
@endif
</div>
</x-filament::section>
@elseif($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>
<x-slot name="description">Navigazione a cartelle per lo stabile attivo: root stabile, cartella fatture_PDF, anno di arrivo. Le azioni restano operative ma visibili solo come icone.</x-slot>
{{ $this->table }}
<div class="space-y-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Percorso corrente</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-slate-700">
@foreach($this->digitalArchiveBreadcrumbs as $crumb)
<button
type="button"
wire:click="openDigitalFolder({{ $crumb['folder_id'] === null ? 'null' : $crumb['folder_id'] }})"
class="rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-700"
>
{{ $crumb['label'] }}
</button>
@endforeach
</div>
</div>
<div class="rounded-lg border border-sky-100 bg-white px-4 py-3 text-xs text-slate-600">
Nomi file fatture ordinati per data e protocollo, cosi la ricerca e l'elenco restano leggibili anche senza aprire il PDF.
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
@forelse($this->digitalArchiveFolders as $folder)
<button
type="button"
wire:click="openDigitalFolder({{ $folder['id'] }})"
class="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:shadow"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $folder['nome'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $folder['descrizione'] }}</div>
</div>
<div class="rounded-full bg-sky-50 px-2.5 py-1 text-xs font-semibold text-sky-700">{{ $folder['conteggio'] }}</div>
</div>
</button>
@empty
<div class="rounded-xl border border-dashed border-slate-300 bg-white px-4 py-8 text-sm text-slate-500 md:col-span-2 xl:col-span-4">
Nessuna sottocartella disponibile nel livello corrente.
</div>
@endforelse
</div>
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_320px]">
<div>
{{ $this->table }}
</div>
<div class="space-y-3">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Flusso digitale e fisico</div>
<div class="mt-2 text-sm text-slate-600">Usa l'icona archivio per assegnare contenitore, busta, fascicolo ed etichetta del documento cartaceo. La posizione verra poi richiamata anche nel watermark PDF.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Stampa</div>
<div class="mt-2 text-sm text-slate-600">Icona stampante: PDF senza timbro. Icona duplicato: PDF con watermark archivio digitale e fisico, se la codifica e presente.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Scanner</div>
<div class="mt-2 text-sm text-slate-600">La struttura attuale e pronta per il prossimo aggancio scanner: acquisizione PDF, naming ordinato, assegnazione immediata a cartella digitale e posizione fisica.</div>
</div>
</div>
</div>
</div>
</x-filament::section>
@elseif($this->hubTab === 'template-drive')
<x-filament::section>
@ -85,23 +318,212 @@
@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>
<x-slot name="description">Registro operativo del cartaceo: inserimento manuale, contenitori annidati, movimentazione per codice e stampa etichette 11354 o 99014.</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 class="grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]">
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Elenco archivio condominiale</div>
<div class="mt-1 text-xs text-slate-500">Inserimento manuale come ticket: data, voce, titolo, nota, contenitore iniziale e posizione finale.</div>
</div>
@endforeach
<div class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">DB operativo</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Data</label>
<input type="date" wire:model.defer="physicalArchiveForm.data_archiviazione" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Voce archivio</label>
<select wire:model.live="physicalArchiveForm.voce_numero" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Seleziona voce</option>
@foreach($this->archivioObbligatorioRows as $row)
<option value="{{ $row['numero'] }}">Voce {{ $row['numero'] }} · {{ $row['titolo'] }}</option>
@endforeach
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Titolo da archiviare</label>
<input type="text" wire:model.defer="physicalArchiveForm.titolo" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Contratto ascensore ORONA 2024" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Tipologia elemento</label>
<select wire:model.live="physicalArchiveForm.tipo_item" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->physicalArchiveTypeOptions as $typeKey => $typeLabel)
<option value="{{ $typeKey }}">{{ $typeLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Supporto iniziale</label>
<select wire:model.defer="physicalArchiveForm.supporto_fisico" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Nessun supporto</option>
@foreach($this->archivioFisicoTypes as $type)
<option value="{{ $type }}">{{ $type }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Documento digitale collegato</label>
<select wire:model.defer="physicalArchiveForm.documento_id" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Nessun documento digitale</option>
@foreach($this->physicalArchiveDocumentOptions as $documentId => $documentLabel)
<option value="{{ $documentId }}">{{ $documentLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Contenitore padre</label>
<select wire:model.defer="physicalArchiveForm.parent_id" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Radice archivio</option>
@foreach($this->physicalArchiveParentOptions as $parentId => $parentLabel)
<option value="{{ $parentId }}">{{ $parentLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Etichetta</label>
<select wire:model.defer="physicalArchiveForm.modello_etichetta" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
@foreach($this->getModelliEtichettaOptions() as $labelKey => $labelText)
<option value="{{ $labelKey }}">{{ $labelText }}</option>
@endforeach
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Nota</label>
<textarea wire:model.defer="physicalArchiveForm.note" rows="3" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Elenco fogli, riferimento verbale, contenuto della busta..."></textarea>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Magazzino</label>
<input type="text" wire:model.defer="physicalArchiveForm.magazzino" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. Sede EUR" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Scaffale</label>
<input type="text" wire:model.defer="physicalArchiveForm.scaffale" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. A3" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Ripiano</label>
<input type="text" wire:model.defer="physicalArchiveForm.ripiano" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. 2" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Dettaglio ubicazione</label>
<input type="text" wire:model.defer="physicalArchiveForm.ubicazione_dettaglio" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. lato finestra" />
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" wire:click="savePhysicalArchiveEntry" class="inline-flex items-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-slate-800">Registra voce fisica</button>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Movimentazione per codice</div>
<div class="mt-1 text-xs text-slate-500">Scansioni o inserisci il codice sorgente e il contenitore di arrivo per spostare intere buste o faldoni.</div>
</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Codice sorgente</label>
<input type="text" wire:model.defer="physicalArchiveMoveForm.source_code" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. AF-20-0001" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Codice destinazione</label>
<input type="text" wire:model.defer="physicalArchiveMoveForm.target_code" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Es. AF-20-0010" />
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" wire:click="movePhysicalArchiveItem" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:border-slate-400 hover:bg-slate-50">Registra movimentazione</button>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Contenitori di primo livello</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
@forelse($this->physicalArchiveContainerTree as $item)
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">{{ $item['codice_univoco'] }}</div>
<div class="mt-1 text-sm text-slate-700">{{ $item['titolo'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $item['tipo'] }} · {{ $item['children_count'] }} elementi interni</div>
</div>
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-6 text-sm text-slate-500 sm:col-span-2 xl:col-span-3">Nessun contenitore creato ancora per lo stabile attivo.</div>
@endforelse
</div>
</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 class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Standard etichette</div>
<div class="mt-3 space-y-3 text-sm text-slate-600">
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Modelli attivi: <span class="font-semibold text-slate-900">11354</span> e <span class="font-semibold text-slate-900">99014</span>.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Formato suggerito: <span class="font-semibold text-slate-900">{{ $this->mnemonicCodeHint ?? 'STB00000' }}</span> · contenuto · anno · busta · fascicolo.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">QR in stampa su etichetta e predisposizione NFC per scatole e fascicoli.</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">Formato data archivio: <span class="font-semibold text-slate-900">AAAA-MM-GG</span> o <span class="font-semibold text-slate-900">YYYYMMDD</span> per aggancio automatico ai file gia codificati.</div>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-slate-900">Registro e ricerca</div>
<div class="text-xs text-slate-500">Titolo manuale, ricerca testuale, contenimento annidato</div>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<input type="text" wire:model.live.debounce.300ms="physicalArchiveSearch" class="w-full rounded-lg border-slate-200 text-sm shadow-sm" placeholder="Cerca per titolo, codice, nota..." />
<select wire:model.live="physicalArchiveTypeFilter" class="w-full rounded-lg border-slate-200 text-sm shadow-sm">
<option value="">Tutte le tipologie</option>
@foreach($this->physicalArchiveTypeOptions as $typeKey => $typeLabel)
<option value="{{ $typeKey }}">{{ $typeLabel }}</option>
@endforeach
</select>
</div>
<div class="mt-4 space-y-3">
@forelse($this->physicalArchiveRows as $row)
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-slate-900 px-2.5 py-1 text-[11px] font-semibold text-white">{{ $row['codice_univoco'] }}</span>
<span class="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-slate-700">{{ $row['tipo'] }}</span>
<span class="text-xs text-slate-500">{{ $row['data_archiviazione'] }}</span>
</div>
<div class="mt-2 text-sm font-semibold text-slate-900">{{ $row['titolo'] }}</div>
@if($row['voce'] !== '')
<div class="mt-1 text-xs text-slate-500">{{ $row['voce'] }}</div>
@endif
<div class="mt-1 text-xs text-slate-500">Dentro: {{ $row['parent'] }}</div>
<div class="mt-1 text-xs text-slate-500">Percorso: {{ $row['percorso'] }}</div>
@if($row['ubicazione'] !== '')
<div class="mt-1 text-xs text-slate-500">Ubicazione: {{ $row['ubicazione'] }}</div>
@endif
@if($row['documento'])
<div class="mt-1 text-xs text-sky-700">Digitale collegato: {{ $row['documento'] }}</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<a href="{{ route('filament.archivio-fisico.etichetta', ['item' => $row['id']]) }}?format=11354&autoprint=1" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50">11354</a>
<a href="{{ route('filament.archivio-fisico.etichetta', ['item' => $row['id']]) }}?format=99014&autoprint=1" target="_blank" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50">99014</a>
</div>
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-6 text-sm text-slate-500">Nessuna voce archivio fisico trovata con i filtri correnti.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div class="text-sm font-semibold text-amber-900">Uso operativo</div>
<div class="mt-2 text-sm text-amber-800">Puoi registrare direttamente un pezzo di carta senza scansione, poi metterlo in una busta etichettata, spostare la busta in un faldone e il faldone in una scatola usando solo i codici stampati.</div>
</div>
</div>
</div>
</x-filament::section>

View File

@ -0,0 +1,104 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Etichetta Archivio Fisico</title>
@php($fmt = in_array(($format ?? '11354'), ['11354','99014'], true) ? $format : '11354')
@php($dymoFix = isset($dymoFix) ? (bool) $dymoFix : false)
@php($dymoRot = isset($dymoRot) ? (int) $dymoRot : 0)
@php($dymoDx = isset($dymoDx) ? (float) $dymoDx : 0.0)
@php($dymoDy = isset($dymoDy) ? (float) $dymoDy : 0.0)
@php($autoPrint = request()->has('autoprint') && filter_var(request()->query('autoprint'), FILTER_VALIDATE_BOOL))
<style>
@page { margin: 0; size: {{ $fmt === '99014' ? '54mm 101mm' : '57mm 32mm' }}; }
html, body {
margin: 0;
padding: 0;
width: {{ $fmt === '99014' ? '54mm' : '57mm' }};
height: {{ $fmt === '99014' ? '101mm' : '32mm' }};
font-family: Arial, Helvetica, sans-serif;
overflow: hidden;
}
.label { box-sizing: border-box; width: 100%; height: 100%; padding: {{ $fmt === '99014' ? '3.5mm' : '2mm' }}; }
.panel {
width: 100%;
height: 100%;
border: 0.35mm solid #111;
box-sizing: border-box;
display: grid;
grid-template-columns: {{ $fmt === '99014' ? '1fr 18mm' : '1fr 10.8mm' }};
gap: 2mm;
padding: {{ $fmt === '99014' ? '2.4mm' : '1.5mm' }};
align-items: stretch;
}
.content { min-width: 0; }
.code { font-weight: 800; font-size: {{ $fmt === '99014' ? '14px' : '10px' }}; letter-spacing: 0.4px; }
.title { font-weight: 700; font-size: {{ $fmt === '99014' ? '13px' : '9px' }}; line-height: 1.15; margin-top: 1mm; }
.meta { font-size: {{ $fmt === '99014' ? '10px' : '7px' }}; line-height: 1.2; color: #222; margin-top: 1.1mm; }
.qr {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-width: 0;
min-height: 0;
padding: 0.4mm;
box-sizing: border-box;
}
.qr svg,
.qr img {
display: block;
width: 100% !important;
max-width: 100%;
max-height: 100%;
height: auto !important;
}
.qr svg {
aspect-ratio: 1 / 1;
}
.path { font-size: {{ $fmt === '99014' ? '9px' : '6.6px' }}; line-height: 1.2; color: #444; margin-top: 1mm; word-break: break-word; }
@media print {
html, body { width: 100%; height: 100%; }
.label { transform: translate({{ $dymoDx }}mm, {{ $dymoDy }}mm) rotate({{ $dymoRot }}deg); transform-origin: top left; }
@if($fmt === '11354' && $dymoFix)
@page { size: 32mm 57mm; }
html, body { width: 32mm; height: 57mm; }
.label { width: 57mm; height: 32mm; transform: translate({{ $dymoDx }}mm, calc(57mm + {{ $dymoDy }}mm)) rotate(-90deg); transform-origin: top left; }
@endif
}
</style>
@if($autoPrint)
<script>
window.addEventListener('load', () => setTimeout(() => { try { window.print(); } catch (e) {} }, 80));
window.onafterprint = () => { try { window.history.back(); } catch (e) {} };
</script>
@endif
</head>
<body>
<div class="label">
<div class="panel">
<div class="content">
<div class="code">{{ $item->codice_univoco }}</div>
<div class="title">{{ $item->titolo }}</div>
<div class="meta">
<div><strong>Tipo:</strong> {{ $item->tipo_label }}</div>
@if($item->voce_numero || $item->voce_titolo)
<div><strong>Voce:</strong> {{ $item->voce_numero ? 'Voce ' . $item->voce_numero : '' }} {{ $item->voce_titolo }}</div>
@endif
@if($item->parent)
<div><strong>Dentro:</strong> {{ $item->parent->codice_univoco }}</div>
@endif
@if($item->documento)
<div><strong>Digitale:</strong> {{ $item->documento->nome_file ?: $item->documento->nome }}</div>
@endif
</div>
<div class="path">{{ $item->percorso_fisico }}</div>
</div>
<div class="qr">
{!! $item->qr_code_image !!}
</div>
</div>
</div>
</body>
</html>

View File

@ -590,6 +590,8 @@
->name('filament.documenti-stabili.download');
Route::get('documenti/{documento}/etichetta', [\App\Http\Controllers\Filament\DocumentiPrintController::class, 'etichetta'])
->name('filament.documenti.etichetta');
Route::get('archivio-fisico/{item}/etichetta', [\App\Http\Controllers\Filament\DocumentiPrintController::class, 'etichettaArchivioFisico'])
->name('filament.archivio-fisico.etichetta');
Route::get('import-inbox/download', [\App\Http\Controllers\Filament\ImportInboxController::class, 'download'])
->name('filament.import-inbox.download');
@ -725,6 +727,9 @@
// --- AUTHENTICATION ROUTES ---
require __DIR__ . '/auth.php';
Route::middleware('signed')->get('/archivio-fisico/{item}', [\App\Http\Controllers\Filament\DocumentiPrintController::class, 'showPublicArchivioFisicoItem'])
->name('public.archivio-fisico.show');
Route::middleware(['auth'])->group(function () {
Route::view('/contabilita/registrazione-test', 'contabilita.registrazione-test')
->name('contabilita.registrazione-test');