diff --git a/src/components/NotificationsSidebar/index.js b/src/components/NotificationsSidebar/index.js index b4ec5e2..7489b26 100644 --- a/src/components/NotificationsSidebar/index.js +++ b/src/components/NotificationsSidebar/index.js @@ -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', diff --git a/src/layouts/DefaultLayout/components/AppSidebar/index.js b/src/layouts/DefaultLayout/components/AppSidebar/index.js index 89b5903..28840a8 100644 --- a/src/layouts/DefaultLayout/components/AppSidebar/index.js +++ b/src/layouts/DefaultLayout/components/AppSidebar/index.js @@ -27,6 +27,13 @@ const AppSidebar = () => { id: 2, enable: intersection(permissions, ['MANAGE_TENDERS']).length }, + { + label: __('Rendicontazione', 'gepafin'), + icon: 'pi pi-receipt', + href: '/rendicontazione', + id: 21, + enable: intersection(permissions, ['MANAGE_TENDERS']).length + }, { label: __('Domande in lavorazione', 'gepafin'), icon: 'pi pi-file', diff --git a/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js b/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js new file mode 100644 index 0000000..3fd8546 --- /dev/null +++ b/src/modules/rendicontazione/pages/BandoRendicontazioneSchemaEdit.js @@ -0,0 +1,589 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useNavigate, useParams } from 'react-router-dom'; + +// store +import { useStoreValue } from '../../../store'; + +// components +import { Button } from 'primereact/button'; +import { Toast } from 'primereact/toast'; +import { Tag } from 'primereact/tag'; +import { Skeleton } from 'primereact/skeleton'; +import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup'; +import { InputText } from 'primereact/inputtext'; +import { InputNumber } from 'primereact/inputnumber'; +import { InputSwitch } from 'primereact/inputswitch'; +import { MultiSelect } from 'primereact/multiselect'; +import { Dropdown } from 'primereact/dropdown'; +import { Calendar } from 'primereact/calendar'; +import { InputTextarea } from 'primereact/inputtextarea'; +import BlockingOverlay from '../../../components/BlockingOverlay'; + +// api +import RendicontazioneService from '../service/rendicontazioneService'; +import BandoService from '../../../service/bando-service'; + +// ---------- costanti ---------- +const IVA_REGIMES = [ + { value: 'ORDINARIO', label: 'Ordinario' }, + { value: 'FORFETTARIO', label: 'Forfettario' }, + { value: 'ESENTE', label: 'Esente' } +]; + +const 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_end: gate.period_end ? new Date(gate.period_end) : null, + period_start_rule: gate.period_start_rule ?? 'erogato_date', + iva_regimes_allowed: ivaAllowed, + iva_ordinario_imponibile_only: gate.iva_ordinario_imponibile_only ?? true, + categories: (expenses.categories || []).map(c => ({ + code: c.code || '', label: c.label || '', + description: c.description || '', cap_amount: c.cap_amount ?? null + })), + ula_enabled: ula.enabled ?? false, + ula_threshold: ula.threshold ?? 1.0, + ula_period_start_rule: ula.period_start_rule ?? 'erogato_date', + ula_period_end: ula.period_end ? new Date(ula.period_end) : null, + ula_supporting_doc_required: ula.supporting_doc_required ?? true, + ula_supporting_doc_types: (ula.supporting_doc_types || []).map(t => typeof t === 'string' ? t : t.code), + docs_required: parseList(docs.required_types), + cap_pct_erogato: gate.cap_pct_erogato != null ? Math.round(gate.cap_pct_erogato * 100) : 50, + cap_absolute: gate.cap_absolute ?? 12500, + require_invoice_per_category: gate.require_at_least_one_invoice_per_nonzero_category ?? true, + require_ula_above_threshold: gate.require_ula_above_threshold ?? true, + require_all_documents_resolved: gate.require_all_documents_resolved ?? true + }; +}; + +const formToSchemaJson = (f, base = null) => { + const orig = base || {}; + const fmtDate = (d) => d ? (typeof d === 'string' ? d : d.toISOString().slice(0, 10)) : null; + return { + version: orig.version || '1.0', + template_id: orig.template_id || 'CUSTOM', + template_label: orig.template_label || 'Schema personalizzato', + sections: [ + { + type: 'static_fields', id: 'general', label: 'Dati generali', + fields: [{ + id: 'iva_regime', type: 'select', label: 'Regime IVA', required: true, + options: IVA_REGIMES.filter(o => f.iva_regimes_allowed.includes(o.value)) + }] + }, + { + type: 'category_grid', id: 'expenses', label: 'Spese ammissibili per categoria', + categories: f.categories, + invoice_schema: { required_fields: ['invoice_number','invoice_date','payment_date','supplier_name','supplier_vat','description','taxable','vat','total','pdf'] } + }, + { + type: 'ula_block', id: 'ula', label: 'Calcolo ULA', + enabled: f.ula_enabled, threshold: f.ula_threshold, + period_start_rule: f.ula_period_start_rule, + period_end: fmtDate(f.ula_period_end), + supporting_doc_required: f.ula_supporting_doc_required, + supporting_doc_types: ULA_DOC_TYPES + .filter(t => f.ula_supporting_doc_types.includes(t.value)) + .map(t => ({ code: t.value, label: t.label })) + }, + { + type: 'document_checklist', id: 'docs', label: 'Documenti richiesti', + required_types: f.docs_required + } + ], + gate_rules: { + amount_range: { min: f.amount_min, max: f.amount_max }, + cap_pct_erogato: f.cap_pct_erogato / 100, + cap_absolute: f.cap_absolute, + iva_ordinario_imponibile_only: f.iva_ordinario_imponibile_only, + period_start_rule: f.period_start_rule, + period_end: fmtDate(f.period_end), + require_at_least_one_invoice_per_nonzero_category: f.require_invoice_per_category, + require_ula_above_threshold: f.require_ula_above_threshold, + require_all_documents_resolved: f.require_all_documents_resolved + } + }; +}; + + +const BandoRendicontazioneSchemaEdit = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const isAsyncRequest = useStoreValue('isAsyncRequest'); + const callId = parseInt(id); + + const [bando, setBando] = useState(null); + const [bandoLoading, setBandoLoading] = useState(true); + const [schemaRecord, setSchemaRecord] = useState(null); + const [schemaLoading, setSchemaLoading] = useState(true); + const [form, setForm] = useState(null); + const [dirty, setDirty] = useState(false); + const toast = useRef(null); + + // ---------- load ---------- + const loadBando = () => { + setBandoLoading(true); + BandoService.getBando(callId, + (r) => { setBando(r?.data || null); setBandoLoading(false); }, + () => setBandoLoading(false)); + }; + + const loadSchema = () => { + setSchemaLoading(true); + RendicontazioneService.getSchemaByCallId(callId, + (resp) => { + const rec = resp?.data || null; + setSchemaRecord(rec); + setForm(rec ? schemaJsonToForm(rec.schema_json) : null); + setDirty(false); + setSchemaLoading(false); + }, + (err) => { + if (err?.status === 404) { setSchemaRecord(null); setForm(null); } + else toast.current?.show({ severity: 'error', summary: __('Errore caricamento schema','gepafin'), detail: err?.detail }); + setSchemaLoading(false); + }); + }; + + useEffect(() => { + if (!isNaN(callId)) { loadBando(); loadSchema(); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [callId]); + + // ---------- updates ---------- + const update = (patch) => { setForm(p => ({ ...p, ...patch })); setDirty(true); }; + const updateCategory = (idx, patch) => { + setForm(p => ({ ...p, categories: p.categories.map((c,i) => i===idx ? {...c, ...patch} : c) })); + setDirty(true); + }; + const addCategory = () => { + setForm(p => ({ ...p, categories: [...p.categories, { code:'', label:'', description:'', cap_amount:null }] })); + setDirty(true); + }; + const removeCategory = (idx) => { + setForm(p => ({ ...p, categories: p.categories.filter((_,i) => i!==idx) })); + setDirty(true); + }; + const updateDoc = (idx, patch) => { + setForm(p => ({ ...p, docs_required: p.docs_required.map((d,i) => i===idx ? {...d,...patch} : d) })); + setDirty(true); + }; + const addDoc = () => { + setForm(p => ({ ...p, docs_required: [...p.docs_required, { code:'', label:'' }] })); + setDirty(true); + }; + const removeDoc = (idx) => { + setForm(p => ({ ...p, docs_required: p.docs_required.filter((_,i) => i!==idx) })); + setDirty(true); + }; + + // ---------- actions ---------- + const handleInitializeRestart = (e) => { + confirmPopup({ + target: e.currentTarget, + message: __('Inizializzo lo schema con il template RE-START? Sarà modificabile finché non verrà pubblicato.','gepafin'), + icon: 'pi pi-info-circle', + acceptLabel: __('Inizializza','gepafin'), rejectLabel: __('Annulla','gepafin'), + accept: () => RendicontazioneService.initializeRestartTemplate(callId, + () => { toast.current?.show({severity:'success', summary: __('Schema inizializzato','gepafin')}); loadSchema(); }, + (err) => toast.current?.show({severity:'error', summary:__('Inizializzazione fallita','gepafin'), detail: err?.detail})) + }); + }; + + const handleSave = () => { + const newJson = formToSchemaJson(form, schemaRecord?.schema_json); + RendicontazioneService.updateSchema(callId, newJson, + (resp) => { + toast.current?.show({severity:'success', summary: __('Schema salvato','gepafin')}); + setSchemaRecord(resp?.data); + setForm(schemaJsonToForm(resp?.data?.schema_json)); + setDirty(false); + }, + (err) => toast.current?.show({severity:'error', summary:__('Salvataggio fallito','gepafin'), detail: err?.detail})); + }; + + const handlePublish = (e) => { + confirmPopup({ + target: e.currentTarget, + message: __('Dopo la pubblicazione lo schema non sarà più modificabile e diventerà visibile ai beneficiari. Confermi?','gepafin'), + icon: 'pi pi-exclamation-triangle', + acceptLabel: __('Pubblica','gepafin'), rejectLabel: __('Annulla','gepafin'), + acceptClassName: 'p-button-success', + accept: () => RendicontazioneService.publishSchema(callId, + (resp) => { toast.current?.show({severity:'success', summary:__('Schema pubblicato','gepafin')}); setSchemaRecord(resp?.data); }, + (err) => toast.current?.show({severity:'error', summary:__('Pubblicazione fallita','gepafin'), detail: err?.detail})) + }); + }; + + // ---------- render ---------- + const isPublished = schemaRecord?.status === 'PUBLISHED'; + const readOnly = isPublished; + const hasSchema = !!schemaRecord; + + const statusTag = useMemo(() => { + if (!hasSchema) return ; + if (isPublished) return ; + return ; + }, [hasSchema, isPublished]); + + return ( +
+ + + + + {/* HEADER — flex column, border-left */} +
+

