feat: add protocol dashboard and local archive labels
This commit is contained in:
parent
e9cf216fa4
commit
8bc7783ce3
31
README.md
31
README.md
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
184
app/Models/DocumentoArchivioFisicoItem.php
Normal file
184
app/Models/DocumentoArchivioFisicoItem.php
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user