@topconsultnpm/sdkui-react 6.22.0-dev3.7 → 6.22.0-dev3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,8 @@ export interface TMTabControlProps {
36
36
  hideBarWhenSingle?: boolean;
37
37
  /** Se valorizzata mostra la 'x' sui tab e notifica la richiesta di chiusura. */
38
38
  onTabClose?: (id: string) => void;
39
+ /** Se true anche l'ultimo tab rimasto e chiudibile, lasciando il controllo senza tab. Default false. */
40
+ closableWhenSingle?: boolean;
39
41
  /** Se valorizzata abilita la rinomina del titolo. */
40
42
  onTabRename?: (id: string, label: string) => void;
41
43
  /** Se valorizzata abilita lo spostamento con trascinamento: sposta draggedId nella posizione di targetId. */
@@ -341,7 +341,7 @@ const shortenLabel = (label) => {
341
341
  /** Contenitore dichiarativo di un tab: figlio diretto di TMTabControl, non renderizza nulla di suo. */
342
342
  export const TMTabPanel = ({ children }) => _jsx(_Fragment, { children: children });
343
343
  const TMTabControl = (props) => {
344
- const { selectedId, defaultSelectedId, onSelectedIdChange, hideBarWhenSingle = false, onTabClose, onTabRename, onTabReorder, onTabAdd, tabMenuItems, addCaption = SDKUI_Localizator.AddTab, renameCaption = SDKUI_Localizator.Rename, closeCaption = SDKUI_Localizator.Close, closeOtherCaption = SDKUI_Localizator.CloseOtherTabs, variant = 'module', textColor, idleTextColor, contentAnimation = 'none', animationDuration = 300, swipeEnabled = true, contentStyle, style, children } = props;
344
+ const { selectedId, defaultSelectedId, onSelectedIdChange, hideBarWhenSingle = false, onTabClose, closableWhenSingle = false, onTabRename, onTabReorder, onTabAdd, tabMenuItems, addCaption = SDKUI_Localizator.AddTab, renameCaption = SDKUI_Localizator.Rename, closeCaption = SDKUI_Localizator.Close, closeOtherCaption = SDKUI_Localizator.CloseOtherTabs, variant = 'module', textColor, idleTextColor, contentAnimation = 'none', animationDuration = 300, swipeEnabled = true, contentStyle, style, children } = props;
345
345
  const v = useMemo(() => VARIANTS[variant](), [variant]);
346
346
  const titleColor = textColor ?? v.textColor;
347
347
  const titleIdleColor = idleTextColor ?? v.idleTextColor;
@@ -523,8 +523,8 @@ const TMTabControl = (props) => {
523
523
  // L'input si apre dopo la chiusura del menu, altrimenti perde subito il focus
524
524
  pushOwn({ name: renameCaption, icon: _jsx(IconEdit, { fontSize: 18 }), onClick: () => { closeMenu(); setTimeout(() => setEditingId(panel.props.id), 0); } });
525
525
  }
526
- // Come per la 'x': l'ultimo tab rimasto non si chiude
527
- if (onTabClose && panel.props.closable !== false && panels.length > 1) {
526
+ // Come per la 'x': l'ultimo tab rimasto non si chiude, salvo closableWhenSingle
527
+ if (onTabClose && panel.props.closable !== false && (closableWhenSingle || panels.length > 1)) {
528
528
  pushOwn({ name: closeCaption, icon: _jsx(IconCloseOutline, { fontSize: 18 }), onClick: () => { closeMenu(); onTabClose(panel.props.id); } });
529
529
  }
530
530
  // Chiude in blocco gli altri tab chiudibili, ma prima chiede conferma dicendo quanti sono
@@ -547,7 +547,7 @@ const TMTabControl = (props) => {
547
547
  pushOwn({ name: addCaption, icon: _jsx(IconAdd, { fontSize: 18 }), onClick: () => { closeMenu(); onTabAdd(); } }, items.length > 0);
548
548
  }
549
549
  return items;
550
- }, [panels, tabMenuItems, onTabRename, onTabClose, onTabAdd, renameCaption, closeCaption, closeOtherCaption, addCaption]);
550
+ }, [panels, tabMenuItems, onTabRename, onTabClose, closableWhenSingle, onTabAdd, renameCaption, closeCaption, closeOtherCaption, addCaption]);
551
551
  // Tasto destro sul tab: niente menu del browser, si apre quello con Rinomina e Chiudi.