{__('Schema rendicontazione','gepafin')}

+

+ {bandoLoading + ? + : <> + {(bando && bando.name) || `Bando #${callId}`} + {statusTag} + } +

+
+ +
+ + {/* ACTIONS — torna indietro + salva/pubblica */} +
+
+
+
+ +
+ + {/* CONTENT */} + {schemaLoading && ( +
+ +
+ )} + + {!schemaLoading && !hasSchema && ( +
+ +

{__('Nessuno schema di rendicontazione per questo bando','gepafin')}

+

+ {__('Puoi inizializzarlo con un template predefinito. Per ora è disponibile il template RE-START (fondo prestiti con remissione del debito).','gepafin')} +

+
+ )} + + {!schemaLoading && hasSchema && form && ( +
e.preventDefault()}> + + {/* 1 - IMPORTI E PERIODO */} +
+

{__('1. Importi ammissibili e periodo','gepafin')}

+
+
+ + update({amount_min: e.value})} + mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} /> +
+
+ + update({amount_max: e.value})} + mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} /> +
+
+
+
+ + update({period_start_rule: e.value})} + options={PERIOD_START_RULES} disabled={readOnly} /> +
+
+ + update({period_end: e.value})} + dateFormat="dd/mm/yy" showIcon disabled={readOnly} /> +
+
+
+ +
+ + {/* 2 - IVA */} +
+

