diff --git a/src/layouts/DefaultLayout/components/AppSidebar/index.js b/src/layouts/DefaultLayout/components/AppSidebar/index.js index 47f69c8..2c77e79 100644 --- a/src/layouts/DefaultLayout/components/AppSidebar/index.js +++ b/src/layouts/DefaultLayout/components/AppSidebar/index.js @@ -27,6 +27,13 @@ const AppSidebar = () => { id: 2, enable: intersection(permissions, ['MANAGE_TENDERS']).length }, + { + label: __('Istruttoria rendicontazioni', 'gepafin'), + icon: 'pi pi-check-square', + href: '/istruttoria', + id: 12, + enable: intersection(permissions, ['EVALUATE_APPLICATIONS']).length + }, { label: __('Rendicontazione', 'gepafin'), icon: 'pi pi-receipt', diff --git a/src/modules/rendicontazione/pages/IstruttoriaPratica.js b/src/modules/rendicontazione/pages/IstruttoriaPratica.js new file mode 100644 index 0000000..648aab8 --- /dev/null +++ b/src/modules/rendicontazione/pages/IstruttoriaPratica.js @@ -0,0 +1,483 @@ +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 { 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 { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup'; + +import RendicontazioneService from '../service/rendicontazioneService'; + +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' }, + UNDER_REVIEW: { severity: 'warning', label: 'In valutazione' }, + AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso aperto' }, + APPROVED: { severity: 'success', label: 'Approvata' }, + REJECTED: { severity: 'danger', label: 'Respinta' } +}; + +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) => '€ ' + 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); // {practice, gate_check, amendments} + + const [approveDialog, setApproveDialog] = useState({ visible: false, amount: null }); + const [rejectDialog, setRejectDialog] = useState({ visible: false, reason: '' }); + const [amendDialog, setAmendDialog] = useState({ visible: false, text: '', deadline: null }); + + 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]); + + 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 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); + + // ---------- actions ---------- + const afterOk = (msg) => () => { + 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 handleClaim = (e) => { + confirmPopup({ + target: e.currentTarget, + message: __('Prendere in carico la pratica? Lo stato passerà a In valutazione.', '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', 'gepafin'), detail: __('Minimo 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 = (e, amendment) => { + confirmPopup({ + target: e.currentTarget, + message: __('Chiudi questa richiesta di soccorso? La pratica torna in valutazione.', 'gepafin'), + icon: 'pi pi-info-circle', + acceptLabel: __('Chiudi', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'), + accept: () => RendicontazioneService.closeAmendment(practiceId, amendment.id, + afterOk(__('Soccorso chiuso', 'gepafin')), onErr) + }); + }; + + // ---------- render ---------- + if (loading) { + return
; + } + if (!practice) { + return

{__('Pratica non trovata', 'gepafin')}

; + } + + const statusCfg = PRACTICE_STATUS[practice.status] || { severity: 'secondary', label: practice.status }; + const totals = gate?.totals || {}; + const perCat = totals.per_category || {}; + + const invoicesOfCat = (code) => practice.invoices.filter(i => i.category_code === code); + + return ( +
+ + + +
+

{__('Istruttoria pratica', 'gepafin')}

+

+ + {practice.schema_snapshot?.template_label || `Call #${practice.call_id}`} · {__('Pratica', 'gepafin')} #{practice.application_id} + + + + +

+
+ +
+ + {/* ACTIONS */} +
+
+
+
+ +
+ + {/* RIEPILOGO */} +
+

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

+
+
+ {__('Azienda', 'gepafin')} +
Company #{practice.company_id}
+
+
+ {__('Erogato', 'gepafin')} +
{euro(practice.amount_erogato)}
+
+
+ {__('Regime IVA', 'gepafin')} +
{practice.iva_regime || '—'}
+
+
+ {__('Totale fatture', 'gepafin')} +
{euro(totals.grand_total || 0)}
+
+
+ {__('Cap remissione', 'gepafin')} +
{euro(totals.max_remission || 0)}
+
+
+ {__('Remissione calcolata', 'gepafin')} +
{euro(totals.remission_due || 0)}
+
+ {practice.approved_remission != null && ( +
+ {__('Remissione approvata', 'gepafin')} +
{euro(practice.approved_remission)}
+
+ )} +
+ {practice.rejection_reason && ( +
+ {__('Motivo rifiuto:', 'gepafin')} +
{practice.rejection_reason}
+
+ )} +
+ +
+ + {/* AMENDMENTS */} + {amendments.length > 0 && (<> +
+

{__('Soccorso istruttorio', 'gepafin')} ({amendments.length})

+
+ {amendments.map(a => { + const cfg = AMENDMENT_STATUS[a.status] || { severity: 'secondary', label: a.status }; + return ( +
+
+
+ + + {__('Deadline:', 'gepafin')} {formatDate(a.deadline)} · {__('Creata:', 'gepafin')} {formatDateTime(a.created_at)} + +
+ {a.status !== 'CLOSED' && isReviewable && ( +
+
+ {__('Richiesta istruttore:', 'gepafin')} +
{a.request_text}
+ {a.response_text && (<> + + {__('Risposta beneficiario', 'gepafin')} ({formatDateTime(a.response_at)}): + +
{a.response_text}
+ )} +
+
+ ); + })} +
+
+
+ )} + + {/* GATE CHECKS */} + {gate && ( +
+

{__('Requisiti di validità', 'gepafin')}

+
+ {gate.checks.map((c, i) => ( +
+ +
+
{c.label}
+ {c.detail} +
+
+ ))} +
+
+ )} + +
+ + {/* FATTURE PER CATEGORIA */} +
+

{__('Fatture rendicontate', 'gepafin')}

+
+ {categories.map(cat => { + const invs = invoicesOfCat(cat.code); + const total = perCat[cat.code] || 0; + return ( +
+
+
+ {cat.code} — {cat.label} +
+
+ {euro(total)} +
{invs.length} {__('fatture', 'gepafin')}
+
+
+ {invs.length > 0 ? ( + + + formatDate(r.invoice_date)} /> + formatDate(r.payment_date)} /> + + {r.description.slice(0, 40)}{r.description.length > 40 ? '…' : ''}} /> + euro(r.taxable)} /> + euro(r.vat)} /> + euro(r.total)} /> + + ) : {__('Nessuna fattura', 'gepafin')}} +
+ ); + })} +
+
+ + {/* ULA */} + {ulaSection.enabled && (<> +
+
+

{__('Dipendenti ULA', 'gepafin')}

+ {practice.ula_employees.length > 0 ? ( + + + + CONTRACT_TYPES[r.contract_type] || r.contract_type} /> + + Number(r.fte_pct).toFixed(2)} /> + `${formatDate(r.period_start_date)} → ${formatDate(r.period_end_date)}`} /> + r.supporting_doc_filename + ? {r.supporting_doc_filename} + : } /> + + ) :

{__('Nessun dipendente caricato', 'gepafin')}

} +
+ )} + +
+ + {/* DOCUMENTI */} +
+

