data-primals-engine 1.4.2 → 1.5.0

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.
Files changed (59) hide show
  1. package/README.md +878 -856
  2. package/client/package-lock.json +82 -0
  3. package/client/package.json +2 -0
  4. package/client/src/App.jsx +1 -1
  5. package/client/src/App.scss +25 -7
  6. package/client/src/AssistantChat.scss +3 -2
  7. package/client/src/ConditionBuilder.jsx +1 -1
  8. package/client/src/ConditionBuilder2.jsx +2 -1
  9. package/client/src/DashboardView.jsx +569 -569
  10. package/client/src/DataEditor.jsx +376 -368
  11. package/client/src/DataLayout.jsx +4 -10
  12. package/client/src/DataTable.jsx +858 -817
  13. package/client/src/Field.jsx +1825 -1784
  14. package/client/src/FlexDataRenderer.jsx +2 -0
  15. package/client/src/FlexTreeUtils.js +1 -1
  16. package/client/src/GeolocationField.jsx +94 -0
  17. package/client/src/KPIDialog.jsx +11 -1
  18. package/client/src/ModelCreator.jsx +1 -2
  19. package/client/src/ModelCreatorField.jsx +24 -27
  20. package/client/src/ModelList.jsx +1 -1
  21. package/client/src/RelationField.jsx +2 -2
  22. package/client/src/constants.js +3 -3
  23. package/client/src/filter.js +0 -155
  24. package/client/src/hooks/useTutorials.jsx +62 -65
  25. package/client/src/translations.js +14 -2
  26. package/package.json +2 -1
  27. package/perf/README.md +147 -0
  28. package/perf/artillery-hooks.js +37 -0
  29. package/perf/perf-shot-hardwork.yml +84 -0
  30. package/perf/perf-shot-search.yml +45 -0
  31. package/perf/setup.yml +26 -0
  32. package/server.js +1 -1
  33. package/src/constants.js +264 -31
  34. package/src/core.js +15 -1
  35. package/src/data.js +1 -1
  36. package/src/defaultModels.js +1544 -1540
  37. package/src/email.js +5 -2
  38. package/src/engine.js +10 -3
  39. package/src/filter.js +274 -260
  40. package/src/i18n.js +187 -177
  41. package/src/modules/assistant/assistant.js +3 -1
  42. package/src/modules/bucket.js +12 -15
  43. package/src/modules/data/data.backup.js +11 -8
  44. package/src/modules/data/data.core.js +2 -1
  45. package/src/modules/data/data.js +6 -3
  46. package/src/modules/data/data.operations.js +610 -168
  47. package/src/modules/data/data.routes.js +1821 -1785
  48. package/src/modules/data/data.scheduling.js +2 -1
  49. package/src/modules/data/data.validation.js +7 -1
  50. package/src/modules/file.js +4 -2
  51. package/src/modules/user.js +4 -1
  52. package/src/modules/workflow.js +9 -10
  53. package/src/openai.jobs.js +2 -0
  54. package/src/packs.js +22 -5
  55. package/src/providers.js +22 -7
  56. package/swagger-en.yml +133 -0
  57. package/test/data.integration.test.js +1060 -981
  58. package/test/import_export.integration.test.js +1 -1
  59. package/test/model.integration.test.js +377 -221
