Files
bflows-bandi-fe/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js
BFLOWS fca18de751 feat(rendicontazione): integrazione upload reale + preview PDF + verbale nei flussi
IstruttoriaPratica.js:
- previewDialog esteso con entityType/entityId (non piu solo filename)
- openPreview/closePreview/doDownload rimpiazzano openPreview/downloadStub stub
- Dialog placeholder 'anteprima simulata' rimosso, sostituito con <FilePreviewDialog/>
- Bottoni anteprima/scarica in fatture/ULA/documenti usano gli endpoint reali
  (disabled se !storage_path)
- Nuovi bottoni 'Anteprima verbale' (HTML tab) e 'Scarica verbale PDF'
  nella toolbar per status in UNDER_REVIEW/AWAITING_AMENDMENT/APPROVED/REJECTED
- downloadVerbale/openVerbaleHtml helpers

PraticaRendicontazioneEdit.js:
- previewDialog state + openPreview/closePreview
- updateInvoiceFile/updateUlaFile/updateDocFile: aggiornano lo stato locale
  dopo upload/delete senza full reload pagina
- ensureDocRecord: auto-crea RemissionDocument (via upsertDocument con filename=null)
  prima dell'upload cosi FileUploadCell ha un entityId valido
- Colonne 'Allegato' nelle DataTable fatture/ULA ora renderizzano <FileUploadCell/>
  con onPreview/onChange wired
- Sezione documenti: FileUploadCell per record esistenti, bottone 'Carica'
  per record non ancora creati
- Modal fattura: rimosso campo 'Nome file PDF (simulato)', infobox post-save guida
  al caricamento dalla tabella
- Modal dipendente: rimosso campo 'Nome file allegato (simulato)', infobox analogo
- <FilePreviewDialog/> montato in chiusura

Test JSX: @babel/parser OK su entrambi i file. Webpack ricompila hot-reload.
2026-04-18 16:55:22 +02:00

804 lines
47 KiB
JavaScript

