data-primals-engine 1.0.10 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/README.md +8 -0
- package/client/index.html +31 -0
- package/client/index.js +25 -0
- package/client/package-lock.json +11664 -0
- package/client/package.json +70 -0
- package/client/public/demo/26899917-d1ba-4df4-bb33-48d09a8778da.jpg +0 -0
- package/client/public/demo/4b9894c7-12cd-466d-8fd3-a2757b09ec96.jpg +0 -0
- package/client/public/demo/7ed369ac-a1a2-4b45-b0cd-4fd5bcf5a75a.jpg +0 -0
- package/client/public/doc/API.postman_collection.json +214 -0
- package/client/public/github.svg +1 -0
- package/client/public/profilCompany.jpg +0 -0
- package/client/public/profilDeveloper.jpg +0 -0
- package/client/public/profilEngineer.jpg +0 -0
- package/client/public/profilPersonal.jpg +0 -0
- package/client/public/profils .psd +0 -0
- package/client/public/react.svg +9 -0
- package/client/src/APIInfo.jsx +328 -0
- package/client/src/AddWidgetTypeModal.jsx +44 -0
- package/client/src/AddWidgetTypeModal.scss +87 -0
- package/client/src/App.css +42 -0
- package/client/src/App.jsx +586 -0
- package/client/src/App.scss +1466 -0
- package/client/src/AssistantChat.jsx +280 -0
- package/client/src/AssistantChat.scss +229 -0
- package/client/src/Button.jsx +12 -0
- package/client/src/Button.scss +197 -0
- package/client/src/CalculationBuilder.jsx +338 -0
- package/client/src/CalculationStepRow.jsx +199 -0
- package/client/src/CalendarConfigModal.jsx +49 -0
- package/client/src/ChartConfigModal.jsx +402 -0
- package/client/src/ConditionBuilder.jsx +622 -0
- package/client/src/ConditionBuilder.scss +672 -0
- package/client/src/ConditionBuilder2.jsx +757 -0
- package/client/src/ContentView.jsx +274 -0
- package/client/src/CronBuilder.jsx +126 -0
- package/client/src/CronBuilder.scss +128 -0
- package/client/src/CronPartBuilder.jsx +150 -0
- package/client/src/Dashboard.jsx +210 -0
- package/client/src/Dashboard.scss +445 -0
- package/client/src/DashboardChart.jsx +362 -0
- package/client/src/DashboardFlexViewItem.jsx +129 -0
- package/client/src/DashboardView.jsx +554 -0
- package/client/src/DataChart.jsx +349 -0
- package/client/src/DataEditor.jsx +556 -0
- package/client/src/DataLayout.jsx +709 -0
- package/client/src/DataTable.jsx +996 -0
- package/client/src/DataTable.scss +73 -0
- package/client/src/Dialog.jsx +90 -0
- package/client/src/Dialog.scss +117 -0
- package/client/src/DisplayFlexNodeRenderer.jsx +115 -0
- package/client/src/DocumentationPageLayout.jsx +93 -0
- package/client/src/DocumentationPageLayout.scss +262 -0
- package/client/src/Draggable.jsx +222 -0
- package/client/src/Draggable.scss +49 -0
- package/client/src/ExportDialog.jsx +172 -0
- package/client/src/Field.jsx +1558 -0
- package/client/src/FlexBuilder.jsx +269 -0
- package/client/src/FlexBuilder.scss +226 -0
- package/client/src/FlexBuilderControls.jsx +170 -0
- package/client/src/FlexBuilderModal.jsx +75 -0
- package/client/src/FlexBuilderModal.scss +109 -0
- package/client/src/FlexBuilderPreview.jsx +48 -0
- package/client/src/FlexBuilderUtils.js +84 -0
- package/client/src/FlexDataRenderer.jsx +170 -0
- package/client/src/FlexDataRenderer.scss +80 -0
- package/client/src/FlexNode.jsx +290 -0
- package/client/src/FlexTreeUtils.js +129 -0
- package/client/src/HiddenableCell.jsx +46 -0
- package/client/src/HiddenableCell.scss +36 -0
- package/client/src/KPIDialog.jsx +39 -0
- package/client/src/KPIWidget.jsx +139 -0
- package/client/src/KanbanCard.jsx +92 -0
- package/client/src/KanbanColumn.jsx +29 -0
- package/client/src/KanbanConfigModal.jsx +115 -0
- package/client/src/KanbanView.jsx +113 -0
- package/client/src/KanbanView.scss +107 -0
- package/client/src/MessageRotator.jsx +77 -0
- package/client/src/MessageRotator.scss +14 -0
- package/client/src/ModelCreator.jsx +640 -0
- package/client/src/ModelCreator.scss +154 -0
- package/client/src/ModelCreatorField.jsx +810 -0
- package/client/src/ModelImporter.jsx +120 -0
- package/client/src/ModelList.jsx +205 -0
- package/client/src/Notification.jsx +137 -0
- package/client/src/Notification.scss +126 -0
- package/client/src/NotificationProvider.jsx +73 -0
- package/client/src/PackGallery.jsx +211 -0
- package/client/src/PackGallery.scss +156 -0
- package/client/src/Pagination.jsx +141 -0
- package/client/src/RTE.jsx +415 -0
- package/client/src/RTETrans.jsx +103 -0
- package/client/src/RelationField.jsx +247 -0
- package/client/src/RelationValue.jsx +230 -0
- package/client/src/RestoreConfirmationModal.jsx +183 -0
- package/client/src/RestoreDialog.jsx +134 -0
- package/client/src/RestoreDialog.scss +67 -0
- package/client/src/RichTextEditorModal.jsx +34 -0
- package/client/src/RichTextEditorModal.scss +54 -0
- package/client/src/TourSpotlight.jsx +234 -0
- package/client/src/TourSpotlight.scss +100 -0
- package/client/src/TutorialsMenu.jsx +83 -0
- package/client/src/TutorialsMenu.scss +217 -0
- package/client/src/TutorialsRewardModal.jsx +65 -0
- package/client/src/Utils.jsx +78 -0
- package/client/src/ViewSwitcher.jsx +54 -0
- package/client/src/ViewSwitcher.scss +74 -0
- package/client/src/Webpage.jsx +69 -0
- package/client/src/_variables.scss +21 -0
- package/client/src/assets/react.svg +1 -0
- package/client/src/constants.js +68 -0
- package/client/src/contexts/AuthContext.jsx +28 -0
- package/client/src/contexts/ModelContext.jsx +328 -0
- package/client/src/contexts/UIContext.jsx +52 -0
- package/client/src/core/data.js +35 -0
- package/client/src/entry-client.jsx +15 -0
- package/client/src/entry-server.jsx +13 -0
- package/client/src/filter.js +222 -0
- package/client/src/hooks/data.js +101 -0
- package/client/src/hooks/useDebounce.js +20 -0
- package/client/src/hooks/useDragAndDrop.js +59 -0
- package/client/src/hooks/useLocalStorage.js +100 -0
- package/client/src/hooks/useMount.js +42 -0
- package/client/src/hooks/useTutorials.jsx +227 -0
- package/client/src/hooks/useWindowSize.js +24 -0
- package/client/src/i18n.js +47 -0
- package/client/src/index.css +90 -0
- package/client/src/index.js +10 -0
- package/client/src/main.jsx +11 -0
- package/client/src/translations.js +15940 -0
- package/client/src/tutorials.js +112 -0
- package/client/vite.config.js +30 -0
- package/eslint.config.js +50 -0
- package/package.json +48 -27
- package/server.js +9 -0
- package/src/constants.js +12 -23
- package/src/core.js +9 -8
- package/src/data.js +81 -80
- package/src/defaultModels.js +114 -115
- package/src/email.js +8 -9
- package/src/engine.js +5 -5
- package/src/events.js +1 -2
- package/src/i18n.js +5390 -211
- package/src/middlewares/middleware-mongodb.js +94 -93
- package/src/migrate.js +24 -24
- package/src/modules/assistant.js +55 -55
- package/src/modules/bucket.js +10 -9
- package/src/modules/data.js +387 -357
- package/src/modules/file.js +3 -6
- package/src/modules/mongodb.js +6 -7
- package/src/modules/user.js +24 -47
- package/src/modules/workflow.js +68 -67
- package/src/openai.jobs.js +2 -2
- package/src/packs.js +30 -31
- package/src/providers.js +101 -3
- package/src/setenv.js +1 -1
- package/src/user.js +0 -17
- package/src/workers/crypto-worker.js +21 -20
- package/src/workers/import-export-worker.js +18 -18
- package/test/data.backup.integration.test.js +7 -12
- package/test/data.integration.test.js +27 -25
- package/test/globalSetup.js +2 -2
- package/test/import_export.integration.test.js +6 -6
- package/test/model.integration.test.js +21 -22
- package/test/workflow.integration.test.js +2 -5
- package/test/workflow.robustness.test.js +6 -13
- package/vitest.config.js +2 -2
|
@@ -0,0 +1,996 @@
|
|
|
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 "data-primals-engine/data";
|
|
8
|
+
import cronstrue from 'cronstrue/i18n';
|
|
9
|
+
import {
|
|
10
|
+
FaBook,
|
|
11
|
+
FaCopy,
|
|
12
|
+
FaDatabase,
|
|
13
|
+
FaFileExport,
|
|
14
|
+
FaFileImport,
|
|
15
|
+
FaFilter,
|
|
16
|
+
FaInfo,
|
|
17
|
+
FaPlus,
|
|
18
|
+
FaTrash
|
|
19
|
+
} from "react-icons/fa";
|
|
20
|
+
import {useNotificationContext} from "./NotificationProvider.jsx";
|
|
21
|
+
import {
|
|
22
|
+
elementsPerPage,
|
|
23
|
+
kilobytes,
|
|
24
|
+
maxBytesPerSecondThrottleData,
|
|
25
|
+
maxFileSize,
|
|
26
|
+
maxRequestData
|
|
27
|
+
} from "data-primals-engine/constants";
|
|
28
|
+
import Button from "./Button.jsx";
|
|
29
|
+
import {Dialog, DialogProvider} from "./Dialog.jsx";
|
|
30
|
+
import {
|
|
31
|
+
CheckboxField,
|
|
32
|
+
CodeField,
|
|
33
|
+
ColorField,
|
|
34
|
+
FileField,
|
|
35
|
+
FilterField,
|
|
36
|
+
ModelField,
|
|
37
|
+
PhoneField,
|
|
38
|
+
TextField
|
|
39
|
+
} from "./Field.jsx";
|
|
40
|
+
import RelationValue from "./RelationValue.jsx";
|
|
41
|
+
import {FaPencil, FaTriangleExclamation} from "react-icons/fa6";
|
|
42
|
+
import RestoreConfirmationModal from "./RestoreConfirmationModal.jsx";
|
|
43
|
+
import {event_trigger, isLightColor} from "data-primals-engine/core";
|
|
44
|
+
import {isConditionMet} from "./DataEditor.jsx";
|
|
45
|
+
import {Tooltip} from "react-tooltip";
|
|
46
|
+
import ExportDialog from "./ExportDialog.jsx";
|
|
47
|
+
import Captions from "yet-another-react-lightbox/plugins/captions";
|
|
48
|
+
import {Zoom} from "yet-another-react-lightbox/plugins";
|
|
49
|
+
import Lightbox from "yet-another-react-lightbox";
|
|
50
|
+
|
|
51
|
+
import "./DataTable.scss"
|
|
52
|
+
import useLocalStorage from "./hooks/useLocalStorage.js";
|
|
53
|
+
import TutorialsMenu from "../src/TutorialsMenu.jsx";
|
|
54
|
+
import {useTutorials} from "./hooks/useTutorials.jsx";
|
|
55
|
+
import PackGallery from "./PackGallery.jsx";
|
|
56
|
+
import {HiddenableCell} from "./HiddenableCell.jsx";
|
|
57
|
+
|
|
58
|
+
// Ajoutez cette constante pour la clé de sessionStorage
|
|
59
|
+
const SESSION_STORAGE_IMPORT_JOBS_KEY = 'activeImportJobs';
|
|
60
|
+
|
|
61
|
+
const Header = ({
|
|
62
|
+
reversed = false,
|
|
63
|
+
handleFilter,
|
|
64
|
+
model,
|
|
65
|
+
setCheckedItems,
|
|
66
|
+
checkedItems,
|
|
67
|
+
data,
|
|
68
|
+
filterActive,
|
|
69
|
+
onChangeFilterValue,
|
|
70
|
+
setFilterValues,
|
|
71
|
+
filterValues
|
|
72
|
+
}) => {
|
|
73
|
+
|
|
74
|
+
const { me } = useAuthContext()
|
|
75
|
+
const {
|
|
76
|
+
models,
|
|
77
|
+
countByModel,
|
|
78
|
+
selectedModel,
|
|
79
|
+
setPagedFilters,
|
|
80
|
+
page
|
|
81
|
+
} = useModelContext();
|
|
82
|
+
let totalCol = 0;
|
|
83
|
+
return <><tr className={reversed ? ' reversed' : ''}>
|
|
84
|
+
<th className={"mini"}>
|
|
85
|
+
<div className="flex flex-row">
|
|
86
|
+
<Button onClick={handleFilter} className={filterActive ? ' active' : ''}><FaFilter/></Button>
|
|
87
|
+
<CheckboxField checked={checkedItems.length === data.length} onChange={e => {
|
|
88
|
+
if (checkedItems.length === data.length) {
|
|
89
|
+
setCheckedItems([]);
|
|
90
|
+
} else {
|
|
91
|
+
setCheckedItems(data);
|
|
92
|
+
}
|
|
93
|
+
}}/>
|
|
94
|
+
</div>
|
|
95
|
+
</th>
|
|
96
|
+
{model.fields.map(field => {
|
|
97
|
+
if (field.type === 'password')
|
|
98
|
+
return <></>;
|
|
99
|
+
if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
|
|
100
|
+
return <th className={"empty"} key={"datatable-th-"+model.name+":"+field.name}>
|
|
101
|
+
<div className="flex flex-mini-gap flex-centered">
|
|
102
|
+
<FaTriangleExclamation color={"#df8107"} data-tooltip-id={`tooltip-desc`} data-tooltip-content={"\""+field.relation + "\" model not found"} />
|
|
103
|
+
{field.name}
|
|
104
|
+
<Tooltip id={"tooltip-desc"} render={({content}) => {
|
|
105
|
+
gtag('render hint '+field.relation);
|
|
106
|
+
return content;
|
|
107
|
+
}} />
|
|
108
|
+
</div>
|
|
109
|
+
</th>;
|
|
110
|
+
|
|
111
|
+
totalCol++;
|
|
112
|
+
return <FilterField reversed={reversed} filterValues={filterValues} setFilterValues={setFilterValues}
|
|
113
|
+
model={model} field={field} active={filterActive}
|
|
114
|
+
onChangeFilterValue={onChangeFilterValue}/>;
|
|
115
|
+
})}
|
|
116
|
+
<th><Trans i18nKey="actions">Actions</Trans>
|
|
117
|
+
{filterActive && <div>
|
|
118
|
+
<button onClick={() => {
|
|
119
|
+
setFilterValues({});
|
|
120
|
+
setPagedFilters(pagedFilters => ({...pagedFilters, [model.name]: {}}));
|
|
121
|
+
}}>x
|
|
122
|
+
</button>
|
|
123
|
+
</div>}</th>
|
|
124
|
+
</tr>
|
|
125
|
+
{reversed && (
|
|
126
|
+
<tr>
|
|
127
|
+
{countByModel?.[selectedModel?.name] === 0 &&
|
|
128
|
+
<td colSpan={totalCol + 2}><Trans i18nKey="no_data">Aucune donnée enregistrée</Trans></td>}
|
|
129
|
+
</tr>)}
|
|
130
|
+
</>
|
|
131
|
+
;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function DataTable({
|
|
135
|
+
model,
|
|
136
|
+
checkedItems,
|
|
137
|
+
setCheckedItems,
|
|
138
|
+
onEdit,
|
|
139
|
+
onAddData,
|
|
140
|
+
onDelete,
|
|
141
|
+
onShowAPI,
|
|
142
|
+
filterValues,
|
|
143
|
+
setFilterValues
|
|
144
|
+
}) {
|
|
145
|
+
const {
|
|
146
|
+
models,
|
|
147
|
+
elementsPerPage,
|
|
148
|
+
paginatedDataByModel,
|
|
149
|
+
countByModel,
|
|
150
|
+
pagedSort,
|
|
151
|
+
selectedModel,
|
|
152
|
+
pagedFilters,
|
|
153
|
+
setPagedFilters,
|
|
154
|
+
page
|
|
155
|
+
} = useModelContext();
|
|
156
|
+
const {t, i18n} = useTranslation();
|
|
157
|
+
const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
|
|
158
|
+
const queryClient = useQueryClient();
|
|
159
|
+
const {me} = useAuthContext()
|
|
160
|
+
|
|
161
|
+
const isDataLoaded = true;
|
|
162
|
+
const [importVisible, setImportVisible] = useState(false);
|
|
163
|
+
const [filterActive, setFilterActive] = useState(false)
|
|
164
|
+
|
|
165
|
+
const data = paginatedDataByModel[model?.name] || [];
|
|
166
|
+
|
|
167
|
+
const [showPackGallery, setShowPackGallery] = useState(false);
|
|
168
|
+
const [showExportDialog, setExportDialogVisible] = useState(false);
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
const [showAddPack, setAddPackVisible] = useState(false);
|
|
172
|
+
const handleAddPack = () => {
|
|
173
|
+
setAddPackVisible(!showAddPack);
|
|
174
|
+
}
|
|
175
|
+
const handleShowPacks = () => {
|
|
176
|
+
setShowPackGallery(true);
|
|
177
|
+
};
|
|
178
|
+
const handleDelete = async (item) => {
|
|
179
|
+
if (model && item && item._id) { // Assurez-vous d'avoir un identifiant unique pour chaque élément
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetch(`/api/data/${item._id}?_user=${encodeURIComponent(getUserId(me))}`, { // Assurez-vous que l'URL est correcte
|
|
182
|
+
method: 'DELETE',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (response.ok) {
|
|
186
|
+
const res = await response.json();
|
|
187
|
+
|
|
188
|
+
if (res.success) {
|
|
189
|
+
onDelete?.(item);
|
|
190
|
+
const notificationData = {
|
|
191
|
+
id: 'datatable.deleteData.success',
|
|
192
|
+
title: t('datatable.deleteData.success', 'Données supprimées avec succès'),
|
|
193
|
+
icon: <FaInfo/>,
|
|
194
|
+
status: 'completed'
|
|
195
|
+
};
|
|
196
|
+
addNotification(notificationData);
|
|
197
|
+
} else {
|
|
198
|
+
const notificationData = {
|
|
199
|
+
title: res.message,
|
|
200
|
+
status: 'error'
|
|
201
|
+
};
|
|
202
|
+
addNotification(notificationData);
|
|
203
|
+
console.log('Données non trouvées');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
event_trigger('API_DELETE_DATA', { model: item._model, id: item._id });
|
|
207
|
+
queryClient.invalidateQueries(['api/data', item._model, 'page', page, elementsPerPage, pagedFilters[item._model], pagedSort[item._model]]);
|
|
208
|
+
|
|
209
|
+
} else {
|
|
210
|
+
console.error('Erreur lors de la suppression des données');
|
|
211
|
+
// Gérer les erreurs (afficher un message à l'utilisateur, etc.)
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const notificationData = {
|
|
215
|
+
title: error.message,
|
|
216
|
+
status: 'error'
|
|
217
|
+
};
|
|
218
|
+
addNotification(notificationData);
|
|
219
|
+
console.error('Erreur lors de la suppression des données:', error);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
console.warn("Impossible de supprimer, l'élément ne possède pas d'ID");
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleEdit = (item) => {
|
|
227
|
+
onEdit(item); // Call onEdit with the item to edit
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const onChangeFilterValue = (field, value, tr) => {
|
|
231
|
+
setPagedFilters(pagedFilters => ({
|
|
232
|
+
[model.name]: {...pagedFilters[model.name] || {}, [field.name]: value || undefined}
|
|
233
|
+
}));
|
|
234
|
+
queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const {addNotification} = useNotificationContext();
|
|
238
|
+
|
|
239
|
+
const {mutate: exportMutation, isLoading} = useMutation(async (data) => {
|
|
240
|
+
|
|
241
|
+
console.log('Exportation des données');
|
|
242
|
+
const params = new URLSearchParams();
|
|
243
|
+
params.append('model', model.name);
|
|
244
|
+
params.append("_user", getUserId(me));
|
|
245
|
+
|
|
246
|
+
// Cas de la table de données : requête paginée
|
|
247
|
+
params.append('limit', maxRequestData + '');
|
|
248
|
+
params.append('attachment', '1');
|
|
249
|
+
params.append("depth", data.depth+"");
|
|
250
|
+
if( data.withModels )
|
|
251
|
+
params.append("withModels", "1");
|
|
252
|
+
|
|
253
|
+
const ids = data.exportSelection ? checkedItems.map(item => item._id) : [];
|
|
254
|
+
const body = JSON.stringify({filter: pagedFilters[model.name], models: data.models || [], ids});
|
|
255
|
+
|
|
256
|
+
params.append('ids', ids);
|
|
257
|
+
console.log('Fetch des données');
|
|
258
|
+
return fetch(`/api/data/export?lang=${lang}&${params.toString()}`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
body,
|
|
261
|
+
headers: {"Content-Type": "application/json"}
|
|
262
|
+
})
|
|
263
|
+
.then(resp => resp.status === 200 ? resp.blob() : Promise.reject('something went wrong'))
|
|
264
|
+
.then((blob) => {
|
|
265
|
+
const url = window.URL.createObjectURL(blob);
|
|
266
|
+
const a = document.createElement('a');
|
|
267
|
+
a.style.display = 'none';
|
|
268
|
+
a.href = url;
|
|
269
|
+
// the filename you want
|
|
270
|
+
a.download = model.name + '.dump.json';
|
|
271
|
+
document.body.appendChild(a);
|
|
272
|
+
a.click();
|
|
273
|
+
window.URL.revokeObjectURL(url);
|
|
274
|
+
})
|
|
275
|
+
.then(e => {
|
|
276
|
+
|
|
277
|
+
const notificationData = {
|
|
278
|
+
title: t('dataimporter.success', 'Exportation de ' + model.name + ' réussie'),
|
|
279
|
+
icon: <FaInfo/>,
|
|
280
|
+
status: 'completed'
|
|
281
|
+
};
|
|
282
|
+
addNotification(notificationData);
|
|
283
|
+
return e;
|
|
284
|
+
}).catch(e => {
|
|
285
|
+
const notificationData = {
|
|
286
|
+
title: e.message,
|
|
287
|
+
status: 'error'
|
|
288
|
+
};
|
|
289
|
+
addNotification(notificationData);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const handleExport = () => {
|
|
294
|
+
setExportDialogVisible(true)
|
|
295
|
+
}
|
|
296
|
+
const handleImport = () => {
|
|
297
|
+
setImportVisible(true);
|
|
298
|
+
}
|
|
299
|
+
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
|
|
300
|
+
|
|
301
|
+
const handleBackup = () => {
|
|
302
|
+
setIsBackupModalOpen(true);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
const handleConfirmRestore = async () => {
|
|
307
|
+
try {
|
|
308
|
+
// Make the API call to request the restore link
|
|
309
|
+
const response = await fetch('/api/backup/request-restore', {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
// ... other options ...
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const result = await response.json();
|
|
315
|
+
if (response.ok && result.message) {
|
|
316
|
+
addNotification({ status: 'completed', title: result.message });
|
|
317
|
+
} else {
|
|
318
|
+
addNotification({ status: 'error', title: result.error || t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
addNotification({ status: 'error', title: t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
|
|
322
|
+
console.error('Error requesting restore link:', error);
|
|
323
|
+
} finally {
|
|
324
|
+
setIsBackupModalOpen(false); // Close the modal
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const handleFilter = () => {
|
|
329
|
+
setFilterActive(!filterActive);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
const [lightboxIndex, setLightboxIndex] = useState(0);
|
|
334
|
+
const [lightboxOpened, setLightboxOpened] = useState(false);
|
|
335
|
+
const [lightboxSlides, setLightboxSlides] = useState([]);
|
|
336
|
+
const [tutorialDialogVisible, setTutorialDialogVisible] = useState(false);
|
|
337
|
+
|
|
338
|
+
const [newPackName, setNewPackName] = useState(''); // pack
|
|
339
|
+
|
|
340
|
+
const handleShowTutorialMenu = () => {
|
|
341
|
+
setTutorialDialogVisible(!tutorialDialogVisible);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!model)
|
|
345
|
+
return <></>;
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<div className={`datatable${filterActive ? ' filter-active' : ''}`}>
|
|
349
|
+
{<div className="flex actions flex-left">
|
|
350
|
+
<Button onClick={() => onAddData(model)}><FaPlus/><Trans i18nKey="btns.addData">Ajouter une
|
|
351
|
+
donnée</Trans></Button>
|
|
352
|
+
<Button onClick={handleImport} title={t("btns.import")}><FaFileImport/><Trans
|
|
353
|
+
i18nKey="btns.import">Importer</Trans></Button>
|
|
354
|
+
<Button disabled={isLoading} onClick={handleExport} title={t("btns.export")}><FaFileExport/><Trans
|
|
355
|
+
i18nKey="btns.export">Exporter</Trans></Button>
|
|
356
|
+
<Button className="tourStep-import-datapack" onClick={handleShowPacks} title={t("btns.addPack")}><FaPlus/><Trans
|
|
357
|
+
i18nKey="btns.addPack">Packs...</Trans></Button>
|
|
358
|
+
|
|
359
|
+
<Button className={/^demo[0-9]{1,2}$/.test(me.username) ? "btn-primary" : "btn"} onClick={handleShowTutorialMenu} title={t("btns.showTutos")}><FaPlus/><Trans
|
|
360
|
+
i18nKey="btns.showTutos">Tutoriels</Trans></Button>
|
|
361
|
+
{!/^demo[0-9]{1,2}$/.test(me.username) && (<Button onClick={handleBackup} title={t("btns.backup")}><FaDatabase/><Trans
|
|
362
|
+
i18nKey="btns.backup">Backup</Trans></Button>)}
|
|
363
|
+
<DialogProvider>
|
|
364
|
+
{tutorialDialogVisible && (
|
|
365
|
+
<Dialog isClosable={true} isModal={true} onClose={() => setTutorialDialogVisible(false)}>
|
|
366
|
+
<TutorialsMenu />
|
|
367
|
+
</Dialog>
|
|
368
|
+
)}
|
|
369
|
+
</DialogProvider>
|
|
370
|
+
<Button className="btn btn-primary" onClick={() => {
|
|
371
|
+
onShowAPI(selectedModel);
|
|
372
|
+
}}><FaBook/> {t('btns.api', 'API')}</Button>
|
|
373
|
+
</div>}
|
|
374
|
+
<div className="table-wrapper">
|
|
375
|
+
<Tooltip id={"tooltipFile"} clickable={true} />
|
|
376
|
+
<Lightbox
|
|
377
|
+
inline={{
|
|
378
|
+
style: { width: "100%", maxWidth: "900px", aspectRatio: "3 / 2" },
|
|
379
|
+
}} plugins={[Captions,Zoom]} index={lightboxIndex} open={lightboxOpened} close={()=> setLightboxOpened(false)}
|
|
380
|
+
slides={lightboxSlides}
|
|
381
|
+
on={{
|
|
382
|
+
click: ()=>{
|
|
383
|
+
|
|
384
|
+
}
|
|
385
|
+
}}
|
|
386
|
+
/>
|
|
387
|
+
{isDataLoaded && (
|
|
388
|
+
<table>
|
|
389
|
+
<thead>
|
|
390
|
+
<Header model={model} setCheckedItems={setCheckedItems} filterValues={filterValues} data={data} setFilterValues={setFilterValues} onChangeFilterValue={onChangeFilterValue} checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>
|
|
391
|
+
</thead>
|
|
392
|
+
<tbody>
|
|
393
|
+
{(data || []).map((item) => (
|
|
394
|
+
<><DialogProvider>
|
|
395
|
+
<tr key={item._id} onDoubleClick={() => onEdit(item)} onClick={(e) => {
|
|
396
|
+
const checked = (!e.target.closest('tr').querySelector('td:first-child input')?.checked);
|
|
397
|
+
if (checked) {
|
|
398
|
+
setCheckedItems(items => {
|
|
399
|
+
return [...items, item];
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
402
|
+
setCheckedItems(items => items.filter(i => i._id !== item._id));
|
|
403
|
+
}
|
|
404
|
+
}}>
|
|
405
|
+
<td className={"mini"}>
|
|
406
|
+
<CheckboxField className={"input-ref"}
|
|
407
|
+
checked={checkedItems.some(i => i._id === item._id)}
|
|
408
|
+
onChange={(e) => {
|
|
409
|
+
if (e.target.checked) {
|
|
410
|
+
setCheckedItems(items => {
|
|
411
|
+
return [...items, item];
|
|
412
|
+
});
|
|
413
|
+
} else {
|
|
414
|
+
setCheckedItems(items => items.filter(i => i._id !== item._id));
|
|
415
|
+
}
|
|
416
|
+
}}/></td>
|
|
417
|
+
{model.fields.map(field => {
|
|
418
|
+
|
|
419
|
+
if( !isConditionMet(model, field.condition, item, models, me)){
|
|
420
|
+
return <td className={"notmet"} key={item._id + field.name}></td>; // Do not render the header cell if the condition isn't met
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const hiddenable = (content) => {
|
|
424
|
+
if(field.hiddenable)
|
|
425
|
+
return <HiddenableCell value={content} />;
|
|
426
|
+
return content;
|
|
427
|
+
}
|
|
428
|
+
if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
|
|
429
|
+
return <td className={"empty"} key={item._id + field.name}></td>
|
|
430
|
+
if (field.type === "relation" && typeof field.relation === "string") {
|
|
431
|
+
return <td key={item._id + field.name} style={{backgroundColor: field.color}}>{hiddenable(<RelationValue field={field}
|
|
432
|
+
data={item}/>)}</td>;
|
|
433
|
+
}
|
|
434
|
+
if( field.type === "cronSchedule" ){
|
|
435
|
+
let val = '';
|
|
436
|
+
try {
|
|
437
|
+
val = cronstrue.toString(item[field.name], { locale: lang, throwExceptionOnParseError: true });
|
|
438
|
+
} catch (e) {
|
|
439
|
+
|
|
440
|
+
}
|
|
441
|
+
return <td
|
|
442
|
+
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
443
|
+
key={field.name}>{hiddenable(val)}</td>;
|
|
444
|
+
}
|
|
445
|
+
if (field.type === "date" && item[field.name]) {
|
|
446
|
+
return <td
|
|
447
|
+
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
448
|
+
key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
|
|
449
|
+
day: "numeric",
|
|
450
|
+
month: "numeric",
|
|
451
|
+
year: "numeric"
|
|
452
|
+
}))}</td>;
|
|
453
|
+
}
|
|
454
|
+
if (field.type === "datetime" && item[field.name]) {
|
|
455
|
+
return <td
|
|
456
|
+
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
457
|
+
key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
|
|
458
|
+
day: "numeric",
|
|
459
|
+
month: "numeric",
|
|
460
|
+
year: "numeric",
|
|
461
|
+
hour: "numeric",
|
|
462
|
+
minute: "numeric"
|
|
463
|
+
}))}</td>;
|
|
464
|
+
}
|
|
465
|
+
if (field.type === "enum") {
|
|
466
|
+
return <td
|
|
467
|
+
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>;
|
|
468
|
+
}
|
|
469
|
+
if (field.type === "code") {
|
|
470
|
+
const v = typeof(item[field.name]) === "string" ? item[field.name] : (item[field.name] ? JSON.stringify(item[field.name], null, 2) : '');
|
|
471
|
+
return <td
|
|
472
|
+
key={field.name}>{v && hiddenable(<CodeField language={field.language} name={field.name} value={v} disabled={true} />)}</td>;
|
|
473
|
+
}
|
|
474
|
+
if (field.type === "object") {
|
|
475
|
+
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>;
|
|
476
|
+
}
|
|
477
|
+
if (field.type === 'email') {
|
|
478
|
+
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>;
|
|
479
|
+
}
|
|
480
|
+
if (field.type === 'phone') {
|
|
481
|
+
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>;
|
|
482
|
+
}
|
|
483
|
+
if (field.type === 'model') {
|
|
484
|
+
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>;
|
|
485
|
+
}
|
|
486
|
+
if (field.type === 'password') {
|
|
487
|
+
return <></>;
|
|
488
|
+
}
|
|
489
|
+
if (field.type === 'array') {
|
|
490
|
+
let t = <></>
|
|
491
|
+
if (field.itemsType === 'file') {
|
|
492
|
+
const click = (e,i) => {
|
|
493
|
+
setLightboxIndex(i);
|
|
494
|
+
setLightboxOpened(true);
|
|
495
|
+
setLightboxSlides(item[field.name].map(s => ({
|
|
496
|
+
src: `/resources/${s.guid}`,
|
|
497
|
+
title: s.name
|
|
498
|
+
})));
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
};
|
|
501
|
+
t = (item[field.name] || []).map((it,i) => {
|
|
502
|
+
|
|
503
|
+
const r = `
|
|
504
|
+
<strong>filename</strong> : ${it.filename}<br />
|
|
505
|
+
<strong>guid</strong> : ${it.guid}<br />
|
|
506
|
+
<strong>type</strong> : ${it.mimeType}<br />
|
|
507
|
+
<strong>size</strong> : ${it.size} bytes<br />
|
|
508
|
+
<strong>timestamp</strong> : ${it.timestamp ? new Date(it.timestamp).toLocaleString(lang) : ''}
|
|
509
|
+
`;
|
|
510
|
+
return <a key={it.guid} href={`/resources/${it.guid}`} target="_blank"
|
|
511
|
+
data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
|
|
512
|
+
rel="noopener noreferrer" onClick={(e) => click(e,i)}><img
|
|
513
|
+
className="image" src={`/resources/${it.guid}`}
|
|
514
|
+
alt={`${it.name} (${it.guid})`}/></a>
|
|
515
|
+
});
|
|
516
|
+
return <td key={field.name}>
|
|
517
|
+
{hiddenable(<div className="gallery">{t}</div>)}
|
|
518
|
+
</td>;
|
|
519
|
+
}
|
|
520
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name]?.join(', ') || '')}</td>;
|
|
521
|
+
}
|
|
522
|
+
if (field.type === 'url') {
|
|
523
|
+
return <td key={field.name}>
|
|
524
|
+
{hiddenable(item[field.name] && (<><a href={item[field.name]}
|
|
525
|
+
title={item[field.name]} style={{color: field.color}}
|
|
526
|
+
className={"link-value"}
|
|
527
|
+
target="_blank">{item[field.name]}</a>
|
|
528
|
+
<button title={"Copy URL"} onClick={(e) => {
|
|
529
|
+
navigator.clipboard.writeText(item[field.name]).then(function () {
|
|
530
|
+
console.log('Async: Copying to clipboard was successful!');
|
|
531
|
+
}, function (err) {
|
|
532
|
+
console.error('Async: Could not copy text: ', err);
|
|
533
|
+
});
|
|
534
|
+
}}><FaCopy/></button>
|
|
535
|
+
</>))}</td>;
|
|
536
|
+
}
|
|
537
|
+
if (field.type === "file" && item[field.name]) {
|
|
538
|
+
const r = `
|
|
539
|
+
<strong>filename</strong> : ${item[field.name].filename}<br />
|
|
540
|
+
<strong>guid</strong> : ${item[field.name].guid}<br />
|
|
541
|
+
<strong>type</strong> : ${item[field.name].mimeType}<br />
|
|
542
|
+
<strong>size</strong> : ${item[field.name].size} bytes<br />
|
|
543
|
+
<strong>timestamp</strong> : ${item[field.name].timestamp.toLocaleString(lang)}
|
|
544
|
+
`;
|
|
545
|
+
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))
|
|
546
|
+
return <td key={field.name}>{hiddenable(<a
|
|
547
|
+
data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
|
|
548
|
+
href={`/resources/${item[field.name].guid}`} target="_blank"
|
|
549
|
+
rel="noopener noreferrer"><img className="image"
|
|
550
|
+
src={`/resources/${item[field.name].guid}`}
|
|
551
|
+
alt={`${item[field.name].filename}`}/></a>
|
|
552
|
+
)}</td>;
|
|
553
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""}><a style={{color: field.color}}
|
|
554
|
+
href={`/resources/${item[field.name].guid}`}
|
|
555
|
+
target="_blank"
|
|
556
|
+
rel="noopener noreferrer" data-tooltip-id={"tooltipFile"} data-tooltip-html={r}>{hiddenable(item[field.name].filename)}</a>
|
|
557
|
+
</td>;
|
|
558
|
+
}
|
|
559
|
+
if (field.type === 'number') {
|
|
560
|
+
let val = item[field.name];
|
|
561
|
+
if (val && field.unit) {
|
|
562
|
+
let formatter = new Intl.NumberFormat(lang);
|
|
563
|
+
val = formatter.format(item[field.name]);
|
|
564
|
+
}
|
|
565
|
+
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>;
|
|
566
|
+
}
|
|
567
|
+
if (field.type === "boolean") {
|
|
568
|
+
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>;
|
|
569
|
+
}
|
|
570
|
+
if (field.type === 'string_t') {
|
|
571
|
+
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>;
|
|
572
|
+
}
|
|
573
|
+
if (field.type === 'richtext') {
|
|
574
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
|
|
575
|
+
{hiddenable(<div className="rte-value"
|
|
576
|
+
dangerouslySetInnerHTML={{__html: item[field.name]}}></div>)}
|
|
577
|
+
</td>;
|
|
578
|
+
}
|
|
579
|
+
if (field.type === 'richtext_t') {
|
|
580
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
|
|
581
|
+
{hiddenable(<div className="rte-value"
|
|
582
|
+
dangerouslySetInnerHTML={{__html: item[field.name]?.[lang] || ''}}></div>)}
|
|
583
|
+
</td>;
|
|
584
|
+
}
|
|
585
|
+
if (field.type === 'color') {
|
|
586
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
|
|
587
|
+
{hiddenable(<ColorField name={field.name} disabled={true}
|
|
588
|
+
value={item[field.name]}/>)}</td>;
|
|
589
|
+
}
|
|
590
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
|
|
591
|
+
{hiddenable(item[field.name])}</td>;
|
|
592
|
+
})}
|
|
593
|
+
<td>
|
|
594
|
+
<button onClick={() => handleEdit(item)}><FaPencil/></button>
|
|
595
|
+
<button onClick={() => handleDelete(item)}><FaTrash/></button>
|
|
596
|
+
</td>
|
|
597
|
+
</tr>
|
|
598
|
+
</DialogProvider></>
|
|
599
|
+
))}
|
|
600
|
+
</tbody>
|
|
601
|
+
|
|
602
|
+
<tfoot>
|
|
603
|
+
{data.length > 10 && (<Header reversed={true} model={model} setCheckedItems={setCheckedItems} filterValues={filterValues} data={data} setFilterValues={setFilterValues} onChangeFilterValue={onChangeFilterValue} checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>)}
|
|
604
|
+
</tfoot>
|
|
605
|
+
|
|
606
|
+
</table>)}
|
|
607
|
+
{!isDataLoaded && <div className="spinner-loader"></div>}
|
|
608
|
+
<DialogProvider>
|
|
609
|
+
{importVisible && (<DataImporter onClose={() => {
|
|
610
|
+
setImportVisible(false);
|
|
611
|
+
}}/>)}
|
|
612
|
+
<RestoreConfirmationModal
|
|
613
|
+
isOpen={isBackupModalOpen}
|
|
614
|
+
onClose={() => setIsBackupModalOpen(false)}
|
|
615
|
+
onConfirm={handleConfirmRestore}
|
|
616
|
+
/>
|
|
617
|
+
<ExportDialog isOpen={showExportDialog} onClose={() => {
|
|
618
|
+
setExportDialogVisible(false);
|
|
619
|
+
console.log("close")
|
|
620
|
+
}} availableModels={models} currentModel={selectedModel.name} hasSelection={true} onExport={(data)=>{
|
|
621
|
+
exportMutation(data);
|
|
622
|
+
console.log(data);
|
|
623
|
+
}} />
|
|
624
|
+
{showPackGallery && (
|
|
625
|
+
<Dialog isClosable={true} isModal={true} onClose={() => setShowPackGallery(false)}>
|
|
626
|
+
<PackGallery />
|
|
627
|
+
</Dialog>
|
|
628
|
+
)}
|
|
629
|
+
</DialogProvider>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
function DataImporter({onClose}) {
|
|
637
|
+
|
|
638
|
+
const [file, setFile] = useState(null);
|
|
639
|
+
const {selectedModel, page} = useModelContext();
|
|
640
|
+
|
|
641
|
+
const {me} = useAuthContext();
|
|
642
|
+
const {t, i18n} = useTranslation();
|
|
643
|
+
|
|
644
|
+
const queryClient = useQueryClient();
|
|
645
|
+
const {addNotification} = useNotificationContext();
|
|
646
|
+
|
|
647
|
+
const [hasHeaders, setHasHeaders] = useState(true);
|
|
648
|
+
const [csvHeaders, setCSVHeaders] = useState(selectedModel.fields.map(field => field.name));
|
|
649
|
+
|
|
650
|
+
// --- MODIFIÉ : État pour gérer plusieurs tâches d'importation ---
|
|
651
|
+
// Cet objet stockera les données de progression de chaque tâche, indexées par leur jobId
|
|
652
|
+
const [importJobs, setImportJobs] = useState({});
|
|
653
|
+
// Cette liste stockera les IDs des tâches qui sont actuellement suivies via SSE
|
|
654
|
+
const [activeJobIds, setActiveJobIds] = useState([]);
|
|
655
|
+
|
|
656
|
+
// Référence pour stocker les instances EventSource, indexées par jobId
|
|
657
|
+
const eventSourceRefs = useRef({});
|
|
658
|
+
|
|
659
|
+
const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
|
|
660
|
+
|
|
661
|
+
const [storedJobIds, setStoredJobIds] = useLocalStorage(SESSION_STORAGE_IMPORT_JOBS_KEY, []);
|
|
662
|
+
// --- NOUVEAU : Charger/Sauvegarder les IDs des tâches actives depuis sessionStorage ---
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
// Charger les IDs des tâches actives depuis sessionStorage au montage
|
|
665
|
+
setActiveJobIds(storedJobIds);
|
|
666
|
+
|
|
667
|
+
// Pour chaque jobId stockpour obtenir le dernier statut
|
|
668
|
+
storedJobIds.forEach(jobId => {
|
|
669
|
+
startProgressTracking(jobId);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// Fonction de nettoyage : fermer toutes les connexions EventSource lors du démontage du composant
|
|
673
|
+
return () => {
|
|
674
|
+
Object.values(eventSourceRefs.current).forEach(es => es.close());
|
|
675
|
+
eventSourceRefs.current = {}; // Effacer les références
|
|
676
|
+
};
|
|
677
|
+
}, []); // S'exécute une seule fois au montage
|
|
678
|
+
|
|
679
|
+
// --- NOUVEAU : Effet pour mettre à jour sessionStorage lorsque activeJobIds change ---
|
|
680
|
+
useEffect(() => {
|
|
681
|
+
setStoredJobIds(activeJobIds);
|
|
682
|
+
}, [activeJobIds]);
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
// Mutation pour initier l'importation (envoi du fichier au serveur)
|
|
686
|
+
const {isLoading, mutate: importMutation} = useMutation(async () => {
|
|
687
|
+
console.log('Initiating data import...');
|
|
688
|
+
const params = new FormData();
|
|
689
|
+
params.append('model', selectedModel?.name);
|
|
690
|
+
params.append("_user", getUserId(me));
|
|
691
|
+
params.append("hasHeaders", !!hasHeaders);
|
|
692
|
+
params.append("csvHeaders", csvHeaders.join(','));
|
|
693
|
+
if (file) {
|
|
694
|
+
params.append("file", file);
|
|
695
|
+
} else {
|
|
696
|
+
addNotification({ title: t('dataimporter.noFileSelected', 'Veuillez sélectionner un fichier à importer.'), status: 'warning' });
|
|
697
|
+
return Promise.reject(new Error("No file selected"));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
try {
|
|
701
|
+
const response = await fetch(`/api/data/import?lang=${lang}`, {
|
|
702
|
+
method: 'POST',
|
|
703
|
+
body: params
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
if (response.status === 202) {
|
|
707
|
+
const { job } = await response.json();
|
|
708
|
+
const { jobId} = job;
|
|
709
|
+
|
|
710
|
+
// --- MODIFIÉ : Ajouter le nouvel jobId à activeJobIds et à l'état importJobs ---
|
|
711
|
+
setActiveJobIds(prevIds => [...prevIds, jobId]);
|
|
712
|
+
setImportJobs(prevJobs => ({
|
|
713
|
+
...prevJobs,
|
|
714
|
+
[jobId]: {
|
|
715
|
+
jobId,
|
|
716
|
+
status: 'pending',
|
|
717
|
+
totalRecords: 0,
|
|
718
|
+
processedRecords: 0,
|
|
719
|
+
errors: [],
|
|
720
|
+
// Ajoutez d'autres champs initiaux que vous souhaitez afficher immédiatement
|
|
721
|
+
}
|
|
722
|
+
}));
|
|
723
|
+
startProgressTracking(jobId); // Commencer le suivi de cette nouvelle tâche
|
|
724
|
+
addNotification({
|
|
725
|
+
title: t('dataimporter.initiated', 'Importation initiée. Suivi de la progression...'),
|
|
726
|
+
icon: <FaInfo/>,
|
|
727
|
+
status: 'info'
|
|
728
|
+
});
|
|
729
|
+
} else {
|
|
730
|
+
const errorData = await response.json();
|
|
731
|
+
addNotification({
|
|
732
|
+
title: errorData.error || t('dataimporter.error', 'Erreur lors de l\'importation.'),
|
|
733
|
+
status: 'error'
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
} catch (e) {
|
|
737
|
+
addNotification({
|
|
738
|
+
title: e.message || t('dataimporter.networkError', 'Erreur réseau lors de l\'importation.'),
|
|
739
|
+
status: 'error'
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// Fonction pour démarrer le suivi de la progression via Server-Sent Events (SSE) pour un jobId spécifique
|
|
745
|
+
|
|
746
|
+
// Fonction pour démarrer le suivi de la progression via Server-Sent Events (SSE) pour un jobId spécifique
|
|
747
|
+
const startProgressTracking = (jobId) => {
|
|
748
|
+
// Fermer toute connexion EventSource existante pour ce jobId pour éviter les doublons
|
|
749
|
+
if (eventSourceRefs.current[jobId]) {
|
|
750
|
+
eventSourceRefs.current[jobId].close();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const eventSource = new EventSource(`/api/import/progress/${jobId}`);
|
|
754
|
+
eventSourceRefs.current[jobId] = eventSource; // Stocker l'instance pour le nettoyage
|
|
755
|
+
|
|
756
|
+
eventSource.onmessage = (event) => {
|
|
757
|
+
const data = JSON.parse(event.data);
|
|
758
|
+
// --- MODIFIÉ : Mettre à jour la progression de la tâche spécifique ---
|
|
759
|
+
setImportJobs(prevJobs => ({
|
|
760
|
+
...prevJobs,
|
|
761
|
+
[jobId]: data
|
|
762
|
+
}));
|
|
763
|
+
|
|
764
|
+
// Si la tâche est terminée (succès ou échec), fermer la connexion SSE.
|
|
765
|
+
// NE PAS la retirer de activeJobIds ici, pour qu'elle persiste au rafraîchissement.
|
|
766
|
+
// Elle sera retirar le bouton "Effacer".
|
|
767
|
+
if (data.status === 'completed' || data.status === 'failed' || data.status === 'not_found') {
|
|
768
|
+
eventSource.close();
|
|
769
|
+
delete eventSourceRefs.current[jobId]; // Supprimer la référence de l'EventSource
|
|
770
|
+
|
|
771
|
+
// --- LIGNE MODIFIÉE/SUPPRIMÉE ---
|
|
772
|
+
// Supprimez ou commentez la ligne suivante :
|
|
773
|
+
// setActiveJobIds(prevIds => prevIds.filter(id => id !== jobId));
|
|
774
|
+
|
|
775
|
+
queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page]); // Rafraîchir les données du tableau
|
|
776
|
+
|
|
777
|
+
if (data.status === 'completed') {
|
|
778
|
+
addNotification({
|
|
779
|
+
title: t('dataimporter.success', 'Importation des données réussie.'),
|
|
780
|
+
icon: <FaInfo/>,
|
|
781
|
+
status: 'completed'
|
|
782
|
+
});
|
|
783
|
+
} else if (data.status === 'failed') {
|
|
784
|
+
addNotification({
|
|
785
|
+
title: t('dataimporter.failed', 'Importation échouée. Voir les détails pour les erreurs.'),
|
|
786
|
+
status: 'error'
|
|
787
|
+
});
|
|
788
|
+
} else if (data.status === 'not_found') {
|
|
789
|
+
addNotification({
|
|
790
|
+
title: t('dataimporter.jobNotFound', 'Tâche d\'importation non trouvée ou déjà terminée.'),
|
|
791
|
+
status: 'warning'
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
eventSource.onerror = (error) => {
|
|
798
|
+
console.error(`EventSource error for job ${jobId}:`, error);
|
|
799
|
+
eventSource.close();
|
|
800
|
+
delete eventSourceRefs.current[jobId];
|
|
801
|
+
setStoredJobIds(prevIds => prevIds.filter(id => id !== jobId));
|
|
802
|
+
};
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const handleImportClick = () => {
|
|
806
|
+
importMutation();
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const handleCloseModal = () => {
|
|
810
|
+
// Fermer toutes les connexions EventSource avant de fermer la modale
|
|
811
|
+
Object.values(eventSourceRefs.current).forEach(es => es.close());
|
|
812
|
+
eventSourceRefs.current = {}; // Effacer les références
|
|
813
|
+
onClose();
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
// Déterminer si une importation est actuellement en cours (pour désactiver les boutons)
|
|
817
|
+
const isAnyImportInProgress = Object.values(importJobs).some(job => job.status === 'pending' || job.status === 'processing');
|
|
818
|
+
|
|
819
|
+
// Filtrer et trier les tâches à afficher (par exemple, les tâches en cours en premier)
|
|
820
|
+
const jobsToDisplay = Object.values(importJobs).sort((a, b) => {
|
|
821
|
+
// Trier par statut (en attente/en cours en premier, puis échoué, puis terminé)
|
|
822
|
+
const statusOrder = { 'pending': 1, 'processing': 2, 'failed': 3, 'completed': 4, 'not_found': 5 };
|
|
823
|
+
return statusOrder[a.status] - statusOrder[b.status];
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
return (
|
|
827
|
+
<Dialog isClosable={true} isModal={true} onClose={handleCloseModal}>
|
|
828
|
+
<>
|
|
829
|
+
<h2>
|
|
830
|
+
<Trans i18nKey="dataimporter.title" values={{model: t('model_' + selectedModel?.name, selectedModel?.name)}}>
|
|
831
|
+
Importer des données dans {t('model_' + selectedModel?.name, selectedModel?.name)}
|
|
832
|
+
</Trans>
|
|
833
|
+
</h2>
|
|
834
|
+
<p>
|
|
835
|
+
<Trans i18nKey="dataimporter.info" values={{constante: (maxBytesPerSecondThrottleData / kilobytes) + 'ko/s'}}></Trans>
|
|
836
|
+
</p>
|
|
837
|
+
|
|
838
|
+
{/* Toujours afficher le formulaire de sélection de fichier et le bouton d'importation */}
|
|
839
|
+
<FileField
|
|
840
|
+
name="file"
|
|
841
|
+
maxSize={maxFileSize}
|
|
842
|
+
mimeTypes={['application/json', 'text/csv']}
|
|
843
|
+
type="file"
|
|
844
|
+
multiple={false}
|
|
845
|
+
onChange={(files) => {
|
|
846
|
+
setFile(files && files.length > 0 ? files[files.length - 1].file : null);
|
|
847
|
+
}}
|
|
848
|
+
/>
|
|
849
|
+
{file && (file.name.endsWith('.csv') || file.type === 'text/csv') && (
|
|
850
|
+
<div className="checkbox-label flex flex-row">
|
|
851
|
+
<label htmlFor="hasHeadersCheckbox">
|
|
852
|
+
<input
|
|
853
|
+
type="checkbox"
|
|
854
|
+
id="hasHeadersCheckbox"
|
|
855
|
+
checked={hasHeaders}
|
|
856
|
+
onChange={(e) => setHasHeaders(e.target.checked)}
|
|
857
|
+
/>
|
|
858
|
+
<Trans i18nKey="dataimporter.hasCsvHeaders"></Trans>
|
|
859
|
+
</label>
|
|
860
|
+
{!hasHeaders && (
|
|
861
|
+
<table>
|
|
862
|
+
<thead>
|
|
863
|
+
<tr>
|
|
864
|
+
<th><Trans i18nKey={"dataimporter.csvColumn"}>Num de colonne du CSV</Trans></th>
|
|
865
|
+
<th><Trans i18nKey="dataimporter.field">Champ du modèle</Trans></th>
|
|
866
|
+
</tr>
|
|
867
|
+
</thead>
|
|
868
|
+
<tbody>
|
|
869
|
+
{csvHeaders.map((mappedFieldName, index) => {
|
|
870
|
+
let fieldObjectForModelField = null;
|
|
871
|
+
let currentFieldValueForModelField = null;
|
|
872
|
+
|
|
873
|
+
if (mappedFieldName && mappedFieldName.trim() !== '') {
|
|
874
|
+
fieldObjectForModelField = selectedModel?.fields.find(f_model => f_model.name === mappedFieldName);
|
|
875
|
+
if (fieldObjectForModelField) {
|
|
876
|
+
currentFieldValueForModelField = fieldObjectForModelField.name;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return (
|
|
881
|
+
<tr key={`${selectedModel.name}-csvmap-${index}`}>
|
|
882
|
+
<td><Trans i18nKey="dataimporter.column" values={[index + 1]}>colonne {index + 1}</Trans></td>
|
|
883
|
+
<td>
|
|
884
|
+
<div className="flex">
|
|
885
|
+
<ModelField
|
|
886
|
+
disableable={true}
|
|
887
|
+
showModel={false}
|
|
888
|
+
value={selectedModel.name}
|
|
889
|
+
fieldValue={currentFieldValueForModelField}
|
|
890
|
+
onChange={({name: propName, value: selectedValue}) => {
|
|
891
|
+
const newCsvHeaders = [...csvHeaders];
|
|
892
|
+
newCsvHeaders[index] = selectedValue?.field ?? '';
|
|
893
|
+
setCSVHeaders(newCsvHeaders);
|
|
894
|
+
}}
|
|
895
|
+
fields={true}
|
|
896
|
+
model={selectedModel}
|
|
897
|
+
field={fieldObjectForModelField}
|
|
898
|
+
/>
|
|
899
|
+
<Button className="flex" onClick={() => {
|
|
900
|
+
const csvH = [...csvHeaders];
|
|
901
|
+
setCSVHeaders(csvH.filter((_, i) => i !== index));
|
|
902
|
+
}}><FaTrash/></Button>
|
|
903
|
+
</div>
|
|
904
|
+
</td>
|
|
905
|
+
</tr>
|
|
906
|
+
);
|
|
907
|
+
})}
|
|
908
|
+
<tr>
|
|
909
|
+
<td colSpan={2}>
|
|
910
|
+
<Button onClick={() => {
|
|
911
|
+
const csvH = [...csvHeaders];
|
|
912
|
+
csvH.push('');
|
|
913
|
+
setCSVHeaders(csvH)
|
|
914
|
+
}}><Trans i18nKey="dataimporter.addColumn">Ajouter une colonne</Trans></Button>
|
|
915
|
+
</td>
|
|
916
|
+
</tr>
|
|
917
|
+
</tbody>
|
|
918
|
+
</table>
|
|
919
|
+
)}
|
|
920
|
+
</div>
|
|
921
|
+
)}
|
|
922
|
+
<div>
|
|
923
|
+
<Button onClick={handleImportClick} disabled={isLoading || !file}>
|
|
924
|
+
<Trans i18nKey="btns.import">Importer</Trans>
|
|
925
|
+
</Button>
|
|
926
|
+
</div>
|
|
927
|
+
|
|
928
|
+
{/* Afficher la progression pour toutes les tâches d'importation actives/suivies */}
|
|
929
|
+
{jobsToDisplay.length > 0 && (
|
|
930
|
+
<div className="import-jobs-list">
|
|
931
|
+
<h3><Trans i18nKey="dataimporter.activeImports">Importations en cours / terminées</Trans></h3>
|
|
932
|
+
{jobsToDisplay.map(job => {
|
|
933
|
+
const progressPercentage = job.totalRecords > 0
|
|
934
|
+
? (job.processedRecords / job.totalRecords) * 100
|
|
935
|
+
: 0;
|
|
936
|
+
const isJobFinished = job.status === 'completed' || job.status === 'failed' || job.status === 'not_found';
|
|
937
|
+
|
|
938
|
+
return (
|
|
939
|
+
<div key={job.jobId} className="import-progress-container">
|
|
940
|
+
<h4><Trans i18nKey="dataimporter.jobId">Tâche ID:</Trans> {job.jobId?.substring(0, 8)}...</h4>
|
|
941
|
+
<p>
|
|
942
|
+
<Trans i18nKey="dataimporter.status">Statut:</Trans>{' '}
|
|
943
|
+
<strong>{t(`dataimporter.status.${job.status}`, job.status)}</strong>
|
|
944
|
+
</p>
|
|
945
|
+
{job.totalRecords > 0 && (
|
|
946
|
+
<p>
|
|
947
|
+
<Trans i18nKey="dataimporter.recordsProcessed">Enregistrements traités:</Trans>{' '}
|
|
948
|
+
{job.processedRecords} / {job.totalRecords}
|
|
949
|
+
</p>
|
|
950
|
+
)}
|
|
951
|
+
<div className="progress-bar-wrapper">
|
|
952
|
+
<div
|
|
953
|
+
className="progress-bar"
|
|
954
|
+
style={{ width: `${progressPercentage}%` }}
|
|
955
|
+
>
|
|
956
|
+
{progressPercentage.toFixed(0)}%
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
|
|
960
|
+
{job.errors && job.errors.length > 0 && (
|
|
961
|
+
<div className="import-errors">
|
|
962
|
+
<h4><Trans i18nKey="dataimporter.errors">Erreurs:</Trans></h4>
|
|
963
|
+
<ul>
|
|
964
|
+
{job.errors.map((error, index) => (
|
|
965
|
+
<li key={index}>{error}</li>
|
|
966
|
+
))}
|
|
967
|
+
</ul>
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
{isJobFinished && (
|
|
971
|
+
<div className="flex justify-end mt-2">
|
|
972
|
+
<Button onClick={() => {
|
|
973
|
+
setImportJobs(prevJobs => {
|
|
974
|
+
const newJobs = { ...prevJobs };
|
|
975
|
+
delete newJobs[job.jobId];
|
|
976
|
+
return newJobs;
|
|
977
|
+
});
|
|
978
|
+
setActiveJobIds(prevIds => prevIds.filter(id => id !== job.jobId));
|
|
979
|
+
}}><Trans i18nKey="btns.clear">Effacer</Trans></Button>
|
|
980
|
+
</div>
|
|
981
|
+
)}
|
|
982
|
+
</div>
|
|
983
|
+
);
|
|
984
|
+
})}
|
|
985
|
+
</div>
|
|
986
|
+
)}
|
|
987
|
+
|
|
988
|
+
<div className="flex justify-end mt-4">
|
|
989
|
+
<Button onClick={handleCloseModal} disabled={isAnyImportInProgress}>
|
|
990
|
+
<Trans i18nKey="btns.close">Fermer</Trans>
|
|
991
|
+
</Button>
|
|
992
|
+
</div>
|
|
993
|
+
</>
|
|
994
|
+
</Dialog>
|
|
995
|
+
);
|
|
996
|
+
}
|