diff --git a/ipi-survey-platform/src/assets/images/resetpassword.svg b/ipi-survey-platform/src/assets/images/resetpassword.svg new file mode 100644 index 0000000..813bf94 --- /dev/null +++ b/ipi-survey-platform/src/assets/images/resetpassword.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ipi-survey-platform/src/components/common/FormControls.jsx b/ipi-survey-platform/src/components/common/FormControls.jsx index 31649a0..20f8b22 100644 --- a/ipi-survey-platform/src/components/common/FormControls.jsx +++ b/ipi-survey-platform/src/components/common/FormControls.jsx @@ -49,12 +49,24 @@ export const TextField = ({ onFocus, onBlur, variant = 'default', + required = false, + showToggle = false, + toggleLabels = { show: 'Show', hide: 'Hide' }, + error = '', + readOnly = false, }) => { const wrapperStyle = {}; if (width !== 'auto') { wrapperStyle.width = typeof width === 'number' ? `${width}px` : width; } const [focused, setFocused] = React.useState(false); + const isPasswordField = type === 'password'; + const [showPassword, setShowPassword] = React.useState(false); + React.useEffect(() => { + if (!isPasswordField) { + setShowPassword(false); + } + }, [isPasswordField]); const handleFocus = (event) => { setFocused(true); onFocus?.(event); @@ -68,7 +80,18 @@ export const TextField = ({ if (leftIcon) { inputStyle.paddingLeft = '40px'; } - inputStyle.borderColor = focused ? focusBorderColors[variant] : idleBorderColors[variant] || idleBorderColors.default; + if (isPasswordField && showToggle) { + inputStyle.paddingRight = '44px'; + } + const hasError = Boolean(error); + inputStyle.borderColor = hasError + ? '#B91C1C' + : focused && !readOnly + ? focusBorderColors[variant] + : idleBorderColors[variant] || idleBorderColors.default; + if (readOnly) { + inputStyle.backgroundColor = '#F9FAFB'; + } if (variant !== 'toolbar') { inputStyle.color = '#232528'; } @@ -77,13 +100,14 @@ export const TextField = ({ {label && ( )}
{leftIcon && ( )} + {isPasswordField && showToggle && ( + + )}
+ {error &&

{error}

} ); }; @@ -118,6 +166,8 @@ export const SelectField = ({ allowClear = false, onClear, clearValue = '', + error = '', + readOnly = false, }) => { const wrapperStyle = {}; if (width !== 'auto') { @@ -128,7 +178,7 @@ export const SelectField = ({ const [focused, setFocused] = React.useState(false); const normalizedClear = clearValue ?? ''; const hasValue = value !== undefined && value !== null && value !== '' && value !== normalizedClear; - const showClear = allowClear && hasValue; + const showClear = allowClear && hasValue && !readOnly; if (rightIcon) { inputStyle.paddingRight = '40px'; } @@ -140,12 +190,21 @@ export const SelectField = ({ setFocused(false); onBlur?.(event); }; - inputStyle.borderColor = focused ? focusBorderColors[variant] : idleBorderColors[variant] || idleBorderColors.default; + const hasError = Boolean(error); + inputStyle.borderColor = hasError + ? '#B91C1C' + : focused + ? focusBorderColors[variant] + : idleBorderColors[variant] || idleBorderColors.default; if (variant === 'toolbar') { inputStyle.color = hasValue && value !== 'All' && value !== 'Status' && value !== 'Year' && value !== 'Quarter' && value !== 'Emirates' ? '#232528' : '#5F646D'; } else { inputStyle.color = hasValue ? '#232528' : '#232528'; } + if (readOnly) { + inputStyle.backgroundColor = '#F9FAFB'; + inputStyle.cursor = 'not-allowed'; + } const handleClear = (event) => { event.preventDefault(); event.stopPropagation(); @@ -167,7 +226,11 @@ export const SelectField = ({ id={id || name} name={name} value={value} - onChange={onChange} + onChange={(event) => { + if (!readOnly) { + onChange(event); + } + }} className={`w-full appearance-none text-sm focus:outline-none ${className}`} style={inputStyle} onFocus={handleFocus} @@ -197,6 +260,132 @@ export const SelectField = ({ rightIcon && )} + {readOnly &&
} + {error &&

{error}

} +
+ ); +}; + +const formatPhoneDigits = (digits) => { + if (!digits) return ''; + const cleaned = digits.replace(/\D/g, '').slice(0, 8); + const part1 = cleaned.slice(0, 2); + const part2 = cleaned.slice(2, 5); + const part3 = cleaned.slice(5, 8); + return [part1, part2, part3].filter(Boolean).join(' '); +}; + +export const PhoneField = ({ + label, + countryCode = '+971', + onCountryCodeChange, + countryOptions = [{ label: '+971', value: '+971' }], + placeholder = 'XX XXX XXX', + value, + onChange, + width = 'auto', + required = false, + error = '', + readOnly = false, + className = '', +}) => { + const wrapperStyle = {}; + if (width !== 'auto') { + wrapperStyle.width = typeof width === 'number' ? `${width}px` : width; + } + const [focused, setFocused] = React.useState(false); + const handleFocus = () => { + if (!readOnly) setFocused(true); + }; + const handleBlur = () => { + setFocused(false); + }; + const hasError = Boolean(error); + const borderColor = hasError ? '#B91C1C' : focused ? '#92722A' : '#CBA344'; + const containerStyle = { + display: 'flex', + alignItems: 'center', + border: `2px solid ${borderColor}`, + borderRadius: '4px', + backgroundColor: readOnly ? '#F9FAFB' : '#FFFFFF', + transition: 'border-color 0.2s ease', + height: '40px', + paddingRight: '8px', + pointerEvents: readOnly ? 'none' : 'auto', + }; + const selectStyle = { + border: 'none', + background: 'transparent', + paddingLeft: '12px', + paddingRight: '24px', + fontSize: '14px', + color: '#232528', + appearance: 'none', + WebkitAppearance: 'none', + MozAppearance: 'none', + cursor: readOnly ? 'not-allowed' : 'pointer', + height: '100%', + }; + const inputStyle = { + flex: 1, + border: 'none', + outline: 'none', + background: 'transparent', + fontSize: '14px', + color: '#232528', + padding: '0 0 0 8px', + }; + const arrowStyle = { + position: 'absolute', + right: '8px', + top: '50%', + transform: 'translateY(-50%)', + pointerEvents: 'none', + height: '12px', + width: '12px', + }; + + const handleSelectChange = (event) => { + onCountryCodeChange?.(event.target.value); + }; + + const handleInputChange = (event) => { + const digits = event.target.value.replace(/\D/g, '') || ''; + const formatted = formatPhoneDigits(digits); + onChange?.(formatted); + }; + + const displayValue = formatPhoneDigits((value || '').replace(/\D/g, '')); + + return ( +
+ {label && ( + + )} +
+
+ + +
+ +
+ {error &&

{error}

}
); }; diff --git a/ipi-survey-platform/src/components/common/Table.jsx b/ipi-survey-platform/src/components/common/Table.jsx index 51487d9..0b0ea8f 100644 --- a/ipi-survey-platform/src/components/common/Table.jsx +++ b/ipi-survey-platform/src/components/common/Table.jsx @@ -21,7 +21,8 @@ import CaretDoubleRightIcon from '../../assets/images/CaretDoubleRight.svg'; // onPageChange?: (page: number) => void, // pageSize?: number, // totalItems?: number, -// pageSizeOptions?: number[], // reserved for future use +// pageSizeOptions?: number[], +// onPageSizeChange?: (size: number) => void, // } const Table = ({ headers = [], @@ -44,6 +45,17 @@ const Table = ({ const totalPages = enablePagination ? Math.max(1, Math.ceil(totalItems / pageSize)) : 1; const isControlled = enablePagination && typeof pagination?.currentPage === 'number' && typeof pagination?.onPageChange === 'function'; const [internalPage, setInternalPage] = React.useState(1); + const pageSizeOptions = enablePagination ? pagination?.pageSizeOptions ?? [] : []; + + const handlePageSizeChange = React.useCallback( + (event) => { + if (!enablePagination) return; + const nextSize = Number(event.target.value); + if (Number.isNaN(nextSize) || nextSize <= 0) return; + pagination?.onPageSizeChange?.(nextSize); + }, + [enablePagination, pagination] + ); React.useEffect(() => { if (!enablePagination || isControlled) return; @@ -189,8 +201,8 @@ const Table = ({ {enablePagination && (
-
-
+
+
-
+
+ {pageSizeOptions.length > 0 && pagination?.onPageSizeChange && ( + + )} + + ); + }, + [handleSort, sortConfig] + ); const headers = [ - 'Establishment Name', + renderSortableHeader('Establishment Name', 'establishmentName'), + 'Name', 'Emirates', 'ISIC Code', 'Establishment ID', 'Products', - 'Total Employees', + renderSortableHeader('Total Employees', 'totalEmployees'), 'Created By', - 'Created on', - 'Last Updated', + renderSortableHeader('Created on', 'createdOn'), + renderSortableHeader('Last Updated', 'lastUpdated'), 'Actions', ]; const columnWidths = React.useMemo(() => { const base = Array(headers.length).fill(undefined); - base[0] = 326; // Establishment Name - base[1] = 118; // Emirates - base[2] = 132; // ISIC Code - base[3] = 208; // Establishment ID - base[4] = 100; // Products - base[5] = 152; // Total Employees - base[6] = 152; // Created By - base[7] = 148; // Created on - base[8] = 136; // Last Updated - base[9] = 119; // Actions + base[0] = 250; // Establishment Name + base[1] = 118; // Name + base[2] = 118; // Emirates + base[3] = 132; // ISIC Code + base[4] = 208; // Establishment ID + base[5] = 100; // Products + base[6] = 152; // Total Employees + base[7] = 152; // Created By + base[8] = 148; // Created on + base[9] = 136; // Last Updated + base[10] = 119; // Actions return base; }, [headers.length]); @@ -275,6 +1014,7 @@ const CompanyProfile = () => { contactMakaniNumber: 'corporateMakaniNumber', contactPersonName: 'corporateContactPersonName', contactPersonDesignation: 'corporateContactPersonDesignation', + contactCountryCode: 'corporateCountryCode', contactMobileNumber: 'corporateMobileNumber', contactEmail: 'corporateEmail', contactWebsite: 'corporateWebsite', @@ -282,12 +1022,34 @@ const CompanyProfile = () => { [] ); + const computeFieldErrorMessage = React.useCallback((field, value) => { + if (field === 'contactWebsite' || field === 'corporateWebsite') { + return getWebsiteValidationMessage(value); + } + return ''; + }, []); + + const corporateFieldKeys = React.useMemo(() => Object.values(contactToCorporateMap), [contactToCorporateMap]); + const corporateBackupRef = React.useRef(null); + + const captureCorporateValues = React.useCallback( + (source) => { + const snapshot = {}; + corporateFieldKeys.forEach((key) => { + snapshot[key] = source?.[key] ?? ''; + }); + return snapshot; + }, + [corporateFieldKeys] + ); + const filteredProfiles = React.useMemo(() => { - if (!searchTerm.trim()) return profiles; - const normalized = searchTerm.toLowerCase(); + if (!debouncedSearch.trim()) return profiles; + const normalized = debouncedSearch.toLowerCase(); return profiles.filter((item) => [ item.establishmentName, + item.userProfileName, item.emirate, item.isicCode, item.establishmentId, @@ -298,9 +1060,80 @@ const CompanyProfile = () => { .toLowerCase() .includes(normalized) ); - }, [profiles, searchTerm]); + }, [profiles, debouncedSearch]); - const displayedRows = filteredProfiles.map((item, index) => { + const sortedProfiles = React.useMemo(() => { + const parseDateValue = (value) => { + if (!value) return 0; + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parts = value.split(/[\/-]/).map((part) => part.trim()); + if (parts.length === 3) { + const day = Number(parts[0]); + const month = Number(parts[1]); + const year = Number(parts[2]); + if ([day, month, year].every((part) => Number.isFinite(part))) { + const timestamp = Date.UTC(year, month - 1, day); + if (!Number.isNaN(timestamp)) { + return timestamp; + } + } + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; + } + return 0; + }; + + if (!sortConfig?.field) { + return filteredProfiles; + } + + const multiplier = sortConfig.direction === 'asc' ? 1 : -1; + const items = [...filteredProfiles]; + items.sort((a, b) => { + let result = 0; + switch (sortConfig.field) { + case 'totalEmployees': { + const aValue = Number(a.totalEmployees) || 0; + const bValue = Number(b.totalEmployees) || 0; + result = aValue - bValue; + break; + } + case 'createdOn': { + const aValue = parseDateValue(a.createdOn); + const bValue = parseDateValue(b.createdOn); + result = aValue - bValue; + break; + } + case 'lastUpdated': { + const aValue = (a.lastUpdated || '').toString(); + const bValue = (b.lastUpdated || '').toString(); + result = aValue.localeCompare(bValue, undefined, { sensitivity: 'base' }); + break; + } + default: { + const aValue = (a.establishmentName || '').toString(); + const bValue = (b.establishmentName || '').toString(); + result = aValue.localeCompare(bValue, undefined, { sensitivity: 'base' }); + break; + } + } + + if (result === 0) { + const fallbackA = (a.establishmentName || '').toString(); + const fallbackB = (b.establishmentName || '').toString(); + result = fallbackA.localeCompare(fallbackB, undefined, { sensitivity: 'base' }); + } + + return result * multiplier; + }); + + return items; + }, [filteredProfiles, sortConfig]); + + const displayedRows = sortedProfiles.map((item) => { const isEditing = editingRow !== null && profiles[editingRow]?.establishmentId === item.establishmentId; const isDeleting = @@ -308,6 +1141,7 @@ const CompanyProfile = () => { return [ item.establishmentName, + item.userProfileName || item.contactPersonName || item.createdBy || '', item.emirate, item.isicCode, item.establishmentId, @@ -323,7 +1157,7 @@ const CompanyProfile = () => { className="h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer" title="Edit" aria-label="Edit profile" - onClick={() => handleEdit(index)} + onClick={() => handleEdit(item.apiId ?? item.establishmentId)} > { className="h-8 w-8 grid place-items-center rounded hover:bg-gray-50 cursor-pointer" title="Delete" aria-label="Delete profile" - onClick={() => handleDelete(index)} + onClick={() => handleDelete(item.apiId ?? item.establishmentId)} > { className="h-4 w-4" /> +
), ]; }); React.useEffect(() => { - setCurrentPage(1); + const handle = setTimeout(() => { + setDebouncedSearch(searchTerm); + }, 200); + return () => clearTimeout(handle); }, [searchTerm]); - const handleEdit = (indexInFiltered) => { - const selected = filteredProfiles[indexInFiltered]; - if (!selected) return; - const originalIndex = profiles.findIndex((item) => item.establishmentId === selected.establishmentId); + React.useEffect(() => { + setCurrentPage(1); + }, [debouncedSearch]); + + React.useEffect(() => { + setCurrentPage(1); + }, [pageSize]); + + React.useEffect(() => { + if (modalMode) { + setActiveStep(0); + setConfirmError(''); + setPasswordError(''); + setPasswordReuseError(''); + setSaveError(''); + } + }, [modalMode]); + + const showToast = React.useCallback((type, message) => { + if (!message) return; + if (toastTimeoutRef.current) { + window.clearTimeout(toastTimeoutRef.current); + toastTimeoutRef.current = null; + } + setToast({ type, message }); + toastTimeoutRef.current = window.setTimeout(() => { + setToast(null); + toastTimeoutRef.current = null; + }, 4000); + }, []); + + const closeToast = React.useCallback(() => { + if (toastTimeoutRef.current) { + window.clearTimeout(toastTimeoutRef.current); + toastTimeoutRef.current = null; + } + setToast(null); + }, []); + + React.useEffect( + () => () => { + if (toastTimeoutRef.current) { + window.clearTimeout(toastTimeoutRef.current); + toastTimeoutRef.current = null; + } + }, + [] + ); + + const mapApiEstablishmentToProfile = React.useCallback((item) => { + const base = createEmptyProfile(); + const primaryUser = Array.isArray(item?.users) && item.users.length ? item.users[0] : item?.establishment_user ?? null; + const emiratiMale = item?.emirati_male ?? ''; + const emiratiFemale = item?.emirati_female ?? ''; + const nonEmiratiMale = item?.non_emirati_male ?? ''; + const nonEmiratiFemale = item?.non_emirati_female ?? ''; + const totalEmirati = item?.total_emirati ?? ''; + const totalEmployees = item?.total_employees ?? ''; + + return { + ...base, + apiId: item?.id ?? null, + establishmentName: item?.factory_name ?? '', + emirate: item?.emirate ?? '', + isicCode: item?.isic_code ?? '', + industryCodeBusiness: item?.industry_code ?? base.industryCodeBusiness, + industryCodeCurrent: item?.isic_code ?? base.industryCodeCurrent, + industryDescription: item?.industry_description ?? base.industryDescription, + establishmentId: item?.establishment_code ?? '', + products: base.products, + totalEmployees: totalEmployees !== '' && totalEmployees !== null ? String(totalEmployees) : '', + createdBy: item?.created_by ?? primaryUser?.name ?? '', + createdOn: formatApiDate(item?.created_at), + lastUpdated: item?.updated_by ?? formatApiDate(item?.updated_at), + contactName: item?.factory_name ?? '', + contactEmirate: item?.emirate ?? '', + contactEmail: item?.email ?? '', + contactMakaniNumber: base.contactMakaniNumber, + permanentFactoryCode: item?.permanent_factory_code ?? base.permanentFactoryCode, + uniqueLicenseNumber: item?.license_number ?? base.uniqueLicenseNumber, + corporateName: item?.factory_name ?? '', + corporateEmirate: item?.emirate ?? '', + corporateEmail: item?.email ?? '', + employmentEmiratiMale: emiratiMale !== '' && emiratiMale !== null ? String(emiratiMale) : '', + employmentEmiratiFemale: emiratiFemale !== '' && emiratiFemale !== null ? String(emiratiFemale) : '', + employmentNonEmiratiMale: nonEmiratiMale !== '' && nonEmiratiMale !== null ? String(nonEmiratiMale) : '', + employmentNonEmiratiFemale: nonEmiratiFemale !== '' && nonEmiratiFemale !== null ? String(nonEmiratiFemale) : '', + employmentTotalEmirati: totalEmirati !== '' && totalEmirati !== null ? String(totalEmirati) : '', + employmentTotalEmployees: totalEmployees !== '' && totalEmployees !== null ? String(totalEmployees) : '', + userProfileName: primaryUser?.name ?? base.userProfileName, + userProfileEmail: primaryUser?.email ?? base.userProfileEmail, + }; + }, []); + + React.useEffect(() => { + let cancelled = false; + const loadProfiles = async () => { + try { + const response = await fetchEstablishments({ skipAuth: false }); + if (cancelled) return; + const records = Array.isArray(response?.data) ? response.data : []; + if (records.length) { + setProfiles(records.map(mapApiEstablishmentToProfile)); + } + } catch (error) { + if (cancelled) return; + console.error('Failed to fetch establishments list.', error); + const message = error?.response?.data?.message || error?.message || 'Unable to load establishments. Using default data.'; + showToast('error', message); + } + }; + loadProfiles(); + return () => { + cancelled = true; + }; + }, [mapApiEstablishmentToProfile, showToast]); + + const handleEdit = (identifier) => { + if (modalMode && isDirty) { + const shouldContinue = window.confirm('You have unsaved changes. Do you want to discard them and continue?'); + if (!shouldContinue) { + return; + } + } + if (!identifier) return; + const originalIndex = profiles.findIndex((item) => { + if (item.apiId !== null && item.apiId !== undefined && item.apiId === identifier) { + return true; + } + return item.establishmentId === identifier; + }); if (originalIndex === -1) return; setEditingRow(originalIndex); + const profileRecord = profiles[originalIndex]; const base = createEmptyProfile(); - const nextForm = { + let nextForm = { ...base, - ...profiles[originalIndex], - corporateSameAs: Boolean(profiles[originalIndex]?.corporateSameAs), + ...profileRecord, + corporateSameAs: Boolean(profileRecord?.corporateSameAs), }; if (nextForm.corporateSameAs) { Object.entries(contactToCorporateMap).forEach(([source, target]) => { nextForm[target] = nextForm[source]; }); } + Object.assign(nextForm, computeEmploymentTotals(nextForm)); setForm(nextForm); setModalMode('edit'); + initialSnapshotRef.current = nextForm; + corporateBackupRef.current = captureCorporateValues(nextForm); + setIsDirty(false); + + if (profileRecord?.apiId !== null && profileRecord?.apiId !== undefined) { + (async () => { + try { + const response = await fetchEstablishmentDetail(profileRecord.apiId); + const detail = response?.data || response; + if (!detail) return; + const mapped = { + ...createEmptyProfile(), + ...mapApiEstablishmentToProfile(detail), + }; + mapped.apiId = mapped.apiId ?? profileRecord.apiId; + mapped.corporateSameAs = Boolean(mapped.corporateSameAs); + if (mapped.corporateSameAs) { + Object.entries(contactToCorporateMap).forEach(([source, target]) => { + mapped[target] = mapped[source]; + }); + } + Object.assign(mapped, computeEmploymentTotals(mapped)); + setForm(mapped); + initialSnapshotRef.current = mapped; + corporateBackupRef.current = captureCorporateValues(mapped); + setIsDirty(false); + } catch (error) { + const message = error?.response?.data?.message || error?.message || 'Failed to load establishment details.'; + showToast('error', message); + } + })(); + } }; - const handleDelete = (indexInFiltered) => { - const selected = filteredProfiles[indexInFiltered]; - if (!selected) return; - const originalIndex = profiles.findIndex((item) => item.establishmentId === selected.establishmentId); + const handleDelete = (establishmentId) => { + if (!establishmentId) return; + const originalIndex = profiles.findIndex((item) => { + if (item.apiId !== null && item.apiId !== undefined) { + return item.apiId === establishmentId; + } + return item.establishmentId === establishmentId; + }); + if (originalIndex === -1) return; setDeletingRow(originalIndex); setShowDeleteConfirm(true); }; + const handleAddNew = () => { + if (modalMode && isDirty) { + const shouldContinue = window.confirm('You have unsaved changes. Do you want to discard them and continue?'); + if (!shouldContinue) { + return; + } + } + const emptyProfile = createEmptyProfile(); + Object.assign(emptyProfile, computeEmploymentTotals(emptyProfile)); + setModalMode('add'); + setForm(emptyProfile); + setEditingRow(null); + setActiveStep(0); + setConfirmError(''); + setCurrentPage(1); + setSaveWarning(false); + initialSnapshotRef.current = emptyProfile; + corporateBackupRef.current = captureCorporateValues(emptyProfile); + setIsDirty(false); + }; + const openAddModal = () => { - setForm(createEmptyProfile()); + if (modalMode && isDirty) { + const shouldContinue = window.confirm('You have unsaved changes. Do you want to discard them and continue?'); + if (!shouldContinue) { + return; + } + } + const emptyProfile = createEmptyProfile(); + Object.assign(emptyProfile, computeEmploymentTotals(emptyProfile)); + setForm(emptyProfile); setEditingRow(null); setModalMode('add'); + initialSnapshotRef.current = emptyProfile; + corporateBackupRef.current = captureCorporateValues(emptyProfile); + setIsDirty(false); }; const closeModal = () => { + if (isDirty) { + const shouldClose = window.confirm('You have unsaved changes. Do you really want to discard them?'); + if (!shouldClose) { + return; + } + } + const emptyProfile = createEmptyProfile(); + Object.assign(emptyProfile, computeEmploymentTotals(emptyProfile)); setModalMode(null); - setForm(createEmptyProfile()); + setForm(emptyProfile); setEditingRow(null); + setConfirmError(''); + setIsSaving(false); + setSaveWarning(false); + setIsDirty(false); + initialSnapshotRef.current = emptyProfile; + corporateBackupRef.current = captureCorporateValues(emptyProfile); }; const closeDeleteConfirm = () => { @@ -399,10 +1461,41 @@ const CompanyProfile = () => { setDeletingRow(null); }; - const handleDeleteConfirm = () => { + const handleDeleteConfirm = async () => { if (deletingRow === null) return; - setProfiles((prev) => prev.filter((_, index) => index !== deletingRow)); - closeDeleteConfirm(); + if (isDeletingApi) return; + const targetProfile = profiles[deletingRow]; + if (!targetProfile) { + closeDeleteConfirm(); + return; + } + const targetId = targetProfile.apiId; + if (targetId === null || targetId === undefined) { + setProfiles((prev) => prev.filter((_, index) => index !== deletingRow)); + showToast('success', 'Deleted successfully'); + closeDeleteConfirm(); + return; + } + setIsDeletingApi(true); + try { + const response = await deleteEstablishment(targetId); + const successMessage = response?.message || 'Deleted successfully'; + setProfiles((prev) => + prev.filter((profile) => { + if (targetId !== null && targetId !== undefined) { + return profile.apiId !== targetId; + } + return profile.establishmentId !== targetProfile.establishmentId; + }) + ); + showToast('success', successMessage); + closeDeleteConfirm(); + } catch (error) { + const message = error?.response?.data?.message || error?.message || 'Failed to delete establishment.'; + showToast('error', message); + } finally { + setIsDeletingApi(false); + } }; const handleFormChange = (field) => (event) => { @@ -410,20 +1503,53 @@ const CompanyProfile = () => { const value = isCheckbox ? event.target.checked : event?.target?.value ?? event; setForm((prev) => { const next = { ...prev, [field]: value }; - + if ( + field === 'employmentEmiratiMale' || + field === 'employmentEmiratiFemale' || + field === 'employmentNonEmiratiMale' || + field === 'employmentNonEmiratiFemale' + ) { + Object.assign(next, computeEmploymentTotals(next)); + } if (field === 'corporateSameAs') { if (value) { + corporateBackupRef.current = captureCorporateValues(prev); Object.entries(contactToCorporateMap).forEach(([source, target]) => { - next[target] = next[source]; + next[target] = prev[source]; }); + } else { + const backup = corporateBackupRef.current; + corporateFieldKeys.forEach((target) => { + next[target] = backup?.[target] ?? ''; + }); + corporateBackupRef.current = null; } - return next; } if (next.corporateSameAs && contactToCorporateMap[field]) { next[contactToCorporateMap[field]] = value; } - + if (field === 'userProfilePassword') { + setPasswordError(validatePasswordComplexity(String(value || ''))); + setPasswordReuseError(''); + } + if (field === 'userProfileConfirmPassword') { + setConfirmError(''); + } + const fieldsToValidate = new Set([field]); + if (field === 'corporateSameAs') { + corporateFieldKeys.forEach((key) => fieldsToValidate.add(key)); + } else if (next.corporateSameAs && contactToCorporateMap[field]) { + fieldsToValidate.add(contactToCorporateMap[field]); + } + setFieldErrors((prevErrors) => { + const updatedErrors = { ...prevErrors }; + fieldsToValidate.forEach((targetField) => { + updatedErrors[targetField] = computeFieldErrorMessage(targetField, next[targetField]); + }); + return updatedErrors; + }); + setIsDirty(computeIsDirty(next)); return next; }); }; @@ -436,54 +1562,214 @@ const CompanyProfile = () => { return `${day}/${month}/${year}`; }; - const handleSaveForm = () => { - if (!form.establishmentName || !form.emirate || !form.isicCode || !form.establishmentId) { + const handleSaveForm = async () => { + if (!validateStepFields(activeStep)) { return; } - const employmentTotals = ['employmentEmiratiMale', 'employmentNonEmiratiMale', 'employmentEmiratiFemale', 'employmentNonEmiratiFemale'].reduce( - (acc, key) => acc + (Number(form[key]) || 0), - 0 - ); - const totalEmirati = ['employmentEmiratiMale', 'employmentEmiratiFemale'].reduce( - (acc, key) => acc + (Number(form[key]) || 0), - 0 - ); - - const computedTotalEmployees = - form.employmentTotalEmployees || (employmentTotals > 0 ? String(employmentTotals) : form.totalEmployees || '0'); - const computedTotalEmirati = - form.employmentTotalEmirati || (totalEmirati > 0 ? String(totalEmirati) : form.employmentTotalEmirati || ''); - - const payload = { - ...form, - products: form.products || '0', - totalEmployees: computedTotalEmployees, - employmentTotalEmployees: computedTotalEmployees, - employmentTotalEmirati: computedTotalEmirati, - createdBy: form.createdBy || 'Admin', - createdOn: form.createdOn || getToday(), - lastUpdated: form.lastUpdated || 'Admin', - corporateSameAs: Boolean(form.corporateSameAs), - }; - - if (payload.corporateSameAs) { - Object.entries(contactToCorporateMap).forEach(([source, target]) => { - payload[target] = payload[source]; - }); + if (form.userProfilePassword) { + const complexityMessage = validatePasswordComplexity(form.userProfilePassword); + if (complexityMessage) { + setPasswordError(complexityMessage); + return; + } + const history = Array.isArray(form.passwordHistory) ? form.passwordHistory.slice(0, MAX_PASSWORD_HISTORY) : []; + if (history.some((item) => item?.value === form.userProfilePassword)) { + setPasswordReuseError('Password must not match any of the last five passwords.'); + return; + } + if (!form.userProfileConfirmPassword || form.userProfilePassword !== form.userProfileConfirmPassword) { + setConfirmError('Passwords do not match'); + return; + } } - if (modalMode === 'edit' && editingRow !== null) { - setProfiles((prev) => - prev.map((item, index) => (index === editingRow ? { ...item, ...payload, lastUpdated: form.lastUpdated || 'Admin' } : item)) + const websiteErrorMap = {}; + ['contactWebsite', 'corporateWebsite'].forEach((websiteField) => { + const message = computeFieldErrorMessage(websiteField, form[websiteField]); + if (message) { + websiteErrorMap[websiteField] = message; + } + }); + const websiteFieldsWithErrors = Object.keys(websiteErrorMap); + if (websiteFieldsWithErrors.length) { + setFieldErrors((prevErrors) => ({ ...prevErrors, ...websiteErrorMap })); + if (websiteErrorMap.contactWebsite) { + setActiveStep(2); + } else if (websiteErrorMap.corporateWebsite) { + setActiveStep(3); + } + return; + } + + const startTime = performance.now(); + setIsSaving(true); + setSaveWarning(false); + setSaveError(''); + + try { + const employmentTotals = ['employmentEmiratiMale', 'employmentNonEmiratiMale', 'employmentEmiratiFemale', 'employmentNonEmiratiFemale'].reduce( + (acc, key) => acc + (Number(form[key]) || 0), + 0 + ); + const totalEmirati = ['employmentEmiratiMale', 'employmentEmiratiFemale'].reduce( + (acc, key) => acc + (Number(form[key]) || 0), + 0 ); - } else { - setProfiles((prev) => [{ ...payload }, ...prev]); - } - closeModal(); + const computedTotalEmployees = + form.employmentTotalEmployees || (employmentTotals > 0 ? String(employmentTotals) : form.totalEmployees || '0'); + const computedTotalEmirati = + form.employmentTotalEmirati || (totalEmirati > 0 ? String(totalEmirati) : form.employmentTotalEmirati || ''); + + const today = getToday(); + + let createdRecordId = null; + let apiResultData = null; + if (modalMode !== 'edit') { + const apiPayload = { + establishment_code: form.establishmentId || '', + factory_name: form.establishmentName || form.contactName || '', + email: form.contactEmail || form.userProfileEmail || '', + permanent_factory_code: form.permanentFactoryCode || '', + industry_code: form.industryCodeCurrent || form.industryCodeBusiness || '', + license_number: form.uniqueLicenseNumber || '', + isic_code: form.industryCodeBusiness || form.isicCode || '', + emirate: form.emirate || form.contactEmirate || '', + emirati_male: Number(form.employmentEmiratiMale) || 0, + emirati_female: Number(form.employmentEmiratiFemale) || 0, + non_emirati_male: Number(form.employmentNonEmiratiMale) || 0, + non_emirati_female: Number(form.employmentNonEmiratiFemale) || 0, + total_emirati: Number(totalEmirati) || 0, + total_employees: Number(employmentTotals) || 0, + establishment_user: { + name: form.userProfileName || '', + email: form.userProfileEmail || '', + password: form.userProfilePassword || '', + }, + }; + const apiResponse = await createEstablishment(apiPayload); + const successMessage = apiResponse?.message || 'Establishment added successfully.'; + showToast('success', successMessage); + const createdRecord = apiResponse?.data; + createdRecordId = createdRecord?.id ?? createdRecord?.establishment?.id ?? null; + apiResultData = createdRecord; + } else { + const targetId = form.apiId ?? profiles[editingRow]?.apiId ?? null; + if (targetId === null || targetId === undefined) { + throw new Error('Unable to update establishment. Missing identifier.'); + } + const establishmentUserPayload = { + name: form.userProfileName || '', + email: form.userProfileEmail || '', + }; + if (form.userProfilePassword) { + establishmentUserPayload.password = form.userProfilePassword; + } + if (!establishmentUserPayload.name && !establishmentUserPayload.email && !establishmentUserPayload.password) { + delete establishmentUserPayload.name; + delete establishmentUserPayload.email; + } + const updatePayload = { + establishment_code: form.establishmentId || '', + factory_name: form.establishmentName || form.contactName || '', + email: form.contactEmail || form.userProfileEmail || '', + permanent_factory_code: form.permanentFactoryCode || '', + industry_code: form.industryCodeCurrent || form.industryCodeBusiness || '', + license_number: form.uniqueLicenseNumber || '', + isic_code: form.industryCodeBusiness || form.isicCode || '', + emirate: form.emirate || form.contactEmirate || '', + emirati_male: Number(form.employmentEmiratiMale) || 0, + emirati_female: Number(form.employmentEmiratiFemale) || 0, + non_emirati_male: Number(form.employmentNonEmiratiMale) || 0, + non_emirati_female: Number(form.employmentNonEmiratiFemale) || 0, + total_emirati: Number(totalEmirati) || 0, + total_employees: Number(employmentTotals) || 0, + }; + if (establishmentUserPayload.name || establishmentUserPayload.email || establishmentUserPayload.password) { + updatePayload.establishment_user = establishmentUserPayload; + } + const apiResponse = await updateEstablishment(targetId, updatePayload); + const successMessage = apiResponse?.message || 'Establishment updated successfully.'; + showToast('success', successMessage); + apiResultData = apiResponse?.data; + } + + const payload = { + ...form, + products: form.products || '0', + totalEmployees: computedTotalEmployees, + employmentTotalEmployees: computedTotalEmployees, + employmentTotalEmirati: computedTotalEmirati, + createdBy: form.createdBy || 'Admin', + createdOn: form.createdOn || today, + lastUpdated: today, + corporateSameAs: Boolean(form.corporateSameAs), + requiresPasswordReset: modalMode !== 'edit' ? true : Boolean(form.requiresPasswordReset), + lastPasswordSetAt: form.userProfilePassword ? today : form.lastPasswordSetAt || today, + }; + + const nextPasswordHistory = form.userProfilePassword + ? [{ value: form.userProfilePassword, changedAt: today }, ...(Array.isArray(form.passwordHistory) ? form.passwordHistory : [])].slice( + 0, + MAX_PASSWORD_HISTORY + ) + : form.passwordHistory; + + const nextForm = { + ...payload, + corporateSameAs: Boolean(payload.corporateSameAs), + passwordHistory: nextPasswordHistory, + apiId: modalMode === 'edit' ? form.apiId ?? profiles[editingRow]?.apiId ?? null : createdRecordId, + }; + + if (apiResultData) { + const mappedFromApi = mapApiEstablishmentToProfile(apiResultData); + nextForm.apiId = mappedFromApi.apiId ?? nextForm.apiId; + Object.assign(nextForm, mappedFromApi); + } + + await new Promise((resolve) => requestAnimationFrame(resolve)); + if (modalMode === 'edit' && editingRow !== null) { + const updatedProfiles = [...profiles]; + updatedProfiles[editingRow] = nextForm; + setProfiles(updatedProfiles); + } else { + setProfiles((prev) => [nextForm, ...prev]); + } + const emptyProfile = createEmptyProfile(); + Object.assign(emptyProfile, computeEmploymentTotals(emptyProfile)); + setForm(emptyProfile); + setModalMode(null); + setEditingRow(null); + } catch (error) { + const message = error?.response?.data?.message || error?.message || 'Failed to save profile. Please try again.'; + setSaveError(message); + setSaveWarning(false); + return; + } finally { + setIsSaving(false); + const elapsed = performance.now() - startTime; + if (elapsed > 1200) { + setSaveWarning(true); + } + } }; + React.useEffect(() => { + if (!modalMode) return undefined; + const handleBeforeUnload = (event) => { + if (isDirty) { + event.preventDefault(); + event.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [isDirty, modalMode]); + const deletingProfile = React.useMemo(() => { if (deletingRow === null || deletingRow < 0 || deletingRow >= profiles.length) return null; return profiles[deletingRow]; @@ -491,6 +1777,52 @@ const CompanyProfile = () => { return (
+ {toast && ( +
+
+
+ {toast.type === 'success' ? ( + + + + ) : ( + + + + )} + {toast.message} +
+ +
+
+ )}

Company Profile

@@ -528,6 +1860,8 @@ const CompanyProfile = () => { onPageChange: setCurrentPage, pageSize, totalItems: filteredProfiles.length, + pageSizeOptions, + onPageSizeChange: setPageSize, }} /> @@ -535,17 +1869,19 @@ const CompanyProfile = () => {
-
+

{modalMode === 'edit' ? 'Edit Company Profile' : 'Add Profile'}

@@ -561,394 +1897,105 @@ const CompanyProfile = () => {
-
-
-
-

Identification Particulars

-
- - - - -
- +
+ +
+
+ {renderStepContent()}
- -
-
-

Establishment Contact Details

-

Provide the primary contact information for the establishment.

-
-
- - - - - - - - - - - - -
-
- -
-
-
-

Corporate/Head Office Contact Details

-

Provide corporate office details or copy from establishment contact details.

-
- -
-
- - - - - - - - - - - - -
-
- -
-
-

Employment in Establishment

-
-
- - - - - - -
-
- -
-
-

Administrative Details

-
-
- - - - - - - - - -
-
-
+
-
- - +
+
+ {isSaving ? ( + + + + + + Saving profile… + + ) : saveError ? ( + {saveError} + ) : saveWarning ? ( + 'Save completed, but took longer than expected. Please verify the result.' + ) : ( + '\u00A0' + )} +
+ {activeStep === formSteps.length - 1 ? ( +
+ + +
+ ) : ( + + )}
@@ -956,7 +2003,10 @@ const CompanyProfile = () => { {showDeleteConfirm && deletingProfile && (
-
+
{
diff --git a/ipi-survey-platform/src/services/establishments/establishmentService.js b/ipi-survey-platform/src/services/establishments/establishmentService.js index f1eaf32..dc15d1c 100644 --- a/ipi-survey-platform/src/services/establishments/establishmentService.js +++ b/ipi-survey-platform/src/services/establishments/establishmentService.js @@ -1,9 +1,39 @@ -import { getRequest } from '../api/CommonService.js'; +import { getRequest, postRequest, putRequest, deleteRequest } from '../api/CommonService.js'; import resolveEstablishmentId from '../utils/establishment.js'; const dashboardEndpoint = '/establishment_dashboard'; const establishmentsEndpoint = '/establishments'; +export const fetchEstablishments = async (config = {}) => { + const response = await getRequest(establishmentsEndpoint, config); + return response.data; +}; + +export const createEstablishment = async (payload, config = {}) => { + const response = await postRequest(establishmentsEndpoint, payload, config); + return response.data; +}; + +export const deleteEstablishment = async (establishmentId, config = {}) => { + const resolvedId = resolveEstablishmentId(establishmentId); + if (resolvedId === undefined) { + throw new Error('Missing establishment ID for delete request.'); + } + const url = `${establishmentsEndpoint}/${resolvedId}`; + const response = await deleteRequest(url, config); + return response.data; +}; + +export const updateEstablishment = async (establishmentId, payload, config = {}) => { + const resolvedId = resolveEstablishmentId(establishmentId); + if (resolvedId === undefined) { + throw new Error('Missing establishment ID for update request.'); + } + const url = `${establishmentsEndpoint}/${resolvedId}`; + const response = await putRequest(url, payload, config); + return response.data; +}; + export const fetchEstablishmentDashboard = async (establishmentId, config = {}) => { const resolvedId = resolveEstablishmentId(establishmentId); if (resolvedId === undefined) { @@ -25,6 +55,10 @@ export const fetchEstablishmentDetail = async (establishmentId, config = {}) => }; export default { + fetchEstablishments, fetchEstablishmentDashboard, fetchEstablishmentDetail, + createEstablishment, + updateEstablishment, + deleteEstablishment, };