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:
BFLOWS
2026-04-18 18:51:57 +02:00
parent 381fd64fef
commit 8988bed952
3 changed files with 251 additions and 9 deletions

View 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;

View File

@@ -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>
)} )}

View File

@@ -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 = {