From 381fd64fefb0e810e53dbd8907a2bf66b6157e95 Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Sat, 18 Apr 2026 17:53:04 +0200 Subject: [PATCH] feat(v2): FE multi-tranche + custom_checks + manager view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service/rendicontazioneService.js: - startPractice(appId, cb, cb, opts) v2: accetta period_label + copy_ula_from_previous - copyUlaOptions(practiceId): preview ULA tranche N-1 per pre-fill - Custom checks: listCustomChecks, declareCustomCheck (form-data + optional file), deleteCustomCheckDocument, verifyCustomCheck, fetchCustomCheckDocumentBlob - Manager: managerAssignments, managerInstructorsList, reassignInstructor B1 BandoRendicontazioneSchemaEdit.js (editor superadmin): - schemaJsonToForm: estrae gate_rules.max_tranches + custom_checks[] top-level - formToSchemaJson: scrive max_tranches e custom_checks + schema_version=2 - Helpers addCheck/removeCheck/updateCheck (pattern fieldsRepeater esistente) - Sezione 7 'Tranches di rendicontazione': InputNumber max_tranches (1-20) - Sezione 8 'Controlli aggiuntivi': array editable con code (snake_case sanitized), label, description, requires_document, required B2 RendicontazioniMie.js (dashboard benef) — RISCRITTA: - Raggruppamento per application_id con card per bando - Riquadro info cumulativo (cap totale, gia approvato, disponibile, tranches N/M) - Elenco tranche con badge stato + bottoni 'Continua' (DRAFT) / 'Apri' (non editable) - Bottone '+ Nuova rendicontazione' con 4 stati: attivo / disabilitato 'Limite raggiunto' / 'Completa prima' / 'Remissione esaurita' - Dialog avvio: InputText period_label + Checkbox copy_ula (solo se sequence > 1) B3 PraticaRendicontazioneEdit.js (beneficiario): - useMemo customChecksDefs da schema_snapshot.custom_checks - State customChecks + loadCustomChecks useCallback - Sezione 5/4 'Controlli aggiuntivi (dichiarazioni)': per ogni check checkbox 'Dichiaro', badge Obbligatorio/Opzionale/status, upload PDF/JPG/PNG 15MB se requires_document, preview filename+size - Bordo rosso su check obbligatori non dichiarati B4 IstruttoriaPratica.js (istruttore): - State customChecks + loadCustomChecks + ccVerifyDialog - Sezione 'Verifica controlli aggiuntivi' (dopo Verifica documenti): lista con label/codice/badge stato beneficiario/validazione/note istruttore - Azioni: preview, download, thumbs-up (VALIDO toggle), thumbs-down (NON_VALIDO) - Dialog motivazione NON_VALIDO con InputTextarea (min 5 char) B5 IstruttoriaQueue.js (manager): - Toggle 'Coda standard' vs 'Vista manager (riassegnazioni)' visibile solo per ROLE_INSTRUCTOR_MANAGER o ROLE_SUPER_ADMIN - Tabella manager con colonne: Bando/Pratica/Tranche, Stato, Istruttore domanda, Assegnato a (o badge 'Da assegnare' se unassigned), Erogato - Azione 'Riassegna' (o 'Assegna' se unassigned): apre Dialog con Dropdown istruttori (pool pre_instructor + manager) + InputTextarea motivazione - Opzione 'Metti in coda (nessuno)' nel Dropdown per unassign Tutti i file validati via @babel/parser JSX. Webpack compila senza errori (solo warning eslint preesistenti non-B). --- .../pages/BandoRendicontazioneSchemaEdit.js | 132 ++++++++- .../pages/IstruttoriaPratica.js | 176 ++++++++++- .../rendicontazione/pages/IstruttoriaQueue.js | 262 +++++++++++++++-- .../pages/PraticaRendicontazioneEdit.js | 122 +++++++- .../pages/RendicontazioniMie.js | 277 +++++++++++++----- .../service/rendicontazioneService.js | 96 +++++- 6 files changed, 960 insertions(+), 105 deletions(-) diff --git a/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js b/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js index f547e51..98e1589 100644 --- a/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js +++ b/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js @@ -88,7 +88,16 @@ const schemaJsonToForm = (j) => { cap_absolute: gate.cap_absolute ?? 12500, require_invoice_per_category: gate.require_at_least_one_invoice_per_nonzero_category ?? true, require_ula_above_threshold: gate.require_ula_above_threshold ?? true, - require_all_documents_resolved: gate.require_all_documents_resolved ?? true + require_all_documents_resolved: gate.require_all_documents_resolved ?? true, + // v2 multi-tranche + custom_checks + max_tranches: gate.max_tranches ?? 1, + custom_checks: (j.custom_checks || []).map(cc => ({ + code: cc.code || '', + label: cc.label || '', + description: cc.description || '', + requires_document: !!cc.requires_document, + required: !!cc.required, + })) }; }; @@ -138,8 +147,17 @@ const formToSchemaJson = (f, base = null) => { 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 - } + require_all_documents_resolved: f.require_all_documents_resolved, + max_tranches: f.max_tranches || 1 + }, + custom_checks: (f.custom_checks || []).map(cc => ({ + code: cc.code, + label: cc.label, + description: cc.description, + requires_document: !!cc.requires_document, + required: !!cc.required, + })), + schema_version: 2 }; }; @@ -215,6 +233,20 @@ const BandoRendicontazioneSchemaEdit = () => { setDirty(true); }; + // v2 custom_checks + const updateCheck = (idx, patch) => { + setForm(p => ({ ...p, custom_checks: p.custom_checks.map((c,i) => i===idx ? {...c, ...patch} : c) })); + setDirty(true); + }; + const addCheck = () => { + setForm(p => ({ ...p, custom_checks: [...(p.custom_checks || []), { code:'', label:'', description:'', requires_document:false, required:false }] })); + setDirty(true); + }; + const removeCheck = (idx) => { + setForm(p => ({ ...p, custom_checks: p.custom_checks.filter((_,i) => i!==idx) })); + setDirty(true); + }; + // ---------- actions ---------- const handleInitializeRestart = (e) => { confirmPopup({ @@ -594,6 +626,100 @@ const BandoRendicontazioneSchemaEdit = () => {
+ {/* 7 - TRANCHES + CUSTOM CHECKS (v2) */} +
+

{__('7. Tranches di rendicontazione','gepafin')}

+

+ {__('Numero massimo di tranche che il beneficiario puo aprire per questo bando. Il default 1 mantiene il comportamento classico a rendicontazione unica. Aumenta il numero per permettere rendicontazioni multi-fase (es. stati di avanzamento).','gepafin')} +

+
+ + update({max_tranches: e.value})} + min={1} max={20} showButtons disabled={readOnly} /> +
+
+ +
+ +
+