552
552
  const openMenu = (e, id) => {
553
553
  e.preventDefault();
@@ -562,8 +562,9 @@ const TMTabControl = (props) => {
562
562
  return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', minHeight: 0, ...style }, children: [showBar &&
563
563
  _jsxs(TabsHeader, { $v: v, onContextMenu: (e) => e.preventDefault(), children: [arrows.left && _jsx(NavButton, { $v: v, type: 'button', tabIndex: -1, onClick: () => scrollTabs(-1), children: _jsx(IconLeft, { fontSize: 18 }) }), _jsxs(TabsStrip, { $v: v, ref: stripRef, role: 'tablist', onScroll: refreshArrows, onKeyDown: handleBarKeyDown, children: [panels.map((p, index) => {
564
564
  const isActive = p.props.id === activeId;
565
- // Con un solo tab la 'x' sparisce: non deve essere possibile restare senza contenuto
566
- const canClose = !!onTabClose && p.props.closable !== false && panels.length > 1;
565
+ // Con un solo tab la 'x' sparisce: non deve essere possibile restare senza contenuto,
566
+ // a meno che closableWhenSingle non consenta di chiudere anche l'ultimo tab.
567
+ const canClose = !!onTabClose && p.props.closable !== false && (closableWhenSingle || panels.length > 1);
567
568
  const canRename = !!onTabRename && p.props.renamable !== false && typeof p.props.label === 'string';
568
569
  const isDropTarget = !!dragId && dropId === p.props.id && dragId !== p.props.id;
569
570
  const draggedIndex = panels.findIndex(t => t.props.id === dragId);
@@ -147,15 +147,26 @@ const TMTreeView = ({ dataSource = [], focusedItem, selectedItems = [], allowMul
147
147
  return [];
148
148
  return flattenTreeWithDepth(dataSource);
149
149
  }, [enableVirtualization, dataSource, flattenTreeWithDepth]);
150
+ // Chiave dell'ultimo elemento verso cui si è effettivamente scrollato.
151
+ // Evita di ripetere lo scroll quando cambia solo la struttura dell'albero
152
+ // (es. espansione di un nodo) a parità di elemento focalizzato.
153
+ const lastScrolledKeyRef = useRef(undefined);
150
154
  // Scroll verso l'elemento focalizzato quando cambia (navigazione con frecce)
151
155
  useEffect(() => {
152
- if (!focusedItem?.key)
156
+ if (!focusedItem?.key) {
157
+ lastScrolledKeyRef.current = undefined;
158
+ return;
159
+ }
160
+ // Focus invariato: l'effect è stato rieseguito solo perché l'albero è cambiato
161
+ // (espansione/collasso di un nodo). La posizione di scroll non va toccata.
162
+ if (lastScrolledKeyRef.current === focusedItem.key)
153
163
  return;
154
164
  if (enableVirtualization) {
155
165
  // Lista virtualizzata: usa scrollToRow per garantire che l'elemento sia visibile
156
166
  const index = flattenedItems.findIndex(item => item.node.key === focusedItem.key);
157
167
  if (index >= 0 && listRef.current) {
158
168
  listRef.current.scrollToRow({ index, align: 'smart' });
169
+ lastScrolledKeyRef.current = focusedItem.key;
159
170
  }
160
171
  }
161
172
  else {
@@ -164,6 +175,7 @@ const TMTreeView = ({ dataSource = [], focusedItem, selectedItems = [], allowMul
164
175
  const element = containerRef.current.querySelector(`[data-node-key="${focusedItem.key}"]`);
165
176
  if (element) {
166
177
  element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
178
+ lastScrolledKeyRef.current = focusedItem.key;
167
179
  }
168
180
  }
169
181
  }
@@ -20,6 +20,12 @@ import ShowAlert from '../../base/TMAlert';
20
20
  import { TMColors } from '../../../utils/theme';
21
21
  import TMDownloadRelationViewerSection from './TMDownloadRelationViewerSection';
22
22
  import { buildInitialDownloadSettings, fileExists, generateTargetFileName, generateUniqueFileName, getDcmtInfosToDownload, getFloatingLabelStyle, isDirectoryPickerSupported, } from './copyAndMergeDcmtsShared';
23
+ /**
24
+ * Numero di documenti scaricati in parallelo.
25
+ * I file vengono consumati via callback (scrittura su cartella o raccolta per lo ZIP),
26
+ * quindi il parallelismo è sicuro; l'ordine finale è garantito a valle del download.
27
+ */
28
+ const COPY_DOWNLOAD_CONCURRENCY = 5;
23
29
  /**
24
30
  * Form per la copia/zip dei documenti in una cartella locale.
25
31
  * Logica condivisa con TMMergeToPdfForm è esternalizzata in copyAndMergeDcmtsShared
@@ -111,10 +117,24 @@ const TMCopyToFolderForm = ({ mode, selectedDcmtInfos, onClose, showTMRelationVi
111
117
  const handleSeparatorChange = (e) => {
112
118
  updateSettings('separatorChar', e.target.value.slice(0, 1));
113
119
  };
120
+ // ---- Serializzazione delle scritture su filesystem ----
121
+ // I download sono paralleli, le scritture no: fileExists/generateUniqueFileName/
122
+ // getFileHandle sono check-then-act sulla stessa cartella e due scritture contemporanee
123
+ // potrebbero sovrascriversi o calcolare lo stesso nome "univoco".
124
+ const writeChainRef = useRef(Promise.resolve());
125
+ const runExclusive = (action) => {
126
+ const result = writeChainRef.current.then(action);
127
+ // La catena non deve interrompersi se una scrittura fallisce: l'errore viene
128
+ // comunque propagato al chiamante tramite il promise restituito.
129
+ writeChainRef.current = result.catch(() => undefined);
130
+ return result;
131
+ };
114
132
  // ---- Scrittura file nel filesystem locale ----
115
133
  const writeFileToFolder = async (file, dcmtInfo) => {
116
134
  try {
117
- let targetFileName = await generateTargetFileName(file, dcmtInfo, {
135
+ // Fuori dal lock: con la denominazione da metadati questa fa una chiamata al
136
+ // server per documento e deve poter andare in parallelo agli altri download.
137
+ const baseFileName = await generateTargetFileName(file, dcmtInfo, {
118
138
  fileNamingMode: settings.fileNamingMode,
119
139
  separatorChar: settings.separatorChar,
120
140
  });
@@ -123,7 +143,7 @@ const TMCopyToFolderForm = ({ mode, selectedDcmtInfos, onClose, showTMRelationVi
123
143
  const url = URL.createObjectURL(file);
124
144
  const a = document.createElement('a');
125
145
  a.href = url;
126
- a.download = targetFileName;
146
+ a.download = baseFileName;
127
147
  document.body.appendChild(a);
128
148
  a.target = '_blank';
129
149
  a.rel = 'noreferrer';
@@ -132,23 +152,29 @@ const TMCopyToFolderForm = ({ mode, selectedDcmtInfos, onClose, showTMRelationVi
132
152
  URL.revokeObjectURL(url);
133
153
  return;
134
154
  }
135
- const exists = await fileExists(folderHandleRef.current, targetFileName);
136
- if (exists) {
137
- switch (settings.fileExistsMode) {
138
- case 'skip':
139
- return;
140
- case 'rename':
141
- targetFileName = await generateUniqueFileName(folderHandleRef.current, targetFileName);
142
- break;
143
- case 'overwrite':
144
- default:
145
- break;
155
+ // Dentro il lock: controllo esistenza, calcolo del nome univoco e scrittura
156
+ // devono restare un'unica operazione atomica sulla cartella.
157
+ const folderHandle = folderHandleRef.current;
158
+ await runExclusive(async () => {
159
+ let targetFileName = baseFileName;
160
+ const exists = await fileExists(folderHandle, targetFileName);
161
+ if (exists) {
162
+ switch (settings.fileExistsMode) {
163
+ case 'skip':
164
+ return;
165
+ case 'rename':
166
+ targetFileName = await generateUniqueFileName(folderHandle, targetFileName);
167
+ break;
168
+ case 'overwrite':
169
+ default:
170
+ break;
171
+ }
146
172
  }
147
- }
148
- const fileHandle = await folderHandleRef.current.getFileHandle(targetFileName, { create: true });
149
- const writable = await fileHandle.createWritable();
150
- await writable.write(file);
151
- await writable.close();
173
+ const fileHandle = await folderHandle.getFileHandle(targetFileName, { create: true });
174
+ const writable = await fileHandle.createWritable();
175
+ await writable.write(file);
176
+ await writable.close();
177
+ });
152
178
  }
153
179
  catch (err) {
154
180
  console.error('Errore nella scrittura del file:', err);
@@ -176,10 +202,12 @@ const TMCopyToFolderForm = ({ mode, selectedDcmtInfos, onClose, showTMRelationVi
176
202
  retrieveOptions.invoiceRetrieveFormat = settings.invoiceFormat ?? SDKUI_Globals.userSettings.searchSettings.invoiceRetrieveFormat;
177
203
  retrieveOptions.orderRetrieveFormat = settings.orderFormat ?? SDKUI_Globals.userSettings.searchSettings.orderRetrieveFormat;
178
204
  if (settings.exportMode === 'copy') {
179
- await downloadDcmtsAsync({ inputDcmts: dcmtInfosToDownload, downloadType: DownloadTypes.Dcmt, downloadMode: 'download', onFileDownloaded: writeFileToFolder, skipConfirmation: true, retrieveOptions, useCache: false });
205
+ // Senza handle della cartella ogni file viene salvato con un click su <a download>:
206
+ // in quel caso si resta sequenziali, perché il browser blocca i click ravvicinati.
207
+ const copyConcurrency = folderHandleRef.current ? COPY_DOWNLOAD_CONCURRENCY : 1;
208
+ await downloadDcmtsAsync({ inputDcmts: dcmtInfosToDownload, downloadType: DownloadTypes.Dcmt, downloadMode: 'download', onFileDownloaded: writeFileToFolder, skipConfirmation: true, retrieveOptions, useCache: false, downloadConcurrency: copyConcurrency });
180
209
  }
181
210
  else if (settings.exportMode === 'zip') {
182
- const zipEntries = [];
183
211
  const fileNameCounts = new Map();
184
212
  const getUniqueZipFileName = (fileName) => {
185
213
  const lowerName = fileName.toLowerCase();
@@ -192,14 +220,25 @@ const TMCopyToFolderForm = ({ mode, selectedDcmtInfos, onClose, showTMRelationVi
192
220
  const ext = dotIndex > 0 ? fileName.substring(dotIndex) : '';
193
221
  return `${baseName} (${count})${ext}`;
194
222
  };
223
+ // Con i download paralleli i file arrivano in ordine di completamento: si raccolgono
224
+ // in mappa (nome incluso, calcolato in parallelo) e l'archivio viene costruito dopo
225
+ // nell'ordine di selezione, così i suffissi di deduplica restano quelli sequenziali.
226
+ const downloadedEntries = new Map();
195
227
  const collectFileForZip = async (file, dcmtInfo) => {
196
228
  const targetFileName = await generateTargetFileName(file, dcmtInfo, {
197
229
  fileNamingMode: settings.fileNamingMode,
198
230
  separatorChar: settings.separatorChar,
199
231
  });
200
- zipEntries.push({ filename: getUniqueZipFileName(targetFileName), data: file });
232
+ downloadedEntries.set(`${dcmtInfo.TID}_${dcmtInfo.DID}`, { file, targetFileName });
201
233
  };
202
- await downloadDcmtsAsync({ inputDcmts: dcmtInfosToDownload, downloadType: DownloadTypes.Dcmt, downloadMode: 'download', onFileDownloaded: collectFileForZip, skipConfirmation: true, retrieveOptions, useCache: false });
234
+ await downloadDcmtsAsync({ inputDcmts: dcmtInfosToDownload, downloadType: DownloadTypes.Dcmt, downloadMode: 'download', onFileDownloaded: collectFileForZip, skipConfirmation: true, retrieveOptions, useCache: false, downloadConcurrency: COPY_DOWNLOAD_CONCURRENCY });
235
+ const zipEntries = [];
236
+ for (const dcmtInfo of dcmtInfosToDownload) {
237
+ const entry = downloadedEntries.get(`${dcmtInfo.TID}_${dcmtInfo.DID}`);
238
+ if (!entry)
239
+ continue;
240
+ zipEntries.push({ filename: getUniqueZipFileName(entry.targetFileName), data: entry.file });
241
+ }
203
242
  if (zipEntries.length > 0) {
204
243
  const zipFileName = settings.zipFileName.trim().toLowerCase().endsWith('.zip')
205
244
  ? settings.zipFileName.trim()
@@ -472,7 +472,10 @@ const TMMasterDetailDcmts = ({ allTasks = [], getAllTasks, deleteTaskByIdsCallba
472
472
  ], [tmTreeView, tmFormOrResult, focusedItem?.isDcmt, dtdMaster]);
473
473
  return (_jsxs("div", { style: { width: '100%', height: '100%', position: 'relative' }, children: [isCheckingFirstLoad && (_jsx(TMLoadingOverlay, { onCancel: handleCancelFirstLoad, onStop: handleStopRelationsCalculation, description: relationsProgress.max > 1
474
474
  ? `${isForMaster ? SDKUI_Localizator.LoadingMasterDocuments : SDKUI_Localizator.LoadingDetailDocuments} (${relationsProgress.value}/${relationsProgress.max})`
475
- : undefined, progressValue: relationsProgress.value, progressMax: relationsProgress.max })), _jsxs("div", { style: isCheckingFirstLoad ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', opacity: 0, pointerEvents: 'none' } : { width: '100%', height: '100%' }, children: [_jsx(TMLayoutWaitingContainer, { direction: 'vertical', showWaitPanel: showWaitPanel, showWaitPanelPrimary: showPrimary, showWaitPanelSecondary: showSecondary, waitPanelTitle: waitPanelTitle, waitPanelTextPrimary: waitPanelTextPrimary, waitPanelValuePrimary: waitPanelValuePrimary, waitPanelMaxValuePrimary: waitPanelMaxValuePrimary, waitPanelTextSecondary: waitPanelTextSecondary, waitPanelValueSecondary: waitPanelValueSecondary, waitPanelMaxValueSecondary: waitPanelMaxValueSecondary, isCancelable: true, abortController: abortController, children: _jsx(TMPanelManagerProvider, { panels: initialPanels, initialVisibility: allInitialPanelVisibility, defaultDimensions: initialPanelDimensions, initialDimensions: initialPanelDimensions, initialMobilePanelId: 'tmTreeView', children: _jsx(TMPanelManagerContainer, { panels: initialPanels, direction: "horizontal", showToolbar: true }) }) }), renderDcmtOperations, renderFloatingBar] })] }));
475
+ : undefined, progressValue: relationsProgress.value, progressMax: relationsProgress.max,
476
+ // Con un solo documento in input la chiamata è unica: non ci sono
477
+ // risultati parziali da mostrare, quindi il pulsante resta disabilitato
478
+ stopDisabled: (inputDcmts?.length ?? 0) <= 1 })), _jsxs("div", { style: isCheckingFirstLoad ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', opacity: 0, pointerEvents: 'none' } : { width: '100%', height: '100%' }, children: [_jsx(TMLayoutWaitingContainer, { direction: 'vertical', showWaitPanel: showWaitPanel, showWaitPanelPrimary: showPrimary, showWaitPanelSecondary: showSecondary, waitPanelTitle: waitPanelTitle, waitPanelTextPrimary: waitPanelTextPrimary, waitPanelValuePrimary: waitPanelValuePrimary, waitPanelMaxValuePrimary: waitPanelMaxValuePrimary, waitPanelTextSecondary: waitPanelTextSecondary, waitPanelValueSecondary: waitPanelValueSecondary, waitPanelMaxValueSecondary: waitPanelMaxValueSecondary, isCancelable: true, abortController: abortController, children: _jsx(TMPanelManagerProvider, { panels: initialPanels, initialVisibility: allInitialPanelVisibility, defaultDimensions: initialPanelDimensions, initialDimensions: initialPanelDimensions, initialMobilePanelId: 'tmTreeView', children: _jsx(TMPanelManagerContainer, { panels: initialPanels, direction: "horizontal", showToolbar: true }) }) }), renderDcmtOperations, renderFloatingBar] })] }));
476
479
  };
477
480
  export default TMMasterDetailDcmts;
478
481
  /**
@@ -551,7 +554,7 @@ const TMFormOrResultWrapper = ({ refreshKey, deviceType, focusedItem, onTaskCrea
551
554
  }, fetchRemoteCertificates: fetchRemoteCertificates }, refreshKey) :
552
555
  focusedItem?.searchResult === undefined ? (_jsx(TMPanel, { title: SDKUI_Localizator.SearchResult, children: _jsx(TMToppyMessage, { message: SDKUI_Localizator.SelectDocumentToViewSearchResults }) })) : (_jsx(TMSearchResult, { groupId: 'tmFormOrResult', isClosable: deviceType !== DeviceType.MOBILE, context: SearchResultContext.METADATA_SEARCH, allowFloatingBar: false, allowRelations: false, openDcmtFormAsModal: true, searchResults: focusedItem?.searchResult ?? [], showSearchResultSidebar: false, showDcmtFormSidebar: false, autoFocusFirstRow: false, onTaskCreateRequest: onTaskCreateRequest, onClose: () => { setPanelVisibilityById('tmTreeView', true); }, allTasks: allTasks, getAllTasks: getAllTasks, deleteTaskByIdsCallback: deleteTaskByIdsCallback, addTaskCallback: addTaskCallback, editTaskCallback: editTaskCallback, handleNavigateToWGs: handleNavigateToWGs, handleNavigateToDossiers: handleNavigateToDossiers, editPdfForm: editPdfForm, onOpenPdfEditorRequest: onOpenPdfEditorRequest, openS4TViewer: openS4TViewer, onOpenS4TViewerRequest: onOpenS4TViewerRequest, enablePinIcons: false, onRefreshAfterAddDcmtToFavs: onRefreshAfterAddDcmtToFavs, showBackButton: false, onRefreshSearchAsyncDatagrid: onRefreshSearchResults, onReferenceClick: handleNavigateToReference, fetchRemoteCertificates: fetchRemoteCertificates }, refreshKey)) }));
553
556
  };
554
- const TMLoadingOverlay = ({ onCancel, onStop, description, cancelText, stopText, progressValue, progressMax }) => {
557
+ const TMLoadingOverlay = ({ onCancel, onStop, description, cancelText, stopText, progressValue, progressMax, stopDisabled }) => {
555
558
  // Stesso calcolo di TMWaitPanel: 0 quando il massimo non è ancora noto
556
559
  const progressPercent = (progressMax ?? 0) > 0
557
560
  ? Math.min(100, ((progressValue ?? 0) / (progressMax ?? 1)) * 100)
@@ -625,20 +628,24 @@ const TMLoadingOverlay = ({ onCancel, onStop, description, cancelText, stopText,
625
628
  .tm-spinner-animation div:nth-child(7):after { top: 63px; left: 17px; background: #782b7d; }
626
629
  .tm-spinner-animation div:nth-child(8) { animation-delay: -0.288s; }
627
630
  .tm-spinner-animation div:nth-child(8):after { top: 56px; left: 12px; background: #782b7d; }
628
- ` }), _jsxs("div", { className: "tm-spinner-animation", children: [_jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {})] })] }), _jsx("span", { style: { fontSize: '14px', color: '#334155', textAlign: 'center' }, children: description ?? SDKUI_Localizator.Loading }), (progressMax ?? 0) > 1 && (_jsxs("div", { style: { width: '260px' }, children: [_jsx("div", { style: { width: '100%', height: '20px', background: '#e0e0e0', borderRadius: '10px', overflow: 'hidden' }, children: _jsx("div", { style: { width: `${progressPercent.toFixed(2)}%`, height: '100%', background: '#4caf50', transition: 'width 0.3s ease' } }) }), _jsxs("p", { style: { fontWeight: 'bold', color: '#333', margin: '8px 0 0', textAlign: 'center' }, children: [progressPercent.toFixed(2), "%"] })] })), (onStop || onCancel) && (_jsxs("div", { style: { marginTop: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap', gap: '10px' }, children: [onStop && (_jsx("button", { onClick: onStop, title: SDKUI_Localizator.StopRelationsCalculation, style: {
631
+ ` }), _jsxs("div", { className: "tm-spinner-animation", children: [_jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {}), _jsx("div", {})] })] }), _jsx("span", { style: { fontSize: '14px', color: '#334155', textAlign: 'center' }, children: description ?? SDKUI_Localizator.Loading }), (progressMax ?? 0) > 1 && (_jsxs("div", { style: { width: '260px' }, children: [_jsx("div", { style: { width: '100%', height: '20px', background: '#e0e0e0', borderRadius: '10px', overflow: 'hidden' }, children: _jsx("div", { style: { width: `${progressPercent.toFixed(2)}%`, height: '100%', background: '#4caf50', transition: 'width 0.3s ease' } }) }), _jsxs("p", { style: { fontWeight: 'bold', color: '#333', margin: '8px 0 0', textAlign: 'center' }, children: [progressPercent.toFixed(2), "%"] })] })), (onStop || onCancel) && (_jsxs("div", { style: { marginTop: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap', gap: '10px' }, children: [onStop && (_jsx("button", { onClick: onStop, disabled: stopDisabled, title: SDKUI_Localizator.StopRelationsCalculation, style: {
629
632
  padding: '8px 24px',
630
633
  fontSize: '13px',
631
634
  fontWeight: 500,
632
- color: 'white',
633
- background: '#0c448e',
634
- border: '1.5px solid #0c448e',
635
+ color: stopDisabled ? '#94a3b8' : 'white',
636
+ background: stopDisabled ? '#e2e8f0' : '#0c448e',
637
+ border: `1.5px solid ${stopDisabled ? '#e2e8f0' : '#0c448e'}`,
635
638
  borderRadius: '6px',
636
- cursor: 'pointer',
639
+ cursor: stopDisabled ? 'not-allowed' : 'pointer',
637
640
  transition: 'all 0.2s ease',
638
641
  }, onMouseEnter: (e) => {
642
+ if (stopDisabled)
643
+ return;
639
644
  e.currentTarget.style.background = '#0a3872';
640
645
  e.currentTarget.style.borderColor = '#0a3872';
641
646
  }, onMouseLeave: (e) => {
647
+ if (stopDisabled)
648
+ return;
642
649
  e.currentTarget.style.background = '#0c448e';
643
650
  e.currentTarget.style.borderColor = '#0c448e';
644
651
  }, children: stopText ?? SDKUI_Localizator.ShowPartialResults })), onCancel && (_jsx("button", { onClick: onCancel, style: {
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState } from 'react';
3
3
  import { DownloadTypes } from '../../../ts';
4
4
  import { calcResponsiveSizes, getExceptionMessage, IconFolderOpen, IconPlay, IconUndo, SDKUI_Globals, SDKUI_Localizator, } from '../../../helper';
5
- import { DcmtOpers, FileFormats, GeneralRetrieveFormats, InvoiceRetrieveFormats, OrderRetrieveFormats, ResultTypes, RetrieveFileOptions, ValidationItem, } from '@topconsultnpm/sdk-ts';
5
+ import { DcmtOpers, FileFormats, GeneralRetrieveFormats, InvoiceRetrieveFormats, OrderRetrieveFormats, ResultTypes, ValidationItem, } from '@topconsultnpm/sdk-ts';
6
6
  import TMModal from '../../base/TMModal';
7
7
  import { useDeviceType } from '../../base/TMDeviceProvider';
8
8
  import TMTextBox from '../../editors/TMTextBox';
@@ -18,6 +18,12 @@ import ShowAlert from '../../base/TMAlert';
18
18
  import TMTooltip from '../../base/TMTooltip';
19
19
  import MergePdfManager from '../../../helper/MergePdfManager';
20
20
  import { TMResultManager } from '../../forms/TMResultDialog';
21
+ /**
22
+ * Numero di documenti scaricati in parallelo durante l'unione.
23
+ * I file vengono consumati via callback (nessun click su <a download>), quindi il
24
+ * parallelismo è sicuro; il riordino finale è garantito da pdfFilesMap.
25
+ */
26
+ const MERGE_DOWNLOAD_CONCURRENCY = 5;
21
27
  /**
22
28
  * Converte il formato di visualizzazione fatture impostato dall'utente nel corrispondente formato PDF:
23
29
  * il foglio di stile SDI resta SDI, in tutti gli altri casi si usa ASW.
@@ -190,16 +196,17 @@ const TMMergeToPdfForm = (props) => {
190
196
  }
191
197
  // Per i documenti con file, usa il download normale
192
198
  if (dcmtsWithFile.length > 0) {
193
- const rfo = new RetrieveFileOptions();
194
- rfo.retrieveReason = DcmtOpers.None;
195
- rfo.generalRetrieveFormat = GeneralRetrieveFormats.OriginalUnsigned;
196
- rfo.cvtFormat = FileFormats.PDF;
197
- rfo.invoiceRetrieveFormat = toPdfInvoiceRetrieveFormat(SDKUI_Globals.userSettings?.searchSettings.invoiceRetrieveFormat);
198
- rfo.orderRetrieveFormat = toPdfOrderRetrieveFormat(SDKUI_Globals.userSettings?.searchSettings.orderRetrieveFormat);
199
+ const rfo = {
200
+ retrieveReason: DcmtOpers.None,
201
+ generalRetrieveFormat: GeneralRetrieveFormats.OriginalUnsigned,
202
+ cvtFormat: FileFormats.PDF,
203
+ invoiceRetrieveFormat: toPdfInvoiceRetrieveFormat(SDKUI_Globals.userSettings?.searchSettings.invoiceRetrieveFormat),
204
+ orderRetrieveFormat: toPdfOrderRetrieveFormat(SDKUI_Globals.userSettings?.searchSettings.orderRetrieveFormat),
205
+ };
199
206
  const collectFileForMerge = async (file, dcmtInfo) => {
200
207
  pdfFilesMap.set(`${dcmtInfo.TID}_${dcmtInfo.DID}`, file);
201
208
  };
202
- await downloadDcmtsAsync({ inputDcmts: dcmtsWithFile, downloadType: DownloadTypes.Dcmt, downloadMode: 'download', onFileDownloaded: collectFileForMerge, skipConfirmation: true, retrieveOptions: rfo, useCache: false });
209
+ await downloadDcmtsAsync({ inputDcmts: dcmtsWithFile, downloadType: DownloadTypes.Dcmt, downloadMode: 'download', onFileDownloaded: collectFileForMerge, skipConfirmation: true, retrieveOptions: rfo, useCache: false, downloadConcurrency: MERGE_DOWNLOAD_CONCURRENCY });
203
210
  }
204
211
  // Ricostruisci l'array pdfFiles nell'ordine originale di selezione
205
212
  const pdfFiles = [];
@@ -24,6 +24,13 @@ export interface DownloadDcmtsAsyncParams {
24
24
  * - false (default): usa il nome file recuperato dal backend (file.name) come priorità
25
25
  */
26
26
  useInputFileName?: boolean;
27
+ /**
28
+ * Numero di download eseguiti in parallelo (default 1 = sequenziale, comportamento preesistente).
29
+ * Valori > 1 vanno usati solo quando il chiamante consuma i file via
30
+ * `onFileDownloaded`: nel percorso senza callback ogni file genera un click su un
31
+ * tag <a download>, e più click ravvicinati vengono bloccati dal browser.
32
+ */
33
+ downloadConcurrency?: number;
27
34
  }
28
35
  export interface UseDcmtOperationsReturn {
29
36
  abortController: AbortController;
@@ -9,6 +9,10 @@ import { importCertificatesAsync } from '../helper/certificateImportHelper';
9
9
  import { isXMLFileExt } from '../helper/dcmtsHelper';
10
10
  import { ShowConfirm } from '../components/base/TMConfirm';
11
11
  import { generateTargetFileName } from '../components/features/documents/copyAndMergeDcmtsShared';
12
+ /** Limite massimo di download paralleli accettato da `downloadDcmtsAsync`. */
13
+ const MAX_DOWNLOAD_CONCURRENCY = 8;
14
+ /** Intervallo minimo (ms) fra due aggiornamenti della progress bar di download. */
15
+ const DOWNLOAD_PROGRESS_THROTTLE_MS = 100;
12
16
  const isScannerLicenseConfigured = () => {
13
17
  try {
14
18
  const scannerLicense = SDKUI_Globals.userSettings.advancedSettings.scannerLicense;
@@ -257,7 +261,7 @@ export const useDcmtOperations = () => {
257
261
  ShowAlert({ message: SDKUI_Localizator.ZipCreatedSavedInFolder.replaceParams(zipFileName, "Zip creato con successo"), mode: 'success', duration: 5000, title: SDKUI_Localizator.CompressToZipFile });
258
262
  }
259
263
  };
260
- const _downloadDcmtsAsync = async (inputDcmts, downloadMode = "download", onFileDownloaded, skipConfirmation = false, retrieveOptions, useCache = true, showSuccessAlert = true, useInputFileName = false) => {
264
+ const _downloadDcmtsAsync = async (inputDcmts, downloadMode = "download", onFileDownloaded, skipConfirmation = false, retrieveOptions, useCache = true, showSuccessAlert = true, useInputFileName = false, downloadConcurrency = 1) => {
261
265
  if (inputDcmts === undefined)
262
266
  return;
263
267
  if (inputDcmts.length <= 0)
@@ -286,85 +290,150 @@ export const useDcmtOperations = () => {
286
290
  setShowSecondary(true);
287
291
  setWaitPanelTitle(operationTitle);
288
292
  abortController = new AbortController();
289
- const rfo = new RetrieveFileOptions();
290
- rfo.retrieveReason = retrieveOptions?.retrieveReason ?? DcmtOpers.ShowFile;
291
- if (retrieveOptions?.cvtFormat !== undefined)
292
- rfo.cvtFormat = retrieveOptions.cvtFormat;
293
- if (retrieveOptions?.generalRetrieveFormat !== undefined)
294
- rfo.generalRetrieveFormat = retrieveOptions.generalRetrieveFormat;
295
- rfo.invoiceRetrieveFormat = retrieveOptions?.invoiceRetrieveFormat ?? SDKUI_Globals.userSettings.searchSettings.invoiceRetrieveFormat;
296
- rfo.orderRetrieveFormat = retrieveOptions?.orderRetrieveFormat ?? SDKUI_Globals.userSettings.searchSettings.orderRetrieveFormat;
297
- let result = [];
298
- setWaitPanelMaxValuePrimary(inputDcmts.length);
299
- let firstBlock = true;
300
- let maxFileSize = 0;
301
- for (let i = 0; i < inputDcmts.length; i++) {
302
- if (abortController.signal.aborted) {
303
- result.push({ rowIndex: i, id1: inputDcmts[i].TID, id2: inputDcmts[i].DID, resultType: ResultTypes.WARNING, description: `Operazione interrotta. Elaborati ${i + 1} documenti` });
304
- break;
293
+ // Riferimento const: il narrowing su inputDcmts non sopravvive dentro le closure del pool.
294
+ const dcmts = inputDcmts;
295
+ /**
296
+ * Ogni richiesta usa la propria istanza di RetrieveFileOptions: con il pool più
297
+ * download sono in volo contemporaneamente e non possono condividere lo stesso oggetto.
298
+ */
299
+ const buildRetrieveFileOptions = (dcmt) => {
300
+ const rfo = new RetrieveFileOptions();
301
+ rfo.retrieveReason = retrieveOptions?.retrieveReason ?? DcmtOpers.ShowFile;
302
+ if (retrieveOptions?.cvtFormat !== undefined)
303
+ rfo.cvtFormat = retrieveOptions.cvtFormat;
304
+ if (retrieveOptions?.generalRetrieveFormat !== undefined)
305
+ rfo.generalRetrieveFormat = retrieveOptions.generalRetrieveFormat;
306
+ rfo.invoiceRetrieveFormat = retrieveOptions?.invoiceRetrieveFormat ?? SDKUI_Globals.userSettings.searchSettings.invoiceRetrieveFormat;
307
+ rfo.orderRetrieveFormat = retrieveOptions?.orderRetrieveFormat ?? SDKUI_Globals.userSettings.searchSettings.orderRetrieveFormat;
308
+ return rfo;
309
+ };
310
+ const results = new Array(dcmts.length);
311
+ setWaitPanelMaxValuePrimary(dcmts.length);
312
+ // ---- Progress secondaria: aggregata sui download in volo e throttlata ----
313
+ // Gli eventi di avanzamento di axios arrivano a ogni chunk: senza throttle ognuno
314
+ // provoca un re-render dell'intera form (relation viewer compreso).
315
+ const inFlightBytes = new Map();
316
+ let pendingSecondary;
317
+ let secondaryTimer;
318
+ const flushSecondaryProgress = () => {
319
+ secondaryTimer = undefined;
320
+ if (!pendingSecondary)
321
+ return;
322
+ setWaitPanelMaxValueSecondary(pendingSecondary.max);
323
+ setWaitPanelValueSecondary(pendingSecondary.value);
324
+ setWaitPanelTextSecondary(pendingSecondary.text);
325
+ pendingSecondary = undefined;
326
+ };
327
+ const publishSecondaryProgress = () => {
328
+ let loaded = 0;
329
+ let total = 0;
330
+ for (const bytes of inFlightBytes.values()) {
331
+ loaded += bytes.loaded;
332
+ total += bytes.total;
305
333
  }
306
- try {
307
- setWaitPanelTextPrimary(`Download file ${i + 1} di ${inputDcmts.length}`);
308
- let file;
309
- const cacheKey = `${inputDcmts[i].TID}-${inputDcmts[i].DID}`;
310
- if (useCache && dcmtsFileCacheDownload.has(cacheKey)) {
311
- file = dcmtsFileCacheDownload.get(cacheKey);
334
+ pendingSecondary = total > 0
335
+ ? { value: loaded, max: total, text: `Downloading... ${Globalization.getNumberDisplayValue(loaded, true)} / ${Globalization.getNumberDisplayValue(total, true)}` }
336
+ : { value: 0, max: 0, text: '' };
337
+ if (secondaryTimer === undefined)
338
+ secondaryTimer = setTimeout(flushSecondaryProgress, DOWNLOAD_PROGRESS_THROTTLE_MS);
339
+ };
340
+ // ---- Pool di download ----
341
+ let nextIndex = 0;
342
+ let started = 0;
343
+ let completed = 0;
344
+ let abortReported = false;
345
+ const downloadOne = async (i) => {
346
+ started++;
347
+ setWaitPanelTextPrimary(`Download file ${started} di ${dcmts.length}`);
348
+ let file;
349
+ const cacheKey = `${dcmts[i].TID}-${dcmts[i].DID}`;
350
+ if (useCache && dcmtsFileCacheDownload.has(cacheKey)) {
351
+ file = dcmtsFileCacheDownload.get(cacheKey);
352
+ }
353
+ else {
354
+ inFlightBytes.set(i, { loaded: 0, total: 0 });
355
+ try {
356
+ file = await SDK_Globals.tmSession?.NewSearchEngine().RetrieveFileAsync(dcmts[i].TID, dcmts[i].DID, buildRetrieveFileOptions(dcmts[i]), abortController.signal, (pd) => {
357
+ inFlightBytes.set(i, { loaded: pd.ProgressBarValue, total: pd.ProgressBarMaximum ?? 0 });
358
+ publishSecondaryProgress();
359
+ });
360
+ }
361
+ finally {
362
+ inFlightBytes.delete(i);
363
+ publishSecondaryProgress();
364
+ }
365
+ }
366
+ completed++;
367
+ setWaitPanelValuePrimary(completed);
368
+ if (downloadMode === "openInNewWindow") {
369
+ if (onFileDownloaded && file) {
370
+ onFileDownloaded(file, dcmts[i]);
312
371
  }
313
372
  else {
314
- file = await SDK_Globals.tmSession?.NewSearchEngine().RetrieveFileAsync(inputDcmts[i].TID, inputDcmts[i].DID, rfo, abortController.signal, (pd) => {
315
- if (firstBlock) {
316
- maxFileSize = pd.ProgressBarMaximum ?? 0;
317
- setWaitPanelMaxValueSecondary(maxFileSize);
318
- firstBlock = false;
319
- }
320
- setWaitPanelValueSecondary(pd.ProgressBarValue);
321
- setWaitPanelTextSecondary(`Downloading... ${Globalization.getNumberDisplayValue(pd.ProgressBarValue, true)} / ${Globalization.getNumberDisplayValue(maxFileSize, true)}`);
322
- if (pd.ProgressBarValue === pd.ProgressBarMaximum) {
323
- setWaitPanelMaxValueSecondary(0);
324
- setWaitPanelValueSecondary(0);
325
- setWaitPanelTextSecondary('');
326
- firstBlock = true;
327
- }
328
- });
373
+ window.open(window.URL.createObjectURL(file), '_blank', 'noopener');
329
374
  }
330
- setWaitPanelValuePrimary(i + 1);
331
- const fileURL = window.URL.createObjectURL(file);
332
- if (downloadMode === "openInNewWindow") {
333
- (onFileDownloaded && file)
334
- ? onFileDownloaded(file, inputDcmts[i])
335
- : window.open(fileURL, '_blank', 'noopener');
375
+ }
376
+ else {
377
+ if (onFileDownloaded && file) {
378
+ await onFileDownloaded(file, dcmts[i]);
336
379
  }
337
380
  else {
338
- if (onFileDownloaded && file) {
339
- await onFileDownloaded(file, inputDcmts[i]);
340
- }
341
- else {
342
- const alink2 = document.createElement('a');
343
- alink2.href = fileURL;
344
- // Usa useInputFileName per determinare la priorità del nome file
345
- alink2.download = getDownloadFileName(file, inputDcmts[i], useInputFileName);
346
- alink2.target = "_blank";
347
- alink2.rel = "noreferrer";
348
- alink2.click();
349
- }
381
+ // L'object URL serve solo in questo ramo: creato qui e revocato dopo il
382
+ // click, così i blob non restano agganciati al documento per sempre.
383
+ const fileURL = window.URL.createObjectURL(file);
384
+ const alink2 = document.createElement('a');
385
+ alink2.href = fileURL;
386
+ // Usa useInputFileName per determinare la priorità del nome file
387
+ alink2.download = getDownloadFileName(file, dcmts[i], useInputFileName);
388
+ alink2.target = "_blank";
389
+ alink2.rel = "noreferrer";
390
+ alink2.click();
391
+ // 60s come per l'iframe di stampa: revocare troppo presto può far fallire
392
+ // il download se il browser non ha ancora risolto il blob URL.
393
+ setTimeout(() => window.URL.revokeObjectURL(fileURL), 60000);
350
394
  }
351
- if (useCache) {
352
- if (dcmtsFileCacheDownload.size >= CACHE_SIZE_LIMIT) {
353
- const oldestKey = dcmtsFileCacheDownload.keys().next().value;
354
- dcmtsFileCacheDownload.delete(oldestKey);
355
- }
356
- dcmtsFileCacheDownload.set(cacheKey, file);
395
+ }
396
+ if (useCache) {
397
+ if (dcmtsFileCacheDownload.size >= CACHE_SIZE_LIMIT) {
398
+ const oldestKey = dcmtsFileCacheDownload.keys().next().value;
399
+ dcmtsFileCacheDownload.delete(oldestKey);
357
400
  }
358
- result.push({ rowIndex: i, id1: inputDcmts[i].TID, id2: inputDcmts[i].DID, resultType: ResultTypes.SUCCESS });
401
+ dcmtsFileCacheDownload.set(cacheKey, file);
359
402
  }
360
- catch (ex) {
361
- result.push({ rowIndex: i, id1: inputDcmts[i].TID, id2: inputDcmts[i].DID, resultType: ResultTypes.ERROR, description: getExceptionMessage(ex) });
362
- const err = ex;
363
- if (err.name === 'CanceledError') {
364
- ShowAlert({ message: err.message, mode: 'warning', duration: 3000, title: 'Abort' });
403
+ results[i] = { rowIndex: i, id1: dcmts[i].TID, id2: dcmts[i].DID, resultType: ResultTypes.SUCCESS };
404
+ };
405
+ const runWorker = async () => {
406
+ while (true) {
407
+ const i = nextIndex++;
408
+ if (i >= dcmts.length)
409
+ return;
410
+ if (abortController.signal.aborted) {
411
+ // Un solo avviso di interruzione anche quando più worker si fermano insieme.
412
+ if (!abortReported) {
413
+ abortReported = true;
414
+ results[i] = { rowIndex: i, id1: dcmts[i].TID, id2: dcmts[i].DID, resultType: ResultTypes.WARNING, description: `Operazione interrotta. Elaborati ${completed} documenti` };
415
+ }
416
+ return;
417
+ }
418
+ try {
419
+ await downloadOne(i);
420
+ }
421
+ catch (ex) {
422
+ results[i] = { rowIndex: i, id1: dcmts[i].TID, id2: dcmts[i].DID, resultType: ResultTypes.ERROR, description: getExceptionMessage(ex) };
423
+ const err = ex;
424
+ if (err.name === 'CanceledError') {
425
+ ShowAlert({ message: err.message, mode: 'warning', duration: 3000, title: 'Abort' });
426
+ }
365
427
  }
366
428
  }
367
- }
429
+ };
430
+ // `|| 1` intercetta NaN/0/negativi: senza, Array.from({length: NaN}) non creerebbe
431
+ // alcun worker e il download terminerebbe in silenzio senza scaricare nulla.
432
+ const workers = Math.max(1, Math.min(Math.trunc(downloadConcurrency) || 1, MAX_DOWNLOAD_CONCURRENCY, dcmts.length));
433
+ await Promise.all(Array.from({ length: workers }, () => runWorker()));
434
+ if (secondaryTimer !== undefined)
435
+ clearTimeout(secondaryTimer);
436
+ const result = results.filter((r) => r !== undefined);
368
437
  setWaitPanelTextPrimary('');
369
438
  setWaitPanelMaxValuePrimary(0);
370
439
  setWaitPanelValuePrimary(0);
@@ -436,12 +505,13 @@ export const useDcmtOperations = () => {
436
505
  };
437
506
  const downloadDcmtsAsync = async (params) => {
438
507
  const { inputDcmts, downloadType = DownloadTypes.Attachment, downloadMode = "download", onFileDownloaded, confirmAttachments, skipConfirmation = false, retrieveOptions, useCache = true, showSuccessAlert = true, useInputFileName = false, // Default: usa il nome dal backend
508
+ downloadConcurrency = 1, // Default: sequenziale
439
509
  } = params;
440
510
  switch (downloadType) {
441
511
  // Per il download di documenti, passa useInputFileName per controllare la priorità del nome
442
- case DownloadTypes.Dcmt: return await _downloadDcmtsAsync(inputDcmts, downloadMode, onFileDownloaded, skipConfirmation, retrieveOptions, useCache, showSuccessAlert, useInputFileName);
512
+ case DownloadTypes.Dcmt: return await _downloadDcmtsAsync(inputDcmts, downloadMode, onFileDownloaded, skipConfirmation, retrieveOptions, useCache, showSuccessAlert, useInputFileName, downloadConcurrency);
443
513
  case DownloadTypes.Attachment: return await _downloadAttachmentsAsync(inputDcmts, confirmAttachments, showSuccessAlert);
444
- default: return await _downloadDcmtsAsync(inputDcmts, undefined, undefined, skipConfirmation, retrieveOptions, useCache, showSuccessAlert, useInputFileName);
514
+ default: return await _downloadDcmtsAsync(inputDcmts, undefined, undefined, skipConfirmation, retrieveOptions, useCache, showSuccessAlert, useInputFileName, downloadConcurrency);
445
515
  }
446
516
  };
447
517
  const uploadDcmtsAsync = async (inputDcmts, operationTitle, operType, actionAfterOperationAsync) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topconsultnpm/sdkui-react",
3
- "version": "6.22.0-dev3.7",
3
+ "version": "6.22.0-dev3.9",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",