diff --git a/package.json b/package.json index 21d670b..a55fc51 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "@sentry/browser": "9.11.0", "@stomp/stompjs": "7.1.1", "@tanstack/react-table": "8.21.2", + "@univerjs/preset-docs-core": "^0.18.0", + "@univerjs/preset-sheets-core": "^0.18.0", + "@univerjs/presets": "^0.18.0", "@wordpress/i18n": "5.21.0", "@wordpress/react-i18n": "4.21.0", "codice-fiscale-js": "2.3.22", @@ -42,6 +45,7 @@ "recharts": "2.15.2", "sockjs-client": "1.6.1", "validate.js": "0.13.1", + "xlsx": "^0.18.5", "zustand": "5.0.6", "zustand-x": "6.1.1" }, @@ -88,4 +92,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/src/assets/scss/components/formBuilder.scss b/src/assets/scss/components/formBuilder.scss index 23fcb37..0fc994f 100644 --- a/src/assets/scss/components/formBuilder.scss +++ b/src/assets/scss/components/formBuilder.scss @@ -176,6 +176,19 @@ width: 40rem; } +.formBuilder__elementSettings--wide { + width: 90%; +} + +// Univer renders context menus / popups with position:fixed and z-index:1070, +// but PrimeReact Sidebar (modal type) sits at z-index ~1100, hiding them. +// Raise all Univer popup/floating layers above the sidebar. +.univer-popup, +.univer-z-\[1020\], +.univer-z-\[1080\] { + z-index: 9999 !important; +} + .formElementSettings { display: flex; flex-direction: column; diff --git a/src/components/FormField/components/Spreadsheet/index.js b/src/components/FormField/components/Spreadsheet/index.js new file mode 100644 index 0000000..7b9343f --- /dev/null +++ b/src/components/FormField/components/Spreadsheet/index.js @@ -0,0 +1,84 @@ +import React, { useEffect, useRef } from 'react'; +import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets'; +import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'; +import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'; +import '@univerjs/preset-sheets-core/lib/index.css'; + +const parseWorkbook = (val) => { + if (!val) return null; + if (typeof val === 'object' && val.sheets) return val; + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val); + return parsed && parsed.sheets ? parsed : null; + } catch { + return null; + } + } + return null; +}; + +const Spreadsheet = ({ fieldName, defaultValue, setDataFn, template }) => { + const containerRef = useRef(null); + const univerRef = useRef(null); + const univerAPIRef = useRef(null); + const saveTimerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const { univer, univerAPI } = createUniver({ + locale: LocaleType.EN_US, + locales: { + [LocaleType.EN_US]: mergeLocales(UniverPresetSheetsCoreEnUS), + }, + presets: [ + UniverSheetsCorePreset({ + container: containerRef.current, + }), + ], + }); + + univerRef.current = univer; + univerAPIRef.current = univerAPI; + + const initialData = parseWorkbook(defaultValue) || parseWorkbook(template) || { name: 'Sheet' }; + univerAPI.createWorkbook(initialData); + + univerAPI.addEvent(univerAPI.Event.BeforeCommandExecute, () => { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + const wb = univerAPIRef.current?.getActiveWorkbook(); + if (wb) { + setDataFn(fieldName, wb.save()); + } + }, 300); + }); + + return () => { + clearTimeout(saveTimerRef.current); + if (univerRef.current) { + univerRef.current.dispose(); + univerRef.current = null; + univerAPIRef.current = null; + } + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const handler = (e) => { e.stopPropagation(); e.preventDefault(); }; + el.addEventListener('wheel', handler, { passive: false }); + return () => el.removeEventListener('wheel', handler); + }, []); + + return ( +
+ ); +}; + +export default Spreadsheet; diff --git a/src/components/FormField/index.js b/src/components/FormField/index.js index 67f7ba9..5688f4f 100644 --- a/src/components/FormField/index.js +++ b/src/components/FormField/index.js @@ -19,6 +19,7 @@ import Table from './components/Table'; import PasswordField from './components/PasswordField'; import CriteriaTable from './components/CriteriaTable'; import FileSelect from './components/FileSelect'; +import Spreadsheet from './components/Spreadsheet'; const FormField = (props) => { const fields = { @@ -38,6 +39,7 @@ const FormField = (props) => { criteria_table: CriteriaTable, password: PasswordField, fileselect: FileSelect, + spreadsheet: Spreadsheet, } const Comp = !isNil(fields[props.type]) ? fields[props.type] : null; diff --git a/src/pages/BandoApplication/index.js b/src/pages/BandoApplication/index.js index b154dad..e33c453 100644 --- a/src/pages/BandoApplication/index.js +++ b/src/pages/BandoApplication/index.js @@ -730,6 +730,7 @@ const BandoApplication = () => { if (!tableColumns) { tableColumns = head(o.settings.filter(o => o.name === 'criteria_table_columns')); } + const template = head(o.settings.filter(s => s.name === 'template')); const step = head(o.settings.filter(o => o.name === 'step')); const mime = head(o.settings.filter(o => o.name === 'mime')); const documentCategories = head(o.settings.filter(o => o.name === 'documentCategories')); @@ -794,6 +795,7 @@ const BandoApplication = () => { sourceId={getApplicationId()} useGrouping={false} tableColumns={tableColumns ? tableColumns.value : {}} + template={template ? template.value : null} /> }) : null} diff --git a/src/pages/BandoApplicationPreview/index.js b/src/pages/BandoApplicationPreview/index.js index ae9baac..ff459d4 100644 --- a/src/pages/BandoApplicationPreview/index.js +++ b/src/pages/BandoApplicationPreview/index.js @@ -378,6 +378,7 @@ const BandoApplicationPreview = () => { if (!tableColumns) { tableColumns = head(o.settings.filter(o => o.name === 'criteria_table_columns')); } + const template = head(o.settings.filter(s => s.name === 'template')); const step = head(o.settings.filter(o => o.name === 'step')); const mime = head(o.settings.filter(o => o.name === 'mime')); const formula = head(o.settings.filter(o => o.name === 'formula')); @@ -435,6 +436,7 @@ const BandoApplicationPreview = () => { sourceId={getApplicationId()} useGrouping={false} tableColumns={tableColumns ? tableColumns.value : {}} + template={template ? template.value : null} /> })} diff --git a/src/pages/BandoEdit/components/BandoEditFormStep3/index.js b/src/pages/BandoEdit/components/BandoEditFormStep3/index.js index dbd398e..d5c40ea 100644 --- a/src/pages/BandoEdit/components/BandoEditFormStep3/index.js +++ b/src/pages/BandoEdit/components/BandoEditFormStep3/index.js @@ -92,6 +92,29 @@ const BandoEditFormStep3 = forwardRef(function () { storeSet('unsetAsyncRequest'); } + const getFormsCallback = (resp) => { + if (resp.status === 'SUCCESS') { + const EXCLUDED_TYPES = new Set(['fileupload', 'fileselect', 'table', 'criteria_table', 'spreadsheet']); + const raw = (resp.data ?? []).flatMap(form => + (form.content ?? []) + .filter(f => !EXCLUDED_TYPES.has(f.name)) + .map(f => ({ + id: f.id, + label: f.label, + placeholder: f.settings?.find(s => s.name === 'placeholder')?.value ?? '' + })) + ); + const byLabel = new Map(); + raw.forEach(f => { + if (!byLabel.has(f.label)) byLabel.set(f.label, { label: f.label, ids: [], placeholder: f.placeholder }); + byLabel.get(f.label).ids.push(f.id); + }); + storeSet('callFormFields', Array.from(byLabel.values())); + } + } + + const errGetFormsCallback = () => {} + const getFormCallback = (resp) => { if (resp.status === 'SUCCESS') { storeSet('formId', resp.data.id); @@ -117,12 +140,14 @@ const BandoEditFormStep3 = forwardRef(function () { useEffect(() => { storeSet('setAsyncRequest'); FormsService.getElementItems(getElementItemsCallback, errGetElementItemsCallbacks); + FormsService.getFormsForCall(id, getFormsCallback, errGetFormsCallback); return () => { storeSet('formId', 0); storeSet('formElements', []); storeSet('activeElement', ''); storeSet('selectedElement', ''); + storeSet('callFormFields', []); } }, []); diff --git a/src/pages/BandoEdit/components/BandoEditFormStep3Excel/index.js b/src/pages/BandoEdit/components/BandoEditFormStep3Excel/index.js new file mode 100644 index 0000000..e06f777 --- /dev/null +++ b/src/pages/BandoEdit/components/BandoEditFormStep3Excel/index.js @@ -0,0 +1,338 @@ +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useNavigate, useParams } from 'react-router-dom'; +import { klona } from 'klona'; +import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets'; +import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'; +import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'; +import '@univerjs/preset-sheets-core/lib/index.css'; + +// api +import EvaluationFormsService from '../../../../service/evaluation-forms-service'; +import FormsService from '../../../../service/forms-service'; + +// store +import { storeGet, storeSet } from '../../../../store'; + +// tools +import set404FromErrorResponse from '../../../../helpers/set404FromErrorResponse'; +import { xlsxToWorkbookData } from './xlsxToWorkbookData'; + +// components +import BandoEditFormActions from '../BandoEditFormActions'; +import { Toast } from 'primereact/toast'; + +const BandoEditFormStep3Excel = forwardRef(function () { + const navigate = useNavigate(); + const { id } = useParams(); + const [formName, setFormName] = useState(''); + const [bandoStatus, setBandoStatus] = useState(''); + const [tagTooltip, setTagTooltip] = useState(null); // { label } | null + const toast = useRef(null); + const univerRef = useRef(null); + const containerRef = useRef(null); + const univerAPIRef = useRef(null); + const formFieldsRef = useRef([]); + const fieldsLoadedRef = useRef(false); + const pendingWorkbookRef = useRef(undefined); // undefined = eval form not yet returned, null = returned with no workbook + const mousePos = useRef({ x: 0, y: 0 }); + const fileInputRef = useRef(null); + + const getBandoId = () => { + const parsed = parseInt(id) + return !isNaN(parsed) ? parsed : 0; + } + + const onSaveDraft = () => { + const workbookData = univerAPIRef.current?.getActiveWorkbook()?.save() ?? null; + const formId = storeGet('formId'); + const formData = { + label: formName, + content: workbookData, + } + + storeSet('setAsyncRequest'); + EvaluationFormsService.updateForm(formId, formData, updateFormCallback, errUpdateFormCallback) + } + + const updateFormCallback = (resp) => { + if (resp.status === 'SUCCESS') { + setBandoStatus(resp.data.callStatus); + if (toast.current) { + toast.current.show({ + severity: 'success', + summary: '', + detail: __('Il bando è stato aggiornato correttamente!', 'gepafin') + }); + } + } + storeSet('unsetAsyncRequest'); + } + + const errUpdateFormCallback = (resp) => { + set404FromErrorResponse(resp); + storeSet('unsetAsyncRequest'); + } + + const openPreview = () => { + const bandoId = getBandoId(); + navigate(`/bandi/${bandoId}/preview`); + } + + const openPreviewEvaluation = () => { + const bandoId = getBandoId(); + navigate(`/bandi/${bandoId}/preview-evaluation`); + } + + const getFormCallback = (resp) => { + if (resp.status === 'SUCCESS') { + storeSet('formId', resp.data.id); + storeSet('formLabel', resp.data.label); + setFormName(resp.data.label); + setBandoStatus(resp.data.callStatus); + const content = resp.data.content ? klona(resp.data.content) : null; + storeSet('formElements', content); + const workbook = content && content.sheets ? content : null; + if (fieldsLoadedRef.current) { + initializeUniver(workbook); + } else { + pendingWorkbookRef.current = workbook; // null or workbook; signals eval form returned + } + } else { + if (!fieldsLoadedRef.current) { + pendingWorkbookRef.current = null; // eval form done, no workbook + } + } + storeSet('unsetAsyncRequest'); + } + + const getFormsCallback = (resp) => { + if (resp.status === 'SUCCESS') { + const EXCLUDED_TYPES = new Set(['fileupload', 'fileselect', 'table', 'criteria_table']); + const raw = (resp.data ?? []).flatMap(form => + (form.content ?? []) + .filter(f => !EXCLUDED_TYPES.has(f.name)) + .map(f => ({ id: f.id, label: f.label })) + ); + // Group by label: same label across forms → one entry with multiple ids + const byLabel = new Map(); + raw.forEach(f => { + if (!byLabel.has(f.label)) byLabel.set(f.label, { label: f.label, ids: [] }); + byLabel.get(f.label).ids.push(f.id); + }); + formFieldsRef.current = Array.from(byLabel.values()); + } + fieldsLoadedRef.current = true; + if (pendingWorkbookRef.current !== undefined) { + // eval form already returned; initialize with its workbook (may be null) + initializeUniver(pendingWorkbookRef.current); + pendingWorkbookRef.current = undefined; + } + // else: eval form hasn't returned yet; getFormCallback will trigger initializeUniver + } + + const errGetFormsCallback = () => { + fieldsLoadedRef.current = true; + if (pendingWorkbookRef.current !== undefined) { + initializeUniver(pendingWorkbookRef.current); + pendingWorkbookRef.current = undefined; + } + } + + const errGetFormCallback = (resp) => { + set404FromErrorResponse(resp); + if (!fieldsLoadedRef.current) { + pendingWorkbookRef.current = null; + } + storeSet('unsetAsyncRequest'); + } + + useEffect(() => { + storeSet('setAsyncRequest'); + EvaluationFormsService.getFormForCall(id, getFormCallback, errGetFormCallback) + }, [id]); + + useEffect(() => { + FormsService.getFormsForCall(id, getFormsCallback, errGetFormsCallback); + }, [id]); + + const insertFieldTag = (ids, type) => { + const api = univerAPIRef.current; + if (!api) return; + const range = api.getActiveWorkbook()?.getActiveSheet()?.getActiveRange(); + if (!range) return; + range.setValue(`{{gepafin_field:${ids.join(',')}|${type}}}`); + range.setBackground('#dbeafe'); + }; + + const initializeUniver = useCallback((workbookData) => { + if (univerRef.current) { + univerRef.current.dispose(); + univerRef.current = null; + univerAPIRef.current = null; + } + + if (!containerRef.current) return; + + const { univer, univerAPI } = createUniver({ + locale: LocaleType.EN_US, + locales: { + [LocaleType.EN_US]: mergeLocales(UniverPresetSheetsCoreEnUS), + }, + presets: [ + UniverSheetsCorePreset({ + container: containerRef.current, + }), + ], + }); + + univerAPI.createWorkbook(workbookData || { name: 'Sheet' }); + univerRef.current = univer; + univerAPIRef.current = univerAPI; + + // Disable adding new sheets + univerAPI.addEvent(univerAPI.Event.BeforeCommandExecute, (event) => { + if (event.id === 'sheet.command.insert-sheet') { + event.cancel = true; + } + }); + + // Context menu: "Inserisci variabile GEPAFIN" with per-field submenus + const fields = formFieldsRef.current; + const submenu = univerAPI.createSubmenu({ + id: 'gepafin-insert-variable', + title: 'Inserisci variabile GEPAFIN', + }); + + fields.forEach((field, index) => { + if (index > 0) submenu.addSeparator(); + + const menuKey = field.ids[0]; + const countLabel = field.ids.length > 1 ? ` [${field.ids.length}]` : ''; + + submenu.addSubmenu(univerAPI.createMenu({ + id: `gepafin-field-${menuKey}-label`, + title: `${field.label}${countLabel} (etichetta)`, + action: () => insertFieldTag(field.ids, 'label'), + })); + + submenu.addSubmenu(univerAPI.createMenu({ + id: `gepafin-field-${menuKey}-value`, + title: `${field.label}${countLabel} (valore)`, + action: () => insertFieldTag(field.ids, 'value'), + })); + }); + + submenu.appendTo(['contextMenu.mainArea', 'contextMenu.others']); + + // Tooltip on hover over tagged cells + univerAPI.addEvent(univerAPI.Event.CellHover, (params) => { + const { row, column, worksheet } = params; + const cellValue = worksheet?.getRange(row, column)?.getValue?.(); + if (typeof cellValue === 'string' && cellValue.startsWith('{{gepafin_field:')) { + const match = cellValue.match(/\{\{gepafin_field:([^|]+)\|([^}]+)\}\}/); + if (match) { + const tagIds = match[1].split(','); + const field = formFieldsRef.current.find(f => f.ids.some(id => tagIds.includes(id))); + const countSuffix = tagIds.length > 1 ? ` — ${tagIds.length} moduli` : ''; + setTagTooltip({ label: `${field ? field.label : match[1]} (${match[2]})${countSuffix}` }); + } + } else { + setTagTooltip(null); + } + }); + }, []); // containerRef, formFieldsRef, univerRef, univerAPIRef are stable refs + + useEffect(() => { + return () => { + if (univerRef.current) { + univerRef.current.dispose(); + univerRef.current = null; + univerAPIRef.current = null; + } + }; + }, []); + + // Prevent page scroll while hovering over the spreadsheet + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const handler = (e) => { e.stopPropagation(); e.preventDefault(); }; + el.addEventListener('wheel', handler, { passive: false }); + return () => el.removeEventListener('wheel', handler); + }, []); + + const handleImport = (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const data = xlsxToWorkbookData(ev.target.result); + initializeUniver(data); + }; + reader.readAsArrayBuffer(file); + e.target.value = ''; // reset so same file can be re-imported + }; + + return ( +