data-primals-engine 1.5.2 → 1.6.1

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