@@ -1,817 +1,858 @@
1
- // Composant pour afficher les données (DataTable basique)
2
- import {useModelContext} from "./contexts/ModelContext.jsx";
3
- import {Trans, useTranslation} from "react-i18next";
4
- import {useMutation, useQuery, useQueryClient} from "react-query";
5
- import {useAuthContext} from "./contexts/AuthContext.jsx";
6
- import React, {useEffect, useMemo, useRef, useState} from "react";
7
- import {getUserId} from "../../src/data.js";
8
- import cronstrue from 'cronstrue/i18n';
9
- import {Event} from "../../src/events.js";
10
-
11
- import {
12
- FaBook,
13
- FaCopy,
14
- FaDatabase, FaEraser,
15
- FaFileExport,
16
- FaFileImport,
17
- FaFilter, FaHistory,
18
- FaInfo,
19
- FaPlus,
20
- FaTrash, FaWrench
21
- } from "react-icons/fa";
22
- import {useNotificationContext} from "./NotificationProvider.jsx";
23
- import {
24
- elementsPerPage,
25
- kilobytes,
26
- maxBytesPerSecondThrottleData,
27
- maxFileSize,
28
- maxRequestData
29
- } from "../../src/constants.js";
30
- import Button from "./Button.jsx";
31
- import {Dialog, DialogProvider} from "./Dialog.jsx";
32
- import {
33
- CheckboxField,
34
- CodeField,
35
- ColorField,
36
- FileField,
37
- FilterField,
38
- ModelField,
39
- PhoneField,
40
- TextField
41
- } from "./Field.jsx";
42
- import RelationValue from "./RelationValue.jsx";
43
- import {FaGear, FaPencil, FaTriangleExclamation} from "react-icons/fa6";
44
- import RestoreConfirmationModal from "./RestoreConfirmationModal.jsx";
45
- import {event_trigger, isLightColor} from "../../src/core.js";
46
- import {Tooltip} from "react-tooltip";
47
- import ExportDialog from "./ExportDialog.jsx";
48
- import Captions from "yet-another-react-lightbox/plugins/captions";
49
- import {Zoom} from "yet-another-react-lightbox/plugins";
50
- import Lightbox from "yet-another-react-lightbox";
51
-
52
- import "./DataTable.scss"
53
- import useLocalStorage from "./hooks/useLocalStorage.js";
54
- import TutorialsMenu from "../src/TutorialsMenu.jsx";
55
- import {useTutorials} from "./hooks/useTutorials.jsx";
56
- import PackGallery from "./PackGallery.jsx";
57
- import {HiddenableCell} from "./HiddenableCell.jsx";
58
- import ConditionBuilder from "./ConditionBuilder.jsx";
59
- import {pagedFilterToMongoConds} from "./filter.js";
60
- import {isConditionMet} from "../../src/filter";
61
- import {DataImporter} from "./DataImporter.jsx";
62
- import {HistoryDialog} from "./HistoryDialog.jsx";
63
-
64
- const Header = ({
65
- reversed = false,
66
- handleFilter,
67
- model,
68
- setCheckedItems,
69
- checkedItems,
70
- data,
71
- filterActive,
72
- onChangeFilterValue,
73
- setFilterValues,
74
- filterValues,
75
- advanced=true
76
- }) => {
77
-
78
- const {t} = useTranslation()
79
- const { me } = useAuthContext()
80
- const {
81
- models,
82
- countByModel,
83
- selectedModel,
84
- setPagedFilters,
85
- pagedFilters,
86
- page
87
- } = useModelContext();
88
- let totalCol = 0;
89
-
90
- const [advancedFilterVisible, setAdvancedFilterVisible] = useState(false);
91
-
92
- const handleAdvancedFilter = () => {
93
- setAdvancedFilterVisible(true);
94
- }
95
- return <><tr className={reversed ? ' reversed' : ''}>
96
- {advanced && (<th className={"mini"}>
97
- <div className="flex flex-row">
98
-
99
- <Button onClick={handleFilter} className={filterActive ? ' active' : ''}><FaFilter/></Button>
100
- {filterActive && <Button onClick={() => handleAdvancedFilter()}><FaWrench /></Button>}
101
- <CheckboxField checked={checkedItems?.length === data.length} onChange={e => {
102
- if (checkedItems?.length === data.length) {
103
- setCheckedItems([]);
104
- } else {
105
- setCheckedItems(data);
106
- }
107
- }}/>
108
-
109
- <DialogProvider>
110
- {advancedFilterVisible && (
111
- <Dialog title={t("datatable.advancedFilter.title")} isClosable={true} onClose={() => setAdvancedFilterVisible(false)}>
112
- <h3>Edit filter</h3>
113
- <div className="msg">
114
- <Trans i18nKey={"datatable.advancedFilter.desc"}>{t("datatable.advancedFilter.desc")}</Trans>
115
- </div>
116
- <ConditionBuilder onChange={(c) => {
117
- setPagedFilters(pagedFilters => ({
118
- ...pagedFilters,
119
- [model.name]: c || pagedFilters[model.name] || {} }));
120
- }} initialValue={{ $and: pagedFilterToMongoConds(pagedFilters, model)}} models={models} model={model.name} checkedItems={checkedItems} setCheckedItems={setCheckedItems} data={data} filterActive={filterActive} onChangeFilterValue={onChangeFilterValue} setFilterValues={setFilterValues}/>
121
- <Button onClick={() =>{
122
- setPagedFilters(pagedFilters => ({
123
- ...pagedFilters,
124
- [model.name]: {}}));
125
- }}><Trans i18nKey={"btns.reset"}>Reset</Trans></Button>
126
- </Dialog>
127
- )}
128
- </DialogProvider>
129
- </div>
130
- </th>)}
131
- {(model?.fields || []).map(field => {
132
- if (field.type === 'password')
133
- return <></>;
134
- if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
135
- return <th className={"empty"} key={"datatable-th-"+model.name+":"+field.name}>
136
- <div className="flex flex-mini-gap flex-centered">
137
- <FaTriangleExclamation color={"#df8107"} data-tooltip-id={`tooltip-desc`} data-tooltip-content={"\""+field.relation + "\" model not found"} />
138
- {field.name}
139
- <Tooltip id={"tooltip-desc"} render={({content}) => {
140
- gtag('render hint '+field.relation);
141
- return content;
142
- }} />
143
- </div>
144
- </th>;
145
-
146
- totalCol++;
147
- return <FilterField advanced={advanced} reversed={reversed} filterValues={filterValues} setFilterValues={setFilterValues}
148
- model={model} field={field} active={filterActive}
149
- onChangeFilterValue={onChangeFilterValue}/>;
150
- })}
151
- {advanced && (<th><Trans i18nKey="actions">Actions</Trans>
152
- {Object.keys(pagedFilters[model.name] || {})
153
- .filter(f=> Object.keys(pagedFilters[model.name]?.[f] || {}).length > 0).length > 0 && <div>
154
- <button onClick={() => {
155
- setFilterValues({});
156
- setPagedFilters(pagedFilters => ({...pagedFilters, [model.name]: {}}));
157
- }}><FaEraser />
158
- </button>
159
- </div>}</th>)}
160
- </tr>
161
- {reversed && (
162
- <tr>
163
- {countByModel?.[selectedModel?.name] === 0 &&
164
- <td colSpan={totalCol + 2}><Trans i18nKey="no_data">Aucune donnée enregistrée</Trans></td>}
165
- </tr>)}
166
- </>
167
- ;
168
- }
169
-
170
- const formatDuration = (totalSeconds, t) => {
171
- if (totalSeconds === null || totalSeconds === undefined || isNaN(totalSeconds) || totalSeconds === '') {
172
- return '';
173
- }
174
- const total = parseInt(totalSeconds, 10);
175
- if (total === 0) return '0s';
176
-
177
- const d = Math.floor(total / 86400);
178
- const h = Math.floor((total % 86400) / 3600);
179
- const m = Math.floor((total % 3600) / 60);
180
- const s = Math.floor(total % 60);
181
-
182
- const parts = [];
183
- if (d > 0) parts.push(t('duration.day', { count: d }));
184
- if (h > 0) parts.push(t('duration.hour', { count: h }));
185
- if (m > 0) parts.push(t('duration.minute', { count: m }));
186
- if (s > 0) parts.push(t('duration.second', { count: s }));
187
-
188
- if (parts.length === 0) return '0s';
189
-
190
- return parts.slice(0, 2).join(', ');
191
- };
192
-
193
-
194
- const RichText = ({ value, initialLang }) => {
195
- const availableLangs = useMemo(() => (value ? Object.keys(value).filter(k => value[k]) : []), [value]);
196
-
197
- const [selectedLang, setSelectedLang] = useState(() => {
198
- if (availableLangs.includes(initialLang)) {
199
- return initialLang;
200
- }
201
- return availableLangs[0] || null;
202
- });
203
-
204
- useEffect(() => {
205
- setSelectedLang(initialLang);
206
- }, [initialLang]);
207
-
208
- if (!value || availableLangs.length === 0) {
209
- return null;
210
- }
211
-
212
- if (availableLangs.length === 1) {
213
- return <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[availableLangs[0]] || '' }} />;
214
- }
215
-
216
- return (
217
- <div className="richtext-t-cell">
218
- <div className="lang-switcher">
219
- {availableLangs.map(langCode => (
220
- <button
221
- key={langCode}
222
- className={`lang-btn ${selectedLang === langCode ? 'active' : ''}`}
223
- onClick={(e) => {
224
- e.stopPropagation();
225
- setSelectedLang(langCode);
226
- }}
227
- title={langCode.toUpperCase()}
228
- >
229
- {langCode.toUpperCase()}
230
- </button>
231
- ))}
232
- </div>
233
- <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[selectedLang] || '' }} />
234
- </div>
235
- );
236
- };
237
-
238
- export function DataTable({
239
- model,
240
- checkedItems,
241
- setCheckedItems = () => {},
242
- onEdit,
243
- onAddData,
244
- onDuplicateData,
245
- onDelete,
246
- onShowAPI,
247
- filterValues,
248
- setFilterValues = () => {},
249
- data: propData, // NOUVEAU: Accepter les données via les props
250
- advanced=true
251
-
252
- }) {
253
- const {
254
- models,
255
- elementsPerPage,
256
- paginatedDataByModel,
257
- countByModel,
258
- pagedSort,
259
- selectedModel,
260
- pagedFilters,
261
- setPagedFilters,
262
- page
263
- } = useModelContext();
264
- const {t, i18n} = useTranslation();
265
- const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
266
- const queryClient = useQueryClient();
267
- const {me} = useAuthContext();
268
-
269
- // Si des données sont passées en props, on les utilise, sinon on prend celles du contexte.
270
- const data = propData || paginatedDataByModel[model?.name] || [];
271
-
272
- const isDataLoaded = true;
273
- const [importVisible, setImportVisible] = useState(false);
274
- const [filterActive, setFilterActive] = useState(false)
275
-
276
- const [showPackGallery, setShowPackGallery] = useState(false);
277
- const [showExportDialog, setExportDialogVisible] = useState(false);
278
-
279
-
280
- const [selectedRow, setSelectedRow] = useState(null);
281
-
282
- const [showAddPack, setAddPackVisible] = useState(false);
283
- const handleAddPack = () => {
284
- setAddPackVisible(!showAddPack);
285
- }
286
- const handleShowPacks = () => {
287
- setShowPackGallery(true);
288
- };
289
- const handleDelete = async (item) => {
290
- if (model && item && item._id) { // Assurez-vous d'avoir un identifiant unique pour chaque élément
291
- try {
292
- const response = await fetch(`/api/data/${item._id}?_user=${encodeURIComponent(getUserId(me))}`, { // Assurez-vous que l'URL est correcte
293
- method: 'DELETE',
294
- });
295
-
296
- if (response.ok) {
297
- const res = await response.json();
298
-
299
- if (res.success) {
300
- onDelete?.(item);
301
- const notificationData = {
302
- id: 'datatable.deleteData.success',
303
- title: t('datatable.deleteData.success', 'Données supprimées avec succès'),
304
- icon: <FaInfo/>,
305
- status: 'completed'
306
- };
307
- addNotification(notificationData);
308
- } else {
309
- const notificationData = {
310
- title: res.message,
311
- status: 'error'
312
- };
313
- addNotification(notificationData);
314
- console.log('Données non trouvées');
315
- }
316
-
317
- await Event.Trigger('API_DELETE_DATA', "custom", "data",{ model: item._model, id: item._id });
318
- queryClient.invalidateQueries(['api/data', item._model, 'page', page, elementsPerPage, pagedFilters[item._model], pagedSort[item._model]]);
319
-
320
- } else {
321
- console.error('Erreur lors de la suppression des données');
322
- // Gérer les erreurs (afficher un message à l'utilisateur, etc.)
323
- }
324
- } catch (error) {
325
- const notificationData = {
326
- title: error.message,
327
- status: 'error'
328
- };
329
- addNotification(notificationData);
330
- console.error('Erreur lors de la suppression des données:', error);
331
- }
332
- } else {
333
- console.warn("Impossible de supprimer, l'élément ne possède pas d'ID");
334
- }
335
- };
336
-
337
- const handleEdit = (item) => {
338
- onEdit(item); // Call onEdit with the item to edit
339
- }
340
-
341
- const onChangeFilterValue = (field, value, tr) => {
342
- setPagedFilters(pagedFilters => ({
343
- ...pagedFilters, [model.name]: {...pagedFilters[model.name] || {}, [field.name]: value || pagedFilters[model.name]?.[field.name] || undefined}
344
- }));
345
- queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
346
- }
347
-
348
- const {addNotification} = useNotificationContext();
349
-
350
- const {mutate: exportMutation, isLoading} = useMutation(async (data) => {
351
-
352
- console.log('Exportation des données');
353
- const params = new URLSearchParams();
354
- params.append('model', model.name);
355
- params.append("_user", getUserId(me));
356
-
357
- // Cas de la table de données : requête paginée
358
- params.append('limit', maxRequestData + '');
359
- params.append('attachment', '1');
360
- params.append("depth", data.depth+"");
361
- if( data.withModels )
362
- params.append("withModels", "1");
363
-
364
- const ids = data.exportSelection ? checkedItems.map(item => item._id) : [];
365
- const body = JSON.stringify({filter: pagedFilters[model.name], models: data.models || [], ids});
366
-
367
- params.append('ids', ids);
368
- console.log('Fetch des données');
369
- return fetch(`/api/data/export?lang=${lang}&${params.toString()}`, {
370
- method: 'POST',
371
- body,
372
- headers: {"Content-Type": "application/json"}
373
- })
374
- .then(async resp => {
375
- if( resp.status === 200 )
376
- return resp.blob();
377
- else {
378
- const res = await resp.json();
379
- throw new Error(res.error || 'something went wrong')
380
- }
381
- })
382
- .then((blob) => {
383
- const url = window.URL.createObjectURL(blob);
384
- const a = document.createElement('a');
385
- a.style.display = 'none';
386
- a.href = url;
387
- // the filename you want
388
- a.download = model.name + '.dump.json';
389
- document.body.appendChild(a);
390
- a.click();
391
- window.URL.revokeObjectURL(url);
392
- })
393
- .then(e => {
394
- const notificationData = {
395
- title: e.success ? t('dataimporter.success', 'Exportation de ' + model.name + ' réussie'): e.error,
396
- icon: e.success ? <FaInfo/> : null,
397
- status: e.success ? 'completed' : 'error'
398
- };
399
- addNotification(notificationData);
400
- return e;
401
- }).catch(e => {
402
- const notificationData = {
403
- title: e.message,
404
- status: 'error'
405
- };
406
- addNotification(notificationData);
407
- });
408
- });
409
-
410
- const handleExport = () => {
411
- setExportDialogVisible(true)
412
- }
413
- const handleImport = () => {
414
- setImportVisible(true);
415
- }
416
- const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
417
-
418
- const handleBackup = () => {
419
- setIsBackupModalOpen(true);
420
- }
421
-
422
-
423
- const handleConfirmRestore = async () => {
424
- try {
425
- // Make the API call to request the restore link
426
- const response = await fetch('/api/backup/request-restore', {
427
- method: 'POST',
428
- // ... other options ...
429
- });
430
-
431
- const result = await response.json();
432
- if (response.ok && result.message) {
433
- addNotification({ status: 'completed', title: result.message });
434
- } else {
435
- addNotification({ status: 'error', title: result.error || t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
436
- }
437
- } catch (error) {
438
- addNotification({ status: 'error', title: t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
439
- console.error('Error requesting restore link:', error);
440
- } finally {
441
- setIsBackupModalOpen(false); // Close the modal
442
- }
443
- };
444
-
445
- const handleFilter = () => {
446
- setFilterActive(!filterActive);
447
- }
448
-
449
-
450
- const [lightboxIndex, setLightboxIndex] = useState(0);
451
- const [lightboxOpened, setLightboxOpened] = useState(false);
452
- const [lightboxSlides, setLightboxSlides] = useState([]);
453
- const [tutorialDialogVisible, setTutorialDialogVisible] = useState(false);
454
-
455
- const [newPackName, setNewPackName] = useState(''); // pack
456
-
457
- const handleShowTutorialMenu = () => {
458
- setTutorialDialogVisible(!tutorialDialogVisible);
459
- }
460
-
461
- if (!model)
462
- return <></>;
463
-
464
- // NOUVEAU : La fonction qui gère la duplication
465
- const handleDuplicate = (originalData) => {
466
- // 1. Créer une copie superficielle des données de la ligne
467
- const dataToDuplicate = { ...originalData };
468
-
469
- // 2. TRÈS IMPORTANT : Supprimer les champs qui ne doivent pas être copiés.
470
- // - L'_id doit être supprimé pour que le système sache qu'il s'agit d'une NOUVELLE entrée.
471
- // - Le _hash sera recalculé par le backend.
472
- // - Les dates de création/mise à jour seront gérées par le backend.
473
- delete dataToDuplicate._id;
474
- delete dataToDuplicate._hash;
475
- delete dataToDuplicate.createdAt; // si ce champ existe
476
- delete dataToDuplicate.updatedAt; // si ce champ existe
477
-
478
- onDuplicateData(dataToDuplicate);
479
- };
480
- return (
481
- <div className={`datatable${filterActive ? ' filter-active' : ''}`}>
482
- {advanced && <div className="flex actions flex-left">
483
- <Button onClick={() => onAddData(model)}><FaPlus/><Trans i18nKey="btns.addData">Ajouter une
484
- donnée</Trans></Button>
485
- <Button onClick={handleImport} title={t("btns.import")}><FaFileImport/><Trans
486
- i18nKey="btns.import">Importer</Trans></Button>
487
- <Button disabled={isLoading} onClick={handleExport} title={t("btns.export")}><FaFileExport/><Trans
488
- i18nKey="btns.export">Exporter</Trans></Button>
489
- <Button className="tourStep-import-datapack" onClick={handleShowPacks} title={t("btns.addPack")}><FaPlus/><Trans
490
- i18nKey="btns.addPack">Packs...</Trans></Button>
491
-
492
- <Button className={"tourStep-tutorials " + (/^demo[0-9]{1,2}$/.test(me.username) ? "btn-primary" : "btn")} onClick={handleShowTutorialMenu} title={t("btns.showTutos")}><FaPlus/><Trans
493
- i18nKey="btns.showTutos">Tutoriels</Trans></Button>
494
- {!/^demo[0-9]{1,2}$/.test(me.username) && (<Button onClick={handleBackup} title={t("btns.backup")}><FaDatabase/><Trans
495
- i18nKey="btns.backup">Backup</Trans></Button>)}
496
- <DialogProvider>
497
- {tutorialDialogVisible && (
498
- <Dialog isClosable={true} isModal={true} onClose={() => setTutorialDialogVisible(false)}>
499
- <TutorialsMenu />
500
- </Dialog>
501
- )}
502
- </DialogProvider>
503
- <Button className="btn btn-primary" onClick={() => {
504
- onShowAPI(selectedModel);
505
- }}><FaBook/> {t('btns.api', 'API')}</Button>
506
- </div>}
507
- <div className={"table-wrapper"}>
508
- <Tooltip id={"tooltipFile"} clickable={true} />
509
- <Tooltip id={"tooltipActions"} />
510
- <Lightbox
511
- inline={{
512
- style: { width: "100%", maxWidth: "900px", aspectRatio: "3 / 2" },
513
- }} plugins={[Captions,Zoom]} index={lightboxIndex} open={lightboxOpened} close={()=> setLightboxOpened(false)}
514
- slides={lightboxSlides}
515
- on={{
516
- click: ()=>{
517
-
518
- }
519
- }}
520
- />
521
- {isDataLoaded && (
522
- <table>
523
- <thead>
524
- <Header advanced={advanced} model={model} setCheckedItems={setCheckedItems} filterValues={filterValues} data={data} setFilterValues={setFilterValues} onChangeFilterValue={onChangeFilterValue} checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>
525
- </thead>
526
- <tbody>
527
- {(data || []).map((item) => (
528
- <><DialogProvider>
529
- <tr key={item._id} onDoubleClick={() => onEdit(item)} onClick={(e) => {
530
- const checked = (!e.target.closest('tr').querySelector('td:first-child input')?.checked);
531
- if (checked) {
532
- setCheckedItems(items => {
533
- return [...items, item];
534
- });
535
- } else {
536
- setCheckedItems(items => items.filter(i => i._id !== item._id));
537
- }
538
- }}>
539
- {advanced && (<td className={"mini"}>
540
- <CheckboxField className={"input-ref"}
541
- checked={checkedItems?.some(i => i._id === item._id)}
542
- onChange={(e) => {
543
- if (e) {
544
- setCheckedItems(items => {
545
- return [...items, item];
546
- });
547
- } else {
548
- setCheckedItems(items => items.filter(i => i._id !== item._id));
549
- }
550
- }}/></td>)}
551
- {(model?.fields ||[]).map(field => {
552
-
553
- if( !isConditionMet(model, field.condition, item, models, me)){
554
- return <td className={"notmet"} key={item._id + field.name}></td>; // Do not render the header cell if the condition isn't met
555
- }
556
-
557
- const hiddenable = (content) => {
558
- if(field.hiddenable)
559
- return <HiddenableCell value={content} />;
560
- return content;
561
- }
562
- if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
563
- return <td className={"empty"} key={item._id + field.name}></td>
564
- if (field.type === "relation" && typeof field.relation === "string") {
565
- return <td key={item._id + field.name} style={{backgroundColor: field.color}}>{hiddenable(<RelationValue field={field}
566
- data={item}/>)}</td>;
567
- }
568
- if( field.type === "cronSchedule" ){
569
- let val = '';
570
- try {
571
- val = cronstrue.toString(item[field.name], { locale: lang, throwExceptionOnParseError: true });
572
- } catch (e) {
573
-
574
- }
575
- return <td
576
- className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
577
- key={field.name}>{hiddenable(val)}</td>;
578
- }
579
- if (field.type === "date" && item[field.name]) {
580
- return <td
581
- className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
582
- key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
583
- day: "numeric",
584
- month: "numeric",
585
- year: "numeric"
586
- }))}</td>;
587
- }
588
- if (field.type === "datetime" && item[field.name]) {
589
- return <td
590
- className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
591
- key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
592
- day: "numeric",
593
- month: "numeric",
594
- year: "numeric",
595
- hour: "numeric",
596
- minute: "numeric"
597
- }))}</td>;
598
- }
599
- if (field.type === "enum") {
600
- return <td
601
- key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(field.items.includes(item[field.name]) ? `${t(item[field.name], item[field.name])}` : `${item[field.name] || ''}`)}</td>;
602
- }
603
- if (field.type === "code") {
604
- const v = typeof(item[field.name]) === "string" ? item[field.name] : (item[field.name] ? JSON.stringify(item[field.name], null, 2) : '');
605
- return <td
606
- key={field.name}>{v && hiddenable(<CodeField language={field.language} name={field.name} value={v} disabled={true} />)}</td>;
607
- }
608
- if (field.type === "object") {
609
- return <td key={field.name}>{hiddenable(<CodeField language={'json'} name={field.name} value={item[field.name] ? JSON.stringify(item[field.name], null, 2) : ''} disabled={true} />)}</td>;
610
- }
611
- if (field.type === 'email') {
612
- return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}><a href={"mailto:"+item[field.name]} style={{color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name])}</a></td>;
613
- }
614
- if (field.type === 'phone') {
615
- return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] && <PhoneField name={"phone"} value={item[field.name]} disabled={true} onChange={() => {}}></PhoneField>)}</td>;
616
- }
617
- if (field.type === 'model') {
618
- return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] ? `${t('model_'+item[field.name], item[field.name])} (${item[field.name]})`:'')}</td>;
619
- }
620
- if (field.type === 'password') {
621
- return <></>;
622
- }
623
- if (field.type === 'array') {
624
- let t = <></>
625
- if (field.itemsType === 'file') {
626
- const click = (e,i) => {
627
- setLightboxIndex(i);
628
- setLightboxOpened(true);
629
- setLightboxSlides(item[field.name].map(s => ({
630
- src: `/resources/${s.guid}`,
631
- title: s.name
632
- })));
633
- e.preventDefault();
634
- };
635
- t = (item[field.name] || []).map((it,i) => {
636
-
637
- const r = `
638
- <strong>filename</strong> : ${it.filename}<br />
639
- <strong>guid</strong> : ${it.guid}<br />
640
- <strong>type</strong> : ${it.mimeType}<br />
641
- <strong>size</strong> : ${it.size} bytes<br />
642
- <strong>timestamp</strong> : ${it.timestamp ? new Date(it.timestamp).toLocaleString(lang) : ''}
643
- `;
644
- return <a key={it.guid} href={`/resources/${it.guid}`} target="_blank"
645
- data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
646
- rel="noopener noreferrer" onClick={(e) => click(e,i)}><img
647
- className="image" src={`/resources/${it.guid}`}
648
- alt={`${it.name} (${it.guid})`}/></a>
649
- });
650
- return <td key={field.name}>
651
- {hiddenable(<div className="gallery">{t}</div>)}
652
- </td>;
653
- }
654
- return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name]?.join(', ') || '')}</td>;
655
- }
656
- if (field.type === 'url') {
657
- return <td key={field.name}>
658
- {hiddenable(item[field.name] && (<><a href={item[field.name]}
659
- title={item[field.name]} style={{color: field.color}}
660
- className={"link-value"}
661
- target="_blank">{item[field.name]}</a>
662
- <button title={"Copy URL"} onClick={(e) => {
663
- navigator.clipboard.writeText(item[field.name]).then(function () {
664
- console.log('Async: Copying to clipboard was successful!');
665
- }, function (err) {
666
- console.error('Async: Could not copy text: ', err);
667
- });
668
- }}><FaCopy/></button>
669
- </>))}</td>;
670
- }
671
- if (field.type === "file" && item[field.name]) {
672
- const r = `
673
- <strong>filename</strong> : ${item[field.name].filename}<br />
674
- <strong>guid</strong> : ${item[field.name].guid}<br />
675
- <strong>type</strong> : ${item[field.name].mimeType}<br />
676
- <strong>size</strong> : ${item[field.name].size} bytes<br />
677
- <strong>timestamp</strong> : ${item[field.name].timestamp.toLocaleString(lang)}
678
- `;
679
- if (['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/bmp', 'image/tiff', 'image/x-icon', 'image/x-windows-bmp'].includes(item[field.name].mimeType))
680
- return <td key={field.name}>{hiddenable(<a
681
- data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
682
- href={`/resources/${item[field.name].guid}`} target="_blank"
683
- rel="noopener noreferrer"><img className="image"
684
- src={`/resources/${item[field.name].guid}`}
685
- alt={`${item[field.name].filename}`}/></a>
686
- )}</td>;
687
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""}><a style={{color: field.color}}
688
- href={`/resources/${item[field.name].guid}`}
689
- target="_blank"
690
- rel="noopener noreferrer" data-tooltip-id={"tooltipFile"} data-tooltip-html={r}>{hiddenable(item[field.name].filename)}</a>
691
- </td>;
692
- }
693
- if (field.type === 'number') {
694
- if (field.delay) {
695
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(formatDuration(item[field.name], t))}</td>;
696
- }
697
- if (field.gauge) {
698
- const value = item[field.name];
699
- if (value == null) {
700
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color}}></td>;
701
- }
702
- const min = field.min || 0;
703
- const max = field.max || 100;
704
- const percentage = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
705
-
706
- const displayValue = field.percent ? `${Math.round(percentage)}%` : value;
707
- const title = field.percent ? `${value} (${Math.round(percentage)}%)` : `${value} / ${max}`;
708
-
709
- return (
710
- <td key={field.name} className={isLightColor(field.color) ? "lighted" : "unlighted"} style={{backgroundColor: field.color}}>
711
- {hiddenable(
712
- <div className="gauge-container" data-tooltip-id={"tooltipFile"} data-tooltip-content={title}>
713
- <div className="gauge-bar" style={{ width: `${percentage}%` }}></div>
714
- <span className="gauge-label">{displayValue}</span>
715
- </div>
716
- )}
717
- </td>);
718
- }
719
- let val = item[field.name];
720
- if (val && field.unit) {
721
- let formatter = new Intl.NumberFormat(lang);
722
- val = formatter.format(item[field.name]);
723
- }
724
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(val ? `${val} ${field.unit || ''}` : '')}</td>;
725
- }
726
- if (field.type === "boolean") {
727
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name] ? t('yes') : t('no'))}</td>;
728
- }
729
- if (field.type === 'string_t') {
730
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name] ? (item[field.name].value || item[field.name].key) : '')}</td>;
731
- }
732
- if (field.type === 'richtext') {
733
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
734
- {hiddenable(<div className="rte-value"
735
- dangerouslySetInnerHTML={{__html: item[field.name]}}></div>)}
736
- </td>;
737
- }
738
- if (field.type === 'richtext_t') {
739
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
740
- {hiddenable(<RichText value={item[field.name]} initialLang={lang} />)}
741
- </td>;
742
- }
743
- if (field.type === 'color') {
744
- return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
745
- {hiddenable(<ColorField name={field.name} disabled={true}
746
- value={item[field.name]}/>)}</td>;
747
- }
748
- return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
749
- {hiddenable(item[field.name])}</td>;
750
- })}
751
- {advanced && (<td>
752
- <button data-tooltip-id="tooltipActions"
753
- data-tooltip-content={t('btns.edit', 'Modifier')}
754
- onClick={() => handleEdit(item)}><FaPencil/></button>
755
- <button
756
- onClick={() => handleDuplicate(item)}
757
- data-tooltip-id="tooltipActions"
758
- data-tooltip-content={t('btns.duplicate', 'Dupliquer')}
759
- >
760
- <FaCopy/>
761
- </button>
762
- {selectedModel?.history?.enabled && (<button
763
- onClick={() => setSelectedRow(item._id)}
764
- data-tooltip-id="tooltipActions"
765
- data-tooltip-content={t('btns.showHistory', 'Voir l\'historique')}
766
- >
767
- <FaHistory />
768
- </button>)}
769
-
770
- <button data-tooltip-id="tooltipActions"
771
- data-tooltip-content={t('btns.delete', 'Supprimer')}
772
- onClick={() => handleDelete(item)}><FaTrash/></button>
773
- </td>)}
774
- </tr>
775
- </DialogProvider></>
776
- ))}
777
-
778
- </tbody>
779
-
780
- <tfoot>
781
- {data.length > 10 && (<Header advanced={advanced} reversed={true} model={model} setCheckedItems={setCheckedItems}
782
- filterValues={filterValues} data={data}
783
- setFilterValues={setFilterValues}
784
- onChangeFilterValue={onChangeFilterValue}
785
- checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>)}
786
- </tfoot>
787
-
788
- </table>)}
789
- {!isDataLoaded && <div className="spinner-loader"></div>}
790
- <DialogProvider>
791
- {importVisible && (<DataImporter onClose={() => {
792
- setImportVisible(false);
793
- }}/>)}
794
-
795
- {selectedRow && (
796
- <HistoryDialog onClose={() => setSelectedRow(null)} modelName={selectedModel?.name} recordId={selectedRow} />
797
- )}
798
- <RestoreConfirmationModal
799
- isOpen={isBackupModalOpen}
800
- onClose={() => setIsBackupModalOpen(false)}
801
- onConfirm={handleConfirmRestore}
802
- />
803
- <ExportDialog isOpen={showExportDialog} onClose={() => {
804
- setExportDialogVisible(false);
805
- }} availableModels={models} currentModel={selectedModel.name} hasSelection={true} onExport={(data)=>{
806
- exportMutation(data);
807
- }} />
808
- {showPackGallery && (
809
- <Dialog isClosable={true} isModal={true} onClose={() => setShowPackGallery(false)}>
810
- <PackGallery />
811
- </Dialog>
812
- )}
813
- </DialogProvider>
814
- </div>
815
- </div>
816
- );
817
- }
1
+ // Composant pour afficher les données (DataTable basique)
2
+ import {useModelContext} from "./contexts/ModelContext.jsx";
3
+ import {Trans, useTranslation} from "react-i18next";
4
+ import {useMutation, useQuery, useQueryClient} from "react-query";
5
+ import {useAuthContext} from "./contexts/AuthContext.jsx";
6
+ import React, {useEffect, useMemo, useRef, useState} from "react";
7
+ import {getUserId} from "../../src/data.js";
8
+ import cronstrue from 'cronstrue/i18n';
9
+ import {Event} from "../../src/events.js";
10
+
11
+ import {
12
+ FaBook,
13
+ FaCopy,
14
+ FaDatabase, FaEraser, FaEye,
15
+ FaFileExport,
16
+ FaFileImport,
17
+ FaFilter, FaHistory,
18
+ FaInfo,
19
+ FaPlus,
20
+ FaTrash, FaWrench
21
+ } from "react-icons/fa";
22
+ import {useNotificationContext} from "./NotificationProvider.jsx";
23
+ import {
24
+ elementsPerPage,
25
+ kilobytes,
26
+ maxBytesPerSecondThrottleData,
27
+ maxFileSize,
28
+ maxRequestData
29
+ } from "../../src/constants.js";
30
+ import Button from "./Button.jsx";
31
+ import {Dialog, DialogProvider} from "./Dialog.jsx";
32
+ import {
33
+ CheckboxField,
34
+ CodeField,
35
+ ColorField,
36
+ FileField,
37
+ FilterField,
38
+ ModelField,
39
+ PhoneField,
40
+ TextField
41
+ } from "./Field.jsx";
42
+ import RelationValue from "./RelationValue.jsx";
43
+ import {FaGear, FaPencil, FaTriangleExclamation} from "react-icons/fa6";
44
+ import RestoreConfirmationModal from "./RestoreConfirmationModal.jsx";
45
+ import {event_trigger, isLightColor} from "../../src/core.js";
46
+ import {Tooltip} from "react-tooltip";
47
+ import ExportDialog from "./ExportDialog.jsx";
48
+ import Captions from "yet-another-react-lightbox/plugins/captions";
49
+ import {Zoom} from "yet-another-react-lightbox/plugins";
50
+ import Lightbox from "yet-another-react-lightbox";
51
+
52
+ import "./DataTable.scss"
53
+ import useLocalStorage from "./hooks/useLocalStorage.js";
54
+ import TutorialsMenu from "../src/TutorialsMenu.jsx";
55
+ import {useTutorials} from "./hooks/useTutorials.jsx";
56
+ import PackGallery from "./PackGallery.jsx";
57
+ import {HiddenableCell} from "./HiddenableCell.jsx";
58
+ import ConditionBuilder from "./ConditionBuilder.jsx";
59
+ import {pagedFilterToMongoConds} from "./filter.js";
60
+ import {isConditionMet} from "../../src/filter";
61
+ import {DataImporter} from "./DataImporter.jsx";
62
+ import {HistoryDialog} from "./HistoryDialog.jsx";
63
+ import {Config} from "../../src/config.js";
64
+
65
+ const Header = ({
66
+ reversed = false,
67
+ handleFilter,
68
+ model,
69
+ setCheckedItems,
70
+ checkedItems,
71
+ data,
72
+ filterActive,
73
+ onChangeFilterValue,
74
+ setFilterValues,
75
+ filterValues,
76
+ advanced=true
77
+ }) => {
78
+
79
+ const {t} = useTranslation()
80
+ const { me } = useAuthContext()
81
+ const {
82
+ models,
83
+ countByModel,
84
+ selectedModel,
85
+ setPagedFilters,
86
+ pagedFilters,
87
+ page
88
+ } = useModelContext();
89
+ let totalCol = 0;
90
+
91
+ const [advancedFilterVisible, setAdvancedFilterVisible] = useState(false);
92
+
93
+ const handleAdvancedFilter = () => {
94
+ setAdvancedFilterVisible(true);
95
+ }
96
+
97
+ const [iconFilterActive, setIconFilterActive] = useState(true);
98
+ useEffect(() => {
99
+ if (pagedFilters && selectedModel?.name) {
100
+ const modelFilters = pagedFilters[selectedModel.name] || {};
101
+ const isActive = Object.keys(modelFilters)
102
+ .some(fieldKey => {
103
+ const fieldFilter = modelFilters[fieldKey];
104
+ return fieldFilter && Object.keys(fieldFilter).length > 0;
105
+ });
106
+ setIconFilterActive(isActive);
107
+ }
108
+ }, [pagedFilters, selectedModel?.name]);
109
+
110
+ return <><tr className={reversed ? ' reversed' : ''}>
111
+ {advanced && (<th className={"mini"}>
112
+ <div className="flex flex-row">
113
+
114
+ <Button onClick={handleFilter} className={iconFilterActive ? ' active' : ''}><FaFilter/></Button>
115
+ {filterActive && <Button onClick={() => handleAdvancedFilter()}><FaWrench /></Button>}
116
+ <CheckboxField checkbox={true} checked={checkedItems?.length === data.length} onChange={e => {
117
+ if (checkedItems?.length === data.length) {
118
+ setCheckedItems([]);
119
+ } else {
120
+ setCheckedItems(data);
121
+ }
122
+ }}/>
123
+
124
+ <DialogProvider>
125
+ {advancedFilterVisible && (
126
+ <Dialog title={t("datatable.advancedFilter.title")} isClosable={true} onClose={() => setAdvancedFilterVisible(false)}>
127
+ <h3>Edit filter</h3>
128
+ <div className="msg">
129
+ <Trans i18nKey={"datatable.advancedFilter.desc"}>{t("datatable.advancedFilter.desc")}</Trans>
130
+ </div>
131
+ <ConditionBuilder onChange={(c) => {
132
+ setPagedFilters(pagedFilters => ({
133
+ ...pagedFilters,
134
+ [model.name]: c || pagedFilters[model.name] || {} }));
135
+ }} initialValue={{ $and: pagedFilterToMongoConds(pagedFilters, model)}} models={models} model={model.name} checkedItems={checkedItems} setCheckedItems={setCheckedItems} data={data} filterActive={filterActive} onChangeFilterValue={onChangeFilterValue} setFilterValues={setFilterValues}/>
136
+ <Button onClick={() =>{
137
+ setPagedFilters(pagedFilters => ({
138
+ ...pagedFilters,
139
+ [model.name]: {}}));
140
+ }}><Trans i18nKey={"btns.reset"}>Reset</Trans></Button>
141
+ </Dialog>
142
+ )}
143
+ </DialogProvider>
144
+ </div>
145
+ </th>)}
146
+ {(model?.fields || []).map(field => {
147
+ if (field.type === 'password')
148
+ return <></>;
149
+ if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
150
+ return <th className={"empty"} key={"datatable-th-"+model.name+":"+field.name}>
151
+ <div className="flex flex-mini-gap flex-centered">
152
+ <FaTriangleExclamation color={"#df8107"} data-tooltip-id={`tooltip-desc`} data-tooltip-content={"\""+field.relation + "\" model not found"} />
153
+ {field.name}
154
+ <Tooltip id={"tooltip-desc"} render={({content}) => {
155
+ gtag('render hint '+field.relation);
156
+ return content;
157
+ }} />
158
+ </div>
159
+ </th>;
160
+
161
+ totalCol++;
162
+ return <FilterField advanced={advanced} reversed={reversed} filterValues={filterValues} setFilterValues={setFilterValues}
163
+ model={model} field={field} active={filterActive}
164
+ onChangeFilterValue={onChangeFilterValue}/>;
165
+ })}
166
+ {advanced && (<th><Trans i18nKey="actions">Actions</Trans>
167
+ {Object.keys(pagedFilters[model.name] || {})
168
+ .filter(f=> Object.keys(pagedFilters[model.name]?.[f] || {}).length > 0).length > 0 && <div>
169
+ <button onClick={() => {
170
+ setFilterValues({});
171
+ setPagedFilters(pagedFilters => ({...pagedFilters, [model.name]: {}}));
172
+ }}><FaEraser />
173
+ </button>
174
+ </div>}</th>)}
175
+ </tr>
176
+ {reversed && (
177
+ <tr>
178
+ {countByModel?.[selectedModel?.name] === 0 &&
179
+ <td colSpan={totalCol + 2}><Trans i18nKey="no_data">Aucune donnée enregistrée</Trans></td>}
180
+ </tr>)}
181
+ </>
182
+ ;
183
+ }
184
+
185
+ const formatDuration = (totalSeconds, t) => {
186
+ if (totalSeconds === null || totalSeconds === undefined || isNaN(totalSeconds) || totalSeconds === '') {
187
+ return '';
188
+ }
189
+ const total = parseInt(totalSeconds, 10);
190
+ if (total === 0) return '0s';
191
+
192
+ const d = Math.floor(total / 86400);
193
+ const h = Math.floor((total % 86400) / 3600);
194
+ const m = Math.floor((total % 3600) / 60);
195
+ const s = Math.floor(total % 60);
196
+
197
+ const parts = [];
198
+ if (d > 0) parts.push(t('duration.day', { count: d }));
199
+ if (h > 0) parts.push(t('duration.hour', { count: h }));
200
+ if (m > 0) parts.push(t('duration.minute', { count: m }));
201
+ if (s > 0) parts.push(t('duration.second', { count: s }));
202
+
203
+ if (parts.length === 0) return '0s';
204
+
205
+ return parts.slice(0, 2).join(', ');
206
+ };
207
+
208
+
209
+ const RichText = ({ value, initialLang }) => {
210
+ const availableLangs = useMemo(() => (value ? Object.keys(value).filter(k => value[k]) : []), [value]);
211
+
212
+ const [selectedLang, setSelectedLang] = useState(() => {
213
+ if (availableLangs.includes(initialLang)) {
214
+ return initialLang;
215
+ }
216
+ return availableLangs[0] || null;
217
+ });
218
+
219
+ useEffect(() => {
220
+ setSelectedLang(initialLang);
221
+ }, [initialLang]);
222
+
223
+ if (!value || availableLangs.length === 0) {
224
+ return null;
225
+ }
226
+
227
+ if (availableLangs.length === 1) {
228
+ return <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[availableLangs[0]] || '' }} />;
229
+ }
230
+
231
+ return (
232
+ <div className="richtext-t-cell">
233
+ <div className="lang-switcher">
234
+ {availableLangs.map(langCode => (
235
+ <button
236
+ key={langCode}
237
+ className={`lang-btn ${selectedLang === langCode ? 'active' : ''}`}
238
+ onClick={(e) => {
239
+ e.stopPropagation();
240
+ setSelectedLang(langCode);
241
+ }}
242
+ title={langCode.toUpperCase()}
243
+ >
244
+ {langCode.toUpperCase()}
245
+ </button>
246
+ ))}
247
+ </div>
248
+ <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[selectedLang] || '' }} />
249
+ </div>
250
+ );
251
+ };
252
+
253
+ export function DataTable({
254
+ model,
255
+ checkedItems,
256
+ setCheckedItems = () => {},
257
+ onEdit,
258
+ onAddData,
259
+ onDuplicateData,
260
+ onDelete,
261
+ onShowAPI,
262
+ filterValues,
263
+ setFilterValues = () => {},
264
+ data: propData, // NOUVEAU: Accepter les données via les props
265
+ advanced=true
266
+
267
+ }) {
268
+ const {
269
+ models,
270
+ elementsPerPage,
271
+ paginatedDataByModel,
272
+ countByModel,
273
+ pagedSort,
274
+ selectedModel,
275
+ pagedFilters,
276
+ setPagedFilters,
277
+ page
278
+ } = useModelContext();
279
+ const {t, i18n} = useTranslation();
280
+ const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
281
+ const queryClient = useQueryClient();
282
+ const {me} = useAuthContext();
283
+
284
+ // Si des données sont passées en props, on les utilise, sinon on prend celles du contexte.
285
+ const data = propData || paginatedDataByModel[model?.name] || [];
286
+
287
+ const isDataLoaded = true;
288
+ const [importVisible, setImportVisible] = useState(false);
289
+ const [filterActive, setFilterActive] = useState(false)
290
+
291
+ const [showPackGallery, setShowPackGallery] = useState(false);
292
+ const [showExportDialog, setExportDialogVisible] = useState(false);
293
+
294
+
295
+ const [selectedRow, setSelectedRow] = useState(null);
296
+
297
+ const [showAddPack, setAddPackVisible] = useState(false);
298
+ const handleAddPack = () => {
299
+ setAddPackVisible(!showAddPack);
300
+ }
301
+ const handleShowPacks = () => {
302
+ setShowPackGallery(true);
303
+ };
304
+ const handleDelete = async (item) => {
305
+ if (model && item && item._id) { // Assurez-vous d'avoir un identifiant unique pour chaque élément
306
+ try {
307
+ const response = await fetch(`/api/data/${item._id}?_user=${encodeURIComponent(getUserId(me))}`, { // Assurez-vous que l'URL est correcte
308
+ method: 'DELETE',
309
+ });
310
+
311
+ if (response.ok) {
312
+ const res = await response.json();
313
+
314
+ if (res.success) {
315
+ onDelete?.(item);
316
+ const notificationData = {
317
+ id: 'datatable.deleteData.success',
318
+ title: t('datatable.deleteData.success', 'Données supprimées avec succès'),
319
+ icon: <FaInfo/>,
320
+ status: 'completed'
321
+ };
322
+ addNotification(notificationData);
323
+ } else {
324
+ const notificationData = {
325
+ title: res.message,
326
+ status: 'error'
327
+ };
328
+ addNotification(notificationData);
329
+ console.log('Données non trouvées');
330
+ }
331
+
332
+ await Event.Trigger('API_DELETE_DATA', "custom", "data",{ model: item._model, id: item._id });
333
+ queryClient.invalidateQueries(['api/data', item._model, 'page', page, elementsPerPage, pagedFilters[item._model], pagedSort[item._model]]);
334
+
335
+ } else {
336
+ console.error('Erreur lors de la suppression des données');
337
+ // Gérer les erreurs (afficher un message à l'utilisateur, etc.)
338
+ }
339
+ } catch (error) {
340
+ const notificationData = {
341
+ title: error.message,
342
+ status: 'error'
343
+ };
344
+ addNotification(notificationData);
345
+ console.error('Erreur lors de la suppression des données:', error);
346
+ }
347
+ } else {
348
+ console.warn("Impossible de supprimer, l'élément ne possède pas d'ID");
349
+ }
350
+ };
351
+
352
+ const handleEdit = (item) => {
353
+ onEdit(item); // Call onEdit with the item to edit
354
+ }
355
+
356
+ const onChangeFilterValue = (field, value, tr) => {
357
+ setPagedFilters(pagedFilters => ({
358
+ ...pagedFilters, [model.name]: {...pagedFilters[model.name] || {}, [field.name]: value || pagedFilters[model.name]?.[field.name] || undefined}
359
+ }));
360
+ queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
361
+ }
362
+
363
+ const {addNotification} = useNotificationContext();
364
+
365
+ const {mutate: exportMutation, isLoading} = useMutation(async (data) => {
366
+
367
+ console.log('Exportation des données');
368
+ const params = new URLSearchParams();
369
+ params.append('model', model.name);
370
+ params.append("_user", getUserId(me));
371
+
372
+ // Cas de la table de données : requête paginée
373
+
374
+ const m = Config.Get('maxRequestData', maxRequestData);
375
+ params.append('limit', m + '');
376
+ params.append('attachment', '1');
377
+ params.append("depth", data.depth+"");
378
+ if( data.withModels )
379
+ params.append("withModels", "1");
380
+
381
+ const ids = data.exportSelection ? checkedItems.map(item => item._id) : [];
382
+ const body = JSON.stringify({filter: pagedFilters[model.name], models: data.models || [], ids});
383
+
384
+ params.append('ids', ids);
385
+ console.log('Fetch des données');
386
+ return fetch(`/api/data/export?lang=${lang}&${params.toString()}`, {
387
+ method: 'POST',
388
+ body,
389
+ headers: {"Content-Type": "application/json"}
390
+ })
391
+ .then(async resp => {
392
+ if( resp.status === 200 )
393
+ return resp.blob();
394
+ else {
395
+ const res = await resp.json();
396
+ throw new Error(res.error || 'something went wrong')
397
+ }
398
+ })
399
+ .then((blob) => {
400
+ const url = window.URL.createObjectURL(blob);
401
+ const a = document.createElement('a');
402
+ a.style.display = 'none';
403
+ a.href = url;
404
+ // the filename you want
405
+ a.download = model.name + '.dump.json';
406
+ document.body.appendChild(a);
407
+ a.click();
408
+ window.URL.revokeObjectURL(url);
409
+ })
410
+ .then(e => {
411
+ const notificationData = {
412
+ title: e.success ? t('dataimporter.success', 'Exportation de ' + model.name + ' réussie'): e.error,
413
+ icon: e.success ? <FaInfo/> : null,
414
+ status: e.success ? 'completed' : 'error'
415
+ };
416
+ addNotification(notificationData);
417
+ return e;
418
+ }).catch(e => {
419
+ const notificationData = {
420
+ title: e.message,
421
+ status: 'error'
422
+ };
423
+ addNotification(notificationData);
424
+ });
425
+ });
426
+
427
+ const handleExport = () => {
428
+ setExportDialogVisible(true)
429
+ }
430
+ const handleImport = () => {
431
+ setImportVisible(true);
432
+ }
433
+ const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
434
+
435
+ const handleBackup = () => {
436
+ setIsBackupModalOpen(true);
437
+ }
438
+
439
+
440
+ const handleConfirmRestore = async () => {
441
+ try {
442
+ // Make the API call to request the restore link
443
+ const response = await fetch('/api/backup/request-restore', {
444
+ method: 'POST',
445
+ // ... other options ...
446
+ });
447
+
448
+ const result = await response.json();
449
+ if (response.ok && result.message) {
450
+ addNotification({ status: 'completed', title: result.message });
451
+ } else {
452
+ addNotification({ status: 'error', title: result.error || t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
453
+ }
454
+ } catch (error) {
455
+ addNotification({ status: 'error', title: t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
456
+ console.error('Error requesting restore link:', error);
457
+ } finally {
458
+ setIsBackupModalOpen(false); // Close the modal
459
+ }
460
+ };
461
+
462
+ const handleFilter = () => {
463
+ setFilterActive(!filterActive);
464
+ }
465
+
466
+ const [lightboxIndex, setLightboxIndex] = useState(0);
467
+ const [lightboxOpened, setLightboxOpened] = useState(false);
468
+ const [lightboxSlides, setLightboxSlides] = useState([]);
469
+ const [tutorialDialogVisible, setTutorialDialogVisible] = useState(false);
470
+
471
+ const [newPackName, setNewPackName] = useState(''); // pack
472
+
473
+ const handleShowTutorialMenu = () => {
474
+ setTutorialDialogVisible(!tutorialDialogVisible);
475
+ }
476
+
477
+ if (!model)
478
+ return <></>;
479
+
480
+ // NOUVEAU : La fonction qui gère la duplication
481
+ const handleDuplicate = (originalData) => {
482
+ // 1. Créer une copie superficielle des données de la ligne
483
+ const dataToDuplicate = { ...originalData };
484
+
485
+ // 2. TRÈS IMPORTANT : Supprimer les champs qui ne doivent pas être copiés.
486
+ // - L'_id doit être supprimé pour que le système sache qu'il s'agit d'une NOUVELLE entrée.
487
+ // - Le _hash sera recalculé par le backend.
488
+ // - Les dates de création/mise à jour seront gérées par le backend.
489
+ delete dataToDuplicate._id;
490
+ delete dataToDuplicate._hash;
491
+ delete dataToDuplicate.createdAt; // si ce champ existe
492
+ delete dataToDuplicate.updatedAt; // si ce champ existe
493
+
494
+ onDuplicateData(dataToDuplicate);
495
+ };
496
+ return (
497
+ <div className={`datatable${filterActive ? ' filter-active' : ''}`}>
498
+ {advanced && <div className="flex actions flex-left">
499
+ {t(`model_${selectedModel?.name}`, selectedModel?.name) !== selectedModel?.name && (
500
+ <span className="badge"><strong>model</strong> : {selectedModel?.name}</span>)}
501
+ {selectedModel.name === 'dashboard' && <Button className={"btn"} onClick={() => {
502
+ nav('/user/'+getUserHash(me)+'/dashboards');
503
+ }}><FaEye /> Tableaux de bord</Button> }
504
+ <p className="model-desc hint">{t(`model_description_${selectedModel.name}`, selectedModel.description)}</p>
505
+ <Button onClick={() => onAddData(model)}><FaPlus/><Trans i18nKey="btns.addData">Ajouter une
506
+ donnée</Trans></Button>
507
+ <Button onClick={handleImport} title={t("btns.import")}><FaFileImport/><Trans
508
+ i18nKey="btns.import">Importer</Trans></Button>
509
+ <Button disabled={isLoading} onClick={handleExport} title={t("btns.export")}><FaFileExport/><Trans
510
+ i18nKey="btns.export">Exporter</Trans></Button>
511
+ <Button className="tourStep-import-datapack" onClick={handleShowPacks} title={t("btns.addPack")}><FaPlus/><Trans
512
+ i18nKey="btns.addPack">Packs...</Trans></Button>
513
+
514
+ <Button className={"tourStep-tutorials " + (/^demo[0-9]{1,2}$/.test(me.username) ? "btn-primary" : "btn")} onClick={handleShowTutorialMenu} title={t("btns.showTutos")}><FaPlus/><Trans
515
+ i18nKey="btns.showTutos">Tutoriels</Trans></Button>
516
+ {!/^demo[0-9]{1,2}$/.test(me.username) && (<Button onClick={handleBackup} title={t("btns.backup")}><FaDatabase/><Trans
517
+ i18nKey="btns.backup">Backup</Trans></Button>)}
518
+ <DialogProvider>
519
+ {tutorialDialogVisible && (
520
+ <Dialog isClosable={true} isModal={true} onClose={() => setTutorialDialogVisible(false)}>
521
+ <TutorialsMenu />
522
+ </Dialog>
523
+ )}
524
+ </DialogProvider>
525
+ <Button className="btn btn-primary" onClick={() => {
526
+ onShowAPI(selectedModel);
527
+ }}><FaBook/> {t('btns.api', 'API')}</Button>
528
+ </div>}
529
+ <div className={"table-wrapper"}>
530
+ <Tooltip id={"tooltipFile"} clickable={true} />
531
+ <Tooltip id={"tooltipActions"} />
532
+ <Lightbox
533
+ inline={{
534
+ style: { width: "100%", maxWidth: "900px", aspectRatio: "3 / 2" },
535
+ }} plugins={[Captions,Zoom]} index={lightboxIndex} open={lightboxOpened} close={()=> setLightboxOpened(false)}
536
+ slides={lightboxSlides}
537
+ on={{
538
+ click: ()=>{
539
+
540
+ }
541
+ }}
542
+ />
543
+ {isDataLoaded && (
544
+ <table>
545
+ <thead>
546
+ <Header advanced={advanced} model={model} setCheckedItems={setCheckedItems} filterValues={filterValues} data={data} setFilterValues={setFilterValues} onChangeFilterValue={onChangeFilterValue} checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>
547
+ </thead>
548
+ <tbody>
549
+ {(data || []).map((item) => (
550
+ <><DialogProvider>
551
+ <tr key={item._id} onDoubleClick={() => onEdit(item)} onClick={(e) => {
552
+ const checked = (!e.target.closest('tr').querySelector('td:first-child input')?.checked);
553
+ if (checked) {
554
+ setCheckedItems(items => {
555
+ return [...items, item];
556
+ });
557
+ } else {
558
+ setCheckedItems(items => items.filter(i => i._id !== item._id));
559
+ }
560
+ }}>
561
+ {advanced && (<td className={"mini"}>
562
+ <CheckboxField checkbox={true} className={"input-ref"}
563
+ checked={checkedItems?.some(i => i._id === item._id)}
564
+ onChange={(e) => {
565
+ if (e) {
566
+ setCheckedItems(items => {
567
+ return [...items, item];
568
+ });
569
+ } else {
570
+ setCheckedItems(items => items.filter(i => i._id !== item._id));
571
+ }
572
+ }}/></td>)}
573
+ {(model?.fields ||[]).map(field => {
574
+
575
+ if( !isConditionMet(model, field.condition, item, models, me, false)){
576
+ return <td className={"notmet"} key={item._id + field.name}></td>; // Do not render the header cell if the condition isn't met
577
+ }
578
+
579
+ const hiddenable = (content) => {
580
+ if(field.hiddenable)
581
+ return <HiddenableCell value={content} />;
582
+ return content;
583
+ }
584
+ if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
585
+ return <td className={"empty"} key={item._id + field.name}></td>
586
+ if (field.type === "relation" && typeof field.relation === "string") {
587
+ return <td key={item._id + field.name} style={{backgroundColor: field.color}}>{hiddenable(<RelationValue field={field}
588
+ data={item}/>)}</td>;
589
+ }
590
+ if( field.type === "cronSchedule" ){
591
+ let val = '';
592
+ try {
593
+ val = cronstrue.toString(item[field.name], { locale: lang, throwExceptionOnParseError: true });
594
+ } catch (e) {
595
+
596
+ }
597
+ return <td
598
+ className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
599
+ key={field.name}>{hiddenable(val)}</td>;
600
+ }
601
+ if (field.type === "date" && item[field.name]) {
602
+ return <td
603
+ className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
604
+ key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
605
+ day: "numeric",
606
+ month: "numeric",
607
+ year: "numeric"
608
+ }))}</td>;
609
+ }
610
+ if (field.type === "datetime" && item[field.name]) {
611
+ return <td
612
+ className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
613
+ key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
614
+ day: "numeric",
615
+ month: "numeric",
616
+ year: "numeric",
617
+ hour: "numeric",
618
+ minute: "numeric"
619
+ }))}</td>;
620
+ }
621
+ if (field.type === "enum") {
622
+ return <td
623
+ key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(field.items.includes(item[field.name]) ? `${t(item[field.name], item[field.name])}` : `${item[field.name] || ''}`)}</td>;
624
+ }
625
+ if (field.type === "code") {
626
+ const v = typeof(item[field.name]) === "string" ? item[field.name] : (item[field.name] ? JSON.stringify(item[field.name], null, 2) : '');
627
+ return <td
628
+ key={field.name}>{v && hiddenable(<CodeField language={field.language} name={field.name} value={v} disabled={true} />)}</td>;
629
+ }
630
+ if (field.type === "object") {
631
+ return <td key={field.name}>{hiddenable(<CodeField language={'json'} name={field.name} value={item[field.name] ? JSON.stringify(item[field.name], null, 2) : ''} disabled={true} />)}</td>;
632
+ }
633
+ if (field.type === 'email') {
634
+ return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}><a href={"mailto:"+item[field.name]} style={{color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name])}</a></td>;
635
+ }
636
+ if (field.type === 'phone') {
637
+ return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] && <PhoneField name={"phone"} value={item[field.name]} disabled={true} onChange={() => {}}></PhoneField>)}</td>;
638
+ }
639
+ if (field.type === 'model') {
640
+ return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] ? `${t('model_'+item[field.name], item[field.name])} (${item[field.name]})`:'')}</td>;
641
+ }
642
+ if (field.type === 'geolocation') {
643
+ const geoData = item[field.name];
644
+ console.log({geoData})
645
+ if (geoData && geoData.coordinates && geoData.coordinates.length === 2) {
646
+ const [lng, lat] = geoData.coordinates;
647
+ const coordinatesText = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
648
+ const mapUrl = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`;
649
+ return (
650
+ <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>
651
+ {hiddenable(
652
+ <a href={mapUrl} target="_blank" rel="noopener noreferrer" style={{color: 'inherit'}}>
653
+ {coordinatesText}
654
+ </a>
655
+ )}
656
+ </td>
657
+ );
658
+ }
659
+ return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable('')}</td>;
660
+ }
661
+ if (field.type === 'password') {
662
+ return <></>;
663
+ }
664
+ if (field.type === 'array') {
665
+ let t = <></>
666
+ if (field.itemsType === 'file') {
667
+ const click = (e,i) => {
668
+ setLightboxIndex(i);
669
+ setLightboxOpened(true);
670
+ setLightboxSlides(item[field.name].map(s => ({
671
+ src: `/resources/${s.guid}`,
672
+ title: s.name
673
+ })));
674
+ e.preventDefault();
675
+ };
676
+ t = (item[field.name] || []).map((it,i) => {
677
+
678
+ const r = `
679
+ <strong>filename</strong> : ${it.filename}<br />
680
+ <strong>guid</strong> : ${it.guid}<br />
681
+ <strong>type</strong> : ${it.mimeType}<br />
682
+ <strong>size</strong> : ${it.size} bytes<br />
683
+ <strong>timestamp</strong> : ${it.timestamp ? new Date(it.timestamp).toLocaleString(lang) : ''}
684
+ `;
685
+ return <a key={it.guid} href={`/resources/${it.guid}`} target="_blank"
686
+ data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
687
+ rel="noopener noreferrer" onClick={(e) => click(e,i)}><img
688
+ className="image" src={`/resources/${it.guid}`}
689
+ alt={`${it.name} (${it.guid})`}/></a>
690
+ });
691
+ return <td key={field.name}>
692
+ {hiddenable(<div className="gallery">{t}</div>)}
693
+ </td>;
694
+ }
695
+ return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name]?.join(', ') || '')}</td>;
696
+ }
697
+ if (field.type === 'url') {
698
+ return <td key={field.name}>
699
+ {hiddenable(item[field.name] && (<><a href={item[field.name]}
700
+ title={item[field.name]} style={{color: field.color}}
701
+ className={"link-value"}
702
+ target="_blank">{item[field.name]}</a>
703
+ <button title={"Copy URL"} onClick={(e) => {
704
+ navigator.clipboard.writeText(item[field.name]).then(function () {
705
+ console.log('Async: Copying to clipboard was successful!');
706
+ }, function (err) {
707
+ console.error('Async: Could not copy text: ', err);
708
+ });
709
+ }}><FaCopy/></button>
710
+ </>))}</td>;
711
+ }
712
+ if (field.type === "file" && item[field.name]) {
713
+ const r = `
714
+ <strong>filename</strong> : ${item[field.name].filename}<br />
715
+ <strong>guid</strong> : ${item[field.name].guid}<br />
716
+ <strong>type</strong> : ${item[field.name].mimeType}<br />
717
+ <strong>size</strong> : ${item[field.name].size} bytes<br />
718
+ <strong>timestamp</strong> : ${item[field.name].timestamp.toLocaleString(lang)}
719
+ `;
720
+ if (['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/bmp', 'image/tiff', 'image/x-icon', 'image/x-windows-bmp'].includes(item[field.name].mimeType))
721
+ return <td key={field.name}>{hiddenable(<a
722
+ data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
723
+ href={`/resources/${item[field.name].guid}`} target="_blank"
724
+ rel="noopener noreferrer"><img className="image"
725
+ src={`/resources/${item[field.name].guid}`}
726
+ alt={`${item[field.name].filename}`}/></a>
727
+ )}</td>;
728
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""}><a style={{color: field.color}}
729
+ href={`/resources/${item[field.name].guid}`}
730
+ target="_blank"
731
+ rel="noopener noreferrer" data-tooltip-id={"tooltipFile"} data-tooltip-html={r}>{hiddenable(item[field.name].filename)}</a>
732
+ </td>;
733
+ }
734
+ if (field.type === 'number') {
735
+ if (field.delay) {
736
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(formatDuration(item[field.name], t))}</td>;
737
+ }
738
+ if (field.gauge) {
739
+ const value = item[field.name];
740
+ if (value == null) {
741
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color}}></td>;
742
+ }
743
+ const min = field.min || 0;
744
+ const max = field.max || 100;
745
+ const percentage = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
746
+
747
+ const displayValue = field.percent ? `${Math.round(percentage)}%` : value;
748
+ const title = field.percent ? `${value} (${Math.round(percentage)}%)` : `${value} / ${max}`;
749
+
750
+ return (
751
+ <td key={field.name} className={isLightColor(field.color) ? "lighted" : "unlighted"} style={{backgroundColor: field.color}}>
752
+ {hiddenable(
753
+ <div className="gauge-container" data-tooltip-id={"tooltipFile"} data-tooltip-content={title}>
754
+ <div className="gauge-bar" style={{ width: `${percentage}%` }}></div>
755
+ <span className="gauge-label">{displayValue}</span>
756
+ </div>
757
+ )}
758
+ </td>);
759
+ }
760
+ let val = item[field.name];
761
+ if (val && field.unit) {
762
+ let formatter = new Intl.NumberFormat(lang);
763
+ val = formatter.format(item[field.name]);
764
+ }
765
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(val ? `${val} ${field.unit || ''}` : '')}</td>;
766
+ }
767
+ if (field.type === "boolean") {
768
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name] ? t('yes') : t('no'))}</td>;
769
+ }
770
+ if (field.type === 'string_t') {
771
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name] ? (item[field.name].value || item[field.name].key) : '')}</td>;
772
+ }
773
+ if (field.type === 'richtext') {
774
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
775
+ {hiddenable(<div className="rte-value"
776
+ dangerouslySetInnerHTML={{__html: item[field.name]}}></div>)}
777
+ </td>;
778
+ }
779
+ if (field.type === 'richtext_t') {
780
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
781
+ {hiddenable(<RichText value={item[field.name]} initialLang={lang} />)}
782
+ </td>;
783
+ }
784
+ if (field.type === 'color') {
785
+ return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
786
+ {hiddenable(<ColorField name={field.name} disabled={true}
787
+ value={item[field.name]}/>)}</td>;
788
+ }
789
+ return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
790
+ {hiddenable(item[field.name])}</td>;
791
+ })}
792
+ {advanced && (<td>
793
+ <button data-tooltip-id="tooltipActions"
794
+ data-tooltip-content={t('btns.edit', 'Modifier')}
795
+ onClick={() => handleEdit(item)}><FaPencil/></button>
796
+ <button
797
+ onClick={() => handleDuplicate(item)}
798
+ data-tooltip-id="tooltipActions"
799
+ data-tooltip-content={t('btns.duplicate', 'Dupliquer')}
800
+ >
801
+ <FaCopy/>
802
+ </button>
803
+ {selectedModel?.history?.enabled && (<button
804
+ onClick={() => setSelectedRow(item._id)}
805
+ data-tooltip-id="tooltipActions"
806
+ data-tooltip-content={t('btns.showHistory', 'Voir l\'historique')}
807
+ >
808
+ <FaHistory />
809
+ </button>)}
810
+
811
+ <button data-tooltip-id="tooltipActions"
812
+ data-tooltip-content={t('btns.delete', 'Supprimer')}
813
+ onClick={() => handleDelete(item)}><FaTrash/></button>
814
+ </td>)}
815
+ </tr>
816
+ </DialogProvider></>
817
+ ))}
818
+
819
+ </tbody>
820
+
821
+ <tfoot>
822
+ {data.length > 10 && (<Header advanced={advanced} reversed={true} model={model} setCheckedItems={setCheckedItems}
823
+ filterValues={filterValues} data={data}
824
+ setFilterValues={setFilterValues}
825
+ onChangeFilterValue={onChangeFilterValue}
826
+ checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>)}
827
+ </tfoot>
828
+
829
+ </table>)}
830
+ {!isDataLoaded && <div className="spinner-loader"></div>}
831
+ <DialogProvider>
832
+ {importVisible && (<DataImporter onClose={() => {
833
+ setImportVisible(false);
834
+ }}/>)}
835
+
836
+ {selectedRow && (
837
+ <HistoryDialog onClose={() => setSelectedRow(null)} modelName={selectedModel?.name} recordId={selectedRow} />
838
+ )}
839
+ <RestoreConfirmationModal
840
+ isOpen={isBackupModalOpen}
841
+ onClose={() => setIsBackupModalOpen(false)}
842
+ onConfirm={handleConfirmRestore}
843
+ />
844
+ <ExportDialog isOpen={showExportDialog} onClose={() => {
845
+ setExportDialogVisible(false);
846
+ }} availableModels={models} currentModel={selectedModel.name} hasSelection={true} onExport={(data)=>{
847
+ exportMutation(data);
848
+ }} />
849
+ {showPackGallery && (
850
+ <Dialog isClosable={true} isModal={true} onClose={() => setShowPackGallery(false)}>
851
+ <PackGallery />
852
+ </Dialog>
853
+ )}
854
+ </DialogProvider>
855
+ </div>
856
+ </div>
857
+ );
858
+ }