data-primals-engine 1.2.3 → 1.2.5

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 (58) hide show
  1. package/CONTRIBUTING.md +91 -0
  2. package/README.md +50 -22
  3. package/client/src/App.jsx +0 -5
  4. package/client/src/App.scss +6 -0
  5. package/client/src/ConditionBuilder.scss +34 -1
  6. package/client/src/ConditionBuilder2.jsx +179 -53
  7. package/client/src/ContentView.jsx +0 -3
  8. package/client/src/CronBuilder.jsx +0 -1
  9. package/client/src/CronPartBuilder.jsx +0 -2
  10. package/client/src/DashboardView.jsx +0 -5
  11. package/client/src/DataEditor.jsx +8 -10
  12. package/client/src/DataImporter.jsx +469 -0
  13. package/client/src/DataLayout.jsx +0 -1
  14. package/client/src/DataTable.jsx +2 -368
  15. package/client/src/DataTable.scss +18 -1
  16. package/client/src/Field.jsx +85 -48
  17. package/client/src/FlexBuilder.jsx +1 -1
  18. package/client/src/ModelCreator.jsx +29 -25
  19. package/client/src/ModelCreator.scss +13 -0
  20. package/client/src/ModelCreatorField.jsx +1 -5
  21. package/client/src/RTE.jsx +1 -6
  22. package/client/src/RTETrans.jsx +0 -2
  23. package/client/src/RelationField.jsx +1 -1
  24. package/client/src/RelationValue.jsx +1 -2
  25. package/client/src/TourSpotlight.jsx +0 -2
  26. package/client/src/filter.js +87 -0
  27. package/client/src/hooks/data.js +1 -3
  28. package/client/src/hooks/useTutorials.jsx +0 -1
  29. package/client/src/translations.js +60 -26
  30. package/package.json +4 -3
  31. package/server.js +2 -2
  32. package/src/data.js +8 -0
  33. package/src/email.js +2 -2
  34. package/src/engine.js +59 -20
  35. package/src/index.js +1 -1
  36. package/src/middlewares/middleware-mongodb.js +0 -1
  37. package/src/modules/assistant.js +1 -3
  38. package/src/modules/bucket.js +3 -4
  39. package/src/modules/data/data.core.js +17 -0
  40. package/src/modules/{data.js → data/data.js} +4595 -5991
  41. package/src/modules/data/data.routes.js +1637 -0
  42. package/src/modules/data/index.js +1 -0
  43. package/src/modules/file.js +1 -1
  44. package/src/modules/mongodb.js +0 -1
  45. package/src/modules/user.js +1 -1
  46. package/src/modules/workflow.js +38 -38
  47. package/src/packs.js +4 -1
  48. package/test/data.backup.integration.test.js +4 -5
  49. package/test/data.integration.test.js +2 -6
  50. package/test/events.test.js +1 -1
  51. package/test/file.test.js +1 -4
  52. package/test/import_export.integration.test.js +22 -15
  53. package/test/model.integration.test.js +8 -10
  54. package/test/user.test.js +2 -2
  55. package/test/vm.test.js +1 -1
  56. package/test/workflow.integration.test.js +17 -14
  57. package/test/workflow.robustness.test.js +15 -10
  58. package/src/modules/test +0 -147
