From 59c254a9c3e4df87272e171c4c9ab825810a71a3 Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Mon, 20 Apr 2026 20:30:11 +0200 Subject: [PATCH] feat(docs-picker): dedup per categoria + distinzione admin/azienda + riga doc 3-stati MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dopo test browser ampio con Carlo sono emersi 3 cose importanti: 1. LOGICA: il BE Spring Gepafin non implementa sostituzione automatica dei documenti stessa categoria — uploadFileForCompany fa saveAll puro, nessun softdelete del precedente, nessun UNIQUE constraint su (company_id, document_category_id). In sandbox risultavano 2 DURC VALID attivi simultaneamente. Mitigazione picker: dedup client-side per categoria, preferenza VALID > DUE, a parita id desc. 2. CATEGORIE: il BE ha 3 macro-tipi (CompanyDocumentTypeEnum): COMPANY_DOCUMENT (azienda — DURC/Visura/Bilancio), PERSONAL_DOCUMENT (amministratore/legale rappresentante — CI/CF/antiric), APPLICATION_DOCUMENT (legato a specifica application). Carlo aveva intuito giusto: admin vs azienda e la divisione personal vs company. Il picker ora fa 2 chiamate al BE (default retituisce COMPANY+APPLICATION, poi filter esplicito documentType=PERSONAL_DOCUMENT) e unisce i risultati con dedup. 3. UX RIGA DOCUMENTO: il layout che avevo fatto (FileUploadCell + Tag esterno + icon button) rompeva il flex causa nesting, tag e refresh andavano a capo. Separati 3 casi semantici puliti: - CASO A repository: riga custom con [icona pdf] filename [tag stato] [button Cambia] [button Rimuovi] — tutto orizzontale - CASO B upload PC: FileUploadCell standard (preview/download/refresh/delete) - CASO C vuoto: 2 pulsanti Carica dal PC / Scegli dal repository CAMBIAMENTI CompanyDocumentPicker.js (+197 -52): - dedupByCategory(docs) con ranking STATUS+id - doppia chiamata getCompanyDocuments (default + PERSONAL) - nuova colonna Origine con badge colorato (blu=Azienda, viola=Admin) - 3 pulsanti manuali per tab Origine (SelectButton PrimeReact aveva issues styling col tema Gepafin: label bianche invisibili sui non-selected) - ORIGIN_CFG con 3 varianti per COMPANY/PERSONAL/APPLICATION CAMBIAMENTI PraticaRendicontazioneEdit.js (+55 -17): - riga doc riscritta con 3 branch distinti per stato - pulsanti outlined con label esplicite 'Cambia' / 'Rimuovi' (icon-only text button avevano scopribilita bassa) - handler Rimuovi collegato a deleteEntityFile esistente + toast feedback Test browser verificato con Carlo: dedup ok (2 DURC in DB → 1 nel picker), tabs Azienda/Amministratore leggibili, label pulsanti chiare, flusso Cambia/Rimuovi funziona. --- .../components/CompanyDocumentPicker.js | 199 ++++++++++-------- .../pages/PraticaRendicontazioneEdit.js | 72 ++++--- 2 files changed, 153 insertions(+), 118 deletions(-) diff --git a/src/modules/rendicontazione/components/CompanyDocumentPicker.js b/src/modules/rendicontazione/components/CompanyDocumentPicker.js index ad134f8..84f2c36 100644 --- a/src/modules/rendicontazione/components/CompanyDocumentPicker.js +++ b/src/modules/rendicontazione/components/CompanyDocumentPicker.js @@ -13,45 +13,55 @@ import { Message } from 'primereact/message'; import CompanyDocumentsService from '../../../service/company-documents-service'; /** - * Modal per selezionare un documento dal repository della company. + * Modal per scegliere un documento dal repository della company. * - * Mostra tutti i company_document della company (BE Gepafin /companyDocument/company/{id}), - * filtri per categoria/stato/ricerca, selezione single-row via DataTable, semaforo stato. + * Carica tramite 2 chiamate al BE Spring: + * - default (no filter) -> COMPANY_DOCUMENT + APPLICATION_DOCUMENT + * - documentType=PERSONAL -> PERSONAL_DOCUMENT (amministratore/legale rappresentante) + * + * Unione dei risultati e deduplicazione per document_category_id scegliendo + * il piu recente attivo (VALID > DUE, a parita id desc). Questo mitiga il + * fatto che il BE Gepafin non implementa sostituzione automatica su upload + * stessa categoria (es. 2 DURC attivi in DB). * * Props: - * visible boolean - * companyId number - * onHide () => void - * onSelect (doc) => void — doc: {id, fileName, status, expirationDate, category, ...} - * currentSourceId number | null — id gia selezionato, per evidenziarlo - * - * Nota: il BE Spring filtra fuori i doc EXPIRED — non compariranno qui. - * Il gate submit comunque blocca via JOIN live lato microservizio. + * visible, companyId, onHide, onSelect(doc), currentSourceId */ const STATUS_CFG = { - VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle', color: 'var(--green-500)' }, - DUE: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-exclamation-triangle', color: 'var(--yellow-500)' }, - EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle', color: 'var(--red-500)' }, + VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle' }, + DUE: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-exclamation-triangle' }, + EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle' }, +}; + +const ORIGIN_CFG = { + COMPANY_DOCUMENT: { label: 'Azienda', icon: 'pi pi-building', color: 'var(--blue-500)', bg: 'var(--blue-50)' }, + PERSONAL_DOCUMENT: { label: 'Amministratore', icon: 'pi pi-user', color: 'var(--purple-500)', bg: 'var(--purple-50)' }, + APPLICATION_DOCUMENT: { label: 'Applicazione', icon: 'pi pi-file', color: 'var(--teal-500)', bg: 'var(--teal-50)' }, }; const formatDate = (d) => { if (!d) return '—'; - try { - const dt = typeof d === 'string' ? new Date(d) : d; - return dt.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }); - } catch { return String(d); } + try { return new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }); } + catch { return String(d); } }; - -const daysUntil = (d) => { - if (!d) return null; - try { - const dt = typeof d === 'string' ? new Date(d) : d; - const ms = dt.getTime() - Date.now(); - return Math.ceil(ms / (1000 * 60 * 60 * 24)); - } catch { return null; } -}; - +const daysUntil = (d) => { if (!d) return null; try { return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000); } catch { return null; } }; const getCategoryName = (d) => (d.category && d.category.categoryName) || d.type || '—'; +const getOriginType = (d) => d.type || 'COMPANY_DOCUMENT'; + +// dedup: per ogni (type + category), mantieni il piu recente attivo. VALID > DUE, poi id desc +const dedupByCategory = (docs) => { + const STATUS_RANK = { VALID: 0, DUE: 1, EXPIRED: 2 }; + const groups = new Map(); + for (const d of docs) { + const key = `${d.type || 'X'}::${getCategoryName(d)}`; + const rank = (STATUS_RANK[d.status] ?? 99) * 1e12 - (d.id || 0); + const prev = groups.get(key); + if (!prev || rank < prev._rank) { + groups.set(key, { ...d, _rank: rank }); + } + } + return Array.from(groups.values()).map(({ _rank, ...rest }) => rest); +}; const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSourceId = null }) => { const [loading, setLoading] = useState(false); @@ -62,32 +72,35 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo const [search, setSearch] = useState(''); const [categoryFilter, setCategoryFilter] = useState(null); const [statusFilter, setStatusFilter] = useState(null); + const [originFilter, setOriginFilter] = useState('ALL'); // ALL | COMPANY_DOCUMENT | PERSONAL_DOCUMENT useEffect(() => { if (!visible || !companyId) return; setLoading(true); setError(null); setSelectedId(currentSourceId); + setSearch(''); setCategoryFilter(null); setStatusFilter(null); setOriginFilter('ALL'); + + // doppia chiamata: company/application + personal + let collected = []; + let pending = 2; + const done = () => { if (--pending === 0) { setDocs(dedupByCategory(collected)); setLoading(false); } }; + CompanyDocumentsService.getCompanyDocuments( companyId, - (resp) => { - const data = resp?.status === 'SUCCESS' ? (resp.data || []) : []; - setDocs(data); - setLoading(false); - }, - () => { - setError(__('Impossibile caricare i documenti del repository. Riprova tra qualche istante.', 'gepafin')); - setLoading(false); - } + (resp) => { if (resp?.status === 'SUCCESS') collected = collected.concat(resp.data || []); done(); }, + () => { setError(__('Impossibile caricare i documenti del repository.', 'gepafin')); done(); } + ); + CompanyDocumentsService.getCompanyDocuments( + companyId, + (resp) => { if (resp?.status === 'SUCCESS') collected = collected.concat(resp.data || []); done(); }, + () => done(), + [['documentType', 'PERSONAL_DOCUMENT']] ); - setSearch(''); setCategoryFilter(null); setStatusFilter(null); }, [visible, companyId, currentSourceId]); - const categoryOptions = useMemo(() => { - const uniq = Array.from(new Set(docs.map(getCategoryName).filter(Boolean))).sort(); - return uniq.map(t => ({ label: t, value: t })); - }, [docs]); - + // opzioni filtri derivate + const categoryOptions = useMemo(() => Array.from(new Set(docs.map(getCategoryName).filter(Boolean))).sort().map(t => ({ label: t, value: t })), [docs]); const statusOptions = [ { label: __('Validi', 'gepafin'), value: 'VALID' }, { label: __('In scadenza', 'gepafin'), value: 'DUE' }, @@ -96,6 +109,7 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo const filteredDocs = useMemo(() => { const s = (search || '').trim().toLowerCase(); return docs.filter(d => { + if (originFilter !== 'ALL' && getOriginType(d) !== originFilter) return false; if (categoryFilter && getCategoryName(d) !== categoryFilter) return false; if (statusFilter && d.status !== statusFilter) return false; if (s) { @@ -104,7 +118,7 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo } return true; }); - }, [docs, search, categoryFilter, statusFilter]); + }, [docs, search, categoryFilter, statusFilter, originFilter]); // templates colonne const nameTpl = (row) => ( @@ -114,42 +128,42 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo {row.fileName || row.name || `Doc #${row.id}`} {row.name && row.name !== row.fileName && ( -
- {row.name} -
+
{row.name}
)} ); - + const originTpl = (row) => { + const cfg = ORIGIN_CFG[getOriginType(row)] || ORIGIN_CFG.COMPANY_DOCUMENT; + return ( + + + {cfg.label} + + ); + }; const categoryTpl = (row) => ( ); - const statusTpl = (row) => { const cfg = STATUS_CFG[row.status] || { severity: 'secondary', label: row.status || '—', icon: 'pi pi-question' }; const days = daysUntil(row.expirationDate); return (
- + {row.status === 'DUE' && days != null && days >= 0 && (
scade tra {days} {days === 1 ? 'giorno' : 'giorni'}
)}
); }; + const expiryTpl = (row) =>
{formatDate(row.expirationDate)}
; - const expiryTpl = (row) => ( -
- {formatDate(row.expirationDate)} -
- ); - - const chosenDoc = useMemo( - () => docs.find(d => d.id === selectedId) || null, - [docs, selectedId] - ); + const chosenDoc = useMemo(() => docs.find(d => d.id === selectedId) || null, [docs, selectedId]); const footer = (
@@ -168,8 +182,7 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo
@@ -188,30 +201,41 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo ); return ( - - {/* info banner */} {__('Seleziona un documento già caricato in fase di domanda. La scadenza viene controllata automaticamente — i documenti scaduti vengono esclusi dal repository.', 'gepafin')} + {__('Seleziona un documento gia caricato. Se esistono piu versioni per la stessa categoria, viene mostrata solo la piu recente valida. I documenti scaduti sono esclusi.', 'gepafin')} } /> - {/* FILTRI — search full width, poi 2 dropdown affiancati con CSS grid rigido */} + {/* tab origine — 3 pulsanti manuali (SelectButton di PrimeReact ha issues di styling col tema Gepafin) */} +
+
+ + {/* filtri secondari */}
- + setSearch(e.target.value)} style={{ width: '100%', paddingLeft: '2.75rem' }} /> @@ -226,13 +250,8 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo
- {/* TABELLA */} {loading ? ( -
- - - -
+
) : error ? ( ) : ( @@ -247,15 +266,15 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo paginator rows={10} paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport" currentPageReportTemplate="{first}-{last} di {totalRecords}" - selectionMode="single" - selection={chosenDoc} + selectionMode="single" selection={chosenDoc} onSelectionChange={(e) => e.value && setSelectedId(e.value.id)} rowClassName={(row) => row.id === selectedId ? 'p-highlight' : ''} style={{ cursor: 'pointer' }}> - - - - + + + + + )}
diff --git a/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js b/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js index 56f1121..2d85cf0 100644 --- a/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js +++ b/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js @@ -656,41 +656,57 @@ const PraticaRendicontazioneEdit = () => {
{dr.code}
- {existing && existing.id && existing.filename ? ( -
- openPreview('document', existing.id, dr.label, existing.filename)} - onChange={(meta) => updateDocFile(dr.code, existing.id, meta)} - toastRef={toast} + {existing && existing.id && existing.filename && existing.source_company_document_id ? ( + // CASO A: documento linkato dal repository company — layout custom +
+
+ + + {existing.filename} + +
+ - {existing.source_company_document_id && ( - - )} {!readOnly && ( -
+ ) : existing && existing.id && existing.filename ? ( + // CASO B: file caricato dal PC — usa FileUploadCell standard + openPreview('document', existing.id, dr.label, existing.filename)} + onChange={(meta) => updateDocFile(dr.code, existing.id, meta)} + toastRef={toast} + /> ) : !readOnly ? ( -
+ // CASO C: doc vuoto — 2 pulsanti +