@visns-studio/visns-components 5.10.10 → 5.11.1
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/README.md +85 -0
- package/package.json +6 -6
- package/src/components/cms/DataGrid.jsx +83 -3
- package/src/components/crm/columns/ColumnRenderers.jsx +97 -2
- package/src/components/crm/generic/ContactSelectorModal.jsx +380 -0
- package/src/components/crm/generic/DatePickerDialog.jsx +302 -0
- package/src/components/crm/generic/GenericDetail.jsx +67 -2
- package/src/components/crm/generic/GenericGrid.jsx +1534 -0
- package/src/components/crm/generic/GenericIndex.jsx +5 -1255
- package/src/components/crm/generic/GenericReportForm.jsx +194 -0
- package/src/components/crm/generic/shared/formatters.js +125 -0
- package/src/components/crm/generic/shared/gridUtils.js +194 -0
- package/src/components/crm/generic/styles/ContactSelectorModal.css +367 -0
- package/src/components/crm/generic/styles/DatePickerDialog.css +84 -0
- package/src/components/crm/generic/styles/GenericDetail.module.scss +77 -0
- package/src/components/crm/generic/styles/GenericIndex.module.scss +176 -0
- package/src/index.js +4 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import Swal from 'sweetalert2';
|
|
3
|
+
import './styles/SweetAlert.module.css';
|
|
4
|
+
import './styles/ContactSelectorModal.css';
|
|
5
|
+
import CustomFetch from '../Fetch';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A utility function that shows a contact selector modal using SweetAlert2
|
|
9
|
+
* Used after date selection to add/select contacts for a survey field
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} options - Configuration options
|
|
12
|
+
* @param {string} options.title - The title of the dialog (default: 'Select Contacts')
|
|
13
|
+
* @param {string} options.message - The message to display in the dialog
|
|
14
|
+
* @param {string} options.confirmLabel - Label for the confirm button (default: 'Save')
|
|
15
|
+
* @param {string} options.cancelLabel - Label for the cancel button (default: 'Cancel')
|
|
16
|
+
* @param {Function} options.onConfirm - Function to call when confirmed with selected contacts
|
|
17
|
+
* @param {Function} options.onCancel - Function to call when cancelled
|
|
18
|
+
* @param {Array} options.existingContacts - Array of existing contacts for this field
|
|
19
|
+
* @param {Object} options.contactConfig - Contact selection configuration
|
|
20
|
+
* @param {Object} options.data - Optional data object to pass context
|
|
21
|
+
* @returns {Promise} - A promise that resolves when the dialog is closed
|
|
22
|
+
*/
|
|
23
|
+
export const showContactSelectorModal = ({
|
|
24
|
+
title = 'Select Contacts',
|
|
25
|
+
message = 'Add contacts for this survey:',
|
|
26
|
+
confirmLabel = 'Save',
|
|
27
|
+
cancelLabel = 'Cancel',
|
|
28
|
+
onConfirm = () => {},
|
|
29
|
+
onCancel = () => {},
|
|
30
|
+
existingContacts = [],
|
|
31
|
+
contactConfig = {},
|
|
32
|
+
data = null,
|
|
33
|
+
rowContext = null,
|
|
34
|
+
}) => {
|
|
35
|
+
const {
|
|
36
|
+
contactsUrl = '/ajax/contacts/dropdown',
|
|
37
|
+
urlParams = {},
|
|
38
|
+
allowManualEntry = true,
|
|
39
|
+
fields = ['name', 'email'],
|
|
40
|
+
multiple = true,
|
|
41
|
+
maxContacts = 10
|
|
42
|
+
} = contactConfig;
|
|
43
|
+
|
|
44
|
+
// State management for the modal
|
|
45
|
+
let modalState = {
|
|
46
|
+
contacts: [...existingContacts],
|
|
47
|
+
availableContacts: [],
|
|
48
|
+
isAddingManual: false,
|
|
49
|
+
newContact: {}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Initialize new contact object
|
|
53
|
+
fields.forEach(field => {
|
|
54
|
+
modalState.newContact[field] = '';
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Process URL parameters with row context substitution
|
|
58
|
+
const processUrlParams = (params, rowData) => {
|
|
59
|
+
if (!params || typeof params !== 'object') return {};
|
|
60
|
+
|
|
61
|
+
console.log('Processing URL params:', {
|
|
62
|
+
params,
|
|
63
|
+
rowData,
|
|
64
|
+
rowDataKeys: rowData ? Object.keys(rowData) : 'no rowData'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const processedParams = {};
|
|
68
|
+
|
|
69
|
+
for (const [key, value] of Object.entries(params)) {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
// Handle arrays (like 'where' parameter)
|
|
72
|
+
processedParams[key] = value.map(item => {
|
|
73
|
+
if (typeof item === 'object' && item !== null) {
|
|
74
|
+
const processedItem = {};
|
|
75
|
+
for (const [itemKey, itemValue] of Object.entries(item)) {
|
|
76
|
+
if (typeof itemValue === 'string' && itemValue.startsWith('{row.') && itemValue.endsWith('}')) {
|
|
77
|
+
// Extract field name from {row.fieldName}
|
|
78
|
+
const fieldName = itemValue.slice(5, -1);
|
|
79
|
+
const actualValue = rowData?.[fieldName];
|
|
80
|
+
console.log(`Replacing ${itemValue} with field ${fieldName}:`, actualValue);
|
|
81
|
+
processedItem[itemKey] = actualValue !== undefined ? actualValue : itemValue;
|
|
82
|
+
} else {
|
|
83
|
+
processedItem[itemKey] = itemValue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return processedItem;
|
|
87
|
+
}
|
|
88
|
+
return item;
|
|
89
|
+
});
|
|
90
|
+
} else if (typeof value === 'string' && value.startsWith('{row.') && value.endsWith('}')) {
|
|
91
|
+
// Handle simple string substitution
|
|
92
|
+
const fieldName = value.slice(5, -1);
|
|
93
|
+
const actualValue = rowData?.[fieldName];
|
|
94
|
+
console.log(`Replacing ${value} with field ${fieldName}:`, actualValue);
|
|
95
|
+
processedParams[key] = actualValue !== undefined ? actualValue : value;
|
|
96
|
+
} else {
|
|
97
|
+
processedParams[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('Processed params result:', processedParams);
|
|
102
|
+
return processedParams;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Fetch available contacts from API
|
|
106
|
+
const fetchContacts = async () => {
|
|
107
|
+
try {
|
|
108
|
+
// Process URL parameters with row context
|
|
109
|
+
const requestParams = processUrlParams(urlParams, rowContext);
|
|
110
|
+
|
|
111
|
+
console.log('Fetching contacts with params:', {
|
|
112
|
+
url: contactsUrl,
|
|
113
|
+
originalParams: urlParams,
|
|
114
|
+
processedParams: requestParams,
|
|
115
|
+
rowContext: rowContext
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const response = await CustomFetch(contactsUrl, 'POST', requestParams);
|
|
119
|
+
if (response.data && response.data.data) {
|
|
120
|
+
modalState.availableContacts = response.data.data.map(contact => ({
|
|
121
|
+
value: contact.id || contact.value,
|
|
122
|
+
label: contact.name || contact.label || contact.title,
|
|
123
|
+
...contact
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
console.log(`Fetched ${modalState.availableContacts.length} contacts`);
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.warn('Failed to fetch contacts:', error);
|
|
130
|
+
modalState.availableContacts = [];
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Generate HTML for the modal
|
|
135
|
+
const generateModalHTML = () => {
|
|
136
|
+
const contactsHtml = modalState.contacts.map((contact, index) => `
|
|
137
|
+
<div class="contact-item" data-index="${index}">
|
|
138
|
+
<div class="contact-info">
|
|
139
|
+
<strong>${contact.name || 'Unknown Contact'}</strong>
|
|
140
|
+
${contact.email ? `<br><small>${contact.email}</small>` : ''}
|
|
141
|
+
<span class="contact-type">${contact.type === 'contact' ? 'Existing Contact' : 'Manual Entry'}</span>
|
|
142
|
+
</div>
|
|
143
|
+
<button type="button" class="remove-contact-btn" data-index="${index}">×</button>
|
|
144
|
+
</div>
|
|
145
|
+
`).join('');
|
|
146
|
+
|
|
147
|
+
const availableContactsOptions = modalState.availableContacts.map(contact =>
|
|
148
|
+
`<option value="${contact.value}">${contact.label}</option>`
|
|
149
|
+
).join('');
|
|
150
|
+
|
|
151
|
+
const manualFieldsHtml = fields.map(field => `
|
|
152
|
+
<div class="form-group">
|
|
153
|
+
<div class="form-field-row">
|
|
154
|
+
<label for="manual-${field}">${field.charAt(0).toUpperCase() + field.slice(1)}:</label>
|
|
155
|
+
<input
|
|
156
|
+
type="${field === 'email' ? 'email' : 'text'}"
|
|
157
|
+
id="manual-${field}"
|
|
158
|
+
class="swal2-input"
|
|
159
|
+
placeholder="Enter ${field}"
|
|
160
|
+
${field === 'email' ? 'required' : ''}
|
|
161
|
+
>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
`).join('');
|
|
165
|
+
|
|
166
|
+
return `
|
|
167
|
+
<div class="contact-selector-modal">
|
|
168
|
+
<div class="contact-modal-header">
|
|
169
|
+
<p class="contact-modal-message">${message}</p>
|
|
170
|
+
${data ? `<small class="field-info">Field: ${data.label || data.name}</small>` : ''}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div class="existing-contacts">
|
|
174
|
+
<h4>Selected Contacts (${modalState.contacts.length})</h4>
|
|
175
|
+
<div class="contacts-list">
|
|
176
|
+
${contactsHtml || '<p class="no-contacts">No contacts selected</p>'}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="add-contact-section">
|
|
181
|
+
<h4>Add Contact</h4>
|
|
182
|
+
|
|
183
|
+
${modalState.availableContacts.length > 0 ? `
|
|
184
|
+
<div class="existing-contact-selector">
|
|
185
|
+
<label for="existing-contact-select">Select Existing Contact:</label>
|
|
186
|
+
<div class="contact-select-row">
|
|
187
|
+
<select id="existing-contact-select" class="swal2-select">
|
|
188
|
+
<option value="">Choose a contact...</option>
|
|
189
|
+
${availableContactsOptions}
|
|
190
|
+
</select>
|
|
191
|
+
<button type="button" id="add-existing-contact" class="add-contact-btn">Add</button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
` : ''}
|
|
195
|
+
|
|
196
|
+
${allowManualEntry ? `
|
|
197
|
+
<div class="manual-contact-section">
|
|
198
|
+
<div class="manual-contact-toggle">
|
|
199
|
+
<button type="button" id="toggle-manual-entry" class="toggle-manual-btn">
|
|
200
|
+
${modalState.isAddingManual ? 'Cancel Manual Entry' : 'Add Manual Contact'}
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div id="manual-contact-form" class="manual-contact-form" style="display: ${modalState.isAddingManual ? 'block' : 'none'}">
|
|
205
|
+
${manualFieldsHtml}
|
|
206
|
+
<button type="button" id="add-manual-contact" class="add-contact-btn">Add Manual Contact</button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
` : ''}
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
${multiple && maxContacts > 1 ? `
|
|
213
|
+
<div class="contact-limits">
|
|
214
|
+
<small>Maximum ${maxContacts} contacts allowed</small>
|
|
215
|
+
</div>
|
|
216
|
+
` : ''}
|
|
217
|
+
</div>
|
|
218
|
+
`;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Event handlers
|
|
222
|
+
const setupEventHandlers = () => {
|
|
223
|
+
// Remove contact handler
|
|
224
|
+
document.addEventListener('click', (e) => {
|
|
225
|
+
if (e.target.classList.contains('remove-contact-btn')) {
|
|
226
|
+
const index = parseInt(e.target.dataset.index);
|
|
227
|
+
modalState.contacts.splice(index, 1);
|
|
228
|
+
updateModalContent();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Add existing contact handler
|
|
233
|
+
const addExistingBtn = document.getElementById('add-existing-contact');
|
|
234
|
+
if (addExistingBtn) {
|
|
235
|
+
addExistingBtn.addEventListener('click', () => {
|
|
236
|
+
const select = document.getElementById('existing-contact-select');
|
|
237
|
+
const selectedValue = select.value;
|
|
238
|
+
|
|
239
|
+
if (selectedValue) {
|
|
240
|
+
const selectedContact = modalState.availableContacts.find(c => c.value == selectedValue);
|
|
241
|
+
if (selectedContact && !modalState.contacts.find(c => c.contact_id == selectedValue)) {
|
|
242
|
+
modalState.contacts.push({
|
|
243
|
+
type: 'contact',
|
|
244
|
+
contact_id: selectedContact.value,
|
|
245
|
+
name: selectedContact.label,
|
|
246
|
+
email: selectedContact.email || ''
|
|
247
|
+
});
|
|
248
|
+
select.value = '';
|
|
249
|
+
updateModalContent();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Toggle manual entry
|
|
256
|
+
const toggleBtn = document.getElementById('toggle-manual-entry');
|
|
257
|
+
if (toggleBtn) {
|
|
258
|
+
toggleBtn.addEventListener('click', () => {
|
|
259
|
+
modalState.isAddingManual = !modalState.isAddingManual;
|
|
260
|
+
updateModalContent();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Add manual contact handler
|
|
265
|
+
const addManualBtn = document.getElementById('add-manual-contact');
|
|
266
|
+
if (addManualBtn) {
|
|
267
|
+
addManualBtn.addEventListener('click', () => {
|
|
268
|
+
const newContact = { type: 'manual' };
|
|
269
|
+
let isValid = true;
|
|
270
|
+
|
|
271
|
+
fields.forEach(field => {
|
|
272
|
+
const input = document.getElementById(`manual-${field}`);
|
|
273
|
+
const value = input ? input.value.trim() : '';
|
|
274
|
+
|
|
275
|
+
if (field === 'email' && value && !isValidEmail(value)) {
|
|
276
|
+
isValid = false;
|
|
277
|
+
input.style.borderColor = '#dc2626';
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (field === 'name' && !value) {
|
|
282
|
+
isValid = false;
|
|
283
|
+
input.style.borderColor = '#dc2626';
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
newContact[field] = value;
|
|
288
|
+
if (input) input.style.borderColor = '';
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (isValid && newContact.name) {
|
|
292
|
+
modalState.contacts.push(newContact);
|
|
293
|
+
|
|
294
|
+
// Clear form
|
|
295
|
+
fields.forEach(field => {
|
|
296
|
+
const input = document.getElementById(`manual-${field}`);
|
|
297
|
+
if (input) input.value = '';
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
modalState.isAddingManual = false;
|
|
301
|
+
updateModalContent();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Update modal content
|
|
308
|
+
const updateModalContent = () => {
|
|
309
|
+
const container = document.querySelector('.contact-selector-modal');
|
|
310
|
+
if (container) {
|
|
311
|
+
container.innerHTML = generateModalHTML().match(/<div class="contact-selector-modal">([\s\S]*)<\/div>/)[1];
|
|
312
|
+
setupEventHandlers();
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Email validation helper
|
|
317
|
+
const isValidEmail = (email) => {
|
|
318
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Validation function
|
|
322
|
+
const validateContacts = () => {
|
|
323
|
+
if (!multiple && modalState.contacts.length > 1) {
|
|
324
|
+
Swal.showValidationMessage('Only one contact is allowed for this field');
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (modalState.contacts.length > maxContacts) {
|
|
329
|
+
Swal.showValidationMessage(`Maximum ${maxContacts} contacts allowed`);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return true;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Initialize and show modal
|
|
337
|
+
const showModal = async () => {
|
|
338
|
+
await fetchContacts();
|
|
339
|
+
|
|
340
|
+
const swalOptions = {
|
|
341
|
+
title: title,
|
|
342
|
+
html: generateModalHTML(),
|
|
343
|
+
showCancelButton: true,
|
|
344
|
+
confirmButtonText: confirmLabel,
|
|
345
|
+
cancelButtonText: cancelLabel,
|
|
346
|
+
confirmButtonColor: 'var(--primary-color, #4f46e5)',
|
|
347
|
+
cancelButtonColor: '#6b7280',
|
|
348
|
+
allowOutsideClick: false,
|
|
349
|
+
allowEscapeKey: true,
|
|
350
|
+
width: '600px',
|
|
351
|
+
padding: '1.25rem',
|
|
352
|
+
customClass: {
|
|
353
|
+
popup: 'contact-selector-dialog',
|
|
354
|
+
htmlContainer: 'contact-selector-content'
|
|
355
|
+
},
|
|
356
|
+
preConfirm: () => {
|
|
357
|
+
if (!validateContacts()) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
return modalState.contacts;
|
|
361
|
+
},
|
|
362
|
+
didOpen: () => {
|
|
363
|
+
setupEventHandlers();
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
return Swal.fire(swalOptions).then((result) => {
|
|
368
|
+
if (result.isConfirmed && result.value) {
|
|
369
|
+
console.log('Contacts selected:', result.value);
|
|
370
|
+
onConfirm(result.value);
|
|
371
|
+
} else if (result.dismiss === Swal.DismissReason.cancel) {
|
|
372
|
+
onCancel();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
return showModal();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export default showContactSelectorModal;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Swal from 'sweetalert2';
|
|
3
|
+
import './styles/SweetAlert.module.css';
|
|
4
|
+
import './styles/DatePickerDialog.css';
|
|
5
|
+
import moment from 'moment';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A utility function that shows a date picker confirmation dialog using SweetAlert2
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} options - Configuration options
|
|
11
|
+
* @param {string} options.title - The title of the dialog (default: 'Select Date')
|
|
12
|
+
* @param {string} options.message - The message to display in the dialog
|
|
13
|
+
* @param {string} options.confirmLabel - Label for the confirm button (default: 'Confirm')
|
|
14
|
+
* @param {string} options.cancelLabel - Label for the cancel button (default: 'Cancel')
|
|
15
|
+
* @param {Function} options.onConfirm - Function to call when confirmed with selected date
|
|
16
|
+
* @param {Function} options.onCancel - Function to call when cancelled
|
|
17
|
+
* @param {string} options.defaultDate - Default date value (ISO string or 'now')
|
|
18
|
+
* @param {string} options.dateFormat - Date format for display (default: 'YYYY-MM-DD')
|
|
19
|
+
* @param {Object} options.data - Optional data object to pass context
|
|
20
|
+
* @param {string} options.timezone - Timezone to use ('auto', 'Australia/Perth', or specific timezone)
|
|
21
|
+
* @param {boolean} options.forcePerth - Force Australia/Perth timezone (default: false)
|
|
22
|
+
* @param {boolean} options.dateOnly - Return date string only for DATE fields (default: auto-detect)
|
|
23
|
+
* @returns {Promise} - A promise that resolves when the dialog is closed
|
|
24
|
+
*/
|
|
25
|
+
export const showDatePickerDialog = ({
|
|
26
|
+
title = 'Select Date',
|
|
27
|
+
message = 'Please select a date:',
|
|
28
|
+
confirmLabel = 'Confirm',
|
|
29
|
+
cancelLabel = 'Cancel',
|
|
30
|
+
onConfirm = () => {},
|
|
31
|
+
onCancel = () => {},
|
|
32
|
+
defaultDate = null,
|
|
33
|
+
dateFormat = 'YYYY-MM-DD',
|
|
34
|
+
data = null,
|
|
35
|
+
timezone = 'auto',
|
|
36
|
+
forcePerth = false,
|
|
37
|
+
dateOnly = 'auto',
|
|
38
|
+
}) => {
|
|
39
|
+
// Timezone detection and handling
|
|
40
|
+
const getTargetTimezone = () => {
|
|
41
|
+
if (forcePerth) {
|
|
42
|
+
return 'Australia/Perth';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (timezone === 'auto') {
|
|
46
|
+
// Try to detect browser timezone
|
|
47
|
+
try {
|
|
48
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.warn('Could not detect timezone, falling back to Australia/Perth');
|
|
51
|
+
return 'Australia/Perth';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return timezone;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const targetTimezone = getTargetTimezone();
|
|
59
|
+
|
|
60
|
+
// Auto-detect if this should be a date-only field based on field name or explicit config
|
|
61
|
+
const shouldUseDateOnly = () => {
|
|
62
|
+
if (dateOnly === true || dateOnly === false) {
|
|
63
|
+
return dateOnly; // Explicit configuration
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Auto-detection based on field name patterns
|
|
67
|
+
const fieldName = data?.name || '';
|
|
68
|
+
const dateOnlyPatterns = [
|
|
69
|
+
/^date_/i, // date_sent, date_delivered, etc.
|
|
70
|
+
/^quarter_/i, // quarter_1, quarter_2, etc.
|
|
71
|
+
/_date$/i, // send_date, delivery_date, etc.
|
|
72
|
+
/^(start|end)_/i, // start_date, end_date, etc.
|
|
73
|
+
/^(birth|death)_/i, // birth_date, death_date, etc.
|
|
74
|
+
/^due_/i, // due_date, etc.
|
|
75
|
+
/^expiry/i, // expiry_date, etc.
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const isDateField = dateOnlyPatterns.some(pattern => pattern.test(fieldName));
|
|
79
|
+
|
|
80
|
+
console.log(`Auto-detecting field type for "${fieldName}": ${isDateField ? 'DATE' : 'DATETIME'} field`);
|
|
81
|
+
|
|
82
|
+
return isDateField;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const useDateOnly = shouldUseDateOnly();
|
|
86
|
+
|
|
87
|
+
// Get timezone offset in hours for a given timezone
|
|
88
|
+
const getTimezoneOffsetHours = (tz) => {
|
|
89
|
+
try {
|
|
90
|
+
// For Australia/Perth, it's UTC+8
|
|
91
|
+
if (tz === 'Australia/Perth') {
|
|
92
|
+
return 8; // +8 hours
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// For other timezones, calculate the offset
|
|
96
|
+
const now = new Date();
|
|
97
|
+
const utc = new Date(now.getTime() + (now.getTimezoneOffset() * 60000));
|
|
98
|
+
const targetTime = new Date(utc.toLocaleString("en-US", {timeZone: tz}));
|
|
99
|
+
return (targetTime.getTime() - utc.getTime()) / (1000 * 60 * 60); // Convert to hours
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.warn('Could not calculate timezone offset, using +8 for Perth:', e);
|
|
102
|
+
return 8; // Default to Perth time
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Convert UTC date to target timezone for display
|
|
107
|
+
const convertToDisplayDate = (utcDate) => {
|
|
108
|
+
if (!utcDate) return moment().format(dateFormat);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// If it's a UTC ISO string, convert to target timezone
|
|
112
|
+
if (typeof utcDate === 'string' && (utcDate.includes('T') || utcDate.includes('Z'))) {
|
|
113
|
+
const offsetHours = getTimezoneOffsetHours(targetTimezone);
|
|
114
|
+
// Add the offset to convert FROM UTC TO local timezone
|
|
115
|
+
return moment.utc(utcDate).add(offsetHours, 'hours').format(dateFormat);
|
|
116
|
+
}
|
|
117
|
+
// If it's already a date string (YYYY-MM-DD), treat it as local date
|
|
118
|
+
return moment(utcDate).format(dateFormat);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.warn('Error converting date, using current date:', e);
|
|
121
|
+
return moment().format(dateFormat);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Convert selected date for API calls
|
|
126
|
+
const convertForAPI = (localDateString) => {
|
|
127
|
+
try {
|
|
128
|
+
// If useDateOnly is true, just return the date string as-is for DATE fields
|
|
129
|
+
if (useDateOnly) {
|
|
130
|
+
console.log(`Date-only field: returning ${localDateString} as-is for DATE field`);
|
|
131
|
+
return localDateString;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// For DATETIME fields, convert to UTC
|
|
135
|
+
const offsetHours = getTimezoneOffsetHours(targetTimezone);
|
|
136
|
+
|
|
137
|
+
// Create a UTC moment object from the date string, then treat it as if it were in the target timezone
|
|
138
|
+
// This means: "2025-06-18" becomes "2025-06-18T00:00:00 in target timezone"
|
|
139
|
+
const naiveMoment = moment.utc(localDateString, dateFormat);
|
|
140
|
+
|
|
141
|
+
// Now subtract the offset to get the actual UTC time
|
|
142
|
+
// If it's Perth (+8), we subtract 8 hours to get UTC
|
|
143
|
+
const utcMoment = naiveMoment.clone().subtract(offsetHours, 'hours');
|
|
144
|
+
|
|
145
|
+
console.log(`Converting: ${localDateString} midnight in ${targetTimezone}`);
|
|
146
|
+
console.log(`Naive UTC: ${naiveMoment.toISOString()}`);
|
|
147
|
+
console.log(`Offset: -${offsetHours} hours`);
|
|
148
|
+
console.log(`Final UTC: ${utcMoment.toISOString()}`);
|
|
149
|
+
|
|
150
|
+
return utcMoment.toISOString();
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.warn('Error converting date, using as-is:', e);
|
|
153
|
+
// Fallback: return the date string as-is
|
|
154
|
+
return localDateString;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Get default date value with timezone handling
|
|
159
|
+
let initialDate = '';
|
|
160
|
+
if (defaultDate === 'now') {
|
|
161
|
+
initialDate = convertToDisplayDate(moment().toISOString());
|
|
162
|
+
} else if (defaultDate) {
|
|
163
|
+
initialDate = convertToDisplayDate(defaultDate);
|
|
164
|
+
} else {
|
|
165
|
+
initialDate = convertToDisplayDate(moment().toISOString());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Enhanced message with item details if available
|
|
169
|
+
let enhancedMessage = message;
|
|
170
|
+
if (data) {
|
|
171
|
+
const identifiers = ['name', 'label', 'title', 'id'];
|
|
172
|
+
for (const id of identifiers) {
|
|
173
|
+
if (data[id] && typeof data[id] === 'string' && data[id].trim() !== '') {
|
|
174
|
+
if (!message.includes(data[id])) {
|
|
175
|
+
enhancedMessage = `${message} (${data[id]})`;
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Create HTML content with compact date input
|
|
183
|
+
const htmlContent = `
|
|
184
|
+
<div style="text-align: left; margin: 8px 0; max-width: 100%; overflow: hidden;">
|
|
185
|
+
<p style="margin-bottom: 8px; color: #374151; word-wrap: break-word; font-size: 14px;">${enhancedMessage}</p>
|
|
186
|
+
<div style="margin: 8px 0; width: 100%;">
|
|
187
|
+
<label for="swal-date-input" style="display: block; margin-bottom: 4px; font-weight: 500; color: #374151; font-size: 13px;">
|
|
188
|
+
Date <span style="font-size: 11px; color: #6b7280; font-weight: normal;">(${targetTimezone})</span>:
|
|
189
|
+
</label>
|
|
190
|
+
<input
|
|
191
|
+
id="swal-date-input"
|
|
192
|
+
type="date"
|
|
193
|
+
value="${initialDate}"
|
|
194
|
+
class="swal2-input"
|
|
195
|
+
style="
|
|
196
|
+
width: 100% !important;
|
|
197
|
+
max-width: 100% !important;
|
|
198
|
+
box-sizing: border-box !important;
|
|
199
|
+
padding: 8px 10px !important;
|
|
200
|
+
border: 1px solid #d1d5db !important;
|
|
201
|
+
border-radius: 4px !important;
|
|
202
|
+
font-size: 13px !important;
|
|
203
|
+
font-family: inherit !important;
|
|
204
|
+
background-color: #ffffff !important;
|
|
205
|
+
outline: none !important;
|
|
206
|
+
transition: border-color 0.2s ease !important;
|
|
207
|
+
margin: 0 !important;
|
|
208
|
+
display: block !important;
|
|
209
|
+
height: 34px !important;
|
|
210
|
+
"
|
|
211
|
+
onfocus="this.style.borderColor='#4f46e5'"
|
|
212
|
+
onblur="this.style.borderColor='#d1d5db'">
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
// Configure SweetAlert2 options
|
|
218
|
+
const swalOptions = {
|
|
219
|
+
title: title,
|
|
220
|
+
html: htmlContent,
|
|
221
|
+
icon: 'question',
|
|
222
|
+
showCancelButton: true,
|
|
223
|
+
confirmButtonText: confirmLabel,
|
|
224
|
+
cancelButtonText: cancelLabel,
|
|
225
|
+
confirmButtonColor: 'var(--primary-color, #4f46e5)',
|
|
226
|
+
cancelButtonColor: '#6b7280',
|
|
227
|
+
allowOutsideClick: true,
|
|
228
|
+
allowEscapeKey: true,
|
|
229
|
+
reverseButtons: true,
|
|
230
|
+
focusCancel: false,
|
|
231
|
+
buttonsStyling: true,
|
|
232
|
+
width: 'auto',
|
|
233
|
+
padding: '0.75rem',
|
|
234
|
+
customClass: {
|
|
235
|
+
popup: 'date-picker-dialog',
|
|
236
|
+
htmlContainer: 'date-picker-content'
|
|
237
|
+
},
|
|
238
|
+
preConfirm: () => {
|
|
239
|
+
const dateInput = document.getElementById('swal-date-input');
|
|
240
|
+
const selectedDate = dateInput.value;
|
|
241
|
+
|
|
242
|
+
if (!selectedDate) {
|
|
243
|
+
Swal.showValidationMessage('Please select a date');
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Validate the date
|
|
248
|
+
if (!moment(selectedDate).isValid()) {
|
|
249
|
+
Swal.showValidationMessage('Please enter a valid date');
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return selectedDate;
|
|
254
|
+
},
|
|
255
|
+
didOpen: () => {
|
|
256
|
+
// Focus on the date input when dialog opens
|
|
257
|
+
const dateInput = document.getElementById('swal-date-input');
|
|
258
|
+
if (dateInput) {
|
|
259
|
+
// Additional CSS to ensure the input stays within bounds
|
|
260
|
+
dateInput.style.setProperty('width', '100%', 'important');
|
|
261
|
+
dateInput.style.setProperty('max-width', '100%', 'important');
|
|
262
|
+
dateInput.style.setProperty('box-sizing', 'border-box', 'important');
|
|
263
|
+
dateInput.style.setProperty('overflow', 'hidden', 'important');
|
|
264
|
+
|
|
265
|
+
// Focus the input
|
|
266
|
+
dateInput.focus();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Apply additional container styling
|
|
270
|
+
const popup = document.querySelector('.date-picker-dialog');
|
|
271
|
+
if (popup) {
|
|
272
|
+
popup.style.setProperty('max-width', '90vw', 'important');
|
|
273
|
+
popup.style.setProperty('width', 'auto', 'important');
|
|
274
|
+
popup.style.setProperty('min-width', '320px', 'important');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const htmlContainer = document.querySelector('.date-picker-content');
|
|
278
|
+
if (htmlContainer) {
|
|
279
|
+
htmlContainer.style.setProperty('overflow', 'hidden', 'important');
|
|
280
|
+
htmlContainer.style.setProperty('word-wrap', 'break-word', 'important');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Show SweetAlert2 dialog
|
|
286
|
+
return Swal.fire(swalOptions).then((result) => {
|
|
287
|
+
if (result.isConfirmed && result.value) {
|
|
288
|
+
// Convert the selected date for API calls (either date string or UTC datetime)
|
|
289
|
+
const selectedDateForAPI = convertForAPI(result.value);
|
|
290
|
+
const selectedDateLocal = result.value;
|
|
291
|
+
|
|
292
|
+
// Log for debugging
|
|
293
|
+
console.log(`Date selected: ${selectedDateLocal} (${targetTimezone}) -> ${selectedDateForAPI} (${useDateOnly ? 'DATE' : 'UTC'})`);
|
|
294
|
+
|
|
295
|
+
onConfirm(selectedDateForAPI, selectedDateLocal, targetTimezone);
|
|
296
|
+
} else if (result.dismiss === Swal.DismissReason.cancel) {
|
|
297
|
+
onCancel();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export default showDatePickerDialog;
|