{__('Documenti', 'gepafin')}

+
+ {docsRequired.map(dr => { + const existing = practice.documents.find(d => d.doc_code === dr.code); + return ( +
+ +
+ {dr.label} +
{dr.code}
+
+
+ {existing?.filename + ? {existing.filename} — {__('caricato il', 'gepafin')} {formatDateTime(existing.uploaded_at)} + : {__('Non caricato', 'gepafin')}} +
+
+ ); + })} +
+
+ + {/* ---------- DIALOG APPROVA ---------- */} + setApproveDialog({ visible: false, amount: null })}> +
{ e.preventDefault(); doApprove(); }}> +
+ + setApproveDialog(d => ({ ...d, amount: e.value }))} /> + + {__('Valore calcolato:', 'gepafin')} {euro(totals.remission_due || 0)}. {__('Puoi modificarlo se necessario.', 'gepafin')} + +
+
+
+
+
+ + {/* ---------- DIALOG RESPINGI ---------- */} + setRejectDialog({ visible: false, reason: '' })}> +
{ e.preventDefault(); doReject(); }}> +
+ + setRejectDialog(d => ({ ...d, reason: e.target.value }))} /> + {__('Minimo 10 caratteri. Verrà inviata al beneficiario.', 'gepafin')} +
+
+
+
+
+ + {/* ---------- DIALOG SOCCORSO ---------- */} + setAmendDialog({ visible: false, text: '', deadline: null })}> +
{ e.preventDefault(); doAmend(); }}> +
+ + setAmendDialog(d => ({ ...d, text: e.target.value }))} + placeholder={__('Descrivi le integrazioni richieste...', 'gepafin')} /> + {__('Sarà visibile al beneficiario, che potrà rispondere integrando la documentazione.', 'gepafin')} +
+
+ + setAmendDialog(d => ({ ...d, deadline: e.value }))} /> +
+
+
+
+
+
+ ); +}; + +export default IstruttoriaPratica; diff --git a/src/modules/rendicontazione/pages/IstruttoriaQueue.js b/src/modules/rendicontazione/pages/IstruttoriaQueue.js new file mode 100644 index 0000000..85f46b5 --- /dev/null +++ b/src/modules/rendicontazione/pages/IstruttoriaQueue.js @@ -0,0 +1,125 @@ +import React, { useEffect, useState, useRef } from 'react'; +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 RendicontazioneService from '../service/rendicontazioneService'; + +const STATUS_TAGS = { + SUBMITTED: { severity: 'info', label: 'Da prendere in carico' }, + UNDER_REVIEW: { severity: 'warning', label: 'In valutazione' }, + 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') : '—'; + +const IstruttoriaQueue = () => { + const navigate = useNavigate(); + const toast = useRef(null); + const [items, setItems] = useState([]); + const [isManager, setIsManager] = useState(false); + const [loading, setLoading] = useState(true); + + const load = () => { + setLoading(true); + RendicontazioneService.instructorQueue( + (resp) => { + setItems(resp?.data?.items || []); + setIsManager(!!resp?.data?.manager_view); + setLoading(false); + }, + (err) => { + toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail }); + setLoading(false); + } + ); + }; + + useEffect(() => { load(); }, []); + + const callTpl = (row) => ( +
+ {row.call_name || `Bando #${row.call_id}`} +
{row.company_name} · pratica #{row.application_id}
+
+ ); + const statusTpl = (row) => { + const c = STATUS_TAGS[row.status] || { severity: 'secondary', label: row.status }; + return
+ + {row.open_amendments > 0 && ( +
+ +
+ )} +
; + }; + const submittedTpl = (row) => row.submitted_at ? formatDate(row.submitted_at) : '—'; + const erogatoTpl = (row) => {euro(row.amount_erogato)}; + const remissionTpl = (row) => row.remission_due != null + ? {euro(row.remission_due)} + : ; + const progressTpl = (row) => ( + + {row.invoice_count} {__('fatt.','gepafin')} · {row.ula_count} {__('dip.','gepafin')} · {row.document_count} {__('doc','gepafin')} + + ); + const actionsTpl = (row) => { + const label = row.status === 'SUBMITTED' ? __('Apri e prendi in carico', 'gepafin') : __('Apri', 'gepafin'); + return