style(docs-picker): redesign grafico + usa category.categoryName invece di type
Fix identificato testando: il BE Spring CompanyDocumentDao ha 2 filtri che escludevano i doc seedati: status!=EXPIRED e type IN (COMPANY_DOCUMENT, APPLICATION_DOCUMENT). Il campo 'type' nel BE e una macro-tipologia, non il sotto-tipo (DURC/VISURA/etc) che vive in document_category_id. Seed aggiornato (runtime): type='COMPANY_DOCUMENT' per tutti i doc, e il sotto-tipo viene esposto via companyDocument.category.categoryName. Picker rifatto: - usa category.categoryName come tipo visibile/filtrabile (non piu type) - filtro status con sole opzioni VALID/DUE (gli EXPIRED sono filtrati dal BE e non appaiono mai qui — il gate submit li blocca comunque via JOIN live sul microservizio) - header con icona cartella + contatore X/Y documenti - banner Message informativo sull esclusione EXPIRED - filtri in grid CSS rigido (search full-width, 2 dropdown affiancati 2fr equal) per evitare stacking verticale indesiderato - DataTable con stripedRows, icone file-pdf, scadenze monospace - per status DUE: mostra 'scade tra N giorni' - footer: 'Selezionato: NOME + tag' a sx, pulsanti a dx Componente passato da 195 a 254 righe. Webpack compila pulito (solo warning no-unused-vars preesistenti non miei).
This commit is contained in:
@@ -4,11 +4,11 @@ import { Dialog } from 'primereact/dialog';
|
|||||||
import { DataTable } from 'primereact/datatable';
|
import { DataTable } from 'primereact/datatable';
|
||||||
import { Column } from 'primereact/column';
|
import { Column } from 'primereact/column';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { RadioButton } from 'primereact/radiobutton';
|
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import { Skeleton } from 'primereact/skeleton';
|
import { Skeleton } from 'primereact/skeleton';
|
||||||
|
import { Message } from 'primereact/message';
|
||||||
|
|
||||||
import CompanyDocumentsService from '../../../service/company-documents-service';
|
import CompanyDocumentsService from '../../../service/company-documents-service';
|
||||||
|
|
||||||
@@ -16,20 +16,22 @@ import CompanyDocumentsService from '../../../service/company-documents-service'
|
|||||||
* Modal per selezionare un documento dal repository della company.
|
* Modal per selezionare un documento dal repository della company.
|
||||||
*
|
*
|
||||||
* Mostra tutti i company_document della company (BE Gepafin /companyDocument/company/{id}),
|
* Mostra tutti i company_document della company (BE Gepafin /companyDocument/company/{id}),
|
||||||
* permette filtri per tipo, stato e testo libero, selezione radio, conferma.
|
* filtri per categoria/stato/ricerca, selezione single-row via DataTable, semaforo stato.
|
||||||
* Alla conferma chiama onSelect(companyDocument).
|
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* visible boolean
|
* visible boolean
|
||||||
* companyId number
|
* companyId number
|
||||||
* onHide () => void
|
* onHide () => void
|
||||||
* onSelect (doc) => void — doc: {id, fileName, type, status, expirationDate, ...}
|
* onSelect (doc) => void — doc: {id, fileName, status, expirationDate, category, ...}
|
||||||
* currentSourceId number | null — id gia selezionato, per evidenziarlo
|
* 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.
|
||||||
*/
|
*/
|
||||||
const STATUS_CFG = {
|
const STATUS_CFG = {
|
||||||
VALID: { severity: 'success', label: __('Valido', 'gepafin'), icon: 'pi pi-check-circle' },
|
VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle', color: 'var(--green-500)' },
|
||||||
DUE: { severity: 'warning', label: __('In scadenza', 'gepafin'), icon: 'pi pi-exclamation-triangle' },
|
DUE: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-exclamation-triangle', color: 'var(--yellow-500)' },
|
||||||
EXPIRED: { severity: 'danger', label: __('Scaduto', 'gepafin'), icon: 'pi pi-times-circle' },
|
EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle', color: 'var(--red-500)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (d) => {
|
const formatDate = (d) => {
|
||||||
@@ -40,6 +42,17 @@ const formatDate = (d) => {
|
|||||||
} catch { return String(d); }
|
} 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 getCategoryName = (d) => (d.category && d.category.categoryName) || d.type || '—';
|
||||||
|
|
||||||
const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSourceId = null }) => {
|
const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSourceId = null }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [docs, setDocs] = useState([]);
|
const [docs, setDocs] = useState([]);
|
||||||
@@ -47,10 +60,9 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo
|
|||||||
|
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState(null);
|
const [categoryFilter, setCategoryFilter] = useState(null);
|
||||||
const [statusFilter, setStatusFilter] = useState(null);
|
const [statusFilter, setStatusFilter] = useState(null);
|
||||||
|
|
||||||
// load al mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !companyId) return;
|
if (!visible || !companyId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -64,63 +76,75 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
setError(__('Impossibile caricare i documenti del repository', 'gepafin'));
|
setError(__('Impossibile caricare i documenti del repository. Riprova tra qualche istante.', 'gepafin'));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// reset filtri all'apertura
|
setSearch(''); setCategoryFilter(null); setStatusFilter(null);
|
||||||
setSearch(''); setTypeFilter(null); setStatusFilter(null);
|
|
||||||
}, [visible, companyId, currentSourceId]);
|
}, [visible, companyId, currentSourceId]);
|
||||||
|
|
||||||
// opzioni filtri derivate dai dati
|
const categoryOptions = useMemo(() => {
|
||||||
const typeOptions = useMemo(() => {
|
const uniq = Array.from(new Set(docs.map(getCategoryName).filter(Boolean))).sort();
|
||||||
const uniq = Array.from(new Set(docs.map(d => d.type).filter(Boolean))).sort();
|
|
||||||
return uniq.map(t => ({ label: t, value: t }));
|
return uniq.map(t => ({ label: t, value: t }));
|
||||||
}, [docs]);
|
}, [docs]);
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ label: __('Tutti', 'gepafin'), value: null },
|
|
||||||
{ label: __('Validi', 'gepafin'), value: 'VALID' },
|
{ label: __('Validi', 'gepafin'), value: 'VALID' },
|
||||||
{ label: __('In scadenza', 'gepafin'), value: 'DUE' },
|
{ label: __('In scadenza', 'gepafin'), value: 'DUE' },
|
||||||
{ label: __('Scaduti', 'gepafin'), value: 'EXPIRED' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const filteredDocs = useMemo(() => {
|
const filteredDocs = useMemo(() => {
|
||||||
const s = (search || '').trim().toLowerCase();
|
const s = (search || '').trim().toLowerCase();
|
||||||
return docs.filter(d => {
|
return docs.filter(d => {
|
||||||
if (typeFilter && d.type !== typeFilter) return false;
|
if (categoryFilter && getCategoryName(d) !== categoryFilter) return false;
|
||||||
if (statusFilter && d.status !== statusFilter) return false;
|
if (statusFilter && d.status !== statusFilter) return false;
|
||||||
if (s) {
|
if (s) {
|
||||||
const hay = [d.fileName, d.name, d.type].filter(Boolean).join(' ').toLowerCase();
|
const hay = [d.fileName, d.name, getCategoryName(d)].filter(Boolean).join(' ').toLowerCase();
|
||||||
if (!hay.includes(s)) return false;
|
if (!hay.includes(s)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [docs, search, typeFilter, statusFilter]);
|
}, [docs, search, categoryFilter, statusFilter]);
|
||||||
|
|
||||||
// templates colonne
|
// templates colonne
|
||||||
const selectionTpl = (row) => (
|
|
||||||
<RadioButton inputId={`pick_${row.id}`} name="pickDoc" value={row.id}
|
|
||||||
checked={selectedId === row.id} onChange={() => setSelectedId(row.id)} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const nameTpl = (row) => (
|
const nameTpl = (row) => (
|
||||||
<div>
|
<div>
|
||||||
<strong>{row.fileName || row.name || `Doc #${row.id}`}</strong>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)', fontSize: '1.1rem' }} />
|
||||||
|
<strong style={{ fontSize: '0.95rem' }}>{row.fileName || row.name || `Doc #${row.id}`}</strong>
|
||||||
|
</div>
|
||||||
{row.name && row.name !== row.fileName && (
|
{row.name && row.name !== row.fileName && (
|
||||||
<div><small className="text-color-secondary">{row.name}</small></div>
|
<div style={{ marginLeft: '1.6rem' }}>
|
||||||
|
<small className="text-color-secondary">{row.name}</small>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const typeTpl = (row) => <code>{row.type || '—'}</code>;
|
const categoryTpl = (row) => (
|
||||||
|
<Tag value={getCategoryName(row)} severity="secondary"
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.8rem' }} />
|
||||||
|
);
|
||||||
|
|
||||||
const statusTpl = (row) => {
|
const statusTpl = (row) => {
|
||||||
const cfg = STATUS_CFG[row.status] || { severity: 'secondary', label: row.status || '—', icon: 'pi pi-question' };
|
const cfg = STATUS_CFG[row.status] || { severity: 'secondary', label: row.status || '—', icon: 'pi pi-question' };
|
||||||
return <Tag severity={cfg.severity} icon={cfg.icon} value={cfg.label} />;
|
const days = daysUntil(row.expirationDate);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tag severity={cfg.severity} icon={cfg.icon} value={cfg.label}
|
||||||
|
style={{ fontWeight: 600 }} />
|
||||||
|
{row.status === 'DUE' && days != null && days >= 0 && (
|
||||||
|
<div><small className="text-color-secondary">scade tra {days} {days === 1 ? 'giorno' : 'giorni'}</small></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const expiryTpl = (row) => formatDate(row.expirationDate);
|
const expiryTpl = (row) => (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||||
|
{formatDate(row.expirationDate)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const chosenDoc = useMemo(
|
const chosenDoc = useMemo(
|
||||||
() => docs.find(d => d.id === selectedId) || null,
|
() => docs.find(d => d.id === selectedId) || null,
|
||||||
@@ -128,17 +152,21 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo
|
|||||||
);
|
);
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<div className="flex justify-content-between align-items-center">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '1rem' }}>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{chosenDoc && (
|
{chosenDoc ? (
|
||||||
<small className="text-color-secondary">
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
{__('Selezionato', 'gepafin')}: <strong>{chosenDoc.fileName || chosenDoc.name}</strong>
|
<small className="text-color-secondary">{__('Selezionato', 'gepafin')}:</small>
|
||||||
{' '}<Tag severity={(STATUS_CFG[chosenDoc.status]||{}).severity || 'secondary'}
|
<strong style={{ fontSize: '0.9rem' }}>{chosenDoc.fileName || chosenDoc.name}</strong>
|
||||||
|
<Tag severity={(STATUS_CFG[chosenDoc.status]||{}).severity || 'secondary'}
|
||||||
|
icon={(STATUS_CFG[chosenDoc.status]||{}).icon}
|
||||||
value={(STATUS_CFG[chosenDoc.status]||{}).label || chosenDoc.status} />
|
value={(STATUS_CFG[chosenDoc.status]||{}).label || chosenDoc.status} />
|
||||||
</small>
|
</div>
|
||||||
|
) : (
|
||||||
|
<small className="text-color-secondary">{__('Seleziona una riga per continuare', 'gepafin')}</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
<Button label={__('Annulla', 'gepafin')} severity="secondary" text onClick={onHide} />
|
<Button label={__('Annulla', 'gepafin')} severity="secondary" text onClick={onHide} />
|
||||||
<Button label={__('Seleziona', 'gepafin')} icon="pi pi-check"
|
<Button label={__('Seleziona', 'gepafin')} icon="pi pi-check"
|
||||||
disabled={!chosenDoc}
|
disabled={!chosenDoc}
|
||||||
@@ -147,45 +175,76 @@ const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSo
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const dialogHeader = (
|
||||||
<Dialog header={__('Scegli dal repository aziendale', 'gepafin')}
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
visible={visible} onHide={onHide} style={{ width: '900px', maxWidth: '95vw' }}
|
<i className="pi pi-folder-open" style={{ color: 'var(--primary-color)', fontSize: '1.3rem' }} />
|
||||||
modal dismissableMask footer={footer}>
|
<span>{__('Scegli dal repository aziendale', 'gepafin')}</span>
|
||||||
|
{!loading && !error && (
|
||||||
|
<small className="text-color-secondary" style={{ marginLeft: 'auto' }}>
|
||||||
|
{filteredDocs.length} / {docs.length} {__('documenti', 'gepafin')}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* FILTRI */}
|
return (
|
||||||
<div className="flex flex-wrap gap-2 align-items-center mb-3">
|
<Dialog header={dialogHeader}
|
||||||
<span className="p-input-icon-left" style={{ flex: 1, minWidth: 200 }}>
|
visible={visible} onHide={onHide}
|
||||||
|
style={{ width: '960px', maxWidth: '95vw' }}
|
||||||
|
modal dismissableMask footer={footer}
|
||||||
|
contentStyle={{ paddingTop: '1rem' }}>
|
||||||
|
|
||||||
|
{/* info banner */}
|
||||||
|
<Message severity="info" className="mb-3" style={{ width: '100%' }}
|
||||||
|
content={
|
||||||
|
<small>{__('Seleziona un documento già caricato in fase di domanda. La scadenza viene controllata automaticamente — i documenti scaduti vengono esclusi dal repository.', 'gepafin')}</small>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* FILTRI — search full width, poi 2 dropdown affiancati con CSS grid rigido */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||||
|
<span className="p-input-icon-left" style={{ display: 'block', width: '100%' }}>
|
||||||
<i className="pi pi-search" />
|
<i className="pi pi-search" />
|
||||||
<InputText placeholder={__('Cerca per nome o tipo...', 'gepafin')}
|
<InputText placeholder={__('Cerca per nome o categoria...', 'gepafin')}
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||||
style={{ width: '100%' }} />
|
style={{ width: '100%' }} />
|
||||||
</span>
|
</span>
|
||||||
<Dropdown value={typeFilter} onChange={(e) => setTypeFilter(e.value)}
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
options={typeOptions} placeholder={__('Tipo', 'gepafin')}
|
<Dropdown value={categoryFilter} onChange={(e) => setCategoryFilter(e.value)}
|
||||||
showClear style={{ minWidth: '160px' }} />
|
options={categoryOptions} placeholder={__('Categoria', 'gepafin')}
|
||||||
|
showClear style={{ width: '100%' }} />
|
||||||
<Dropdown value={statusFilter} onChange={(e) => setStatusFilter(e.value)}
|
<Dropdown value={statusFilter} onChange={(e) => setStatusFilter(e.value)}
|
||||||
options={statusOptions} placeholder={__('Stato', 'gepafin')}
|
options={statusOptions} placeholder={__('Stato', 'gepafin')}
|
||||||
showClear style={{ minWidth: '160px' }} />
|
showClear style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TABELLA */}
|
{/* TABELLA */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div><Skeleton height="2rem" className="mb-2" /><Skeleton height="2rem" className="mb-2" /><Skeleton height="2rem" /></div>
|
<div>
|
||||||
) : error ? (
|
<Skeleton height="2.5rem" className="mb-2" />
|
||||||
<div className="p-3" style={{ background: 'var(--red-50)', color: 'var(--red-700)', borderRadius: 6 }}>
|
<Skeleton height="2.5rem" className="mb-2" />
|
||||||
{error}
|
<Skeleton height="2.5rem" />
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Message severity="error" text={error} style={{ width: '100%' }} />
|
||||||
) : (
|
) : (
|
||||||
<DataTable value={filteredDocs} dataKey="id"
|
<DataTable value={filteredDocs} dataKey="id"
|
||||||
emptyMessage={__('Nessun documento nel repository aziendale', 'gepafin')}
|
emptyMessage={<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-color-secondary)' }}>
|
||||||
scrollable scrollHeight="420px" size="small"
|
<i className="pi pi-inbox" style={{ fontSize: '2rem', display: 'block', marginBottom: '0.5rem' }} />
|
||||||
|
{docs.length === 0
|
||||||
|
? __('Nessun documento nel repository della tua azienda', 'gepafin')
|
||||||
|
: __('Nessun documento corrisponde ai filtri', 'gepafin')}
|
||||||
|
</div>}
|
||||||
|
scrollable scrollHeight="400px" size="small" stripedRows
|
||||||
selectionMode="single"
|
selectionMode="single"
|
||||||
selection={chosenDoc} onSelectionChange={(e) => e.value && setSelectedId(e.value.id)}>
|
selection={chosenDoc}
|
||||||
<Column body={selectionTpl} style={{ width: '3rem' }} />
|
onSelectionChange={(e) => e.value && setSelectedId(e.value.id)}
|
||||||
<Column header={__('Documento', 'gepafin')} body={nameTpl} />
|
rowClassName={(row) => row.id === selectedId ? 'p-highlight' : ''}
|
||||||
<Column header={__('Tipo', 'gepafin')} body={typeTpl} style={{ width: '180px' }} />
|
style={{ cursor: 'pointer' }}>
|
||||||
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '150px' }} />
|
<Column header={__('Documento', 'gepafin')} body={nameTpl} style={{ minWidth: '280px' }} />
|
||||||
<Column header={__('Scadenza', 'gepafin')} body={expiryTpl} style={{ width: '130px' }} />
|
<Column header={__('Categoria', 'gepafin')} body={categoryTpl} style={{ width: '180px' }} />
|
||||||
|
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '170px' }} />
|
||||||
|
<Column header={__('Scadenza', 'gepafin')} body={expiryTpl} style={{ width: '120px' }} />
|
||||||
</DataTable>
|
</DataTable>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user