{__('8. Controlli aggiuntivi (dichiarazioni beneficiario)','gepafin')} ({(form.custom_checks || []).length})

+

+ {__('Dichiarazioni aggiuntive richieste al beneficiario, oltre ai documenti standard. Ogni controllo puo richiedere o meno un documento allegato e puo essere obbligatorio o opzionale. Esempi: dichiarazione antiriciclaggio (senza doc, obbligatoria), polizza fidejussoria (con doc, opzionale).','gepafin')} +

+
+ {(form.custom_checks || []).map((c, i) => ( +
+
+ {c.code || `check #${i+1}`} — {c.label || __('(senza etichetta)','gepafin')} + {!readOnly && ( +
+
+
+ + updateCheck(i,{code:e.target.value.toLowerCase().replace(/[^a-z0-9_]/g,'_')})} + placeholder="antiriciclaggio" disabled={readOnly} /> +
+
+ + updateCheck(i,{label:e.target.value})} + placeholder={__('Dichiarazione antiriciclaggio','gepafin')} disabled={readOnly} /> +
+
+
+ + updateCheck(i,{description:e.target.value})} + rows={3} autoResize disabled={readOnly} + placeholder={__('Dichiaro che il beneficiario rispetta...','gepafin')} /> +
+
+
+
+ updateCheck(i,{requires_document:e.value})} disabled={readOnly} /> + +
+ + {__("Se attivo, il beneficiario puo allegare un PDF (max 15MB).",'gepafin')} + +
+
+
+ updateCheck(i,{required:e.value})} disabled={readOnly} /> + +
+ + {__("Se attivo, il beneficiario deve dichiararlo prima di poter inviare la pratica.",'gepafin')} + +
+
+
+ ))} +
+ {!readOnly && ( +
+
+ )} +
+ +
+ {/* ACTIONS BOTTOM (copia degli action top per comodità) */} {!isPublished && (
diff --git a/src/modules/rendicontazione/pages/IstruttoriaPratica.js b/src/modules/rendicontazione/pages/IstruttoriaPratica.js index 41c9abc..49668e3 100644 --- a/src/modules/rendicontazione/pages/IstruttoriaPratica.js +++ b/src/modules/rendicontazione/pages/IstruttoriaPratica.js @@ -79,6 +79,15 @@ const IstruttoriaPratica = () => { const [approveDialog, setApproveDialog] = useState({ visible: false, amount: null }); const [rejectDialog, setRejectDialog] = useState({ visible: false, reason: '' }); const [amendDialog, setAmendDialog] = useState({ visible: false, text: '', deadline: null }); + // v2: custom_checks (merge schema+values dal BE) + const [customChecks, setCustomChecks] = useState([]); + const [ccVerifyDialog, setCcVerifyDialog] = useState({ visible: false, cc: null, status: null, notes: '' }); + const loadCustomChecks = useCallback(() => { + if (!practiceId) return; + RendicontazioneService.listCustomChecks(practiceId, + (resp) => setCustomChecks(resp?.data?.custom_checks || []), + () => {}); + }, [practiceId]); const load = useCallback(() => { setLoading(true); @@ -92,6 +101,7 @@ const IstruttoriaPratica = () => { }, [practiceId]); useEffect(() => { load(); }, [load]); + useEffect(() => { loadCustomChecks(); }, [loadCustomChecks]); const practice = bundle?.practice; const gate = bundle?.gate_check; @@ -108,6 +118,9 @@ const IstruttoriaPratica = () => { const raw = s.required_types || []; return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r); }, [sections]); + const customChecksDefs = useMemo(() => { + return practice?.schema_snapshot?.custom_checks || []; + }, [practice]); const openAmendments = amendments.filter(a => a.status === 'AWAITING' || a.status === 'RESPONSE_RECEIVED'); const isReviewable = practice && ['SUBMITTED', 'UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status); @@ -269,6 +282,45 @@ const IstruttoriaPratica = () => { }, onErr); }; + // v2: verify custom_check + const verifyCustomCheckInline = (cc, status, notes) => { + RendicontazioneService.verifyCustomCheck(practiceId, cc.code, + { verification_status: status, verification_notes: notes || null }, + (resp) => { + toast.current?.show({ severity: 'success', summary: __('Controllo aggiornato', 'gepafin') }); + loadCustomChecks(); + }, onErr); + }; + const openCcVerifyDialog = (cc, status) => { + setCcVerifyDialog({ visible: true, cc, status, notes: cc.verification_notes || '' }); + }; + const confirmCcVerify = () => { + const { cc, status, notes } = ccVerifyDialog; + verifyCustomCheckInline(cc, status, notes); + setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' }); + }; + const downloadCustomCheckDoc = (cc) => { + RendicontazioneService.fetchCustomCheckDocumentBlob(practiceId, cc.code, false, + ({ objectUrl, filename }) => { + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(objectUrl), 60000); + }, + (err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail })); + }; + const previewCustomCheckDoc = (cc) => { + RendicontazioneService.fetchCustomCheckDocumentBlob(practiceId, cc.code, true, + ({ objectUrl }) => { + const w = window.open(objectUrl, '_blank'); + if (w) setTimeout(() => URL.revokeObjectURL(objectUrl), 120000); + }, + (err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail })); + }; + // Final notes + checklist (debounced inline save) const saveFinalNotes = (patch) => { RendicontazioneService.setInstructorFinalNotes(practiceId, patch, afterOk(__('Verbale aggiornato', 'gepafin')), onErr); @@ -944,7 +996,110 @@ const IstruttoriaPratica = () => {
- {/* VERBALE ISTRUTTORIA */} +{/* VERIFICA CONTROLLI AGGIUNTIVI (v2) */} + {customChecksDefs.length > 0 && (<> +
+
+

{__('Verifica controlli aggiuntivi', 'gepafin')}

+

+ {__('Dichiarazioni aggiuntive del beneficiario. Valida ciascun controllo con VALIDO o NON_VALIDO (richiede motivazione). I controlli obbligatori non dichiarati impediscono l\'approvazione.', 'gepafin')} +

+
    + {customChecksDefs.map(def => { + const val = customChecks.find(c => c.code === def.code) || {}; + const stat = val.verification_status || 'PENDING'; + const declared = !!val.beneficiary_declared; + const hasDoc = !!val.filename_original; + const missingRequired = def.required && !declared; + const sevMap = { + PENDING: { severity: 'secondary', label: __('Da verificare', 'gepafin') }, + VALIDO: { severity: 'success', label: __('Valido', 'gepafin') }, + NON_VALIDO: { severity: 'danger', label: __('Non valido', 'gepafin') } + }; + const cfg = sevMap[stat] || sevMap.PENDING; + return ( +
  1. +
    +
    +
    + {def.label} + {def.code} + {def.required && } + {declared + ? + : } + +
    + {def.description && ( +
    + {def.description} +
    + )} + {def.requires_document && ( +
    + {hasDoc ? ( + + + {val.filename_original} + {val.size_bytes && ({(val.size_bytes/1024).toFixed(1)} KB)} + + ) : ( + + + {__('Nessun documento allegato', 'gepafin')} + + )} +
    + )} + {val.verification_notes && ( +
    + {val.verification_notes} +
    + )} +
    +
    +
    +
    +
  2. + ); + })} +
+
+ )} + + {/* VERBALE ISTRUTTORIA */} {isVerifiable && (<>
@@ -1087,6 +1242,25 @@ const IstruttoriaPratica = () => {
+ {/* DIALOG VERIFICA CUSTOM CHECK (motivazione NON_VALIDO) */} + setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' })}> +
{ e.preventDefault(); confirmCcVerify(); }}> +
+ + setCcVerifyDialog(d => ({ ...d, notes: e.target.value }))} + placeholder={__('Es: dichiarazione non coerente con il bando...', 'gepafin')} /> +
+
+
+
+
); }; diff --git a/src/modules/rendicontazione/pages/IstruttoriaQueue.js b/src/modules/rendicontazione/pages/IstruttoriaQueue.js index 85f46b5..01ec607 100644 --- a/src/modules/rendicontazione/pages/IstruttoriaQueue.js +++ b/src/modules/rendicontazione/pages/IstruttoriaQueue.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; import { __ } from '@wordpress/i18n'; import { useNavigate } from 'react-router-dom'; @@ -8,8 +8,12 @@ import { Column } from 'primereact/column'; import { Tag } from 'primereact/tag'; import { Toast } from 'primereact/toast'; import { Skeleton } from 'primereact/skeleton'; +import { Dialog } from 'primereact/dialog'; +import { Dropdown } from 'primereact/dropdown'; +import { InputTextarea } from 'primereact/inputtextarea'; import RendicontazioneService from '../service/rendicontazioneService'; +import { storeGet } from '../../../store'; const STATUS_TAGS = { SUBMITTED: { severity: 'info', label: 'Da prendere in carico' }, @@ -24,15 +28,29 @@ const IstruttoriaQueue = () => { const navigate = useNavigate(); const toast = useRef(null); const [items, setItems] = useState([]); - const [isManager, setIsManager] = useState(false); + const [isManagerFromQueue, setIsManagerFromQueue] = useState(false); const [loading, setLoading] = useState(true); - const load = () => { + // v2 manager view + const [managerMode, setManagerMode] = useState(false); // toggle UI + const [managerItems, setManagerItems] = useState([]); + const [instructors, setInstructors] = useState([]); + const [reassignDialog, setReassignDialog] = useState({ visible: false, practice: null, newInstructorId: null, reason: '' }); + const [reassigning, setReassigning] = useState(false); + + // Controllo ruolo utente per mostrare toggle manager + const userRole = useMemo(() => { + const user = storeGet('getUser'); + return user?.authorities?.[0] || user?.role || null; + }, []); + const canUseManagerView = userRole === 'ROLE_INSTRUCTOR_MANAGER' || userRole === 'ROLE_SUPER_ADMIN'; + + const loadQueue = () => { setLoading(true); RendicontazioneService.instructorQueue( (resp) => { setItems(resp?.data?.items || []); - setIsManager(!!resp?.data?.manager_view); + setIsManagerFromQueue(!!resp?.data?.manager_view); setLoading(false); }, (err) => { @@ -42,8 +60,66 @@ const IstruttoriaQueue = () => { ); }; - useEffect(() => { load(); }, []); + const loadManagerAssignments = () => { + setLoading(true); + RendicontazioneService.managerAssignments( + (resp) => { + setManagerItems(resp?.data?.assignments || []); + setLoading(false); + }, + (err) => { + toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail }); + setLoading(false); + } + ); + }; + const loadInstructors = () => { + RendicontazioneService.managerInstructorsList( + (resp) => setInstructors(resp?.data?.instructors || []), + () => {} + ); + }; + + useEffect(() => { + if (managerMode) { + loadManagerAssignments(); + if (instructors.length === 0) loadInstructors(); + } else { + loadQueue(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [managerMode]); + + const openReassign = (row) => { + if (instructors.length === 0) loadInstructors(); + setReassignDialog({ + visible: true, + practice: row, + newInstructorId: row.assigned_instructor_id || null, + reason: '' + }); + }; + + const confirmReassign = () => { + const { practice, newInstructorId, reason } = reassignDialog; + setReassigning(true); + RendicontazioneService.reassignInstructor( + practice.id, newInstructorId, reason, + (resp) => { + setReassigning(false); + setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' }); + toast.current?.show({ severity: 'success', summary: resp?.message || __('Pratica riassegnata', 'gepafin') }); + loadManagerAssignments(); + }, + (err) => { + setReassigning(false); + toast.current?.show({ severity: 'error', summary: __('Riassegnazione fallita', 'gepafin'), detail: err?.detail }); + } + ); + }; + + // ---------- templates ---------- const callTpl = (row) => (
{row.call_name || `Bando #${row.call_id}`} @@ -68,7 +144,7 @@ const IstruttoriaQueue = () => { : ; const progressTpl = (row) => ( - {row.invoice_count} {__('fatt.','gepafin')} · {row.ula_count} {__('dip.','gepafin')} · {row.document_count} {__('doc','gepafin')} + {row.invoice_count} {__('fatt.', 'gepafin')} · {row.ula_count} {__('dip.', 'gepafin')} · {row.document_count} {__('doc', 'gepafin')} ); const actionsTpl = (row) => { @@ -82,6 +158,35 @@ const IstruttoriaQueue = () => { return #{row.assigned_instructor_id}; }; + // Manager view templates + const mgrCallTpl = (row) => ( +
+ {row.call_name || `Bando #${row.call_id}`} +
{row.company_name} · pratica #{row.application_id} · T{row.sequence_number}{row.period_label ? ` — ${row.period_label}` : ''}
+
+ ); + const mgrSuggestedTpl = (row) => ( + row.suggested_instructor_id + ?
{row.suggested_instructor_name || `#${row.suggested_instructor_id}`}
+ : {__('nessuno', 'gepafin')} + ); + const mgrAssignedTpl = (row) => { + if (row.is_unassigned) { + return ; + } + return
{row.assigned_instructor_name || `#${row.assigned_instructor_id}`}
; + }; + const mgrActionsTpl = (row) => ( +
+
+ ); + return (
@@ -89,35 +194,136 @@ const IstruttoriaQueue = () => {

{__('Coda istruttoria', 'gepafin')}

- {isManager - ? __('Vista manager: vedi tutte le pratiche in carico a tutti gli istruttori.', 'gepafin') - : __('Pool di pratiche da prendere in carico + pratiche assegnate a te.', 'gepafin')} + {managerMode + ? __('Vista manager: tutte le pratiche inviate con istruttore suggerito e assegnato. Puoi riassegnare le pratiche da qui.', 'gepafin') + : (isManagerFromQueue + ? __('Vista manager: vedi tutte le pratiche in carico a tutti gli istruttori.', 'gepafin') + : __('Pool di pratiche da prendere in carico + pratiche assegnate a te.', 'gepafin'))}

-
- {loading && } - {!loading && items.length === 0 && ( -
- -

{__('Nessuna pratica in coda al momento.', 'gepafin')}

+ {/* TOGGLE MANAGER VIEW */} + {canUseManagerView && ( +
+
+
+
+ )} + +
+ + {/* CODA STANDARD */} + {!managerMode && ( +
+ {loading && } + {!loading && items.length === 0 && ( +
+ +

{__('Nessuna pratica in coda al momento.', 'gepafin')}

+
+ )} + {!loading && items.length > 0 && ( + + + + + + + + + + + )} +
+ )} + + {/* VISTA MANAGER */} + {managerMode && ( +
+ {loading && } + {!loading && managerItems.length === 0 && ( +
+ +

{__('Nessuna pratica attiva da gestire.', 'gepafin')}

+
+ )} + {!loading && managerItems.length > 0 && ( + + + + + + + + + + )} +
+ )} + + {/* DIALOG RIASSEGNA */} + !reassigning && setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' })} + modal + footer={( +
+
+ )}> + {reassignDialog.practice && ( +
+

+ {reassignDialog.practice.call_name} — pratica #{reassignDialog.practice.application_id} + {' '}T{reassignDialog.practice.sequence_number} + {reassignDialog.practice.period_label && ` — ${reassignDialog.practice.period_label}`} +

+

+ {__('Istruttore domanda', 'gepafin')}: {reassignDialog.practice.suggested_instructor_name || __('nessuno', 'gepafin')} +
+ {__('Attualmente assegnato a', 'gepafin')}: {reassignDialog.practice.assigned_instructor_name || __('nessuno', 'gepafin')} +

+
+ + setReassignDialog(d => ({ ...d, newInstructorId: e.value }))} + disabled={reassigning} + placeholder={__('Seleziona istruttore', 'gepafin')} /> + + {__('Se la pratica era in SUBMITTED e assegni a qualcuno, passa automaticamente a IN LAVORAZIONE.', 'gepafin')} + +
+
+ + setReassignDialog(d => ({ ...d, reason: e.target.value }))} + placeholder={__('Es: carico di lavoro, competenza specifica, assenza istruttore...', 'gepafin')} + disabled={reassigning} /> +
)} - {!loading && items.length > 0 && ( - - - - - - - - - - - )} -
+
); }; diff --git a/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js b/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js index 0afa509..411216d 100644 --- a/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js +++ b/src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js @@ -76,6 +76,14 @@ const PraticaRendicontazioneEdit = () => { const [practice, setPractice] = useState(null); const [loading, setLoading] = useState(true); const [gate, setGate] = useState(null); + const [customChecks, setCustomChecks] = useState([]); // v2: merge schema+values dal BE + const loadCustomChecks = useCallback(() => { + if (!practiceId) return; + RendicontazioneService.listCustomChecks(practiceId, + (resp) => setCustomChecks(resp?.data?.custom_checks || []), + () => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [practiceId]); // modal fattura const [invDialog, setInvDialog] = useState({ visible: false, data: null }); @@ -155,6 +163,7 @@ const PraticaRendicontazioneEdit = () => { }; useEffect(() => { load(); }, [load]); + useEffect(() => { loadCustomChecks(); }, [loadCustomChecks]); const readOnly = practice && practice.status !== 'DRAFT'; @@ -170,6 +179,10 @@ const PraticaRendicontazioneEdit = () => { const raw = docsSection.required_types || []; return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r); }, [docsSection]); + // v2: custom_checks definition + values (state separato con fetch dedicato) + const customChecksDefs = useMemo(() => { + return practice?.schema_snapshot?.custom_checks || []; + }, [practice]); const ivaAllowed = useMemo(() => { const gen = sections.find(x => x.type === 'static_fields'); const ivaField = (gen?.fields || []).find(f => f.id === 'iva_regime'); @@ -253,6 +266,18 @@ const PraticaRendicontazioneEdit = () => { }); }; + // v2: custom_checks + const declareCustomCheck = (code, declared, file) => { + RendicontazioneService.declareCustomCheck(practiceId, code, declared, file, + (resp) => { toast.current?.show({ severity: 'success', summary: __('Controllo aggiornato','gepafin') }); loadCustomChecks(); }, + onMutationError); + }; + const deleteCustomCheckDoc = (code) => { + RendicontazioneService.deleteCustomCheckDocument(practiceId, code, + (resp) => { toast.current?.show({ severity: 'success', summary: __('Documento rimosso','gepafin') }); loadCustomChecks(); }, + onMutationError); + }; + // documents const upsertDocument = (docCode, filename) => { RendicontazioneService.upsertDocument(practiceId, docCode, { doc_code: docCode, filename }, @@ -617,7 +642,102 @@ const PraticaRendicontazioneEdit = () => {
- {/* BOTTOM ACTIONS */} +{/* SEZIONE 5: CONTROLLI AGGIUNTIVI (v2) */} + {customChecksDefs.length > 0 && (<> +
+

{__((ulaSection.enabled ? '5.' : '4.') + ' Controlli aggiuntivi (dichiarazioni)', 'gepafin')}

+

+ {__('Dichiarazioni richieste dal bando oltre ai documenti standard. I controlli obbligatori devono essere tutti dichiarati prima di poter inviare la pratica.', 'gepafin')} +

+ +
+ {customChecksDefs.map((def) => { + const val = customChecks.find(c => c.code === def.code) || {}; + const declared = !!val.beneficiary_declared; + const hasDoc = !!val.filename_original; + const isMissing = def.required && !declared; + return ( +
+
+
+ declareCustomCheck(def.code, e.target.checked, null)} + style={{ width: '20px', height: '20px', cursor: readOnly ? 'default' : 'pointer' }} /> +
+
+
+ {__('Dichiaro', 'gepafin')}: {def.label} + {def.required && ( + + )} + {!def.required && ( + + )} + {val.verification_status && val.verification_status !== 'PENDING' && ( + + )} +
+ {def.description && ( +
+ {def.description} +
+ )} + + {def.requires_document && ( +
+ {hasDoc ? ( +
+ + + {val.filename_original} + {val.size_bytes && ({(val.size_bytes/1024).toFixed(1)} KB)} + + {!readOnly && ( +
+ ) : ( + !readOnly && ( +
+ {__('Allega documento (PDF, JPG, PNG — max 15MB):', 'gepafin')} + { + const f = e.target.files?.[0]; + if (f) declareCustomCheck(def.code, declared, f); + e.target.value = ''; + }} + style={{ display: 'block', marginTop: '0.4rem' }} /> +
+ ) + )} +
+ )} + + {val.verification_notes && ( +
+ {__('Note istruttore', 'gepafin')}: {val.verification_notes} +
+ )} +
+
+
+ ); + })} +
+
+
+ )} + + {/* BOTTOM ACTIONS */} {!readOnly && (
diff --git a/src/modules/rendicontazione/pages/RendicontazioniMie.js b/src/modules/rendicontazione/pages/RendicontazioniMie.js index d8b8548..804cf49 100644 --- a/src/modules/rendicontazione/pages/RendicontazioniMie.js +++ b/src/modules/rendicontazione/pages/RendicontazioniMie.js @@ -3,37 +3,43 @@ import { __ } from '@wordpress/i18n'; import { useNavigate } from 'react-router-dom'; import { Button } from 'primereact/button'; -import { DataTable } from 'primereact/datatable'; -import { Column } from 'primereact/column'; import { Tag } from 'primereact/tag'; import { Toast } from 'primereact/toast'; import { Skeleton } from 'primereact/skeleton'; +import { Dialog } from 'primereact/dialog'; +import { InputText } from 'primereact/inputtext'; +import { Checkbox } from 'primereact/checkbox'; import RendicontazioneService from '../service/rendicontazioneService'; const STATUS_TAGS = { - NOT_STARTED: { severity: 'info', label: 'Da avviare' }, - 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' }, + 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 fmtEur = (v) => { + const n = Number(v || 0); + return `€ ${n.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +}; + const RendicontazioniMie = () => { const navigate = useNavigate(); const toast = useRef(null); - const [rows, setRows] = useState([]); + const [apps, setApps] = useState([]); const [loading, setLoading] = useState(true); + const [startDialog, setStartDialog] = useState(null); // { application_id, max_tranches, next_seq, show_copy_ula } + const [startForm, setStartForm] = useState({ period_label: '', copy_ula: true }); + const [starting, setStarting] = useState(false); const load = () => { setLoading(true); RendicontazioneService.listMine( (resp) => { - const practices = (resp?.data?.practices || []).map(p => ({ ...p, isReady: false })); - const ready = (resp?.data?.ready_to_start || []).map(r => ({ ...r, isReady: true })); - setRows([...practices, ...ready]); + setApps(resp?.data?.applications || []); setLoading(false); }, (err) => { @@ -45,53 +51,137 @@ const RendicontazioniMie = () => { useEffect(() => { load(); /* eslint-disable-next-line */ }, []); - const handleStart = (applicationId) => { - RendicontazioneService.startPractice(applicationId, + const openStartDialog = (app) => { + const nextSeq = (app.tranches?.length || 0) + 1; + setStartDialog({ + application_id: app.application_id, + call_name: app.call_name, + max_tranches: app.max_tranches, + next_seq: nextSeq, + show_copy_ula: nextSeq > 1, + max_remission_next: app.max_remission_next_tranche, + }); + setStartForm({ period_label: '', copy_ula: nextSeq > 1 }); + }; + + const confirmStart = () => { + if (!startDialog) return; + setStarting(true); + RendicontazioneService.startPractice( + startDialog.application_id, (resp) => { - toast.current?.show({ severity: 'success', summary: __('Rendicontazione avviata', 'gepafin') }); + setStarting(false); + setStartDialog(null); + toast.current?.show({ severity: 'success', summary: resp?.message || __('Tranche avviata', 'gepafin') }); navigate(`/rendicontazioni/${resp.data.id}`); }, - (err) => toast.current?.show({ severity: 'error', summary: __('Avvio fallito', 'gepafin'), detail: err?.detail }) + (err) => { + setStarting(false); + toast.current?.show({ severity: 'error', summary: __('Avvio fallito', 'gepafin'), detail: err?.detail }); + }, + { + period_label: startForm.period_label?.trim() || null, + copy_ula_from_previous: startForm.copy_ula, + } ); }; - const callTpl = (row) => ( -
- {row.call_name || `Bando #${row.call_id}`} -
{row.company_name}
-
- ); + const renderApplicationCard = (app) => { + const hasTranches = (app.tranches?.length || 0) > 0; + const nextSeq = (app.tranches?.length || 0) + 1; + const canStart = !!app.can_start_new; + const blockReason = app.start_blocked_reason; - const erogatoTpl = (row) => { - const v = Number(row.amount_erogato || 0); - return € {v.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}; - }; - - const statusTpl = (row) => { - const key = row.isReady ? 'NOT_STARTED' : (row.status || 'DRAFT'); - const conf = STATUS_TAGS[key] || { severity: 'secondary', label: key }; - return ; - }; - - const progressTpl = (row) => { - if (row.isReady) return ; return ( - - {row.invoice_count || 0} {__('fatture','gepafin')} · {row.ula_count || 0} {__('dipendenti','gepafin')} · {row.document_count || 0} {__('doc','gepafin')} - - ); - }; +
+ {/* HEADER CARD */} +
+
+

+ {app.call_name || `Bando #${app.call_id}`} +

+
+ {app.company_name || ''} · {__('Domanda', 'gepafin')} #{app.application_id} +
+
+
+
{__('Finanziamento erogato','gepafin')}
+
{fmtEur(app.amount_erogato)}
+
+
- const actionsTpl = (row) => { - if (row.isReady) { - return
+ ); + })} +
+ )} + + {/* BOTTONE NUOVA TRANCHE */} +
+ {!canStart && blockReason && ( + + + {blockReason} + + )} +
+
+ ); }; return ( @@ -100,32 +190,79 @@ const RendicontazioniMie = () => {

{__('Le mie rendicontazioni', 'gepafin')}

-

{__('Per ogni pratica finanziata puoi avviare la rendicontazione delle spese e il calcolo della remissione del debito.', 'gepafin')}

+

{__('Per ogni pratica finanziata puoi avviare la rendicontazione delle spese e il calcolo della remissione del debito. I bandi che prevedono piu tranches permettono rendicontazioni multi-fase.', 'gepafin')}

-
- {loading && } - {!loading && rows.length === 0 && ( -
- -

{__('Non ci sono rendicontazioni da avviare al momento.', 'gepafin')}

- - {__('Le rendicontazioni diventano disponibili dopo la firma del contratto e quando l\'ente ha pubblicato lo schema di rendicontazione per il bando.', 'gepafin')} - + {loading && } + + {!loading && apps.length === 0 && ( +
+ +

{__('Non ci sono rendicontazioni disponibili al momento.', 'gepafin')}

+ + {__('Le rendicontazioni diventano disponibili dopo la firma del contratto e quando l\'ente ha pubblicato lo schema di rendicontazione per il bando.', 'gepafin')} + +
+ )} + + {!loading && apps.length > 0 && apps.map(renderApplicationCard)} + + {/* START DIALOG */} + !starting && setStartDialog(null)} + modal + footer={( +
+
+ )}> + {startDialog && ( +
+

+ {__('Stai per avviare la tranche', 'gepafin')} + {' '}T{startDialog.next_seq} / {startDialog.max_tranches} + {' '}{__('del bando', 'gepafin')} {startDialog.call_name}. +

+

+ {__('Cap remissione disponibile per questa tranche', 'gepafin')}: + {' '}{fmtEur(startDialog.max_remission_next)} +

+ +
+ + setStartForm(f => ({ ...f, period_label: e.target.value }))} + placeholder={__('es. "I trimestre 2021", "Stato avanzamento II"', 'gepafin')} + disabled={starting} /> + + {__('Descrizione libera per identificare la tranche. Apparirà sul verbale.', 'gepafin')} + +
+ + {startDialog.show_copy_ula && ( +
+
+ setStartForm(f => ({ ...f, copy_ula: e.checked }))} + disabled={starting} /> + +
+ + {__('Se attivo, i dipendenti censiti nella tranche precedente saranno precaricati. Potrai modificarli o rimuoverli prima di inviare.', 'gepafin')} + +
+ )}
)} - {!loading && rows.length > 0 && ( - - - - - - - - )} -
+
); }; diff --git a/src/modules/rendicontazione/service/rendicontazioneService.js b/src/modules/rendicontazione/service/rendicontazioneService.js index 0a4cad7..782b415 100644 --- a/src/modules/rendicontazione/service/rendicontazioneService.js +++ b/src/modules/rendicontazione/service/rendicontazioneService.js @@ -97,10 +97,21 @@ const extendPractice = { }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); }, - startPractice(applicationId, onSuccess, onError) { + startPractice(applicationId, onSuccess, onError, opts = {}) { + // opts: { period_label?: string, copy_ula_from_previous?: bool } fetch(`${BASE_URL}/api/remission-practices/start`, { method: 'POST', mode: 'cors', headers: buildHeaders(), - body: JSON.stringify({ application_id: applicationId }) + body: JSON.stringify({ + application_id: applicationId, + period_label: opts.period_label ?? null, + copy_ula_from_previous: opts.copy_ula_from_previous !== false, + }) + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + }, + + copyUlaOptions(practiceId, onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/${practiceId}/copy-ula-options`, { + method: 'GET', mode: 'cors', headers: buildHeaders() }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); }, @@ -400,3 +411,84 @@ const extendVerbale = { }; Object.assign(RendicontazioneService, extendVerbale); + +// ====================== v2 CUSTOM CHECKS ====================== +const extendCustomChecks = { + listCustomChecks(practiceId, onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks`, { + method: 'GET', mode: 'cors', headers: buildHeaders() + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + }, + + declareCustomCheck(practiceId, code, declared, file, onSuccess, onError) { + const fd = new FormData(); + fd.append('beneficiary_declared', declared ? 'true' : 'false'); + if (file) fd.append('file', file); + fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/declare`, { + method: 'PUT', mode: 'cors', + headers: _buildBearerOnly(), // no Content-Type: boundary auto + body: fd + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + }, + + deleteCustomCheckDocument(practiceId, code, onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/document`, { + method: 'DELETE', mode: 'cors', headers: buildHeaders() + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + }, + + verifyCustomCheck(practiceId, code, body, onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/verify`, { + method: 'PUT', mode: 'cors', headers: buildHeaders(), + body: JSON.stringify(body) + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + }, + + fetchCustomCheckDocumentBlob(practiceId, code, inline, onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/document?inline=${inline ? 1 : 0}`, { + method: 'GET', mode: 'cors', headers: _buildBearerOnly() + }).then(async r => { + if (r.status < 200 || r.status >= 300) { + let detail = r.statusText; + try { const j = await r.json(); detail = j.detail || detail; } catch(e){} + if (onError) onError({ status: r.status, detail }); + return; + } + let filename = 'file'; + const cd = r.headers.get('Content-Disposition') || ''; + const m = cd.match(/filename="([^"]+)"/); + if (m) filename = m[1]; + const blob = await r.blob(); + const objectUrl = URL.createObjectURL(blob); + if (onSuccess) onSuccess({ blob, objectUrl, filename }); + }).catch(e => handleError(e, onError)); + } +}; +Object.assign(RendicontazioneService, extendCustomChecks); + + +// ====================== v2 MANAGER ISTRUTTORE ====================== +const extendAssignmentManager = { + managerAssignments(onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/instructor-manager/assignments`, { + method: 'GET', mode: 'cors', headers: buildHeaders() + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + }, + + managerInstructorsList(onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/instructor-manager/instructors`, { + method: 'GET', mode: 'cors', headers: buildHeaders() + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + }, + + reassignInstructor(practiceId, newInstructorId, reason, onSuccess, onError) { + fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/reassign`, { + method: 'POST', mode: 'cors', headers: buildHeaders(), + body: JSON.stringify({ + new_instructor_id: newInstructorId, + reassignment_reason: reason || null, + }) + }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); + } +}; +Object.assign(RendicontazioneService, extendAssignmentManager);