{__('2. Regime IVA','gepafin')}

+
+ + update({iva_regimes_allowed: e.value})} + disabled={readOnly} display="chip" placeholder={__('Seleziona regimi','gepafin')} /> +
+
+
+ update({iva_ordinario_imponibile_only: e.value})} disabled={readOnly} /> + +
+ {__('Se attivo, in regime ordinario l\'IVA non viene considerata rendicontabile — vale solo la base imponibile della fattura.','gepafin')} +
+
+ +
+ + {/* 3 - CATEGORIE */} +
+

{__('3. Categorie di spesa ammissibili','gepafin')} ({form.categories.length})

+ +
+ {form.categories.map((c, i) => ( +
+
+ {c.code || `#${i+1}`} — {c.label || __('(senza nome)','gepafin')} + {!readOnly && ( +
+
+
+ + updateCategory(i,{code:e.target.value})} + placeholder="B1" disabled={readOnly} /> +
+
+ + updateCategory(i,{cap_amount:e.value})} + mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} placeholder="—" /> +
+
+
+ + updateCategory(i,{label:e.target.value})} disabled={readOnly} /> +
+
+ + updateCategory(i,{description:e.target.value})} + rows={2} disabled={readOnly} autoResize /> +
+
+ ))} +
+ + {!readOnly && ( +
+
+ )} +
+ +
+ + {/* 4 - ULA */} +
+

