feat(rendicontazione): lato istruttore - queue + review + soccorso istruttorio
Backend (rendicontazione-api):
- 4 nuove colonne su remission_practice: assigned_instructor_id, reviewed_at,
reviewed_by, rejection_reason, approved_remission
- Nuova tabella remission_amendment_request (id, practice_id, request_text,
scope jsonb, deadline, status AWAITING/RESPONSE_RECEIVED/CLOSED/EXPIRED,
response_text, audit cols)
- Router instructor.py con 8 endpoint:
GET /instructor/queue (SUBMITTED pool + UNDER_REVIEW/AWAITING_AMENDMENT assigned,
o tutto se manager/superadmin)
GET /instructor/{id} (practice + gate_check + amendments)
POST /instructor/{id}/claim (SUBMITTED -> UNDER_REVIEW)
POST /instructor/{id}/approve (approved_remission opz, default = remission_due calcolato)
POST /instructor/{id}/reject (rejection_reason min 10 char)
POST /instructor/{id}/amendment (crea soccorso: request_text + deadline)
POST /instructor/{id}/amendment/{aid}/close (chiude soccorso, pratica torna UNDER_REVIEW)
POST /instructor/{id}/amendment/{aid}/respond-beneficiary (benef risponde)
- GET /{id} ora ritorna anche amendments (per beneficiario)
Frontend:
- Pagina IstruttoriaQueue (125 righe): coda pratiche con stato, istruttore
assegnato, erogato, remission_due calcolata, azioni contestuali
- Pagina IstruttoriaPratica (483 righe): dettaglio pratica readonly per istruttore,
riepilogo esteso, amendments panel con chiudi, gate check, fatture/ULA/docs,
3 Dialog per approva/respingi/soccorso
- PraticaRendicontazioneEdit esteso con sezione 'Richieste di soccorso istruttorio'
visibile al beneficiario + Dialog rispondi con request_text dell'istruttore
- Sidebar: voce 'Istruttoria rendicontazioni' per EVALUATE_APPLICATIONS
(pre_instructor + instructor_manager)
- Routes /istruttoria e /istruttoria/:id con gate sui tre ruoli
Test end-to-end OK: benef crea+submit, istruttore claim+amendment, benef risponde,
istruttore chiude+approva -> APPROVED remission 8500 EUR su NAPOLI SAS (erogato 17000).
Utenti sandbox creati:
- istruttore@sandbox.local / istruttore123 (ROLE_PRE_INSTRUCTOR)
- manager@sandbox.local / manager123 (ROLE_INSTRUCTOR_MANAGER)
This commit is contained in:
@@ -79,6 +79,8 @@ const PraticaRendicontazioneEdit = () => {
|
||||
const [invDialog, setInvDialog] = useState({ visible: false, data: null });
|
||||
// modal dipendente ULA
|
||||
const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
|
||||
// modal risposta soccorso istruttorio
|
||||
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '' });
|
||||
|
||||
// ---------- load ----------
|
||||
const load = useCallback(() => {
|
||||
@@ -225,6 +227,18 @@ const PraticaRendicontazioneEdit = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const submitAmendmentResponse = () => {
|
||||
if (!amendDialog.responseText || amendDialog.responseText.trim().length < 5) {
|
||||
toast.current?.show({ severity: 'warn', summary: __('Risposta troppo corta', 'gepafin') });
|
||||
return;
|
||||
}
|
||||
RendicontazioneService.respondAmendmentBeneficiary(
|
||||
practiceId, amendDialog.amendment.id, amendDialog.responseText,
|
||||
(resp) => { setAmendDialog({ visible: false, amendment: null, responseText: '' });
|
||||
afterMutation(__('Risposta inviata all\'istruttore', 'gepafin'))(resp); },
|
||||
onMutationError);
|
||||
};
|
||||
|
||||
// ---------- render guards ----------
|
||||
if (loading) {
|
||||
return <div className="appPage"><div className="appPageSection"><Skeleton width="100%" height="15rem" /></div></div>;
|
||||
@@ -323,6 +337,53 @@ const PraticaRendicontazioneEdit = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SOCCORSO ISTRUTTORIO (se presente) */}
|
||||
{practice.amendments && practice.amendments.length > 0 && (<>
|
||||
<div className="appPage__spacer"></div>
|
||||
<div className="appPageSection">
|
||||
<h2>{__('Richieste di soccorso istruttorio', 'gepafin')}</h2>
|
||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
||||
{__('L\'istruttore ha chiesto integrazioni o chiarimenti. Rispondi al più presto.', 'gepafin')}
|
||||
</p>
|
||||
<div className="fieldsRepeater">
|
||||
{practice.amendments.map(a => {
|
||||
const statusCfg = {
|
||||
AWAITING: { sev: 'warning', label: 'In attesa della tua risposta' },
|
||||
RESPONSE_RECEIVED: { sev: 'info', label: 'Risposta inviata, in attesa di chiusura' },
|
||||
CLOSED: { sev: 'success', label: 'Chiusa' },
|
||||
EXPIRED: { sev: 'danger', label: 'Scaduta' }
|
||||
}[a.status] || { sev: 'secondary', label: a.status };
|
||||
return (
|
||||
<div key={a.id} className="fieldsRepeater__panel"
|
||||
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem',
|
||||
background: a.status === 'AWAITING' ? 'var(--orange-50)' : 'var(--surface-50)' }}>
|
||||
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<Tag severity={statusCfg.sev} value={statusCfg.label} />
|
||||
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)' }}>
|
||||
{__('Scadenza:', 'gepafin')} {new Date(a.deadline).toLocaleDateString('it-IT')}
|
||||
</span>
|
||||
</div>
|
||||
{a.status === 'AWAITING' && (
|
||||
<Button icon="pi pi-reply" label={__('Rispondi', 'gepafin')} size="small" severity="warning"
|
||||
onClick={() => setAmendDialog({ visible: true, amendment: a, responseText: '' })} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
|
||||
<div style={{ whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>{a.request_text}</div>
|
||||
{a.response_text && (<>
|
||||
<small className="text-color-secondary">{__('Tua risposta:', 'gepafin')}</small>
|
||||
<div style={{ whiteSpace: 'pre-wrap', padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}>{a.response_text}</div>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* SEZIONE 1: REGIME IVA */}
|
||||
@@ -567,6 +628,30 @@ const PraticaRendicontazioneEdit = () => {
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* ---------- DIALOG RISPOSTA SOCCORSO ---------- */}
|
||||
<Dialog visible={amendDialog.visible} style={{ width: '560px' }}
|
||||
header={__('Rispondi al soccorso istruttorio', 'gepafin')} modal
|
||||
onHide={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })}>
|
||||
{amendDialog.amendment && (
|
||||
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); submitAmendmentResponse(); }}>
|
||||
<div style={{ padding: '0.75rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '1rem' }}>
|
||||
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
|
||||
<div style={{ whiteSpace: 'pre-wrap', marginTop: '0.25rem' }}>{amendDialog.amendment.request_text}</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('La tua risposta', 'gepafin')}</label>
|
||||
<InputTextarea value={amendDialog.responseText} rows={5} autoResize
|
||||
onChange={(e) => setAmendDialog(d => ({ ...d, responseText: e.target.value }))}
|
||||
placeholder={__('Descrivi le integrazioni fornite, allegati caricati, chiarimenti...', 'gepafin')} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })} />
|
||||
<Button type="submit" label={__('Invia risposta', 'gepafin')} icon="pi pi-send" severity="warning" />
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* ---------- DIALOG DIPENDENTE ULA ---------- */}
|
||||
<Dialog visible={empDialog.visible} style={{ width: '620px', maxWidth: '95vw' }}
|
||||
header={__('Aggiungi dipendente', 'gepafin')}
|
||||
|
||||
Reference in New Issue
Block a user