feat(v2): FE multi-tranche + custom_checks + manager view
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).
This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* 7 - TRANCHES + CUSTOM CHECKS (v2) */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('7. Tranches di rendicontazione','gepafin')}</h2>
|
||||
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
|
||||
{__('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')}
|
||||
</p>
|
||||
<div className="appForm__field" style={{maxWidth:'300px'}}>
|
||||
<label>{__('Tranches massime','gepafin')}</label>
|
||||
<InputNumber value={form.max_tranches}
|
||||
onValueChange={(e) => update({max_tranches: e.value})}
|
||||
min={1} max={20} showButtons disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
<div className="appPageSection">
|
||||
<h2>{__('8. Controlli aggiuntivi (dichiarazioni beneficiario)','gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({(form.custom_checks || []).length})</span></h2>
|
||||
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
|
||||
{__('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')}
|
||||
</p>
|
||||
<div className="fieldsRepeater">
|
||||
{(form.custom_checks || []).map((c, i) => (
|
||||
<div key={i} className="fieldsRepeater__panel" style={{ padding:'1rem', border:'1px solid var(--surface-border)', borderRadius:'6px', background:'var(--surface-50)' }}>
|
||||
<div className="fieldsRepeater__heading" style={{ marginBottom:'0.5rem' }}>
|
||||
<strong style={{ color:'var(--primary-color)' }}>{c.code || `check #${i+1}`} — {c.label || __('(senza etichetta)','gepafin')}</strong>
|
||||
{!readOnly && (
|
||||
<Button type="button" icon="pi pi-trash" severity="danger" outlined
|
||||
size="small" onClick={() => removeCheck(i)}
|
||||
tooltip={__('Rimuovi controllo','gepafin')} tooltipOptions={{position:'top'}} />
|
||||
)}
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Codice (snake_case)','gepafin')}</label>
|
||||
<InputText value={c.code}
|
||||
onChange={(e) => updateCheck(i,{code:e.target.value.toLowerCase().replace(/[^a-z0-9_]/g,'_')})}
|
||||
placeholder="antiriciclaggio" disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Etichetta visibile','gepafin')}</label>
|
||||
<InputText value={c.label}
|
||||
onChange={(e) => updateCheck(i,{label:e.target.value})}
|
||||
placeholder={__('Dichiarazione antiriciclaggio','gepafin')} disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Descrizione (testo mostrato al beneficiario)','gepafin')}</label>
|
||||
<InputTextarea value={c.description}
|
||||
onChange={(e) => updateCheck(i,{description:e.target.value})}
|
||||
rows={3} autoResize disabled={readOnly}
|
||||
placeholder={__('Dichiaro che il beneficiario rispetta...','gepafin')} />
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={c.requires_document}
|
||||
onChange={(e) => updateCheck(i,{requires_document:e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && updateCheck(i,{requires_document: !c.requires_document})}>
|
||||
{__('Richiede documento allegato','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{__("Se attivo, il beneficiario puo allegare un PDF (max 15MB).",'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={c.required}
|
||||
onChange={(e) => updateCheck(i,{required:e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && updateCheck(i,{required: !c.required})}>
|
||||
{__('Obbligatorio','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{__("Se attivo, il beneficiario deve dichiararlo prima di poter inviare la pratica.",'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Button type="button" icon="pi pi-plus" iconPos="right" outlined
|
||||
label={__('Aggiungi controllo aggiuntivo','gepafin')} onClick={addCheck} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* ACTIONS BOTTOM (copia degli action top per comodità) */}
|
||||
{!isPublished && (
|
||||
<div className="appPageSection">
|
||||
|
||||
@@ -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,6 +996,109 @@ const IstruttoriaPratica = () => {
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* VERIFICA CONTROLLI AGGIUNTIVI (v2) */}
|
||||
{customChecksDefs.length > 0 && (<>
|
||||
<div className="appPage__spacer"></div>
|
||||
<div className="appPageSection">
|
||||
<h2>{__('Verifica controlli aggiuntivi', 'gepafin')}</h2>
|
||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
||||
{__('Dichiarazioni aggiuntive del beneficiario. Valida ciascun controllo con VALIDO o NON_VALIDO (richiede motivazione). I controlli obbligatori non dichiarati impediscono l\'approvazione.', 'gepafin')}
|
||||
</p>
|
||||
<ol className="appPageSection__list">
|
||||
{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 (
|
||||
<li key={def.code} className="appPageSection__listItem">
|
||||
<div className="appPageSection__listItemRow">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<strong>{def.label}</strong>
|
||||
<code style={{ fontSize: '0.85em' }}>{def.code}</code>
|
||||
{def.required && <Tag severity="warning" value={__('Obbligatorio', 'gepafin')} />}
|
||||
{declared
|
||||
? <Tag severity="success" value={__('Dichiarato', 'gepafin')} />
|
||||
: <Tag severity={missingRequired ? 'danger' : 'secondary'} value={__('Non dichiarato', 'gepafin')} />}
|
||||
<Tag severity={cfg.severity} value={cfg.label} />
|
||||
</div>
|
||||
{def.description && (
|
||||
<div style={{ marginTop: '0.3rem', fontSize: '0.9em', color: 'var(--text-color-secondary)', whiteSpace: 'pre-wrap' }}>
|
||||
{def.description}
|
||||
</div>
|
||||
)}
|
||||
{def.requires_document && (
|
||||
<div style={{ marginTop: '0.4rem', fontSize: '0.9em' }}>
|
||||
{hasDoc ? (
|
||||
<span>
|
||||
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)', marginRight: '0.3rem' }} />
|
||||
<strong>{val.filename_original}</strong>
|
||||
{val.size_bytes && <small className="text-color-secondary"> ({(val.size_bytes/1024).toFixed(1)} KB)</small>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-color-secondary">
|
||||
<i className="pi pi-file" style={{ marginRight: '0.3rem' }} />
|
||||
{__('Nessun documento allegato', 'gepafin')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{val.verification_notes && (
|
||||
<div style={{ marginTop: '0.5rem', padding: '0.4rem 0.6rem', background: 'var(--surface-100)', borderLeft: '3px solid var(--orange-400)', fontSize: '0.9em' }}>
|
||||
<i className="pi pi-pencil" style={{ marginRight: '0.4rem' }} />{val.verification_notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="appPageSection__iconActions">
|
||||
<Button icon="pi pi-eye" rounded outlined severity="info"
|
||||
disabled={!hasDoc}
|
||||
onClick={() => previewCustomCheckDoc(val)}
|
||||
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
||||
<Button icon="pi pi-download" rounded outlined severity="info"
|
||||
disabled={!hasDoc}
|
||||
onClick={() => downloadCustomCheckDoc(val)}
|
||||
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
||||
<Button icon="pi pi-thumbs-up" rounded outlined
|
||||
severity={stat === 'VALIDO' ? 'success' : 'secondary'}
|
||||
disabled={!isVerifiable}
|
||||
onClick={() => {
|
||||
if (stat === 'VALIDO') {
|
||||
verifyCustomCheckInline({ code: def.code }, 'PENDING', null);
|
||||
} else {
|
||||
verifyCustomCheckInline({ code: def.code }, 'VALIDO', val.verification_notes);
|
||||
}
|
||||
}}
|
||||
tooltip={stat === 'VALIDO' ? __('Annulla valido', 'gepafin') : __('Valido', 'gepafin')}
|
||||
tooltipOptions={{ position: 'top' }} />
|
||||
<Button icon="pi pi-thumbs-down" rounded outlined
|
||||
severity={stat === 'NON_VALIDO' ? 'danger' : 'secondary'}
|
||||
disabled={!isVerifiable}
|
||||
onClick={() => {
|
||||
if (stat === 'NON_VALIDO') {
|
||||
verifyCustomCheckInline({ code: def.code }, 'PENDING', null);
|
||||
} else {
|
||||
openCcVerifyDialog({ code: def.code, verification_notes: val.verification_notes }, 'NON_VALIDO');
|
||||
}
|
||||
}}
|
||||
tooltip={stat === 'NON_VALIDO' ? __('Annulla non valido', 'gepafin') : __('Non valido', 'gepafin')}
|
||||
tooltipOptions={{ position: 'top' }} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* VERBALE ISTRUTTORIA */}
|
||||
{isVerifiable && (<>
|
||||
<div className="appPage__spacer"></div>
|
||||
@@ -1087,6 +1242,25 @@ const IstruttoriaPratica = () => {
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
{/* DIALOG VERIFICA CUSTOM CHECK (motivazione NON_VALIDO) */}
|
||||
<Dialog visible={ccVerifyDialog.visible} style={{ width: '520px' }}
|
||||
header={__('Marca controllo come non valido', 'gepafin')} modal
|
||||
onHide={() => setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' })}>
|
||||
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); confirmCcVerify(); }}>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Motivazione (obbligatoria)', 'gepafin')}</label>
|
||||
<InputTextarea value={ccVerifyDialog.notes} rows={4} autoResize
|
||||
onChange={(e) => setCcVerifyDialog(d => ({ ...d, notes: e.target.value }))}
|
||||
placeholder={__('Es: dichiarazione non coerente con il bando...', 'gepafin')} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<Button type="button" outlined label={__('Annulla', 'gepafin')}
|
||||
onClick={() => setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' })} />
|
||||
<Button type="submit" label={__('Conferma', 'gepafin')} icon="pi pi-times" severity="danger"
|
||||
disabled={!ccVerifyDialog.notes || ccVerifyDialog.notes.trim().length < 5} />
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
<div>
|
||||
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
|
||||
@@ -82,6 +158,35 @@ const IstruttoriaQueue = () => {
|
||||
return <span>#{row.assigned_instructor_id}</span>;
|
||||
};
|
||||
|
||||
// Manager view templates
|
||||
const mgrCallTpl = (row) => (
|
||||
<div>
|
||||
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
|
||||
<div><small className="text-color-secondary">{row.company_name} · pratica #{row.application_id} · <strong>T{row.sequence_number}</strong>{row.period_label ? ` — ${row.period_label}` : ''}</small></div>
|
||||
</div>
|
||||
);
|
||||
const mgrSuggestedTpl = (row) => (
|
||||
row.suggested_instructor_id
|
||||
? <div><strong>{row.suggested_instructor_name || `#${row.suggested_instructor_id}`}</strong></div>
|
||||
: <span className="text-color-secondary">{__('nessuno', 'gepafin')}</span>
|
||||
);
|
||||
const mgrAssignedTpl = (row) => {
|
||||
if (row.is_unassigned) {
|
||||
return <Tag severity="warning" value={__('Da assegnare', 'gepafin')} icon="pi pi-exclamation-triangle" />;
|
||||
}
|
||||
return <div><strong>{row.assigned_instructor_name || `#${row.assigned_instructor_id}`}</strong></div>;
|
||||
};
|
||||
const mgrActionsTpl = (row) => (
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
<Button icon="pi pi-eye" size="small" outlined
|
||||
label={__('Apri', 'gepafin')}
|
||||
onClick={() => navigate(`/istruttoria/${row.id}`)} />
|
||||
<Button icon="pi pi-user-edit" size="small" severity="warning"
|
||||
label={row.is_unassigned ? __('Assegna', 'gepafin') : __('Riassegna', 'gepafin')}
|
||||
onClick={() => openReassign(row)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="appPage">
|
||||
<Toast ref={toast} />
|
||||
@@ -89,14 +194,38 @@ const IstruttoriaQueue = () => {
|
||||
<div className="appPage__pageHeader">
|
||||
<h1>{__('Coda istruttoria', 'gepafin')}</h1>
|
||||
<p>
|
||||
{isManager
|
||||
{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')}
|
||||
: __('Pool di pratiche da prendere in carico + pratiche assegnate a te.', 'gepafin'))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* TOGGLE MANAGER VIEW */}
|
||||
{canUseManagerView && (
|
||||
<div className="appPageSection">
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<Button icon="pi pi-list"
|
||||
label={__('Coda standard', 'gepafin')}
|
||||
outlined={managerMode}
|
||||
severity={managerMode ? 'secondary' : 'info'}
|
||||
onClick={() => setManagerMode(false)} />
|
||||
<Button icon="pi pi-users"
|
||||
label={__('Vista manager (riassegnazioni)', 'gepafin')}
|
||||
outlined={!managerMode}
|
||||
severity={!managerMode ? 'secondary' : 'warning'}
|
||||
onClick={() => setManagerMode(true)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* CODA STANDARD */}
|
||||
{!managerMode && (
|
||||
<div className="appPageSection">
|
||||
{loading && <Skeleton width="100%" height="10rem" />}
|
||||
{!loading && items.length === 0 && (
|
||||
@@ -118,6 +247,83 @@ const IstruttoriaQueue = () => {
|
||||
</DataTable>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VISTA MANAGER */}
|
||||
{managerMode && (
|
||||
<div className="appPageSection">
|
||||
{loading && <Skeleton width="100%" height="10rem" />}
|
||||
{!loading && managerItems.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', width: '100%' }}>
|
||||
<i className="pi pi-check-circle" style={{ fontSize: '2.5rem', color: 'var(--green-500)', display: 'block', marginBottom: '0.75rem' }} />
|
||||
<p>{__('Nessuna pratica attiva da gestire.', 'gepafin')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && managerItems.length > 0 && (
|
||||
<DataTable value={managerItems} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}>
|
||||
<Column header={__('Bando / Pratica / Tranche', 'gepafin')} body={mgrCallTpl} />
|
||||
<Column header={__('Inviata il', 'gepafin')} body={submittedTpl} style={{ width: '140px' }} />
|
||||
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '170px' }} />
|
||||
<Column header={__('Istruttore domanda', 'gepafin')} body={mgrSuggestedTpl} style={{ width: '180px' }} />
|
||||
<Column header={__('Assegnato a', 'gepafin')} body={mgrAssignedTpl} style={{ width: '200px' }} />
|
||||
<Column header={__('Erogato', 'gepafin')} body={erogatoTpl} style={{ width: '120px' }} />
|
||||
<Column header={__('Azioni', 'gepafin')} body={mgrActionsTpl} style={{ width: '260px' }} />
|
||||
</DataTable>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DIALOG RIASSEGNA */}
|
||||
<Dialog header={__('Riassegna pratica', 'gepafin')}
|
||||
visible={reassignDialog.visible} style={{ width: '520px' }}
|
||||
onHide={() => !reassigning && setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' })}
|
||||
modal
|
||||
footer={(
|
||||
<div>
|
||||
<Button label={__('Annulla', 'gepafin')} icon="pi pi-times"
|
||||
onClick={() => setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' })}
|
||||
outlined disabled={reassigning} />
|
||||
<Button label={__('Conferma', 'gepafin')} icon="pi pi-check" iconPos="right"
|
||||
severity="warning" loading={reassigning} onClick={confirmReassign} />
|
||||
</div>
|
||||
)}>
|
||||
{reassignDialog.practice && (
|
||||
<div>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<strong>{reassignDialog.practice.call_name}</strong> — pratica #{reassignDialog.practice.application_id}
|
||||
{' '}T{reassignDialog.practice.sequence_number}
|
||||
{reassignDialog.practice.period_label && ` — ${reassignDialog.practice.period_label}`}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-color-secondary)' }}>
|
||||
{__('Istruttore domanda', 'gepafin')}: <strong>{reassignDialog.practice.suggested_instructor_name || __('nessuno', 'gepafin')}</strong>
|
||||
<br />
|
||||
{__('Attualmente assegnato a', 'gepafin')}: <strong>{reassignDialog.practice.assigned_instructor_name || __('nessuno', 'gepafin')}</strong>
|
||||
</p>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Nuovo istruttore', 'gepafin')}</label>
|
||||
<Dropdown value={reassignDialog.newInstructorId}
|
||||
options={[
|
||||
{ user_id: null, display_name: __('— Metti in coda (nessuno) —', 'gepafin') },
|
||||
...instructors
|
||||
]}
|
||||
optionLabel="display_name" optionValue="user_id"
|
||||
onChange={(e) => setReassignDialog(d => ({ ...d, newInstructorId: e.value }))}
|
||||
disabled={reassigning}
|
||||
placeholder={__('Seleziona istruttore', 'gepafin')} />
|
||||
<small className="text-color-secondary">
|
||||
{__('Se la pratica era in SUBMITTED e assegni a qualcuno, passa automaticamente a IN LAVORAZIONE.', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="appForm__field" style={{ marginTop: '1rem' }}>
|
||||
<label>{__('Motivazione (opzionale, audit log)', 'gepafin')}</label>
|
||||
<InputTextarea value={reassignDialog.reason} rows={3} autoResize
|
||||
onChange={(e) => setReassignDialog(d => ({ ...d, reason: e.target.value }))}
|
||||
placeholder={__('Es: carico di lavoro, competenza specifica, assenza istruttore...', 'gepafin')}
|
||||
disabled={reassigning} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,6 +642,101 @@ const PraticaRendicontazioneEdit = () => {
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* SEZIONE 5: CONTROLLI AGGIUNTIVI (v2) */}
|
||||
{customChecksDefs.length > 0 && (<>
|
||||
<div className="appPageSection">
|
||||
<h2>{__((ulaSection.enabled ? '5.' : '4.') + ' Controlli aggiuntivi (dichiarazioni)', 'gepafin')}</h2>
|
||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
||||
{__('Dichiarazioni richieste dal bando oltre ai documenti standard. I controlli obbligatori devono essere tutti dichiarati prima di poter inviare la pratica.', 'gepafin')}
|
||||
</p>
|
||||
|
||||
<div className="fieldsRepeater">
|
||||
{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 (
|
||||
<div key={def.code} className="fieldsRepeater__panel"
|
||||
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem',
|
||||
background: isMissing ? 'var(--red-50)' : 'white' }}>
|
||||
<div style={{ display:'flex', alignItems:'flex-start', gap:'0.75rem' }}>
|
||||
<div style={{ flex:'0 0 auto', paddingTop:'4px' }}>
|
||||
<input type="checkbox"
|
||||
checked={declared}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => declareCustomCheck(def.code, e.target.checked, null)}
|
||||
style={{ width: '20px', height: '20px', cursor: readOnly ? 'default' : 'pointer' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<strong>{__('Dichiaro', 'gepafin')}: {def.label}</strong>
|
||||
{def.required && (
|
||||
<Tag severity={isMissing ? 'danger' : 'success'} value={isMissing ? __('Obbligatorio', 'gepafin') : __('OK', 'gepafin')} />
|
||||
)}
|
||||
{!def.required && (
|
||||
<Tag severity="info" value={__('Opzionale', 'gepafin')} />
|
||||
)}
|
||||
{val.verification_status && val.verification_status !== 'PENDING' && (
|
||||
<Tag severity={val.verification_status === 'VALIDO' ? 'success' : 'danger'}
|
||||
value={val.verification_status} />
|
||||
)}
|
||||
</div>
|
||||
{def.description && (
|
||||
<div className="text-color-secondary" style={{ fontSize: '0.9em', marginTop: '0.35rem', whiteSpace: 'pre-wrap' }}>
|
||||
{def.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{def.requires_document && (
|
||||
<div style={{ marginTop: '0.75rem', padding: '0.6rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
|
||||
{hasDoc ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)' }} />
|
||||
<span style={{ flex: 1, minWidth: '150px' }}>
|
||||
<strong>{val.filename_original}</strong>
|
||||
{val.size_bytes && <small className="text-color-secondary"> ({(val.size_bytes/1024).toFixed(1)} KB)</small>}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small"
|
||||
label={__('Rimuovi','gepafin')}
|
||||
onClick={() => deleteCustomCheckDoc(def.code)} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!readOnly && (
|
||||
<div>
|
||||
<small className="text-color-secondary">{__('Allega documento (PDF, JPG, PNG — max 15MB):', 'gepafin')}</small>
|
||||
<input type="file"
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) declareCustomCheck(def.code, declared, f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
style={{ display: 'block', marginTop: '0.4rem' }} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{val.verification_notes && (
|
||||
<div style={{ marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: val.verification_status === 'NON_VALIDO' ? 'var(--red-50)' : 'var(--surface-50)', borderRadius: '4px', fontSize: '0.85em' }}>
|
||||
<strong>{__('Note istruttore', 'gepafin')}:</strong> {val.verification_notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="appPage__spacer"></div>
|
||||
</>)}
|
||||
|
||||
{/* BOTTOM ACTIONS */}
|
||||
{!readOnly && (
|
||||
<div className="appPageSection">
|
||||
|
||||
@@ -3,16 +3,16 @@ 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' },
|
||||
@@ -21,19 +21,25 @@ const STATUS_TAGS = {
|
||||
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,87 +51,218 @@ 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) => (
|
||||
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;
|
||||
|
||||
return (
|
||||
<div key={app.application_id}
|
||||
className="appPageSection"
|
||||
style={{ marginBottom: '1.5rem', padding: '1.25rem', border: '1px solid var(--surface-border)', borderRadius: '8px', background: 'white' }}>
|
||||
{/* HEADER CARD */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
<div>
|
||||
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
|
||||
<div><small className="text-color-secondary">{row.company_name}</small></div>
|
||||
<h3 style={{ margin: 0, color: 'var(--primary-color)' }}>
|
||||
{app.call_name || `Bando #${app.call_id}`}
|
||||
</h3>
|
||||
<div style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em', marginTop: '2pt' }}>
|
||||
{app.company_name || ''} · {__('Domanda', 'gepafin')} #{app.application_id}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-color-secondary)' }}>{__('Finanziamento erogato','gepafin')}</div>
|
||||
<div style={{ fontSize: '1.3em', fontWeight: 700 }}>{fmtEur(app.amount_erogato)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CAP INFO */}
|
||||
<div style={{ display: 'flex', gap: '1.5rem', padding: '0.75rem 1rem', background: 'var(--surface-50)', borderRadius: '6px', fontSize: '0.9em', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-color-secondary)' }}>{__('Cap remissione totale', 'gepafin')}</div>
|
||||
<strong>{fmtEur(app.max_remission_global)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-color-secondary)' }}>{__('Già approvato', 'gepafin')}</div>
|
||||
<strong style={{ color: app.already_approved_sum > 0 ? '#22543d' : 'inherit' }}>{fmtEur(app.already_approved_sum)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-color-secondary)' }}>{__('Disponibile prossima tranche', 'gepafin')}</div>
|
||||
<strong style={{ color: 'var(--primary-color)' }}>{fmtEur(app.max_remission_next_tranche)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-color-secondary)' }}>{__('Tranches', 'gepafin')}</div>
|
||||
<strong>{app.tranches?.length || 0} / {app.max_tranches}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TRANCHES LIST */}
|
||||
{hasTranches && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{app.tranches.map((t) => {
|
||||
const tag = STATUS_TAGS[t.status] || { severity: 'secondary', label: t.status };
|
||||
const isEditable = t.status === 'DRAFT';
|
||||
return (
|
||||
<div key={t.id}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.6rem 0.75rem', borderBottom: '1px solid var(--surface-border)' }}>
|
||||
<div style={{ width: '3rem', fontWeight: 700, color: 'var(--primary-color)' }}>
|
||||
T{t.sequence_number}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>{t.period_label || <span className="text-color-secondary">{__('nessun periodo indicato','gepafin')}</span>}</div>
|
||||
<small className="text-color-secondary">
|
||||
{t.invoice_count || 0} {__('fatture','gepafin')} · {t.ula_count || 0} {__('dipendenti','gepafin')} · {t.document_count || 0} {__('doc','gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
<Tag value={tag.label} severity={tag.severity} />
|
||||
<Button icon={isEditable ? 'pi pi-pencil' : 'pi pi-eye'}
|
||||
size="small" outlined={!isEditable}
|
||||
label={isEditable ? __('Continua','gepafin') : __('Apri','gepafin')}
|
||||
onClick={() => navigate(`/rendicontazioni/${t.id}`)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
const erogatoTpl = (row) => {
|
||||
const v = Number(row.amount_erogato || 0);
|
||||
return <strong>€ {v.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>;
|
||||
};
|
||||
|
||||
const statusTpl = (row) => {
|
||||
const key = row.isReady ? 'NOT_STARTED' : (row.status || 'DRAFT');
|
||||
const conf = STATUS_TAGS[key] || { severity: 'secondary', label: key };
|
||||
return <Tag value={conf.label} severity={conf.severity} />;
|
||||
};
|
||||
|
||||
const progressTpl = (row) => {
|
||||
if (row.isReady) return <span className="text-color-secondary">—</span>;
|
||||
return (
|
||||
<span className="text-color-secondary" style={{ fontSize: '0.9em' }}>
|
||||
{row.invoice_count || 0} {__('fatture','gepafin')} · {row.ula_count || 0} {__('dipendenti','gepafin')} · {row.document_count || 0} {__('doc','gepafin')}
|
||||
</span>
|
||||
{/* BOTTONE NUOVA TRANCHE */}
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{!canStart && blockReason && (
|
||||
<small className="text-color-secondary" style={{ fontStyle: 'italic' }}>
|
||||
<i className="pi pi-info-circle" style={{ marginRight: '4pt' }} />
|
||||
{blockReason}
|
||||
</small>
|
||||
)}
|
||||
<Button icon="pi pi-plus-circle"
|
||||
label={hasTranches
|
||||
? `${__('+ Nuova tranche','gepafin')} (T${nextSeq})`
|
||||
: __('+ Avvia rendicontazione','gepafin')}
|
||||
severity={canStart ? 'success' : 'secondary'}
|
||||
disabled={!canStart}
|
||||
outlined={!canStart}
|
||||
tooltip={!canStart ? blockReason : undefined}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
onClick={() => openStartDialog(app)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const actionsTpl = (row) => {
|
||||
if (row.isReady) {
|
||||
return <Button icon="pi pi-play" label={__('Avvia rendicontazione', 'gepafin')}
|
||||
size="small" severity="success" onClick={() => handleStart(row.application_id)} />;
|
||||
}
|
||||
const isEditable = row.status === 'DRAFT';
|
||||
return <Button icon={isEditable ? 'pi pi-pencil' : 'pi pi-eye'}
|
||||
label={isEditable ? __('Continua', 'gepafin') : __('Apri', 'gepafin')}
|
||||
size="small" outlined={!isEditable}
|
||||
onClick={() => navigate(`/rendicontazioni/${row.id}`)} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="appPage">
|
||||
<Toast ref={toast} />
|
||||
|
||||
<div className="appPage__pageHeader">
|
||||
<h1>{__('Le mie rendicontazioni', 'gepafin')}</h1>
|
||||
<p>{__('Per ogni pratica finanziata puoi avviare la rendicontazione delle spese e il calcolo della remissione del debito.', 'gepafin')}</p>
|
||||
<p>{__('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')}</p>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
<div className="appPageSection">
|
||||
{loading && <Skeleton width="100%" height="10rem" />}
|
||||
{!loading && rows.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', width: '100%' }}>
|
||||
<i className="pi pi-inbox" style={{ fontSize: '2.5rem', color: 'var(--text-color-secondary)', display: 'block', marginBottom: '0.75rem' }} />
|
||||
<p>{__('Non ci sono rendicontazioni da avviare al momento.', 'gepafin')}</p>
|
||||
|
||||
{!loading && apps.length === 0 && (
|
||||
<div className="appPageSection" style={{ alignItems: 'center', padding: '3rem 2rem' }}>
|
||||
<i className="pi pi-inbox" style={{ fontSize: '2.5rem', color: 'var(--text-color-secondary)', marginBottom: '0.75rem' }} />
|
||||
<p>{__('Non ci sono rendicontazioni disponibili al momento.', 'gepafin')}</p>
|
||||
<small className="text-color-secondary">
|
||||
{__('Le rendicontazioni diventano disponibili dopo la firma del contratto e quando l\'ente ha pubblicato lo schema di rendicontazione per il bando.', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows.length > 0 && (
|
||||
<DataTable value={rows} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}>
|
||||
<Column header={__('Bando', 'gepafin')} body={callTpl} />
|
||||
<Column header={__('Importo erogato', 'gepafin')} body={erogatoTpl} style={{ width: '180px' }} />
|
||||
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '180px' }} />
|
||||
<Column header={__('Avanzamento', 'gepafin')} body={progressTpl} />
|
||||
<Column header={__('Azione', 'gepafin')} body={actionsTpl} style={{ width: '220px' }} />
|
||||
</DataTable>
|
||||
|
||||
{!loading && apps.length > 0 && apps.map(renderApplicationCard)}
|
||||
|
||||
{/* START DIALOG */}
|
||||
<Dialog header={__('Avvia nuova tranche di rendicontazione', 'gepafin')}
|
||||
visible={!!startDialog} style={{ width: '32rem' }}
|
||||
onHide={() => !starting && setStartDialog(null)}
|
||||
modal
|
||||
footer={(
|
||||
<div>
|
||||
<Button label={__('Annulla', 'gepafin')} icon="pi pi-times"
|
||||
onClick={() => setStartDialog(null)} outlined disabled={starting} />
|
||||
<Button label={__('Avvia tranche', 'gepafin')} icon="pi pi-play" iconPos="right"
|
||||
severity="success" loading={starting} onClick={confirmStart} />
|
||||
</div>
|
||||
)}>
|
||||
{startDialog && (
|
||||
<div>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
{__('Stai per avviare la tranche', 'gepafin')}
|
||||
{' '}<strong>T{startDialog.next_seq}</strong> / {startDialog.max_tranches}
|
||||
{' '}{__('del bando', 'gepafin')} <strong>{startDialog.call_name}</strong>.
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-color-secondary)' }}>
|
||||
{__('Cap remissione disponibile per questa tranche', 'gepafin')}:
|
||||
{' '}<strong>{fmtEur(startDialog.max_remission_next)}</strong>
|
||||
</p>
|
||||
|
||||
<div className="appForm__field">
|
||||
<label>{__('Periodo / fase (opzionale)', 'gepafin')}</label>
|
||||
<InputText value={startForm.period_label}
|
||||
onChange={(e) => setStartForm(f => ({ ...f, period_label: e.target.value }))}
|
||||
placeholder={__('es. "I trimestre 2021", "Stato avanzamento II"', 'gepafin')}
|
||||
disabled={starting} />
|
||||
<small className="text-color-secondary">
|
||||
{__('Descrizione libera per identificare la tranche. Apparirà sul verbale.', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{startDialog.show_copy_ula && (
|
||||
<div className="appForm__field" style={{ marginTop: '1rem' }}>
|
||||
<div className="appForm__row">
|
||||
<Checkbox inputId="copy_ula" checked={startForm.copy_ula}
|
||||
onChange={(e) => setStartForm(f => ({ ...f, copy_ula: e.checked }))}
|
||||
disabled={starting} />
|
||||
<label htmlFor="copy_ula" style={{ cursor: 'pointer' }}>
|
||||
{__('Copia i dipendenti ULA dalla tranche precedente', 'gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{__('Se attivo, i dipendenti censiti nella tranche precedente saranno precaricati. Potrai modificarli o rimuoverli prima di inviare.', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user