Compare commits
7 Commits
main
...
fca18de751
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca18de751 | ||
|
|
fe0b4f1113 | ||
|
|
2268fd98f5 | ||
|
|
61cdfbd06b | ||
|
|
115f31bdef | ||
|
|
9c483ade34 | ||
|
|
8888e0326d |
@@ -174,6 +174,10 @@ const NotificationsSidebar = () => {
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
// BFLOWS: consenti di disabilitare WSS via env (sandbox senza RabbitMQ)
|
||||
if (process.env.REACT_APP_ENABLE_WEBSOCKET === '0') {
|
||||
return;
|
||||
}
|
||||
socket.current = new SockJS(socketUrl, null, {
|
||||
transports: [
|
||||
'websocket',
|
||||
|
||||
@@ -27,6 +27,27 @@ const AppSidebar = () => {
|
||||
id: 2,
|
||||
enable: intersection(permissions, ['MANAGE_TENDERS']).length
|
||||
},
|
||||
{
|
||||
label: __('Istruttoria rendicontazioni', 'gepafin'),
|
||||
icon: 'pi pi-check-square',
|
||||
href: '/istruttoria',
|
||||
id: 12,
|
||||
enable: intersection(permissions, ['EVALUATE_APPLICATIONS']).length
|
||||
},
|
||||
{
|
||||
label: __('Rendicontazione', 'gepafin'),
|
||||
icon: 'pi pi-receipt',
|
||||
href: '/rendicontazione',
|
||||
id: 21,
|
||||
enable: intersection(permissions, ['MANAGE_TENDERS']).length
|
||||
},
|
||||
{
|
||||
label: __('Dev: cambia utente', 'gepafin'),
|
||||
icon: 'pi pi-user-edit',
|
||||
href: '/dev-switch-user',
|
||||
id: 99,
|
||||
enable: intersection(permissions, ['MANAGE_USERS']).length
|
||||
},
|
||||
{
|
||||
label: __('Domande in lavorazione', 'gepafin'),
|
||||
icon: 'pi pi-file',
|
||||
@@ -83,6 +104,13 @@ const AppSidebar = () => {
|
||||
id: 10,
|
||||
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
|
||||
},
|
||||
{
|
||||
label: __('Le mie rendicontazioni', 'gepafin'),
|
||||
icon: 'pi pi-receipt',
|
||||
href: '/rendicontazioni',
|
||||
id: 11,
|
||||
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
|
||||
},
|
||||
{
|
||||
label: __('Archivio domande', 'gepafin'),
|
||||
icon: 'pi pi-briefcase',
|
||||
|
||||
114
src/modules/rendicontazione/components/FilePreviewDialog.js
Normal file
114
src/modules/rendicontazione/components/FilePreviewDialog.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
|
||||
/**
|
||||
* Dialog full-height per preview inline PDF/immagini.
|
||||
* Fetcha il blob dal microservizio (Authorization header), crea object URL,
|
||||
* lo monta in iframe. Revoca l'URL alla chiusura.
|
||||
*
|
||||
* Props:
|
||||
* visible boolean
|
||||
* onHide () => void
|
||||
* entityType 'invoice' | 'ula' | 'document'
|
||||
* entityId UUID
|
||||
* title stringa titolo dialog
|
||||
* filename nome file da mostrare e usare per download
|
||||
*/
|
||||
const FilePreviewDialog = ({ visible, onHide, entityType, entityId, title, filename }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [objectUrl, setObjectUrl] = useState(null);
|
||||
const currentUrlRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// cleanup url precedente
|
||||
if (currentUrlRef.current) {
|
||||
URL.revokeObjectURL(currentUrlRef.current);
|
||||
currentUrlRef.current = null;
|
||||
}
|
||||
|
||||
if (!visible || !entityType || !entityId) {
|
||||
setObjectUrl(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
RendicontazioneService.fetchEntityFileBlob(entityType, entityId, true,
|
||||
({ objectUrl }) => {
|
||||
currentUrlRef.current = objectUrl;
|
||||
setObjectUrl(objectUrl);
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setError(err.detail || __('Errore caricamento file', 'gepafin'));
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (currentUrlRef.current) {
|
||||
URL.revokeObjectURL(currentUrlRef.current);
|
||||
currentUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible, entityType, entityId]);
|
||||
|
||||
const onDownload = () => {
|
||||
RendicontazioneService.downloadEntityFile(entityType, entityId,
|
||||
(err) => setError(err.detail || __('Errore download', 'gepafin'))
|
||||
);
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="flex justify-content-between">
|
||||
<span className="text-color-secondary">{filename}</span>
|
||||
<div>
|
||||
<Button label={__('Scarica', 'gepafin')} icon="pi pi-download"
|
||||
className="p-button-sm p-button-outlined"
|
||||
onClick={onDownload} disabled={!objectUrl} />
|
||||
<Button label={__('Chiudi', 'gepafin')} icon="pi pi-times"
|
||||
className="p-button-sm p-button-text ml-2"
|
||||
onClick={onHide} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={title || __('Anteprima file', 'gepafin')}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '90vw', height: '92vh' }}
|
||||
contentStyle={{ padding: 0, overflow: 'hidden' }}
|
||||
maximizable
|
||||
footer={footer}
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex align-items-center justify-content-center" style={{ height: '100%' }}>
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="p-4">
|
||||
<div className="p-error">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={filename || 'preview'}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreviewDialog;
|
||||
164
src/modules/rendicontazione/components/FileUploadCell.js
Normal file
164
src/modules/rendicontazione/components/FileUploadCell.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Button } from 'primereact/button';
|
||||
import { confirmPopup } from 'primereact/confirmpopup';
|
||||
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
|
||||
/**
|
||||
* Cella compatta per gestire il file allegato a una entita.
|
||||
*
|
||||
* Stati:
|
||||
* nessun file -> bottone "Carica"
|
||||
* file presente -> chip nome + icone (preview, download, delete)
|
||||
* upload in corso -> spinner
|
||||
*
|
||||
* Props:
|
||||
* entityType 'invoice' | 'ula' | 'document'
|
||||
* entityId UUID
|
||||
* filename nome file corrente (null se nessuno)
|
||||
* sizeBytes dimensione in byte (opzionale)
|
||||
* readOnly se true nasconde upload/delete, mostra solo download/preview
|
||||
* compact se true riduce bottone "Carica"
|
||||
* onChange (fileMeta | null) => void — callback dopo upload/delete OK
|
||||
* onPreview () => void — attiva dialog preview esterno
|
||||
* onError (err) => void
|
||||
* toastRef ref al Toast per messaggi
|
||||
*/
|
||||
const MAX_BYTES = 15 * 1024 * 1024;
|
||||
const ACCEPT = '.pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png';
|
||||
|
||||
const formatSize = (n) => {
|
||||
if (!n) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const FileUploadCell = ({
|
||||
entityType, entityId, filename, sizeBytes,
|
||||
readOnly = false, compact = false,
|
||||
onChange, onPreview, onError, toastRef
|
||||
}) => {
|
||||
const inputRef = useRef(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const triggerPick = () => inputRef.current && inputRef.current.click();
|
||||
|
||||
const onFileChange = (e) => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
e.target.value = ''; // reset per riupload stesso nome
|
||||
if (!f) return;
|
||||
if (f.size > MAX_BYTES) {
|
||||
const msg = __('File troppo grande (max 15 MB)', 'gepafin');
|
||||
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
|
||||
onError && onError({ detail: msg });
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
RendicontazioneService.uploadEntityFile(entityType, entityId, f,
|
||||
(resp) => {
|
||||
setUploading(false);
|
||||
toastRef && toastRef.current && toastRef.current.show({
|
||||
severity: 'success', summary: __('File caricato', 'gepafin'),
|
||||
detail: resp.data && resp.data.filename_original });
|
||||
onChange && onChange(resp.data);
|
||||
},
|
||||
(err) => {
|
||||
setUploading(false);
|
||||
const msg = err.detail || __('Errore upload', 'gepafin');
|
||||
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
|
||||
onError && onError(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const onDownloadClick = () => {
|
||||
RendicontazioneService.downloadEntityFile(entityType, entityId,
|
||||
(err) => {
|
||||
const msg = err.detail || __('Errore download', 'gepafin');
|
||||
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const onDeleteClick = (ev) => {
|
||||
confirmPopup({
|
||||
target: ev.currentTarget,
|
||||
message: __('Eliminare il file allegato?', 'gepafin'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: __('Elimina', 'gepafin'),
|
||||
rejectLabel: __('Annulla', 'gepafin'),
|
||||
acceptClassName: 'p-button-danger p-button-sm',
|
||||
rejectClassName: 'p-button-text p-button-sm',
|
||||
accept: () => {
|
||||
RendicontazioneService.deleteEntityFile(entityType, entityId,
|
||||
() => {
|
||||
toastRef && toastRef.current && toastRef.current.show({
|
||||
severity: 'info', summary: __('File eliminato', 'gepafin') });
|
||||
onChange && onChange(null);
|
||||
},
|
||||
(err) => {
|
||||
const msg = err.detail || __('Errore eliminazione', 'gepafin');
|
||||
toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!filename) {
|
||||
if (readOnly) {
|
||||
return <span className="text-color-secondary text-sm">{__('— non caricato —', 'gepafin')}</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon={uploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'}
|
||||
label={compact ? null : __('Carica', 'gepafin')}
|
||||
className={compact ? 'p-button-sm p-button-text' : 'p-button-sm p-button-outlined'}
|
||||
disabled={uploading}
|
||||
onClick={triggerPick}
|
||||
tooltip={__('Carica PDF/JPG/PNG (max 15 MB)', 'gepafin')}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
/>
|
||||
<input type="file" ref={inputRef} accept={ACCEPT}
|
||||
style={{ display: 'none' }} onChange={onFileChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-1" style={{ flexWrap: 'nowrap' }}>
|
||||
<div className="flex align-items-center" style={{ minWidth: 0, flex: 1 }}>
|
||||
<i className="pi pi-file-pdf text-primary mr-1" />
|
||||
<span className="text-sm" style={{
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 200
|
||||
}} title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
{sizeBytes ? <span className="text-color-secondary text-xs ml-1">({formatSize(sizeBytes)})</span> : null}
|
||||
</div>
|
||||
<Button icon="pi pi-eye" className="p-button-text p-button-sm"
|
||||
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
||||
onClick={() => onPreview && onPreview()} />
|
||||
<Button icon="pi pi-download" className="p-button-text p-button-sm"
|
||||
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
||||
onClick={onDownloadClick} />
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button icon="pi pi-refresh" className="p-button-text p-button-sm p-button-secondary"
|
||||
tooltip={__('Sostituisci', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
||||
disabled={uploading} onClick={triggerPick} />
|
||||
<Button icon="pi pi-trash" className="p-button-text p-button-sm p-button-danger"
|
||||
tooltip={__('Elimina', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
||||
disabled={uploading} onClick={onDeleteClick} />
|
||||
<input type="file" ref={inputRef} accept={ACCEPT}
|
||||
style={{ display: 'none' }} onChange={onFileChange} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadCell;
|
||||
@@ -0,0 +1,615 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
// store
|
||||
import { useStoreValue } from '../../../store';
|
||||
|
||||
// components
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { MultiSelect } from 'primereact/multiselect';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import BlockingOverlay from '../../../components/BlockingOverlay';
|
||||
|
||||
// api
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
import BandoService from '../../../service/bando-service';
|
||||
|
||||
// ---------- costanti ----------
|
||||
const IVA_REGIMES = [
|
||||
{ value: 'ORDINARIO', label: 'Ordinario' },
|
||||
{ value: 'FORFETTARIO', label: 'Forfettario' },
|
||||
{ value: 'ESENTE', label: 'Esente' }
|
||||
];
|
||||
|
||||
const AMOUNT_BASIS_OPTIONS = [
|
||||
{ value: 'imponibile_always', label: 'Solo imponibile (sempre)' },
|
||||
{ value: 'imponibile_only_ordinario', label: 'Imponibile in ordinario, totale in forfettario' },
|
||||
{ value: 'totale_always', label: 'Totale IVA inclusa (sempre)' }
|
||||
];
|
||||
|
||||
const PERIOD_START_RULES = [
|
||||
{ value: 'erogato_date', label: 'Data di erogazione del finanziamento' },
|
||||
{ value: 'contract_signed_date', label: 'Data firma contratto' },
|
||||
{ value: 'custom', label: 'Data personalizzata' }
|
||||
];
|
||||
|
||||
const ULA_DOC_TYPES = [
|
||||
{ value: 'LUL', label: 'Libro Unico del Lavoro (LUL)' },
|
||||
{ value: 'GESTIONALE_PAGHE', label: 'Estratto gestionale paghe' },
|
||||
{ value: 'DICHIARAZIONE_CDL', label: 'Dichiarazione Consulente del Lavoro' },
|
||||
{ value: 'ALTRO', label: 'Altro documento di supporto' }
|
||||
];
|
||||
|
||||
// ---------- helpers JSON <-> form ----------
|
||||
const schemaJsonToForm = (j) => {
|
||||
if (!j || !j.sections) return null;
|
||||
const general = j.sections.find(s => s.type === 'static_fields') || {};
|
||||
const expenses = j.sections.find(s => s.type === 'category_grid') || {};
|
||||
const ula = j.sections.find(s => s.type === 'ula_block') || {};
|
||||
const docs = j.sections.find(s => s.type === 'document_checklist') || {};
|
||||
const gate = j.gate_rules || {};
|
||||
const ivaField = (general.fields || []).find(f => f.id === 'iva_regime');
|
||||
const ivaAllowed = ivaField && ivaField.options
|
||||
? ivaField.options.map(o => typeof o === 'string' ? o : o.value)
|
||||
: ['ORDINARIO','FORFETTARIO','ESENTE'];
|
||||
const parseList = (list) => (list || []).map(x =>
|
||||
typeof x === 'string' ? { code: x, label: x } : { code: x.code || '', label: x.label || x.code || '' });
|
||||
return {
|
||||
amount_min: gate.amount_range?.min ?? 5000,
|
||||
amount_max: gate.amount_range?.max ?? 25000,
|
||||
period_start: gate.period_start ? new Date(gate.period_start) : null,
|
||||
period_end: gate.period_end ? new Date(gate.period_end) : null,
|
||||
amount_basis: gate.amount_basis || (gate.iva_ordinario_imponibile_only === false ? 'totale_always' : 'imponibile_only_ordinario'),
|
||||
period_start_rule: gate.period_start_rule ?? 'erogato_date',
|
||||
iva_regimes_allowed: ivaAllowed,
|
||||
iva_ordinario_imponibile_only: gate.iva_ordinario_imponibile_only ?? true,
|
||||
categories: (expenses.categories || []).map(c => ({
|
||||
code: c.code || '', label: c.label || '',
|
||||
description: c.description || '', cap_amount: c.cap_amount ?? null
|
||||
})),
|
||||
ula_enabled: ula.enabled ?? false,
|
||||
ula_threshold: ula.threshold ?? 1.0,
|
||||
ula_period_start_rule: ula.period_start_rule ?? 'erogato_date',
|
||||
ula_period_end: ula.period_end ? new Date(ula.period_end) : null,
|
||||
ula_supporting_doc_required: ula.supporting_doc_required ?? true,
|
||||
ula_supporting_doc_types: (ula.supporting_doc_types || []).map(t => typeof t === 'string' ? t : t.code),
|
||||
docs_required: parseList(docs.required_types),
|
||||
cap_pct_erogato: gate.cap_pct_erogato != null ? Math.round(gate.cap_pct_erogato * 100) : 50,
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
const formToSchemaJson = (f, base = null) => {
|
||||
const orig = base || {};
|
||||
const fmtDate = (d) => d ? (typeof d === 'string' ? d : d.toISOString().slice(0, 10)) : null;
|
||||
return {
|
||||
version: orig.version || '1.0',
|
||||
template_id: orig.template_id || 'CUSTOM',
|
||||
template_label: orig.template_label || 'Schema personalizzato',
|
||||
sections: [
|
||||
{
|
||||
type: 'static_fields', id: 'general', label: 'Dati generali',
|
||||
fields: [{
|
||||
id: 'iva_regime', type: 'select', label: 'Regime IVA', required: true,
|
||||
options: IVA_REGIMES.filter(o => f.iva_regimes_allowed.includes(o.value))
|
||||
}]
|
||||
},
|
||||
{
|
||||
type: 'category_grid', id: 'expenses', label: 'Spese ammissibili per categoria',
|
||||
categories: f.categories,
|
||||
invoice_schema: { required_fields: ['invoice_number','invoice_date','payment_date','supplier_name','supplier_vat','description','taxable','vat','total','pdf'] }
|
||||
},
|
||||
{
|
||||
type: 'ula_block', id: 'ula', label: 'Calcolo ULA',
|
||||
enabled: f.ula_enabled, threshold: f.ula_threshold,
|
||||
period_start_rule: f.ula_period_start_rule,
|
||||
period_end: fmtDate(f.ula_period_end),
|
||||
supporting_doc_required: f.ula_supporting_doc_required,
|
||||
supporting_doc_types: ULA_DOC_TYPES
|
||||
.filter(t => f.ula_supporting_doc_types.includes(t.value))
|
||||
.map(t => ({ code: t.value, label: t.label }))
|
||||
},
|
||||
{
|
||||
type: 'document_checklist', id: 'docs', label: 'Documenti richiesti',
|
||||
required_types: f.docs_required
|
||||
}
|
||||
],
|
||||
gate_rules: {
|
||||
amount_range: { min: f.amount_min, max: f.amount_max },
|
||||
cap_pct_erogato: f.cap_pct_erogato / 100,
|
||||
cap_absolute: f.cap_absolute,
|
||||
iva_ordinario_imponibile_only: f.iva_ordinario_imponibile_only,
|
||||
period_start_rule: f.period_start_rule,
|
||||
period_start: fmtDate(f.period_start),
|
||||
period_end: fmtDate(f.period_end),
|
||||
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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const BandoRendicontazioneSchemaEdit = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isAsyncRequest = useStoreValue('isAsyncRequest');
|
||||
const callId = parseInt(id);
|
||||
|
||||
const [bando, setBando] = useState(null);
|
||||
const [bandoLoading, setBandoLoading] = useState(true);
|
||||
const [schemaRecord, setSchemaRecord] = useState(null);
|
||||
const [schemaLoading, setSchemaLoading] = useState(true);
|
||||
const [form, setForm] = useState(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const toast = useRef(null);
|
||||
|
||||
// ---------- load ----------
|
||||
const loadBando = () => {
|
||||
setBandoLoading(true);
|
||||
BandoService.getBando(callId,
|
||||
(r) => { setBando(r?.data || null); setBandoLoading(false); },
|
||||
() => setBandoLoading(false));
|
||||
};
|
||||
|
||||
const loadSchema = () => {
|
||||
setSchemaLoading(true);
|
||||
RendicontazioneService.getSchemaByCallId(callId,
|
||||
(resp) => {
|
||||
const rec = resp?.data || null;
|
||||
setSchemaRecord(rec);
|
||||
setForm(rec ? schemaJsonToForm(rec.schema_json) : null);
|
||||
setDirty(false);
|
||||
setSchemaLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
if (err?.status === 404) { setSchemaRecord(null); setForm(null); }
|
||||
else toast.current?.show({ severity: 'error', summary: __('Errore caricamento schema','gepafin'), detail: err?.detail });
|
||||
setSchemaLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNaN(callId)) { loadBando(); loadSchema(); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [callId]);
|
||||
|
||||
// ---------- updates ----------
|
||||
const update = (patch) => { setForm(p => ({ ...p, ...patch })); setDirty(true); };
|
||||
const updateCategory = (idx, patch) => {
|
||||
setForm(p => ({ ...p, categories: p.categories.map((c,i) => i===idx ? {...c, ...patch} : c) }));
|
||||
setDirty(true);
|
||||
};
|
||||
const addCategory = () => {
|
||||
setForm(p => ({ ...p, categories: [...p.categories, { code:'', label:'', description:'', cap_amount:null }] }));
|
||||
setDirty(true);
|
||||
};
|
||||
const removeCategory = (idx) => {
|
||||
setForm(p => ({ ...p, categories: p.categories.filter((_,i) => i!==idx) }));
|
||||
setDirty(true);
|
||||
};
|
||||
const updateDoc = (idx, patch) => {
|
||||
setForm(p => ({ ...p, docs_required: p.docs_required.map((d,i) => i===idx ? {...d,...patch} : d) }));
|
||||
setDirty(true);
|
||||
};
|
||||
const addDoc = () => {
|
||||
setForm(p => ({ ...p, docs_required: [...p.docs_required, { code:'', label:'' }] }));
|
||||
setDirty(true);
|
||||
};
|
||||
const removeDoc = (idx) => {
|
||||
setForm(p => ({ ...p, docs_required: p.docs_required.filter((_,i) => i!==idx) }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// ---------- actions ----------
|
||||
const handleInitializeRestart = (e) => {
|
||||
confirmPopup({
|
||||
target: e.currentTarget,
|
||||
message: __('Inizializzo lo schema con il template RE-START? Sarà modificabile finché non verrà pubblicato.','gepafin'),
|
||||
icon: 'pi pi-info-circle',
|
||||
acceptLabel: __('Inizializza','gepafin'), rejectLabel: __('Annulla','gepafin'),
|
||||
accept: () => RendicontazioneService.initializeRestartTemplate(callId,
|
||||
() => { toast.current?.show({severity:'success', summary: __('Schema inizializzato','gepafin')}); loadSchema(); },
|
||||
(err) => toast.current?.show({severity:'error', summary:__('Inizializzazione fallita','gepafin'), detail: err?.detail}))
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const newJson = formToSchemaJson(form, schemaRecord?.schema_json);
|
||||
RendicontazioneService.updateSchema(callId, newJson,
|
||||
(resp) => {
|
||||
toast.current?.show({severity:'success', summary: __('Schema salvato','gepafin')});
|
||||
setSchemaRecord(resp?.data);
|
||||
setForm(schemaJsonToForm(resp?.data?.schema_json));
|
||||
setDirty(false);
|
||||
},
|
||||
(err) => toast.current?.show({severity:'error', summary:__('Salvataggio fallito','gepafin'), detail: err?.detail}));
|
||||
};
|
||||
|
||||
const handlePublish = (e) => {
|
||||
confirmPopup({
|
||||
target: e.currentTarget,
|
||||
message: __('Dopo la pubblicazione lo schema non sarà più modificabile e diventerà visibile ai beneficiari. Confermi?','gepafin'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: __('Pubblica','gepafin'), rejectLabel: __('Annulla','gepafin'),
|
||||
acceptClassName: 'p-button-success',
|
||||
accept: () => RendicontazioneService.publishSchema(callId,
|
||||
(resp) => { toast.current?.show({severity:'success', summary:__('Schema pubblicato','gepafin')}); setSchemaRecord(resp?.data); },
|
||||
(err) => toast.current?.show({severity:'error', summary:__('Pubblicazione fallita','gepafin'), detail: err?.detail}))
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
const isPublished = schemaRecord?.status === 'PUBLISHED';
|
||||
const readOnly = isPublished;
|
||||
const hasSchema = !!schemaRecord;
|
||||
|
||||
const statusTag = useMemo(() => {
|
||||
if (!hasSchema) return <Tag severity="info" value={__('Non creato','gepafin')} />;
|
||||
if (isPublished) return <Tag severity="success" value={__('Pubblicato','gepafin')} />;
|
||||
return <Tag severity="warning" value={__('Bozza','gepafin')} />;
|
||||
}, [hasSchema, isPublished]);
|
||||
|
||||
return (
|
||||
<div className="appPage">
|
||||
<Toast ref={toast} />
|
||||
<ConfirmPopup />
|
||||
<BlockingOverlay isBlocked={isAsyncRequest} />
|
||||
|
||||
{/* HEADER — flex column, border-left */}
|
||||
<div className="appPage__pageHeader">
|
||||
<h1>{__('Schema rendicontazione','gepafin')}</h1>
|
||||
<p>
|
||||
{bandoLoading
|
||||
? <Skeleton width="20rem" height="1.2rem" />
|
||||
: <>
|
||||
<span className="companyName">{(bando && bando.name) || `Bando #${callId}`}</span>
|
||||
<span style={{ marginLeft: '1rem' }}>{statusTag}</span>
|
||||
</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* ACTIONS — torna indietro + salva/pubblica */}
|
||||
<div className="appPageSection">
|
||||
<div className="appPageSection__actions">
|
||||
<Button type="button" outlined icon="pi pi-arrow-left"
|
||||
label={__('Indietro','gepafin')} onClick={() => navigate('/rendicontazione')} />
|
||||
{hasSchema && !isPublished && (
|
||||
<>
|
||||
<Button type="button" icon="pi pi-save" iconPos="right"
|
||||
label={__('Salva bozza','gepafin')} onClick={handleSave} disabled={!dirty} />
|
||||
<Button type="button" icon="pi pi-check-circle" iconPos="right" severity="success"
|
||||
label={__('Pubblica','gepafin')} onClick={handlePublish} disabled={dirty} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* CONTENT */}
|
||||
{schemaLoading && (
|
||||
<div className="appPageSection">
|
||||
<Skeleton width="100%" height="12rem" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!schemaLoading && !hasSchema && (
|
||||
<div className="appPageSection" style={{ alignItems: 'center', padding: '3rem 2rem' }}>
|
||||
<i className="pi pi-file-edit" style={{ fontSize: '3rem', color: 'var(--text-color-secondary)', marginBottom: '1rem' }} />
|
||||
<h2 style={{ marginBottom: '0.5rem' }}>{__('Nessuno schema di rendicontazione per questo bando','gepafin')}</h2>
|
||||
<p style={{ color: 'var(--text-color-secondary)', marginBottom: '1.5rem', textAlign: 'center' }}>
|
||||
{__('Puoi inizializzarlo con un template predefinito. Per ora è disponibile il template RE-START (fondo prestiti con remissione del debito).','gepafin')}
|
||||
</p>
|
||||
<Button icon="pi pi-plus-circle" iconPos="right"
|
||||
label={__('Inizializza con template RE-START','gepafin')}
|
||||
onClick={handleInitializeRestart} severity="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!schemaLoading && hasSchema && form && (
|
||||
<form className="appForm p-fluid" onSubmit={(e) => e.preventDefault()}>
|
||||
|
||||
{/* 1 - IMPORTI E PERIODO */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('1. Importi ammissibili e periodo','gepafin')}</h2>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Importo minimo erogato','gepafin')}</label>
|
||||
<InputNumber value={form.amount_min} onValueChange={(e) => update({amount_min: e.value})}
|
||||
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Importo massimo erogato','gepafin')}</label>
|
||||
<InputNumber value={form.amount_max} onValueChange={(e) => update({amount_max: e.value})}
|
||||
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Inizio periodo — regola','gepafin')}</label>
|
||||
<Dropdown value={form.period_start_rule}
|
||||
onChange={(e) => update({period_start_rule: e.value})}
|
||||
options={PERIOD_START_RULES} disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Inizio periodo — data (se fissa)','gepafin')}</label>
|
||||
<Calendar value={form.period_start} onChange={(e) => update({period_start: e.value})}
|
||||
dateFormat="dd/mm/yy" showIcon disabled={readOnly} />
|
||||
<small className="text-color-secondary">{__("Usata dalla verifica date fatture. Compila se la regola non è 'data erogazione'.",'gepafin')}</small>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Fine periodo','gepafin')}</label>
|
||||
<Calendar value={form.period_end} onChange={(e) => update({period_end: e.value})}
|
||||
dateFormat="dd/mm/yy" showIcon disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appForm__field" style={{maxWidth: '600px'}}>
|
||||
<label>{__('Base di calcolo ammissibile','gepafin')}</label>
|
||||
<Dropdown value={form.amount_basis}
|
||||
onChange={(e) => update({amount_basis: e.value})}
|
||||
options={AMOUNT_BASIS_OPTIONS} disabled={readOnly} />
|
||||
<small className="text-color-secondary">
|
||||
{__("Determina su quale importo delle fatture si calcola la remissione. La norma del bando può prevedere regimi diversi.", 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* 2 - IVA */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('2. Regime IVA','gepafin')}</h2>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Regimi IVA consentiti','gepafin')}</label>
|
||||
<MultiSelect value={form.iva_regimes_allowed} options={IVA_REGIMES}
|
||||
onChange={(e) => update({iva_regimes_allowed: e.value})}
|
||||
disabled={readOnly} display="chip" placeholder={__('Seleziona regimi','gepafin')} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={form.iva_ordinario_imponibile_only}
|
||||
onChange={(e) => update({iva_ordinario_imponibile_only: e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && update({iva_ordinario_imponibile_only: !form.iva_ordinario_imponibile_only})}>
|
||||
{__('Regime ordinario: solo imponibile rendicontabile','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
<small>{__('Se attivo, in regime ordinario l\'IVA non viene considerata rendicontabile — vale solo la base imponibile della fattura.','gepafin')}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* 3 - CATEGORIE */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('3. Categorie di spesa ammissibili','gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({form.categories.length})</span></h2>
|
||||
|
||||
<div className="fieldsRepeater">
|
||||
{form.categories.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 || `#${i+1}`} — {c.label || __('(senza nome)','gepafin')}</strong>
|
||||
{!readOnly && (
|
||||
<Button type="button" icon="pi pi-trash" severity="danger" outlined
|
||||
size="small" onClick={() => removeCategory(i)}
|
||||
tooltip={__('Rimuovi categoria','gepafin')} tooltipOptions={{position:'top'}} />
|
||||
)}
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Codice','gepafin')}</label>
|
||||
<InputText value={c.code} onChange={(e) => updateCategory(i,{code:e.target.value})}
|
||||
placeholder="B1" disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Cap importo (opzionale)','gepafin')}</label>
|
||||
<InputNumber value={c.cap_amount}
|
||||
onValueChange={(e) => updateCategory(i,{cap_amount:e.value})}
|
||||
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Nome categoria','gepafin')}</label>
|
||||
<InputText value={c.label}
|
||||
onChange={(e) => updateCategory(i,{label:e.target.value})} disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Descrizione','gepafin')}</label>
|
||||
<InputTextarea value={c.description}
|
||||
onChange={(e) => updateCategory(i,{description:e.target.value})}
|
||||
rows={2} disabled={readOnly} autoResize />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Button type="button" icon="pi pi-plus" iconPos="right" outlined
|
||||
label={__('Aggiungi categoria','gepafin')} onClick={addCategory} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* 4 - ULA */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('4. Calcolo ULA (incremento occupazione)','gepafin')}</h2>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={form.ula_enabled}
|
||||
onChange={(e) => update({ula_enabled: e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && update({ula_enabled: !form.ula_enabled})}>
|
||||
{__('Calcolo ULA richiesto per questo bando','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{form.ula_enabled && (
|
||||
<>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Soglia minima di incremento','gepafin')}</label>
|
||||
<InputNumber value={form.ula_threshold}
|
||||
onValueChange={(e) => update({ula_threshold: e.value})}
|
||||
mode="decimal" minFractionDigits={1} maxFractionDigits={2} min={0} disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Periodo fine ULA','gepafin')}</label>
|
||||
<Calendar value={form.ula_period_end}
|
||||
onChange={(e) => update({ula_period_end: e.value})}
|
||||
dateFormat="dd/mm/yy" showIcon disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={form.ula_supporting_doc_required}
|
||||
onChange={(e) => update({ula_supporting_doc_required: e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && update({ula_supporting_doc_required: !form.ula_supporting_doc_required})}>
|
||||
{__('Allegato di supporto obbligatorio','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{form.ula_supporting_doc_required && (
|
||||
<div className="appForm__field">
|
||||
<label>{__('Tipi di documento ammessi','gepafin')}</label>
|
||||
<MultiSelect value={form.ula_supporting_doc_types} options={ULA_DOC_TYPES}
|
||||
onChange={(e) => update({ula_supporting_doc_types: e.value})}
|
||||
disabled={readOnly} display="chip" placeholder={__('Seleziona tipi','gepafin')} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* 5 - DOCUMENTI */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('5. Documenti richiesti','gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({form.docs_required.length})</span></h2>
|
||||
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
|
||||
{__('I documenti già in regola nel repository della Company saranno riutilizzati automaticamente. Solo quelli scaduti o mancanti richiederanno caricamento.','gepafin')}
|
||||
</p>
|
||||
<div className="fieldsRepeater">
|
||||
{form.docs_required.map((d, i) => (
|
||||
<div key={i} className="fieldsRepeater__panel" style={{ padding:'0.75rem 1rem', border:'1px solid var(--surface-border)', borderRadius:'6px', background:'var(--surface-50)' }}>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Codice','gepafin')}</label>
|
||||
<InputText value={d.code} onChange={(e) => updateDoc(i,{code:e.target.value})}
|
||||
placeholder="DURC" disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Etichetta visibile al beneficiario','gepafin')}</label>
|
||||
<InputText value={d.label} onChange={(e) => updateDoc(i,{label:e.target.value})} disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ textAlign: 'right', marginTop:'0.5rem' }}>
|
||||
<Button type="button" icon="pi pi-trash" severity="danger" outlined size="small"
|
||||
onClick={() => removeDoc(i)} label={__('Rimuovi','gepafin')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Button type="button" icon="pi pi-plus" iconPos="right" outlined
|
||||
label={__('Aggiungi documento','gepafin')} onClick={addDoc} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* 6 - REGOLE */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('6. Regole di validazione (gate pre-submit)','gepafin')}</h2>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Cap remissione (% erogato)','gepafin')}</label>
|
||||
<InputNumber value={form.cap_pct_erogato}
|
||||
onValueChange={(e) => update({cap_pct_erogato: e.value})}
|
||||
suffix=" %" min={0} max={100} disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Cap remissione assoluto','gepafin')}</label>
|
||||
<InputNumber value={form.cap_absolute}
|
||||
onValueChange={(e) => update({cap_absolute: e.value})}
|
||||
mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={form.require_invoice_per_category}
|
||||
onChange={(e) => update({require_invoice_per_category: e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && update({require_invoice_per_category: !form.require_invoice_per_category})}>
|
||||
{__('Richiedi almeno una fattura per ogni categoria con importo > 0','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={form.require_ula_above_threshold}
|
||||
onChange={(e) => update({require_ula_above_threshold: e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && update({require_ula_above_threshold: !form.require_ula_above_threshold})}>
|
||||
{__('Richiedi ULA sopra soglia per validare','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={form.require_all_documents_resolved}
|
||||
onChange={(e) => update({require_all_documents_resolved: e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && update({require_all_documents_resolved: !form.require_all_documents_resolved})}>
|
||||
{__('Richiedi che tutti i documenti siano in regola','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* ACTIONS BOTTOM (copia degli action top per comodità) */}
|
||||
{!isPublished && (
|
||||
<div className="appPageSection">
|
||||
<div className="appPageSection__actions">
|
||||
<Button type="button" icon="pi pi-save" iconPos="right"
|
||||
label={__('Salva bozza','gepafin')} onClick={handleSave} disabled={!dirty} />
|
||||
<Button type="button" icon="pi pi-check-circle" iconPos="right" severity="success"
|
||||
label={__('Pubblica','gepafin')} onClick={handlePublish} disabled={dirty} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BandoRendicontazioneSchemaEdit;
|
||||
83
src/modules/rendicontazione/pages/DevSwitchUser.js
Normal file
83
src/modules/rendicontazione/pages/DevSwitchUser.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Toast } from 'primereact/toast';
|
||||
|
||||
import { storeSet } from '../../../store';
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
|
||||
|
||||
/**
|
||||
* Pagina sandbox: permette al superadmin di impersonare un altro utente
|
||||
* (tipicamente beneficiario) senza passare per SPID. Solo per sviluppo.
|
||||
*/
|
||||
const DevSwitchUser = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
const [email, setEmail] = useState('beneficiario@sandbox.local');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const doImpersonate = () => {
|
||||
setLoading(true);
|
||||
RendicontazioneService.impersonate(email,
|
||||
(resp) => {
|
||||
const data = resp?.data;
|
||||
if (!data?.token) {
|
||||
toast.current?.show({ severity: 'error', summary: __('Risposta vuota', 'gepafin') });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// popola lo store Zustand come dopo il login
|
||||
storeSet('setAuthData', {
|
||||
token: data.token,
|
||||
userData: data.user
|
||||
});
|
||||
toast.current?.show({ severity: 'success', summary: __('Ora sei ', 'gepafin') + data.user.email });
|
||||
// aspetta un tick e ricarica a root
|
||||
setTimeout(() => window.location.replace('/'), 700);
|
||||
},
|
||||
(err) => {
|
||||
toast.current?.show({ severity: 'error', summary: __('Impersonate fallito', 'gepafin'), detail: err?.detail });
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="appPage">
|
||||
<Toast ref={toast} />
|
||||
<div className="appPage__pageHeader">
|
||||
<h1>{__('Dev: cambia utente', 'gepafin')}</h1>
|
||||
<p>{__('Pagina sandbox. Permette di impersonare un utente (es. beneficiario) senza passare per SPID.', 'gepafin')}</p>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
<div className="appPageSection">
|
||||
<Card style={{ width: '100%', maxWidth: '500px' }}>
|
||||
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doImpersonate(); }}>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Email utente da impersonare', 'gepafin')}</label>
|
||||
<InputText value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<small className="text-color-secondary">
|
||||
{__('Prova: beneficiario@sandbox.local oppure admin@sandbox.local', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem' }}>
|
||||
<Button type="button" outlined label={__('Indietro', 'gepafin')}
|
||||
onClick={() => navigate('/')} />
|
||||
<Button type="submit" label={__('Impersona', 'gepafin')} icon="pi pi-user-edit"
|
||||
loading={loading} severity="warning" />
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevSwitchUser;
|
||||
1094
src/modules/rendicontazione/pages/IstruttoriaPratica.js
Normal file
1094
src/modules/rendicontazione/pages/IstruttoriaPratica.js
Normal file
File diff suppressed because it is too large
Load Diff
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;
|
||||
803
src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js
Normal file
803
src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js
Normal file
@@ -0,0 +1,803 @@
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
// components
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
|
||||
// api
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
import FileUploadCell from '../components/FileUploadCell';
|
||||
import FilePreviewDialog from '../components/FilePreviewDialog';
|
||||
|
||||
// ---------- costanti ----------
|
||||
const IVA_REGIME_LABELS = {
|
||||
ORDINARIO: 'Ordinario (IVA non rendicontabile)',
|
||||
FORFETTARIO: 'Forfettario (IVA rendicontabile)',
|
||||
ESENTE: 'Esente'
|
||||
};
|
||||
|
||||
const CONTRACT_TYPES = [
|
||||
{ value: 'T_IND', label: 'Tempo indeterminato' },
|
||||
{ value: 'T_DET', label: 'Tempo determinato' },
|
||||
{ value: 'APPR', label: 'Apprendistato' },
|
||||
{ value: 'STAGE', label: 'Tirocinio / Stage' },
|
||||
{ value: 'COLL', label: 'Collaborazione coordinata' },
|
||||
{ value: 'ALTRO', label: 'Altro' }
|
||||
];
|
||||
|
||||
const STATUS_TAGS = {
|
||||
DRAFT: { severity: 'warning', label: 'In compilazione' },
|
||||
SUBMITTED: { severity: 'info', label: 'Inviata' },
|
||||
UNDER_REVIEW: { severity: 'info', label: 'In valutazione' },
|
||||
APPROVED: { severity: 'success', label: 'Approvata' },
|
||||
REJECTED: { severity: 'danger', label: 'Respinta' },
|
||||
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') : '—';
|
||||
|
||||
// empty invoice/employee templates
|
||||
const emptyInvoice = (catCode) => ({
|
||||
category_code: catCode || '',
|
||||
invoice_number: '', invoice_date: null, payment_date: null,
|
||||
supplier_name: '', supplier_vat: '',
|
||||
description: '', taxable: null, vat: 0, total: null,
|
||||
pdf_filename: ''
|
||||
});
|
||||
|
||||
const emptyEmployee = () => ({
|
||||
codice_fiscale: '', full_name: '',
|
||||
contract_type: 'T_IND', role_description: '',
|
||||
fte_pct: 1.0,
|
||||
period_start_date: null, period_end_date: null,
|
||||
supporting_doc_type: 'LUL', supporting_doc_filename: ''
|
||||
});
|
||||
|
||||
|
||||
const PraticaRendicontazioneEdit = () => {
|
||||
const { id: practiceId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
|
||||
const [practice, setPractice] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [gate, setGate] = useState(null);
|
||||
|
||||
// modal fattura
|
||||
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: '' });
|
||||
// preview file
|
||||
const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null });
|
||||
const openPreview = (entityType, entityId, title, filename) => setPreviewDialog({ visible: true, entityType, entityId, title, filename });
|
||||
const closePreview = () => setPreviewDialog({ visible: false, entityType: null, entityId: null, filename: null, title: null });
|
||||
// update locale riga dopo upload/delete
|
||||
const updateInvoiceFile = (invoiceId, fileMeta) => {
|
||||
setPractice(p => p ? { ...p, invoices: p.invoices.map(i => i.id === invoiceId ? {
|
||||
...i,
|
||||
pdf_filename: fileMeta ? fileMeta.filename_original : null,
|
||||
storage_path: fileMeta ? fileMeta.storage_path : null,
|
||||
size_bytes: fileMeta ? fileMeta.size_bytes : null,
|
||||
} : i) } : p);
|
||||
};
|
||||
const updateUlaFile = (empId, fileMeta) => {
|
||||
setPractice(p => p ? { ...p, ula_employees: p.ula_employees.map(e => e.id === empId ? {
|
||||
...e,
|
||||
supporting_doc_filename: fileMeta ? fileMeta.filename_original : null,
|
||||
storage_path: fileMeta ? fileMeta.storage_path : null,
|
||||
size_bytes: fileMeta ? fileMeta.size_bytes : null,
|
||||
} : e) } : p);
|
||||
};
|
||||
const updateDocFile = (docCode, docId, fileMeta) => {
|
||||
setPractice(p => {
|
||||
if (!p) return p;
|
||||
const exists = p.documents.find(d => d.doc_code === docCode);
|
||||
const newDocs = exists
|
||||
? p.documents.map(d => d.doc_code === docCode ? {
|
||||
...d,
|
||||
filename: fileMeta ? fileMeta.filename_original : null,
|
||||
storage_path: fileMeta ? fileMeta.storage_path : null,
|
||||
size_bytes: fileMeta ? fileMeta.size_bytes : null,
|
||||
} : d)
|
||||
: p.documents;
|
||||
return { ...p, documents: newDocs };
|
||||
});
|
||||
};
|
||||
// ensure doc record exists (returns id via callback)
|
||||
const ensureDocRecord = (docCode, onReady) => {
|
||||
const existing = practice?.documents?.find(d => d.doc_code === docCode);
|
||||
if (existing && existing.id) { onReady(existing.id); return; }
|
||||
RendicontazioneService.upsertDocument(practiceId, docCode, { filename: null },
|
||||
(resp) => {
|
||||
const newDoc = resp?.data;
|
||||
if (newDoc && newDoc.id) {
|
||||
setPractice(p => p ? { ...p, documents: [...p.documents.filter(d => d.doc_code !== docCode), newDoc] } : p);
|
||||
onReady(newDoc.id);
|
||||
}
|
||||
},
|
||||
(err) => toast.current?.show({ severity: 'error', summary: __('Errore preparazione documento', 'gepafin'), detail: err?.detail })
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- load ----------
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
RendicontazioneService.getPractice(practiceId,
|
||||
(resp) => { setPractice(resp?.data); setLoading(false); refreshGate(resp?.data); },
|
||||
(err) => {
|
||||
toast.current?.show({ severity: 'error', summary: __('Errore caricamento', 'gepafin'), detail: err?.detail });
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [practiceId]);
|
||||
|
||||
const refreshGate = (p) => {
|
||||
RendicontazioneService.gateCheck(practiceId,
|
||||
(resp) => setGate(resp?.data),
|
||||
() => setGate(null));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const readOnly = practice && practice.status !== 'DRAFT';
|
||||
|
||||
// ---------- derived ----------
|
||||
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 docsSection = useMemo(() => sections.find(x => x.type === 'document_checklist') || {}, [sections]);
|
||||
const docsRequired = useMemo(() => {
|
||||
const raw = docsSection.required_types || [];
|
||||
return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r);
|
||||
}, [docsSection]);
|
||||
const ivaAllowed = useMemo(() => {
|
||||
const gen = sections.find(x => x.type === 'static_fields');
|
||||
const ivaField = (gen?.fields || []).find(f => f.id === 'iva_regime');
|
||||
const opts = ivaField?.options || [];
|
||||
return opts.map(o => (typeof o === 'string' ? { value: o, label: IVA_REGIME_LABELS[o] || o }
|
||||
: { value: o.value, label: IVA_REGIME_LABELS[o.value] || o.label || o.value }));
|
||||
}, [sections]);
|
||||
|
||||
// ---------- actions ----------
|
||||
const afterMutation = (successMsg) => (resp) => {
|
||||
toast.current?.show({ severity: 'success', summary: successMsg });
|
||||
load();
|
||||
};
|
||||
const onMutationError = (err) => {
|
||||
toast.current?.show({ severity: 'error', summary: __('Operazione fallita', 'gepafin'), detail: err?.detail || JSON.stringify(err?.message || err) });
|
||||
};
|
||||
|
||||
const updateIvaRegime = (regime) => {
|
||||
RendicontazioneService.updatePractice(practiceId, { iva_regime: regime },
|
||||
afterMutation(__('Regime IVA aggiornato', 'gepafin')), onMutationError);
|
||||
};
|
||||
|
||||
// invoices
|
||||
const openAddInvoice = (catCode) => setInvDialog({ visible: true, data: emptyInvoice(catCode) });
|
||||
const saveInvoice = () => {
|
||||
const d = invDialog.data;
|
||||
// validazione minima
|
||||
if (!d.invoice_number || !d.invoice_date || !d.payment_date || !d.supplier_name ||
|
||||
!d.supplier_vat || !d.description || d.taxable == null || d.total == null) {
|
||||
toast.current?.show({ severity: 'warn', summary: __('Campi obbligatori mancanti', 'gepafin'), detail: __('Compila tutti i campi della fattura.', 'gepafin') });
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...d,
|
||||
invoice_date: typeof d.invoice_date === 'string' ? d.invoice_date : d.invoice_date.toISOString().slice(0, 10),
|
||||
payment_date: typeof d.payment_date === 'string' ? d.payment_date : d.payment_date.toISOString().slice(0, 10)
|
||||
};
|
||||
RendicontazioneService.addInvoice(practiceId, payload,
|
||||
(resp) => { setInvDialog({ visible: false, data: null }); afterMutation(__('Fattura aggiunta', 'gepafin'))(resp); },
|
||||
onMutationError);
|
||||
};
|
||||
const deleteInvoice = (e, inv) => {
|
||||
confirmPopup({
|
||||
target: e.currentTarget,
|
||||
message: __('Rimuovere questa fattura?', 'gepafin'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: __('Rimuovi', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
|
||||
acceptClassName: 'p-button-danger',
|
||||
accept: () => RendicontazioneService.deleteInvoice(practiceId, inv.id,
|
||||
afterMutation(__('Fattura rimossa', 'gepafin')), onMutationError)
|
||||
});
|
||||
};
|
||||
|
||||
// ula
|
||||
const openAddEmployee = () => setEmpDialog({ visible: true, data: emptyEmployee() });
|
||||
const saveEmployee = () => {
|
||||
const d = empDialog.data;
|
||||
if (!d.codice_fiscale || !d.full_name || !d.contract_type ||
|
||||
!d.period_start_date || !d.period_end_date || d.fte_pct == null) {
|
||||
toast.current?.show({ severity: 'warn', summary: __('Campi obbligatori mancanti', 'gepafin') });
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...d,
|
||||
period_start_date: typeof d.period_start_date === 'string' ? d.period_start_date : d.period_start_date.toISOString().slice(0, 10),
|
||||
period_end_date: typeof d.period_end_date === 'string' ? d.period_end_date : d.period_end_date.toISOString().slice(0, 10)
|
||||
};
|
||||
RendicontazioneService.addUlaEmployee(practiceId, payload,
|
||||
(resp) => { setEmpDialog({ visible: false, data: null }); afterMutation(__('Dipendente aggiunto', 'gepafin'))(resp); },
|
||||
onMutationError);
|
||||
};
|
||||
const deleteEmployee = (e, emp) => {
|
||||
confirmPopup({
|
||||
target: e.currentTarget,
|
||||
message: __('Rimuovere questo dipendente?', 'gepafin'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: __('Rimuovi', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
|
||||
acceptClassName: 'p-button-danger',
|
||||
accept: () => RendicontazioneService.deleteUlaEmployee(practiceId, emp.id,
|
||||
afterMutation(__('Dipendente rimosso', 'gepafin')), onMutationError)
|
||||
});
|
||||
};
|
||||
|
||||
// documents
|
||||
const upsertDocument = (docCode, filename) => {
|
||||
RendicontazioneService.upsertDocument(practiceId, docCode, { doc_code: docCode, filename },
|
||||
afterMutation(__('Documento aggiornato', 'gepafin')), onMutationError);
|
||||
};
|
||||
const clearDocument = (docCode) => {
|
||||
RendicontazioneService.clearDocument(practiceId, docCode,
|
||||
afterMutation(__('Documento rimosso', 'gepafin')), onMutationError);
|
||||
};
|
||||
|
||||
// submit
|
||||
const handleSubmit = (e) => {
|
||||
confirmPopup({
|
||||
target: e.currentTarget,
|
||||
message: __('Confermi l\'invio della pratica di rendicontazione? Dopo l\'invio non potrai più modificarla.', 'gepafin'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: __('Invia', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
|
||||
acceptClassName: 'p-button-success',
|
||||
accept: () => RendicontazioneService.submitPractice(practiceId,
|
||||
(resp) => {
|
||||
toast.current?.show({ severity: 'success', summary: __('Pratica inviata', 'gepafin') });
|
||||
load();
|
||||
},
|
||||
onMutationError)
|
||||
});
|
||||
};
|
||||
|
||||
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>;
|
||||
}
|
||||
if (!practice) {
|
||||
return <div className="appPage"><div className="appPageSection"><p>{__('Pratica non trovata', 'gepafin')}</p></div></div>;
|
||||
}
|
||||
|
||||
const statusCfg = STATUS_TAGS[practice.status] || { severity: 'secondary', label: practice.status };
|
||||
const totals = gate?.totals || {};
|
||||
const remissionDue = totals.remission_due || 0;
|
||||
const grandTotal = totals.grand_total || 0;
|
||||
const maxRemission = totals.max_remission || 0;
|
||||
const perCategory = totals.per_category || {};
|
||||
|
||||
const invoicesOfCat = (code) => practice.invoices.filter(i => i.category_code === code);
|
||||
|
||||
return (
|
||||
<div className="appPage">
|
||||
<Toast ref={toast} />
|
||||
<ConfirmPopup />
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="appPage__pageHeader">
|
||||
<h1>{__('Rendicontazione', 'gepafin')}</h1>
|
||||
<p>
|
||||
<span className="companyName">
|
||||
{practice.schema_snapshot?.template_label || `Bando #${practice.call_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 lista', 'gepafin')} onClick={() => navigate('/rendicontazioni')} />
|
||||
{!readOnly && (
|
||||
<Button type="button" icon="pi pi-send" iconPos="right" severity="success"
|
||||
label={__('Invia rendicontazione', 'gepafin')}
|
||||
onClick={handleSubmit}
|
||||
disabled={!gate?.passed}
|
||||
tooltip={!gate?.passed ? __('Completa tutti i requisiti prima di inviare', 'gepafin') : null} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* RIEPILOGO FINANZIARIO */}
|
||||
<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">{__('Importo erogato', 'gepafin')}</small>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{euro(practice.amount_erogato)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<small className="text-color-secondary">{__('Totale fatture rendicontate', 'gepafin')}</small>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{euro(grandTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<small className="text-color-secondary">{__('Cap remissione massimo', 'gepafin')}</small>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{euro(maxRemission)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<small className="text-color-secondary">{__('Remissione spettante', 'gepafin')}</small>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--primary-color)' }}>{euro(remissionDue)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* GATE CHECKS */}
|
||||
{gate && (
|
||||
<div className="appPageSection">
|
||||
<h2>{__('Requisiti per invio', '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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('1. Regime IVA', 'gepafin')}</h2>
|
||||
<form className="appForm p-fluid" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="appForm__field" style={{ maxWidth: '500px' }}>
|
||||
<label>{__('Seleziona il tuo regime IVA', 'gepafin')}</label>
|
||||
<Dropdown value={practice.iva_regime}
|
||||
onChange={(e) => updateIvaRegime(e.value)}
|
||||
options={ivaAllowed}
|
||||
placeholder={__('Seleziona...', 'gepafin')}
|
||||
disabled={readOnly} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* SEZIONE 2: FATTURE PER CATEGORIA */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('2. Fatture per categoria', 'gepafin')}</h2>
|
||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
||||
{__('Carica le fatture assegnandole alla categoria di spesa appropriata. I totali si aggiornano in tempo reale.', 'gepafin')}
|
||||
</p>
|
||||
|
||||
<div className="fieldsRepeater">
|
||||
{categories.map((cat) => {
|
||||
const invs = invoicesOfCat(cat.code);
|
||||
const catTotal = perCategory[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.75rem' }}>
|
||||
<div>
|
||||
<strong style={{ color: 'var(--primary-color)' }}>{cat.code}</strong> — {cat.label}
|
||||
<div><small className="text-color-secondary">{cat.description}</small></div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div><strong>{euro(catTotal)}</strong></div>
|
||||
<small className="text-color-secondary">{invs.length} {__('fatture', 'gepafin')}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invs.length > 0 && (
|
||||
<DataTable value={invs} dataKey="id" size="small" responsiveLayout="scroll">
|
||||
<Column field="invoice_number" header={__('N°', 'gepafin')} />
|
||||
<Column field="invoice_date" header={__('Data', 'gepafin')}
|
||||
body={(r) => formatDate(r.invoice_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 field="taxable" header={__('Imponibile', 'gepafin')} body={(r) => euro(r.taxable)} />
|
||||
<Column field="total" header={__('Totale', 'gepafin')} body={(r) => euro(r.total)} />
|
||||
<Column header={__('Allegato PDF', 'gepafin')} style={{ minWidth: '280px' }}
|
||||
body={(r) => (
|
||||
<FileUploadCell
|
||||
entityType="invoice" entityId={r.id}
|
||||
filename={r.pdf_filename} sizeBytes={r.size_bytes}
|
||||
readOnly={readOnly}
|
||||
onPreview={() => openPreview('invoice', r.id, `Fattura ${r.invoice_number}`, r.pdf_filename)}
|
||||
onChange={(meta) => updateInvoiceFile(r.id, meta)}
|
||||
toastRef={toast}
|
||||
/>
|
||||
)} />
|
||||
{!readOnly && (
|
||||
<Column header="" body={(r) => (
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small"
|
||||
onClick={(e) => deleteInvoice(e, r)} />
|
||||
)} style={{ width: '60px' }} />
|
||||
)}
|
||||
</DataTable>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<Button type="button" icon="pi pi-plus" outlined size="small"
|
||||
label={__('Aggiungi fattura', 'gepafin') + ' ' + cat.code}
|
||||
onClick={() => openAddInvoice(cat.code)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEZIONE 3: ULA */}
|
||||
{ulaSection.enabled && (<>
|
||||
<div className="appPage__spacer"></div>
|
||||
<div className="appPageSection">
|
||||
<h2>{__('3. Calcolo ULA — Dipendenti', 'gepafin')}</h2>
|
||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
||||
{__('Inserisci i dipendenti che contano per l\'incremento occupazionale. Soglia minima richiesta:', 'gepafin')} <strong>{ulaSection.threshold}</strong>.
|
||||
</p>
|
||||
|
||||
{practice.ula_employees.length > 0 && (
|
||||
<DataTable value={practice.ula_employees} dataKey="id" size="small" responsiveLayout="scroll" style={{ width: '100%', marginBottom: '0.75rem' }}>
|
||||
<Column field="codice_fiscale" header="CF" />
|
||||
<Column field="full_name" header={__('Nome', 'gepafin')} />
|
||||
<Column field="contract_type" header={__('Contratto', 'gepafin')}
|
||||
body={(r) => (CONTRACT_TYPES.find(c => c.value === r.contract_type)?.label || r.contract_type)} />
|
||||
<Column field="fte_pct" 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 header={__('Allegato', 'gepafin')} style={{ minWidth: '280px' }}
|
||||
body={(r) => (
|
||||
<FileUploadCell
|
||||
entityType="ula" entityId={r.id}
|
||||
filename={r.supporting_doc_filename} sizeBytes={r.size_bytes}
|
||||
readOnly={readOnly}
|
||||
onPreview={() => openPreview('ula', r.id, r.full_name, r.supporting_doc_filename)}
|
||||
onChange={(meta) => updateUlaFile(r.id, meta)}
|
||||
toastRef={toast}
|
||||
/>
|
||||
)} />
|
||||
{!readOnly && (
|
||||
<Column header="" body={(r) => (
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small"
|
||||
onClick={(e) => deleteEmployee(e, r)} />
|
||||
)} style={{ width: '60px' }} />
|
||||
)}
|
||||
</DataTable>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<Button type="button" icon="pi pi-plus" outlined size="small"
|
||||
label={__('Aggiungi dipendente', 'gepafin')} onClick={openAddEmployee} />
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* SEZIONE 4: DOCUMENTI */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__((ulaSection.enabled ? '4.' : '3.') + ' Documenti richiesti', 'gepafin')}</h2>
|
||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
||||
{__('Carica un file per ciascun documento richiesto. In questa sandbox viene registrato solo il nome del file (upload reale al prossimo sprint).', 'gepafin')}
|
||||
</p>
|
||||
<div className="fieldsRepeater">
|
||||
{docsRequired.map((dr) => {
|
||||
const existing = practice.documents.find(d => d.doc_code === dr.code);
|
||||
const hasFile = !!(existing && existing.filename);
|
||||
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={hasFile ? 'pi pi-check-circle' : 'pi pi-circle'}
|
||||
style={{ color: hasFile ? 'var(--green-500)' : 'var(--text-color-secondary)', 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: '260px' }}>
|
||||
{existing && existing.id ? (
|
||||
<FileUploadCell
|
||||
entityType="document" entityId={existing.id}
|
||||
filename={existing.filename} sizeBytes={existing.size_bytes}
|
||||
readOnly={readOnly}
|
||||
onPreview={() => openPreview('document', existing.id, dr.label, existing.filename)}
|
||||
onChange={(meta) => updateDocFile(dr.code, existing.id, meta)}
|
||||
toastRef={toast}
|
||||
/>
|
||||
) : !readOnly ? (
|
||||
<Button type="button" icon="pi pi-upload" size="small" outlined
|
||||
label={__('Carica', 'gepafin')}
|
||||
onClick={() => ensureDocRecord(dr.code, () => {/* reload not needed, setPractice already updated */})} />
|
||||
) : (
|
||||
<span className="text-color-secondary">{__('Nessun file', 'gepafin')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* BOTTOM ACTIONS */}
|
||||
{!readOnly && (
|
||||
<div className="appPageSection">
|
||||
<div className="appPageSection__actions">
|
||||
<Button type="button" icon="pi pi-send" iconPos="right" severity="success"
|
||||
label={__('Invia rendicontazione', 'gepafin')}
|
||||
onClick={handleSubmit}
|
||||
disabled={!gate?.passed} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------- DIALOG FATTURA ---------- */}
|
||||
<Dialog visible={invDialog.visible} style={{ width: '640px', maxWidth: '95vw' }}
|
||||
header={__('Aggiungi fattura', 'gepafin') + (invDialog.data?.category_code ? ` — ${invDialog.data.category_code}` : '')}
|
||||
modal onHide={() => setInvDialog({ visible: false, data: null })}>
|
||||
{invDialog.data && (
|
||||
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); saveInvoice(); }}>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Numero fattura', 'gepafin')}</label>
|
||||
<InputText value={invDialog.data.invoice_number}
|
||||
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, invoice_number: e.target.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Data fattura', 'gepafin')}</label>
|
||||
<Calendar value={invDialog.data.invoice_date} dateFormat="dd/mm/yy" showIcon
|
||||
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, invoice_date: e.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Data pagamento', 'gepafin')}</label>
|
||||
<Calendar value={invDialog.data.payment_date} dateFormat="dd/mm/yy" showIcon
|
||||
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, payment_date: e.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Fornitore', 'gepafin')}</label>
|
||||
<InputText value={invDialog.data.supplier_name}
|
||||
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, supplier_name: e.target.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('P. IVA fornitore', 'gepafin')}</label>
|
||||
<InputText value={invDialog.data.supplier_vat}
|
||||
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, supplier_vat: e.target.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Descrizione', 'gepafin')}</label>
|
||||
<InputTextarea value={invDialog.data.description} rows={2} autoResize
|
||||
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, description: e.target.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Imponibile (€)', 'gepafin')}</label>
|
||||
<InputNumber value={invDialog.data.taxable} mode="currency" currency="EUR" locale="it-IT"
|
||||
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, taxable: e.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('IVA (€)', 'gepafin')}</label>
|
||||
<InputNumber value={invDialog.data.vat} mode="currency" currency="EUR" locale="it-IT"
|
||||
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, vat: e.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Totale (€)', 'gepafin')}</label>
|
||||
<InputNumber value={invDialog.data.total} mode="currency" currency="EUR" locale="it-IT"
|
||||
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, total: e.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field" style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em', padding: '0.5rem 0.75rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
|
||||
<i className="pi pi-info-circle" style={{ marginRight: '0.4rem' }} />
|
||||
{__('Dopo aver salvato la fattura potrai caricare il PDF dalla tabella.', 'gepafin')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setInvDialog({ visible: false, data: null })} />
|
||||
<Button type="submit" label={__('Aggiungi', 'gepafin')} icon="pi pi-check" />
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</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')}
|
||||
modal onHide={() => setEmpDialog({ visible: false, data: null })}>
|
||||
{empDialog.data && (
|
||||
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); saveEmployee(); }}>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Codice fiscale', 'gepafin')}</label>
|
||||
<InputText value={empDialog.data.codice_fiscale}
|
||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, codice_fiscale: e.target.value.toUpperCase() } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Nome e cognome', 'gepafin')}</label>
|
||||
<InputText value={empDialog.data.full_name}
|
||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, full_name: e.target.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Tipo contratto', 'gepafin')}</label>
|
||||
<Dropdown value={empDialog.data.contract_type} options={CONTRACT_TYPES}
|
||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, contract_type: e.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Mansione (opzionale)', 'gepafin')}</label>
|
||||
<InputText value={empDialog.data.role_description}
|
||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, role_description: e.target.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Percentuale tempo (0-1)', 'gepafin')}</label>
|
||||
<InputNumber value={empDialog.data.fte_pct} mode="decimal" minFractionDigits={2} maxFractionDigits={4} min={0} max={1}
|
||||
onValueChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, fte_pct: e.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Inizio periodo', 'gepafin')}</label>
|
||||
<Calendar value={empDialog.data.period_start_date} dateFormat="dd/mm/yy" showIcon
|
||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, period_start_date: e.value } }))} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Fine periodo', 'gepafin')}</label>
|
||||
<Calendar value={empDialog.data.period_end_date} dateFormat="dd/mm/yy" showIcon
|
||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, period_end_date: e.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Tipo documento di supporto', 'gepafin')}</label>
|
||||
<Dropdown value={empDialog.data.supporting_doc_type}
|
||||
options={(ulaSection.supporting_doc_types || []).map(t => typeof t === 'string' ? { value: t, label: t } : { value: t.code, label: t.label })}
|
||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, supporting_doc_type: e.value } }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field" style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em', padding: '0.5rem 0.75rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
|
||||
<i className="pi pi-info-circle" style={{ marginRight: '0.4rem' }} />
|
||||
{__("Dopo aver salvato il dipendente potrai caricare il LUL o il documento di supporto dalla tabella.", 'gepafin')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setEmpDialog({ visible: false, data: null })} />
|
||||
<Button type="submit" label={__('Aggiungi', 'gepafin')} icon="pi pi-check" />
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
<FilePreviewDialog
|
||||
visible={previewDialog.visible}
|
||||
onHide={closePreview}
|
||||
entityType={previewDialog.entityType}
|
||||
entityId={previewDialog.entityId}
|
||||
title={previewDialog.title}
|
||||
filename={previewDialog.filename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PraticaRendicontazioneEdit;
|
||||
145
src/modules/rendicontazione/pages/RendicontazioneHome.js
Normal file
145
src/modules/rendicontazione/pages/RendicontazioneHome.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// components
|
||||
import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { Toast } from 'primereact/toast';
|
||||
|
||||
// api
|
||||
import BandoService from '../../../service/bando-service';
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
|
||||
|
||||
const SCHEMA_STATUS_CONFIG = {
|
||||
null: { severity: 'info', label: __('Non creato', 'gepafin'), icon: 'pi pi-circle' },
|
||||
DRAFT: { severity: 'warning', label: __('Bozza', 'gepafin'), icon: 'pi pi-pencil' },
|
||||
PUBLISHED: { severity: 'success', label: __('Pubblicato', 'gepafin'), icon: 'pi pi-check-circle' }
|
||||
};
|
||||
|
||||
|
||||
const RendicontazioneHome = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
|
||||
const [rows, setRows] = useState([]); // {bando, schema}
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = () => {
|
||||
setLoading(true);
|
||||
BandoService.getBandiPaginated({ page: 0, size: 100 },
|
||||
(resp) => {
|
||||
const bandi = resp?.data?.body || [];
|
||||
// per ogni bando, tento di caricare lo schema di rendicontazione
|
||||
const baseRows = bandi.map(b => ({ bando: b, schema: null, schemaLoaded: false }));
|
||||
setRows(baseRows);
|
||||
setLoading(false);
|
||||
|
||||
// Caricamento schemi in parallelo — update progressivo
|
||||
bandi.forEach((b, idx) => {
|
||||
RendicontazioneService.getSchemaByCallId(b.id,
|
||||
(schemaResp) => {
|
||||
setRows(prev => prev.map((r, i) => i === idx
|
||||
? { ...r, schema: schemaResp?.data || null, schemaLoaded: true }
|
||||
: r));
|
||||
},
|
||||
(err) => {
|
||||
// 404 = schema non ancora creato, tutto ok
|
||||
setRows(prev => prev.map((r, i) => i === idx
|
||||
? { ...r, schema: null, schemaLoaded: true }
|
||||
: r));
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
setLoading(false);
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.message || __('Impossibile caricare i bandi', 'gepafin') });
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const editSchema = (bandoId) => {
|
||||
navigate(`/bandi/${bandoId}/rendicontazione-schema`);
|
||||
};
|
||||
|
||||
// --- column templates ---
|
||||
const bandoNameTpl = (row) => (
|
||||
<div>
|
||||
<strong>{row.bando.name || `Bando #${row.bando.id}`}</strong>
|
||||
{row.bando.descriptionShort && (
|
||||
<div><small className="text-color-secondary">{row.bando.descriptionShort.slice(0, 80)}{row.bando.descriptionShort.length > 80 ? '…' : ''}</small></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const bandoStatusTpl = (row) => (
|
||||
<Tag value={row.bando.status || '—'} severity={row.bando.status === 'PUBLISH' ? 'success' : 'secondary'} />
|
||||
);
|
||||
|
||||
const schemaStatusTpl = (row) => {
|
||||
if (!row.schemaLoaded) return <Skeleton width="6rem" height="1.5rem" />;
|
||||
const key = row.schema ? row.schema.status : null;
|
||||
const conf = SCHEMA_STATUS_CONFIG[key] || SCHEMA_STATUS_CONFIG[null];
|
||||
return <Tag icon={conf.icon} value={conf.label} severity={conf.severity} />;
|
||||
};
|
||||
|
||||
const actionsTpl = (row) => {
|
||||
if (!row.schemaLoaded) return <Skeleton width="8rem" height="2rem" />;
|
||||
const hasSchema = !!row.schema;
|
||||
return (
|
||||
<Button
|
||||
icon={hasSchema ? 'pi pi-pencil' : 'pi pi-plus-circle'}
|
||||
label={hasSchema ? __('Modifica', 'gepafin') : __('Crea schema', 'gepafin')}
|
||||
className={hasSchema ? 'p-button-outlined p-button-sm' : 'p-button-sm'}
|
||||
severity={hasSchema ? null : 'success'}
|
||||
onClick={() => editSchema(row.bando.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<Toast ref={toast} />
|
||||
|
||||
<div className="mb-3">
|
||||
<h2 className="mb-1">{__('Gestione rendicontazione', 'gepafin')}</h2>
|
||||
<p className="m-0 text-color-secondary">
|
||||
{__('Configura per ciascun bando lo schema di rendicontazione che i beneficiari vedranno dopo la firma del contratto. Ogni bando ha uno schema: categorie di spesa, regole ULA, documenti richiesti.', 'gepafin')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<DataTable
|
||||
value={rows}
|
||||
loading={loading}
|
||||
dataKey="bando.id"
|
||||
emptyMessage={__('Nessun bando disponibile', 'gepafin')}
|
||||
paginator={rows.length > 15}
|
||||
rows={15}
|
||||
stripedRows
|
||||
>
|
||||
<Column field="bando.id" header="ID" style={{ width: '60px' }} />
|
||||
<Column field="bando.name" header={__('Bando', 'gepafin')} body={bandoNameTpl} />
|
||||
<Column field="bando.status" header={__('Stato bando', 'gepafin')} body={bandoStatusTpl} style={{ width: '140px' }} />
|
||||
<Column header={__('Schema rendicontazione', 'gepafin')} body={schemaStatusTpl} style={{ width: '180px' }} />
|
||||
<Column header={__('Azioni', 'gepafin')} body={actionsTpl} style={{ width: '180px' }} />
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RendicontazioneHome;
|
||||
133
src/modules/rendicontazione/pages/RendicontazioniMie.js
Normal file
133
src/modules/rendicontazione/pages/RendicontazioniMie.js
Normal file
@@ -0,0 +1,133 @@
|
||||
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 = {
|
||||
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' },
|
||||
APPROVED: { severity: 'success', label: 'Approvata' },
|
||||
REJECTED: { severity: 'danger', label: 'Respinta' },
|
||||
AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso istruttorio' }
|
||||
};
|
||||
|
||||
const RendicontazioniMie = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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]);
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail });
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
|
||||
|
||||
const handleStart = (applicationId) => {
|
||||
RendicontazioneService.startPractice(applicationId,
|
||||
(resp) => {
|
||||
toast.current?.show({ severity: 'success', summary: __('Rendicontazione avviata', 'gepafin') });
|
||||
navigate(`/rendicontazioni/${resp.data.id}`);
|
||||
},
|
||||
(err) => toast.current?.show({ severity: 'error', summary: __('Avvio fallito', 'gepafin'), detail: err?.detail })
|
||||
);
|
||||
};
|
||||
|
||||
const callTpl = (row) => (
|
||||
<div>
|
||||
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
|
||||
<div><small className="text-color-secondary">{row.company_name}</small></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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RendicontazioniMie;
|
||||
402
src/modules/rendicontazione/service/rendicontazioneService.js
Normal file
402
src/modules/rendicontazione/service/rendicontazioneService.js
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Client HTTP per rendicontazione-api (microservizio BFLOWS).
|
||||
* Usa fetch nativa come NetworkService. Il microservizio valida lo stesso JWT di GEPAFIN-BE.
|
||||
*
|
||||
* Env var: REACT_APP_RENDICONTAZIONE_API_URL (es. http://78.46.41.91:18090)
|
||||
*/
|
||||
import { storeGet } from '../../../store';
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_RENDICONTAZIONE_API_URL || '';
|
||||
|
||||
const buildHeaders = () => {
|
||||
const token = storeGet('getToken');
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
h['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return h;
|
||||
};
|
||||
|
||||
const handleResponse = async (response, onSuccess, onError) => {
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (e) {
|
||||
body = { detail: response.statusText };
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
if (onSuccess) onSuccess(body);
|
||||
} else {
|
||||
if (onError) onError({ status: response.status, ...body });
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (err, onError) => {
|
||||
if (onError) onError({ status: 0, detail: err.message });
|
||||
};
|
||||
|
||||
const RendicontazioneService = {
|
||||
getSchemaByCallId(callId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
initializeRestartTemplate(callId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}/initialize-restart`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
updateSchema(callId, schemaJson, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ schema_json: schemaJson })
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
publishSchema(callId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}/publish`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deleteSchema(callId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
getRestartTemplatePreview(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/templates/restart`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
}
|
||||
};
|
||||
|
||||
export default RendicontazioneService;
|
||||
|
||||
// ====================== PRATICHE BENEFICIARIO ======================
|
||||
|
||||
const extendPractice = {
|
||||
listMine(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/mine`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
startPractice(applicationId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/start`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ application_id: applicationId })
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
getPractice(practiceId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
updatePractice(practiceId, patch, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(patch)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
addInvoice(practiceId, invoice, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/invoices`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(invoice)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deleteInvoice(practiceId, invoiceId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/invoices/${invoiceId}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
addUlaEmployee(practiceId, emp, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/ula-employees`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(emp)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deleteUlaEmployee(practiceId, empId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/ula-employees/${empId}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
upsertDocument(practiceId, docCode, payload, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/documents/${docCode}`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ doc_code: docCode, ...payload })
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
clearDocument(practiceId, docCode, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/documents/${docCode}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
gateCheck(practiceId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/gate-check`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
submitPractice(practiceId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/submit`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// dev-only: impersonation per test beneficiary
|
||||
impersonate(email, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/debug/impersonate`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ email })
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
}
|
||||
};
|
||||
|
||||
// Attach to main export
|
||||
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);
|
||||
|
||||
|
||||
// ====================== VERIFICA SINGOLA RIGA ISTRUTTORE ======================
|
||||
|
||||
const extendVerify = {
|
||||
verifyInvoice(practiceId, invoiceId, body, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/invoices/${invoiceId}/verify`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
verifyUlaEmployee(practiceId, empId, body, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/ula-employees/${empId}/verify`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
verifyDocument(practiceId, docCode, body, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/documents/${docCode}/verify`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
setInstructorFinalNotes(practiceId, body, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/final-notes`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
}
|
||||
};
|
||||
|
||||
Object.assign(RendicontazioneService, extendVerify);
|
||||
|
||||
|
||||
// ====================== FILE UPLOAD ======================
|
||||
|
||||
const _buildBearerOnly = () => {
|
||||
const token = storeGet('getToken');
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const extendFiles = {
|
||||
/**
|
||||
* Upload file per entita (invoice/ula/document).
|
||||
* Restituisce i metadata del file via onSuccess.
|
||||
*/
|
||||
uploadEntityFile(entityType, entityId, file, onSuccess, onError) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}/upload`, {
|
||||
method: 'POST', mode: 'cors',
|
||||
headers: _buildBearerOnly(), // no Content-Type: browser mette boundary
|
||||
body: fd
|
||||
}).then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
/**
|
||||
* Elimina file allegato a una entita.
|
||||
*/
|
||||
deleteEntityFile(entityType, entityId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch file come Blob (per preview in iframe tramite object URL).
|
||||
* onSuccess({blob, objectUrl, filename}).
|
||||
*/
|
||||
fetchEntityFileBlob(entityType, entityId, inline, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}?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;
|
||||
}
|
||||
// estrae filename da Content-Disposition
|
||||
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));
|
||||
},
|
||||
|
||||
/**
|
||||
* Download forzato: apre finestra "save as" del browser.
|
||||
*/
|
||||
downloadEntityFile(entityType, entityId, onError) {
|
||||
this.fetchEntityFileBlob(entityType, entityId, 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);
|
||||
},
|
||||
onError
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Object.assign(RendicontazioneService, extendFiles);
|
||||
|
||||
|
||||
// ====================== VERBALE ISTRUTTORIA ======================
|
||||
|
||||
const extendVerbale = {
|
||||
/**
|
||||
* Scarica il verbale di istruttoria come PDF (download forzato).
|
||||
*/
|
||||
downloadVerbale(practiceId, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/verbale.pdf`, {
|
||||
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;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `verbale_istruttoria_${practiceId.slice(0, 8)}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}).catch(e => { if (onError) onError({ status: 0, detail: e.message }); });
|
||||
},
|
||||
|
||||
/**
|
||||
* Apre preview HTML del verbale in una nuova tab (debug rapido).
|
||||
*/
|
||||
async openVerbaleHtml(practiceId) {
|
||||
const token = storeGet('getToken');
|
||||
const r = await fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/verbale.html`, {
|
||||
method: 'GET', mode: 'cors',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
});
|
||||
if (!r.ok) throw new Error('Verbale HTML fetch failed');
|
||||
const html = await r.text();
|
||||
const w = window.open('', '_blank');
|
||||
if (w) { w.document.open(); w.document.write(html); w.document.close(); }
|
||||
}
|
||||
};
|
||||
|
||||
Object.assign(RendicontazioneService, extendVerbale);
|
||||
@@ -89,6 +89,10 @@ const BandoEdit = () => {
|
||||
navigate(`/bandi/${id}/flow`);
|
||||
}
|
||||
|
||||
const openRendicontazioneSchema = () => {
|
||||
navigate(`/bandi/${id}/rendicontazione-schema`);
|
||||
}
|
||||
|
||||
const validateBando = () => {
|
||||
storeSet('setAsyncRequest');
|
||||
bandoMsgs.current.clear();
|
||||
@@ -408,6 +412,22 @@ const BandoEdit = () => {
|
||||
: <p>{__('Nessun modulo creato ancora', 'gepafin')}</p>}
|
||||
</div>
|
||||
|
||||
<div className="appPageSection">
|
||||
<h2>{__('Schema di rendicontazione', 'gepafin')}</h2>
|
||||
<p className="text-color-secondary">
|
||||
{__('Configura come i beneficiari dovranno rendicontare dopo la firma del contratto: categorie di spesa, ULA, documenti richiesti.', 'gepafin')}
|
||||
</p>
|
||||
<div className="row">
|
||||
<Button
|
||||
type="button"
|
||||
outlined={data.status === 'PUBLISH'}
|
||||
onClick={openRendicontazioneSchema}
|
||||
icon="pi pi-receipt"
|
||||
iconPos="right"
|
||||
label={__('Crea o modifica schema di rendicontazione', 'gepafin')}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
<div className="appPageSection">
|
||||
|
||||
@@ -14,6 +14,13 @@ import BandoView from './pages/BandoView';
|
||||
import BandoFormsEdit from './pages/BandoFormsEdit';
|
||||
import BandoForms from './pages/BandoForms';
|
||||
import BandoFormsPreview from './pages/BandoFormsPreview';
|
||||
import BandoRendicontazioneSchemaEdit from './modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit';
|
||||
import RendicontazioneHome from './modules/rendicontazione/pages/RendicontazioneHome';
|
||||
import RendicontazioniMie from './modules/rendicontazione/pages/RendicontazioniMie';
|
||||
import PraticaRendicontazioneEdit from './modules/rendicontazione/pages/PraticaRendicontazioneEdit';
|
||||
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 Imieibandi from './pages/Imieibandi';
|
||||
import BandoApplication from './pages/BandoApplication';
|
||||
@@ -135,6 +142,51 @@ const routes = ({ role, chosenCompanyId }) => {
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/rendicontazione" element={<DefaultLayout>
|
||||
{'ROLE_SUPER_ADMIN' === role ? <RendicontazioneHome/> : <PageNotFound/>}
|
||||
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/bandi/:id/rendicontazione-schema" element={<DefaultLayout>
|
||||
{'ROLE_SUPER_ADMIN' === role ? <BandoRendicontazioneSchemaEdit/> : <PageNotFound/>}
|
||||
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/rendicontazioni" element={<DefaultLayout>
|
||||
{'ROLE_BENEFICIARY' === role ? <RendicontazioniMie/> : null}
|
||||
{'ROLE_SUPER_ADMIN' === role ? <RendicontazioniMie/> : null}
|
||||
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/rendicontazioni/:id" element={<DefaultLayout>
|
||||
{'ROLE_BENEFICIARY' === role ? <PraticaRendicontazioneEdit/> : null}
|
||||
{'ROLE_SUPER_ADMIN' === role ? <PraticaRendicontazioneEdit/> : null}
|
||||
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/dev-switch-user" element={<DefaultLayout>
|
||||
{'ROLE_SUPER_ADMIN' === role ? <DevSwitchUser/> : <PageNotFound/>}
|
||||
</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>
|
||||
{'ROLE_SUPER_ADMIN' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_BENEFICIARY' === role ? <BandiPreferredBeneficiario/> : null}
|
||||
|
||||
Reference in New Issue
Block a user