feat(docs): picker modal per scegliere documenti dal repository company
Chiude la promessa UX del super_admin (testo editor: 'I documenti già in regola nel
repository della Company saranno riutilizzati automaticamente'). Nel benef, oltre al
classico upload dal PC, e ora possibile pescare documenti dal repository Gepafin
della company, ereditando filename/scadenza e status live (VALID/DUE/EXPIRED).
Nuovi componenti:
- CompanyDocumentPicker.js (195 righe): Dialog PrimeReact con filtri tipo/stato/testo,
DataTable con radio selection, semaforo tag VALID/DUE/EXPIRED, mostra scadenza
formattata IT, pulsante conferma disabilitato finche nulla e selezionato.
Servizio:
- RendicontazioneService.linkDocumentFromRepository(remDocId, companyDocId, cb, err)
chiama il nuovo endpoint microservizio POST .../document/{id}/link-from-repository.
Integrazione PraticaRendicontazioneEdit sezione 4 Documenti:
- 2 state + 2 handler nuovi: repoPicker {visible, docCode}, openRepositoryPicker,
closeRepositoryPicker, handleRepositoryPick (ensureDocRecord -> link -> toast).
- UI riga documento richiesto ora ha 2 pulsanti quando vuoto:
[pi-upload] Carica dal PC [pi-folder-open] Scegli dal repository
- Quando linked: accanto al FileUploadCell compare Tag semaforo con lo status del
sorgente (VALID=verde/DUE=giallo/EXPIRED=rosso) + pulsante cambia (ri-apre picker).
- CompanyDocumentPicker montato a fondo pagina, riceve practice.company_id +
currentSourceId per evidenziare la scelta gia fatta.
Webpack compila pulito (solo warning no-unused-vars preesistenti non miei).
Test E2E backend gia verdi nel commit backend 7c8de6a.
This commit is contained in:
195
src/modules/rendicontazione/components/CompanyDocumentPicker.js
Normal file
195
src/modules/rendicontazione/components/CompanyDocumentPicker.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { RadioButton } from 'primereact/radiobutton';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
|
||||
import CompanyDocumentsService from '../../../service/company-documents-service';
|
||||
|
||||
/**
|
||||
* Modal per selezionare un documento dal repository della company.
|
||||
*
|
||||
* Mostra tutti i company_document della company (BE Gepafin /companyDocument/company/{id}),
|
||||
* permette filtri per tipo, stato e testo libero, selezione radio, conferma.
|
||||
* Alla conferma chiama onSelect(companyDocument).
|
||||
*
|
||||
* Props:
|
||||
* visible boolean
|
||||
* companyId number
|
||||
* onHide () => void
|
||||
* onSelect (doc) => void — doc: {id, fileName, type, status, expirationDate, ...}
|
||||
* currentSourceId number | null — id gia selezionato, per evidenziarlo
|
||||
*/
|
||||
const STATUS_CFG = {
|
||||
VALID: { severity: 'success', label: __('Valido', 'gepafin'), icon: 'pi pi-check-circle' },
|
||||
DUE: { severity: 'warning', label: __('In scadenza', 'gepafin'), icon: 'pi pi-exclamation-triangle' },
|
||||
EXPIRED: { severity: 'danger', label: __('Scaduto', 'gepafin'), icon: 'pi pi-times-circle' },
|
||||
};
|
||||
|
||||
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); }
|
||||
};
|
||||
|
||||
const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSourceId = null }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState(null);
|
||||
const [statusFilter, setStatusFilter] = useState(null);
|
||||
|
||||
// load al mount
|
||||
useEffect(() => {
|
||||
if (!visible || !companyId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedId(currentSourceId);
|
||||
CompanyDocumentsService.getCompanyDocuments(
|
||||
companyId,
|
||||
(resp) => {
|
||||
const data = resp?.status === 'SUCCESS' ? (resp.data || []) : [];
|
||||
setDocs(data);
|
||||
setLoading(false);
|
||||
},
|
||||
() => {
|
||||
setError(__('Impossibile caricare i documenti del repository', 'gepafin'));
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
// reset filtri all'apertura
|
||||
setSearch(''); setTypeFilter(null); setStatusFilter(null);
|
||||
}, [visible, companyId, currentSourceId]);
|
||||
|
||||
// opzioni filtri derivate dai dati
|
||||
const typeOptions = useMemo(() => {
|
||||
const uniq = Array.from(new Set(docs.map(d => d.type).filter(Boolean))).sort();
|
||||
return uniq.map(t => ({ label: t, value: t }));
|
||||
}, [docs]);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: __('Tutti', 'gepafin'), value: null },
|
||||
{ label: __('Validi', 'gepafin'), value: 'VALID' },
|
||||
{ label: __('In scadenza', 'gepafin'), value: 'DUE' },
|
||||
{ label: __('Scaduti', 'gepafin'), value: 'EXPIRED' },
|
||||
];
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
const s = (search || '').trim().toLowerCase();
|
||||
return docs.filter(d => {
|
||||
if (typeFilter && d.type !== typeFilter) return false;
|
||||
if (statusFilter && d.status !== statusFilter) return false;
|
||||
if (s) {
|
||||
const hay = [d.fileName, d.name, d.type].filter(Boolean).join(' ').toLowerCase();
|
||||
if (!hay.includes(s)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [docs, search, typeFilter, statusFilter]);
|
||||
|
||||
// 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) => (
|
||||
<div>
|
||||
<strong>{row.fileName || row.name || `Doc #${row.id}`}</strong>
|
||||
{row.name && row.name !== row.fileName && (
|
||||
<div><small className="text-color-secondary">{row.name}</small></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const typeTpl = (row) => <code>{row.type || '—'}</code>;
|
||||
|
||||
const statusTpl = (row) => {
|
||||
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 expiryTpl = (row) => formatDate(row.expirationDate);
|
||||
|
||||
const chosenDoc = useMemo(
|
||||
() => docs.find(d => d.id === selectedId) || null,
|
||||
[docs, selectedId]
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{chosenDoc && (
|
||||
<small className="text-color-secondary">
|
||||
{__('Selezionato', 'gepafin')}: <strong>{chosenDoc.fileName || chosenDoc.name}</strong>
|
||||
{' '}<Tag severity={(STATUS_CFG[chosenDoc.status]||{}).severity || 'secondary'}
|
||||
value={(STATUS_CFG[chosenDoc.status]||{}).label || chosenDoc.status} />
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button label={__('Annulla', 'gepafin')} severity="secondary" text onClick={onHide} />
|
||||
<Button label={__('Seleziona', 'gepafin')} icon="pi pi-check"
|
||||
disabled={!chosenDoc}
|
||||
onClick={() => { if (chosenDoc) { onSelect(chosenDoc); onHide(); } }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog header={__('Scegli dal repository aziendale', 'gepafin')}
|
||||
visible={visible} onHide={onHide} style={{ width: '900px', maxWidth: '95vw' }}
|
||||
modal dismissableMask footer={footer}>
|
||||
|
||||
{/* FILTRI */}
|
||||
<div className="flex flex-wrap gap-2 align-items-center mb-3">
|
||||
<span className="p-input-icon-left" style={{ flex: 1, minWidth: 200 }}>
|
||||
<i className="pi pi-search" />
|
||||
<InputText placeholder={__('Cerca per nome o tipo...', 'gepafin')}
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: '100%' }} />
|
||||
</span>
|
||||
<Dropdown value={typeFilter} onChange={(e) => setTypeFilter(e.value)}
|
||||
options={typeOptions} placeholder={__('Tipo', 'gepafin')}
|
||||
showClear style={{ minWidth: '160px' }} />
|
||||
<Dropdown value={statusFilter} onChange={(e) => setStatusFilter(e.value)}
|
||||
options={statusOptions} placeholder={__('Stato', 'gepafin')}
|
||||
showClear style={{ minWidth: '160px' }} />
|
||||
</div>
|
||||
|
||||
{/* TABELLA */}
|
||||
{loading ? (
|
||||
<div><Skeleton height="2rem" className="mb-2" /><Skeleton height="2rem" className="mb-2" /><Skeleton height="2rem" /></div>
|
||||
) : error ? (
|
||||
<div className="p-3" style={{ background: 'var(--red-50)', color: 'var(--red-700)', borderRadius: 6 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<DataTable value={filteredDocs} dataKey="id"
|
||||
emptyMessage={__('Nessun documento nel repository aziendale', 'gepafin')}
|
||||
scrollable scrollHeight="420px" size="small"
|
||||
selectionMode="single"
|
||||
selection={chosenDoc} onSelectionChange={(e) => e.value && setSelectedId(e.value.id)}>
|
||||
<Column body={selectionTpl} style={{ width: '3rem' }} />
|
||||
<Column header={__('Documento', 'gepafin')} body={nameTpl} />
|
||||
<Column header={__('Tipo', 'gepafin')} body={typeTpl} style={{ width: '180px' }} />
|
||||
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '150px' }} />
|
||||
<Column header={__('Scadenza', 'gepafin')} body={expiryTpl} style={{ width: '130px' }} />
|
||||
</DataTable>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyDocumentPicker;
|
||||
@@ -21,6 +21,7 @@ import { Column } from 'primereact/column';
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
import FileUploadCell from '../components/FileUploadCell';
|
||||
import FilePreviewDialog from '../components/FilePreviewDialog';
|
||||
import CompanyDocumentPicker from '../components/CompanyDocumentPicker';
|
||||
|
||||
// ---------- costanti ----------
|
||||
const IVA_REGIME_LABELS = {
|
||||
@@ -143,6 +144,44 @@ const PraticaRendicontazioneEdit = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// --- CompanyDocument picker state + handlers ---
|
||||
const [repoPicker, setRepoPicker] = useState({ visible: false, docCode: null });
|
||||
const openRepositoryPicker = (docCode) => setRepoPicker({ visible: true, docCode });
|
||||
const closeRepositoryPicker = () => setRepoPicker({ visible: false, docCode: null });
|
||||
|
||||
// quando l'utente sceglie un doc dal picker: ensure record -> link-from-repository -> update state
|
||||
const handleRepositoryPick = (companyDoc) => {
|
||||
const docCode = repoPicker.docCode;
|
||||
if (!docCode || !companyDoc) return;
|
||||
ensureDocRecord(docCode, (remDocId) => {
|
||||
RendicontazioneService.linkDocumentFromRepository(
|
||||
remDocId, companyDoc.id,
|
||||
(resp) => {
|
||||
const d = resp?.data || {};
|
||||
setPractice(p => p ? {
|
||||
...p,
|
||||
documents: p.documents.map(x => x.doc_code === docCode ? {
|
||||
...x,
|
||||
filename: d.filename ?? companyDoc.fileName,
|
||||
expires_at: d.expires_at ?? null,
|
||||
source_company_document_id: d.source_company_document_id ?? companyDoc.id,
|
||||
source_status: d.source_status ?? companyDoc.status,
|
||||
size_bytes: null,
|
||||
} : x)
|
||||
} : p);
|
||||
const sev = (d.source_status === 'EXPIRED') ? 'warn' : 'success';
|
||||
toast.current?.show({
|
||||
severity: sev,
|
||||
summary: __('Documento collegato dal repository', 'gepafin'),
|
||||
detail: companyDoc.fileName + ' · ' + (d.source_status || companyDoc.status)
|
||||
});
|
||||
},
|
||||
(err) => toast.current?.show({ severity: 'error', summary: __('Errore link repository', 'gepafin'), detail: err?.detail })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// ---------- load ----------
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -617,19 +656,45 @@ const PraticaRendicontazioneEdit = () => {
|
||||
<div><small className="text-color-secondary"><code>{dr.code}</code></small></div>
|
||||
</div>
|
||||
<div style={{ flex: 2, minWidth: '260px' }}>
|
||||
{existing && existing.id ? (
|
||||
<FileUploadCell
|
||||
entityType="document" entityId={existing.id}
|
||||
filename={existing.filename} sizeBytes={existing.size_bytes}
|
||||
readOnly={readOnly}
|
||||
onPreview={() => openPreview('document', existing.id, dr.label, existing.filename)}
|
||||
onChange={(meta) => updateDocFile(dr.code, existing.id, meta)}
|
||||
toastRef={toast}
|
||||
/>
|
||||
{existing && existing.id && existing.filename ? (
|
||||
<div className="flex align-items-center gap-2 flex-wrap">
|
||||
<FileUploadCell
|
||||
entityType="document" entityId={existing.id}
|
||||
filename={existing.filename} sizeBytes={existing.size_bytes}
|
||||
readOnly={readOnly}
|
||||
onPreview={() => openPreview('document', existing.id, dr.label, existing.filename)}
|
||||
onChange={(meta) => updateDocFile(dr.code, existing.id, meta)}
|
||||
toastRef={toast}
|
||||
/>
|
||||
{existing.source_company_document_id && (
|
||||
<Tag
|
||||
severity={existing.source_status === 'VALID' ? 'success'
|
||||
: existing.source_status === 'DUE' ? 'warning'
|
||||
: existing.source_status === 'EXPIRED' ? 'danger' : 'info'}
|
||||
icon={existing.source_status === 'VALID' ? 'pi pi-check-circle'
|
||||
: existing.source_status === 'DUE' ? 'pi pi-exclamation-triangle'
|
||||
: existing.source_status === 'EXPIRED' ? 'pi pi-times-circle' : 'pi pi-link'}
|
||||
value={existing.source_status === 'EXPIRED' ? __('Scaduto', 'gepafin')
|
||||
: existing.source_status === 'DUE' ? __('In scadenza', 'gepafin')
|
||||
: existing.source_status === 'VALID' ? __('Dal repository', 'gepafin')
|
||||
: __('Dal repository', 'gepafin')}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<Button type="button" icon="pi pi-folder-open" size="small" text
|
||||
tooltip={__('Cambia: scegli dal repository', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
||||
onClick={() => openRepositoryPicker(dr.code)} />
|
||||
)}
|
||||
</div>
|
||||
) : !readOnly ? (
|
||||
<Button type="button" icon="pi pi-upload" size="small" outlined
|
||||
label={__('Carica', 'gepafin')}
|
||||
onClick={() => ensureDocRecord(dr.code, () => {/* reload not needed, setPractice already updated */})} />
|
||||
<div className="flex gap-2 align-items-center flex-wrap">
|
||||
<Button type="button" icon="pi pi-upload" size="small" outlined
|
||||
label={__('Carica dal PC', 'gepafin')}
|
||||
onClick={() => ensureDocRecord(dr.code, () => {/* reload not needed */})} />
|
||||
<Button type="button" icon="pi pi-folder-open" size="small" outlined severity="secondary"
|
||||
label={__('Scegli dal repository', 'gepafin')}
|
||||
onClick={() => openRepositoryPicker(dr.code)} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-color-secondary">{__('Nessun file', 'gepafin')}</span>
|
||||
)}
|
||||
@@ -908,6 +973,14 @@ const PraticaRendicontazioneEdit = () => {
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
<CompanyDocumentPicker
|
||||
visible={repoPicker.visible}
|
||||
companyId={practice?.company_id}
|
||||
currentSourceId={(practice?.documents?.find(d => d.doc_code === repoPicker.docCode) || {}).source_company_document_id || null}
|
||||
onHide={closeRepositoryPicker}
|
||||
onSelect={handleRepositoryPick}
|
||||
/>
|
||||
|
||||
<FilePreviewDialog
|
||||
visible={previewDialog.visible}
|
||||
onHide={closePreview}
|
||||
|
||||
@@ -342,6 +342,20 @@ const extendFiles = {
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
/**
|
||||
* Collega un remission_document a un company_document del repository Gepafin.
|
||||
* Alternativa all upload dal PC: copia filename/expires_at/storage_path dal sorgente
|
||||
* e traccia source_company_document_id per lookup live dello status (VALID/DUE/EXPIRED).
|
||||
* Usato solo su entityType='document'.
|
||||
*/
|
||||
linkDocumentFromRepository(remissionDocumentId, companyDocumentId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-files/document/${remissionDocumentId}/link-from-repository`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ company_document_id: companyDocumentId })
|
||||
}).then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
/**
|
||||
* Elimina file allegato a una entita.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user