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:
@@ -27,6 +27,13 @@ const AppSidebar = () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
enable: intersection(permissions, ['MANAGE_TENDERS']).length
|
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'),
|
label: __('Rendicontazione', 'gepafin'),
|
||||||
icon: 'pi pi-receipt',
|
icon: 'pi pi-receipt',
|
||||||
|
|||||||
483
src/modules/rendicontazione/pages/IstruttoriaPratica.js
Normal file
483
src/modules/rendicontazione/pages/IstruttoriaPratica.js
Normal file
@@ -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 <div className="appPage"><div className="appPageSection"><Skeleton width="100%" height="15rem" /></div></div>;
|
||||||
|
}
|
||||||
|
if (!practice) {
|
||||||
|
return <div className="appPage"><div className="appPageSection"><p>{__('Pratica non trovata', 'gepafin')}</p></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="appPage">
|
||||||
|
<Toast ref={toast} />
|
||||||
|
<ConfirmPopup />
|
||||||
|
|
||||||
|
<div className="appPage__pageHeader">
|
||||||
|
<h1>{__('Istruttoria pratica', 'gepafin')}</h1>
|
||||||
|
<p>
|
||||||
|
<span className="companyName">
|
||||||
|
{practice.schema_snapshot?.template_label || `Call #${practice.call_id}`} · {__('Pratica', 'gepafin')} #{practice.application_id}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: '1rem' }}>
|
||||||
|
<Tag severity={statusCfg.severity} value={statusCfg.label} />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="appPage__spacer"></div>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<div className="appPageSection">
|
||||||
|
<div className="appPageSection__actions">
|
||||||
|
<Button type="button" outlined icon="pi pi-arrow-left"
|
||||||
|
label={__('Torna alla coda', 'gepafin')} onClick={() => navigate('/istruttoria')} />
|
||||||
|
|
||||||
|
{practice.status === 'SUBMITTED' && (
|
||||||
|
<Button type="button" icon="pi pi-user-plus" iconPos="right"
|
||||||
|
label={__('Prendi in carico', 'gepafin')} onClick={handleClaim} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDecidable && (<>
|
||||||
|
<Button type="button" icon="pi pi-check" iconPos="right" severity="success"
|
||||||
|
label={__('Approva', 'gepafin')}
|
||||||
|
onClick={() => setApproveDialog({ visible: true, amount: totals.remission_due || 0 })} />
|
||||||
|
<Button type="button" icon="pi pi-times" iconPos="right" severity="danger" outlined
|
||||||
|
label={__('Respingi', 'gepafin')}
|
||||||
|
onClick={() => setRejectDialog({ visible: true, reason: '' })} />
|
||||||
|
<Button type="button" icon="pi pi-comment" iconPos="right" severity="warning" outlined
|
||||||
|
label={__('Soccorso istruttorio', 'gepafin')}
|
||||||
|
disabled={openAmendments.length > 0}
|
||||||
|
onClick={() => setAmendDialog({ visible: true, text: '', deadline: null })} />
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="appPage__spacer"></div>
|
||||||
|
|
||||||
|
{/* RIEPILOGO */}
|
||||||
|
<div className="appPageSection" style={{ background: 'var(--surface-50)', padding: '1.25rem', borderRadius: '6px' }}>
|
||||||
|
<h2 style={{ margin: '0 0 0.5rem 0' }}>{__('Riepilogo', 'gepafin')}</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem', width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<small className="text-color-secondary">{__('Azienda', 'gepafin')}</small>
|
||||||
|
<div style={{ fontSize: '1rem', fontWeight: 700 }}>Company #{practice.company_id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="text-color-secondary">{__('Erogato', 'gepafin')}</small>
|
||||||
|
<div style={{ fontSize: '1.15rem', fontWeight: 700 }}>{euro(practice.amount_erogato)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="text-color-secondary">{__('Regime IVA', 'gepafin')}</small>
|
||||||
|
<div style={{ fontSize: '1rem', fontWeight: 700 }}>{practice.iva_regime || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="text-color-secondary">{__('Totale fatture', 'gepafin')}</small>
|
||||||
|
<div style={{ fontSize: '1.15rem', fontWeight: 700 }}>{euro(totals.grand_total || 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="text-color-secondary">{__('Cap remissione', 'gepafin')}</small>
|
||||||
|
<div style={{ fontSize: '1.15rem', fontWeight: 700 }}>{euro(totals.max_remission || 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="text-color-secondary">{__('Remissione calcolata', 'gepafin')}</small>
|
||||||
|
<div style={{ fontSize: '1.4rem', fontWeight: 700, color: 'var(--primary-color)' }}>{euro(totals.remission_due || 0)}</div>
|
||||||
|
</div>
|
||||||
|
{practice.approved_remission != null && (
|
||||||
|
<div>
|
||||||
|
<small className="text-color-secondary">{__('Remissione approvata', 'gepafin')}</small>
|
||||||
|
<div style={{ fontSize: '1.4rem', fontWeight: 700, color: 'var(--green-600)' }}>{euro(practice.approved_remission)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{practice.rejection_reason && (
|
||||||
|
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--red-50)', borderRadius: '4px', borderLeft: '4px solid var(--red-500)' }}>
|
||||||
|
<strong>{__('Motivo rifiuto:', 'gepafin')}</strong>
|
||||||
|
<div style={{ marginTop: '0.25rem' }}>{practice.rejection_reason}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="appPage__spacer"></div>
|
||||||
|
|
||||||
|
{/* AMENDMENTS */}
|
||||||
|
{amendments.length > 0 && (<>
|
||||||
|
<div className="appPageSection">
|
||||||
|
<h2>{__('Soccorso istruttorio', 'gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({amendments.length})</span></h2>
|
||||||
|
<div className="fieldsRepeater">
|
||||||
|
{amendments.map(a => {
|
||||||
|
const cfg = AMENDMENT_STATUS[a.status] || { severity: 'secondary', label: a.status };
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="fieldsRepeater__panel"
|
||||||
|
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem', background: 'var(--surface-50)' }}>
|
||||||
|
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<Tag severity={cfg.severity} value={cfg.label} />
|
||||||
|
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)' }}>
|
||||||
|
{__('Deadline:', 'gepafin')} {formatDate(a.deadline)} · {__('Creata:', 'gepafin')} {formatDateTime(a.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{a.status !== 'CLOSED' && isReviewable && (
|
||||||
|
<Button icon="pi pi-check" label={__('Chiudi soccorso', 'gepafin')}
|
||||||
|
size="small" outlined severity="success"
|
||||||
|
onClick={(e) => closeAmendment(e, a)} />
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
{__('Risposta beneficiario', 'gepafin')} ({formatDateTime(a.response_at)}):
|
||||||
|
</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>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* GATE CHECKS */}
|
||||||
|
{gate && (
|
||||||
|
<div className="appPageSection">
|
||||||
|
<h2>{__('Requisiti di validità', 'gepafin')}</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', width: '100%' }}>
|
||||||
|
{gate.checks.map((c, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<i className={c.passed ? 'pi pi-check-circle' : 'pi pi-times-circle'}
|
||||||
|
style={{ color: c.passed ? 'var(--green-500)' : 'var(--orange-500)', fontSize: '1.25rem' }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{c.label}</div>
|
||||||
|
<small className="text-color-secondary">{c.detail}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="appPage__spacer"></div>
|
||||||
|
|
||||||
|
{/* FATTURE PER CATEGORIA */}
|
||||||
|
<div className="appPageSection">
|
||||||
|
<h2>{__('Fatture rendicontate', 'gepafin')}</h2>
|
||||||
|
<div className="fieldsRepeater">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const invs = invoicesOfCat(cat.code);
|
||||||
|
const total = perCat[cat.code] || 0;
|
||||||
|
return (
|
||||||
|
<div key={cat.code} className="fieldsRepeater__panel"
|
||||||
|
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem' }}>
|
||||||
|
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ color: 'var(--primary-color)' }}>{cat.code}</strong> — {cat.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<strong>{euro(total)}</strong>
|
||||||
|
<div><small className="text-color-secondary">{invs.length} {__('fatture', 'gepafin')}</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{invs.length > 0 ? (
|
||||||
|
<DataTable value={invs} dataKey="id" size="small" responsiveLayout="scroll">
|
||||||
|
<Column field="invoice_number" header={__('N°', 'gepafin')} />
|
||||||
|
<Column header={__('Data', 'gepafin')} body={(r) => formatDate(r.invoice_date)} />
|
||||||
|
<Column header={__('Pagamento', 'gepafin')} body={(r) => formatDate(r.payment_date)} />
|
||||||
|
<Column field="supplier_name" header={__('Fornitore', 'gepafin')} />
|
||||||
|
<Column field="description" header={__('Descrizione', 'gepafin')}
|
||||||
|
body={(r) => <span title={r.description}>{r.description.slice(0, 40)}{r.description.length > 40 ? '…' : ''}</span>} />
|
||||||
|
<Column header={__('Imponibile', 'gepafin')} body={(r) => euro(r.taxable)} />
|
||||||
|
<Column header={__('IVA', 'gepafin')} body={(r) => euro(r.vat)} />
|
||||||
|
<Column header={__('Totale', 'gepafin')} body={(r) => euro(r.total)} />
|
||||||
|
</DataTable>
|
||||||
|
) : <small className="text-color-secondary">{__('Nessuna fattura', 'gepafin')}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ULA */}
|
||||||
|
{ulaSection.enabled && (<>
|
||||||
|
<div className="appPage__spacer"></div>
|
||||||
|
<div className="appPageSection">
|
||||||
|
<h2>{__('Dipendenti ULA', 'gepafin')}</h2>
|
||||||
|
{practice.ula_employees.length > 0 ? (
|
||||||
|
<DataTable value={practice.ula_employees} dataKey="id" size="small" responsiveLayout="scroll" style={{ width: '100%' }}>
|
||||||
|
<Column field="codice_fiscale" header="CF" />
|
||||||
|
<Column field="full_name" header={__('Nome', 'gepafin')} />
|
||||||
|
<Column header={__('Contratto', 'gepafin')} body={(r) => CONTRACT_TYPES[r.contract_type] || r.contract_type} />
|
||||||
|
<Column field="role_description" header={__('Mansione', 'gepafin')} />
|
||||||
|
<Column header="FTE" body={(r) => Number(r.fte_pct).toFixed(2)} />
|
||||||
|
<Column header={__('Periodo', 'gepafin')}
|
||||||
|
body={(r) => `${formatDate(r.period_start_date)} → ${formatDate(r.period_end_date)}`} />
|
||||||
|
<Column field="supporting_doc_filename" header={__('Allegato', 'gepafin')}
|
||||||
|
body={(r) => r.supporting_doc_filename
|
||||||
|
? <span><i className="pi pi-file" /> {r.supporting_doc_filename}</span>
|
||||||
|
: <span className="text-color-secondary">—</span>} />
|
||||||
|
</DataTable>
|
||||||
|
) : <p className="text-color-secondary">{__('Nessun dipendente caricato', 'gepafin')}</p>}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
<div className="appPage__spacer"></div>
|
||||||
|
|
||||||
|
{/* DOCUMENTI */}
|
||||||
|
<div className="appPageSection">
|
||||||
|
<h2>{__('Documenti', 'gepafin')}</h2>
|
||||||
|
<div className="fieldsRepeater">
|
||||||
|
{docsRequired.map(dr => {
|
||||||
|
const existing = practice.documents.find(d => d.doc_code === dr.code);
|
||||||
|
return (
|
||||||
|
<div key={dr.code} className="fieldsRepeater__panel"
|
||||||
|
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '0.75rem 1rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<i className={existing?.filename ? 'pi pi-check-circle' : 'pi pi-times-circle'}
|
||||||
|
style={{ color: existing?.filename ? 'var(--green-500)' : 'var(--orange-500)', fontSize: '1.25rem' }} />
|
||||||
|
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||||
|
<strong>{dr.label}</strong>
|
||||||
|
<div><small className="text-color-secondary"><code>{dr.code}</code></small></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 2, minWidth: '200px' }}>
|
||||||
|
{existing?.filename
|
||||||
|
? <span><i className="pi pi-file" /> {existing.filename} — {__('caricato il', 'gepafin')} {formatDateTime(existing.uploaded_at)}</span>
|
||||||
|
: <span className="text-color-secondary">{__('Non caricato', 'gepafin')}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- DIALOG APPROVA ---------- */}
|
||||||
|
<Dialog visible={approveDialog.visible} style={{ width: '480px' }}
|
||||||
|
header={__('Approva pratica', 'gepafin')} modal
|
||||||
|
onHide={() => setApproveDialog({ visible: false, amount: null })}>
|
||||||
|
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doApprove(); }}>
|
||||||
|
<div className="appForm__field">
|
||||||
|
<label>{__('Remissione da approvare (€)', 'gepafin')}</label>
|
||||||
|
<InputNumber value={approveDialog.amount} mode="currency" currency="EUR" locale="it-IT"
|
||||||
|
onValueChange={(e) => setApproveDialog(d => ({ ...d, amount: e.value }))} />
|
||||||
|
<small className="text-color-secondary">
|
||||||
|
{__('Valore calcolato:', 'gepafin')} {euro(totals.remission_due || 0)}. {__('Puoi modificarlo se necessario.', 'gepafin')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
|
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setApproveDialog({ visible: false, amount: null })} />
|
||||||
|
<Button type="submit" label={__('Conferma approvazione', 'gepafin')} icon="pi pi-check" severity="success" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ---------- DIALOG RESPINGI ---------- */}
|
||||||
|
<Dialog visible={rejectDialog.visible} style={{ width: '560px' }}
|
||||||
|
header={__('Respingi pratica', 'gepafin')} modal
|
||||||
|
onHide={() => setRejectDialog({ visible: false, reason: '' })}>
|
||||||
|
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doReject(); }}>
|
||||||
|
<div className="appForm__field">
|
||||||
|
<label>{__('Motivazione del rifiuto', 'gepafin')}</label>
|
||||||
|
<InputTextarea value={rejectDialog.reason} rows={4} autoResize
|
||||||
|
onChange={(e) => setRejectDialog(d => ({ ...d, reason: e.target.value }))} />
|
||||||
|
<small className="text-color-secondary">{__('Minimo 10 caratteri. Verrà inviata al beneficiario.', 'gepafin')}</small>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
|
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setRejectDialog({ visible: false, reason: '' })} />
|
||||||
|
<Button type="submit" label={__('Conferma rifiuto', 'gepafin')} icon="pi pi-times" severity="danger" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ---------- DIALOG SOCCORSO ---------- */}
|
||||||
|
<Dialog visible={amendDialog.visible} style={{ width: '560px' }}
|
||||||
|
header={__('Avvia soccorso istruttorio', 'gepafin')} modal
|
||||||
|
onHide={() => setAmendDialog({ visible: false, text: '', deadline: null })}>
|
||||||
|
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doAmend(); }}>
|
||||||
|
<div className="appForm__field">
|
||||||
|
<label>{__('Richiesta al beneficiario', 'gepafin')}</label>
|
||||||
|
<InputTextarea value={amendDialog.text} rows={5} autoResize
|
||||||
|
onChange={(e) => setAmendDialog(d => ({ ...d, text: e.target.value }))}
|
||||||
|
placeholder={__('Descrivi le integrazioni richieste...', 'gepafin')} />
|
||||||
|
<small className="text-color-secondary">{__('Sarà visibile al beneficiario, che potrà rispondere integrando la documentazione.', 'gepafin')}</small>
|
||||||
|
</div>
|
||||||
|
<div className="appForm__field">
|
||||||
|
<label>{__('Scadenza risposta', 'gepafin')}</label>
|
||||||
|
<Calendar value={amendDialog.deadline} dateFormat="dd/mm/yy" showIcon
|
||||||
|
onChange={(e) => setAmendDialog(d => ({ ...d, deadline: e.value }))} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
|
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setAmendDialog({ visible: false, text: '', deadline: null })} />
|
||||||
|
<Button type="submit" label={__('Invia richiesta', 'gepafin')} icon="pi pi-send" severity="warning" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IstruttoriaPratica;
|
||||||
125
src/modules/rendicontazione/pages/IstruttoriaQueue.js
Normal file
125
src/modules/rendicontazione/pages/IstruttoriaQueue.js
Normal file
@@ -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) => (
|
||||||
|
<div>
|
||||||
|
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
|
||||||
|
<div><small className="text-color-secondary">{row.company_name} · pratica #{row.application_id}</small></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const statusTpl = (row) => {
|
||||||
|
const c = STATUS_TAGS[row.status] || { severity: 'secondary', label: row.status };
|
||||||
|
return <div>
|
||||||
|
<Tag value={c.label} severity={c.severity} />
|
||||||
|
{row.open_amendments > 0 && (
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Tag value={`${row.open_amendments} soccorso aperto`} severity="warning" icon="pi pi-clock" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
const submittedTpl = (row) => row.submitted_at ? formatDate(row.submitted_at) : '—';
|
||||||
|
const erogatoTpl = (row) => <strong>{euro(row.amount_erogato)}</strong>;
|
||||||
|
const remissionTpl = (row) => row.remission_due != null
|
||||||
|
? <span style={{ color: 'var(--primary-color)', fontWeight: 600 }}>{euro(row.remission_due)}</span>
|
||||||
|
: <span className="text-color-secondary">—</span>;
|
||||||
|
const progressTpl = (row) => (
|
||||||
|
<small className="text-color-secondary">
|
||||||
|
{row.invoice_count} {__('fatt.','gepafin')} · {row.ula_count} {__('dip.','gepafin')} · {row.document_count} {__('doc','gepafin')}
|
||||||
|
</small>
|
||||||
|
);
|
||||||
|
const actionsTpl = (row) => {
|
||||||
|
const label = row.status === 'SUBMITTED' ? __('Apri e prendi in carico', 'gepafin') : __('Apri', 'gepafin');
|
||||||
|
return <Button icon="pi pi-eye" label={label} size="small"
|
||||||
|
outlined={row.status !== 'SUBMITTED'}
|
||||||
|
onClick={() => navigate(`/istruttoria/${row.id}`)} />;
|
||||||
|
};
|
||||||
|
const assignedTpl = (row) => {
|
||||||
|
if (!row.assigned_instructor_id) return <span className="text-color-secondary">—</span>;
|
||||||
|
return <span>#{row.assigned_instructor_id}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="appPage">
|
||||||
|
<Toast ref={toast} />
|
||||||
|
|
||||||
|
<div className="appPage__pageHeader">
|
||||||
|
<h1>{__('Coda istruttoria', 'gepafin')}</h1>
|
||||||
|
<p>
|
||||||
|
{isManager
|
||||||
|
? __('Vista manager: vedi tutte le pratiche in carico a tutti gli istruttori.', 'gepafin')
|
||||||
|
: __('Pool di pratiche da prendere in carico + pratiche assegnate a te.', 'gepafin')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="appPage__spacer"></div>
|
||||||
|
|
||||||
|
<div className="appPageSection">
|
||||||
|
{loading && <Skeleton width="100%" height="10rem" />}
|
||||||
|
{!loading && items.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>{__('Nessuna pratica in coda al momento.', 'gepafin')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && items.length > 0 && (
|
||||||
|
<DataTable value={items} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}>
|
||||||
|
<Column header={__('Bando / Azienda', 'gepafin')} body={callTpl} />
|
||||||
|
<Column header={__('Inviata il', 'gepafin')} body={submittedTpl} style={{ width: '140px' }} />
|
||||||
|
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '180px' }} />
|
||||||
|
<Column header={__('Istruttore', 'gepafin')} body={assignedTpl} style={{ width: '100px' }} />
|
||||||
|
<Column header={__('Erogato', 'gepafin')} body={erogatoTpl} style={{ width: '130px' }} />
|
||||||
|
<Column header={__('Remissione', 'gepafin')} body={remissionTpl} style={{ width: '140px' }} />
|
||||||
|
<Column header={__('Contenuto', 'gepafin')} body={progressTpl} />
|
||||||
|
<Column header={__('Azione', 'gepafin')} body={actionsTpl} style={{ width: '220px' }} />
|
||||||
|
</DataTable>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IstruttoriaQueue;
|
||||||
@@ -79,6 +79,8 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
const [invDialog, setInvDialog] = useState({ visible: false, data: null });
|
const [invDialog, setInvDialog] = useState({ visible: false, data: null });
|
||||||
// modal dipendente ULA
|
// modal dipendente ULA
|
||||||
const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
|
const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
|
||||||
|
// modal risposta soccorso istruttorio
|
||||||
|
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '' });
|
||||||
|
|
||||||
// ---------- load ----------
|
// ---------- load ----------
|
||||||
const load = useCallback(() => {
|
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 ----------
|
// ---------- render guards ----------
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="appPage"><div className="appPageSection"><Skeleton width="100%" height="15rem" /></div></div>;
|
return <div className="appPage"><div className="appPageSection"><Skeleton width="100%" height="15rem" /></div></div>;
|
||||||
@@ -323,6 +337,53 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
</div>
|
</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>
|
<div className="appPage__spacer"></div>
|
||||||
|
|
||||||
{/* SEZIONE 1: REGIME IVA */}
|
{/* SEZIONE 1: REGIME IVA */}
|
||||||
@@ -567,6 +628,30 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
)}
|
)}
|
||||||
</Dialog>
|
</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 DIPENDENTE ULA ---------- */}
|
||||||
<Dialog visible={empDialog.visible} style={{ width: '620px', maxWidth: '95vw' }}
|
<Dialog visible={empDialog.visible} style={{ width: '620px', maxWidth: '95vw' }}
|
||||||
header={__('Aggiungi dipendente', 'gepafin')}
|
header={__('Aggiungi dipendente', 'gepafin')}
|
||||||
|
|||||||
@@ -179,3 +179,62 @@ const extendPractice = {
|
|||||||
|
|
||||||
// Attach to main export
|
// Attach to main export
|
||||||
Object.assign(RendicontazioneService, extendPractice);
|
Object.assign(RendicontazioneService, extendPractice);
|
||||||
|
|
||||||
|
|
||||||
|
// ====================== ISTRUTTORE ======================
|
||||||
|
|
||||||
|
const extendInstructor = {
|
||||||
|
instructorQueue(onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/queue`, {
|
||||||
|
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
},
|
||||||
|
|
||||||
|
instructorViewPractice(practiceId, onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}`, {
|
||||||
|
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
},
|
||||||
|
|
||||||
|
claimPractice(practiceId, onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/claim`, {
|
||||||
|
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
},
|
||||||
|
|
||||||
|
approvePractice(practiceId, body, onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/approve`, {
|
||||||
|
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||||
|
body: JSON.stringify(body || {})
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectPractice(practiceId, reason, onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/reject`, {
|
||||||
|
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||||
|
body: JSON.stringify({ rejection_reason: reason })
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
},
|
||||||
|
|
||||||
|
createAmendment(practiceId, body, onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment`, {
|
||||||
|
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAmendment(practiceId, amendmentId, onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/close`, {
|
||||||
|
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
},
|
||||||
|
|
||||||
|
respondAmendmentBeneficiary(practiceId, amendmentId, responseText, onSuccess, onError) {
|
||||||
|
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/respond-beneficiary`, {
|
||||||
|
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||||
|
body: JSON.stringify({ response_text: responseText })
|
||||||
|
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(RendicontazioneService, extendInstructor);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import RendicontazioneHome from './modules/rendicontazione/pages/Rendicontazione
|
|||||||
import RendicontazioniMie from './modules/rendicontazione/pages/RendicontazioniMie';
|
import RendicontazioniMie from './modules/rendicontazione/pages/RendicontazioniMie';
|
||||||
import PraticaRendicontazioneEdit from './modules/rendicontazione/pages/PraticaRendicontazioneEdit';
|
import PraticaRendicontazioneEdit from './modules/rendicontazione/pages/PraticaRendicontazioneEdit';
|
||||||
import DevSwitchUser from './modules/rendicontazione/pages/DevSwitchUser';
|
import DevSwitchUser from './modules/rendicontazione/pages/DevSwitchUser';
|
||||||
|
import IstruttoriaQueue from './modules/rendicontazione/pages/IstruttoriaQueue';
|
||||||
|
import IstruttoriaPratica from './modules/rendicontazione/pages/IstruttoriaPratica';
|
||||||
import BandoFlowEdit from './pages/BandoFlowEdit';
|
import BandoFlowEdit from './pages/BandoFlowEdit';
|
||||||
import Imieibandi from './pages/Imieibandi';
|
import Imieibandi from './pages/Imieibandi';
|
||||||
import BandoApplication from './pages/BandoApplication';
|
import BandoApplication from './pages/BandoApplication';
|
||||||
@@ -171,6 +173,20 @@ const routes = ({ role, chosenCompanyId }) => {
|
|||||||
<Route path="/dev-switch-user" element={<DefaultLayout>
|
<Route path="/dev-switch-user" element={<DefaultLayout>
|
||||||
{'ROLE_SUPER_ADMIN' === role ? <DevSwitchUser/> : <PageNotFound/>}
|
{'ROLE_SUPER_ADMIN' === role ? <DevSwitchUser/> : <PageNotFound/>}
|
||||||
</DefaultLayout>}/>
|
</DefaultLayout>}/>
|
||||||
|
<Route path="/istruttoria" element={<DefaultLayout>
|
||||||
|
{'ROLE_PRE_INSTRUCTOR' === role ? <IstruttoriaQueue/> : null}
|
||||||
|
{'ROLE_INSTRUCTOR_MANAGER' === role ? <IstruttoriaQueue/> : null}
|
||||||
|
{'ROLE_SUPER_ADMIN' === role ? <IstruttoriaQueue/> : null}
|
||||||
|
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
|
||||||
|
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||||
|
</DefaultLayout>}/>
|
||||||
|
<Route path="/istruttoria/:id" element={<DefaultLayout>
|
||||||
|
{'ROLE_PRE_INSTRUCTOR' === role ? <IstruttoriaPratica/> : null}
|
||||||
|
{'ROLE_INSTRUCTOR_MANAGER' === role ? <IstruttoriaPratica/> : null}
|
||||||
|
{'ROLE_SUPER_ADMIN' === role ? <IstruttoriaPratica/> : null}
|
||||||
|
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
|
||||||
|
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||||
|
</DefaultLayout>}/>
|
||||||
<Route path="/bandi-osservati" element={<DefaultLayout>
|
<Route path="/bandi-osservati" element={<DefaultLayout>
|
||||||
{'ROLE_SUPER_ADMIN' === role ? <PageNotFound/> : null}
|
{'ROLE_SUPER_ADMIN' === role ? <PageNotFound/> : null}
|
||||||
{'ROLE_BENEFICIARY' === role ? <BandiPreferredBeneficiario/> : null}
|
{'ROLE_BENEFICIARY' === role ? <BandiPreferredBeneficiario/> : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user