import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { __ } from '@wordpress/i18n'; import { useNavigate, useParams } from 'react-router-dom'; import { Button } from 'primereact/button'; import { Toast } from 'primereact/toast'; import { Tag } from 'primereact/tag'; import { Skeleton } from 'primereact/skeleton'; import { Dialog } from 'primereact/dialog'; 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 RendicontazioneService from '../service/rendicontazioneService'; import FilePreviewDialog from '../components/FilePreviewDialog'; const CONTRACT_TYPES = { T_IND: 'Tempo indeterminato', T_DET: 'Tempo determinato', APPR: 'Apprendistato', STAGE: 'Tirocinio / Stage', COLL: 'Collaborazione coordinata', ALTRO: 'Altro' }; const PRACTICE_STATUS = { DRAFT: { severity: 'warning', label: 'Bozza beneficiario' }, SUBMITTED: { severity: 'info', label: 'Inviata — da prendere in carico' }, UNDER_REVIEW: { severity: 'warning', label: 'In lavorazione' }, AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso aperto' }, APPROVED: { severity: 'success', label: 'Approvata' }, REJECTED: { severity: 'danger', label: 'Respinta' } }; const VERIFICATION_INVOICE_TAG = { PENDING: { severity: 'secondary', label: 'Da verificare' }, AMMESSA: { severity: 'success', label: 'Ammessa' }, PARZIALE: { severity: 'warning', label: 'Parziale' }, RESPINTA: { severity: 'danger', label: 'Respinta' } }; const VERIFICATION_DOC_TAG = { PENDING: { severity: 'secondary', label: 'Da verificare' }, VALIDO: { severity: 'success', label: 'Valido' }, NON_VALIDO: { severity: 'danger', label: 'Non valido' }, SCADUTO: { severity: 'warning', label: 'Scaduto' } }; const AMENDMENT_STATUS = { AWAITING: { severity: 'warning', label: 'Attesa risposta' }, RESPONSE_RECEIVED: { severity: 'info', label: 'Risposta ricevuta' }, CLOSED: { severity: 'success', label: 'Chiusa' }, EXPIRED: { severity: 'danger', label: 'Scaduta' } }; const euro = (v) => v == null ? '—' : '€ ' + Number(v || 0).toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const formatDate = (d) => d ? new Date(d).toLocaleDateString('it-IT') : '—'; const formatDateTime = (d) => d ? new Date(d).toLocaleString('it-IT') : '—'; const IstruttoriaPratica = () => { const { id: practiceId } = useParams(); const navigate = useNavigate(); const toast = useRef(null); const [loading, setLoading] = useState(true); const [bundle, setBundle] = useState(null); // dialoghi const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: 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: '' }); 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); RendicontazioneService.instructorViewPractice(practiceId, (resp) => { setBundle(resp?.data); setLoading(false); }, (err) => { toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail }); setLoading(false); } ); }, [practiceId]); useEffect(() => { load(); }, [load]); useEffect(() => { loadCustomChecks(); }, [loadCustomChecks]); const practice = bundle?.practice; const gate = bundle?.gate_check; const amendments = bundle?.amendments || []; 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 docsRequired = useMemo(() => { const s = sections.find(x => x.type === 'document_checklist') || {}; 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); const isDecidable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status); const isVerifiable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status); // ---------- actions ---------- const afterOk = (msg) => (resp) => { toast.current?.show({ severity: 'success', summary: msg }); load(); }; const onErr = (err) => { toast.current?.show({ severity: 'error', summary: __('Operazione fallita', 'gepafin'), detail: typeof err?.detail === 'object' ? JSON.stringify(err.detail) : err?.detail }); }; 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 }); const downloadVerbale = () => { RendicontazioneService.downloadVerbale(practiceId, (err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail || __('Verbale non disponibile', 'gepafin') }) ); }; const openVerbaleHtml = () => { RendicontazioneService.openVerbaleHtml(practiceId).catch(() => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: __('Verbale non disponibile', 'gepafin') }) ); }; const doDownload = (entityType, entityId) => { RendicontazioneService.downloadEntityFile(entityType, entityId, (err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail || __('Download non riuscito', 'gepafin') }) ); }; // Quick verify (thumbs up/down) senza rettifica const quickVerifyInvoice = (invoice, newStatus) => { RendicontazioneService.verifyInvoice(practiceId, invoice.id, { verification_status: newStatus, verification_notes: null }, afterOk(__(`Fattura ${newStatus.toLowerCase()}`, 'gepafin')), onErr); }; const quickVerifyUla = (employee, newStatus) => { RendicontazioneService.verifyUlaEmployee(practiceId, employee.id, { verification_status: newStatus, verification_notes: null }, afterOk(__(`Dipendente ${newStatus.toLowerCase()}`, 'gepafin')), onErr); }; const quickVerifyDoc = (doc, newStatus) => { RendicontazioneService.verifyDocument(practiceId, doc.doc_code, { verification_status: newStatus, verification_notes: null }, (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); }; // 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), () => {}); }; // 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 = () => { const d = docNoteDialog.doc; 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 }); 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); }; // 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); }; // Claim / approve / reject / amendment / close amendment — come prima const handleClaim = (ev) => { confirmPopup({ target: ev.currentTarget, message: __('Prendere in carico la pratica? Lo stato passerà a "In lavorazione".', 'gepafin'), icon: 'pi pi-info-circle', acceptLabel: __('Prendi in carico', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'), accept: () => RendicontazioneService.claimPractice(practiceId, afterOk(__('Pratica presa in carico', 'gepafin')), onErr) }); }; const doApprove = () => { const body = approveDialog.amount != null ? { approved_remission: approveDialog.amount } : {}; RendicontazioneService.approvePractice(practiceId, body, (resp) => { setApproveDialog({ visible: false, amount: null }); afterOk(__('Pratica approvata', 'gepafin'))(resp); }, onErr); }; const doReject = () => { if (!rejectDialog.reason || rejectDialog.reason.trim().length < 10) { toast.current?.show({ severity: 'warn', summary: __('Motivazione troppo corta (min 10 caratteri)', 'gepafin') }); return; } RendicontazioneService.rejectPractice(practiceId, rejectDialog.reason, (resp) => { setRejectDialog({ visible: false, reason: '' }); afterOk(__('Pratica respinta', 'gepafin'))(resp); }, onErr); }; const doAmend = () => { if (!amendDialog.text || amendDialog.text.trim().length < 10) { toast.current?.show({ severity: 'warn', summary: __('Testo troppo corto', 'gepafin') }); return; } if (!amendDialog.deadline) { toast.current?.show({ severity: 'warn', summary: __('Deadline obbligatoria', 'gepafin') }); return; } const body = { request_text: amendDialog.text, deadline: typeof amendDialog.deadline === 'string' ? amendDialog.deadline : amendDialog.deadline.toISOString().slice(0, 10) }; RendicontazioneService.createAmendment(practiceId, body, (resp) => { setAmendDialog({ visible: false, text: '', deadline: null }); afterOk(__('Soccorso avviato', 'gepafin'))(resp); }, onErr); }; const closeAmendment = (ev, a) => { confirmPopup({ target: ev.currentTarget, message: __('Chiudi questa richiesta di soccorso? La pratica torna in lavorazione.', 'gepafin'), icon: 'pi pi-info-circle', acceptLabel: __('Chiudi', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'), accept: () => RendicontazioneService.closeAmendment(practiceId, a.id, afterOk(__('Soccorso chiuso', 'gepafin')), onErr) }); }; // ---------- render helpers ---------- const renderThumbsRow = (currentStatus, onUp, onDown, onPreview, onDownload, filename, extraButtons) => { const isUp = currentStatus === 'AMMESSA' || currentStatus === 'VALIDO'; const isDown = currentStatus === 'RESPINTA' || currentStatus === 'NON_VALIDO' || currentStatus === 'SCADUTO'; return (
{__('Pratica non trovata', 'gepafin')}
{practice.schema_snapshot?.template_label || `Bando #${practice.call_id}`} · {__('Pratica', 'gepafin')} #{practice.application_id}
{__("Modifica l'importo ammesso direttamente nella riga (salvataggio automatico quando esci dal campo). Clicca ▸ per aprire le note.", 'gepafin')}
{(() => { 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 || ''); }); if (sortedInvoices.length === 0) { return{__('Nessuna fattura caricata', 'gepafin')}
; } return ({__("Modifica l'FTE ammesso direttamente nella riga (salvataggio automatico quando esci dal campo). Clicca ▸ per aprire le note.", 'gepafin')}
{(() => { const totalFteDecl = practice.ula_employees.reduce((a, e) => a + Number(e.fte_pct || 0), 0); const totalFteVerif = practice.ula_employees .filter(e => ['AMMESSA', 'PARZIALE'].includes(e.verification_status)) .reduce((a, e) => a + Number((e.fte_pct_verified != null ? e.fte_pct_verified : e.fte_pct) || 0), 0); const thresholdOK = totalFteVerif >= Number(ulaSection.threshold || 1); return ({dr.code}
{doc.filename ? {__('Dichiarazioni aggiuntive del beneficiario. Valida ciascun controllo con VALIDO o NON_VALIDO (richiede motivazione). I controlli obbligatori non dichiarati impediscono l\'approvazione.', 'gepafin')}
{def.code}
{def.required &&