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,199 @@
|
|
|
1
|
+
import React, {useEffect} from 'react';
|
|
2
|
+
import { FaTrash, FaEquals } from 'react-icons/fa';
|
|
3
|
+
import {getFieldDefinitionFromPath, isAtomicDateOperator} from "./core/data.js";
|
|
4
|
+
import {MONGO_OPERATORS, OPERAND_TYPES} from "./constants.js";
|
|
5
|
+
|
|
6
|
+
// --- Sub-component for a single calculation step ---
|
|
7
|
+
const CalculationStepRow = ({
|
|
8
|
+
step,
|
|
9
|
+
onUpdateStep,
|
|
10
|
+
onRemoveStep,
|
|
11
|
+
allFlattenedFields,
|
|
12
|
+
previousStepAliases,
|
|
13
|
+
isLastStep,
|
|
14
|
+
currentModelName,
|
|
15
|
+
models,
|
|
16
|
+
}) => {
|
|
17
|
+
const handleOperandChange = (operandKey, part, value) => {
|
|
18
|
+
const newOperand = { ...step[operandKey], [part]: value };
|
|
19
|
+
if (part === 'type') {
|
|
20
|
+
if (value === OPERAND_TYPES.CONSTANT) {
|
|
21
|
+
newOperand.value = (step.operator === MONGO_OPERATORS.CONCAT.mongo) ? '' : 0;
|
|
22
|
+
} else {
|
|
23
|
+
newOperand.value = '';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
onUpdateStep(step.id, { ...step, [operandKey]: newOperand });
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleOperatorChange = (e) => {
|
|
30
|
+
const newOperator = e.target.value;
|
|
31
|
+
const updatedStep = { ...step, operator: newOperator };
|
|
32
|
+
|
|
33
|
+
const adjustConstantOperand = (operand) => {
|
|
34
|
+
if (operand.type === OPERAND_TYPES.CONSTANT) {
|
|
35
|
+
if (newOperator === MONGO_OPERATORS.CONCAT.mongo) {
|
|
36
|
+
operand.value = String(operand.value);
|
|
37
|
+
} else {
|
|
38
|
+
const num = parseFloat(operand.value);
|
|
39
|
+
operand.value = isNaN(num) ? 0 : num;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return operand;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
updatedStep.operand1 = adjustConstantOperand({...updatedStep.operand1});
|
|
46
|
+
if (!isAtomicDateOperator(newOperator)) {
|
|
47
|
+
updatedStep.operand2 = adjustConstantOperand({...updatedStep.operand2});
|
|
48
|
+
} else {
|
|
49
|
+
delete updatedStep.operand2;
|
|
50
|
+
}
|
|
51
|
+
onUpdateStep(step.id, updatedStep);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleAliasChange = (e) => {
|
|
55
|
+
onUpdateStep(step.id, { ...step, outputAlias: e.target.value.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '') });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const renderOperandInput = (operandKey) => {
|
|
59
|
+
const operand = step[operandKey];
|
|
60
|
+
const isDateOperator = Object.values(MONGO_OPERATORS).filter(op => op.isDate).map(op => op.mongo).includes(step.operator);
|
|
61
|
+
const isAtomic = isAtomicDateOperator(step.operator);
|
|
62
|
+
const isDisabled = isAtomic && isDateOperator;
|
|
63
|
+
// Filtrage des champs pour le type d'opérande, appliqué au mapping des options
|
|
64
|
+
const filteredFields = allFlattenedFields.filter(field => {
|
|
65
|
+
if (step.operator === MONGO_OPERATORS.CONCAT.mongo) return true;
|
|
66
|
+
const targetFieldDef = getFieldDefinitionFromPath(field.value, currentModelName, models);
|
|
67
|
+
return targetFieldDef && (isDateOperator ? ['date', 'datetime'].includes(targetFieldDef.type) : targetFieldDef.type === 'number');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Group fields by model for the dropdown
|
|
71
|
+
const groupedFields = filteredFields.reduce((acc, field) => {
|
|
72
|
+
const modelLabel = field.modelLabel || 'Current Model';
|
|
73
|
+
if (!acc[modelLabel]) {
|
|
74
|
+
acc[modelLabel] = [];
|
|
75
|
+
}
|
|
76
|
+
acc[modelLabel].push(field);
|
|
77
|
+
return acc;
|
|
78
|
+
}, {});
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex">
|
|
82
|
+
<select
|
|
83
|
+
value={operand.type}
|
|
84
|
+
onChange={(e) => handleOperandChange(operandKey, 'type', e.target.value)}
|
|
85
|
+
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
|
86
|
+
disabled={isDisabled}
|
|
87
|
+
>
|
|
88
|
+
<option value={OPERAND_TYPES.FIELD}>Field</option>
|
|
89
|
+
{!isDateOperator && !isAtomic && <option value={OPERAND_TYPES.CONSTANT}>Constant</option>}
|
|
90
|
+
{previousStepAliases.length > 0 && (
|
|
91
|
+
<option value={OPERAND_TYPES.PREVIOUS_STEP}>Previous Step Result</option>
|
|
92
|
+
)}
|
|
93
|
+
</select>
|
|
94
|
+
|
|
95
|
+
{operand.type === OPERAND_TYPES.FIELD && (
|
|
96
|
+
<select
|
|
97
|
+
value={operand.value}
|
|
98
|
+
onChange={(e) => handleOperandChange(operandKey, 'value', e.target.value)}
|
|
99
|
+
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ccc', minWidth: '150px' }}
|
|
100
|
+
>
|
|
101
|
+
<option value="">Select Field</option>
|
|
102
|
+
{Object.entries(groupedFields).map(([modelLabel, fieldsInGroup]) => (
|
|
103
|
+
<optgroup label={modelLabel} key={modelLabel}>
|
|
104
|
+
{fieldsInGroup.map((field) => (
|
|
105
|
+
<option key={field.value} value={field.value}>
|
|
106
|
+
{field.label}
|
|
107
|
+
</option>
|
|
108
|
+
))}
|
|
109
|
+
</optgroup>
|
|
110
|
+
))}
|
|
111
|
+
</select>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{operand.type === OPERAND_TYPES.CONSTANT && (
|
|
115
|
+
(step.operator === MONGO_OPERATORS.CONCAT.mongo) ? (
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
value={operand.value}
|
|
119
|
+
onChange={(e) => handleOperandChange(operandKey, 'value', e.target.value)}
|
|
120
|
+
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ccc', width: '100px' }}
|
|
121
|
+
placeholder="Enter text"
|
|
122
|
+
/>
|
|
123
|
+
) : (
|
|
124
|
+
<input
|
|
125
|
+
type="number"
|
|
126
|
+
value={operand.value}
|
|
127
|
+
onChange={(e) => handleOperandChange(operandKey, 'value', e.target.value === '' ? '' : parseFloat(e.target.value))}
|
|
128
|
+
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ccc', width: '100px' }}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{operand.type === OPERAND_TYPES.PREVIOUS_STEP && (
|
|
134
|
+
<select
|
|
135
|
+
value={operand.value}
|
|
136
|
+
onChange={(e) => handleOperandChange(operandKey, 'value', e.target.value)}
|
|
137
|
+
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ccc', minWidth: '120px' }}
|
|
138
|
+
>
|
|
139
|
+
<option value="">Select Step</option>
|
|
140
|
+
{previousStepAliases.map((alias) => (
|
|
141
|
+
<option key={alias} value={alias}>
|
|
142
|
+
{alias}
|
|
143
|
+
</option>
|
|
144
|
+
))}
|
|
145
|
+
</select>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div style={{
|
|
154
|
+
display: 'flex',
|
|
155
|
+
alignItems: 'center',
|
|
156
|
+
gap: '10px',
|
|
157
|
+
padding: '10px',
|
|
158
|
+
border: '1px solid #eee',
|
|
159
|
+
borderRadius: '4px',
|
|
160
|
+
marginBottom: '10px',
|
|
161
|
+
flexWrap: 'wrap',
|
|
162
|
+
backgroundColor: isLastStep ? '#f0f8ff' : '#fff'
|
|
163
|
+
}}>
|
|
164
|
+
<input
|
|
165
|
+
type="text"
|
|
166
|
+
placeholder="Output Alias (no spaces)"
|
|
167
|
+
value={step.outputAlias}
|
|
168
|
+
onChange={handleAliasChange}
|
|
169
|
+
style={{ maxWidth: "120px", padding: '8px', borderRadius: '4px', border: '1px solid #ccc', fontWeight: 'bold' }}
|
|
170
|
+
title="Name for the result of this calculation step (e.g., my_calculated_value)"
|
|
171
|
+
/>
|
|
172
|
+
<FaEquals color="#555" />
|
|
173
|
+
{renderOperandInput('operand1')}
|
|
174
|
+
<select
|
|
175
|
+
value={step.operator}
|
|
176
|
+
onChange={handleOperatorChange}
|
|
177
|
+
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
|
178
|
+
>
|
|
179
|
+
{Object.entries(MONGO_OPERATORS).map(([key, op]) => (
|
|
180
|
+
<option key={key} value={op.mongo}>
|
|
181
|
+
{op.label}
|
|
182
|
+
</option>
|
|
183
|
+
))}
|
|
184
|
+
</select>
|
|
185
|
+
{/* Suppression du second operande si l'opérateur est atomique */}
|
|
186
|
+
{!isAtomicDateOperator(step.operator) && renderOperandInput('operand2')}
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => onRemoveStep(step.id)}
|
|
190
|
+
style={{ background: 'none', border: 'none', color: 'red', cursor: 'pointer', fontSize: '1.2em' }}
|
|
191
|
+
title="Remove this step"
|
|
192
|
+
>
|
|
193
|
+
<FaTrash />
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export default CalculationStepRow;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/components/dataview/CalendarConfigModal.jsx
|
|
2
|
+
import React, { useState, useMemo } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import {Dialog} from "./Dialog.jsx";
|
|
5
|
+
|
|
6
|
+
// Remplacez par vos propres composants Modal, Select, Button
|
|
7
|
+
const Modal = ({ isOpen, onClose, title, children }) => isOpen ? <div style={{...modalStyles}}>{title}{children}{onClose}</div> : null;
|
|
8
|
+
const Select = ({ children, ...props }) => <select {...props}>{children}</select>;
|
|
9
|
+
const Button = (props) => <button {...props} />;
|
|
10
|
+
|
|
11
|
+
export default function CalendarConfigModal({ isOpen, onClose, onSave, modelFields }) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const [selectedField, setSelectedField] = useState('');
|
|
14
|
+
|
|
15
|
+
const dateFields = useMemo(() =>
|
|
16
|
+
modelFields.filter(f => f.type === 'date' || f.type === 'datetime'),
|
|
17
|
+
[modelFields]
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const handleSave = () => {
|
|
21
|
+
if (selectedField) {
|
|
22
|
+
onSave({ dateField: selectedField });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (!isOpen) return null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
// Utilisez votre propre composant Modal ici
|
|
30
|
+
<Dialog isOpen={isOpen} onClose={onClose}>
|
|
31
|
+
<h2>{t('dataview.calendar.configureTitle')}</h2>
|
|
32
|
+
<p>{t('dataview.calendar.notConfigured')}</p>
|
|
33
|
+
|
|
34
|
+
<div style={{ margin: '20px 0' }}>
|
|
35
|
+
<label htmlFor="dateField">{t('dataview.calendar.dateFieldLabel')}</label>
|
|
36
|
+
<br />
|
|
37
|
+
<Select id="dateField" value={selectedField} onChange={e => setSelectedField(e.target.value)}>
|
|
38
|
+
<option value="">{t('dataview.calendar.dateFieldPlaceholder')}</option>
|
|
39
|
+
{dateFields.map(field => (
|
|
40
|
+
<option key={field.name} value={field.name}>{field.name}</option>
|
|
41
|
+
))}
|
|
42
|
+
</Select>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<Button onClick={handleSave} disabled={!selectedField}>{t('btns.saveConfig')}</Button>
|
|
46
|
+
<Button onClick={onClose} style={{ marginLeft: '10px' }}>{t('btns.cancel')}</Button>
|
|
47
|
+
</Dialog>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// C:/Dev/hackersonline-engine/client/src/ChartConfigModal.jsx
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { useModelContext } from './contexts/ModelContext.jsx';
|
|
5
|
+
import {ColorField, SelectField, TextField} from './Field.jsx';
|
|
6
|
+
import Button from './Button.jsx';
|
|
7
|
+
import { useAuthContext } from "./contexts/AuthContext.jsx";
|
|
8
|
+
import { Dialog } from "./Dialog.jsx";
|
|
9
|
+
import ConditionBuilder from "./ConditionBuilder.jsx";
|
|
10
|
+
|
|
11
|
+
// Styles (supposés définis ailleurs ou inline pour l'exemple)
|
|
12
|
+
const modalStyles = {
|
|
13
|
+
overlay: {
|
|
14
|
+
position: 'fixed',
|
|
15
|
+
top: 0,
|
|
16
|
+
left: 0,
|
|
17
|
+
right: 0,
|
|
18
|
+
bottom: 0,
|
|
19
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
20
|
+
display: 'flex',
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
justifyContent: 'center',
|
|
23
|
+
zIndex: 1000, // Assurez-vous qu'il est au-dessus des autres éléments
|
|
24
|
+
},
|
|
25
|
+
content: {
|
|
26
|
+
backgroundColor: '#fff',
|
|
27
|
+
padding: '20px',
|
|
28
|
+
borderRadius: '8px',
|
|
29
|
+
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
|
|
30
|
+
minWidth: '320px',
|
|
31
|
+
maxWidth: '90vw',
|
|
32
|
+
maxHeight: '90vh',
|
|
33
|
+
overflowY: 'auto',
|
|
34
|
+
display: 'flex',
|
|
35
|
+
flexDirection: 'column',
|
|
36
|
+
gap: '15px', // Espace entre les champs
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// *** AJOUT: Prop initialConfig avec valeur par défaut null ***
|
|
41
|
+
const ChartConfigModal = ({ isOpen, onClose, onSave, initialConfig = null }) => {
|
|
42
|
+
const { t } = useTranslation();
|
|
43
|
+
const { models } = useModelContext();
|
|
44
|
+
const { me } = useAuthContext();
|
|
45
|
+
|
|
46
|
+
// États internes (inchangés)
|
|
47
|
+
const [selectedModel, setSelectedModel] = useState('');
|
|
48
|
+
const [filter, setFilter] = useState({});
|
|
49
|
+
const [modelFields, setModelFields] = useState([]);
|
|
50
|
+
const [xAxisField, setXAxisField] = useState('');
|
|
51
|
+
const [yAxisField, setYAxisField] = useState('');
|
|
52
|
+
const [groupByField, setGroupByField] = useState('');
|
|
53
|
+
const [colorField, setColorField] = useState('#4BC0C0');
|
|
54
|
+
const [groupByLabelField, setGroupByLabelField] = useState('');
|
|
55
|
+
const [relatedModelFields, setRelatedModelFields] = useState([]);
|
|
56
|
+
const [chartType, setChartType] = useState('bar');
|
|
57
|
+
const [chartAggregationType, setChartAggregationType] = useState('count');
|
|
58
|
+
const [chartTitle, setChartTitle] = useState('');
|
|
59
|
+
|
|
60
|
+
const currentModel = models.find(f => f.name === selectedModel && f._user === me.username);
|
|
61
|
+
|
|
62
|
+
// *** AJOUT: useEffect pour initialiser ou réinitialiser l'état basé sur initialConfig ***
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// Ne s'exécute que si le modal est ouvert
|
|
65
|
+
if (isOpen) {
|
|
66
|
+
if (initialConfig) {
|
|
67
|
+
// Mode édition: Pré-remplir les états
|
|
68
|
+
setSelectedModel(initialConfig.model || '');
|
|
69
|
+
setFilter(initialConfig.filter);
|
|
70
|
+
setChartTitle(initialConfig.title || '');
|
|
71
|
+
setChartType(initialConfig.type || 'bar');
|
|
72
|
+
setXAxisField(initialConfig.xAxis || '');
|
|
73
|
+
setYAxisField(initialConfig.yAxis || '');
|
|
74
|
+
setGroupByField(initialConfig.groupBy || '');
|
|
75
|
+
setColorField(initialConfig.chartBackgroundColor || null);
|
|
76
|
+
setGroupByLabelField(initialConfig.groupByLabelField || '');
|
|
77
|
+
setChartAggregationType(initialConfig.aggregationType || 'count');
|
|
78
|
+
// Note: modelFields et relatedModelFields seront chargés par les autres useEffects
|
|
79
|
+
// déclenchés par le changement de selectedModel et groupByField.
|
|
80
|
+
} else {
|
|
81
|
+
// Mode ajout: Réinitialiser les états (sécurité, même si déjà fait au changement de modèle)
|
|
82
|
+
setSelectedModel('');
|
|
83
|
+
setFilter(null);
|
|
84
|
+
setChartTitle('');
|
|
85
|
+
setChartType('bar');
|
|
86
|
+
setXAxisField('');
|
|
87
|
+
setYAxisField('');
|
|
88
|
+
setGroupByField('');
|
|
89
|
+
setGroupByLabelField('');
|
|
90
|
+
setChartAggregationType('count');
|
|
91
|
+
setModelFields([]);
|
|
92
|
+
setRelatedModelFields([]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// De son absence)
|
|
96
|
+
}, [isOpen, initialConfig]);
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
// useEffect pour charger les champs du modèle (inchangé)
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (selectedModel && models) {
|
|
102
|
+
const modelDef = models.find(m => m.name === selectedModel);
|
|
103
|
+
const fields = modelDef ? modelDef.fields : [];
|
|
104
|
+
setModelFields(fields);
|
|
105
|
+
// Réinitialiser les champs dépendants SEULEMENT si ce n'est pas le chargement initial en mode édition
|
|
106
|
+
if (!initialConfig || selectedModel !== initialConfig.model) {
|
|
107
|
+
setXAxisField('');
|
|
108
|
+
setYAxisField('');
|
|
109
|
+
setFilter(null);
|
|
110
|
+
setGroupByField('');
|
|
111
|
+
setGroupByLabelField('');
|
|
112
|
+
setRelatedModelFields([]);
|
|
113
|
+
setChartAggregationType('count');
|
|
114
|
+
// Ne pas réinitialiser le titre ici, car il peut être défini avant le modèle
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
setModelFields([]);
|
|
118
|
+
}
|
|
119
|
+
}, [selectedModel, models, initialConfig]); // Ajouter initialConfig aux dépendances
|
|
120
|
+
|
|
121
|
+
// useEffect pour charger les champs liés
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (groupByField && models && modelFields.length > 0) {
|
|
124
|
+
const mainModelField = modelFields.find(f => f.name === groupByField);
|
|
125
|
+
if (mainModelField?.type === 'relation') {
|
|
126
|
+
const relatedModelName = mainModelField.relation;
|
|
127
|
+
const relatedModelDef = models.find(m => m.name === relatedModelName);
|
|
128
|
+
const fields = relatedModelDef ? relatedModelDef.fields : [];
|
|
129
|
+
setRelatedModelFields(fields);
|
|
130
|
+
// Pré-remplir groupByLabelField si en mode édition, sinon chercher le défaut
|
|
131
|
+
const labelToSet = (initialConfig && groupByField === initialConfig.groupBy && initialConfig.groupByLabelField)
|
|
132
|
+
? initialConfig.groupByLabelField
|
|
133
|
+
: (fields.find(f => f.asMain && ['string', 'string_t', 'enum'].includes(f.type))?.name ||
|
|
134
|
+
fields.find(f => f.name === 'name' && ['string', 'string_t', 'enum'].includes(f.type))?.name ||
|
|
135
|
+
fields.find(f => f.name === 'title' && ['string', 'string_t', 'enum'].includes(f.type))?.name || '');
|
|
136
|
+
setGroupByLabelField(labelToSet);
|
|
137
|
+
} else {
|
|
138
|
+
setRelatedModelFields([]);
|
|
139
|
+
setGroupByLabelField('');
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
setRelatedModelFields([]);
|
|
143
|
+
setGroupByLabelField('');
|
|
144
|
+
}
|
|
145
|
+
}, [groupByField, modelFields, models, initialConfig]); // Ajouter initialConfig aux dépendances
|
|
146
|
+
|
|
147
|
+
// useEffect pour réinitialiser l'agrégation si l'axe Y est enlevé (inchangé)
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!yAxisField && chartAggregationType !== 'count') {
|
|
150
|
+
setChartAggregationType('count');
|
|
151
|
+
}
|
|
152
|
+
}, [yAxisField, chartAggregationType]);
|
|
153
|
+
|
|
154
|
+
// Logique de validation et options (inchangée)
|
|
155
|
+
const isGroupingChart = ['pie', 'doughnut'].includes(chartType);
|
|
156
|
+
const isRelationGroupBy = isGroupingChart && modelFields.find(f => f.name === groupByField)?.type === 'relation';
|
|
157
|
+
const isYAxisRequiredForValidation = chartAggregationType && !['count'].includes(chartAggregationType);
|
|
158
|
+
|
|
159
|
+
// handleSave (inchangé - envoie l'état actuel)
|
|
160
|
+
const handleSave = () => {
|
|
161
|
+
const isValid = selectedModel && chartType && chartTitle &&
|
|
162
|
+
(isGroupingChart
|
|
163
|
+
? (groupByField && (!isRelationGroupBy || groupByLabelField) && (isYAxisRequiredForValidation ? !!yAxisField : true))
|
|
164
|
+
: (xAxisField && chartAggregationType && (isYAxisRequiredForValidation ? !!yAxisField : true))
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (isValid) {
|
|
168
|
+
// L'objet envoyé contient l'état actuel des champs
|
|
169
|
+
onSave({
|
|
170
|
+
// *** AJOUT: Inclure l'ID si on est en mode édition ***
|
|
171
|
+
id: initialConfig?.id, // Sera undefined en mode ajout
|
|
172
|
+
title: chartTitle,
|
|
173
|
+
model: selectedModel,
|
|
174
|
+
xAxis: !isGroupingChart ? xAxisField : undefined,
|
|
175
|
+
yAxis: yAxisField || undefined,
|
|
176
|
+
groupBy: isGroupingChart ? groupByField : undefined,
|
|
177
|
+
groupByLabelField: isRelationGroupBy ? groupByLabelField : undefined,
|
|
178
|
+
type: chartType,
|
|
179
|
+
aggregationType: chartAggregationType,
|
|
180
|
+
chartBackgroundColor: colorField,
|
|
181
|
+
filter,
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
let errorMsg = t('chartConfigModal.fillFields', "Veuillez remplir tous les champs requis.");
|
|
185
|
+
if (isYAxisRequiredForValidation && !yAxisField) {
|
|
186
|
+
errorMsg += ` ${t('chartConfigModal.yAxisRequiredForAggregation', "L'axe Y est requis pour l'agrégation '{aggregation}'.", { aggregation: t('aggregation.'+chartAggregationType, chartAggregationType) })}`;
|
|
187
|
+
}
|
|
188
|
+
alert(errorMsg);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if (!isOpen) return null;
|
|
193
|
+
|
|
194
|
+
// Options pour les SelectFields (inchangées)
|
|
195
|
+
const modelOptions = models
|
|
196
|
+
? models.filter(f => f._user === me?.username).map(m => ({ label: t(`model_${m.name}`, m.name), value: m.name }))
|
|
197
|
+
: [];
|
|
198
|
+
const xAxisOptions = modelFields
|
|
199
|
+
.filter(f => ['string', 'string_t', 'enum', 'date', 'datetime', 'number', 'boolean'].includes(f.type))
|
|
200
|
+
.map(f => ({ label: t(`field_${selectedModel}_${f.name}`, f.name), value: f.name }));
|
|
201
|
+
const yAxisOptions = modelFields
|
|
202
|
+
.filter(f => ['number'].includes(f.type))
|
|
203
|
+
.map(f => ({ label: t(`field_${selectedModel}_${f.name}`, f.name), value: f.name }));
|
|
204
|
+
const groupByOptions = modelFields
|
|
205
|
+
.filter(f => f.type === 'enum' || f.type === 'relation')
|
|
206
|
+
.map(f => ({ label: `${t(`field_${selectedModel}_${f.name}`, f.name)} (${f.type})`, value: f.name }));
|
|
207
|
+
const groupByLabelOptions = relatedModelFields
|
|
208
|
+
.filter(f => ['string', 'string_t', 'enum'].includes(f.type))
|
|
209
|
+
.map(f => ({ label: t(`field_${relatedModelFields.find(rm=>rm.name === f.name)?.relation || selectedModel}_${f.name}`, f.name), value: f.name }));
|
|
210
|
+
const chartTypeOptions = [
|
|
211
|
+
{ label: t('chartType.bar', 'Barres'), value: 'bar' },
|
|
212
|
+
{ label: t('chartType.line', 'Ligne'), value: 'line' },
|
|
213
|
+
{ label: t('chartType.pie', 'Secteurs'), value: 'pie' },
|
|
214
|
+
{ label: t('chartType.doughnut', 'Donut'), value: 'doughnut' },
|
|
215
|
+
];
|
|
216
|
+
// Options d'agrégation de base - AJOUT DE 'value'
|
|
217
|
+
const baseAggregationOptions = [
|
|
218
|
+
{ label: t('aggregation.count', 'Count'), value: 'count' },
|
|
219
|
+
{ label: t('aggregation.value', 'Value'), value: 'value' }, // Option pour afficher la valeur brute
|
|
220
|
+
{ label: t('aggregation.sum', 'Sum'), value: 'sum' },
|
|
221
|
+
{ label: t('aggregation.avg', 'Average'), value: 'avg' },
|
|
222
|
+
{ label: t('aggregation.median', 'Median'), value: 'median' }, // Attention: median peut être coûteux en perf
|
|
223
|
+
{ label: t('aggregation.min', 'Minimum'), value: 'min' },
|
|
224
|
+
{ label: t('aggregation.max', 'Maximum'), value: 'max' }
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
// Filtrer les options d'agrégation disponibles
|
|
228
|
+
// 'value', 'sum', 'avg', etc. ne sont dispo que si yAxisField est sélectionné
|
|
229
|
+
const availableAggregationOptions = yAxisField
|
|
230
|
+
? baseAggregationOptions
|
|
231
|
+
: baseAggregationOptions.filter(opt => opt.value === 'count');
|
|
232
|
+
|
|
233
|
+
// Validation pour le bouton Enregistrer (inchangée)
|
|
234
|
+
const isSaveDisabled = !selectedModel || !chartTitle || !chartType ||
|
|
235
|
+
(isGroupingChart
|
|
236
|
+
? (!groupByField || (isRelationGroupBy && !groupByLabelField) || (isYAxisRequiredForValidation && !yAxisField))
|
|
237
|
+
: (!xAxisField || !chartAggregationType || (isYAxisRequiredForValidation && !yAxisField))
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<Dialog isOpen={isOpen} onClose={onClose} isClosable>
|
|
242
|
+
<form onSubmit={(e) => {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
handleSave();
|
|
245
|
+
return false;
|
|
246
|
+
}}>
|
|
247
|
+
{/* *** MODIFICATION: Titre dynamique *** */}
|
|
248
|
+
<h2>{initialConfig
|
|
249
|
+
? t('chartConfigModal.editTitle', 'Modifier le Graphique')
|
|
250
|
+
: t('chartConfigModal.addTitle', 'Configurer un Nouveau Graphique')}
|
|
251
|
+
</h2>
|
|
252
|
+
|
|
253
|
+
{/* Champs TextField et SelectField (inchangés dans leur structure) */}
|
|
254
|
+
<TextField
|
|
255
|
+
label={t('chartConfigModal.chartTitle', 'Titre du Graphique')}
|
|
256
|
+
value={chartTitle}
|
|
257
|
+
onChange={(e) => setChartTitle(e.target.value)}
|
|
258
|
+
required
|
|
259
|
+
/>
|
|
260
|
+
<SelectField
|
|
261
|
+
label={t('chartConfigModal.model', 'Source de Données (Modèle)')}
|
|
262
|
+
value={selectedModel}
|
|
263
|
+
onChange={(item) => setSelectedModel(item.value)}
|
|
264
|
+
items={[{label: t('selectPlaceholder', 'Choisir...'), value: ''}, ...modelOptions]}
|
|
265
|
+
required
|
|
266
|
+
// *** AJOUT: Désactiver le modèle en mode édition pour éviter les incohérences ***
|
|
267
|
+
hint={initialConfig ? t('chartConfigModal.modelCantBeChanged', 'Le modèle ne peut pas être changé en mode édition.') : ''}
|
|
268
|
+
/>
|
|
269
|
+
{/* Section pour le ConditionBuilder */}
|
|
270
|
+
{currentModel?.fields.length > 0 && (
|
|
271
|
+
<div className="flex"><label>{t('modelcreator.relationFilter', 'Filtre sur les données')}</label>
|
|
272
|
+
<ConditionBuilder
|
|
273
|
+
modelFields={currentModel.fields}
|
|
274
|
+
selectableModels={false}
|
|
275
|
+
model={currentModel.name} // Nom du modèle actuel du graphique
|
|
276
|
+
models={models} // Liste de tous les modèles (pour les relations potentielles dans le filtre)
|
|
277
|
+
initialValue={filter} // Le filtre actuel du graphique
|
|
278
|
+
onChange={(e) => setFilter(e)} // Fonction pour mettre à jour chartConfig.filter
|
|
279
|
+
/></div>
|
|
280
|
+
)}
|
|
281
|
+
<SelectField
|
|
282
|
+
label={t('chartConfigModal.chartType', 'Type de Graphique')}
|
|
283
|
+
value={chartType}
|
|
284
|
+
onChange={(item) => setChartType(item.value)}
|
|
285
|
+
items={chartTypeOptions}
|
|
286
|
+
required
|
|
287
|
+
/>
|
|
288
|
+
|
|
289
|
+
{selectedModel && (
|
|
290
|
+
<>
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
{/* Affichage GroupBy pour Pie/Doughnut, XAxis pour les autres */}
|
|
295
|
+
{isGroupingChart ? (
|
|
296
|
+
<>
|
|
297
|
+
<SelectField
|
|
298
|
+
label={t('chartConfigModal.groupBy', 'Répartir par (Enum/Relation)')}
|
|
299
|
+
value={groupByField}
|
|
300
|
+
onChange={(item) => setGroupByField(item.value)}
|
|
301
|
+
items={[{
|
|
302
|
+
label: t('selectPlaceholder', 'Choisir...'),
|
|
303
|
+
value: ''
|
|
304
|
+
}, ...groupByOptions]}
|
|
305
|
+
required={isGroupingChart}
|
|
306
|
+
disabled={groupByOptions.length === 0}
|
|
307
|
+
hint={groupByOptions.length === 0 ? t('chartConfigModal.noGroupableFields', 'Aucun champ groupable disponible') : ''}
|
|
308
|
+
/>
|
|
309
|
+
{isRelationGroupBy && (
|
|
310
|
+
<SelectField
|
|
311
|
+
label={t('chartConfigModal.groupByLabel', 'Champ Label (Relation)')}
|
|
312
|
+
value={groupByLabelField}
|
|
313
|
+
onChange={(item) => setGroupByLabelField(item.value)}
|
|
314
|
+
items={[{
|
|
315
|
+
label: t('selectPlaceholder', 'Choisir...'),
|
|
316
|
+
value: ''
|
|
317
|
+
}, ...groupByLabelOptions]}
|
|
318
|
+
required={isRelationGroupBy}
|
|
319
|
+
disabled={relatedModelFields.length === 0}
|
|
320
|
+
hint={relatedModelFields.length === 0 && groupByField ? t('chartConfigModal.loadingRelated', 'Chargement...') : ''}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</>
|
|
324
|
+
) : (
|
|
325
|
+
<SelectField
|
|
326
|
+
label={t('chartConfigModal.xAxis', 'Axe X')}
|
|
327
|
+
value={xAxisField}
|
|
328
|
+
onChange={(item) => setXAxisField(item.value)}
|
|
329
|
+
items={[{label: t('selectPlaceholder', 'Choisir...'), value: ''}, ...xAxisOptions]}
|
|
330
|
+
required={!isGroupingChart}
|
|
331
|
+
disabled={xAxisOptions.length === 0}
|
|
332
|
+
hint={xAxisOptions.length === 0 ? t('chartConfigModal.noXAxisFields', 'Aucun champ utilisable pour l\'axe X') : ''}
|
|
333
|
+
/>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{/* Axe Y (inchangé) */}
|
|
337
|
+
<SelectField
|
|
338
|
+
label={t('chartConfigModal.yAxis', 'Axe Y (Numérique)')}
|
|
339
|
+
value={yAxisField}
|
|
340
|
+
onChange={(item) => setYAxisField(item.value)}
|
|
341
|
+
items={[{label: t('selectPlaceholder', 'Choisir...'), value: ''}, ...yAxisOptions]}
|
|
342
|
+
required={isYAxisRequiredForValidation}
|
|
343
|
+
disabled={yAxisOptions.length === 0}
|
|
344
|
+
// *** MODIFICATION: Hint mis our refléter la condition de l'axe Y ***
|
|
345
|
+
hint={yAxisOptions.length === 0
|
|
346
|
+
? t('chartConfigModal.noNumericFields', 'Aucun champ numérique disponible')
|
|
347
|
+
: (chartAggregationType === 'count'
|
|
348
|
+
? t('chartConfigModal.yAxisOptionalForCount', 'Optionnel si Agrégation = Count')
|
|
349
|
+
: t('chartConfigModal.yAxisRequiredForAggregation', "Requis pour l'agrégation '{aggregation}'", {aggregation: t('aggregation.' + chartAggregationType, chartAggregationType)})
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
/>
|
|
353
|
+
|
|
354
|
+
{/* Type d'Agrégation (inchangé) */}
|
|
355
|
+
<SelectField
|
|
356
|
+
label={t('chartConfigModal.aggregationType', 'Agrégation')}
|
|
357
|
+
value={chartAggregationType}
|
|
358
|
+
onChange={(item) => setChartAggregationType(item.value)}
|
|
359
|
+
items={availableAggregationOptions} // Utilise les options filtrées
|
|
360
|
+
required={true} // Toujours requis conceptuellement
|
|
361
|
+
disabled={false} // Le filtrage des options suffit
|
|
362
|
+
// *** MODIFICATION: Hint mis à jour pour expliquer 'value' ***
|
|
363
|
+
hint={
|
|
364
|
+
chartAggregationType === 'value'
|
|
365
|
+
? t('chartConfigModal.aggregationHintValue', 'Affiche la valeur brute du champ de l\'axe Y. Note: le backend peut retourner des données non agrégées ou la première/dernière valeur par groupe.')
|
|
366
|
+
: (!isGroupingChart
|
|
367
|
+
? t('chartConfigModal.aggregationHintBarLine', 'D comment agréger les valeurs sur l\'axe Y (ou compter les éléments si l\'axe Y n\'est pas défini).')
|
|
368
|
+
: t('chartConfigModal.aggregationHintPie', 'Définit comment agréger les valeurs pour chaque segment (si l\'axe Y est défini).')
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
/>
|
|
372
|
+
|
|
373
|
+
</>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
<div className="flex">
|
|
377
|
+
<label
|
|
378
|
+
className="flex-1"
|
|
379
|
+
htmlFor="chartBackgroundColor">{t('charts.backgroundColor', 'Couleur de fond du graphique')}</label>
|
|
380
|
+
<ColorField // Ou votre composant de sélection de couleur
|
|
381
|
+
name="chartBackgroundColor"
|
|
382
|
+
className={"flex-1"}
|
|
383
|
+
value={colorField || '#FFFFFF'}
|
|
384
|
+
onChange={(event) => {
|
|
385
|
+
setColorField(event.value);
|
|
386
|
+
}}
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{/* Boutons Annuler / Enregistrer (inchangés) */}
|
|
391
|
+
<div className="flex flex-end flex-gap actions"
|
|
392
|
+
style={{marginTop: 'auto', paddingTop: '15px', borderTop: '1px solid #eee'}}>
|
|
393
|
+
<Button onClick={onClose} type="button" className="btn">{t('btns.cancel', 'Annuler')}</Button>
|
|
394
|
+
<Button onClick={handleSave} type="button" className="btn btn-primary"
|
|
395
|
+
disabled={isSaveDisabled}>{t('btns.save', 'Enregistrer')}</Button>
|
|
396
|
+
</div>
|
|
397
|
+
</form>
|
|
398
|
+
</Dialog>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export default ChartConfigModal;
|