@@ -111,8 +111,6 @@ export const CronPartBuilder = ({ label, masked, value, defaultValue, onChange,
111
111
  { value: 'range', label: t('cron.range', `Plage (ex: 9-17)`) },
112
112
  ];
113
113
 
114
- console.log({label, masked})
115
-
116
114
  return (
117
115
  <div className="cron-part-controls">
118
116
  <SelectField disabled={masked} items={modeOptions} value={mode} onChange={handleModeChange} />
@@ -13,10 +13,6 @@ import { useQuery, useQueryClient, useMutation } from "react-query";
13
13
  import { useAuthContext } from "./contexts/AuthContext.jsx";
14
14
  import { DialogProvider } from "./Dialog.jsx";
15
15
  import {useModelContext} from "./contexts/ModelContext.jsx";
16
- import FlexDataRenderer from "./FlexDataRenderer.jsx";
17
- import {conditionToApiSearchFilter} from "../../src/data.js";
18
- // --- MODIFICATION : Import de la fonction cssProps ---
19
- import { cssProps } from 'data-primals-engine/core';
20
16
  import {DashboardFlexViewItem} from "./DashboardFlexViewItem.jsx";
21
17
 
22
18
  // --- updateDashboardLayout (fonction utilitaire, peut rester ici ou être externalisée) ---
@@ -85,7 +81,6 @@ export function DashboardView({ dashboard }) {
85
81
  (newLayout) => updateDashboardLayout(dashboard, newLayout, me.username, t),
86
82
  {
87
83
  onMutate: async (newLayout) => {
88
- console.log("Optimi stic update:", newLayout);
89
84
  const previousLayout = layoutState;
90
85
  setLayoutState(newLayout);
91
86
  return { previousLayout };
@@ -25,7 +25,7 @@ import Draggable from "./Draggable.jsx";
25
25
  import CronBuilder from "./CronBuilder.jsx";
26
26
  import RTETrans from "./RTETrans.jsx";
27
27
  import uniqid from "uniqid";
28
- import {isConditionMet} from "data-primals-engine/filter";
28
+ import {isConditionMet} from "../../src/filter";
29
29
 
30
30
  // ... (fonction getInputType) ...
31
31
  // Fonction pour obtenir le type d'input HTML basé sur le type de champ du modèle
@@ -62,7 +62,6 @@ export const DataEditor = forwardRef(function MyDataEditor({
62
62
  formData,
63
63
  setFormData, record, setRecord}, ref){
64
64
 
65
- const [focusedField, setFocusedField] = useState({});
66
65
  const {me} = useAuthContext()
67
66
  const {models} = useModelContext()
68
67
 
@@ -103,7 +102,7 @@ export const DataEditor = forwardRef(function MyDataEditor({
103
102
  case 'textarea':
104
103
  return <textarea key={field.name} {...inputProps} />
105
104
  case 'richtext':
106
- return <RTE help={focusedField?.name === field.name ? t('field_'+model.name+'_'+field.name+'_hint', field.hint || '') : ''} onFocus={() => setFocusedField(field)} onBlur={() => setFocusedField(null)} key={field.name} {...inputProps} field={field} name={formData._id} />;
105
+ return <RTE help={t('field_'+model.name+'_'+field.name+'_hint', field.hint || '')} key={field.name} {...inputProps} field={field} name={formData._id} />;
107
106
  case 'richtext_t':
108
107
  return <RTETrans
109
108
  key={field.name}
@@ -144,7 +143,6 @@ export const DataEditor = forwardRef(function MyDataEditor({
144
143
  // C'est un nom de modèle statique
145
144
  builderModelName = field.targetModel;
146
145
  }
147
- console.log({builderModelName})
148
146
  }
149
147
  return <div className={"flex flex-1"} style={{width:'100%'}} key={field.name}>
150
148
  {currentViewMode !== 'builder' && ( <div className="condition-builder-toggle">
@@ -219,10 +217,10 @@ export const DataEditor = forwardRef(function MyDataEditor({
219
217
  inputProps["min"] = field.min;
220
218
  if( field.max)
221
219
  inputProps["max"] = field.max;
222
- return <NumberField help={focusedField?.name === field.name ? t('field_'+model.name+'_'+field.name+'_hint', field.hint || '') : ''} onFocus={() => setFocusedField(field)} onBlur={() => setFocusedField(null)} unit={field.unit} key={field.name} {...inputProps} onChange={(e) => handleChange({name: field.name, value: parseFloat(e.target.value.replace(',', '.'))})} />
220
+ return <NumberField help={t('field_'+model.name+'_'+field.name+'_hint', field.hint || '')} unit={field.unit} key={field.name} {...inputProps} onChange={(e) => handleChange({name: field.name, value: parseFloat(e.target.value.replace(',', '.'))})} />
223
221
  case 'relation':
224
222
  return (
225
- <RelationField onFocus={() => setFocusedField(field)} onBlur={() => setFocusedField(null)} help={focusedField?.name === field.name ? t('field_'+model.name+'_'+field.name+'_hint', field.hint || '') : ''} key={field.name} model={model} field={field} value={value} onChange={(e) => {
223
+ <RelationField help={t('field_'+model.name+'_'+field.name+'_hint', field.hint || '')} key={field.name} model={model} field={field} value={value} onChange={(e) => {
226
224
  handleChange(e)
227
225
  }} refreshTime={refreshTime} />
228
226
  );
@@ -255,8 +253,8 @@ export const DataEditor = forwardRef(function MyDataEditor({
255
253
  const displayValue = (typeof value === 'object' && value !== null) ? value.key : (value || '');
256
254
 
257
255
  return <TextField
258
- help={focusedField?.name === field.name ? t('field_' + model.name + '_' + field.name + '_hint', field.hint || '') : ''}
259
- onFocus={() => setFocusedField(field)} onBlur={() => setFocusedField(null)} key={field.name}
256
+ help={t('field_' + model.name + '_' + field.name + '_hint', field.hint || '')}
257
+ key={field.name}
260
258
  type={getInputType(field.type)} {...inputProps}
261
259
  value={displayValue}
262
260
  onChange={(e) => handleChange({name: field.name, value: e.target.value})} />
@@ -266,9 +264,9 @@ export const DataEditor = forwardRef(function MyDataEditor({
266
264
  case 'color':
267
265
  return <ColorField help={t('field_'+model.name+'_'+field.name+'_hint', field.hint || '')} key={field.name} name={field.name} value={value} onChange={handleChange} />
268
266
  case 'email':
269
- return <EmailField onFocus={() => setFocusedField(field)} onBlur={() => setFocusedField(null)} help={focusedField?.name === field.name ? t('field_'+model.name+'_'+field.name+'_hint', field.hint || '') : ''} key={field.name} type={getInputType(field.type)} {...inputProps} onChange={(e) => handleChange({name: field.name, value: e.target.value})} />
267
+ return <EmailField help={t('field_'+model.name+'_'+field.name+'_hint', field.hint || '')} key={field.name} type={getInputType(field.type)} {...inputProps} onChange={(e) => handleChange({name: field.name, value: e.target.value})} />
270
268
  default:
271
- return <TextField onFocus={() => setFocusedField(field)} onBlur={() => setFocusedField(null)} help={focusedField?.name === field.name ? t('field_'+model.name+'_'+field.name+'_hint', field.hint || '') : ''} key={field.name} type={getInputType(field.type)} {...inputProps} onChange={(e) => handleChange({name: field.name, value: e.target.value})} />
269
+ return <TextField help={t('field_'+model.name+'_'+field.name+'_hint', field.hint || '')} key={field.name} type={getInputType(field.type)} {...inputProps} onChange={(e) => handleChange({name: field.name, value: e.target.value})} />
272
270
  }
273
271
  }
274
272
 
@@ -0,0 +1,469 @@
1
+ import {useAuthContext} from "./contexts/AuthContext.jsx";
2
+ import {useModelContext} from "./contexts/ModelContext.jsx";
3
+ import {useNotificationContext} from "./NotificationProvider.jsx";
4
+ import {useEffect, useRef, useState} from "react";
5
+ import {Trans, useTranslation} from "react-i18next";
6
+ import {useMutation, useQueryClient} from "react-query";
7
+ import useLocalStorage from "./hooks/useLocalStorage.js";
8
+ import {getUserId} from "../../src/data.js";
9
+ import {kilobytes, maxBytesPerSecondThrottleData, maxFileSize} from "../../src/constants";
10
+ import {FileField, ModelField} from "./Field.jsx";
11
+ import Button from "./Button.jsx";
12
+ import {FaInfo, FaTrash} from "react-icons/fa";
13
+ import {Dialog} from "./Dialog.jsx";
14
+ import readXlsxFile from 'read-excel-file'
15
+ // Ajoutez cette constante pour la clé de sessionStorage
16
+ const SESSION_STORAGE_IMPORT_JOBS_KEY = 'activeImportJobs';
17
+
18
+ export function DataImporter({onClose}) {
19
+ const [previewData, setPreviewData] = useState(null);
20
+ const [file, setFile] = useState(null);
21
+ const {selectedModel, page} = useModelContext();
22
+
23
+ const isCsvFile = file && (file.name.endsWith('.csv') || file.type === 'text/csv');
24
+ const isExcelFile = file && ((file.name.endsWith('.xlsx') ||
25
+ ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26
+ 'application/vnd.ms-excel'].includes(file.type)));
27
+
28
+ const {me} = useAuthContext();
29
+ const {t, i18n} = useTranslation();
30
+
31
+ const queryClient = useQueryClient();
32
+ const {addNotification} = useNotificationContext();
33
+
34
+ const [hasHeaders, setHasHeaders] = useState(true);
35
+ const [csvHeaders, setCSVHeaders] = useState(selectedModel.fields.map(field => field.name));
36
+
37
+ // --- MODIFIÉ : État pour gérer plusieurs tâches d'importation ---
38
+ // Cet objet stockera les données de progression de chaque tâche, indexées par leur jobId
39
+ const [importJobs, setImportJobs] = useState({});
40
+ // Cette liste stockera les IDs des tâches qui sont actuellement suivies via SSE
41
+ const [activeJobIds, setActiveJobIds] = useState([]);
42
+
43
+ // Référence pour stocker les instances EventSource, indexées par jobId
44
+ const eventSourceRefs = useRef({});
45
+
46
+ const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
47
+
48
+ const [storedJobIds, setStoredJobIds] = useLocalStorage(SESSION_STORAGE_IMPORT_JOBS_KEY, []);
49
+ // --- NOUVEAU : Charger/Sauvegarder les IDs des tâches actives depuis sessionStorage ---
50
+ useEffect(() => {
51
+ // Charger les IDs des tâches actives depuis sessionStorage au montage
52
+ setActiveJobIds(storedJobIds);
53
+
54
+ // Pour chaque jobId stockpour obtenir le dernier statut
55
+ storedJobIds.forEach(jobId => {
56
+ startProgressTracking(jobId);
57
+ });
58
+
59
+ // Fonction de nettoyage : fermer toutes les connexions EventSource lors du démontage du composant
60
+ return () => {
61
+ Object.values(eventSourceRefs.current).forEach(es => es.close());
62
+ eventSourceRefs.current = {}; // Effacer les références
63
+ };
64
+ }, []); // S'exécute une seule fois au montage
65
+
66
+ // --- NOUVEAU : Effet pour mettre à jour sessionStorage lorsque activeJobIds change ---
67
+ useEffect(() => {
68
+ setStoredJobIds(activeJobIds);
69
+ }, [activeJobIds]);
70
+
71
+
72
+ // Mutation pour initier l'importation (envoi du fichier au serveur)
73
+ const {isLoading, mutate: importMutation} = useMutation(async () => {
74
+ console.log('Initiating data import...');
75
+ const params = new FormData();
76
+ params.append('model', selectedModel?.name);
77
+ params.append("_user", getUserId(me));
78
+ params.append("hasHeaders", !!hasHeaders);
79
+ params.append("csvHeaders", csvHeaders.join(','));
80
+ if (file) {
81
+ params.append("file", file);
82
+ } else {
83
+ addNotification({ title: t('dataimporter.noFileSelected', 'Veuillez sélectionner un fichier à importer.'), status: 'warning' });
84
+ return Promise.reject(new Error("No file selected"));
85
+ }
86
+
87
+ try {
88
+ const response = await fetch(`/api/data/import?lang=${lang}`, {
89
+ method: 'POST',
90
+ body: params
91
+ });
92
+
93
+ if (response.status === 202) {
94
+ const { job } = await response.json();
95
+ const { jobId} = job;
96
+
97
+ // --- MODIFIÉ : Ajouter le nouvel jobId à activeJobIds et à l'état importJobs ---
98
+ setActiveJobIds(prevIds => [...prevIds, jobId]);
99
+ setImportJobs(prevJobs => ({
100
+ ...prevJobs,
101
+ [jobId]: {
102
+ jobId,
103
+ status: 'pending',
104
+ totalRecords: 0,
105
+ processedRecords: 0,
106
+ errors: [],
107
+ // Ajoutez d'autres champs initiaux que vous souhaitez afficher immédiatement
108
+ }
109
+ }));
110
+ startProgressTracking(jobId); // Commencer le suivi de cette nouvelle tâche
111
+ addNotification({
112
+ title: t('dataimporter.initiated', 'Importation initiée. Suivi de la progression...'),
113
+ icon: <FaInfo/>,
114
+ status: 'info'
115
+ });
116
+ } else {
117
+ const errorData = await response.json();
118
+ addNotification({
119
+ title: errorData.error || t('dataimporter.error', 'Erreur lors de l\'importation.'),
120
+ status: 'error'
121
+ });
122
+ }
123
+ } catch (e) {
124
+ addNotification({
125
+ title: e.message || t('dataimporter.networkError', 'Erreur réseau lors de l\'importation.'),
126
+ status: 'error'
127
+ });
128
+ }
129
+ });
130
+
131
+ // Fonction pour démarrer le suivi de la progression via Server-Sent Events (SSE) pour un jobId spécifique
132
+
133
+ // Fonction pour démarrer le suivi de la progression via Server-Sent Events (SSE) pour un jobId spécifique
134
+ const startProgressTracking = (jobId) => {
135
+ // Fermer toute connexion EventSource existante pour ce jobId pour éviter les doublons
136
+ if (eventSourceRefs.current[jobId]) {
137
+ eventSourceRefs.current[jobId].close();
138
+ }
139
+
140
+ const eventSource = new EventSource(`/api/import/progress/${jobId}`);
141
+ eventSourceRefs.current[jobId] = eventSource; // Stocker l'instance pour le nettoyage
142
+
143
+ eventSource.onmessage = (event) => {
144
+ const data = JSON.parse(event.data);
145
+ // --- MODIFIÉ : Mettre à jour la progression de la tâche spécifique ---
146
+ setImportJobs(prevJobs => ({
147
+ ...prevJobs,
148
+ [jobId]: data
149
+ }));
150
+
151
+ // Si la tâche est terminée (succès ou échec), fermer la connexion SSE.
152
+ // NE PAS la retirer de activeJobIds ici, pour qu'elle persiste au rafraîchissement.
153
+ // Elle sera retirar le bouton "Effacer".
154
+ if (data.status === 'completed' || data.status === 'failed' || data.status === 'not_found') {
155
+ eventSource.close();
156
+ delete eventSourceRefs.current[jobId]; // Supprimer la référence de l'EventSource
157
+
158
+ // --- LIGNE MODIFIÉE/SUPPRIMÉE ---
159
+ // Supprimez ou commentez la ligne suivante :
160
+ // setActiveJobIds(prevIds => prevIds.filter(id => id !== jobId));
161
+
162
+ queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page]); // Rafraîchir les données du tableau
163
+
164
+ if (data.status === 'completed') {
165
+ addNotification({
166
+ title: t('dataimporter.success', 'Importation des données réussie.'),
167
+ icon: <FaInfo/>,
168
+ status: 'completed'
169
+ });
170
+ } else if (data.status === 'failed') {
171
+ addNotification({
172
+ title: t('dataimporter.failed', 'Importation échouée. Voir les détails pour les erreurs.'),
173
+ status: 'error'
174
+ });
175
+ } else if (data.status === 'not_found') {
176
+ addNotification({
177
+ title: t('dataimporter.jobNotFound', 'Tâche d\'importation non trouvée ou déjà terminée.'),
178
+ status: 'warning'
179
+ });
180
+ }
181
+ }
182
+ };
183
+
184
+ eventSource.onerror = (error) => {
185
+ console.error(`EventSource error for job ${jobId}:`, error);
186
+ eventSource.close();
187
+ delete eventSourceRefs.current[jobId];
188
+ setStoredJobIds(prevIds => prevIds.filter(id => id !== jobId));
189
+ };
190
+ };
191
+
192
+ const handleImportClick = () => {
193
+ importMutation();
194
+ };
195
+
196
+ const handleCloseModal = () => {
197
+ // Fermer toutes les connexions EventSource avant de fermer la modale
198
+ Object.values(eventSourceRefs.current).forEach(es => es.close());
199
+ eventSourceRefs.current = {}; // Effacer les références
200
+ onClose();
201
+ };
202
+ const handleFilePreview = async (file) => {
203
+ console.log('handleFilePreview');
204
+ if (!file) {
205
+ setPreviewData(null);
206
+ return;
207
+ }
208
+
209
+ const isExcelFile = file && ((file.name.endsWith('.xlsx') ||
210
+ ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
211
+ 'application/vnd.ms-excel'].includes(file.type)));
212
+
213
+ if (isExcelFile) {
214
+ console.log('excel');
215
+ try {
216
+ const arrayBuffer = await file.arrayBuffer();
217
+ const rows = await readXlsxFile(arrayBuffer);
218
+ setPreviewData(rows);
219
+ console.log(rows);
220
+ } catch (error) {
221
+ console.error('Error reading Excel file:', error);
222
+ addNotification({
223
+ title: t('dataimporter.excelReadError', 'Erreur lors de la lecture du fichier Excel'),
224
+ status: 'error'
225
+ });
226
+ }
227
+ } else {
228
+ setPreviewData(null);
229
+ }
230
+ };
231
+
232
+ // Déterminer si une importation est actuellement en cours (pour désactiver les boutons)
233
+ const isAnyImportInProgress = Object.values(importJobs).some(job => job.status === 'pending' || job.status === 'processing');
234
+
235
+ // Filtrer et trier les tâches à afficher (par exemple, les tâches en cours en premier)
236
+ const jobsToDisplay = Object.values(importJobs).sort((a, b) => {
237
+ // Trier par statut (en attente/en cours en premier, puis échoué, puis terminé)
238
+ const statusOrder = { 'pending': 1, 'processing': 2, 'failed': 3, 'completed': 4, 'not_found': 5 };
239
+ return statusOrder[a.status] - statusOrder[b.status];
240
+ });
241
+
242
+ const { models } = useModelContext();
243
+
244
+ return (
245
+ <Dialog isClosable={true} isModal={true} onClose={handleCloseModal}>
246
+ <>
247
+ <h2>
248
+ <Trans i18nKey="dataimporter.title" values={{model: t('model_' + selectedModel?.name, selectedModel?.name)}}>
249
+ Importer des données dans {t('model_' + selectedModel?.name, selectedModel?.name)}
250
+ </Trans>
251
+ </h2>
252
+ <p className="msg msg-info">
253
+ <Trans i18nKey="dataimporter.info" values={{constante: (maxBytesPerSecondThrottleData / kilobytes) + 'ko/s'}}></Trans>
254
+ </p>
255
+
256
+ {/* Toujours afficher le formulaire de sélection de fichier et le bouton d'importation */}
257
+ <FileField
258
+ name="file"
259
+ maxSize={maxFileSize}
260
+ mimeTypes={[
261
+ 'application/json',
262
+ 'text/csv',
263
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
264
+ 'application/vnd.ms-excel'
265
+ ]}
266
+ type="file"
267
+ multiple={false}
268
+ onChange={async (files) => {
269
+ const newFile = files && files.length > 0 ? files[files.length - 1].file : null;
270
+ setFile(newFile);
271
+ await handleFilePreview(newFile);
272
+ }}
273
+ />
274
+
275
+ {file && (isExcelFile || isCsvFile) && (
276
+ <div className="checkbox-label flex flex-row">
277
+ {isCsvFile && (<label htmlFor="hasHeadersCheckbox">
278
+ <input
279
+ type="checkbox"
280
+ id="hasHeadersCheckbox"
281
+ checked={hasHeaders}
282
+ onChange={(e) => setHasHeaders(e.target.checked)}
283
+ />
284
+ <Trans i18nKey="dataimporter.hasCsvHeaders"></Trans>
285
+ </label>)}
286
+ {(!hasHeaders || isExcelFile) && (
287
+ <table>
288
+ <thead>
289
+ <tr>
290
+ <th><Trans i18nKey={"dataimporter.columnType"} values={[isExcelFile?'Excel':'CSV']}>Numéro de colonne</Trans></th>
291
+ <th><Trans i18nKey="dataimporter.field">Champ du modèle</Trans></th>
292
+ </tr>
293
+ </thead>
294
+ <tbody>
295
+ {selectedModel?.fields.map((field, index) => {
296
+ const currentFieldValue = csvHeaders[index] || '';
297
+ const fieldObject = selectedModel.fields.find(f => f.name === currentFieldValue);
298
+
299
+ return (
300
+ <tr key={`${selectedModel.name}-csvmap-${index}`}>
301
+ <td><Trans i18nKey="dataimporter.column" values={[index + 1]}>colonne {index + 1}</Trans></td>
302
+ <td>
303
+ <div className="flex">
304
+ <ModelField
305
+ disableable={true}
306
+ showModel={false}
307
+ value={selectedModel.name}
308
+ fieldValue={currentFieldValue}
309
+ onChange={({name: propName, value: selectedValue}) => {
310
+ const newCsvHeaders = [...csvHeaders];
311
+ newCsvHeaders[index] = selectedValue?.field ?? '';
312
+ setCSVHeaders(newCsvHeaders);
313
+ }}
314
+ fields={true}
315
+ model={selectedModel}
316
+ field={fieldObject}
317
+ />
318
+ <Button className="flex" onClick={() => {
319
+ const newHeaders = [...csvHeaders];
320
+ newHeaders.splice(index, 1);
321
+ setCSVHeaders(newHeaders);
322
+ }}><FaTrash/></Button>
323
+ </div>
324
+ </td>
325
+ </tr>
326
+ );
327
+ })}
328
+ <tr>
329
+ <td colSpan={2}>
330
+ <Button onClick={() => {
331
+ const csvH = [...csvHeaders];
332
+ csvH.push('');
333
+ setCSVHeaders(csvH)
334
+ }}><Trans i18nKey="dataimporter.addColumn">Ajouter une colonne</Trans></Button>
335
+ </td>
336
+ </tr>
337
+ </tbody>
338
+ </table>
339
+ )}
340
+ </div>
341
+ )}
342
+
343
+ {previewData && (
344
+ <div className="excel-preview mt-4">
345
+ <h3><Trans i18nKey="dataimporter.excelPreview">Aperçu des données Excel</Trans></h3>
346
+ <div className="msg msg-tiny">
347
+ <Trans i18nKey="dataimporter.previewNote">
348
+ Note: Ceci est un aperçu des premières lignes. Les cellules vides sont affichées comme "(vide)".
349
+ </Trans>
350
+ </div>
351
+ <div className="preview-table-container" style={{ maxHeight: '300px', overflow: 'auto' }}>
352
+ <table className="preview-table">
353
+ <thead>
354
+ <tr>
355
+ {previewData[0].map((_, colIndex) => {
356
+ // Récupérer le nom du champ mappé pour cette colonne depuis l'état `csvHeaders`
357
+ const mappedFieldName = csvHeaders[colIndex];
358
+
359
+ // Si un champ est mappé, on affiche son nom traduit.
360
+ // Sinon, on affiche un nom générique comme "Colonne X".
361
+ const headerLabel = mappedFieldName
362
+ ? t(`field_${mappedFieldName}`, mappedFieldName)
363
+ : t('dataimporter.column', 'Colonne {{count}}', { count: colIndex + 1 });
364
+
365
+ return (
366
+ <th key={`header-${colIndex}`}>
367
+ {headerLabel}
368
+ </th>
369
+ );
370
+ })}
371
+ </tr>
372
+ </thead>
373
+ <tbody>
374
+ {previewData.map((row, rowIndex) => (
375
+ <tr key={`row-${rowIndex}`}>
376
+ {row.map((cell, cellIndex) => (
377
+ <td
378
+ key={`cell-${rowIndex}-${cellIndex}`}
379
+ style={{
380
+ border: '1px solid #ddd',
381
+ padding: '4px',
382
+ backgroundColor: rowIndex === 0 && hasHeaders ? '#f0f0f0' : 'transparent'
383
+ }}
384
+ >
385
+ {cell !== null ? String(cell) : <span style={{ color: '#999' }}><Trans i18nKey="dataimporter.nullValue">(vide)</Trans></span>}
386
+ </td>
387
+ ))}
388
+ </tr>
389
+ ))}
390
+ </tbody>
391
+ </table>
392
+ </div>
393
+ </div>
394
+ )}
395
+ <div>
396
+ <Button onClick={handleImportClick} disabled={isLoading || !file}>
397
+ <Trans i18nKey="btns.import">Importer</Trans>
398
+ </Button>
399
+ </div>
400
+
401
+ {/* Afficher la progression pour toutes les tâches d'importation actives/suivies */}
402
+ {jobsToDisplay.length > 0 && (
403
+ <div className="import-jobs-list">
404
+ <h3><Trans i18nKey="dataimporter.activeImports">Importations en cours / terminées</Trans></h3>
405
+ {jobsToDisplay.map(job => {
406
+ const progressPercentage = job.totalRecords > 0
407
+ ? (job.processedRecords / job.totalRecords) * 100
408
+ : 0;
409
+ const isJobFinished = job.status === 'completed' || job.status === 'failed' || job.status === 'not_found';
410
+
411
+ return (
412
+ <div key={job.jobId} className="import-progress-container">
413
+ <h4><Trans i18nKey="dataimporter.jobId">Tâche ID:</Trans> {job.jobId?.substring(0, 8)}...</h4>
414
+ <p>
415
+ <Trans i18nKey="dataimporter.status">Statut:</Trans>{' '}
416
+ <strong>{t(`dataimporter.status.${job.status}`, job.status)}</strong>
417
+ </p>
418
+ {job.totalRecords > 0 && (
419
+ <p>
420
+ <Trans i18nKey="dataimporter.recordsProcessed">Enregistrements traités:</Trans>{' '}
421
+ {job.processedRecords} / {job.totalRecords}
422
+ </p>
423
+ )}
424
+ <div className="progress-bar-wrapper">
425
+ <div
426
+ className="progress-bar"
427
+ style={{ width: `${progressPercentage}%` }}
428
+ >
429
+ {progressPercentage.toFixed(0)}%
430
+ </div>
431
+ </div>
432
+
433
+ {job.errors && job.errors.length > 0 && (
434
+ <div className="import-errors">
435
+ <h4><Trans i18nKey="dataimporter.errors">Erreurs:</Trans></h4>
436
+ <ul>
437
+ {job.errors.map((error, index) => (
438
+ <li key={index}>{error}</li>
439
+ ))}
440
+ </ul>
441
+ </div>
442
+ )}
443
+ {isJobFinished && (
444
+ <div className="flex justify-end mt-2">
445
+ <Button onClick={() => {
446
+ setImportJobs(prevJobs => {
447
+ const newJobs = { ...prevJobs };
448
+ delete newJobs[job.jobId];
449
+ return newJobs;
450
+ });
451
+ setActiveJobIds(prevIds => prevIds.filter(id => id !== job.jobId));
452
+ }}><Trans i18nKey="btns.clear">Effacer</Trans></Button>
453
+ </div>
454
+ )}
455
+ </div>
456
+ );
457
+ })}
458
+ </div>
459
+ )}
460
+
461
+ <div className="flex justify-end mt-4">
462
+ <Button onClick={handleCloseModal} disabled={isAnyImportInProgress}>
463
+ <Trans i18nKey="btns.close">Fermer</Trans>
464
+ </Button>
465
+ </div>
466
+ </>
467
+ </Dialog>
468
+ );
469
+ }
@@ -97,7 +97,6 @@ function DataLayout() {
97
97
 
98
98
  // --- MODIFICATION : Logique de changement de vue mise à jour ---
99
99
  const handleSwitchView = (viewName) => {
100
- console.log({viewName})
101
100
  if (viewName === 'table') {
102
101
  setCurrentView('table');
103
102
  return;