import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { __ } from '@wordpress/i18n';
import { useNavigate, useParams } from 'react-router-dom';
// components
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Tag } from 'primereact/tag';
import { Skeleton } from 'primereact/skeleton';
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { InputTextarea } from 'primereact/inputtextarea';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
// api
import RendicontazioneService from '../service/rendicontazioneService';
import FileUploadCell from '../components/FileUploadCell';
import FilePreviewDialog from '../components/FilePreviewDialog';
// ---------- costanti ----------
const IVA_REGIME_LABELS = {
ORDINARIO: 'Ordinario (IVA non rendicontabile)',
FORFETTARIO: 'Forfettario (IVA rendicontabile)',
ESENTE: 'Esente'
};
const CONTRACT_TYPES = [
{ value: 'T_IND', label: 'Tempo indeterminato' },
{ value: 'T_DET', label: 'Tempo determinato' },
{ value: 'APPR', label: 'Apprendistato' },
{ value: 'STAGE', label: 'Tirocinio / Stage' },
{ value: 'COLL', label: 'Collaborazione coordinata' },
{ value: 'ALTRO', label: 'Altro' }
];
const STATUS_TAGS = {
DRAFT: { severity: 'warning', label: 'In compilazione' },
SUBMITTED: { severity: 'info', label: 'Inviata' },
UNDER_REVIEW: { severity: 'info', label: 'In valutazione' },
APPROVED: { severity: 'success', label: 'Approvata' },
REJECTED: { severity: 'danger', label: 'Respinta' },
AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso istruttorio' }
};
const euro = (v) => '€ ' + Number(v || 0).toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT') : '—';
// empty invoice/employee templates
const emptyInvoice = (catCode) => ({
category_code: catCode || '',
invoice_number: '', invoice_date: null, payment_date: null,
supplier_name: '', supplier_vat: '',
description: '', taxable: null, vat: 0, total: null,
pdf_filename: ''
});
const emptyEmployee = () => ({
codice_fiscale: '', full_name: '',
contract_type: 'T_IND', role_description: '',
fte_pct: 1.0,
period_start_date: null, period_end_date: null,
supporting_doc_type: 'LUL', supporting_doc_filename: ''
});
const PraticaRendicontazioneEdit = () => {
const { id: practiceId } = useParams();
const navigate = useNavigate();
const toast = useRef(null);
const [practice, setPractice] = useState(null);
const [loading, setLoading] = useState(true);
const [gate, setGate] = useState(null);
// modal fattura
const [invDialog, setInvDialog] = useState({ visible: false, data: null });
// modal dipendente ULA
const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
// modal risposta soccorso istruttorio
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '' });
// preview file
const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null });
const openPreview = (entityType, entityId, title, filename) => setPreviewDialog({ visible: true, entityType, entityId, title, filename });
const closePreview = () => setPreviewDialog({ visible: false, entityType: null, entityId: null, filename: null, title: null });
// update locale riga dopo upload/delete
const updateInvoiceFile = (invoiceId, fileMeta) => {
setPractice(p => p ? { ...p, invoices: p.invoices.map(i => i.id === invoiceId ? {
...i,
pdf_filename: fileMeta ? fileMeta.filename_original : null,
storage_path: fileMeta ? fileMeta.storage_path : null,
size_bytes: fileMeta ? fileMeta.size_bytes : null,
} : i) } : p);
};
const updateUlaFile = (empId, fileMeta) => {
setPractice(p => p ? { ...p, ula_employees: p.ula_employees.map(e => e.id === empId ? {
...e,
supporting_doc_filename: fileMeta ? fileMeta.filename_original : null,
storage_path: fileMeta ? fileMeta.storage_path : null,
size_bytes: fileMeta ? fileMeta.size_bytes : null,
} : e) } : p);
};
const updateDocFile = (docCode, docId, fileMeta) => {
setPractice(p => {
if (!p) return p;
const exists = p.documents.find(d => d.doc_code === docCode);
const newDocs = exists
? p.documents.map(d => d.doc_code === docCode ? {
...d,
filename: fileMeta ? fileMeta.filename_original : null,
storage_path: fileMeta ? fileMeta.storage_path : null,
size_bytes: fileMeta ? fileMeta.size_bytes : null,
} : d)
: p.documents;
return { ...p, documents: newDocs };
});
};
// ensure doc record exists (returns id via callback)
const ensureDocRecord = (docCode, onReady) => {
const existing = practice?.documents?.find(d => d.doc_code === docCode);
if (existing && existing.id) { onReady(existing.id); return; }
RendicontazioneService.upsertDocument(practiceId, docCode, { filename: null },
(resp) => {
const newDoc = resp?.data;
if (newDoc && newDoc.id) {
setPractice(p => p ? { ...p, documents: [...p.documents.filter(d => d.doc_code !== docCode), newDoc] } : p);
onReady(newDoc.id);
}
},
(err) => toast.current?.show({ severity: 'error', summary: __('Errore preparazione documento', 'gepafin'), detail: err?.detail })
);
};
// ---------- load ----------
const load = useCallback(() => {
setLoading(true);
RendicontazioneService.getPractice(practiceId,
(resp) => { setPractice(resp?.data); setLoading(false); refreshGate(resp?.data); },
(err) => {
toast.current?.show({ severity: 'error', summary: __('Errore caricamento', 'gepafin'), detail: err?.detail });
setLoading(false);
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [practiceId]);
const refreshGate = (p) => {
RendicontazioneService.gateCheck(practiceId,
(resp) => setGate(resp?.data),
() => setGate(null));
};
useEffect(() => { load(); }, [load]);
const readOnly = practice && practice.status !== 'DRAFT';
// ---------- derived ----------
const sections = practice?.schema_snapshot?.sections || [];
const categories = useMemo(() => {
const s = sections.find(x => x.type === 'category_grid') || {};
return s.categories || [];
}, [sections]);
const ulaSection = useMemo(() => sections.find(x => x.type === 'ula_block') || {}, [sections]);
const docsSection = useMemo(() => sections.find(x => x.type === 'document_checklist') || {}, [sections]);
const docsRequired = useMemo(() => {
const raw = docsSection.required_types || [];
return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r);
}, [docsSection]);
const ivaAllowed = useMemo(() => {
const gen = sections.find(x => x.type === 'static_fields');
const ivaField = (gen?.fields || []).find(f => f.id === 'iva_regime');
const opts = ivaField?.options || [];
return opts.map(o => (typeof o === 'string' ? { value: o, label: IVA_REGIME_LABELS[o] || o }
: { value: o.value, label: IVA_REGIME_LABELS[o.value] || o.label || o.value }));
}, [sections]);
// ---------- actions ----------
const afterMutation = (successMsg) => (resp) => {
toast.current?.show({ severity: 'success', summary: successMsg });
load();
};
const onMutationError = (err) => {
toast.current?.show({ severity: 'error', summary: __('Operazione fallita', 'gepafin'), detail: err?.detail || JSON.stringify(err?.message || err) });
};
const updateIvaRegime = (regime) => {
RendicontazioneService.updatePractice(practiceId, { iva_regime: regime },
afterMutation(__('Regime IVA aggiornato', 'gepafin')), onMutationError);
};
// invoices
const openAddInvoice = (catCode) => setInvDialog({ visible: true, data: emptyInvoice(catCode) });
const saveInvoice = () => {
const d = invDialog.data;
// validazione minima
if (!d.invoice_number || !d.invoice_date || !d.payment_date || !d.supplier_name ||
!d.supplier_vat || !d.description || d.taxable == null || d.total == null) {
toast.current?.show({ severity: 'warn', summary: __('Campi obbligatori mancanti', 'gepafin'), detail: __('Compila tutti i campi della fattura.', 'gepafin') });
return;
}
const payload = {
...d,
invoice_date: typeof d.invoice_date === 'string' ? d.invoice_date : d.invoice_date.toISOString().slice(0, 10),
payment_date: typeof d.payment_date === 'string' ? d.payment_date : d.payment_date.toISOString().slice(0, 10)
};
RendicontazioneService.addInvoice(practiceId, payload,
(resp) => { setInvDialog({ visible: false, data: null }); afterMutation(__('Fattura aggiunta', 'gepafin'))(resp); },
onMutationError);
};
const deleteInvoice = (e, inv) => {
confirmPopup({
target: e.currentTarget,
message: __('Rimuovere questa fattura?', 'gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Rimuovi', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
acceptClassName: 'p-button-danger',
accept: () => RendicontazioneService.deleteInvoice(practiceId, inv.id,
afterMutation(__('Fattura rimossa', 'gepafin')), onMutationError)
});
};
// ula
const openAddEmployee = () => setEmpDialog({ visible: true, data: emptyEmployee() });
const saveEmployee = () => {
const d = empDialog.data;
if (!d.codice_fiscale || !d.full_name || !d.contract_type ||
!d.period_start_date || !d.period_end_date || d.fte_pct == null) {
toast.current?.show({ severity: 'warn', summary: __('Campi obbligatori mancanti', 'gepafin') });
return;
}
const payload = {
...d,
period_start_date: typeof d.period_start_date === 'string' ? d.period_start_date : d.period_start_date.toISOString().slice(0, 10),
period_end_date: typeof d.period_end_date === 'string' ? d.period_end_date : d.period_end_date.toISOString().slice(0, 10)
};
RendicontazioneService.addUlaEmployee(practiceId, payload,
(resp) => { setEmpDialog({ visible: false, data: null }); afterMutation(__('Dipendente aggiunto', 'gepafin'))(resp); },
onMutationError);
};
const deleteEmployee = (e, emp) => {
confirmPopup({
target: e.currentTarget,
message: __('Rimuovere questo dipendente?', 'gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Rimuovi', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
acceptClassName: 'p-button-danger',
accept: () => RendicontazioneService.deleteUlaEmployee(practiceId, emp.id,
afterMutation(__('Dipendente rimosso', 'gepafin')), onMutationError)
});
};
// documents
const upsertDocument = (docCode, filename) => {
RendicontazioneService.upsertDocument(practiceId, docCode, { doc_code: docCode, filename },
afterMutation(__('Documento aggiornato', 'gepafin')), onMutationError);
};
const clearDocument = (docCode) => {
RendicontazioneService.clearDocument(practiceId, docCode,
afterMutation(__('Documento rimosso', 'gepafin')), onMutationError);
};
// submit
const handleSubmit = (e) => {
confirmPopup({
target: e.currentTarget,
message: __('Confermi l\'invio della pratica di rendicontazione? Dopo l\'invio non potrai più modificarla.', 'gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Invia', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
acceptClassName: 'p-button-success',
accept: () => RendicontazioneService.submitPractice(practiceId,
(resp) => {
toast.current?.show({ severity: 'success', summary: __('Pratica inviata', 'gepafin') });
load();
},
onMutationError)
});
};
const submitAmendmentResponse = () => {
if (!amendDialog.responseText || amendDialog.responseText.trim().length < 5) {
toast.current?.show({ severity: 'warn', summary: __('Risposta troppo corta', 'gepafin') });
return;
}
RendicontazioneService.respondAmendmentBeneficiary(
practiceId, amendDialog.amendment.id, amendDialog.responseText,
(resp) => { setAmendDialog({ visible: false, amendment: null, responseText: '' });
afterMutation(__('Risposta inviata all\'istruttore', 'gepafin'))(resp); },
onMutationError);
};
// ---------- render guards ----------
if (loading) {
return <div className="appPage"><div className="appPageSection"><Skeleton width="100%" height="15rem" /></div></div>;
}
if (!practice) {
return <div className="appPage"><div className="appPageSection"><p>{__('Pratica non trovata', 'gepafin')}</p></div></div>;
}
const statusCfg = STATUS_TAGS[practice.status] || { severity: 'secondary', label: practice.status };
const totals = gate?.totals || {};
const remissionDue = totals.remission_due || 0;
const grandTotal = totals.grand_total || 0;
const maxRemission = totals.max_remission || 0;
const perCategory = totals.per_category || {};
const invoicesOfCat = (code) => practice.invoices.filter(i => i.category_code === code);
return (
<div className="appPage">
<Toast ref={toast} />
<ConfirmPopup />
{/* HEADER */}
<div className="appPage__pageHeader">
<h1>{__('Rendicontazione', 'gepafin')}</h1>
<p>
<span className="companyName">
{practice.schema_snapshot?.template_label || `Bando #${practice.call_id}`}
</span>
<span style={{ marginLeft: '1rem' }}>
<Tag severity={statusCfg.severity} value={statusCfg.label} />
</span>
</p>
</div>
<div className="appPage__spacer"></div>
{/* ACTIONS */}
<div className="appPageSection">
<div className="appPageSection__actions">
<Button type="button" outlined icon="pi pi-arrow-left"
label={__('Torna alla lista', 'gepafin')} onClick={() => navigate('/rendicontazioni')} />
{!readOnly && (
<Button type="button" icon="pi pi-send" iconPos="right" severity="success"
label={__('Invia rendicontazione', 'gepafin')}
onClick={handleSubmit}
disabled={!gate?.passed}
tooltip={!gate?.passed ? __('Completa tutti i requisiti prima di inviare', 'gepafin') : null} />
)}
</div>
</div>
<div className="appPage__spacer"></div>
{/* RIEPILOGO FINANZIARIO */}
<div className="appPageSection" style={{ background: 'var(--surface-50)', padding: '1.25rem', borderRadius: '6px' }}>
<h2 style={{ margin: '0 0 0.5rem 0' }}>{__('Riepilogo', 'gepafin')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem', width: '100%' }}>
<div>
<small className="text-color-secondary">{__('Importo erogato', 'gepafin')}</small>
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{euro(practice.amount_erogato)}</div>
</div>
<div>
<small className="text-color-secondary">{__('Totale fatture rendicontate', 'gepafin')}</small>
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{euro(grandTotal)}</div>
</div>
<div>
<small className="text-color-secondary">{__('Cap remissione massimo', 'gepafin')}</small>
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{euro(maxRemission)}</div>
</div>
<div>
<small className="text-color-secondary">{__('Remissione spettante', 'gepafin')}</small>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--primary-color)' }}>{euro(remissionDue)}</div>
</div>
</div>
</div>
<div className="appPage__spacer"></div>
{/* GATE CHECKS */}
{gate && (
<div className="appPageSection">
<h2>{__('Requisiti per invio', 'gepafin')}</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', width: '100%' }}>
{gate.checks.map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<i className={c.passed ? 'pi pi-check-circle' : 'pi pi-times-circle'}
style={{ color: c.passed ? 'var(--green-500)' : 'var(--orange-500)', fontSize: '1.25rem' }} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{c.label}</div>
<small className="text-color-secondary">{c.detail}</small>
</div>
</div>
))}
</div>
</div>
)}
{/* SOCCORSO ISTRUTTORIO (se presente) */}
{practice.amendments && practice.amendments.length > 0 && (<>
<div className="appPage__spacer"></div>
<div className="appPageSection">
<h2>{__('Richieste di soccorso istruttorio', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('L\'istruttore ha chiesto integrazioni o chiarimenti. Rispondi al più presto.', 'gepafin')}
</p>
<div className="fieldsRepeater">
{practice.amendments.map(a => {
const statusCfg = {
AWAITING: { sev: 'warning', label: 'In attesa della tua risposta' },
RESPONSE_RECEIVED: { sev: 'info', label: 'Risposta inviata, in attesa di chiusura' },
CLOSED: { sev: 'success', label: 'Chiusa' },
EXPIRED: { sev: 'danger', label: 'Scaduta' }
}[a.status] || { sev: 'secondary', label: a.status };
return (
<div key={a.id} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem',
background: a.status === 'AWAITING' ? 'var(--orange-50)' : 'var(--surface-50)' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}>
<div>
<Tag severity={statusCfg.sev} value={statusCfg.label} />
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)' }}>
{__('Scadenza:', 'gepafin')} {new Date(a.deadline).toLocaleDateString('it-IT')}
</span>
</div>
{a.status === 'AWAITING' && (
<Button icon="pi pi-reply" label={__('Rispondi', 'gepafin')} size="small" severity="warning"
onClick={() => setAmendDialog({ visible: true, amendment: a, responseText: '' })} />
)}
</div>
<div>
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>{a.request_text}</div>
{a.response_text && (<>
<small className="text-color-secondary">{__('Tua risposta:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}>{a.response_text}</div>
</>)}
</div>
</div>
);
})}
</div>
</div>
</>)}
<div className="appPage__spacer"></div>
{/* SEZIONE 1: REGIME IVA */}
<div className="appPageSection">
<h2>{__('1. Regime IVA', 'gepafin')}</h2>
<form className="appForm p-fluid" onSubmit={(e) => e.preventDefault()}>
<div className="appForm__field" style={{ maxWidth: '500px' }}>
<label>{__('Seleziona il tuo regime IVA', 'gepafin')}</label>
<Dropdown value={practice.iva_regime}
onChange={(e) => updateIvaRegime(e.value)}
options={ivaAllowed}
placeholder={__('Seleziona...', 'gepafin')}
disabled={readOnly} />
</div>
</form>
</div>
<div className="appPage__spacer"></div>
{/* SEZIONE 2: FATTURE PER CATEGORIA */}
<div className="appPageSection">
<h2>{__('2. Fatture per categoria', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('Carica le fatture assegnandole alla categoria di spesa appropriata. I totali si aggiornano in tempo reale.', 'gepafin')}
</p>
<div className="fieldsRepeater">
{categories.map((cat) => {
const invs = invoicesOfCat(cat.code);
const catTotal = perCategory[cat.code] || 0;
return (
<div key={cat.code} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.75rem' }}>
<div>
<strong style={{ color: 'var(--primary-color)' }}>{cat.code}</strong> {cat.label}
<div><small className="text-color-secondary">{cat.description}</small></div>
</div>
<div style={{ textAlign: 'right' }}>
<div><strong>{euro(catTotal)}</strong></div>
<small className="text-color-secondary">{invs.length} {__('fatture', 'gepafin')}</small>
</div>
</div>
{invs.length > 0 && (
<DataTable value={invs} dataKey="id" size="small" responsiveLayout="scroll">
<Column field="invoice_number" header={__('N°', 'gepafin')} />
<Column field="invoice_date" header={__('Data', 'gepafin')}
body={(r) => formatDate(r.invoice_date)} />
<Column field="supplier_name" header={__('Fornitore', 'gepafin')} />
<Column field="description" header={__('Descrizione', 'gepafin')}
body={(r) => <span title={r.description}>{r.description.slice(0, 40)}{r.description.length > 40 ? '…' : ''}</span>} />
<Column field="taxable" header={__('Imponibile', 'gepafin')} body={(r) => euro(r.taxable)} />
<Column field="total" header={__('Totale', 'gepafin')} body={(r) => euro(r.total)} />
<Column header={__('Allegato PDF', 'gepafin')} style={{ minWidth: '280px' }}
body={(r) => (
<FileUploadCell
entityType="invoice" entityId={r.id}
filename={r.pdf_filename} sizeBytes={r.size_bytes}
readOnly={readOnly}
onPreview={() => openPreview('invoice', r.id, `Fattura ${r.invoice_number}`, r.pdf_filename)}
onChange={(meta) => updateInvoiceFile(r.id, meta)}
toastRef={toast}
/>
)} />
{!readOnly && (
<Column header="" body={(r) => (
<Button icon="pi pi-trash" severity="danger" outlined size="small"
onClick={(e) => deleteInvoice(e, r)} />
)} style={{ width: '60px' }} />
)}
</DataTable>
)}
{!readOnly && (
<div style={{ marginTop: '0.75rem' }}>
<Button type="button" icon="pi pi-plus" outlined size="small"
label={__('Aggiungi fattura', 'gepafin') + ' ' + cat.code}
onClick={() => openAddInvoice(cat.code)} />
</div>
)}
</div>
);
})}
</div>
</div>
{/* SEZIONE 3: ULA */}
{ulaSection.enabled && (<>
<div className="appPage__spacer"></div>
<div className="appPageSection">
<h2>{__('3. Calcolo ULA — Dipendenti', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('Inserisci i dipendenti che contano per l\'incremento occupazionale. Soglia minima richiesta:', 'gepafin')} <strong>{ulaSection.threshold}</strong>.
</p>
{practice.ula_employees.length > 0 && (
<DataTable value={practice.ula_employees} dataKey="id" size="small" responsiveLayout="scroll" style={{ width: '100%', marginBottom: '0.75rem' }}>
<Column field="codice_fiscale" header="CF" />
<Column field="full_name" header={__('Nome', 'gepafin')} />
<Column field="contract_type" header={__('Contratto', 'gepafin')}
body={(r) => (CONTRACT_TYPES.find(c => c.value === r.contract_type)?.label || r.contract_type)} />
<Column field="fte_pct" header="FTE" body={(r) => Number(r.fte_pct).toFixed(2)} />
<Column header={__('Periodo', 'gepafin')}
body={(r) => `${formatDate(r.period_start_date)}${formatDate(r.period_end_date)}`} />
<Column header={__('Allegato', 'gepafin')} style={{ minWidth: '280px' }}
body={(r) => (
<FileUploadCell
entityType="ula" entityId={r.id}
filename={r.supporting_doc_filename} sizeBytes={r.size_bytes}
readOnly={readOnly}
onPreview={() => openPreview('ula', r.id, r.full_name, r.supporting_doc_filename)}
onChange={(meta) => updateUlaFile(r.id, meta)}
toastRef={toast}
/>
)} />
{!readOnly && (
<Column header="" body={(r) => (
<Button icon="pi pi-trash" severity="danger" outlined size="small"
onClick={(e) => deleteEmployee(e, r)} />
)} style={{ width: '60px' }} />
)}
</DataTable>
)}
{!readOnly && (
<Button type="button" icon="pi pi-plus" outlined size="small"
label={__('Aggiungi dipendente', 'gepafin')} onClick={openAddEmployee} />
)}
</div>
</>)}
<div className="appPage__spacer"></div>
{/* SEZIONE 4: DOCUMENTI */}
<div className="appPageSection">
<h2>{__((ulaSection.enabled ? '4.' : '3.') + ' Documenti richiesti', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('Carica un file per ciascun documento richiesto. In questa sandbox viene registrato solo il nome del file (upload reale al prossimo sprint).', 'gepafin')}
</p>
<div className="fieldsRepeater">
{docsRequired.map((dr) => {
const existing = practice.documents.find(d => d.doc_code === dr.code);
const hasFile = !!(existing && existing.filename);
return (
<div key={dr.code} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '0.75rem 1rem',
display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<i className={hasFile ? 'pi pi-check-circle' : 'pi pi-circle'}
style={{ color: hasFile ? 'var(--green-500)' : 'var(--text-color-secondary)', fontSize: '1.25rem' }} />
<div style={{ flex: 1, minWidth: '200px' }}>
<strong>{dr.label}</strong>
<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}
/>
) : !readOnly ? (
<Button type="button" icon="pi pi-upload" size="small" outlined
label={__('Carica', 'gepafin')}
onClick={() => ensureDocRecord(dr.code, () => {/* reload not needed, setPractice already updated */})} />
) : (
<span className="text-color-secondary">{__('Nessun file', 'gepafin')}</span>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="appPage__spacer"></div>
{/* BOTTOM ACTIONS */}
{!readOnly && (
<div className="appPageSection">
<div className="appPageSection__actions">
<Button type="button" icon="pi pi-send" iconPos="right" severity="success"
label={__('Invia rendicontazione', 'gepafin')}
onClick={handleSubmit}
disabled={!gate?.passed} />
</div>
</div>
)}
{/* ---------- DIALOG FATTURA ---------- */}
<Dialog visible={invDialog.visible} style={{ width: '640px', maxWidth: '95vw' }}
header={__('Aggiungi fattura', 'gepafin') + (invDialog.data?.category_code ? `${invDialog.data.category_code}` : '')}
modal onHide={() => setInvDialog({ visible: false, data: null })}>
{invDialog.data && (
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); saveInvoice(); }}>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Numero fattura', 'gepafin')}</label>
<InputText value={invDialog.data.invoice_number}
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, invoice_number: e.target.value } }))} />
</div>
<div className="appForm__field">
<label>{__('Data fattura', 'gepafin')}</label>
<Calendar value={invDialog.data.invoice_date} dateFormat="dd/mm/yy" showIcon
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, invoice_date: e.value } }))} />
</div>
<div className="appForm__field">
<label>{__('Data pagamento', 'gepafin')}</label>
<Calendar value={invDialog.data.payment_date} dateFormat="dd/mm/yy" showIcon
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, payment_date: e.value } }))} />
</div>
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Fornitore', 'gepafin')}</label>
<InputText value={invDialog.data.supplier_name}
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, supplier_name: e.target.value } }))} />
</div>
<div className="appForm__field">
<label>{__('P. IVA fornitore', 'gepafin')}</label>
<InputText value={invDialog.data.supplier_vat}
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, supplier_vat: e.target.value } }))} />
</div>
</div>
<div className="appForm__field">
<label>{__('Descrizione', 'gepafin')}</label>
<InputTextarea value={invDialog.data.description} rows={2} autoResize
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, description: e.target.value } }))} />
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Imponibile (€)', 'gepafin')}</label>
<InputNumber value={invDialog.data.taxable} mode="currency" currency="EUR" locale="it-IT"
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, taxable: e.value } }))} />
</div>
<div className="appForm__field">
<label>{__('IVA (€)', 'gepafin')}</label>
<InputNumber value={invDialog.data.vat} mode="currency" currency="EUR" locale="it-IT"
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, vat: e.value } }))} />
</div>
<div className="appForm__field">
<label>{__('Totale (€)', 'gepafin')}</label>
<InputNumber value={invDialog.data.total} mode="currency" currency="EUR" locale="it-IT"
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, total: e.value } }))} />
</div>
</div>
<div className="appForm__field" style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em', padding: '0.5rem 0.75rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
<i className="pi pi-info-circle" style={{ marginRight: '0.4rem' }} />
{__('Dopo aver salvato la fattura potrai caricare il PDF dalla tabella.', 'gepafin')}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setInvDialog({ visible: false, data: null })} />
<Button type="submit" label={__('Aggiungi', 'gepafin')} icon="pi pi-check" />
</div>
</form>
)}
</Dialog>
{/* ---------- DIALOG RISPOSTA SOCCORSO ---------- */}
<Dialog visible={amendDialog.visible} style={{ width: '560px' }}
header={__('Rispondi al soccorso istruttorio', 'gepafin')} modal
onHide={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })}>
{amendDialog.amendment && (
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); submitAmendmentResponse(); }}>
<div style={{ padding: '0.75rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '1rem' }}>
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', marginTop: '0.25rem' }}>{amendDialog.amendment.request_text}</div>
</div>
<div className="appForm__field">
<label>{__('La tua risposta', 'gepafin')}</label>
<InputTextarea value={amendDialog.responseText} rows={5} autoResize
onChange={(e) => setAmendDialog(d => ({ ...d, responseText: e.target.value }))}
placeholder={__('Descrivi le integrazioni fornite, allegati caricati, chiarimenti...', 'gepafin')} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })} />
<Button type="submit" label={__('Invia risposta', 'gepafin')} icon="pi pi-send" severity="warning" />
</div>
</form>
)}
</Dialog>
{/* ---------- DIALOG DIPENDENTE ULA ---------- */}
<Dialog visible={empDialog.visible} style={{ width: '620px', maxWidth: '95vw' }}
header={__('Aggiungi dipendente', 'gepafin')}
modal onHide={() => setEmpDialog({ visible: false, data: null })}>
{empDialog.data && (
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); saveEmployee(); }}>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Codice fiscale', 'gepafin')}</label>
<InputText value={empDialog.data.codice_fiscale}
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, codice_fiscale: e.target.value.toUpperCase() } }))} />
</div>
<div className="appForm__field">
<label>{__('Nome e cognome', 'gepafin')}</label>
<InputText value={empDialog.data.full_name}
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, full_name: e.target.value } }))} />
</div>
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Tipo contratto', 'gepafin')}</label>
<Dropdown value={empDialog.data.contract_type} options={CONTRACT_TYPES}
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, contract_type: e.value } }))} />
</div>
<div className="appForm__field">
<label>{__('Mansione (opzionale)', 'gepafin')}</label>
<InputText value={empDialog.data.role_description}
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, role_description: e.target.value } }))} />
</div>
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Percentuale tempo (0-1)', 'gepafin')}</label>
<InputNumber value={empDialog.data.fte_pct} mode="decimal" minFractionDigits={2} maxFractionDigits={4} min={0} max={1}
onValueChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, fte_pct: e.value } }))} />
</div>
<div className="appForm__field">
<label>{__('Inizio periodo', 'gepafin')}</label>
<Calendar value={empDialog.data.period_start_date} dateFormat="dd/mm/yy" showIcon
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, period_start_date: e.value } }))} />
</div>
<div className="appForm__field">
<label>{__('Fine periodo', 'gepafin')}</label>
<Calendar value={empDialog.data.period_end_date} dateFormat="dd/mm/yy" showIcon
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, period_end_date: e.value } }))} />
</div>
</div>
<div className="appForm__cols">
<div className="appForm__field">
<label>{__('Tipo documento di supporto', 'gepafin')}</label>
<Dropdown value={empDialog.data.supporting_doc_type}
options={(ulaSection.supporting_doc_types || []).map(t => typeof t === 'string' ? { value: t, label: t } : { value: t.code, label: t.label })}
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, supporting_doc_type: e.value } }))} />
</div>
</div>
<div className="appForm__field" style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em', padding: '0.5rem 0.75rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
<i className="pi pi-info-circle" style={{ marginRight: '0.4rem' }} />
{__("Dopo aver salvato il dipendente potrai caricare il LUL o il documento di supporto dalla tabella.", 'gepafin')}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setEmpDialog({ visible: false, data: null })} />
<Button type="submit" label={__('Aggiungi', 'gepafin')} icon="pi pi-check" />
</div>
</form>
)}
</Dialog>
<FilePreviewDialog
visible={previewDialog.visible}
onHide={closePreview}
entityType={previewDialog.entityType}
entityId={previewDialog.entityId}
title={previewDialog.title}
filename={previewDialog.filename}
/>
</div>
);
};
export default PraticaRendicontazioneEdit;