diff --git a/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js b/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js index 19df8ed..f547e51 100644 --- a/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js +++ b/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js @@ -31,6 +31,12 @@ const IVA_REGIMES = [ { value: 'ESENTE', label: 'Esente' } ]; +const AMOUNT_BASIS_OPTIONS = [ + { value: 'imponibile_always', label: 'Solo imponibile (sempre)' }, + { value: 'imponibile_only_ordinario', label: 'Imponibile in ordinario, totale in forfettario' }, + { value: 'totale_always', label: 'Totale IVA inclusa (sempre)' } +]; + const PERIOD_START_RULES = [ { value: 'erogato_date', label: 'Data di erogazione del finanziamento' }, { value: 'contract_signed_date', label: 'Data firma contratto' }, @@ -63,6 +69,7 @@ const schemaJsonToForm = (j) => { amount_max: gate.amount_range?.max ?? 25000, period_start: gate.period_start ? new Date(gate.period_start) : null, period_end: gate.period_end ? new Date(gate.period_end) : null, + amount_basis: gate.amount_basis || (gate.iva_ordinario_imponibile_only === false ? 'totale_always' : 'imponibile_only_ordinario'), period_start_rule: gate.period_start_rule ?? 'erogato_date', iva_regimes_allowed: ivaAllowed, iva_ordinario_imponibile_only: gate.iva_ordinario_imponibile_only ?? true, @@ -128,6 +135,7 @@ const formToSchemaJson = (f, base = null) => { period_start_rule: f.period_start_rule, period_start: fmtDate(f.period_start), period_end: fmtDate(f.period_end), + amount_basis: f.amount_basis, require_at_least_one_invoice_per_nonzero_category: f.require_invoice_per_category, require_ula_above_threshold: f.require_ula_above_threshold, require_all_documents_resolved: f.require_all_documents_resolved @@ -352,6 +360,16 @@ const BandoRendicontazioneSchemaEdit = () => { dateFormat="dd/mm/yy" showIcon disabled={readOnly} /> + +
+ + update({amount_basis: e.value})} + options={AMOUNT_BASIS_OPTIONS} disabled={readOnly} /> + + {__("Determina su quale importo delle fatture si calcola la remissione. La norma del bando può prevedere regimi diversi.", 'gepafin')} + +
diff --git a/src/modules/rendicontazione/pages/IstruttoriaPratica.js b/src/modules/rendicontazione/pages/IstruttoriaPratica.js index e9914c6..f294583 100644 --- a/src/modules/rendicontazione/pages/IstruttoriaPratica.js +++ b/src/modules/rendicontazione/pages/IstruttoriaPratica.js @@ -7,13 +7,13 @@ import { Toast } from 'primereact/toast'; import { Tag } from 'primereact/tag'; import { Skeleton } from 'primereact/skeleton'; import { Dialog } from 'primereact/dialog'; -import { InputText } from 'primereact/inputtext'; import { InputNumber } from 'primereact/inputnumber'; import { InputTextarea } from 'primereact/inputtextarea'; import { Calendar } from 'primereact/calendar'; +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; import { Checkbox } from 'primereact/checkbox'; import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup'; -import { isNil } from 'ramda'; import RendicontazioneService from '../service/rendicontazioneService'; @@ -68,9 +68,12 @@ const IstruttoriaPratica = () => { // dialoghi const [previewDialog, setPreviewDialog] = useState({ visible: false, filename: null, title: null }); - const [invRectDialog, setInvRectDialog] = useState({ visible: false, invoice: null }); - const [ulaRectDialog, setUlaRectDialog] = useState({ visible: false, employee: null }); const [docNoteDialog, setDocNoteDialog] = useState({ visible: false, doc: null, status: null }); + // tabelle: expanded rows + buffer modifiche inline + const [expandedInv, setExpandedInv] = useState({}); + const [expandedUla, setExpandedUla] = useState({}); + const [invDraft, setInvDraft] = useState({}); // { invoiceId: { amount_verified, notes } } + const [ulaDraft, setUlaDraft] = useState({}); // { employeeId: { fte_pct_verified, notes } } const [approveDialog, setApproveDialog] = useState({ visible: false, amount: null }); const [rejectDialog, setRejectDialog] = useState({ visible: false, reason: '' }); @@ -140,31 +143,97 @@ const IstruttoriaPratica = () => { const quickVerifyDoc = (doc, newStatus) => { RendicontazioneService.verifyDocument(practiceId, doc.doc_code, { verification_status: newStatus, verification_notes: null }, - afterOk(__(`Documento ${newStatus.toLowerCase()}`, 'gepafin')), onErr); + (resp) => { + const updated = resp?.data || {}; + setBundle(b => { + if (!b) return b; + const exists = b.practice.documents.find(d => d.doc_code === doc.doc_code); + const newDocs = exists + ? b.practice.documents.map(d => d.doc_code === doc.doc_code ? { ...d, ...updated } : d) + : [...b.practice.documents, updated]; + return { ...b, practice: { ...b.practice, documents: newDocs } }; + }); + toast.current?.show({ severity: 'success', summary: __(`Documento ${newStatus.toLowerCase()}`, 'gepafin') }); + }, onErr); }; - const saveInvoiceRect = () => { - const i = invRectDialog.invoice; - RendicontazioneService.verifyInvoice(practiceId, i.id, { - verification_status: 'PARZIALE', - taxable_verified: i.taxable_verified, - vat_verified: i.vat_verified, - total_verified: i.total_verified, - verification_notes: i.verification_notes - }, - (resp) => { setInvRectDialog({ visible: false, invoice: null }); afterOk(__('Rettifica salvata', 'gepafin'))(resp); }, - onErr); + // Refresh solo gate_check (totali) senza rileggere tutta la pratica + const refreshGateOnly = () => { + RendicontazioneService.gateCheck(practiceId, + (resp) => setBundle(b => b ? { ...b, gate_check: resp?.data } : b), + () => {}); }; - const saveUlaRect = () => { - const e = ulaRectDialog.employee; - RendicontazioneService.verifyUlaEmployee(practiceId, e.id, { - verification_status: 'PARZIALE', - fte_pct_verified: e.fte_pct_verified, - verification_notes: e.verification_notes - }, - (resp) => { setUlaRectDialog({ visible: false, employee: null }); afterOk(__('Rettifica ULA salvata', 'gepafin'))(resp); }, - onErr); + // Save INLINE fattura con update LOCALE (no reload pagina) + const saveInvoiceInline = (invoice, explicitStatus = null) => { + const draft = invDraft[invoice.id] || {}; + const useTaxable = (gate?.totals?.use_taxable_only ?? true); + const declared = Number(useTaxable ? invoice.taxable : invoice.total); + const verified = draft.amount_verified != null ? Number(draft.amount_verified) : declared; + const notes = draft.notes != null ? draft.notes : invoice.verification_notes; + + let status = explicitStatus; + if (!status) { + if (verified <= 0) status = 'RESPINTA'; + else if (Math.abs(verified - declared) < 0.005) status = 'AMMESSA'; + else status = 'PARZIALE'; + } + const body = { verification_status: status, verification_notes: notes || null }; + if (status !== 'RESPINTA' && status !== 'PENDING') { + if (useTaxable) { + body.taxable_verified = verified; + body.vat_verified = invoice.vat; + body.total_verified = Number(verified) + Number(invoice.vat || 0); + } else { + body.total_verified = verified; + body.vat_verified = invoice.vat; + body.taxable_verified = Number(verified) - Number(invoice.vat || 0); + } + } + RendicontazioneService.verifyInvoice(practiceId, invoice.id, body, + (resp) => { + setInvDraft(prev => { const n = {...prev}; delete n[invoice.id]; return n; }); + const updated = resp?.data || {}; + // update LOCALE della singola fattura (no full page reload!) + setBundle(b => { + if (!b) return b; + const newInvoices = b.practice.invoices.map(i => i.id === invoice.id ? { ...i, ...updated } : i); + return { ...b, practice: { ...b.practice, invoices: newInvoices } }; + }); + refreshGateOnly(); + toast.current?.show({ severity: 'success', summary: __(`Fattura ${status.toLowerCase()}`, 'gepafin') }); + }, onErr); + }; + + // Save INLINE ULA con update LOCALE (no reload pagina) + const saveUlaInline = (emp, explicitStatus = null) => { + const draft = ulaDraft[emp.id] || {}; + const declared = Number(emp.fte_pct); + const verified = draft.fte_pct_verified != null ? Number(draft.fte_pct_verified) : declared; + const notes = draft.notes != null ? draft.notes : emp.verification_notes; + + let status = explicitStatus; + if (!status) { + if (verified <= 0) status = 'RESPINTA'; + else if (Math.abs(verified - declared) < 0.0005) status = 'AMMESSA'; + else status = 'PARZIALE'; + } + const body = { verification_status: status, verification_notes: notes || null }; + if (status !== 'RESPINTA' && status !== 'PENDING') { + body.fte_pct_verified = verified; + } + RendicontazioneService.verifyUlaEmployee(practiceId, emp.id, body, + (resp) => { + setUlaDraft(prev => { const n = {...prev}; delete n[emp.id]; return n; }); + const updated = resp?.data || {}; + setBundle(b => { + if (!b) return b; + const newUlas = b.practice.ula_employees.map(x => x.id === emp.id ? { ...x, ...updated } : x); + return { ...b, practice: { ...b.practice, ula_employees: newUlas } }; + }); + refreshGateOnly(); + toast.current?.show({ severity: 'success', summary: __(`Dipendente ${status.toLowerCase()}`, 'gepafin') }); + }, onErr); }; const saveDocNote = () => { @@ -172,8 +241,19 @@ const IstruttoriaPratica = () => { const status = docNoteDialog.status; RendicontazioneService.verifyDocument(practiceId, d.doc_code, { verification_status: status, verification_notes: d.verification_notes }, - (resp) => { setDocNoteDialog({ visible: false, doc: null, status: null }); afterOk(__('Documento aggiornato', 'gepafin'))(resp); }, - onErr); + (resp) => { + setDocNoteDialog({ visible: false, doc: null, status: null }); + const updated = resp?.data || {}; + setBundle(b => { + if (!b) return b; + const exists = b.practice.documents.find(x => x.doc_code === d.doc_code); + const newDocs = exists + ? b.practice.documents.map(x => x.doc_code === d.doc_code ? { ...x, ...updated } : x) + : [...b.practice.documents, updated]; + return { ...b, practice: { ...b.practice, documents: newDocs } }; + }); + toast.current?.show({ severity: 'success', summary: __('Documento aggiornato', 'gepafin') }); + }, onErr); }; // Final notes + checklist (debounced inline save) @@ -328,6 +408,32 @@ const IstruttoriaPratica = () => {
+ {/* BANNER pratica non presa in carico */} + {practice.status === 'SUBMITTED' && ( +
+
+ +
+ {__("Pratica non ancora presa in carico", 'gepafin')} +
+ {__("Per poter verificare fatture, dipendenti ULA e documenti, clicca su «Prendi in carico» qui sopra. Lo stato della pratica passerà a «In lavorazione».", 'gepafin')} +
+
+
+
+ )} + + {practice.status === 'SUBMITTED' &&
} + {/* RIEPILOGO */}

{__('Riepilogo finanziario', 'gepafin')}

@@ -416,158 +522,328 @@ const IstruttoriaPratica = () => {
)} - {/* FATTURE PER CATEGORIA — pattern ListOfFiles */} + {/* FATTURE — tabella inline unica con raggruppamento per categoria */}

{__('Verifica fatture', 'gepafin')}

- {__('Per ogni fattura: anteprima, download, pollice su per ammettere, pollice giù per respingere, icona matita per rettificare importi ammissibili.', 'gepafin')} + {__("Modifica l'importo ammesso direttamente nella riga (salvataggio automatico quando esci dal campo). Clicca ▸ per aprire le note.", 'gepafin')}

- {categories.map(cat => { - const invs = invoicesOfCat(cat.code); - const totalDecl = perDecl[cat.code] || 0; - const totalVerif = perVerif[cat.code] || 0; - return ( -
-
-

- {cat.code} — {cat.label} -

-
-
{__('Dichiarato:', 'gepafin')} {euro(totalDecl)}
-
{__('Verificato:', 'gepafin')} {euro(totalVerif)}
-
-
+ {(() => { + const useTaxable = totals.use_taxable_only !== false; + const declaredLabel = useTaxable ? __('Imponibile dichiarato', 'gepafin') : __('Totale dichiarato', 'gepafin'); + const ammessoLabel = useTaxable ? __('Imponibile ammesso', 'gepafin') : __('Totale ammesso', 'gepafin'); + // Ordino le fatture secondo l'ordine definito dallo schema (B1 < B2 < B3) + const catOrder = Object.fromEntries(categories.map((c, i) => [c.code, i])); + const sortedInvoices = [...practice.invoices].sort((a, b) => { + const oa = catOrder[a.category_code] ?? 999; + const ob = catOrder[b.category_code] ?? 999; + if (oa !== ob) return oa - ob; + return (a.invoice_number || '').localeCompare(b.invoice_number || ''); + }); - {invs.length === 0 ? ( - {__('Nessuna fattura in questa categoria', 'gepafin')} - ) : ( -
    - {invs.map((inv) => { - const statusCfg = VERIFICATION_INVOICE_TAG[inv.verification_status] || VERIFICATION_INVOICE_TAG.PENDING; - const dateChecks = inv.date_checks || {}; - const invalidDates = dateChecks.invoice_in_period === false || dateChecks.payment_in_period === false; - const hasRect = inv.verification_status === 'PARZIALE' && inv.taxable_verified != null; - return ( -
  1. -
    -
    -
    - {inv.invoice_number} - - {inv.supplier_name} - - {invalidDates && } -
    -
    - {__('Emessa', 'gepafin')} {formatDate(inv.invoice_date)} · {__('pagata', 'gepafin')} {formatDate(inv.payment_date)} · {inv.description} -
    -
    - {__('Dichiarato imp.:', 'gepafin')} {euro(inv.taxable)} · IVA {euro(inv.vat)} · tot {euro(inv.total)} - {hasRect && ( - - {__('Verificato imp.:', 'gepafin')} {euro(inv.taxable_verified)} · IVA {euro(inv.vat_verified)} · tot {euro(inv.total_verified)} - - )} -
    - {inv.verification_notes && ( -
    - {inv.verification_notes} -
    - )} -
    - {renderThumbsRow( - inv.verification_status, - () => quickVerifyInvoice(inv, 'AMMESSA'), - () => quickVerifyInvoice(inv, 'RESPINTA'), - () => openPreview(inv.pdf_filename || `fattura_${inv.invoice_number}.pdf`, `${__('Fattura', 'gepafin')} ${inv.invoice_number}`), - () => downloadStub(inv.pdf_filename || `fattura_${inv.invoice_number}.pdf`), - inv.pdf_filename || `fattura_${inv.invoice_number}.pdf`, -
    -
  2. - ); - })} -
- )} -
+ if (sortedInvoices.length === 0) { + return

{__('Nessuna fattura caricata', 'gepafin')}

; + } + + return ( + setExpandedInv(e.data)} + rowGroupHeaderTemplate={(row) => { + const cat = categories.find(c => c.code === row.category_code) || { code: row.category_code, label: '—' }; + const totalDecl = perDecl[row.category_code] || 0; + const totalVerif = perVerif[row.category_code] || 0; + return ( +
+
+ {cat.code} + {cat.label} +
+
+
{__('Dichiarato:', 'gepafin')} {euro(totalDecl)}
+
{__('Ammesso:', 'gepafin')} {euro(totalVerif)}
+
+
+ ); + }} + rowExpansionTemplate={(inv) => { + const draft = invDraft[inv.id] || {}; + return ( +
+
+
+ + setInvDraft(d => ({ ...d, [inv.id]: { ...(d[inv.id]||{}), notes: ev.target.value } }))} + onBlur={() => { + const d = invDraft[inv.id] || {}; + if (d.notes != null && d.notes !== (inv.verification_notes || '')) { + saveInvoiceInline(inv); + } + }} + placeholder={__('Es: decurtata quota di 400€ per assicurazione accessoria non ammissibile...', 'gepafin')} /> +
+
+
{__('Dettaglio dichiarato:', 'gepafin')}
+
{__('Imponibile:', 'gepafin')} {euro(inv.taxable)}
+
IVA: {euro(inv.vat)} — {__('Totale:', 'gepafin')} {euro(inv.total)}
+
+
+
+ ); + }} + > + + + { + const bad = r.date_checks && (r.date_checks.invoice_in_period === false || r.date_checks.payment_in_period === false); + return + {formatDate(r.invoice_date)} + {bad && } + ; + }} /> + + {r.description.length > 50 ? r.description.slice(0, 50) + '…' : r.description}} /> + {euro(useTaxable ? r.taxable : r.total)}} /> + { + const draft = invDraft[r.id] || {}; + const declared = Number(useTaxable ? r.taxable : r.total); + const currentVerified = r.verification_status === 'PENDING' + ? declared + : (useTaxable ? (r.taxable_verified != null ? Number(r.taxable_verified) : declared) + : (r.total_verified != null ? Number(r.total_verified) : declared)); + const displayVal = draft.amount_verified != null ? draft.amount_verified : currentVerified; + return ( + setInvDraft(d => ({ ...d, [r.id]: { ...(d[r.id]||{}), amount_verified: ev.value } }))} + onBlur={() => { + const d = invDraft[r.id]; + if (d && d.amount_verified != null && Math.abs(d.amount_verified - currentVerified) > 0.005) { + saveInvoiceInline(r); + } + }} + /> + ); + }} /> + { + const cfg = VERIFICATION_INVOICE_TAG[r.verification_status] || VERIFICATION_INVOICE_TAG.PENDING; + return ; + }} /> + ( +
+
+ )} /> +
); - })} + })()}
- {/* ULA DIPENDENTI */} + {/* ULA DIPENDENTI — DataTable semplice con box header sopra */} {ulaSection.enabled && practice.ula_employees.length > 0 && (<>

{__('Verifica dipendenti ULA', 'gepafin')}

-
    - {practice.ula_employees.map(e => { - const cfg = VERIFICATION_INVOICE_TAG[e.verification_status] || VERIFICATION_INVOICE_TAG.PENDING; - return ( -
  1. -
    -
    -
    - {e.full_name} - ({e.codice_fiscale}) - -
    -
    - {CONTRACT_TYPES[e.contract_type] || e.contract_type} - {e.role_description && ` · ${e.role_description}`} - {' · '}{formatDate(e.period_start_date)} → {formatDate(e.period_end_date)} -
    -
    - {__('FTE dichiarato:', 'gepafin')} {Number(e.fte_pct).toFixed(2)} - {e.fte_pct_verified != null && ( - - {__('FTE verificato:', 'gepafin')} {Number(e.fte_pct_verified).toFixed(2)} - - )} -
    - {e.verification_notes && ( -
    - {e.verification_notes} -
    - )} -
    - {renderThumbsRow( - e.verification_status, - () => quickVerifyUla(e, 'AMMESSA'), - () => quickVerifyUla(e, 'RESPINTA'), - () => openPreview(e.supporting_doc_filename, `${__('Documento ULA', 'gepafin')} — ${e.full_name}`), - () => downloadStub(e.supporting_doc_filename), - e.supporting_doc_filename, -
  2. - ); - })} -
+
+
{__('FTE dichiarato:', 'gepafin')} {totalFteDecl.toFixed(2)}
+
{__('FTE ammesso:', 'gepafin')} {totalFteVerif.toFixed(2)}
+
+
+ + setExpandedUla(e.data)} + rowExpansionTemplate={(emp) => { + const draft = ulaDraft[emp.id] || {}; + return ( +
+
+
+ + setUlaDraft(d => ({ ...d, [emp.id]: { ...(d[emp.id]||{}), notes: ev.target.value } }))} + onBlur={() => { + const d = ulaDraft[emp.id] || {}; + if (d.notes != null && d.notes !== (emp.verification_notes || '')) { + saveUlaInline(emp); + } + }} + placeholder={__('Es: dipendente verificato part-time 50% su LUL, non full-time come dichiarato...', 'gepafin')} /> +
+
+ {__('Dettaglio:', 'gepafin')} {CONTRACT_TYPES[emp.contract_type] || emp.contract_type} + {emp.role_description && ` · ${emp.role_description}`} + {' · '}{formatDate(emp.period_start_date)} → {formatDate(emp.period_end_date)} +
+
+
+ ); + }} + > + + + + {CONTRACT_TYPES[r.contract_type] || r.contract_type}} /> + {formatDate(r.period_start_date)} → {formatDate(r.period_end_date)}} /> + {Number(r.fte_pct).toFixed(2)}} /> + { + const draft = ulaDraft[r.id] || {}; + const declared = Number(r.fte_pct); + const currentVerified = r.verification_status === 'PENDING' + ? declared + : (r.fte_pct_verified != null ? Number(r.fte_pct_verified) : declared); + const displayVal = draft.fte_pct_verified != null ? draft.fte_pct_verified : currentVerified; + return ( + setUlaDraft(d => ({ ...d, [r.id]: { ...(d[r.id]||{}), fte_pct_verified: ev.value } }))} + onBlur={() => { + const d = ulaDraft[r.id]; + if (d && d.fte_pct_verified != null && Math.abs(d.fte_pct_verified - currentVerified) > 0.0005) { + saveUlaInline(r); + } + }} + /> + ); + }} /> + { + const cfg = VERIFICATION_INVOICE_TAG[r.verification_status] || VERIFICATION_INVOICE_TAG.PENDING; + return ; + }} /> + ( +
+
+ )} /> +
+
+ ); + })()} )} @@ -599,19 +875,43 @@ const IstruttoriaPratica = () => { )} - {renderThumbsRow( - doc.verification_status, - () => quickVerifyDoc(doc, 'VALIDO'), - () => setDocNoteDialog({ visible: true, doc: { ...doc, verification_notes: doc.verification_notes || '' }, status: 'NON_VALIDO' }), - () => openPreview(doc.filename, `${dr.label} — ${doc.filename || ''}`), - () => downloadStub(doc.filename), - doc.filename, +
+
); @@ -681,92 +981,10 @@ const IstruttoriaPratica = () => { {/* Rettifica fattura */} - setInvRectDialog({ visible: false, invoice: null })}> - {invRectDialog.invoice && ( -
{ e.preventDefault(); saveInvoiceRect(); }}> -
- {invRectDialog.invoice.invoice_number} — {invRectDialog.invoice.supplier_name}
- {invRectDialog.invoice.description} -
-

{__('Dichiarato dal beneficiario', 'gepafin')}

-
-
- - -
-
- - -
-
- - -
-
-

{__('Valori verificati (rettifica)', 'gepafin')}

-
-
- - setInvRectDialog(d => ({ ...d, invoice: { ...d.invoice, taxable_verified: e.value } }))} /> -
-
- - setInvRectDialog(d => ({ ...d, invoice: { ...d.invoice, vat_verified: e.value } }))} /> -
-
- - setInvRectDialog(d => ({ ...d, invoice: { ...d.invoice, total_verified: e.value } }))} /> -
-
-
- - setInvRectDialog(d => ({ ...d, invoice: { ...d.invoice, verification_notes: e.target.value } }))} - placeholder={__('Esempio: decurtata quota di euro X per voce non ammissibile (assicurazione)...', 'gepafin')} /> -
-
-
-
- )} -
+ {/* Rettifica ULA */} - setUlaRectDialog({ visible: false, employee: null })}> - {ulaRectDialog.employee && ( -
{ e.preventDefault(); saveUlaRect(); }}> -
- {ulaRectDialog.employee.full_name} ({ulaRectDialog.employee.codice_fiscale})
- FTE dichiarato: {Number(ulaRectDialog.employee.fte_pct).toFixed(2)} -
-
- - setUlaRectDialog(d => ({ ...d, employee: { ...d.employee, fte_pct_verified: e.value } }))} /> -
-
- - setUlaRectDialog(d => ({ ...d, employee: { ...d.employee, verification_notes: e.target.value } }))} - placeholder={__('Esempio: dipendente in part-time verificato al 60% non 100%...', 'gepafin')} /> -
-
-
-
- )} -
+ {/* Note documento (scaduto/non valido) */}