@visns-studio/visns-components 5.11.2 → 5.11.3
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/package.json +1 -1
- package/src/components/crm/AssociationManager.jsx +431 -0
- package/src/components/crm/ClientAssociationManager.jsx +362 -0
- package/src/components/crm/MergeEntity.jsx +1049 -0
- package/src/components/crm/MultiSelect.jsx +14 -3
- package/src/components/crm/Navigation.jsx +3 -4
- package/src/components/crm/cells/CellWithTooltip.jsx +35 -28
- package/src/components/crm/generic/GenericDetail.jsx +25 -0
- package/src/components/crm/styles/AssociationManager.module.scss +364 -0
- package/src/components/crm/styles/ClientAssociationManager.module.scss +290 -0
- package/src/components/crm/styles/MergeEntity.module.scss +690 -0
- package/src/components/crm/styles/MultiSelect.module.scss +18 -0
- package/src/components/crm/styles/Navigation.module.scss +68 -8
- package/src/components/crm/styles/global-datagrid.css +9 -0
- package/src/index.js +6 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { toast } from 'react-toastify';
|
|
3
|
+
import CustomFetch from './Fetch';
|
|
4
|
+
import MultiSelect from './MultiSelect';
|
|
5
|
+
import styles from './styles/ClientAssociationManager.module.scss';
|
|
6
|
+
|
|
7
|
+
const ClientAssociationManager = ({
|
|
8
|
+
endpoints,
|
|
9
|
+
config,
|
|
10
|
+
dataId,
|
|
11
|
+
onUpdate = () => {}
|
|
12
|
+
}) => {
|
|
13
|
+
const [associations, setAssociations] = useState([]);
|
|
14
|
+
const [availableOptions, setAvailableOptions] = useState([]);
|
|
15
|
+
const [loading, setLoading] = useState(true);
|
|
16
|
+
|
|
17
|
+
// Dynamic state based on config
|
|
18
|
+
const [newAssociation, setNewAssociation] = useState(() => {
|
|
19
|
+
const initialState = {};
|
|
20
|
+
|
|
21
|
+
// Set up dynamic fields based on config
|
|
22
|
+
if (config.fields) {
|
|
23
|
+
config.fields.forEach(field => {
|
|
24
|
+
initialState[field.id] = field.defaultValue || (field.type === 'boolean' ? false :
|
|
25
|
+
field.type === 'select' ? (field.options?.[0]?.value || '') :
|
|
26
|
+
field.type === 'multi-select' ? [] : '');
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return initialState;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Replace placeholders in endpoint URLs and data
|
|
34
|
+
const processTemplate = (template, data = {}) => {
|
|
35
|
+
let processed = template;
|
|
36
|
+
|
|
37
|
+
// Replace {dataId} with current dataId
|
|
38
|
+
processed = processed.replace(/\{dataId\}/g, dataId);
|
|
39
|
+
|
|
40
|
+
// Replace any other placeholders from provided data
|
|
41
|
+
Object.keys(data).forEach(key => {
|
|
42
|
+
const placeholder = new RegExp(`\\{${key}\\}`, 'g');
|
|
43
|
+
processed = processed.replace(placeholder, data[key]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return processed;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Load existing associations
|
|
50
|
+
const loadAssociations = async () => {
|
|
51
|
+
if (!endpoints.list) return;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
setLoading(true);
|
|
55
|
+
const url = processTemplate(endpoints.list);
|
|
56
|
+
const method = endpoints.listMethod || 'GET';
|
|
57
|
+
const response = await CustomFetch(url, method, endpoints.listParams || {});
|
|
58
|
+
|
|
59
|
+
const dataPath = config.dataPath || 'data';
|
|
60
|
+
const associationsData = dataPath.split('.').reduce((obj, key) => obj?.[key], response);
|
|
61
|
+
|
|
62
|
+
// Ensure associationsData is always an array
|
|
63
|
+
const validAssociations = Array.isArray(associationsData) ? associationsData : [];
|
|
64
|
+
setAssociations(validAssociations);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
toast.error(config.messages?.loadError || 'Failed to load associations');
|
|
67
|
+
console.error('Error loading associations:', error);
|
|
68
|
+
setAssociations([]); // Set empty array on error
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Load available options for dropdown/select fields
|
|
75
|
+
const loadAvailableOptions = async () => {
|
|
76
|
+
if (!config.optionsEndpoint) return;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const url = processTemplate(config.optionsEndpoint.url);
|
|
80
|
+
const method = config.optionsEndpoint.method || 'POST';
|
|
81
|
+
const params = config.optionsEndpoint.params || {};
|
|
82
|
+
|
|
83
|
+
const response = await CustomFetch(url, method, params);
|
|
84
|
+
const dataPath = config.optionsEndpoint.dataPath || 'data.data';
|
|
85
|
+
const optionsData = dataPath.split('.').reduce((obj, key) => obj?.[key], response) || [];
|
|
86
|
+
|
|
87
|
+
setAvailableOptions(optionsData);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Failed to load options:', error);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Generic action handler
|
|
94
|
+
const handleAction = async (actionKey, data = {}) => {
|
|
95
|
+
const endpoint = endpoints[actionKey];
|
|
96
|
+
if (!endpoint) return;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const url = processTemplate(endpoint.url || endpoint, data);
|
|
100
|
+
const method = endpoint.method || 'POST';
|
|
101
|
+
const params = { ...endpoint.params, ...data };
|
|
102
|
+
|
|
103
|
+
await CustomFetch(url, method, params);
|
|
104
|
+
|
|
105
|
+
const successMessage = config.messages?.[`${actionKey}Success`] ||
|
|
106
|
+
endpoint.successMessage ||
|
|
107
|
+
'Action completed successfully';
|
|
108
|
+
toast.success(successMessage);
|
|
109
|
+
|
|
110
|
+
// Reset form if it's an associate action
|
|
111
|
+
if (actionKey === 'associate') {
|
|
112
|
+
const resetState = {};
|
|
113
|
+
if (config.fields) {
|
|
114
|
+
config.fields.forEach(field => {
|
|
115
|
+
resetState[field.id] = field.defaultValue || (field.type === 'boolean' ? false :
|
|
116
|
+
field.type === 'select' ? (field.options?.[0]?.value || '') :
|
|
117
|
+
field.type === 'multi-select' ? [] : '');
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
setNewAssociation(resetState);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
loadAssociations();
|
|
124
|
+
onUpdate();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const errorMessage = config.messages?.[`${actionKey}Error`] ||
|
|
127
|
+
endpoint.errorMessage ||
|
|
128
|
+
'Action failed';
|
|
129
|
+
toast.error(errorMessage);
|
|
130
|
+
console.error(error);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Associate handler
|
|
135
|
+
const handleAssociate = async () => {
|
|
136
|
+
// Validate required fields
|
|
137
|
+
const requiredField = config.fields?.find(field => field.required);
|
|
138
|
+
const primaryField = config.fields?.find(field => field.isPrimary);
|
|
139
|
+
|
|
140
|
+
if (requiredField && !newAssociation[requiredField.id]) {
|
|
141
|
+
toast.error(`Please select a ${requiredField.label || requiredField.id}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Prepare data for submission
|
|
146
|
+
const submitData = { ...newAssociation };
|
|
147
|
+
|
|
148
|
+
// Handle complex field values (like objects from MultiSelect)
|
|
149
|
+
Object.keys(submitData).forEach(key => {
|
|
150
|
+
const value = submitData[key];
|
|
151
|
+
if (value && typeof value === 'object' && value.value !== undefined) {
|
|
152
|
+
submitData[key] = value.value;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await handleAction('associate', submitData);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Remove association handler
|
|
160
|
+
const handleDissociate = async (itemId) => {
|
|
161
|
+
const deleteField = config.deleteField || 'id';
|
|
162
|
+
await handleAction('dissociate', { [deleteField]: itemId });
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Set primary handler
|
|
166
|
+
const handleSetPrimary = async (itemId) => {
|
|
167
|
+
if (!endpoints.setPrimary) return;
|
|
168
|
+
|
|
169
|
+
const primaryField = config.fields?.find(field => field.isPrimary);
|
|
170
|
+
const idField = primaryField?.targetField || 'id';
|
|
171
|
+
|
|
172
|
+
await handleAction('setPrimary', { [idField]: itemId });
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (dataId) {
|
|
177
|
+
loadAssociations();
|
|
178
|
+
loadAvailableOptions();
|
|
179
|
+
}
|
|
180
|
+
}, [dataId]);
|
|
181
|
+
|
|
182
|
+
if (loading) {
|
|
183
|
+
return <div className={styles.loading}>{config.messages?.loading || 'Loading associations...'}</div>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div className={styles.container}>
|
|
188
|
+
<div className={styles.header}>
|
|
189
|
+
<h3>{config.title || 'Associations'}</h3>
|
|
190
|
+
<p>{config.description || 'Manage associations'}</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Existing Associations */}
|
|
194
|
+
<div className={styles.existingAssociations}>
|
|
195
|
+
<h4>{config.labels?.currentAssociations || 'Current Associations'}</h4>
|
|
196
|
+
{associations.length === 0 ? (
|
|
197
|
+
<div className={styles.noAssociations}>
|
|
198
|
+
{config.messages?.noAssociations || 'No associations found.'}
|
|
199
|
+
</div>
|
|
200
|
+
) : (
|
|
201
|
+
<div className={styles.associationsList}>
|
|
202
|
+
{associations.map((association) => {
|
|
203
|
+
const nameField = config.displayFields?.name || 'name';
|
|
204
|
+
const primaryField = config.displayFields?.primary || 'is_primary';
|
|
205
|
+
const relationshipField = config.displayFields?.relationship || 'relationship_type';
|
|
206
|
+
const notesField = config.displayFields?.notes || 'notes';
|
|
207
|
+
const dateField = config.displayFields?.date || 'associated_at';
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
key={association.id}
|
|
212
|
+
className={`${styles.associationItem} ${association[primaryField] ? styles.primary : ''}`}
|
|
213
|
+
>
|
|
214
|
+
<div className={styles.associationInfo}>
|
|
215
|
+
<div className={styles.companyName}>
|
|
216
|
+
{association[nameField]}
|
|
217
|
+
{association[primaryField] && (
|
|
218
|
+
<span className={styles.primaryBadge}>{config.labels?.primaryBadge || 'Primary'}</span>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
<div className={styles.relationshipInfo}>
|
|
222
|
+
<span className={styles.relationshipType}>
|
|
223
|
+
{association[relationshipField]}
|
|
224
|
+
</span>
|
|
225
|
+
{association[notesField] && (
|
|
226
|
+
<span className={styles.notes}>
|
|
227
|
+
- {association[notesField]}
|
|
228
|
+
</span>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
<div className={styles.associatedAt}>
|
|
232
|
+
{config.labels?.associatedAt || 'Associated'}: {new Date(association[dateField]).toLocaleDateString()}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div className={styles.associationActions}>
|
|
236
|
+
{!association[primaryField] && endpoints.setPrimary && (
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
className={styles.setPrimaryBtn}
|
|
240
|
+
onClick={() => handleSetPrimary(association.id)}
|
|
241
|
+
>
|
|
242
|
+
{config.labels?.setPrimary || 'Set Primary'}
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className={styles.removeBtn}
|
|
248
|
+
onClick={() => handleDissociate(association.id)}
|
|
249
|
+
>
|
|
250
|
+
{config.labels?.remove || 'Remove'}
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* Add New Association */}
|
|
261
|
+
<div className={styles.newAssociation}>
|
|
262
|
+
<h4>{config.labels?.addNew || 'Add New Association'}</h4>
|
|
263
|
+
<div className={styles.associationForm}>
|
|
264
|
+
{config.fields && config.fields.map((field, index) => {
|
|
265
|
+
const fieldValue = newAssociation[field.id] || '';
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div key={field.id} className={styles.formRow}>
|
|
269
|
+
<div className={styles.formField}>
|
|
270
|
+
<label>
|
|
271
|
+
{field.label}{field.required && ' *'}
|
|
272
|
+
</label>
|
|
273
|
+
|
|
274
|
+
{field.type === 'multi-select' && (
|
|
275
|
+
<MultiSelect
|
|
276
|
+
settings={{ id: field.id }}
|
|
277
|
+
multi={field.multi || false}
|
|
278
|
+
onChange={(value) => setNewAssociation(prev => ({ ...prev, [field.id]: value }))}
|
|
279
|
+
inputValue={fieldValue}
|
|
280
|
+
options={availableOptions}
|
|
281
|
+
placeholder={field.placeholder || `Select ${field.label}...`}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{field.type === 'select' && (
|
|
286
|
+
<select
|
|
287
|
+
value={fieldValue}
|
|
288
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
289
|
+
...prev,
|
|
290
|
+
[field.id]: e.target.value
|
|
291
|
+
}))}
|
|
292
|
+
className={styles.selectField}
|
|
293
|
+
>
|
|
294
|
+
{field.options && field.options.map(option => (
|
|
295
|
+
<option key={option.value || option} value={option.value || option}>
|
|
296
|
+
{option.label || option}
|
|
297
|
+
</option>
|
|
298
|
+
))}
|
|
299
|
+
</select>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{field.type === 'boolean' && (
|
|
303
|
+
<label>
|
|
304
|
+
<input
|
|
305
|
+
type="checkbox"
|
|
306
|
+
checked={fieldValue}
|
|
307
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
308
|
+
...prev,
|
|
309
|
+
[field.id]: e.target.checked
|
|
310
|
+
}))}
|
|
311
|
+
/>
|
|
312
|
+
{field.checkboxLabel || field.label}
|
|
313
|
+
</label>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{field.type === 'textarea' && (
|
|
317
|
+
<textarea
|
|
318
|
+
value={fieldValue}
|
|
319
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
320
|
+
...prev,
|
|
321
|
+
[field.id]: e.target.value
|
|
322
|
+
}))}
|
|
323
|
+
placeholder={field.placeholder || ''}
|
|
324
|
+
className={styles.textareaField}
|
|
325
|
+
rows={field.rows || 3}
|
|
326
|
+
/>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{field.type === 'text' && (
|
|
330
|
+
<input
|
|
331
|
+
type="text"
|
|
332
|
+
value={fieldValue}
|
|
333
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
334
|
+
...prev,
|
|
335
|
+
[field.id]: e.target.value
|
|
336
|
+
}))}
|
|
337
|
+
placeholder={field.placeholder || ''}
|
|
338
|
+
className={styles.textField}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
})}
|
|
345
|
+
|
|
346
|
+
<div className={styles.formActions}>
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
className={styles.associateBtn}
|
|
350
|
+
onClick={handleAssociate}
|
|
351
|
+
disabled={!newAssociation[config.fields?.find(f => f.required)?.id || 'id']}
|
|
352
|
+
>
|
|
353
|
+
{config.labels?.addButton || 'Add Association'}
|
|
354
|
+
</button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export default ClientAssociationManager;
|