Fix document ACL and clean up post-it view
This commit is contained in:
parent
e462a0b033
commit
1c09aae635
|
|
@ -4,6 +4,11 @@ # Changelog
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
- 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.
|
||||
- Added actionable banking reconciliation for tenant rents and supplier payments directly from the MPS movement queue, with automatic operational matching metadata and supplier RA register synchronization when a withholding-bearing supplier invoice is paid.
|
||||
- Normalized module manifest handling so active modules can expose permissions, menu items, and metadata consistently even when their `module.json` files use different shapes.
|
||||
- Added a dedicated single-node Docker install path for local NetGescon distribution tests, with a local-only compose stack, node env example, bootstrap script, backup restore script, and an Ubuntu runbook for restoring a site backup onto a fresh machine.
|
||||
- Consolidated the banking workflow around Contabilita > Casse e banche > Movimenti, now used as a 4-tab hub for account overview, official balances and statement PDFs, imported bank ledger movements, and first-pass reconciliation candidates.
|
||||
- Moved official bank balance and statement management out of Situazione iniziale, fixed bank document URLs to use storage-backed paths, and anchored balance reconstruction on `conto_id` so legacy accounts without IBAN still reconcile correctly.
|
||||
- Added stable-level Documentazione, Posta ufficiale and Protocollo comunicazioni tabs, plus a real admin-wide Comunicazioni regia page reusing Google accounts, Gmail import, Drive templates and protocol records.
|
||||
|
|
|
|||
|
|
@ -410,6 +410,12 @@ private function getDocumentoFormSchema(): array
|
|||
->options(array_combine($this->audienceGroups, $this->audienceGroups))
|
||||
->multiple()
|
||||
->native(false),
|
||||
Select::make('allowed_user_ids')
|
||||
->label('Utenti specifici ammessi')
|
||||
->options($this->getCollaboratorUserOptions())
|
||||
->multiple()
|
||||
->native(false)
|
||||
->searchable(),
|
||||
FileUpload::make('pdf')
|
||||
->label('PDF')
|
||||
->disk('local')
|
||||
|
|
@ -792,6 +798,7 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void
|
|||
'visibility_scope' => (string) ($data['visibility_scope'] ?? 'interno'),
|
||||
'visibility_roles' => $this->normalizeStringArray($data['visibility_roles'] ?? []),
|
||||
'visibility_groups' => $this->normalizeStringArray($data['visibility_groups'] ?? []),
|
||||
'allowed_user_ids' => $this->normalizeIntArray($data['allowed_user_ids'] ?? []),
|
||||
'data_upload' => now(),
|
||||
'note' => $note,
|
||||
'is_demo' => $isDemo,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
|
||||
use App\Models\ChiamataPostIt;
|
||||
use App\Models\CommunicationMessage;
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\PbxClickToCallRequest;
|
||||
use App\Models\RubricaUniversale;
|
||||
use App\Models\Ticket;
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\StabileContext;
|
||||
use UnitEnum;
|
||||
|
||||
class PostItGestione extends Page
|
||||
|
|
@ -69,6 +71,16 @@ class PostItGestione extends Page
|
|||
|
||||
public ?int $selectedAppuntoId = null;
|
||||
|
||||
public ?string $selectedAppuntoOggetto = null;
|
||||
|
||||
public ?string $selectedAppuntoNota = null;
|
||||
|
||||
public string $appuntoAssegnazioneTipo = 'operatore';
|
||||
|
||||
public ?int $appuntoAssegnazioneUserId = null;
|
||||
|
||||
public ?int $appuntoAssegnazioneFornitoreId = null;
|
||||
|
||||
/** @var array<int,string> */
|
||||
public array $riaperturaNote = [];
|
||||
|
||||
|
|
@ -87,6 +99,9 @@ class PostItGestione extends Page
|
|||
/** @var array<int,string> */
|
||||
private array $monitoredResponseGroups = ['601', '603'];
|
||||
|
||||
/** @var array<int,string> */
|
||||
private array $hiddenVisualizationResponseGroups = ['601'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$focus = (int) request()->query('focus_post_it', 0);
|
||||
|
|
@ -100,6 +115,12 @@ public function mount(): void
|
|||
public function selectAppunto(int $postItId): void
|
||||
{
|
||||
$this->selectedAppuntoId = $postItId > 0 ? $postItId : null;
|
||||
$this->loadSelectedAppuntoState();
|
||||
}
|
||||
|
||||
public function updatedSelectedAppuntoId(): void
|
||||
{
|
||||
$this->loadSelectedAppuntoState();
|
||||
}
|
||||
|
||||
public function getPostItInserimentoUrl(): string
|
||||
|
|
@ -245,6 +266,101 @@ public function riapriPostIt(int $postItId): void
|
|||
->send();
|
||||
}
|
||||
|
||||
public function saveSelectedAppunto(): void
|
||||
{
|
||||
if (! $this->isPostItTableReady() || ! $this->selectedAppuntoId) {
|
||||
Notification::make()->title('Seleziona prima un appunto')->warning()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'selectedAppuntoOggetto' => ['nullable', 'string', 'max:255'],
|
||||
'selectedAppuntoNota' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$postIt = ChiamataPostIt::query()->find($this->selectedAppuntoId);
|
||||
if (! $postIt) {
|
||||
Notification::make()->title('Post-it non trovato')->danger()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$postIt->oggetto = $this->normalizeOptionalText($this->selectedAppuntoOggetto);
|
||||
$postIt->nota = $this->normalizeOptionalText($this->selectedAppuntoNota);
|
||||
$postIt->save();
|
||||
|
||||
Notification::make()->title('Appunto aggiornato')->success()->send();
|
||||
}
|
||||
|
||||
public function assegnaSelectedAppunto(): void
|
||||
{
|
||||
if (! $this->isPostItAssignmentReady() || ! $this->selectedAppuntoId) {
|
||||
Notification::make()->title('Assegnazione non disponibile')->warning()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$postIt = ChiamataPostIt::query()->find($this->selectedAppuntoId);
|
||||
if (! $postIt) {
|
||||
Notification::make()->title('Post-it non trovato')->danger()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'appuntoAssegnazioneTipo' => ['required', 'in:operatore,collaboratore,fornitore'],
|
||||
'appuntoAssegnazioneUserId' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'appuntoAssegnazioneFornitoreId' => ['nullable', 'integer', 'exists:fornitori,id'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'assegnazione_tipo' => $this->appuntoAssegnazioneTipo,
|
||||
'assegnato_a_user_id' => null,
|
||||
'assegnato_a_fornitore_id' => null,
|
||||
];
|
||||
|
||||
if (in_array($this->appuntoAssegnazioneTipo, ['operatore', 'collaboratore'], true)) {
|
||||
if (! $this->appuntoAssegnazioneUserId) {
|
||||
Notification::make()->title('Seleziona un utente')->warning()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['assegnato_a_user_id'] = $this->appuntoAssegnazioneUserId;
|
||||
}
|
||||
|
||||
if ($this->appuntoAssegnazioneTipo === 'fornitore') {
|
||||
if (! $this->appuntoAssegnazioneFornitoreId) {
|
||||
Notification::make()->title('Seleziona un fornitore')->warning()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['assegnato_a_fornitore_id'] = $this->appuntoAssegnazioneFornitoreId;
|
||||
}
|
||||
|
||||
$postIt->update($payload);
|
||||
|
||||
if ($postIt->ticket) {
|
||||
$ticketPayload = [];
|
||||
|
||||
if ($this->appuntoAssegnazioneTipo === 'fornitore') {
|
||||
$ticketPayload['assegnato_a_fornitore_id'] = $this->appuntoAssegnazioneFornitoreId;
|
||||
$ticketPayload['assegnato_a_user_id'] = null;
|
||||
} else {
|
||||
$ticketPayload['assegnato_a_user_id'] = $this->appuntoAssegnazioneUserId;
|
||||
}
|
||||
|
||||
if ($ticketPayload !== []) {
|
||||
$postIt->ticket->update($ticketPayload);
|
||||
}
|
||||
|
||||
if (method_exists($postIt->ticket, 'messages') && Schema::hasTable('ticket_messages')) {
|
||||
$postIt->ticket->messages()->create([
|
||||
'user_id' => Auth::id(),
|
||||
'messaggio' => 'Assegnazione da Gestione Post-it: ' . $this->getSelectedAppuntoAssignmentLabel(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()->title('Assegnazione aggiornata')->success()->send();
|
||||
}
|
||||
|
||||
public function getRecentiProperty()
|
||||
{
|
||||
if (! $this->isPostItTableReady()) {
|
||||
|
|
@ -280,6 +396,10 @@ private function shouldShowPostIt(ChiamataPostIt $postIt): bool
|
|||
return false;
|
||||
}
|
||||
|
||||
if ($this->isHiddenVisualizationResponseGroupPostIt($postIt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $this->isLegacyFilteredSmdrPostIt($postIt);
|
||||
}
|
||||
|
||||
|
|
@ -412,6 +532,62 @@ public function getSelectedAppuntoProperty(): ?ChiamataPostIt
|
|||
->find($this->selectedAppuntoId);
|
||||
}
|
||||
|
||||
public function isPostItAssignmentReady(): bool
|
||||
{
|
||||
return $this->isPostItTableReady()
|
||||
&& Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo')
|
||||
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id')
|
||||
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id');
|
||||
}
|
||||
|
||||
public function getOperatoriOptionsProperty(): array
|
||||
{
|
||||
return User::query()
|
||||
->role(['super-admin', 'admin', 'amministratore'])
|
||||
->orderBy('name')
|
||||
->limit(100)
|
||||
->pluck('name', 'id')
|
||||
->map(fn($name): string => (string) $name)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getCollaboratoriOptionsProperty(): array
|
||||
{
|
||||
$adminId = $this->resolveCurrentAmministratoreId();
|
||||
|
||||
return User::query()
|
||||
->whereHas('roles', function ($q): void {
|
||||
$q->where('name', 'collaboratore');
|
||||
})
|
||||
->when($adminId > 0, function ($query) use ($adminId): void {
|
||||
$query->whereHas('stabiliAssegnati', function ($inner) use ($adminId): void {
|
||||
$inner->where('amministratore_id', $adminId);
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit(150)
|
||||
->pluck('name', 'id')
|
||||
->map(fn($name): string => (string) $name)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getFornitoriOptionsProperty(): array
|
||||
{
|
||||
$adminId = $this->resolveCurrentAmministratoreId();
|
||||
|
||||
return Fornitore::query()
|
||||
->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId))
|
||||
->orderByRaw("COALESCE(ragione_sociale, CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))")
|
||||
->limit(150)
|
||||
->get(['id', 'ragione_sociale', 'nome', 'cognome'])
|
||||
->mapWithKeys(function (Fornitore $fornitore): array {
|
||||
$label = trim((string) ($fornitore->ragione_sociale ?: (($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? ''))));
|
||||
|
||||
return [(int) $fornitore->id => ($label !== '' ? $label : ('Fornitore #' . $fornitore->id))];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getOperationalNoteSummary(ChiamataPostIt $postIt, int $limit = 180): string
|
||||
{
|
||||
return Str::limit($this->extractOperationalNoteContent($postIt), $limit);
|
||||
|
|
@ -918,6 +1094,63 @@ private function normalizeGroupedCallerKey(string $value): string
|
|||
return $normalized !== '' ? $normalized : 'sconosciuto';
|
||||
}
|
||||
|
||||
private function loadSelectedAppuntoState(): void
|
||||
{
|
||||
$postIt = $this->selectedAppunto;
|
||||
if (! $postIt) {
|
||||
$this->selectedAppuntoOggetto = null;
|
||||
$this->selectedAppuntoNota = null;
|
||||
$this->appuntoAssegnazioneTipo = 'operatore';
|
||||
$this->appuntoAssegnazioneUserId = null;
|
||||
$this->appuntoAssegnazioneFornitoreId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedAppuntoOggetto = $postIt->oggetto;
|
||||
$this->selectedAppuntoNota = $postIt->nota;
|
||||
$this->appuntoAssegnazioneTipo = in_array((string) ($postIt->assegnazione_tipo ?? ''), ['operatore', 'collaboratore', 'fornitore'], true)
|
||||
? (string) $postIt->assegnazione_tipo
|
||||
: 'operatore';
|
||||
$this->appuntoAssegnazioneUserId = isset($postIt->assegnato_a_user_id) ? (int) $postIt->assegnato_a_user_id : null;
|
||||
$this->appuntoAssegnazioneFornitoreId = isset($postIt->assegnato_a_fornitore_id) ? (int) $postIt->assegnato_a_fornitore_id : null;
|
||||
}
|
||||
|
||||
private function getSelectedAppuntoAssignmentLabel(): string
|
||||
{
|
||||
return match ($this->appuntoAssegnazioneTipo) {
|
||||
'fornitore' => 'fornitore #' . (int) ($this->appuntoAssegnazioneFornitoreId ?? 0),
|
||||
'collaboratore' => 'collaboratore #' . (int) ($this->appuntoAssegnazioneUserId ?? 0),
|
||||
default => 'operatore #' . (int) ($this->appuntoAssegnazioneUserId ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveCurrentAmministratoreId(): int
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user instanceof User) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($user->amministratore) {
|
||||
return (int) $user->amministratore->id;
|
||||
}
|
||||
|
||||
$stabile = StabileContext::getActiveStabile($user);
|
||||
|
||||
return (int) ($stabile?->amministratore_id ?? 0);
|
||||
}
|
||||
|
||||
private function normalizeOptionalText(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim($value);
|
||||
|
||||
return $text !== '' ? $text : null;
|
||||
}
|
||||
|
||||
private function normalizeGroupedPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?: '';
|
||||
|
|
@ -1390,6 +1623,28 @@ private function isMonitoredResponseGroupExtension(?string $extension): bool
|
|||
return in_array($extension, $this->monitoredResponseGroups, true);
|
||||
}
|
||||
|
||||
private function isHiddenVisualizationResponseGroupPostIt(ChiamataPostIt $postIt): bool
|
||||
{
|
||||
$message = $this->resolveMessageFromPostIt($postIt);
|
||||
if (! $message instanceof CommunicationMessage || (string) $message->channel !== 'smdr') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extensions = array_values(array_unique(array_filter([
|
||||
$this->normalizeExtension((string) ($message->target_extension ?? '')),
|
||||
$this->normalizeExtension((string) data_get($message->metadata, 'smdr.extension', '')),
|
||||
$this->normalizeExtension((string) data_get($message->metadata, 'smdr.target_extension', '')),
|
||||
])));
|
||||
|
||||
foreach ($extensions as $extension) {
|
||||
if (in_array($extension, $this->hiddenVisualizationResponseGroups, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getPostItCallStateMeta(ChiamataPostIt $postIt): array
|
||||
{
|
||||
if ($this->isMissedPostIt($postIt)) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class Documento extends Model
|
|||
'visibility_scope',
|
||||
'visibility_groups',
|
||||
'visibility_roles',
|
||||
'allowed_user_ids',
|
||||
'approvato',
|
||||
'archiviato',
|
||||
'urgente',
|
||||
|
|
@ -78,6 +79,7 @@ class Documento extends Model
|
|||
'metadati_ocr' => 'array',
|
||||
'visibility_groups' => 'array',
|
||||
'visibility_roles' => 'array',
|
||||
'allowed_user_ids' => 'array',
|
||||
'dimensione_file' => 'integer',
|
||||
'data_documento' => 'date',
|
||||
'data_scadenza' => 'date',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\User;
|
||||
use App\Support\StabileContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DocumentoArchivioVisibilityService
|
||||
{
|
||||
|
|
@ -94,35 +95,67 @@ public function canAccessDocumentoStabile(User $user, DocumentoStabile $document
|
|||
|
||||
private function applyVisibilityConstraint(Builder $query, User $user, string $table): void
|
||||
{
|
||||
if (! $this->hasVisibilityColumn($table, 'visibility_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roles = $user->getRoleNames()->map(fn($role): string => (string) $role)->values()->all();
|
||||
$groupLabels = $this->resolveAudienceGroupsForUser($user);
|
||||
$userId = (int) $user->id;
|
||||
$hasRolesColumn = $this->hasVisibilityColumn($table, 'visibility_roles');
|
||||
$hasGroupsColumn = $this->hasVisibilityColumn($table, 'visibility_groups');
|
||||
$hasAllowedUsersColumn = $this->hasVisibilityColumn($table, 'allowed_user_ids');
|
||||
|
||||
$query->where(function (Builder $visibilityQuery) use ($table, $roles, $groupLabels, $userId): void {
|
||||
$query->where(function (Builder $visibilityQuery) use ($table, $roles, $groupLabels, $userId, $hasRolesColumn, $hasGroupsColumn, $hasAllowedUsersColumn): void {
|
||||
$visibilityQuery->whereNull($table . '.visibility_scope')
|
||||
->orWhere($table . '.visibility_scope', 'interno')
|
||||
->orWhere($table . '.visibility_scope', 'studio')
|
||||
->orWhere(function (Builder $restrictedQuery) use ($table, $roles, $groupLabels, $userId): void {
|
||||
->orWhere(function (Builder $restrictedQuery) use ($table, $roles, $groupLabels, $userId, $hasRolesColumn, $hasGroupsColumn, $hasAllowedUsersColumn): void {
|
||||
$restrictedQuery->where($table . '.visibility_scope', 'restricted')
|
||||
->where(function (Builder $matchQuery) use ($table, $roles, $groupLabels, $userId): void {
|
||||
if ($roles !== []) {
|
||||
->where(function (Builder $matchQuery) use ($table, $roles, $groupLabels, $userId, $hasRolesColumn, $hasGroupsColumn, $hasAllowedUsersColumn): void {
|
||||
$hasAudienceConstraint = false;
|
||||
|
||||
if ($hasRolesColumn && $roles !== []) {
|
||||
foreach ($roles as $role) {
|
||||
$matchQuery->orWhereJsonContains($table . '.visibility_roles', $role);
|
||||
}
|
||||
|
||||
$hasAudienceConstraint = true;
|
||||
}
|
||||
|
||||
if ($groupLabels !== []) {
|
||||
if ($hasGroupsColumn && $groupLabels !== []) {
|
||||
foreach ($groupLabels as $groupLabel) {
|
||||
$matchQuery->orWhereJsonContains($table . '.visibility_groups', $groupLabel);
|
||||
}
|
||||
|
||||
$hasAudienceConstraint = true;
|
||||
}
|
||||
|
||||
$matchQuery->orWhereJsonContains($table . '.allowed_user_ids', $userId);
|
||||
if ($hasAllowedUsersColumn) {
|
||||
$matchQuery->orWhereJsonContains($table . '.allowed_user_ids', $userId);
|
||||
$hasAudienceConstraint = true;
|
||||
}
|
||||
|
||||
if (! $hasAudienceConstraint) {
|
||||
$matchQuery->whereRaw('1 = 0');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private function hasVisibilityColumn(string $table, string $column): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
$cacheKey = $table . '.' . $column;
|
||||
if (! array_key_exists($cacheKey, $cache)) {
|
||||
$cache[$cacheKey] = Schema::hasColumn($table, $column);
|
||||
}
|
||||
|
||||
return $cache[$cacheKey];
|
||||
}
|
||||
|
||||
private function resolveAudienceGroupsForUser(User $user): array
|
||||
{
|
||||
$groups = [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?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
|
||||
{
|
||||
Schema::table('documenti', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('documenti', 'allowed_user_ids')) {
|
||||
$table->json('allowed_user_ids')->nullable()->after('visibility_roles');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('documenti', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('documenti', 'allowed_user_ids')) {
|
||||
$table->dropColumn('allowed_user_ids');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
153
docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md
Normal file
153
docs/DOCUMENTI-NFC-E-ACQUISIZIONE.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Documenti, NFC e acquisizione
|
||||
|
||||
## Stato attuale archivio documenti
|
||||
|
||||
La pagina Strumenti > Documenti gestisce gia un flusso rapido di archiviazione:
|
||||
|
||||
- selezione stabile;
|
||||
- scelta cartella archivio con ACL;
|
||||
- categoria protocollo e titolo;
|
||||
- metadati principali del documento;
|
||||
- upload PDF;
|
||||
- protocollo progressivo automatico;
|
||||
- collegamento eventuale a movimento contabile;
|
||||
- indicazione faldone fisico per l'archivio cartaceo.
|
||||
|
||||
Dopo il salvataggio il documento viene gia memorizzato con:
|
||||
|
||||
- file PDF archiviato;
|
||||
- protocollo interno;
|
||||
- cartella logica;
|
||||
- visibilita per ruolo, gruppo e utente singolo;
|
||||
- estrazione testo best-effort per ricerca tramite `PdfTextExtractionService`.
|
||||
|
||||
## Indicizzazione e OCR
|
||||
|
||||
La base applicativa e gia predisposta per:
|
||||
|
||||
- testo OCR o testo estratto dal PDF;
|
||||
- metadati OCR;
|
||||
- ricerca documentale su contenuto;
|
||||
- classificazione guidata con cartelle e categorie.
|
||||
|
||||
La parte da verificare operativamente e la qualita reale dell'estrazione sui documenti scansionati e sui PDF immagine. Il prossimo test utile e:
|
||||
|
||||
1. caricare un PDF nativo;
|
||||
2. caricare un PDF da scanner;
|
||||
3. verificare contenuto OCR salvato e ricercabilita.
|
||||
|
||||
## PDF watermark e firma
|
||||
|
||||
Fattibile lato server:
|
||||
|
||||
- watermark visivo con protocollo, stabile, data e utente;
|
||||
- timbro di archiviazione;
|
||||
- overlay su PDF acquisiti;
|
||||
- ristampa etichetta o frontespizio.
|
||||
|
||||
Da distinguere dalla firma digitale vera:
|
||||
|
||||
- watermark: semplice e interno, subito fattibile;
|
||||
- firma PAdES/CAdES: richiede certificato, firma remota o dispositivo di firma.
|
||||
|
||||
Quindi la strada pragmatica e:
|
||||
|
||||
1. watermark/timbro immediato per i PDF archiviati;
|
||||
2. firma digitale solo quando decidiamo provider o certificato operativo.
|
||||
|
||||
## Short link con bcards.it
|
||||
|
||||
Il dominio `bcards.it` si presta bene a generare short link per:
|
||||
|
||||
- apertura rapida cartella archivio;
|
||||
- apertura scheda stabile;
|
||||
- apertura documento o checklist;
|
||||
- accesso a presenza assemblea;
|
||||
- accesso a votazione assemblea;
|
||||
- accesso a pagina di scansione o acquisizione.
|
||||
|
||||
Architettura consigliata:
|
||||
|
||||
- short code univoco nel dominio corto;
|
||||
- redirect verso URL NetGescon firmata o tokenizzata;
|
||||
- scadenza opzionale;
|
||||
- tracciamento accessi;
|
||||
- associazione a tag NFC o QR.
|
||||
|
||||
## iPhone e autenticazione dispositivo
|
||||
|
||||
Safari su iPhone non espone seriale hardware, IMEI o MAC address alla pagina web.
|
||||
|
||||
La soluzione corretta e:
|
||||
|
||||
- primo accesso con login normale;
|
||||
- enrollment del dispositivo;
|
||||
- passkey/WebAuthn o token revocabile;
|
||||
- NFC che apre short link;
|
||||
- il browser completa l'autenticazione con la credenziale gia registrata.
|
||||
|
||||
## ACR122U e tag NFC da PC
|
||||
|
||||
Con il lettore/scrittore ACR122U su PC si puo lavorare, ma non in modo affidabile da una semplice pagina web pura per tutti i browser.
|
||||
|
||||
Strade realistiche:
|
||||
|
||||
- Chrome/Edge desktop con WebUSB/WebHID: possibile solo in alcuni scenari, non ideale come base di produzione;
|
||||
- bridge locale su PC con PC/SC o libnfc: soluzione robusta;
|
||||
- pagina web NetGescon che dialoga con un piccolo servizio locale: soluzione consigliata.
|
||||
|
||||
Uso previsto:
|
||||
|
||||
- leggere UID o NDEF del tag;
|
||||
- scrivere short link `bcards.it/...`;
|
||||
- associare il tag a faldone, cartella, documento o stabile;
|
||||
- richiamare in un click la scheda giusta.
|
||||
|
||||
## Presenze e votazioni assemblea
|
||||
|
||||
Fattibile nello stesso impianto:
|
||||
|
||||
- convocazione assemblea;
|
||||
- check-in presenza via link corto, QR o NFC;
|
||||
- conteggio deleghe e millesimi presenti;
|
||||
- apertura votazioni per punto ODG;
|
||||
- raccolta voto da device autenticato;
|
||||
- verbale e report finale.
|
||||
|
||||
Per farlo bene conviene usare due livelli:
|
||||
|
||||
- regia assemblea da desktop operatore;
|
||||
- accesso rapido da smartphone per presenza e voto quando previsto.
|
||||
|
||||
## Scanner Epson da pagina web
|
||||
|
||||
Pilotare uno scanner Epson direttamente da una pagina web standard non e generalmente affidabile.
|
||||
|
||||
Le strade reali sono:
|
||||
|
||||
- scanner di rete che salva in cartella o invia via mail;
|
||||
- agente locale Windows che usa TWAIN/WIA/SDK Epson;
|
||||
- pagina web che invia il comando al servizio locale e acquisisce il PDF risultante.
|
||||
|
||||
Per operativita immediata la soluzione piu semplice e:
|
||||
|
||||
1. scansione verso cartella controllata;
|
||||
2. upload/ingestione rapida in NetGescon;
|
||||
3. OCR e archiviazione automatica.
|
||||
|
||||
## Dymo LAN 11354
|
||||
|
||||
Domani si puo provare una stampa etichetta su rete con formato `11354` usando:
|
||||
|
||||
- template HTML/PDF per etichetta;
|
||||
- spool di stampa verso stampante LAN;
|
||||
- contenuti minimi: codice stabile, tipo archivio, faldone/cartella, short link o QR.
|
||||
|
||||
## Prossimi passi consigliati
|
||||
|
||||
1. test reale OCR e ricerca su 2-3 PDF campione;
|
||||
2. attivazione watermark PDF in archiviazione;
|
||||
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.
|
||||
|
|
@ -189,7 +189,22 @@
|
|||
|
||||
<div class="rounded-xl border bg-white p-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Appunto completo</div>
|
||||
<div class="mt-2 whitespace-pre-wrap text-sm text-slate-800">{{ $this->selectedAppunto->nota ?: '—' }}</div>
|
||||
<div class="mt-3 space-y-3">
|
||||
<label class="block text-xs text-slate-600">
|
||||
<span class="mb-1 block font-semibold text-slate-700">Oggetto</span>
|
||||
<input type="text" wire:model.defer="selectedAppuntoOggetto" class="w-full rounded-lg border-gray-300 text-sm" />
|
||||
</label>
|
||||
<label class="block text-xs text-slate-600">
|
||||
<span class="mb-1 block font-semibold text-slate-700">Appunto completo</span>
|
||||
<textarea wire:model.defer="selectedAppuntoNota" rows="8" class="w-full rounded-lg border-gray-300 text-sm"></textarea>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::button size="sm" color="primary" wire:click="saveSelectedAppunto">Salva appunto completo</x-filament::button>
|
||||
@if(! $this->selectedAppunto->ticket_id && $this->selectedAppunto->stato !== 'chiusa')
|
||||
<x-filament::button size="sm" color="success" wire:click="convertiInTicket({{ (int) $this->selectedAppunto->id }})">Converti in ticket</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
|
|
@ -203,6 +218,57 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if($this->isPostItAssignmentReady())
|
||||
<div class="rounded-xl border bg-white p-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Assegnazione</div>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<label class="block text-xs text-slate-600 sm:col-span-2">
|
||||
<span class="mb-1 block font-semibold text-slate-700">Tipo assegnazione</span>
|
||||
<select wire:model.live="appuntoAssegnazioneTipo" class="w-full rounded-lg border-gray-300 text-sm">
|
||||
<option value="operatore">Operatore</option>
|
||||
<option value="collaboratore">Collaboratore dello studio</option>
|
||||
<option value="fornitore">Fornitore</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@if($appuntoAssegnazioneTipo === 'operatore')
|
||||
<label class="block text-xs text-slate-600 sm:col-span-2">
|
||||
<span class="mb-1 block font-semibold text-slate-700">Operatore</span>
|
||||
<select wire:model="appuntoAssegnazioneUserId" class="w-full rounded-lg border-gray-300 text-sm">
|
||||
<option value="">Seleziona operatore</option>
|
||||
@foreach($this->operatoriOptions as $id => $label)
|
||||
<option value="{{ $id }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
@elseif($appuntoAssegnazioneTipo === 'collaboratore')
|
||||
<label class="block text-xs text-slate-600 sm:col-span-2">
|
||||
<span class="mb-1 block font-semibold text-slate-700">Collaboratore</span>
|
||||
<select wire:model="appuntoAssegnazioneUserId" class="w-full rounded-lg border-gray-300 text-sm">
|
||||
<option value="">Seleziona collaboratore</option>
|
||||
@foreach($this->collaboratoriOptions as $id => $label)
|
||||
<option value="{{ $id }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
@else
|
||||
<label class="block text-xs text-slate-600 sm:col-span-2">
|
||||
<span class="mb-1 block font-semibold text-slate-700">Fornitore</span>
|
||||
<select wire:model="appuntoAssegnazioneFornitoreId" class="w-full rounded-lg border-gray-300 text-sm">
|
||||
<option value="">Seleziona fornitore</option>
|
||||
@foreach($this->fornitoriOptions as $id => $label)
|
||||
<option value="{{ $id }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<x-filament::button size="sm" color="primary" wire:click="assegnaSelectedAppunto">Salva assegnazione</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ $this->getPostItPageUrl((int) $this->selectedAppunto->id) }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Apri scheda Post-it</a>
|
||||
@if($this->selectedAppunto->rubrica)
|
||||
|
|
@ -226,7 +292,7 @@
|
|||
<p class="text-sm text-amber-800">Qui vedi i Post-it raggruppati per numero chiamante. Le chiamate interne restano nel database ma non vengono visualizzate.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
||||
@forelse($this->recentiRaggruppati as $gruppo)
|
||||
@php($box = $gruppo['latest'])
|
||||
<article class="flex min-h-[320px] flex-col rounded-2xl border border-amber-200 bg-[#fff4b8] p-4 shadow-sm">
|
||||
|
|
@ -306,7 +372,7 @@
|
|||
<p class="text-sm text-slate-700">I Post-it chiusi restano collegati al numero o al nominativo e possono essere riaperti quando serve. Anche qui le chiamate interne sono escluse.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
||||
@forelse($this->recentiChiusiRaggruppati as $gruppo)
|
||||
@php($box = $gruppo['latest'])
|
||||
<article class="flex min-h-[260px] flex-col rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user