data-primals-engine 1.4.0 → 1.4.2

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