{__('4. Calcolo ULA (incremento occupazione)','gepafin')}

+
+
+ update({ula_enabled: e.value})} disabled={readOnly} /> + +
+
+ {form.ula_enabled && ( + <> +
+
+ + update({ula_threshold: e.value})} + mode="decimal" minFractionDigits={1} maxFractionDigits={2} min={0} disabled={readOnly} /> +
+
+ + update({ula_period_end: e.value})} + dateFormat="dd/mm/yy" showIcon disabled={readOnly} /> +
+
+
+
+ update({ula_supporting_doc_required: e.value})} disabled={readOnly} /> + +
+
+ {form.ula_supporting_doc_required && ( +
+ + update({ula_supporting_doc_types: e.value})} + disabled={readOnly} display="chip" placeholder={__('Seleziona tipi','gepafin')} /> +
+ )} + + )} +
+ +
+ + {/* 5 - DOCUMENTI */} +
+

{__('5. Documenti richiesti','gepafin')} ({form.docs_required.length})

+

+ {__('I documenti già in regola nel repository della Company saranno riutilizzati automaticamente. Solo quelli scaduti o mancanti richiederanno caricamento.','gepafin')} +

+
+ {form.docs_required.map((d, i) => ( +
+
+
+ + updateDoc(i,{code:e.target.value})} + placeholder="DURC" disabled={readOnly} /> +
+
+ + updateDoc(i,{label:e.target.value})} disabled={readOnly} /> +
+
+ {!readOnly && ( +
+
+ )} +
+ ))} +
+ {!readOnly && ( +
+
+ )} +
+ +
+ + {/* 6 - REGOLE */} +
+

{__('6. Regole di validazione (gate pre-submit)','gepafin')}

+
+
+ + update({cap_pct_erogato: e.value})} + suffix=" %" min={0} max={100} disabled={readOnly} /> +
+
+ + update({cap_absolute: e.value})} + mode="currency" currency="EUR" locale="it-IT" disabled={readOnly} /> +
+
+
+
+ update({require_invoice_per_category: e.value})} disabled={readOnly} /> + +
+
+
+
+ update({require_ula_above_threshold: e.value})} disabled={readOnly} /> + +
+
+
+
+ update({require_all_documents_resolved: e.value})} disabled={readOnly} /> + +
+
+
+ +
+ + {/* ACTIONS BOTTOM (copia degli action top per comodità) */} + {!isPublished && ( +
+
+
+
+ )} + +
+ )} +
+ ); +}; + +export default BandoRendicontazioneSchemaEdit; diff --git a/src/modules/rendicontazione/pages/RendicontazioneHome.js b/src/modules/rendicontazione/pages/RendicontazioneHome.js new file mode 100644 index 0000000..b300de6 --- /dev/null +++ b/src/modules/rendicontazione/pages/RendicontazioneHome.js @@ -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) => ( +
+ {row.bando.name || `Bando #${row.bando.id}`} + {row.bando.descriptionShort && ( +
{row.bando.descriptionShort.slice(0, 80)}{row.bando.descriptionShort.length > 80 ? '…' : ''}
+ )} +
+ ); + + const bandoStatusTpl = (row) => ( + + ); + + const schemaStatusTpl = (row) => { + if (!row.schemaLoaded) return ; + const key = row.schema ? row.schema.status : null; + const conf = SCHEMA_STATUS_CONFIG[key] || SCHEMA_STATUS_CONFIG[null]; + return ; + }; + + const actionsTpl = (row) => { + if (!row.schemaLoaded) return ; + const hasSchema = !!row.schema; + return ( +