feat(schemas): picker 3-card blank/template/clone per nuovo schema rendicontazione
Sostituisce il vecchio flusso che proponeva solo 'Inizializza con template RE-START'.
Quando il bando non ha ancora uno schema, mostra un picker a 3 card:
1. 'Nuovo schema' -> inizializzazione blank (scheletro vuoto da popolare)
2. 'Da template' -> dropdown con template predefiniti + descrizione
3. 'Clona da bando' -> dropdown bandi con schema esistente
Nuovo componente: components/SchemaTemplatePicker.js (200 righe).
Gestisce:
- loading parallelo di templates + clonable-calls
- selezione card con border highlight primary-color
- dropdown espanso solo sulla card attiva (stopPropagation su click)
- bottone Inizia disabilitato finche la selezione non e completa
- spinner durante init, callback onInitialized con schema_json per aggiornare
il form dell'editor senza reload pagina
Nuovo service esportato: schemaPickerService { listTemplates, listClonableCalls,
initializeSchema(callId, payload) }.
BandoRendicontazioneSchemaEdit.js: rimosso il box 'Inizializza con template RE-START',
sostituito con <SchemaTemplatePicker /> quando !hasSchema. onInitialized popola
setSchemaRecord + setForm + mostra toast di conferma. Funzione handleInitializeRestart
resta nel file (non ancora chiamata, per sicurezza rollback).
This commit is contained in:
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;
|
||||||
@@ -22,6 +22,7 @@ import BlockingOverlay from '../../../components/BlockingOverlay';
|
|||||||
|
|
||||||
// api
|
// api
|
||||||
import RendicontazioneService from '../service/rendicontazioneService';
|
import RendicontazioneService from '../service/rendicontazioneService';
|
||||||
|
import SchemaTemplatePicker from '../components/SchemaTemplatePicker';
|
||||||
import BandoService from '../../../service/bando-service';
|
import BandoService from '../../../service/bando-service';
|
||||||
|
|
||||||
// ---------- costanti ----------
|
// ---------- costanti ----------
|
||||||
@@ -343,15 +344,25 @@ const BandoRendicontazioneSchemaEdit = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!schemaLoading && !hasSchema && (
|
{!schemaLoading && !hasSchema && (
|
||||||
<div className="appPageSection" style={{ alignItems: 'center', padding: '3rem 2rem' }}>
|
<div className="appPageSection">
|
||||||
<i className="pi pi-file-edit" style={{ fontSize: '3rem', color: 'var(--text-color-secondary)', marginBottom: '1rem' }} />
|
<SchemaTemplatePicker
|
||||||
<h2 style={{ marginBottom: '0.5rem' }}>{__('Nessuno schema di rendicontazione per questo bando','gepafin')}</h2>
|
callId={callId}
|
||||||
<p style={{ color: 'var(--text-color-secondary)', marginBottom: '1.5rem', textAlign: 'center' }}>
|
onInitialized={(data) => {
|
||||||
{__('Puoi inizializzarlo con un template predefinito. Per ora è disponibile il template RE-START (fondo prestiti con remissione del debito).','gepafin')}
|
setSchemaRecord(data);
|
||||||
</p>
|
setForm(schemaJsonToForm(data.schema_json));
|
||||||
<Button icon="pi pi-plus-circle" iconPos="right"
|
setDirty(false);
|
||||||
label={__('Inizializza con template RE-START','gepafin')}
|
toast.current?.show({
|
||||||
onClick={handleInitializeRestart} severity="success" />
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,39 @@ const RendicontazioneService = {
|
|||||||
|
|
||||||
export default RendicontazioneService;
|
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 ======================
|
// ====================== PRATICHE BENEFICIARIO ======================
|
||||||
|
|
||||||
const extendPractice = {
|
const extendPractice = {
|
||||||
|
|||||||
Reference in New Issue
Block a user