Compare commits
41 Commits
main
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 548556d4fc | |||
|
|
fba47c6e77 | ||
|
|
09da2b7c25 | ||
|
|
8f9e3d5622 | ||
|
|
3ae5aabe2d | ||
|
|
ec0e7397e6 | ||
|
|
ac1c18c737 | ||
|
|
cad839aea0 | ||
|
|
84ada138f2 | ||
|
|
21c58311e2 | ||
|
|
5bbf39488f | ||
|
|
c481871fa0 | ||
|
|
7ea5d7fd4c | ||
|
|
00ef1eb1e0 | ||
|
|
4a719ded5b | ||
|
|
2028239759 | ||
|
|
dbed5963b2 | ||
|
|
7c508e743b | ||
|
|
c407bd0b0e | ||
|
|
46ee801bd0 | ||
|
|
1116f96acf | ||
|
|
4982df4e60 | ||
|
|
59c254a9c3 | ||
|
|
680c25049f | ||
|
|
de8a36b4ab | ||
|
|
cc829fe25e | ||
|
|
2b6b4dbada | ||
|
|
9d23601ba3 | ||
|
|
1e40d5e139 | ||
|
|
49b7acf987 | ||
|
|
6f83574714 | ||
|
|
4cd74cd500 | ||
|
|
8988bed952 | ||
|
|
381fd64fef | ||
|
|
fca18de751 | ||
|
|
fe0b4f1113 | ||
|
|
2268fd98f5 | ||
|
|
61cdfbd06b | ||
|
|
115f31bdef | ||
|
|
9c483ade34 | ||
|
|
8888e0326d |
2
.env
2
.env
@@ -9,3 +9,5 @@ REACT_APP_FAVICON_FILENAME=gepafin-favicon.ico
|
||||
REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs
|
||||
REACT_APP_EVALUATION_FLOW_ID=1
|
||||
REACT_APP_LOCAL_DEVELOPMENT=1
|
||||
REACT_APP_AR1_API_URL=http://78.46.41.91:18091
|
||||
REACT_APP_RENDICONTAZIONE_API_URL=http://78.46.41.91:18090
|
||||
|
||||
@@ -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,34 @@ 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: __('Configurazione AR1', 'gepafin'),
|
||||
icon: 'pi pi-id-card',
|
||||
href: '/ar1-admin',
|
||||
id: 23,
|
||||
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',
|
||||
@@ -34,6 +62,13 @@ const AppSidebar = () => {
|
||||
id: 3,
|
||||
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
|
||||
},
|
||||
{
|
||||
label: __('Dichiarazione AR1', 'gepafin'),
|
||||
icon: 'pi pi-id-card',
|
||||
href: '/ar1',
|
||||
id: 22,
|
||||
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
|
||||
},
|
||||
{
|
||||
label: __('Bandi disponibili', 'gepafin'),
|
||||
icon: 'pi pi-bookmark',
|
||||
@@ -83,6 +118,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',
|
||||
|
||||
148
src/modules/ar1/components/Ar1ComplianceModal.js
Normal file
148
src/modules/ar1/components/Ar1ComplianceModal.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Message } from 'primereact/message';
|
||||
import Ar1Service from '../service/ar1Service';
|
||||
import Ar1StatusTag from './Ar1StatusTag';
|
||||
import { useStoreValue } from '../../../store';
|
||||
|
||||
const DISMISS_SESSION_KEY_PREFIX = 'ar1-compliance-dismissed-';
|
||||
const DISMISS_WINDOW_HOURS = 24;
|
||||
|
||||
// AR1 (D.Lgs.231/2007) si applica solo alle aziende beneficiarie.
|
||||
// Admin / istruttore / direttore NON devono ricevere il popup.
|
||||
const AR1_POPUP_ALLOWED_ROLES = ['ROLE_BENEFICIARY', 'ROLE_CONFIDI'];
|
||||
|
||||
/**
|
||||
* Dialog AR1 mostrato al login se l'azienda ha AR1 MISSING/EXPIRED/APPROACHING.
|
||||
* - dismissable=false (EXPIRED/MISSING): bloccante, solo CTA "Compila ora"
|
||||
* - dismissable=true (APPROACHING): X chiude + salva in sessionStorage 24h
|
||||
*
|
||||
* Da montare nel layout principale. Esempio:
|
||||
* <Ar1ComplianceModal companyId={userCompanyId} />
|
||||
*/
|
||||
const Ar1ComplianceModal = ({ companyId }) => {
|
||||
const navigate = useNavigate();
|
||||
const role = useStoreValue('getRole');
|
||||
const [status, setStatus] = useState(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Gate ruolo: solo aziende vedono il popup AR1.
|
||||
if (!AR1_POPUP_ALLOWED_ROLES.includes(role)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!companyId) return;
|
||||
const dismissKey = DISMISS_SESSION_KEY_PREFIX + companyId;
|
||||
const dismissed = sessionStorage.getItem(dismissKey);
|
||||
if (dismissed) {
|
||||
const dismissedAt = parseInt(dismissed, 10);
|
||||
if (Date.now() - dismissedAt < DISMISS_WINDOW_HOURS * 3600 * 1000) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Ar1Service.getStatusForCompany(companyId,
|
||||
(resp) => {
|
||||
setLoading(false);
|
||||
const showFor = ['MISSING', 'EXPIRED', 'APPROACHING'];
|
||||
if (resp && showFor.includes(resp.status)) {
|
||||
setStatus(resp);
|
||||
setVisible(true);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setLoading(false);
|
||||
console.warn('Ar1ComplianceModal: status check failed', err);
|
||||
}
|
||||
);
|
||||
}, [companyId, role]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (!status?.is_popup_dismissible) return;
|
||||
sessionStorage.setItem(DISMISS_SESSION_KEY_PREFIX + companyId, Date.now().toString());
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const goToCompile = () => {
|
||||
setVisible(false);
|
||||
navigate('/ar1');
|
||||
};
|
||||
|
||||
if (loading || !status) return null;
|
||||
|
||||
const canDismiss = status.is_popup_dismissible;
|
||||
const isUrgent = status.status === 'EXPIRED' || status.status === 'MISSING';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={<div><i className="pi pi-id-card" style={{ marginRight: 8 }} />{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}</div>}
|
||||
visible={visible}
|
||||
modal
|
||||
closable={canDismiss}
|
||||
closeOnEscape={canDismiss}
|
||||
dismissableMask={canDismiss}
|
||||
onHide={handleDismiss}
|
||||
style={{ width: '560px', maxWidth: '95vw' }}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Ar1StatusTag status={status.status} />
|
||||
</div>
|
||||
|
||||
{isUrgent && (
|
||||
<Message
|
||||
severity="error"
|
||||
text={status.must_recompile_reason ||
|
||||
__('Per proseguire nell\'operativita con Gepafin e necessario aggiornare la dichiarazione di adeguata verifica (D.Lgs. 231/2007).', 'gepafin')}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isUrgent && (
|
||||
<Message
|
||||
severity="warn"
|
||||
text={__('La tua dichiarazione AR1 sta per scadere. Ti chiediamo di aggiornarla per tempo.', 'gepafin')}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p style={{ margin: '10px 0', color: '#444' }}>
|
||||
{__('Il modulo AR1 (Aggiornamento Adeguata Verifica) e richiesto dalla normativa antiriciclaggio D.Lgs. 231/2007. La compilazione si svolge via wizard guidato e termina con la firma digitale (FEQ) del modulo.', 'gepafin')}
|
||||
</p>
|
||||
|
||||
{status.days_to_expiry !== null && status.days_to_expiry !== undefined && (
|
||||
<p style={{ margin: '10px 0', fontWeight: 600 }}>
|
||||
{status.days_to_expiry < 0
|
||||
? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin')
|
||||
: __(`Scadenza tra ${status.days_to_expiry} giorni`, 'gepafin')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
{canDismiss && (
|
||||
<Button
|
||||
label={__('Ricordamelo piu tardi', 'gepafin')}
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
label={status.status === 'DRAFT' ? __('Riprendi compilazione', 'gepafin') : __('Compila ora', 'gepafin')}
|
||||
icon="pi pi-arrow-right"
|
||||
iconPos="right"
|
||||
onClick={goToCompile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ar1ComplianceModal;
|
||||
26
src/modules/ar1/components/Ar1StatusTag.js
Normal file
26
src/modules/ar1/components/Ar1StatusTag.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Tag } from 'primereact/tag';
|
||||
|
||||
/**
|
||||
* Badge per lo status AR1. Stati possibili:
|
||||
* MISSING, DRAFT, AWAITING_SIGNATURE, SIGNED, VERIFIED, VALID, APPROACHING, EXPIRED, SUPERSEDED
|
||||
*/
|
||||
const STATUS_CONFIG = {
|
||||
MISSING: { severity: 'danger', label: 'Da compilare', icon: 'pi pi-exclamation-circle' },
|
||||
DRAFT: { severity: 'warning', label: 'Bozza in corso', icon: 'pi pi-pencil' },
|
||||
AWAITING_SIGNATURE: { severity: 'info', label: 'Attesa firma', icon: 'pi pi-hourglass' },
|
||||
SIGNED: { severity: 'info', label: 'Firmato', icon: 'pi pi-verified' },
|
||||
VERIFIED: { severity: 'success', label: 'Verificato', icon: 'pi pi-check-circle' },
|
||||
VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle' },
|
||||
APPROACHING: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-clock' },
|
||||
EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle' },
|
||||
SUPERSEDED: { severity: 'secondary', label: 'Sostituito', icon: 'pi pi-history' },
|
||||
};
|
||||
|
||||
const Ar1StatusTag = ({ status }) => {
|
||||
const cfg = STATUS_CONFIG[status] || { severity: 'secondary', label: status || '—', icon: 'pi pi-circle' };
|
||||
return <Tag severity={cfg.severity} icon={cfg.icon} value={__(cfg.label, 'gepafin')} />;
|
||||
};
|
||||
|
||||
export default Ar1StatusTag;
|
||||
442
src/modules/ar1/components/QuadriStructureEditor.js
Normal file
442
src/modules/ar1/components/QuadriStructureEditor.js
Normal file
@@ -0,0 +1,442 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// prime
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||
import { confirmDialog } from 'primereact/confirmdialog';
|
||||
|
||||
const FIELD_TYPE_OPTIONS = [
|
||||
{ label: 'Testo libero', value: 'text' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'Data', value: 'date' },
|
||||
{ label: 'Checkbox (si/no)', value: 'checkbox' },
|
||||
{ label: 'Scelta singola (radio)', value: 'radio' },
|
||||
{ label: 'Menu a tendina (enum)', value: 'enum' },
|
||||
{ label: 'Si/No con nota', value: 'yes_no_with_note' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Editor della struttura questions_snapshot di un template AR1.
|
||||
*
|
||||
* Props:
|
||||
* value: { quadri: [...], variant, legal_ref, ... }
|
||||
* onChange: (newValue) => void
|
||||
*
|
||||
* Permette:
|
||||
* - Modificare metadati snapshot (variant_label, variant_description, legal_ref, normative_frame)
|
||||
* - Modificare per ogni quadro: id, title, description
|
||||
* - Aggiungere/rimuovere/modificare fields con tipo, label, required, pattern, max_length, options, prefill_from
|
||||
* - Per Quadro G (is_legal_frame=true): warning visuale + editor del description
|
||||
* - Per Quadri B (row_type=titolare): edit row_fields separato
|
||||
* - Per Quadri C/D (nested_full): edit fields nested
|
||||
* - Aggiungere/rimuovere quadri interi
|
||||
*/
|
||||
const QuadriStructureEditor = ({ value, onChange }) => {
|
||||
const qs = value || { quadri: [] };
|
||||
const quadri = qs.quadri || [];
|
||||
|
||||
const update = (newQs) => onChange(newQs);
|
||||
|
||||
// --- metadati snapshot ---
|
||||
const updateMeta = (key, v) => update({ ...qs, [key]: v });
|
||||
|
||||
// --- quadri ---
|
||||
const updateQuadro = (idx, partial) => {
|
||||
const newQuadri = quadri.map((q, i) => i === idx ? { ...q, ...partial } : q);
|
||||
update({ ...qs, quadri: newQuadri });
|
||||
};
|
||||
|
||||
const moveQuadro = (idx, direction) => {
|
||||
const newIdx = idx + direction;
|
||||
if (newIdx < 0 || newIdx >= quadri.length) return;
|
||||
const newQuadri = [...quadri];
|
||||
[newQuadri[idx], newQuadri[newIdx]] = [newQuadri[newIdx], newQuadri[idx]];
|
||||
update({ ...qs, quadri: newQuadri });
|
||||
};
|
||||
|
||||
const removeQuadro = (idx) => {
|
||||
const q = quadri[idx];
|
||||
confirmDialog({
|
||||
message: `Eliminare il quadro "${q.id} - ${q.title}"? Sara creata una nuova versione.`,
|
||||
header: 'Conferma eliminazione quadro',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Elimina',
|
||||
rejectLabel: 'Annulla',
|
||||
acceptClassName: 'p-button-danger',
|
||||
accept: () => {
|
||||
const newQuadri = quadri.filter((_, i) => i !== idx);
|
||||
update({ ...qs, quadri: newQuadri });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addQuadro = () => {
|
||||
const nextLetter = String.fromCharCode(65 + quadri.length); // A, B, C...
|
||||
const newQuadro = {
|
||||
id: nextLetter,
|
||||
title: `Quadro ${nextLetter} - Nuovo quadro`,
|
||||
description: '',
|
||||
fields: []
|
||||
};
|
||||
update({ ...qs, quadri: [...quadri, newQuadro] });
|
||||
};
|
||||
|
||||
// --- fields dentro quadro ---
|
||||
const updateField = (qIdx, fIdx, partial) => {
|
||||
const q = quadri[qIdx];
|
||||
const newFields = (q.fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
|
||||
updateQuadro(qIdx, { fields: newFields });
|
||||
};
|
||||
|
||||
const addField = (qIdx) => {
|
||||
const q = quadri[qIdx];
|
||||
const fields = q.fields || [];
|
||||
// id basato su letter quadro + numero crescente per evitare collisioni
|
||||
const newField = {
|
||||
id: `campo_${q.id || 'X'}_${fields.length + 1}`.toLowerCase(),
|
||||
type: 'text',
|
||||
label: 'Nuovo campo',
|
||||
required: false
|
||||
};
|
||||
updateQuadro(qIdx, { fields: [...fields, newField] });
|
||||
};
|
||||
|
||||
const removeField = (qIdx, fIdx) => {
|
||||
const q = quadri[qIdx];
|
||||
const newFields = (q.fields || []).filter((_, i) => i !== fIdx);
|
||||
updateQuadro(qIdx, { fields: newFields });
|
||||
};
|
||||
|
||||
const moveField = (qIdx, fIdx, direction) => {
|
||||
const q = quadri[qIdx];
|
||||
const fields = q.fields || [];
|
||||
const newIdx = fIdx + direction;
|
||||
if (newIdx < 0 || newIdx >= fields.length) return;
|
||||
const newFields = [...fields];
|
||||
[newFields[fIdx], newFields[newIdx]] = [newFields[newIdx], newFields[fIdx]];
|
||||
updateQuadro(qIdx, { fields: newFields });
|
||||
};
|
||||
|
||||
// --- row_fields (es. Quadro B titolari) ---
|
||||
const updateRowField = (qIdx, fIdx, partial) => {
|
||||
const q = quadri[qIdx];
|
||||
const newRowFields = (q.row_fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
|
||||
updateQuadro(qIdx, { row_fields: newRowFields });
|
||||
};
|
||||
|
||||
const addRowField = (qIdx) => {
|
||||
const q = quadri[qIdx];
|
||||
const rowFields = q.row_fields || [];
|
||||
updateQuadro(qIdx, {
|
||||
row_fields: [...rowFields, { id: `campo_${rowFields.length + 1}`, type: 'text', label: 'Nuovo campo', required: false }]
|
||||
});
|
||||
};
|
||||
|
||||
const removeRowField = (qIdx, fIdx) => {
|
||||
const q = quadri[qIdx];
|
||||
updateQuadro(qIdx, { row_fields: (q.row_fields || []).filter((_, i) => i !== fIdx) });
|
||||
};
|
||||
|
||||
// --- nested fields (es. Quadro C/D) ---
|
||||
const updateNestedField = (qIdx, fIdx, partial) => {
|
||||
const q = quadri[qIdx];
|
||||
const nested = q.nested_full || {};
|
||||
const newNestedFields = (nested.fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
|
||||
updateQuadro(qIdx, { nested_full: { ...nested, fields: newNestedFields } });
|
||||
};
|
||||
|
||||
const addNestedField = (qIdx) => {
|
||||
const q = quadri[qIdx];
|
||||
const nested = q.nested_full || { fields: [] };
|
||||
const fields = nested.fields || [];
|
||||
updateQuadro(qIdx, {
|
||||
nested_full: {
|
||||
...nested,
|
||||
fields: [...fields, { id: `campo_${fields.length + 1}`, type: 'text', label: 'Nuovo campo', required: false }]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const removeNestedField = (qIdx, fIdx) => {
|
||||
const q = quadri[qIdx];
|
||||
const nested = q.nested_full || {};
|
||||
updateQuadro(qIdx, {
|
||||
nested_full: { ...nested, fields: (nested.fields || []).filter((_, i) => i !== fIdx) }
|
||||
});
|
||||
};
|
||||
|
||||
// ----- render field editor (atom) -----
|
||||
const renderFieldRow = (field, idx, onUpdate, onRemove, onMoveUp, onMoveDown, total) => {
|
||||
const isEnum = field.type === 'enum' || field.type === 'radio';
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
border: '1px solid #ddd', borderRadius: 4, padding: 10,
|
||||
marginBottom: 8, background: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr 160px auto', gap: 8, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>ID (kebab, no spazi)</label>
|
||||
<InputText value={field.id || ''}
|
||||
onChange={(e) => onUpdate({ id: e.target.value.replace(/\s+/g, '_') })}
|
||||
style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Label visualizzato</label>
|
||||
<InputText value={field.label || ''}
|
||||
onChange={(e) => onUpdate({ label: e.target.value })}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Tipo</label>
|
||||
<Dropdown value={field.type || 'text'} options={FIELD_TYPE_OPTIONS}
|
||||
onChange={(e) => onUpdate({ type: e.value })}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button icon="pi pi-arrow-up" rounded outlined size="small"
|
||||
disabled={idx === 0} onClick={() => onMoveUp()} tooltip="Sposta su" />
|
||||
<Button icon="pi pi-arrow-down" rounded outlined size="small"
|
||||
disabled={idx === total - 1} onClick={() => onMoveDown && onMoveDown()} tooltip="Sposta giu" />
|
||||
<Button icon="pi pi-trash" rounded outlined size="small" severity="danger"
|
||||
onClick={() => onRemove()} tooltip="Elimina campo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Checkbox inputId={`req_${idx}_${field.id}`} checked={!!field.required}
|
||||
onChange={(e) => onUpdate({ required: e.checked })} />
|
||||
<label htmlFor={`req_${idx}_${field.id}`} style={{ fontSize: 12 }}>Obbligatorio</label>
|
||||
</div>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Max lunghezza:</label>
|
||||
<InputNumber value={field.max_length || null}
|
||||
onValueChange={(e) => onUpdate({ max_length: e.value })}
|
||||
style={{ width: 90 }} inputStyle={{ fontSize: 12 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Pattern regex:</label>
|
||||
<InputText value={field.pattern || ''}
|
||||
onChange={(e) => onUpdate({ pattern: e.target.value || undefined })}
|
||||
style={{ width: 180, fontFamily: 'monospace', fontSize: 11 }}
|
||||
placeholder="es. ^[0-9]{11}$" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{field.prefill_from && (
|
||||
<Tag severity="info" value={`prefill: ${field.prefill_from}`} style={{ fontSize: 11 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEnum && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label style={{ fontSize: 11, color: '#555', display: 'block', marginBottom: 2 }}>
|
||||
Opzioni (una per riga)
|
||||
</label>
|
||||
<InputTextarea rows={3}
|
||||
value={(field.options || []).map(o => typeof o === 'string' ? o : (o.label || o.value || '')).join('\n')}
|
||||
onChange={(e) => {
|
||||
const opts = e.target.value.split('\n').filter(Boolean);
|
||||
onUpdate({ options: opts });
|
||||
}}
|
||||
style={{ width: '100%', fontSize: 12 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----- render -----
|
||||
return (
|
||||
<div>
|
||||
<Message severity="warn" style={{ marginBottom: 14 }}
|
||||
text="Attenzione: modifiche alla struttura dei quadri generano automaticamente una nuova versione del template. I form gia compilati continuano a usare la versione precedente grazie allo snapshot." />
|
||||
|
||||
{/* Metadati */}
|
||||
<Card title="Metadati template" style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Etichetta variante</label>
|
||||
<InputText value={qs.variant_label || ''}
|
||||
onChange={(e) => updateMeta('variant_label', e.target.value)} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Riferimento normativo</label>
|
||||
<InputText value={qs.legal_ref || ''}
|
||||
onChange={(e) => updateMeta('legal_ref', e.target.value)} style={{ width: '100%' }}
|
||||
placeholder="es. D.Lgs. 231/2007" />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Descrizione variante</label>
|
||||
<InputTextarea rows={2} value={qs.variant_description || ''}
|
||||
onChange={(e) => updateMeta('variant_description', e.target.value)} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Cornice normativa (testo completo)</label>
|
||||
<InputTextarea rows={3} value={qs.normative_frame || ''}
|
||||
onChange={(e) => updateMeta('normative_frame', e.target.value)}
|
||||
style={{ width: '100%', fontSize: 12 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quadri */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>Quadri ({quadri.length})</h3>
|
||||
<Button label="Aggiungi quadro" icon="pi pi-plus" size="small" outlined onClick={addQuadro} />
|
||||
</div>
|
||||
|
||||
<Accordion multiple>
|
||||
{quadri.map((q, qIdx) => (
|
||||
<AccordionTab key={qIdx}
|
||||
header={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<Tag value={q.id || '?'} severity={q.is_legal_frame ? 'warning' : 'info'} />
|
||||
<strong style={{ flex: 1 }}>{q.title || '(senza titolo)'}</strong>
|
||||
{q.is_legal_frame && <Tag value="NORMATIVO" severity="warning" />}
|
||||
{q.row_type && <Tag value={`ripetuto (${q.row_type})`} severity="secondary" />}
|
||||
{q.nested_full && <Tag value="annidato" severity="secondary" />}
|
||||
<small style={{ color: '#888' }}>
|
||||
{(q.fields || []).length} campi
|
||||
{q.row_fields ? ` +${q.row_fields.length} riga` : ''}
|
||||
{q.nested_full?.fields ? ` +${q.nested_full.fields.length} nidif.` : ''}
|
||||
</small>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr auto', gap: 8, alignItems: 'end', marginBottom: 10 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>ID quadro</label>
|
||||
<InputText value={q.id || ''}
|
||||
onChange={(e) => updateQuadro(qIdx, { id: e.target.value.toUpperCase() })}
|
||||
style={{ width: '100%', fontFamily: 'monospace' }} maxLength={4} />
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Titolo</label>
|
||||
<InputText value={q.title || ''}
|
||||
onChange={(e) => updateQuadro(qIdx, { title: e.target.value })}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button icon="pi pi-arrow-up" rounded outlined size="small"
|
||||
disabled={qIdx === 0} onClick={() => moveQuadro(qIdx, -1)} tooltip="Su" />
|
||||
<Button icon="pi pi-arrow-down" rounded outlined size="small"
|
||||
disabled={qIdx === quadri.length - 1} onClick={() => moveQuadro(qIdx, 1)} tooltip="Giu" />
|
||||
<Button icon="pi pi-trash" rounded outlined size="small" severity="danger"
|
||||
onClick={() => removeQuadro(qIdx)} tooltip="Elimina quadro" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<label style={{ fontSize: 11, color: '#555' }}>Descrizione</label>
|
||||
<InputTextarea rows={2} value={q.description || ''}
|
||||
onChange={(e) => updateQuadro(qIdx, { description: e.target.value })}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
|
||||
{q.is_legal_frame && (
|
||||
<Message severity="error" style={{ marginBottom: 10 }}
|
||||
text="Quadro normativo (D.Lgs. 231/2007). Il testo dichiara responsabilita legali del firmatario. Modifica con massima cautela: il contenuto viene inserito nel PDF firmato dal beneficiario." />
|
||||
)}
|
||||
|
||||
{/* Fields normali — sempre mostrato, anche se q.fields non esiste (Quadri B/F/G) */}
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<strong style={{ fontSize: 13 }}>Campi ({(q.fields || []).length})</strong>
|
||||
<Button label="Aggiungi campo" icon="pi pi-plus" size="small" outlined
|
||||
onClick={() => addField(qIdx)} />
|
||||
</div>
|
||||
{(q.fields || []).map((f, fIdx) =>
|
||||
renderFieldRow(f, fIdx,
|
||||
(p) => updateField(qIdx, fIdx, p),
|
||||
() => removeField(qIdx, fIdx),
|
||||
() => moveField(qIdx, fIdx, -1),
|
||||
() => moveField(qIdx, fIdx, 1),
|
||||
(q.fields || []).length
|
||||
)
|
||||
)}
|
||||
{(!q.fields || q.fields.length === 0) && (
|
||||
<p style={{ color: '#888', fontStyle: 'italic', textAlign: 'center', fontSize: 12 }}>
|
||||
Nessun campo diretto. Aggiungine uno col bottone qui sopra
|
||||
{q.row_type ? ` (o usa i "Campi riga" qui sotto per i ${q.row_type})` : ''}
|
||||
{q.nested_full ? ' (o usa i "Campi annidati" qui sotto)' : ''}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* Row fields (Quadro B titolari) */}
|
||||
{q.row_type && (
|
||||
<>
|
||||
<Divider />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<strong style={{ fontSize: 13 }}>Campi riga (per ogni {q.row_type}): ({(q.row_fields || []).length})</strong>
|
||||
<Button label="Aggiungi campo riga" icon="pi pi-plus" size="small" outlined
|
||||
onClick={() => addRowField(qIdx)} />
|
||||
</div>
|
||||
<Message severity="info" style={{ marginBottom: 8, fontSize: 12 }}
|
||||
text={`Questi campi si ripetono per ogni ${q.row_type} aggiunto dal beneficiario. Non eliminare il row_type se ci sono form gia compilati.`} />
|
||||
{(q.row_fields || []).map((f, fIdx) =>
|
||||
renderFieldRow(f, fIdx,
|
||||
(p) => updateRowField(qIdx, fIdx, p),
|
||||
() => removeRowField(qIdx, fIdx),
|
||||
() => {}, () => {},
|
||||
(q.row_fields || []).length
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Nested full (Quadro C/D rappresentante/esecutore) */}
|
||||
{q.nested_full && (
|
||||
<>
|
||||
<Divider />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<strong style={{ fontSize: 13 }}>Campi annidati: ({(q.nested_full.fields || []).length})</strong>
|
||||
<Button label="Aggiungi campo annidato" icon="pi pi-plus" size="small" outlined
|
||||
onClick={() => addNestedField(qIdx)} />
|
||||
</div>
|
||||
{(q.nested_full.fields || []).map((f, fIdx) =>
|
||||
renderFieldRow(f, fIdx,
|
||||
(p) => updateNestedField(qIdx, fIdx, p),
|
||||
() => removeNestedField(qIdx, fIdx),
|
||||
() => {}, () => {},
|
||||
(q.nested_full.fields || []).length
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Upload slots info (Quadro F) */}
|
||||
{q.upload_slots && (
|
||||
<>
|
||||
<Divider />
|
||||
<strong style={{ fontSize: 13 }}>Slot upload:</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{q.upload_slots.map((slot, i) => (
|
||||
<Tag key={i} value={slot.label || slot.id} severity="secondary" style={{ marginRight: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
<small style={{ color: '#888' }}>Gli slot upload sono gestiti dal modello: modifica avanzata non ancora via UI.</small>
|
||||
</>
|
||||
)}
|
||||
</AccordionTab>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuadriStructureEditor;
|
||||
1138
src/modules/ar1/pages/Ar1AdminConfig.js
Normal file
1138
src/modules/ar1/pages/Ar1AdminConfig.js
Normal file
File diff suppressed because it is too large
Load Diff
247
src/modules/ar1/pages/Ar1Home.js
Normal file
247
src/modules/ar1/pages/Ar1Home.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
|
||||
import { useStoreValue } from '../../../store';
|
||||
|
||||
import Ar1Service from '../service/ar1Service';
|
||||
import Ar1StatusTag from '../components/Ar1StatusTag';
|
||||
|
||||
const VARIANT_OPTIONS = [
|
||||
{ label: 'A1 — Persona Giuridica (societa, ente)', value: 'A1' },
|
||||
{ label: 'A2 — Ditta Individuale (P.IVA persona fisica)', value: 'A2' },
|
||||
{ label: 'A3 — Persona Fisica (senza P.IVA)', value: 'A3' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Ar1Home: schermata principale modulo AR1 per il beneficiario.
|
||||
* - Card status con countdown
|
||||
* - CTA dinamici (Compila / Riprendi / Firma / Rinnova)
|
||||
* - Storico dichiarazioni
|
||||
*/
|
||||
const Ar1Home = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
const companyId = useStoreValue('chosenCompanyId');
|
||||
|
||||
const [status, setStatus] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [variantDialogOpen, setVariantDialogOpen] = useState(false);
|
||||
const [selectedVariant, setSelectedVariant] = useState('A1');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const loadAll = () => {
|
||||
if (!companyId) return;
|
||||
setLoading(true);
|
||||
Ar1Service.getStatusForCompany(companyId,
|
||||
(resp) => setStatus(resp),
|
||||
(err) => console.warn('getStatus failed', err)
|
||||
);
|
||||
Ar1Service.listFormsForCompany(companyId,
|
||||
(resp) => {
|
||||
setHistory(resp?.items || []);
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setLoading(false);
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile caricare lo storico' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyId]);
|
||||
|
||||
const startNewDraft = () => {
|
||||
setCreating(true);
|
||||
Ar1Service.createDraft(companyId, selectedVariant,
|
||||
(resp) => {
|
||||
setCreating(false);
|
||||
setVariantDialogOpen(false);
|
||||
if (resp?.id) navigate(`/ar1/wizard/${resp.id}`);
|
||||
},
|
||||
(err) => {
|
||||
setCreating(false);
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile creare il form' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const resumeForm = (formId) => navigate(`/ar1/wizard/${formId}`);
|
||||
const goToSignature = (formId) => navigate(`/ar1/signature/${formId}`);
|
||||
|
||||
const deleteDraft = (formId) => {
|
||||
confirmDialog({
|
||||
message: __('Sei sicuro di voler eliminare questa bozza? L\'operazione non puo essere annullata.', 'gepafin'),
|
||||
header: __('Conferma eliminazione', 'gepafin'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: __('Elimina', 'gepafin'),
|
||||
rejectLabel: __('Annulla', 'gepafin'),
|
||||
acceptClassName: 'p-button-danger',
|
||||
accept: () => {
|
||||
Ar1Service.deleteForm(formId,
|
||||
() => {
|
||||
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Bozza eliminata' });
|
||||
loadAll();
|
||||
},
|
||||
(err) => {
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Eliminazione fallita' });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderStatusCard = () => {
|
||||
if (!status) return null;
|
||||
const isUrgent = ['MISSING', 'EXPIRED'].includes(status.status);
|
||||
const canCompile = ['MISSING', 'EXPIRED', 'APPROACHING', 'VALID'].includes(status.status);
|
||||
const hasActive = status.form_id && ['DRAFT', 'AWAITING_SIGNATURE'].includes(status.status);
|
||||
|
||||
return (
|
||||
<Card title={__('Stato Dichiarazione AR1 — Adeguata Verifica', 'gepafin')} style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Ar1StatusTag status={status.status} />
|
||||
{status.variant && (
|
||||
<span style={{ color: '#666' }}>
|
||||
{__('Variante:', 'gepafin')} <strong>{status.variant}</strong>
|
||||
</span>
|
||||
)}
|
||||
{status.days_to_expiry !== null && status.days_to_expiry !== undefined && (
|
||||
<span style={{ color: isUrgent ? '#b71c1c' : '#444', fontWeight: 600 }}>
|
||||
{status.days_to_expiry < 0
|
||||
? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin')
|
||||
: __(`Scade tra ${status.days_to_expiry} giorni`, 'gepafin')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.must_recompile_reason && (
|
||||
<div style={{ marginTop: 12, padding: 10, background: '#fff3e0', borderLeft: '3px solid #e65100' }}>
|
||||
<i className="pi pi-info-circle" style={{ marginRight: 6 }} />
|
||||
{status.must_recompile_reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{hasActive && status.status === 'DRAFT' && (
|
||||
<Button label={__('Riprendi compilazione', 'gepafin')} icon="pi pi-pencil" onClick={() => resumeForm(status.form_id)} />
|
||||
)}
|
||||
{hasActive && status.status === 'AWAITING_SIGNATURE' && (
|
||||
<Button label={__('Procedi alla firma', 'gepafin')} icon="pi pi-verified" severity="warning" onClick={() => goToSignature(status.form_id)} />
|
||||
)}
|
||||
{!hasActive && canCompile && (
|
||||
<Button
|
||||
label={status.status === 'MISSING' ? __('Compila adesso', 'gepafin') : __('Rinnova dichiarazione', 'gepafin')}
|
||||
icon="pi pi-plus"
|
||||
onClick={() => setVariantDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const statusTpl = (row) => <Ar1StatusTag status={row.status} />;
|
||||
const dateTpl = (row, field) => {
|
||||
const v = row[field];
|
||||
if (!v) return '—';
|
||||
try { return new Date(v).toLocaleDateString('it-IT'); } catch (e) { return v; }
|
||||
};
|
||||
const actionsTpl = (row) => (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{row.status === 'DRAFT' && (
|
||||
<>
|
||||
<Button icon="pi pi-pencil" rounded text onClick={() => resumeForm(row.id)} tooltip={__('Riprendi', 'gepafin')} />
|
||||
<Button icon="pi pi-trash" rounded text severity="danger" onClick={() => deleteDraft(row.id)} tooltip={__('Elimina', 'gepafin')} />
|
||||
</>
|
||||
)}
|
||||
{row.status === 'AWAITING_SIGNATURE' && (
|
||||
<Button icon="pi pi-verified" rounded text severity="warning" onClick={() => goToSignature(row.id)} tooltip={__('Firma', 'gepafin')} />
|
||||
)}
|
||||
{['SIGNED', 'VERIFIED', 'EXPIRED'].includes(row.status) && (
|
||||
<Button icon="pi pi-download" rounded text onClick={async () => {
|
||||
try {
|
||||
const blob = await Ar1Service.downloadPdfSigned(row.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `AR1_${row.variant}_signed.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
|
||||
}
|
||||
}} tooltip={__('Scarica firmato', 'gepafin')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Toast ref={toast} />
|
||||
<ConfirmDialog />
|
||||
|
||||
<h1>{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}</h1>
|
||||
<p style={{ color: '#666' }}>
|
||||
{__('Modulo di aggiornamento dell\'adeguata verifica ai sensi del D.Lgs. 231/2007 (normativa antiriciclaggio).', 'gepafin')}
|
||||
</p>
|
||||
|
||||
{renderStatusCard()}
|
||||
|
||||
<Card title={__('Storico dichiarazioni', 'gepafin')}>
|
||||
<DataTable
|
||||
value={history}
|
||||
loading={loading}
|
||||
emptyMessage={__('Nessuna dichiarazione presente', 'gepafin')}
|
||||
paginator={history.length > 10}
|
||||
rows={10}
|
||||
>
|
||||
<Column field="variant" header={__('Variante', 'gepafin')} />
|
||||
<Column field="template_version" header={__('Versione modulo', 'gepafin')} />
|
||||
<Column field="status" header={__('Stato', 'gepafin')} body={statusTpl} />
|
||||
<Column field="created_at" header={__('Creato il', 'gepafin')} body={(r) => dateTpl(r, 'created_at')} />
|
||||
<Column field="signed_at" header={__('Firmato il', 'gepafin')} body={(r) => dateTpl(r, 'signed_at')} />
|
||||
<Column field="expires_at" header={__('Scade il', 'gepafin')} body={(r) => dateTpl(r, 'expires_at')} />
|
||||
<Column header={__('Azioni', 'gepafin')} body={actionsTpl} style={{ width: 160 }} />
|
||||
</DataTable>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
header={__('Scegli tipologia', 'gepafin')}
|
||||
visible={variantDialogOpen}
|
||||
onHide={() => setVariantDialogOpen(false)}
|
||||
style={{ width: '480px', maxWidth: '95vw' }}
|
||||
modal
|
||||
>
|
||||
<p style={{ marginBottom: 10 }}>
|
||||
{__('Seleziona la tipologia di soggetto che rappresenti:', 'gepafin')}
|
||||
</p>
|
||||
<Dropdown
|
||||
value={selectedVariant}
|
||||
options={VARIANT_OPTIONS}
|
||||
onChange={(e) => setSelectedVariant(e.value)}
|
||||
style={{ width: '100%', marginBottom: 16 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setVariantDialogOpen(false)} />
|
||||
<Button label={__('Inizia compilazione', 'gepafin')} icon="pi pi-arrow-right" iconPos="right" loading={creating} onClick={startNewDraft} />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ar1Home;
|
||||
222
src/modules/ar1/pages/Ar1Signature.js
Normal file
222
src/modules/ar1/pages/Ar1Signature.js
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { FileUpload } from 'primereact/fileupload';
|
||||
import { Message } from 'primereact/message';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
|
||||
import Ar1Service from '../service/ar1Service';
|
||||
import Ar1StatusTag from '../components/Ar1StatusTag';
|
||||
|
||||
/**
|
||||
* Pagina firma AR1.
|
||||
* URL: /ar1/signature/:formId
|
||||
*
|
||||
* Flusso: genera PDF → download unsigned → firma FEQ client side → upload signed → DocVerify.
|
||||
*/
|
||||
const Ar1Signature = () => {
|
||||
const { formId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadAttempt, setUploadAttempt] = useState(0);
|
||||
const fileUploadRef = useRef(null);
|
||||
|
||||
const resetUploadInput = () => {
|
||||
try { fileUploadRef.current?.clear?.(); } catch (_) {}
|
||||
setUploadAttempt(n => n + 1);
|
||||
};
|
||||
|
||||
const refreshForm = () => {
|
||||
Ar1Service.getForm(formId,
|
||||
(resp) => { setForm(resp); setLoading(false); },
|
||||
(err) => {
|
||||
setLoading(false);
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => { if (formId) refreshForm(); /* eslint-disable-next-line */ }, [formId]);
|
||||
|
||||
const handleGeneratePdf = () => {
|
||||
setGenerating(true);
|
||||
Ar1Service.generatePdf(formId,
|
||||
() => {
|
||||
setGenerating(false);
|
||||
refreshForm();
|
||||
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'PDF generato' });
|
||||
},
|
||||
(err) => {
|
||||
setGenerating(false);
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Generazione PDF fallita' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDownloadUnsigned = async () => {
|
||||
try {
|
||||
const blob = await Ar1Service.downloadPdfUnsigned(formId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `AR1_${form.variant}_da-firmare.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSigned = (event) => {
|
||||
const file = event.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||
if (ext !== '.pdf' && ext !== '.p7m') {
|
||||
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Formato non valido', detail: 'Accettati: .pdf (PAdES) o .p7m (CAdES)' });
|
||||
resetUploadInput();
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
if (toast.current) toast.current.show({ severity: 'info', summary: 'Verifica in corso...', detail: 'Analisi firma digitale (fino a 60 secondi)' });
|
||||
|
||||
Ar1Service.uploadSignature(formId, file,
|
||||
(resp) => {
|
||||
setUploading(false);
|
||||
resetUploadInput();
|
||||
refreshForm();
|
||||
const outcome = resp?.outcome;
|
||||
if (outcome === 'VERIFIED') {
|
||||
if (toast.current) toast.current.show({ severity: 'success', summary: 'Firma verificata!', detail: 'La dichiarazione e stata archiviata nei tuoi documenti aziendali.' });
|
||||
setTimeout(() => navigate('/ar1'), 1500);
|
||||
} else if (outcome === 'SIGNED_NOT_VERIFIED') {
|
||||
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Firma accettata', detail: 'La firma e presente ma richiede verifica manuale da parte dell\'istruttore.' });
|
||||
} else if (outcome === 'SIGNED_DOCVERIFY_UNAVAILABLE') {
|
||||
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Verifica rimandata', detail: 'Servizio di verifica momentaneamente non disponibile. L\'istruttore verifichera la firma manualmente.' });
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setUploading(false);
|
||||
resetUploadInput();
|
||||
if (err?.detail?.code === 'NO_SIGNATURE_DETECTED') {
|
||||
if (toast.current) toast.current.show({
|
||||
severity: 'error',
|
||||
summary: 'Firma non rilevata',
|
||||
detail: 'Il file caricato non contiene una firma digitale valida. Firmare il PDF con il proprio strumento FEQ e ricaricarlo.',
|
||||
life: 6000
|
||||
});
|
||||
} else {
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: typeof err?.detail === 'string' ? err.detail : (err?.detail?.message || 'Upload fallito') });
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: 40 }}><ProgressSpinner /></div>;
|
||||
if (!form) return <div style={{ padding: 20 }}><Message severity="error" text={__('Form non trovato', 'gepafin')} /></div>;
|
||||
|
||||
const hasUnsignedPdf = !!form.pdf_unsigned_path;
|
||||
const hasSignedPdf = !!form.pdf_signed_path;
|
||||
const canUploadSig = form.status === 'AWAITING_SIGNATURE';
|
||||
const isDone = ['VERIFIED', 'SIGNED'].includes(form.status);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, maxWidth: 900, margin: '0 auto' }}>
|
||||
<Toast ref={toast} />
|
||||
<h1>{__('Firma AR1', 'gepafin')} — {form.variant}</h1>
|
||||
<div style={{ marginBottom: 20 }}><Ar1StatusTag status={form.status} /></div>
|
||||
|
||||
<Card title={__('1. Scarica il modulo AR1 da firmare', 'gepafin')} style={{ marginBottom: 14 }}>
|
||||
{!hasUnsignedPdf && (
|
||||
<div>
|
||||
<p>{__('Il PDF del tuo modulo AR1 non e ancora stato generato.', 'gepafin')}</p>
|
||||
<Button label={__('Genera PDF', 'gepafin')} icon="pi pi-file-pdf" onClick={handleGeneratePdf} loading={generating} />
|
||||
</div>
|
||||
)}
|
||||
{hasUnsignedPdf && (
|
||||
<div>
|
||||
<p>{__('Scarica il PDF, firmalo con il tuo strumento FEQ (CNS, Aruba, Namirial, Dike) e ricaricalo qui sotto.', 'gepafin')}</p>
|
||||
<Button label={__('Scarica PDF', 'gepafin')} icon="pi pi-download" onClick={handleDownloadUnsigned} outlined />
|
||||
{!isDone && (
|
||||
<Button label={__('Rigenera PDF', 'gepafin')} icon="pi pi-refresh" text onClick={handleGeneratePdf} loading={generating} style={{ marginLeft: 8 }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={__('2. Carica il PDF firmato', 'gepafin')} style={{ marginBottom: 14 }}>
|
||||
{!canUploadSig && !isDone && (
|
||||
<Message severity="warn" text={__(`Il modulo non e in stato AWAITING_SIGNATURE (attuale: ${form.status})`, 'gepafin')} />
|
||||
)}
|
||||
{canUploadSig && (
|
||||
<div>
|
||||
<p>{__('Formati accettati: PDF con firma PAdES oppure file .p7m (CAdES). Dimensione massima 50 MB.', 'gepafin')}</p>
|
||||
<FileUpload
|
||||
ref={fileUploadRef}
|
||||
key={uploadAttempt}
|
||||
name="file"
|
||||
mode="basic"
|
||||
accept=".pdf,.p7m"
|
||||
maxFileSize={50 * 1024 * 1024}
|
||||
customUpload
|
||||
uploadHandler={handleUploadSigned}
|
||||
auto
|
||||
chooseLabel={uploading ? __('Verifica in corso...', 'gepafin') : __('Seleziona PDF firmato', 'gepafin')}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDone && (
|
||||
<div>
|
||||
<Message severity="success" text={
|
||||
form.status === 'VERIFIED'
|
||||
? __('Firma verificata con successo. La dichiarazione e archiviata nei tuoi documenti aziendali.', 'gepafin')
|
||||
: __('Firma accettata. Potrebbe richiedere verifica manuale.', 'gepafin')
|
||||
} style={{ marginBottom: 12 }} />
|
||||
{hasSignedPdf && (
|
||||
<Button label={__('Scarica PDF firmato', 'gepafin')} icon="pi pi-download" onClick={async () => {
|
||||
try {
|
||||
const blob = await Ar1Service.downloadPdfSigned(formId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `AR1_${form.variant}_signed${form.pdf_signed_path?.endsWith('.p7m') ? '.p7m' : '.pdf'}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
|
||||
}
|
||||
}} outlined />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{form.signature_verified_at && (
|
||||
<Card title={__('Dettagli verifica', 'gepafin')}>
|
||||
<p><strong>{__('Firmatario:', 'gepafin')}</strong> {form.signature_signer_name || '—'}</p>
|
||||
<p><strong>{__('Codice fiscale:', 'gepafin')}</strong> {form.signature_signer_cf || '—'}</p>
|
||||
<p><strong>{__('Metodo:', 'gepafin')}</strong> {form.signature_type || '—'}</p>
|
||||
<p><strong>{__('Verificato il:', 'gepafin')}</strong> {new Date(form.signature_verified_at).toLocaleString('it-IT')}</p>
|
||||
<p><strong>{__('Scade il:', 'gepafin')}</strong> {form.expires_at ? new Date(form.expires_at).toLocaleDateString('it-IT') : '—'}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 20, textAlign: 'center' }}>
|
||||
<Button label={__('Torna alla Home AR1', 'gepafin')} severity="secondary" outlined onClick={() => navigate('/ar1')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ar1Signature;
|
||||
392
src/modules/ar1/pages/Ar1Wizard.js
Normal file
392
src/modules/ar1/pages/Ar1Wizard.js
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { RadioButton } from 'primereact/radiobutton';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Steps } from 'primereact/steps';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { Message } from 'primereact/message';
|
||||
import { FileUpload } from 'primereact/fileupload';
|
||||
|
||||
import Ar1Service from '../service/ar1Service';
|
||||
|
||||
/**
|
||||
* Wizard data-driven: legge schema_snapshot del form e genera step/field dinamicamente.
|
||||
* Uno step per quadro. Auto-save onBlur via PUT /quadri.
|
||||
*
|
||||
* URL: /ar1/wizard/:formId
|
||||
*/
|
||||
const Ar1Wizard = () => {
|
||||
const { formId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [quadriValues, setQuadriValues] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const quadri = useMemo(
|
||||
() => (form?.schema_snapshot?.quadri || []).filter(q => !q.is_legal_frame),
|
||||
[form]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId) return;
|
||||
Ar1Service.getForm(formId,
|
||||
(resp) => {
|
||||
setForm(resp);
|
||||
setQuadriValues(resp.quadri || {});
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setLoading(false);
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' });
|
||||
}
|
||||
);
|
||||
}, [formId]);
|
||||
|
||||
const isReadonly = form && form.status !== 'DRAFT';
|
||||
|
||||
const saveQuadro = (quadroId) => {
|
||||
if (isReadonly) return;
|
||||
const patch = { [quadroId]: quadriValues[quadroId] || {} };
|
||||
setSaving(true);
|
||||
Ar1Service.updateQuadri(formId, patch,
|
||||
(resp) => { setSaving(false); setForm(resp); },
|
||||
(err) => {
|
||||
setSaving(false);
|
||||
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Save fallito', detail: err?.detail || 'Riprovare' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleFieldChange = (quadroId, fieldId, value) => {
|
||||
setQuadriValues(prev => ({
|
||||
...prev,
|
||||
[quadroId]: { ...(prev[quadroId] || {}), [fieldId]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRowFieldChange = (quadroId, rowIndex, fieldId, value) => {
|
||||
setQuadriValues(prev => {
|
||||
const q = prev[quadroId] || { rows: [] };
|
||||
const rows = [...(q.rows || [])];
|
||||
rows[rowIndex] = { ...(rows[rowIndex] || {}), [fieldId]: value };
|
||||
return { ...prev, [quadroId]: { ...q, rows } };
|
||||
});
|
||||
};
|
||||
|
||||
const addRow = (quadroId, maxRows) => {
|
||||
setQuadriValues(prev => {
|
||||
const q = prev[quadroId] || { rows: [] };
|
||||
if ((q.rows || []).length >= maxRows) return prev;
|
||||
return { ...prev, [quadroId]: { ...q, rows: [...(q.rows || []), {}] } };
|
||||
});
|
||||
};
|
||||
|
||||
const removeRow = (quadroId, rowIndex) => {
|
||||
setQuadriValues(prev => {
|
||||
const q = prev[quadroId] || { rows: [] };
|
||||
const rows = (q.rows || []).filter((_, i) => i !== rowIndex);
|
||||
return { ...prev, [quadroId]: { ...q, rows } };
|
||||
});
|
||||
};
|
||||
|
||||
const submitFinale = () => {
|
||||
if (!activeQuadro) return;
|
||||
setSubmitting(true);
|
||||
const patch = { [activeQuadro.id]: quadriValues[activeQuadro.id] || {} };
|
||||
Ar1Service.updateQuadri(formId, patch,
|
||||
() => {
|
||||
Ar1Service.submitForSignature(formId,
|
||||
() => {
|
||||
setSubmitting(false);
|
||||
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Modulo pronto per la firma' });
|
||||
setTimeout(() => navigate(`/ar1/signature/${formId}`), 600);
|
||||
},
|
||||
(err) => {
|
||||
setSubmitting(false);
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Submit fallito' });
|
||||
}
|
||||
);
|
||||
},
|
||||
(err) => {
|
||||
setSubmitting(false);
|
||||
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save fallito' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = (field, value, onChange, path = '') => {
|
||||
const key = `${path}-${field.id}`;
|
||||
const disabled = isReadonly;
|
||||
const req = field.required ? ' *' : '';
|
||||
const commonLabel = <label htmlFor={key} style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{field.label}{req}</label>;
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14 }}>
|
||||
{commonLabel}
|
||||
<InputText
|
||||
id={key}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(field.id, field.uppercase ? e.target.value.toUpperCase() : e.target.value)}
|
||||
disabled={disabled}
|
||||
maxLength={field.max_length}
|
||||
placeholder={field.placeholder}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{field.legal_ref && <small style={{ color: '#888' }}>{field.legal_ref}</small>}
|
||||
</div>
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14 }}>
|
||||
{commonLabel}
|
||||
<InputTextarea id={key} value={value || ''} onChange={(e) => onChange(field.id, e.target.value)} disabled={disabled} rows={3} maxLength={field.max_length} style={{ width: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14 }}>
|
||||
{commonLabel}
|
||||
<Calendar id={key} value={value ? new Date(value) : null} onChange={(e) => onChange(field.id, e.value ? e.value.toISOString().slice(0, 10) : null)} disabled={disabled} dateFormat="dd/mm/yy" showIcon style={{ width: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Checkbox inputId={key} checked={!!value} onChange={(e) => onChange(field.id, e.checked)} disabled={disabled} />
|
||||
<label htmlFor={key}>{field.label}</label>
|
||||
</div>
|
||||
);
|
||||
case 'radio':
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14 }}>
|
||||
{commonLabel}
|
||||
{(field.options || []).map((opt, idx) => {
|
||||
const optVal = typeof opt === 'string' ? opt : opt.value;
|
||||
const optLabel = typeof opt === 'string' ? opt : opt.label;
|
||||
const rid = `${key}-opt-${idx}`;
|
||||
return (
|
||||
<div key={rid} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<RadioButton inputId={rid} value={optVal} checked={value === optVal} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} />
|
||||
<label htmlFor={rid}>{optLabel}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
case 'enum':
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14 }}>
|
||||
{commonLabel}
|
||||
<Dropdown id={key} value={value} options={(field.options || []).map(o => ({ label: o.replace(/_/g, ' '), value: o }))} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} style={{ width: '100%' }} showClear />
|
||||
</div>
|
||||
);
|
||||
case 'yes_no_with_note': {
|
||||
const v = typeof value === 'object' && value ? value : {};
|
||||
const yes = v.value === 'si' || v.value === 'yes' || v.value === 'true';
|
||||
const no = v.value === 'no' || v.value === 'false';
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14, padding: 10, background: '#fafafa', borderLeft: '3px solid #003d7a' }}>
|
||||
{commonLabel}
|
||||
{field.legal_ref && <small style={{ color: '#888', display: 'block', marginBottom: 6 }}>{field.legal_ref}</small>}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<RadioButton inputId={`${key}-yes`} checked={yes} onChange={() => onChange(field.id, { ...v, value: 'si' })} disabled={disabled} />
|
||||
<label htmlFor={`${key}-yes`}>{__('Si', 'gepafin')}</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<RadioButton inputId={`${key}-no`} checked={no} onChange={() => onChange(field.id, { ...v, value: 'no' })} disabled={disabled} />
|
||||
<label htmlFor={`${key}-no`}>{__('No', 'gepafin')}</label>
|
||||
</div>
|
||||
</div>
|
||||
{yes && (
|
||||
<InputTextarea
|
||||
value={v.note || ''}
|
||||
onChange={(e) => onChange(field.id, { ...v, note: e.target.value })}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
placeholder={field.note_label || __('Specificare', 'gepafin')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 14 }}>
|
||||
{commonLabel}
|
||||
<InputText value={value || ''} onChange={(e) => onChange(field.id, e.target.value)} disabled={disabled} style={{ width: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderQuadro = (quadro) => {
|
||||
const q = quadriValues[quadro.id] || {};
|
||||
|
||||
if (quadro.upload_slots) {
|
||||
return (
|
||||
<div>
|
||||
<h3>{quadro.title}</h3>
|
||||
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
|
||||
<Message severity="info" text={__('Carica qui i documenti richiesti. Il PDF firmato dell\'AR1 verra archiviato automaticamente nei tuoi documenti aziendali.', 'gepafin')} style={{ marginBottom: 14 }} />
|
||||
{quadro.upload_slots.map(slot => (
|
||||
<div key={slot.id} style={{ marginBottom: 14, padding: 10, border: '1px solid #ddd', borderRadius: 4 }}>
|
||||
<label style={{ fontWeight: 500 }}>{slot.label}{slot.required ? ' *' : ''}</label>
|
||||
{q[slot.id]?.filename ? (
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, padding: 8, background: '#f0f7ff', border: '1px solid #b3d4f0', borderRadius: 4 }}>
|
||||
<i className="pi pi-file" style={{ color: '#003d7a' }} />
|
||||
<span style={{ flex: 1, wordBreak: 'break-all' }}>{q[slot.id].filename}</span>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
label={__('Rimuovi', 'gepafin')}
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
disabled={isReadonly}
|
||||
onClick={() => handleFieldChange(quadro.id, slot.id, null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
name={slot.id}
|
||||
mode="basic"
|
||||
accept={(slot.accept || []).join(',')}
|
||||
maxFileSize={(slot.max_size_mb || 300) * 1024 * 1024}
|
||||
disabled={isReadonly}
|
||||
customUpload
|
||||
uploadHandler={(e) => {
|
||||
const file = e.files[0];
|
||||
handleFieldChange(quadro.id, slot.id, { filename: file.name, size: file.size });
|
||||
if (toast.current) toast.current.show({ severity: 'info', summary: 'File selezionato', detail: file.name });
|
||||
}}
|
||||
auto
|
||||
chooseLabel={__('Scegli file', 'gepafin')}
|
||||
style={{ marginTop: 6 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (quadro.row_type) {
|
||||
const rows = q.rows || [];
|
||||
return (
|
||||
<div>
|
||||
<h3>{quadro.title}</h3>
|
||||
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
|
||||
{rows.map((row, idx) => (
|
||||
<Card key={idx} title={`${quadro.row_type.replace(/_/g, ' ').toUpperCase()} #${idx + 1}`} style={{ marginBottom: 12 }}>
|
||||
{(quadro.row_fields || []).map(field => renderField(field, row[field.id], (fid, val) => handleRowFieldChange(quadro.id, idx, fid, val), `q-${quadro.id}-row-${idx}`))}
|
||||
{!isReadonly && (
|
||||
<Button label={__('Rimuovi', 'gepafin')} severity="danger" outlined icon="pi pi-trash" size="small" onClick={() => removeRow(quadro.id, idx)} />
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
{!isReadonly && rows.length < (quadro.max_rows || 4) && (
|
||||
<Button label={__('Aggiungi', 'gepafin')} icon="pi pi-plus" outlined onClick={() => addRow(quadro.id, quadro.max_rows || 4)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>{quadro.title}</h3>
|
||||
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
|
||||
{(quadro.fields || []).map(field => renderField(field, q[field.id], (fid, val) => handleFieldChange(quadro.id, fid, val), `q-${quadro.id}`))}
|
||||
{quadro.nested_full && (
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
|
||||
<h4 style={{ marginTop: 0 }}>{__('Dettaglio aggiuntivo', 'gepafin')}</h4>
|
||||
{(quadro.nested_full.fields || []).map(field => renderField(
|
||||
field,
|
||||
(q.nested || {})[field.id],
|
||||
(fid, val) => handleFieldChange(quadro.id, 'nested', { ...((q.nested) || {}), [fid]: val }),
|
||||
`q-${quadro.id}-nested`
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: 40 }}><ProgressSpinner /></div>;
|
||||
if (!form) return <div style={{ padding: 20 }}><Message severity="error" text={__('Form non trovato', 'gepafin')} /></div>;
|
||||
if (quadri.length === 0) return <div style={{ padding: 20 }}><Message severity="warn" text={__('Nessun quadro editabile nel template. Contattare il supporto.', 'gepafin')} /></div>;
|
||||
|
||||
const steps = quadri.map(q => ({ label: q.id }));
|
||||
// clamp activeIndex: difensivo se quadri cambia lunghezza o e fuori range
|
||||
const safeIndex = quadri.length === 0 ? 0 : Math.max(0, Math.min(activeIndex, quadri.length - 1));
|
||||
const activeQuadro = quadri[safeIndex];
|
||||
const isLastStep = safeIndex === quadri.length - 1;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Toast ref={toast} />
|
||||
<h1>{__('Compilazione AR1', 'gepafin')} — {form.variant}</h1>
|
||||
{isReadonly && (
|
||||
<Message severity="info" text={__(`Form in stato ${form.status} — sola lettura`, 'gepafin')} style={{ marginBottom: 14 }} />
|
||||
)}
|
||||
|
||||
<Steps
|
||||
model={steps}
|
||||
activeIndex={safeIndex}
|
||||
onSelect={(e) => {
|
||||
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
|
||||
const next = Math.max(0, Math.min(e.index, quadri.length - 1));
|
||||
setActiveIndex(next);
|
||||
}}
|
||||
readOnly={false}
|
||||
style={{ marginBottom: 20 }}
|
||||
/>
|
||||
|
||||
<Card style={{ marginBottom: 14 }} onBlur={() => { if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id); }}>
|
||||
{activeQuadro && renderQuadro(activeQuadro)}
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button label={__('Indietro', 'gepafin')} icon="pi pi-arrow-left" severity="secondary" outlined disabled={activeIndex === 0}
|
||||
onClick={() => {
|
||||
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
|
||||
setActiveIndex(Math.max(0, activeIndex - 1));
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{saving && <span style={{ color: '#888' }}>{__('Salvataggio...', 'gepafin')}</span>}
|
||||
{!isLastStep && (
|
||||
<Button label={__('Avanti', 'gepafin')} icon="pi pi-arrow-right" iconPos="right"
|
||||
onClick={() => {
|
||||
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
|
||||
setActiveIndex(Math.min(quadri.length - 1, activeIndex + 1));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isLastStep && !isReadonly && (
|
||||
<Button label={__('Procedi alla firma', 'gepafin')} icon="pi pi-verified" severity="warning" loading={submitting} onClick={submitFinale} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ar1Wizard;
|
||||
259
src/modules/ar1/service/ar1Service.js
Normal file
259
src/modules/ar1/service/ar1Service.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Client HTTP per ar1-compiler (microservizio BFLOWS).
|
||||
* Il microservizio valida lo stesso JWT di GEPAFIN-BE (HS512 shared secret).
|
||||
*
|
||||
* Env var: REACT_APP_AR1_API_URL (es. http://78.46.41.91:18091)
|
||||
*
|
||||
* Pattern replicato 1:1 da rendicontazioneService.js.
|
||||
*/
|
||||
import { storeGet } from '../../../store';
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_AR1_API_URL || '';
|
||||
|
||||
const buildHeaders = () => {
|
||||
const token = storeGet('getToken');
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
return h;
|
||||
};
|
||||
|
||||
const buildHeadersMultipart = () => {
|
||||
const token = storeGet('getToken');
|
||||
const h = {};
|
||||
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 Ar1Service = {
|
||||
// ---------- Status pubblico (per compliance modal) ----------
|
||||
getStatusForCompany(companyId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/public/ar1-status/${companyId}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- CRUD form beneficiario ----------
|
||||
createDraft(companyId, variant, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ company_id: companyId, variant })
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
getForm(formId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
listFormsForCompany(companyId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/company/${companyId}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
updateQuadri(formId, quadriPatch, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}/quadri`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ quadri: quadriPatch })
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
submitForSignature(formId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}/submit-for-signature`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deleteForm(formId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => {
|
||||
if (r.status === 204) { if (onSuccess) onSuccess({}); }
|
||||
else handleResponse(r, onSuccess, onError);
|
||||
}).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- PDF ----------
|
||||
generatePdf(formId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}/generate-pdf`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
downloadPdfUnsigned(formId) {
|
||||
return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-unsigned`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeadersMultipart()
|
||||
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
|
||||
},
|
||||
|
||||
downloadPdfSigned(formId) {
|
||||
return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-signed`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeadersMultipart()
|
||||
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
|
||||
},
|
||||
|
||||
// ---------- Firma ----------
|
||||
uploadSignature(formId, fileObject, onSuccess, onError) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileObject);
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}/upload-signature`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeadersMultipart(),
|
||||
body: formData
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
reVerifySignature(formId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}/verify`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
archiveToCompanyDocument(formId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/ar1-forms/${formId}/archive-to-company-document`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- ADMIN: templates ----------
|
||||
listTemplates(onSuccess, onError, queryParams) {
|
||||
const qs = queryParams ? ('?' + new URLSearchParams(queryParams).toString()) : '';
|
||||
fetch(`${BASE_URL}/admin/ar1-templates${qs}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
getTemplateDetail(templateId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
getNextVersion(variant, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-templates/${variant}/next-version`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
updateTemplateLayout(templateId, layoutConfig, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}/layout-config`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ layout_config: layoutConfig })
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
createNewTemplateVersion(variant, payload, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-templates/${variant}/new-version`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- ADMIN: policy ----------
|
||||
getPolicy(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-policy`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
updatePolicy(patch, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-policy`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(patch)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- ADMIN: pec-schedule-config ----------
|
||||
listPecSchedule(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
createPecRule(payload, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
updatePecRule(ruleId, payload, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deletePecRule(ruleId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => {
|
||||
if (r.status === 204) { if (onSuccess) onSuccess({}); }
|
||||
else handleResponse(r, onSuccess, onError);
|
||||
}).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- ADMIN: bulk PEC ----------
|
||||
bulkRequestRecompilation(payload, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-forms/bulk-request-recompilation`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- ADMIN: document categories (per dropdown) ----------
|
||||
previewTemplatePdf(templateId) {
|
||||
return fetch(`${BASE_URL}/admin/ar1-templates/${templateId}/preview-pdf`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeadersMultipart()
|
||||
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
|
||||
},
|
||||
|
||||
listDocumentCategories(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/document-categories`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
// ---------- ADMIN: email templates (Opzione 3 — tenant-agnostic, BE Gepafin pull) ----------
|
||||
listEmailTemplates(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-email-templates`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
getEmailTemplate(kind, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
updateEmailTemplate(kind, payload, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
previewEmailTemplate(kind, mockVars, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}/preview`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(mockVars || {})
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
};
|
||||
|
||||
export default Ar1Service;
|
||||
284
src/modules/rendicontazione/components/CompanyDocumentPicker.js
Normal file
284
src/modules/rendicontazione/components/CompanyDocumentPicker.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { Message } from 'primereact/message';
|
||||
|
||||
import CompanyDocumentsService from '../../../service/company-documents-service';
|
||||
|
||||
/**
|
||||
* Modal per scegliere un documento dal repository della company.
|
||||
*
|
||||
* Carica tramite 2 chiamate al BE Spring:
|
||||
* - default (no filter) -> COMPANY_DOCUMENT + APPLICATION_DOCUMENT
|
||||
* - documentType=PERSONAL -> PERSONAL_DOCUMENT (amministratore/legale rappresentante)
|
||||
*
|
||||
* Unione dei risultati e deduplicazione per document_category_id scegliendo
|
||||
* il piu recente attivo (VALID > DUE, a parita id desc). Questo mitiga il
|
||||
* fatto che il BE Gepafin non implementa sostituzione automatica su upload
|
||||
* stessa categoria (es. 2 DURC attivi in DB).
|
||||
*
|
||||
* Props:
|
||||
* visible, companyId, onHide, onSelect(doc), currentSourceId
|
||||
*/
|
||||
const STATUS_CFG = {
|
||||
VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle' },
|
||||
DUE: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-exclamation-triangle' },
|
||||
EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle' },
|
||||
};
|
||||
|
||||
const ORIGIN_CFG = {
|
||||
COMPANY_DOCUMENT: { label: 'Azienda', icon: 'pi pi-building', color: 'var(--blue-500)', bg: 'var(--blue-50)' },
|
||||
PERSONAL_DOCUMENT: { label: 'Amministratore', icon: 'pi pi-user', color: 'var(--purple-500)', bg: 'var(--purple-50)' },
|
||||
APPLICATION_DOCUMENT: { label: 'Applicazione', icon: 'pi pi-file', color: 'var(--teal-500)', bg: 'var(--teal-50)' },
|
||||
};
|
||||
|
||||
const formatDate = (d) => {
|
||||
if (!d) return '—';
|
||||
try { return new Date(d).toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }); }
|
||||
catch { return String(d); }
|
||||
};
|
||||
const daysUntil = (d) => { if (!d) return null; try { return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000); } catch { return null; } };
|
||||
const getCategoryName = (d) => (d.category && d.category.categoryName) || d.type || '—';
|
||||
const getOriginType = (d) => d.type || 'COMPANY_DOCUMENT';
|
||||
|
||||
// dedup: per ogni (type + category), mantieni il piu recente attivo. VALID > DUE, poi id desc
|
||||
const dedupByCategory = (docs) => {
|
||||
const STATUS_RANK = { VALID: 0, DUE: 1, EXPIRED: 2 };
|
||||
const groups = new Map();
|
||||
for (const d of docs) {
|
||||
const key = `${d.type || 'X'}::${getCategoryName(d)}`;
|
||||
const rank = (STATUS_RANK[d.status] ?? 99) * 1e12 - (d.id || 0);
|
||||
const prev = groups.get(key);
|
||||
if (!prev || rank < prev._rank) {
|
||||
groups.set(key, { ...d, _rank: rank });
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values()).map(({ _rank, ...rest }) => rest);
|
||||
};
|
||||
|
||||
const CompanyDocumentPicker = ({ visible, companyId, onHide, onSelect, currentSourceId = null }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState(null);
|
||||
const [statusFilter, setStatusFilter] = useState(null);
|
||||
const [originFilter, setOriginFilter] = useState('ALL'); // ALL | COMPANY_DOCUMENT | PERSONAL_DOCUMENT
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !companyId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedId(currentSourceId);
|
||||
setSearch(''); setCategoryFilter(null); setStatusFilter(null); setOriginFilter('ALL');
|
||||
|
||||
// doppia chiamata: company/application + personal
|
||||
let collected = [];
|
||||
let pending = 2;
|
||||
const done = () => { if (--pending === 0) { setDocs(dedupByCategory(collected)); setLoading(false); } };
|
||||
|
||||
CompanyDocumentsService.getCompanyDocuments(
|
||||
companyId,
|
||||
(resp) => { if (resp?.status === 'SUCCESS') collected = collected.concat(resp.data || []); done(); },
|
||||
() => { setError(__('Impossibile caricare i documenti del repository.', 'gepafin')); done(); }
|
||||
);
|
||||
CompanyDocumentsService.getCompanyDocuments(
|
||||
companyId,
|
||||
(resp) => { if (resp?.status === 'SUCCESS') collected = collected.concat(resp.data || []); done(); },
|
||||
() => done(),
|
||||
[['documentType', 'PERSONAL_DOCUMENT']]
|
||||
);
|
||||
}, [visible, companyId, currentSourceId]);
|
||||
|
||||
// opzioni filtri derivate
|
||||
const categoryOptions = useMemo(() => Array.from(new Set(docs.map(getCategoryName).filter(Boolean))).sort().map(t => ({ label: t, value: t })), [docs]);
|
||||
const statusOptions = [
|
||||
{ label: __('Validi', 'gepafin'), value: 'VALID' },
|
||||
{ label: __('In scadenza', 'gepafin'), value: 'DUE' },
|
||||
];
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
const s = (search || '').trim().toLowerCase();
|
||||
return docs.filter(d => {
|
||||
if (originFilter !== 'ALL' && getOriginType(d) !== originFilter) return false;
|
||||
if (categoryFilter && getCategoryName(d) !== categoryFilter) return false;
|
||||
if (statusFilter && d.status !== statusFilter) return false;
|
||||
if (s) {
|
||||
const hay = [d.fileName, d.name, getCategoryName(d)].filter(Boolean).join(' ').toLowerCase();
|
||||
if (!hay.includes(s)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [docs, search, categoryFilter, statusFilter, originFilter]);
|
||||
|
||||
// templates colonne
|
||||
const nameTpl = (row) => (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)', fontSize: '1.1rem' }} />
|
||||
<strong style={{ fontSize: '0.95rem' }}>{row.fileName || row.name || `Doc #${row.id}`}</strong>
|
||||
</div>
|
||||
{row.name && row.name !== row.fileName && (
|
||||
<div style={{ marginLeft: '1.6rem' }}><small className="text-color-secondary">{row.name}</small></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const originTpl = (row) => {
|
||||
const cfg = ORIGIN_CFG[getOriginType(row)] || ORIGIN_CFG.COMPANY_DOCUMENT;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
background: cfg.bg, color: cfg.color, padding: '0.2rem 0.6rem',
|
||||
borderRadius: '12px', fontSize: '0.8rem', fontWeight: 500
|
||||
}}>
|
||||
<i className={cfg.icon} style={{ fontSize: '0.8rem' }} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const categoryTpl = (row) => (
|
||||
<Tag value={getCategoryName(row)} severity="secondary"
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.8rem' }} />
|
||||
);
|
||||
const statusTpl = (row) => {
|
||||
const cfg = STATUS_CFG[row.status] || { severity: 'secondary', label: row.status || '—', icon: 'pi pi-question' };
|
||||
const days = daysUntil(row.expirationDate);
|
||||
return (
|
||||
<div>
|
||||
<Tag severity={cfg.severity} icon={cfg.icon} value={cfg.label} style={{ fontWeight: 600 }} />
|
||||
{row.status === 'DUE' && days != null && days >= 0 && (
|
||||
<div><small className="text-color-secondary">scade tra {days} {days === 1 ? 'giorno' : 'giorni'}</small></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const expiryTpl = (row) => <div style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>{formatDate(row.expirationDate)}</div>;
|
||||
|
||||
const chosenDoc = useMemo(() => docs.find(d => d.id === selectedId) || null, [docs, selectedId]);
|
||||
|
||||
const footer = (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '1rem' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{chosenDoc ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<small className="text-color-secondary">{__('Selezionato', 'gepafin')}:</small>
|
||||
<strong style={{ fontSize: '0.9rem' }}>{chosenDoc.fileName || chosenDoc.name}</strong>
|
||||
<Tag severity={(STATUS_CFG[chosenDoc.status]||{}).severity || 'secondary'}
|
||||
icon={(STATUS_CFG[chosenDoc.status]||{}).icon}
|
||||
value={(STATUS_CFG[chosenDoc.status]||{}).label || chosenDoc.status} />
|
||||
</div>
|
||||
) : (
|
||||
<small className="text-color-secondary">{__('Seleziona una riga per continuare', 'gepafin')}</small>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<Button label={__('Annulla', 'gepafin')} severity="secondary" text onClick={onHide} />
|
||||
<Button label={__('Seleziona', 'gepafin')} icon="pi pi-check" disabled={!chosenDoc}
|
||||
onClick={() => { if (chosenDoc) { onSelect(chosenDoc); onHide(); } }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const dialogHeader = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<i className="pi pi-folder-open" style={{ color: 'var(--primary-color)', fontSize: '1.3rem' }} />
|
||||
<span>{__('Scegli dal repository aziendale', 'gepafin')}</span>
|
||||
{!loading && !error && (
|
||||
<small className="text-color-secondary" style={{ marginLeft: 'auto' }}>
|
||||
{filteredDocs.length} / {docs.length} {__('documenti', 'gepafin')}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog header={dialogHeader} visible={visible} onHide={onHide}
|
||||
style={{ width: '1000px', maxWidth: '95vw' }}
|
||||
modal dismissableMask footer={footer}
|
||||
contentStyle={{ paddingTop: '1rem' }}>
|
||||
|
||||
<Message severity="info" style={{ width: '100%', marginBottom: '1.5rem' }}
|
||||
content={
|
||||
<small>{__('Seleziona un documento gia caricato. Se esistono piu versioni per la stessa categoria, viene mostrata solo la piu recente valida. I documenti scaduti sono esclusi.', 'gepafin')}</small>
|
||||
} />
|
||||
|
||||
{/* tab origine — 3 pulsanti manuali (SelectButton di PrimeReact ha issues di styling col tema Gepafin) */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<Button type="button" size="small"
|
||||
label={__('Tutti', 'gepafin')}
|
||||
outlined={originFilter !== 'ALL'}
|
||||
onClick={() => setOriginFilter('ALL')}
|
||||
style={{ flex: 1 }} />
|
||||
<Button type="button" size="small" icon="pi pi-building"
|
||||
label={__('Azienda', 'gepafin')}
|
||||
outlined={originFilter !== 'COMPANY_DOCUMENT'}
|
||||
onClick={() => setOriginFilter('COMPANY_DOCUMENT')}
|
||||
style={{ flex: 1 }} />
|
||||
<Button type="button" size="small" icon="pi pi-user"
|
||||
label={__('Amministratore', 'gepafin')}
|
||||
outlined={originFilter !== 'PERSONAL_DOCUMENT'}
|
||||
onClick={() => setOriginFilter('PERSONAL_DOCUMENT')}
|
||||
style={{ flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{/* filtri secondari */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<i className="pi pi-search" style={{ position: 'absolute', left: '1rem', top: '50%',
|
||||
transform: 'translateY(-50%)', color: 'var(--text-color-secondary)',
|
||||
pointerEvents: 'none', zIndex: 1 }} />
|
||||
<InputText placeholder={__('Cerca per nome o categoria...', 'gepafin')}
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: '100%', paddingLeft: '2.75rem' }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<Dropdown value={categoryFilter} onChange={(e) => setCategoryFilter(e.value)}
|
||||
options={categoryOptions} placeholder={__('Categoria', 'gepafin')}
|
||||
showClear style={{ width: '100%' }} />
|
||||
<Dropdown value={statusFilter} onChange={(e) => setStatusFilter(e.value)}
|
||||
options={statusOptions} placeholder={__('Stato', 'gepafin')}
|
||||
showClear style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div><Skeleton height="2.5rem" className="mb-2" /><Skeleton height="2.5rem" className="mb-2" /><Skeleton height="2.5rem" /></div>
|
||||
) : error ? (
|
||||
<Message severity="error" text={error} style={{ width: '100%' }} />
|
||||
) : (
|
||||
<DataTable value={filteredDocs} dataKey="id"
|
||||
emptyMessage={<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-color-secondary)' }}>
|
||||
<i className="pi pi-inbox" style={{ fontSize: '2rem', display: 'block', marginBottom: '0.5rem' }} />
|
||||
{docs.length === 0
|
||||
? __('Nessun documento nel repository della tua azienda', 'gepafin')
|
||||
: __('Nessun documento corrisponde ai filtri', 'gepafin')}
|
||||
</div>}
|
||||
size="small" stripedRows
|
||||
paginator rows={10}
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
|
||||
currentPageReportTemplate="{first}-{last} di {totalRecords}"
|
||||
selectionMode="single" selection={chosenDoc}
|
||||
onSelectionChange={(e) => e.value && setSelectedId(e.value.id)}
|
||||
rowClassName={(row) => row.id === selectedId ? 'p-highlight' : ''}
|
||||
style={{ cursor: 'pointer' }}>
|
||||
<Column header={__('Documento', 'gepafin')} body={nameTpl} style={{ minWidth: '240px' }} />
|
||||
<Column header={__('Origine', 'gepafin')} body={originTpl} style={{ width: '150px' }} />
|
||||
<Column header={__('Categoria', 'gepafin')} body={categoryTpl} style={{ width: '170px' }} />
|
||||
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '160px' }} />
|
||||
<Column header={__('Scadenza', 'gepafin')} body={expiryTpl} style={{ width: '110px' }} />
|
||||
</DataTable>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyDocumentPicker;
|
||||
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;
|
||||
198
src/modules/rendicontazione/components/SchemaTemplatePicker.js
Normal file
198
src/modules/rendicontazione/components/SchemaTemplatePicker.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
|
||||
import { schemaPickerService } from '../service/rendicontazioneService';
|
||||
|
||||
/**
|
||||
* SchemaTemplatePicker
|
||||
* Mostrato quando un bando non ha ancora uno schema di rendicontazione.
|
||||
* Offre 3 modalita: schema nuovo vuoto, da template predefinito, clone da altro bando.
|
||||
*
|
||||
* Props:
|
||||
* callId number — bando target
|
||||
* onInitialized fn(schemaData) — chiamato dopo initialize con successo
|
||||
* onError fn(err)
|
||||
*/
|
||||
const SchemaTemplatePicker = ({ callId, onInitialized, onError }) => {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [clonableCalls, setClonableCalls] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Selezione utente
|
||||
const [mode, setMode] = useState(null); // null | 'blank' | 'template' | 'clone'
|
||||
const [pickedTemplateId, setPickedTemplateId] = useState(null);
|
||||
const [pickedSourceCallId, setPickedSourceCallId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
let tpls = null, clones = null;
|
||||
const finish = () => {
|
||||
if (tpls !== null && clones !== null && active) setLoading(false);
|
||||
};
|
||||
|
||||
schemaPickerService.listTemplates(
|
||||
(resp) => { if (active) { tpls = resp?.data?.templates || []; setTemplates(tpls); } finish(); },
|
||||
(err) => { tpls = []; finish(); if (onError) onError(err); }
|
||||
);
|
||||
schemaPickerService.listClonableCalls(
|
||||
(resp) => { if (active) { clones = resp?.data?.calls || []; setClonableCalls(clones); } finish(); },
|
||||
(err) => { clones = []; finish(); }
|
||||
);
|
||||
return () => { active = false; };
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canSubmit = () => {
|
||||
if (!mode) return false;
|
||||
if (mode === 'blank') return true;
|
||||
if (mode === 'template') return !!pickedTemplateId;
|
||||
if (mode === 'clone') return !!pickedSourceCallId;
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const payload = { source: mode };
|
||||
if (mode === 'template') payload.template_id = pickedTemplateId;
|
||||
if (mode === 'clone') payload.source_call_id = pickedSourceCallId;
|
||||
|
||||
setSaving(true);
|
||||
schemaPickerService.initializeSchema(callId, payload,
|
||||
(resp) => { setSaving(false); if (onInitialized) onInitialized(resp?.data); },
|
||||
(err) => { setSaving(false); if (onError) onError(err); }
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-4"><Skeleton height="14rem"/></div>
|
||||
<div className="col-12 md:col-4"><Skeleton height="14rem"/></div>
|
||||
<div className="col-12 md:col-4"><Skeleton height="14rem"/></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const templateOptions = templates
|
||||
.filter(t => t.template_id !== 'blank') // blank ha card dedicata
|
||||
.map(t => ({ label: t.label, value: t.template_id, description: t.description }));
|
||||
|
||||
const cloneOptions = clonableCalls.map(c => ({
|
||||
label: `${c.name} · ${c.schema_status === 'PUBLISHED' ? __('Pubblicato','gepafin') : __('Bozza','gepafin')}`,
|
||||
value: c.call_id,
|
||||
}));
|
||||
|
||||
const cardStyle = (selected) => ({
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
border: selected ? '2px solid var(--primary-color)' : '2px solid transparent',
|
||||
transition: 'border-color 0.15s'
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<h3>{__('Scegli come iniziare lo schema di rendicontazione','gepafin')}</h3>
|
||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
||||
{__("Puoi partire da un modello vuoto, usare un template predefinito oppure clonare lo schema di un altro bando. Lo schema creato sarà in bozza e modificabile liberamente.", 'gepafin')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid">
|
||||
{/* Card 1: NUOVO SCHEMA VUOTO */}
|
||||
<div className="col-12 md:col-4">
|
||||
<Card
|
||||
title={<><i className="pi pi-plus-circle" style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />{__('Nuovo schema','gepafin')}</>}
|
||||
style={cardStyle(mode === 'blank')}
|
||||
onClick={() => setMode('blank')}
|
||||
>
|
||||
<p style={{ fontSize: '0.9em' }}>
|
||||
{__('Parti da zero con sezioni vuote. Configurerai categorie di spesa, documenti richiesti e controlli aggiuntivi secondo le esigenze del bando.','gepafin')}
|
||||
</p>
|
||||
<div style={{ fontSize: '0.85em', color: 'var(--text-color-secondary)' }}>
|
||||
{__('Consigliato se il bando è nuovo e non somiglia a bandi precedenti.','gepafin')}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Card 2: DA TEMPLATE */}
|
||||
<div className="col-12 md:col-4">
|
||||
<Card
|
||||
title={<><i className="pi pi-book" style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />{__('Da template','gepafin')}</>}
|
||||
style={cardStyle(mode === 'template')}
|
||||
onClick={() => setMode('template')}
|
||||
>
|
||||
<p style={{ fontSize: '0.9em' }}>
|
||||
{__('Parti da un template predefinito che replica schemi di bandi noti (es. RE-START). Potrai comunque modificare tutto.','gepafin')}
|
||||
</p>
|
||||
{mode === 'template' && (
|
||||
<div style={{ marginTop: '0.5rem' }} onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
value={pickedTemplateId}
|
||||
onChange={(e) => setPickedTemplateId(e.value)}
|
||||
options={templateOptions}
|
||||
placeholder={__('Scegli un template...','gepafin')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{pickedTemplateId && (
|
||||
<small className="text-color-secondary" style={{ display: 'block', marginTop: '0.4rem' }}>
|
||||
{templateOptions.find(t => t.value === pickedTemplateId)?.description}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Card 3: CLONE */}
|
||||
<div className="col-12 md:col-4">
|
||||
<Card
|
||||
title={<><i className="pi pi-copy" style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />{__('Clona da bando','gepafin')}</>}
|
||||
style={cardStyle(mode === 'clone')}
|
||||
onClick={() => setMode('clone')}
|
||||
>
|
||||
<p style={{ fontSize: '0.9em' }}>
|
||||
{__('Copia lo schema di un altro bando (in bozza o pubblicato). Utile se il nuovo bando è molto simile a uno esistente.','gepafin')}
|
||||
</p>
|
||||
{mode === 'clone' && (
|
||||
<div style={{ marginTop: '0.5rem' }} onClick={(e) => e.stopPropagation()}>
|
||||
{cloneOptions.length === 0 ? (
|
||||
<Message severity="warn"
|
||||
text={__('Nessun bando con schema configurato da cui clonare.','gepafin')}
|
||||
style={{ width: '100%' }} />
|
||||
) : (
|
||||
<Dropdown
|
||||
value={pickedSourceCallId}
|
||||
onChange={(e) => setPickedSourceCallId(e.value)}
|
||||
options={cloneOptions}
|
||||
placeholder={__('Scegli un bando sorgente...','gepafin')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<Button
|
||||
label={__('Inizia','gepafin')}
|
||||
icon="pi pi-arrow-right"
|
||||
iconPos="right"
|
||||
disabled={!canSubmit() || saving}
|
||||
loading={saving}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchemaTemplatePicker;
|
||||
@@ -0,0 +1,752 @@
|
||||
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 SchemaTemplatePicker from '../components/SchemaTemplatePicker';
|
||||
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,
|
||||
// v2 multi-tranche + custom_checks
|
||||
max_tranches: gate.max_tranches ?? 1,
|
||||
custom_checks: (j.custom_checks || []).map(cc => ({
|
||||
code: cc.code || '',
|
||||
label: cc.label || '',
|
||||
description: cc.description || '',
|
||||
requires_document: !!cc.requires_document,
|
||||
required: !!cc.required,
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
max_tranches: f.max_tranches || 1
|
||||
},
|
||||
custom_checks: (f.custom_checks || []).map(cc => ({
|
||||
code: cc.code,
|
||||
label: cc.label,
|
||||
description: cc.description,
|
||||
requires_document: !!cc.requires_document,
|
||||
required: !!cc.required,
|
||||
})),
|
||||
schema_version: 2
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// v2 custom_checks
|
||||
const updateCheck = (idx, patch) => {
|
||||
setForm(p => ({ ...p, custom_checks: p.custom_checks.map((c,i) => i===idx ? {...c, ...patch} : c) }));
|
||||
setDirty(true);
|
||||
};
|
||||
const addCheck = () => {
|
||||
setForm(p => ({ ...p, custom_checks: [...(p.custom_checks || []), { code:'', label:'', description:'', requires_document:false, required:false }] }));
|
||||
setDirty(true);
|
||||
};
|
||||
const removeCheck = (idx) => {
|
||||
setForm(p => ({ ...p, custom_checks: p.custom_checks.filter((_,i) => i!==idx) }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// ---------- actions ----------
|
||||
const handleInitializeRestart = (e) => {
|
||||
confirmPopup({
|
||||
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">
|
||||
<SchemaTemplatePicker
|
||||
callId={callId}
|
||||
onInitialized={(data) => {
|
||||
setSchemaRecord(data);
|
||||
setForm(schemaJsonToForm(data.schema_json));
|
||||
setDirty(false);
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: __('Schema inizializzato', 'gepafin'),
|
||||
detail: __('Puoi ora configurare le sezioni e salvare come bozza.', 'gepafin')
|
||||
});
|
||||
}}
|
||||
onError={(err) => toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: __('Inizializzazione fallita', 'gepafin'),
|
||||
detail: err?.detail || err?.message
|
||||
})}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 7 - TRANCHES + CUSTOM CHECKS (v2) */}
|
||||
<div className="appPageSection">
|
||||
<h2>{__('7. Tranches di rendicontazione','gepafin')}</h2>
|
||||
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
|
||||
{__('Numero massimo di tranche che il beneficiario puo aprire per questo bando. Il default 1 mantiene il comportamento classico a rendicontazione unica. Aumenta il numero per permettere rendicontazioni multi-fase (es. stati di avanzamento).','gepafin')}
|
||||
</p>
|
||||
<div className="appForm__field" style={{maxWidth:'300px'}}>
|
||||
<label>{__('Tranches massime','gepafin')}</label>
|
||||
<InputNumber value={form.max_tranches}
|
||||
onValueChange={(e) => update({max_tranches: e.value})}
|
||||
min={1} max={20} showButtons disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
<div className="appPageSection">
|
||||
<h2>{__('8. Controlli aggiuntivi (dichiarazioni beneficiario)','gepafin')} <span style={{fontWeight:400, color:'var(--text-color-secondary)', fontSize:'0.9em'}}>({(form.custom_checks || []).length})</span></h2>
|
||||
<p style={{ color:'var(--text-color-secondary)', marginTop: 0 }}>
|
||||
{__('Dichiarazioni aggiuntive richieste al beneficiario, oltre ai documenti standard. Ogni controllo puo richiedere o meno un documento allegato e puo essere obbligatorio o opzionale. Esempi: dichiarazione antiriciclaggio (senza doc, obbligatoria), polizza fidejussoria (con doc, opzionale).','gepafin')}
|
||||
</p>
|
||||
<div className="fieldsRepeater">
|
||||
{(form.custom_checks || []).map((c, i) => (
|
||||
<div key={i} className="fieldsRepeater__panel" style={{ padding:'1rem', border:'1px solid var(--surface-border)', borderRadius:'6px', background:'var(--surface-50)' }}>
|
||||
<div className="fieldsRepeater__heading" style={{ marginBottom:'0.5rem' }}>
|
||||
<strong style={{ color:'var(--primary-color)' }}>{c.code || `check #${i+1}`} — {c.label || __('(senza etichetta)','gepafin')}</strong>
|
||||
{!readOnly && (
|
||||
<Button type="button" icon="pi pi-trash" severity="danger" outlined
|
||||
size="small" onClick={() => removeCheck(i)}
|
||||
tooltip={__('Rimuovi controllo','gepafin')} tooltipOptions={{position:'top'}} />
|
||||
)}
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<label>{__('Codice (snake_case)','gepafin')}</label>
|
||||
<InputText value={c.code}
|
||||
onChange={(e) => updateCheck(i,{code:e.target.value.toLowerCase().replace(/[^a-z0-9_]/g,'_')})}
|
||||
placeholder="antiriciclaggio" disabled={readOnly} />
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Etichetta visibile','gepafin')}</label>
|
||||
<InputText value={c.label}
|
||||
onChange={(e) => updateCheck(i,{label:e.target.value})}
|
||||
placeholder={__('Dichiarazione antiriciclaggio','gepafin')} disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Descrizione (testo mostrato al beneficiario)','gepafin')}</label>
|
||||
<InputTextarea value={c.description}
|
||||
onChange={(e) => updateCheck(i,{description:e.target.value})}
|
||||
rows={3} autoResize disabled={readOnly}
|
||||
placeholder={__('Dichiaro che il beneficiario rispetta...','gepafin')} />
|
||||
</div>
|
||||
<div className="appForm__cols">
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={c.requires_document}
|
||||
onChange={(e) => updateCheck(i,{requires_document:e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && updateCheck(i,{requires_document: !c.requires_document})}>
|
||||
{__('Richiede documento allegato','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{__("Se attivo, il beneficiario puo allegare un PDF (max 15MB).",'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="appForm__field">
|
||||
<div className="appForm__row">
|
||||
<InputSwitch checked={c.required}
|
||||
onChange={(e) => updateCheck(i,{required:e.value})} disabled={readOnly} />
|
||||
<label style={{ cursor: 'pointer' }}
|
||||
onClick={() => !readOnly && updateCheck(i,{required: !c.required})}>
|
||||
{__('Obbligatorio','gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{__("Se attivo, il beneficiario deve dichiararlo prima di poter inviare la pratica.",'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Button type="button" icon="pi pi-plus" iconPos="right" outlined
|
||||
label={__('Aggiungi controllo aggiuntivo','gepafin')} onClick={addCheck} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* ACTIONS BOTTOM (copia degli action top per comodità) */}
|
||||
{!isPublished && (
|
||||
<div className="appPageSection">
|
||||
<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;
|
||||
1527
src/modules/rendicontazione/pages/IstruttoriaPratica.js
Normal file
1527
src/modules/rendicontazione/pages/IstruttoriaPratica.js
Normal file
File diff suppressed because it is too large
Load Diff
336
src/modules/rendicontazione/pages/IstruttoriaQueue.js
Normal file
336
src/modules/rendicontazione/pages/IstruttoriaQueue.js
Normal file
@@ -0,0 +1,336 @@
|
||||
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 { Dialog } from 'primereact/dialog';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
import { storeGet } from '../../../store';
|
||||
|
||||
const STATUS_TAGS = {
|
||||
SUBMITTED: { severity: 'info', label: 'Da prendere in carico' },
|
||||
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 [isManagerFromQueue, setIsManagerFromQueue] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// v2 manager view
|
||||
// Default: manager/superadmin partono in vista 'tutte le pratiche' (managerMode=true).
|
||||
// Pre-instructor vedono solo la coda attiva (managerMode=false).
|
||||
const [managerMode, setManagerMode] = useState(
|
||||
storeGet('getRole') === 'ROLE_INSTRUCTOR_MANAGER' ||
|
||||
storeGet('getRole') === 'ROLE_SUPER_ADMIN'
|
||||
); // toggle UI
|
||||
const [managerItems, setManagerItems] = useState([]);
|
||||
const [instructors, setInstructors] = useState([]);
|
||||
const [reassignDialog, setReassignDialog] = useState({ visible: false, practice: null, newInstructorId: null, reason: '' });
|
||||
const [reassigning, setReassigning] = useState(false);
|
||||
|
||||
// Controllo ruolo utente per mostrare toggle manager.
|
||||
// storeGet('getRole') ritorna userData.role.roleType (es. ROLE_INSTRUCTOR_MANAGER).
|
||||
// Fix: il codice precedente usava storeGet('getUser') che non esiste nel selectors,
|
||||
// quindi canUseManagerView era sempre false e il toggle non compariva mai.
|
||||
const userRole = storeGet('getRole');
|
||||
const canUseManagerView = userRole === 'ROLE_INSTRUCTOR_MANAGER' || userRole === 'ROLE_SUPER_ADMIN';
|
||||
|
||||
const loadQueue = () => {
|
||||
setLoading(true);
|
||||
RendicontazioneService.instructorQueue(
|
||||
(resp) => {
|
||||
setItems(resp?.data?.items || []);
|
||||
setIsManagerFromQueue(!!resp?.data?.manager_view);
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail });
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const loadManagerAssignments = () => {
|
||||
setLoading(true);
|
||||
RendicontazioneService.managerAssignments(
|
||||
(resp) => {
|
||||
setManagerItems(resp?.data?.assignments || []);
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail });
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const loadInstructors = () => {
|
||||
RendicontazioneService.managerInstructorsList(
|
||||
(resp) => setInstructors(resp?.data?.instructors || []),
|
||||
() => {}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (managerMode) {
|
||||
loadManagerAssignments();
|
||||
if (instructors.length === 0) loadInstructors();
|
||||
} else {
|
||||
loadQueue();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [managerMode]);
|
||||
|
||||
const openReassign = (row) => {
|
||||
if (instructors.length === 0) loadInstructors();
|
||||
setReassignDialog({
|
||||
visible: true,
|
||||
practice: row,
|
||||
newInstructorId: row.assigned_instructor_id || null,
|
||||
reason: ''
|
||||
});
|
||||
};
|
||||
|
||||
const confirmReassign = () => {
|
||||
const { practice, newInstructorId, reason } = reassignDialog;
|
||||
setReassigning(true);
|
||||
RendicontazioneService.reassignInstructor(
|
||||
practice.id, newInstructorId, reason,
|
||||
(resp) => {
|
||||
setReassigning(false);
|
||||
setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' });
|
||||
toast.current?.show({ severity: 'success', summary: resp?.message || __('Pratica riassegnata', 'gepafin') });
|
||||
loadManagerAssignments();
|
||||
},
|
||||
(err) => {
|
||||
setReassigning(false);
|
||||
toast.current?.show({ severity: 'error', summary: __('Riassegnazione fallita', 'gepafin'), detail: err?.detail });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- templates ----------
|
||||
const callTpl = (row) => (
|
||||
<div>
|
||||
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
|
||||
<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>;
|
||||
};
|
||||
|
||||
// Manager view templates
|
||||
const mgrCallTpl = (row) => (
|
||||
<div>
|
||||
<strong>{row.call_name || `Bando #${row.call_id}`}</strong>
|
||||
<div><small className="text-color-secondary">{row.company_name} · pratica #{row.application_id} · <strong>T{row.sequence_number}</strong>{row.period_label ? ` — ${row.period_label}` : ''}</small></div>
|
||||
</div>
|
||||
);
|
||||
const mgrSuggestedTpl = (row) => (
|
||||
row.suggested_instructor_id
|
||||
? <div><strong>{row.suggested_instructor_name || `#${row.suggested_instructor_id}`}</strong></div>
|
||||
: <span className="text-color-secondary">{__('nessuno', 'gepafin')}</span>
|
||||
);
|
||||
const mgrAssignedTpl = (row) => {
|
||||
if (row.is_unassigned) {
|
||||
return <Tag severity="warning" value={__('Da assegnare', 'gepafin')} icon="pi pi-exclamation-triangle" />;
|
||||
}
|
||||
return <div><strong>{row.assigned_instructor_name || `#${row.assigned_instructor_id}`}</strong></div>;
|
||||
};
|
||||
const mgrActionsTpl = (row) => (
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
<Button icon="pi pi-eye" size="small" outlined
|
||||
label={__('Apri', 'gepafin')}
|
||||
onClick={() => navigate(`/istruttoria/${row.id}`)} />
|
||||
<Button icon="pi pi-user-edit" size="small" severity="warning"
|
||||
label={row.is_unassigned ? __('Assegna', 'gepafin') : __('Riassegna', 'gepafin')}
|
||||
onClick={() => openReassign(row)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="appPage">
|
||||
<Toast ref={toast} />
|
||||
|
||||
<div className="appPage__pageHeader">
|
||||
<h1>{__('Coda istruttoria', 'gepafin')}</h1>
|
||||
<p>
|
||||
{managerMode
|
||||
? __('Vista manager: tutte le pratiche inviate con istruttore suggerito e assegnato. Puoi riassegnare le pratiche da qui.', 'gepafin')
|
||||
: (isManagerFromQueue
|
||||
? __('Vista manager: vedi tutte le pratiche in carico a tutti gli istruttori.', 'gepafin')
|
||||
: __('Pool di pratiche da prendere in carico + pratiche assegnate a te.', 'gepafin'))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* TOGGLE MANAGER VIEW */}
|
||||
{canUseManagerView && (
|
||||
<div className="appPageSection">
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<Button icon="pi pi-list"
|
||||
label={__('Coda standard', 'gepafin')}
|
||||
outlined={managerMode}
|
||||
severity={managerMode ? 'secondary' : 'info'}
|
||||
onClick={() => setManagerMode(false)} />
|
||||
<Button icon="pi pi-users"
|
||||
label={__('Vista manager (riassegnazioni)', 'gepafin')}
|
||||
outlined={!managerMode}
|
||||
severity={!managerMode ? 'secondary' : 'warning'}
|
||||
onClick={() => setManagerMode(true)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{/* CODA STANDARD */}
|
||||
{!managerMode && (
|
||||
<div className="appPageSection">
|
||||
{loading && <Skeleton width="100%" height="10rem" />}
|
||||
{!loading && items.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* VISTA MANAGER */}
|
||||
{managerMode && (
|
||||
<div className="appPageSection">
|
||||
{loading && <Skeleton width="100%" height="10rem" />}
|
||||
{!loading && managerItems.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', width: '100%' }}>
|
||||
<i className="pi pi-check-circle" style={{ fontSize: '2.5rem', color: 'var(--green-500)', display: 'block', marginBottom: '0.75rem' }} />
|
||||
<p>{__('Nessuna pratica attiva da gestire.', 'gepafin')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && managerItems.length > 0 && (
|
||||
<DataTable value={managerItems} dataKey="id" stripedRows responsiveLayout="scroll" style={{ width: '100%' }}>
|
||||
<Column header={__('Bando / Pratica / Tranche', 'gepafin')} body={mgrCallTpl} />
|
||||
<Column header={__('Inviata il', 'gepafin')} body={submittedTpl} style={{ width: '140px' }} />
|
||||
<Column header={__('Stato', 'gepafin')} body={statusTpl} style={{ width: '170px' }} />
|
||||
<Column header={__('Istruttore domanda', 'gepafin')} body={mgrSuggestedTpl} style={{ width: '180px' }} />
|
||||
<Column header={__('Assegnato a', 'gepafin')} body={mgrAssignedTpl} style={{ width: '200px' }} />
|
||||
<Column header={__('Erogato', 'gepafin')} body={erogatoTpl} style={{ width: '120px' }} />
|
||||
<Column header={__('Azioni', 'gepafin')} body={mgrActionsTpl} style={{ width: '260px' }} />
|
||||
</DataTable>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DIALOG RIASSEGNA */}
|
||||
<Dialog header={__('Riassegna pratica', 'gepafin')}
|
||||
visible={reassignDialog.visible} style={{ width: '520px' }}
|
||||
onHide={() => !reassigning && setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' })}
|
||||
modal
|
||||
footer={(
|
||||
<div>
|
||||
<Button label={__('Annulla', 'gepafin')} icon="pi pi-times"
|
||||
onClick={() => setReassignDialog({ visible: false, practice: null, newInstructorId: null, reason: '' })}
|
||||
outlined disabled={reassigning} />
|
||||
<Button label={__('Conferma', 'gepafin')} icon="pi pi-check" iconPos="right"
|
||||
severity="warning" loading={reassigning} onClick={confirmReassign} />
|
||||
</div>
|
||||
)}>
|
||||
{reassignDialog.practice && (
|
||||
<div>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
<strong>{reassignDialog.practice.call_name}</strong> — pratica #{reassignDialog.practice.application_id}
|
||||
{' '}T{reassignDialog.practice.sequence_number}
|
||||
{reassignDialog.practice.period_label && ` — ${reassignDialog.practice.period_label}`}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-color-secondary)' }}>
|
||||
{__('Istruttore domanda', 'gepafin')}: <strong>{reassignDialog.practice.suggested_instructor_name || __('nessuno', 'gepafin')}</strong>
|
||||
<br />
|
||||
{__('Attualmente assegnato a', 'gepafin')}: <strong>{reassignDialog.practice.assigned_instructor_name || __('nessuno', 'gepafin')}</strong>
|
||||
</p>
|
||||
<div className="appForm__field">
|
||||
<label>{__('Nuovo istruttore', 'gepafin')}</label>
|
||||
<Dropdown value={reassignDialog.newInstructorId}
|
||||
options={[
|
||||
{ user_id: null, display_name: __('— Metti in coda (nessuno) —', 'gepafin') },
|
||||
...instructors
|
||||
]}
|
||||
optionLabel="display_name" optionValue="user_id"
|
||||
onChange={(e) => setReassignDialog(d => ({ ...d, newInstructorId: e.value }))}
|
||||
disabled={reassigning}
|
||||
placeholder={__('Seleziona istruttore', 'gepafin')} />
|
||||
<small className="text-color-secondary">
|
||||
{__('Se la pratica era in SUBMITTED e assegni a qualcuno, passa automaticamente a IN LAVORAZIONE.', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="appForm__field" style={{ marginTop: '1rem' }}>
|
||||
<label>{__('Motivazione (opzionale, audit log)', 'gepafin')}</label>
|
||||
<InputTextarea value={reassignDialog.reason} rows={3} autoResize
|
||||
onChange={(e) => setReassignDialog(d => ({ ...d, reason: e.target.value }))}
|
||||
placeholder={__('Es: carico di lavoro, competenza specifica, assenza istruttore...', 'gepafin')}
|
||||
disabled={reassigning} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IstruttoriaQueue;
|
||||
1091
src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js
Normal file
1091
src/modules/rendicontazione/pages/PraticaRendicontazioneEdit.js
Normal file
File diff suppressed because it is too large
Load Diff
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;
|
||||
481
src/modules/rendicontazione/pages/RendicontazioniMie.js
Normal file
481
src/modules/rendicontazione/pages/RendicontazioniMie.js
Normal file
@@ -0,0 +1,481 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Divider } from 'primereact/divider';
|
||||
|
||||
import RendicontazioneService from '../service/rendicontazioneService';
|
||||
|
||||
const STATUS_TAGS = {
|
||||
DRAFT: { severity: 'warning', label: 'In compilazione', icon: 'pi pi-pencil' },
|
||||
SUBMITTED: { severity: 'info', label: 'Inviata', icon: 'pi pi-send' },
|
||||
UNDER_REVIEW: { severity: 'info', label: 'In valutazione', icon: 'pi pi-eye' },
|
||||
APPROVED: { severity: 'success', label: 'Approvata', icon: 'pi pi-check-circle' },
|
||||
REJECTED: { severity: 'danger', label: 'Respinta', icon: 'pi pi-times-circle' },
|
||||
AWAITING_AMENDMENT: { severity: 'warning', label: 'Soccorso istruttorio',icon: 'pi pi-exclamation-triangle' }
|
||||
};
|
||||
|
||||
const fmtEur = (v) => {
|
||||
const n = Number(v || 0);
|
||||
return `€ ${n.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
// -------- Sottocomponenti di presentazione --------
|
||||
|
||||
const StatTile = ({ label, value, accent = 'var(--text-color)', muted = false }) => (
|
||||
<div style={{
|
||||
flex: '1 1 180px',
|
||||
minWidth: '160px',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'white',
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
letterSpacing: '0.02em',
|
||||
color: 'var(--text-color-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '0.25rem',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '1.15rem',
|
||||
fontWeight: 700,
|
||||
color: muted ? 'var(--text-color-secondary)' : accent,
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TrancheRow = ({ tranche, onOpen, isLast }) => {
|
||||
const tag = STATUS_TAGS[tranche.status] || { severity: 'secondary', label: tranche.status, icon: 'pi pi-circle' };
|
||||
const isEditable = tranche.status === 'DRAFT' || tranche.status === 'AWAITING_AMENDMENT';
|
||||
const hasContent = (tranche.invoice_count || tranche.ula_count || tranche.document_count);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '0.85rem 0',
|
||||
borderBottom: isLast ? 'none' : '1px solid var(--surface-border)',
|
||||
}}>
|
||||
{/* Icona circolare con numero tranche */}
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--primary-color)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem',
|
||||
}}>
|
||||
T{tranche.sequence_number}
|
||||
</div>
|
||||
|
||||
{/* Descrizione tranche */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '2px' }}>
|
||||
{tranche.period_label || <span style={{ color: 'var(--text-color-secondary)', fontWeight: 400, fontStyle: 'italic' }}>{__('Nessun periodo indicato','gepafin')}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-color-secondary)' }}>
|
||||
{hasContent ? (
|
||||
<>
|
||||
<i className="pi pi-file" style={{ marginRight: '4px', fontSize: '0.8em' }} />
|
||||
{tranche.invoice_count || 0} {__('fatture','gepafin')}
|
||||
<span style={{ margin: '0 6px' }}>·</span>
|
||||
<i className="pi pi-users" style={{ marginRight: '4px', fontSize: '0.8em' }} />
|
||||
{tranche.ula_count || 0} {__('dipendenti','gepafin')}
|
||||
<span style={{ margin: '0 6px' }}>·</span>
|
||||
<i className="pi pi-paperclip" style={{ marginRight: '4px', fontSize: '0.8em' }} />
|
||||
{tranche.document_count || 0} {__('documenti','gepafin')}
|
||||
</>
|
||||
) : (
|
||||
<em>{__('Nessun contenuto ancora inserito','gepafin')}</em>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stato + CTA */}
|
||||
<Tag icon={tag.icon} value={tag.label} severity={tag.severity} />
|
||||
<Button
|
||||
icon={isEditable ? 'pi pi-pencil' : 'pi pi-eye'}
|
||||
size="small"
|
||||
outlined={!isEditable}
|
||||
severity={isEditable ? null : 'secondary'}
|
||||
label={isEditable ? __('Continua','gepafin') : __('Apri','gepafin')}
|
||||
onClick={() => onOpen(tranche.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------- Componente principale --------
|
||||
|
||||
const RendicontazioniMie = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useRef(null);
|
||||
const [apps, setApps] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [startDialog, setStartDialog] = useState(null);
|
||||
const [startForm, setStartForm] = useState({ period_label: '', copy_ula: true });
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
RendicontazioneService.listMine(
|
||||
(resp) => {
|
||||
setApps(resp?.data?.applications || []);
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail });
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
|
||||
|
||||
const openStartDialog = (app) => {
|
||||
const nextSeq = (app.tranches?.length || 0) + 1;
|
||||
setStartDialog({
|
||||
application_id: app.application_id,
|
||||
call_name: app.call_name,
|
||||
max_tranches: app.max_tranches,
|
||||
next_seq: nextSeq,
|
||||
show_copy_ula: nextSeq > 1,
|
||||
max_remission_next: app.max_remission_next_tranche,
|
||||
});
|
||||
setStartForm({ period_label: '', copy_ula: nextSeq > 1 });
|
||||
};
|
||||
|
||||
const confirmStart = () => {
|
||||
if (!startDialog) return;
|
||||
setStarting(true);
|
||||
RendicontazioneService.startPractice(
|
||||
startDialog.application_id,
|
||||
(resp) => {
|
||||
setStarting(false);
|
||||
setStartDialog(null);
|
||||
toast.current?.show({ severity: 'success', summary: resp?.message || __('Tranche avviata', 'gepafin') });
|
||||
navigate(`/rendicontazioni/${resp.data.id}`);
|
||||
},
|
||||
(err) => {
|
||||
setStarting(false);
|
||||
toast.current?.show({ severity: 'error', summary: __('Avvio fallito', 'gepafin'), detail: err?.detail });
|
||||
},
|
||||
{
|
||||
period_label: startForm.period_label?.trim() || null,
|
||||
copy_ula_from_previous: startForm.copy_ula,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderApplicationCard = (app) => {
|
||||
const tranchesCount = app.tranches?.length || 0;
|
||||
const hasTranches = tranchesCount > 0;
|
||||
const nextSeq = tranchesCount + 1;
|
||||
const canStart = !!app.can_start_new;
|
||||
const blockReason = app.start_blocked_reason;
|
||||
const progressPct = app.max_remission_global > 0
|
||||
? Math.min(100, (app.already_approved_sum / app.max_remission_global) * 100)
|
||||
: 0;
|
||||
|
||||
const headerTemplate = (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '1rem',
|
||||
padding: '1.25rem 1.5rem 0.75rem',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{ margin: 0, color: 'var(--primary-color)', fontSize: '1.2rem' }}>
|
||||
{app.call_name || `Bando #${app.call_id}`}
|
||||
</h3>
|
||||
<div style={{ color: 'var(--text-color-secondary)', fontSize: '0.85rem', marginTop: '4px' }}>
|
||||
<i className="pi pi-building" style={{ marginRight: '4px' }} />
|
||||
{app.company_name || '—'}
|
||||
<span style={{ margin: '0 6px' }}>·</span>
|
||||
{__('Domanda', 'gepafin')} <strong>#{app.application_id}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-color-secondary)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>
|
||||
{__('Finanziamento erogato','gepafin')}
|
||||
</div>
|
||||
<div style={{ fontSize: '1.4rem', fontWeight: 700, lineHeight: 1.1 }}>
|
||||
{fmtEur(app.amount_erogato)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={app.application_id}
|
||||
header={headerTemplate}
|
||||
style={{ marginBottom: '1.5rem', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
|
||||
{/* BLOCCO TOTALI — 4 stat tile affiancati */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
padding: '0.25rem 0.25rem 1rem',
|
||||
}}>
|
||||
<StatTile
|
||||
label={__('Cap remissione totale', 'gepafin')}
|
||||
value={fmtEur(app.max_remission_global)}
|
||||
accent="#475569"
|
||||
/>
|
||||
<StatTile
|
||||
label={__('Già approvato', 'gepafin')}
|
||||
value={fmtEur(app.already_approved_sum)}
|
||||
accent="#16a34a"
|
||||
muted={!app.already_approved_sum}
|
||||
/>
|
||||
<StatTile
|
||||
label={__('Disponibile prossima tranche', 'gepafin')}
|
||||
value={fmtEur(app.max_remission_next_tranche)}
|
||||
accent="var(--primary-color)"
|
||||
/>
|
||||
<StatTile
|
||||
label={__('Tranches', 'gepafin')}
|
||||
value={`${tranchesCount} / ${app.max_tranches}`}
|
||||
accent="#64748b"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PROGRESS BAR utilizzo cap */}
|
||||
{app.max_remission_global > 0 && (
|
||||
<div style={{ padding: '0 0.25rem 0.75rem' }}>
|
||||
<div style={{
|
||||
height: '6px',
|
||||
background: 'var(--surface-200)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${progressPct}%`,
|
||||
background: progressPct >= 100 ? 'var(--red-500)' : '#16a34a',
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--text-color-secondary)',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{progressPct.toFixed(1)}% {__('del cap già utilizzato','gepafin')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TRANCHES */}
|
||||
{hasTranches ? (
|
||||
<div style={{ padding: '0.5rem 0.25rem 0' }}>
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--text-color-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.02em',
|
||||
marginBottom: '0.25rem',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{__('Elenco tranche','gepafin')}
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid var(--surface-border)' }}>
|
||||
{app.tranches.map((t, idx) => (
|
||||
<TrancheRow
|
||||
key={t.id}
|
||||
tranche={t}
|
||||
onOpen={(id) => navigate(`/rendicontazioni/${id}`)}
|
||||
isLast={idx === app.tranches.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '1.5rem',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-color-secondary)',
|
||||
background: 'var(--surface-50)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9rem',
|
||||
}}>
|
||||
<i className="pi pi-info-circle" style={{ marginRight: '6px' }} />
|
||||
{__('Non hai ancora avviato nessuna tranche di rendicontazione per questo bando.','gepafin')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FOOTER — bottone nuova tranche */}
|
||||
<Divider style={{ margin: '1rem 0 0.75rem' }} />
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{!canStart && blockReason ? (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-color-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<i className="pi pi-info-circle" />
|
||||
<em>{blockReason}</em>
|
||||
</div>
|
||||
) : <span />}
|
||||
|
||||
<Button
|
||||
icon="pi pi-plus-circle"
|
||||
iconPos="left"
|
||||
label={hasTranches
|
||||
? `${__('Nuova tranche','gepafin')} (T${nextSeq})`
|
||||
: __('Avvia rendicontazione','gepafin')}
|
||||
severity={canStart ? 'success' : null}
|
||||
disabled={!canStart}
|
||||
outlined={!canStart}
|
||||
tooltip={!canStart ? blockReason : undefined}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
onClick={() => openStartDialog(app)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
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. I bandi che prevedono piu tranches permettono rendicontazioni multi-fase.', 'gepafin')}</p>
|
||||
</div>
|
||||
|
||||
<div className="appPage__spacer"></div>
|
||||
|
||||
{loading && (
|
||||
<div>
|
||||
<Skeleton width="100%" height="14rem" style={{ marginBottom: '1rem' }} />
|
||||
<Skeleton width="100%" height="14rem" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && apps.length === 0 && (
|
||||
<Card>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '2.5rem 1.5rem',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<i className="pi pi-inbox" style={{ fontSize: '2.5rem', color: 'var(--text-color-secondary)', marginBottom: '0.75rem' }} />
|
||||
<p style={{ fontSize: '1rem', margin: 0 }}>{__('Non ci sono rendicontazioni disponibili al momento.', 'gepafin')}</p>
|
||||
<small className="text-color-secondary" style={{ marginTop: '0.5rem', maxWidth: '28rem' }}>
|
||||
{__('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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && apps.length > 0 && apps.map(renderApplicationCard)}
|
||||
|
||||
{/* START DIALOG */}
|
||||
<Dialog
|
||||
header={__('Avvia nuova tranche di rendicontazione', 'gepafin')}
|
||||
visible={!!startDialog}
|
||||
style={{ width: '32rem' }}
|
||||
onHide={() => !starting && setStartDialog(null)}
|
||||
modal
|
||||
footer={(
|
||||
<div>
|
||||
<Button label={__('Annulla', 'gepafin')} icon="pi pi-times"
|
||||
onClick={() => setStartDialog(null)} outlined disabled={starting} />
|
||||
<Button label={__('Avvia tranche', 'gepafin')} icon="pi pi-play" iconPos="right"
|
||||
severity="success" loading={starting} onClick={confirmStart} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{startDialog && (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--surface-50)',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
{__('Stai per avviare la tranche', 'gepafin')}
|
||||
{' '}<strong>T{startDialog.next_seq}</strong> / {startDialog.max_tranches}
|
||||
{' '}{__('del bando', 'gepafin')}
|
||||
</div>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 700, marginTop: '4px', color: 'var(--primary-color)' }}>
|
||||
{startDialog.call_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-color-secondary)', marginTop: '6px' }}>
|
||||
{__('Cap remissione disponibile', 'gepafin')}:
|
||||
{' '}<strong style={{ color: '#16a34a' }}>{fmtEur(startDialog.max_remission_next)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appForm__field">
|
||||
<label>{__('Periodo / fase (opzionale)', 'gepafin')}</label>
|
||||
<InputText value={startForm.period_label}
|
||||
onChange={(e) => setStartForm(f => ({ ...f, period_label: e.target.value }))}
|
||||
placeholder={__('es. "I trimestre 2021", "Stato avanzamento II"', 'gepafin')}
|
||||
disabled={starting} />
|
||||
<small className="text-color-secondary">
|
||||
{__('Descrizione libera per identificare la tranche. Apparirà sul verbale.', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{startDialog.show_copy_ula && (
|
||||
<div className="appForm__field" style={{ marginTop: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<Checkbox inputId="copy_ula" checked={startForm.copy_ula}
|
||||
onChange={(e) => setStartForm(f => ({ ...f, copy_ula: e.checked }))}
|
||||
disabled={starting} />
|
||||
<label htmlFor="copy_ula" style={{ cursor: 'pointer', margin: 0 }}>
|
||||
{__('Copia i dipendenti ULA dalla tranche precedente', 'gepafin')}
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{__('Se attivo, i dipendenti censiti nella tranche precedente saranno precaricati. Potrai modificarli o rimuoverli prima di inviare.', 'gepafin')}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RendicontazioniMie;
|
||||
601
src/modules/rendicontazione/service/rendicontazioneService.js
Normal file
601
src/modules/rendicontazione/service/rendicontazioneService.js
Normal file
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// =========================================================================
|
||||
// v2.1 — Picker schema (blank / template / clone)
|
||||
// =========================================================================
|
||||
|
||||
export const schemaPickerService = {
|
||||
listTemplates(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/templates`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
listClonableCalls(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/clonable-calls`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
initializeSchema(callId, payload, onSuccess, onError) {
|
||||
// payload = { source: "blank"|"template"|"clone", template_id?, source_call_id? }
|
||||
fetch(`${BASE_URL}/api/rendicontazione-schemas/${callId}/initialize`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(r => handleResponse(r, onSuccess, onError))
|
||||
.catch(e => handleError(e, onError));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ====================== 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, opts = {}) {
|
||||
// opts: { period_label?: string, copy_ula_from_previous?: bool }
|
||||
fetch(`${BASE_URL}/api/remission-practices/start`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
application_id: applicationId,
|
||||
period_label: opts.period_label ?? null,
|
||||
copy_ula_from_previous: opts.copy_ula_from_previous !== false,
|
||||
})
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
copyUlaOptions(practiceId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/copy-ula-options`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
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));
|
||||
},
|
||||
|
||||
// ====== AMENDMENT v3: DRAFT lifecycle + extend + reminder + uploads ======
|
||||
|
||||
updateAmendment(practiceId, amendmentId, body, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deleteAmendment(practiceId, amendmentId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
sendAmendment(practiceId, amendmentId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/send`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
extendAmendment(practiceId, amendmentId, extendedDays, motivation, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/extend`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ extended_days: extendedDays, motivation })
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
sendAmendmentReminder(practiceId, amendmentId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/reminder`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
uploadAmendmentDocument(practiceId, amendmentId, file, onSuccess, onError) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-document`, {
|
||||
method: 'POST', mode: 'cors',
|
||||
headers: _buildBearerOnly(),
|
||||
body: fd
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deleteAmendmentDocument(practiceId, amendmentId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-document`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
uploadResponseDocument(practiceId, amendmentId, file, onSuccess, onError) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-response-document`, {
|
||||
method: 'POST', mode: 'cors',
|
||||
headers: _buildBearerOnly(),
|
||||
body: fd
|
||||
}).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));
|
||||
},
|
||||
|
||||
/**
|
||||
* Collega un remission_document a un company_document del repository Gepafin.
|
||||
* Alternativa all upload dal PC: copia filename/expires_at/storage_path dal sorgente
|
||||
* e traccia source_company_document_id per lookup live dello status (VALID/DUE/EXPIRED).
|
||||
* Usato solo su entityType='document'.
|
||||
*/
|
||||
linkDocumentFromRepository(remissionDocumentId, companyDocumentId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-files/document/${remissionDocumentId}/link-from-repository`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({ company_document_id: companyDocumentId })
|
||||
}).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);
|
||||
|
||||
// ====================== v2 CUSTOM CHECKS ======================
|
||||
const extendCustomChecks = {
|
||||
listCustomChecks(practiceId, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
declareCustomCheck(practiceId, code, declared, file, onSuccess, onError) {
|
||||
const fd = new FormData();
|
||||
fd.append('beneficiary_declared', declared ? 'true' : 'false');
|
||||
if (file) fd.append('file', file);
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/declare`, {
|
||||
method: 'PUT', mode: 'cors',
|
||||
headers: _buildBearerOnly(), // no Content-Type: boundary auto
|
||||
body: fd
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
deleteCustomCheckDocument(practiceId, code, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/document`, {
|
||||
method: 'DELETE', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
verifyCustomCheck(practiceId, code, body, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/verify`, {
|
||||
method: 'PUT', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
fetchCustomCheckDocumentBlob(practiceId, code, inline, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/${practiceId}/custom-checks/${code}/document?inline=${inline ? 1 : 0}`, {
|
||||
method: 'GET', mode: 'cors', headers: _buildBearerOnly()
|
||||
}).then(async r => {
|
||||
if (r.status < 200 || r.status >= 300) {
|
||||
let detail = r.statusText;
|
||||
try { const j = await r.json(); detail = j.detail || detail; } catch(e){}
|
||||
if (onError) onError({ status: r.status, detail });
|
||||
return;
|
||||
}
|
||||
let filename = 'file';
|
||||
const cd = r.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
if (m) filename = m[1];
|
||||
const blob = await r.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
if (onSuccess) onSuccess({ blob, objectUrl, filename });
|
||||
}).catch(e => handleError(e, onError));
|
||||
}
|
||||
};
|
||||
Object.assign(RendicontazioneService, extendCustomChecks);
|
||||
|
||||
|
||||
// ====================== v2 MANAGER ISTRUTTORE ======================
|
||||
const extendAssignmentManager = {
|
||||
managerAssignments(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor-manager/assignments`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
managerInstructorsList(onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor-manager/instructors`, {
|
||||
method: 'GET', mode: 'cors', headers: buildHeaders()
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
},
|
||||
|
||||
reassignInstructor(practiceId, newInstructorId, reason, onSuccess, onError) {
|
||||
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/reassign`, {
|
||||
method: 'POST', mode: 'cors', headers: buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
new_instructor_id: newInstructorId,
|
||||
reassignment_reason: reason || null,
|
||||
})
|
||||
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
|
||||
}
|
||||
};
|
||||
Object.assign(RendicontazioneService, extendAssignmentManager);
|
||||
@@ -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,17 @@ 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 Ar1Home from './modules/ar1/pages/Ar1Home';
|
||||
import Ar1Wizard from './modules/ar1/pages/Ar1Wizard';
|
||||
import Ar1Signature from './modules/ar1/pages/Ar1Signature';
|
||||
import Ar1AdminConfig from './modules/ar1/pages/Ar1AdminConfig';
|
||||
import BandoFlowEdit from './pages/BandoFlowEdit';
|
||||
import Imieibandi from './pages/Imieibandi';
|
||||
import BandoApplication from './pages/BandoApplication';
|
||||
@@ -135,6 +146,79 @@ 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="/ar1" element={<DefaultLayout>
|
||||
{'ROLE_BENEFICIARY' === role ? <Ar1Home/> : null}
|
||||
{'ROLE_SUPER_ADMIN' === role ? <Ar1Home/> : null}
|
||||
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/ar1/wizard/:formId" element={<DefaultLayout>
|
||||
{'ROLE_BENEFICIARY' === role ? <Ar1Wizard/> : null}
|
||||
{'ROLE_SUPER_ADMIN' === role ? <Ar1Wizard/> : null}
|
||||
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/ar1/signature/:formId" element={<DefaultLayout>
|
||||
{'ROLE_BENEFICIARY' === role ? <Ar1Signature/> : null}
|
||||
{'ROLE_SUPER_ADMIN' === role ? <Ar1Signature/> : null}
|
||||
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
|
||||
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
|
||||
</DefaultLayout>}/>
|
||||
<Route path="/ar1-admin" element={<DefaultLayout>
|
||||
{'ROLE_SUPER_ADMIN' === role ? <Ar1AdminConfig/> : <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/: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