diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index e9ba867..9084468 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -1,4 +1,4 @@ -import { Route, Routes } from 'react-router-dom' +import { Route, Routes, Navigate } from 'react-router-dom' import Layout from './layout/AppLayout' import HomePage from './app/dashboard' import CohortManage from './app/cohartManage' @@ -6,8 +6,7 @@ import PolicyEngineList from './app/policyEngine/components/PolicyEngineList' import AddPolicyEngine from './app/policyEngine/components/AddPolicyEngine' import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIncidentsList' import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index' -import ActionBuilderPage from './app/actionBuilder' -import MasterDataManagement from './app/masterData' +import ConfigurationPage from './app/configuration' function AppRoutes() { return ( @@ -18,8 +17,8 @@ function AppRoutes() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/src/app/actionBuilder/components/ActionCategoryManager.tsx b/src/app/actionBuilder/components/ActionCategoryManager.tsx deleted file mode 100644 index b6357ba..0000000 --- a/src/app/actionBuilder/components/ActionCategoryManager.tsx +++ /dev/null @@ -1,516 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - PlusIcon, - PencilSimpleIcon, - TrashIcon, - MagnifyingGlassIcon, - FolderPlusIcon, - CheckCircleIcon, - XCircleIcon, - HashIcon, - CaretLeftIcon, - CaretRightIcon, -} from '@phosphor-icons/react'; -import { - CustomInput, - CustomButton, - CustomTextArea, - CustomSwitch, - CustomStatus, - CustomConfirmationModal, - CustomLoader, - CustomModal, -} from '../../../components/custom'; -import { - getActionCategories, - getActionTypes, - createActionCategory, - updateActionCategory, - deleteActionCategory, -} from '../ActionBuilderApi'; -import type { ActionCategory, ActionType, ActionCategoryFormData } from '../ActionBuilderTypes'; - -const PAGE_SIZE = 10; - -export function ActionCategoryManager() { - const [categories, setCategories] = useState([]); - const [actionTypes, setActionTypes] = useState([]); - const [loading, setLoading] = useState(true); - - // Search & Pagination State - const [searchTerm, setSearchTerm] = useState(''); - const [currentPage, setCurrentPage] = useState(1); - - // Modal Form State - const [isFormOpen, setIsFormOpen] = useState(false); - const [editingCategory, setEditingCategory] = useState(null); - - const [formData, setFormData] = useState({ - code: '', - name: '', - description: '', - displayOrder: 1, - isActive: true, - }); - - const [errorMsg, setErrorMsg] = useState(null); - const [successMsg, setSuccessMsg] = useState(null); - - // Delete Modal State - const [deleteModalTarget, setDeleteModalTarget] = useState(null); - const [deleteError, setDeleteError] = useState(null); - - // Fetch Data - const fetchData = async () => { - setLoading(true); - try { - const [catsRes, typesRes] = await Promise.all([ - getActionCategories().catch(() => []), - getActionTypes().catch(() => []), - ]); - setCategories(Array.isArray(catsRes) ? catsRes : []); - setActionTypes(Array.isArray(typesRes) ? typesRes : []); - } catch (e) { - console.error('Failed to load categories/action-types API data', e); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchData(); - }, []); - - // Reset page number on search - const handleSearchChange = (value: string) => { - setSearchTerm(value); - setCurrentPage(1); - }; - - // Reset Form - const resetForm = () => { - setFormData({ - code: '', - name: '', - description: '', - displayOrder: categories.length + 1, - isActive: true, - }); - setEditingCategory(null); - setErrorMsg(null); - setIsFormOpen(false); - }; - - // Open Create Mode - const handleOpenCreate = () => { - setEditingCategory(null); - setFormData({ - code: '', - name: '', - description: '', - displayOrder: categories.length + 1, - isActive: true, - }); - setErrorMsg(null); - setIsFormOpen(true); - }; - - // Open Edit Mode - const handleOpenEdit = (category: ActionCategory) => { - setEditingCategory(category); - setFormData({ - code: category.code, - name: category.name, - description: category.description || '', - displayOrder: category.displayOrder || 1, - isActive: category.isActive !== false, - }); - setErrorMsg(null); - setIsFormOpen(true); - }; - - // Submit Handler (Add / Edit) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setErrorMsg(null); - setSuccessMsg(null); - - if (!formData.name.trim()) { - setErrorMsg('Category Name is required.'); - return; - } - if (!formData.code.trim()) { - setErrorMsg('Category Code is required.'); - return; - } - - try { - if (editingCategory) { - await updateActionCategory(editingCategory.id, formData); - setSuccessMsg(`Category "${formData.name}" updated successfully!`); - } else { - await createActionCategory(formData); - setSuccessMsg(`Category "${formData.name}" added successfully!`); - } - await fetchData(); - resetForm(); - setTimeout(() => setSuccessMsg(null), 3000); - } catch (err: any) { - setErrorMsg(err.message || err.response?.data?.message || 'An error occurred while saving.'); - } - }; - - // Confirm Delete - const handleConfirmDelete = async () => { - if (!deleteModalTarget) return; - setDeleteError(null); - - const hasChildTypes = actionTypes.some((t) => t.categoryId === deleteModalTarget.id); - if (hasChildTypes) { - setDeleteError('Cannot delete category because it has associated Action Types.'); - return; - } - - try { - await deleteActionCategory(deleteModalTarget.id); - setDeleteModalTarget(null); - setSuccessMsg(`Category deleted successfully.`); - await fetchData(); - setTimeout(() => setSuccessMsg(null), 3000); - } catch (err: any) { - setDeleteError(err.message || err.response?.data?.message || 'Failed to delete category.'); - } - }; - - // Filter & Pagination Calculations - const filteredCategories = categories.filter( - (c) => - c.name.toLowerCase().includes(searchTerm.toLowerCase()) || - c.code.toLowerCase().includes(searchTerm.toLowerCase()) || - (c.description || '').toLowerCase().includes(searchTerm.toLowerCase()) - ); - - const totalItems = filteredCategories.length; - const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); - const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0; - const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems); - - const paginatedCategories = filteredCategories.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE - ); - - return ( -
- {/* Top Banner / Controls */} -
-
-

- - Action Categories -

-

- Manage high-level action categories (e.g. Accommodation, Compensation, Rebooking) -

-
- -
- } - className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[10px] !gap-[10px] !h-[40px] font-semibold text-[14px]" - > - Add New Category - -
-
- - {/* Success Notification */} - {successMsg && ( -
- - {successMsg} -
- )} - - {/* Category CRUD Popup Modal (Add/Edit) */} - - {errorMsg && ( -
- - {errorMsg} -
- )} - -
-
-
- - { - const nameVal = e.target.value; - setFormData((prev) => ({ - ...prev, - name: nameVal, - code: editingCategory ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'), - })); - }} - placeholder="e.g. Passenger Compensation" - /> -
- -
- - setFormData((prev) => ({ ...prev, code: e.target.value }))} - placeholder="e.g. passenger-compensation" - /> -
- -
- - setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))} - placeholder="1" - /> -
-
- -
- - setFormData((prev) => ({ ...prev, description: e.target.value }))} - placeholder="Briefly describe what actions belong in this category..." - rows={3} - /> -
- -
-
- Active Status - setFormData((prev) => ({ ...prev, isActive: e.target.checked }))} - /> -
- -
- - Cancel - - - - {editingCategory ? 'Save Changes' : 'Create Category'} - -
-
-
-
- - {/* Category List Table Card */} -
- {/* Table Filter Bar */} -
-
- handleSearchChange(e.target.value)} - placeholder="Search category by name or code..." - leftIcon={} - className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]" - containerClassName="!gap-0" - /> -
- -
- Total Categories: {categories.length} - - Active: {categories.filter((c) => c.isActive !== false).length} -
-
- - {/* Table Content */} - {loading ? ( -
- -
- ) : ( - <> -
- - - - - - - - - - - - - - {paginatedCategories.length === 0 ? ( - - - - ) : ( - paginatedCategories.map((cat) => { - const childTypesCount = actionTypes.filter((t) => t.categoryId === cat.id).length; - return ( - - - - - - - - - - - - - - - - ); - }) - )} - -
OrderCategory NameCodeDescriptionAction TypesStatusActions
- No categories found. Click "Add New Category" to create one. -
- - {cat.displayOrder ?? 0} - - {cat.name} - - - {cat.code} - - - {cat.description || '—'} - - - {childTypesCount} - - - - -
- - - -
-
-
- - {/* Pagination Footer Bar */} -
-
- Showing {totalItems > 0 ? startIndex : 0} to{' '} - {endIndex} of {totalItems} categories -
- - {totalPages > 1 && ( -
- - - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - ))} - - -
- )} -
- - )} -
- - {/* Confirmation Modal for Delete Category */} - {deleteModalTarget && ( - { - setDeleteModalTarget(null); - setDeleteError(null); - }} - confirmText="Delete Category" - cancelText="Cancel" - variant="danger" - /> - )} -
- ); -} diff --git a/src/app/actionBuilder/components/ActionTypeManager.tsx b/src/app/actionBuilder/components/ActionTypeManager.tsx deleted file mode 100644 index 13a5649..0000000 --- a/src/app/actionBuilder/components/ActionTypeManager.tsx +++ /dev/null @@ -1,551 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - PlusIcon, - PencilSimpleIcon, - TrashIcon, - MagnifyingGlassIcon, - CirclesThreePlusIcon, - CheckCircleIcon, - XCircleIcon, - HashIcon, - CaretLeftIcon, - CaretRightIcon, -} from '@phosphor-icons/react'; -import { - CustomInput, - CustomDropdown, - CustomButton, - CustomTextArea, - CustomSwitch, - CustomStatus, - CustomConfirmationModal, - CustomLoader, - CustomModal, -} from '../../../components/custom'; -import { - getActionCategories, - getActionTypes, - createActionType, - updateActionType, - deleteActionType, -} from '../ActionBuilderApi'; -import type { ActionCategory, ActionType, ActionTypeFormData } from '../ActionBuilderTypes'; - -const PAGE_SIZE = 10; - -export function ActionTypeManager() { - const [categories, setCategories] = useState([]); - const [actionTypes, setActionTypes] = useState([]); - const [loading, setLoading] = useState(true); - - // Search, Category Filter & Pagination State - const [searchTerm, setSearchTerm] = useState(''); - const [selectedCategoryFilter, setSelectedCategoryFilter] = useState('ALL'); - const [currentPage, setCurrentPage] = useState(1); - - // Modal Form State - const [isFormOpen, setIsFormOpen] = useState(false); - const [editingType, setEditingType] = useState(null); - - const [formData, setFormData] = useState({ - categoryId: '', - code: '', - name: '', - description: '', - displayOrder: 1, - isActive: true, - }); - - const [errorMsg, setErrorMsg] = useState(null); - const [successMsg, setSuccessMsg] = useState(null); - - // Delete Modal State - const [deleteModalTarget, setDeleteModalTarget] = useState(null); - - // Fetch Data - const fetchData = async () => { - setLoading(true); - try { - const [catsRes, typesRes] = await Promise.all([ - getActionCategories().catch(() => []), - getActionTypes().catch(() => []), - ]); - setCategories(Array.isArray(catsRes) ? catsRes : []); - setActionTypes(Array.isArray(typesRes) ? typesRes : []); - } catch (e) { - console.error('Failed loading action categories/types API data', e); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchData(); - }, []); - - // Filter change handler - const handleSearchChange = (val: string) => { - setSearchTerm(val); - setCurrentPage(1); - }; - - const handleCategoryFilterChange = (val: string) => { - setSelectedCategoryFilter(val); - setCurrentPage(1); - }; - - // Reset Form - const resetForm = () => { - setFormData({ - categoryId: categories[0]?.id || '', - code: '', - name: '', - description: '', - displayOrder: actionTypes.length + 1, - isActive: true, - }); - setEditingType(null); - setErrorMsg(null); - setIsFormOpen(false); - }; - - // Open Create Mode - const handleOpenCreate = () => { - setEditingType(null); - setFormData({ - categoryId: categories[0]?.id || '', - code: '', - name: '', - description: '', - displayOrder: actionTypes.length + 1, - isActive: true, - }); - setErrorMsg(null); - setIsFormOpen(true); - }; - - // Open Edit Mode - const handleOpenEdit = (actionType: ActionType) => { - setEditingType(actionType); - setFormData({ - categoryId: actionType.categoryId, - code: actionType.code, - name: actionType.name, - description: actionType.description || '', - displayOrder: actionType.displayOrder || 1, - isActive: actionType.isActive !== false, - }); - setErrorMsg(null); - setIsFormOpen(true); - }; - - // Submit Handler (Add / Edit) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setErrorMsg(null); - setSuccessMsg(null); - - if (!formData.categoryId) { - setErrorMsg('Please select a Category.'); - return; - } - if (!formData.name.trim()) { - setErrorMsg('Action Type Name is required.'); - return; - } - if (!formData.code.trim()) { - setErrorMsg('Action Type Code is required.'); - return; - } - - try { - if (editingType) { - await updateActionType(editingType.id, formData); - setSuccessMsg(`Action Type "${formData.name}" updated successfully!`); - } else { - await createActionType(formData); - setSuccessMsg(`Action Type "${formData.name}" added successfully!`); - } - await fetchData(); - resetForm(); - setTimeout(() => setSuccessMsg(null), 3000); - } catch (err: any) { - setErrorMsg(err.message || err.response?.data?.message || 'An error occurred while saving.'); - } - }; - - // Confirm Delete - const handleConfirmDelete = async () => { - if (!deleteModalTarget) return; - try { - await deleteActionType(deleteModalTarget.id); - setDeleteModalTarget(null); - setSuccessMsg(`Action Type deleted successfully.`); - await fetchData(); - setTimeout(() => setSuccessMsg(null), 3000); - } catch (err: any) { - setErrorMsg(err.message || err.response?.data?.message || 'Failed to delete action type.'); - } - }; - - // Options for Dropdowns - const categoryDropdownOptions = categories.map((c) => ({ - label: `${c.name} (${c.code})`, - value: c.id, - })); - - const filterCategoryDropdownOptions = [ - { label: 'All Categories', value: 'ALL' }, - ...categories.map((c) => ({ label: c.name, value: c.id })), - ]; - - // Helper map for Category names - const categoryMap = new Map(categories.map((c) => [c.id, c.name])); - - // Filter & Pagination Calculations - const filteredActionTypes = actionTypes.filter((t) => { - const matchesCategory = selectedCategoryFilter === 'ALL' || t.categoryId === selectedCategoryFilter; - const catName = t.categoryName || categoryMap.get(t.categoryId) || ''; - const matchesSearch = - t.name.toLowerCase().includes(searchTerm.toLowerCase()) || - t.code.toLowerCase().includes(searchTerm.toLowerCase()) || - catName.toLowerCase().includes(searchTerm.toLowerCase()) || - (t.description || '').toLowerCase().includes(searchTerm.toLowerCase()); - return matchesCategory && matchesSearch; - }); - - const totalItems = filteredActionTypes.length; - const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); - const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0; - const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems); - - const paginatedActionTypes = filteredActionTypes.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE - ); - - return ( -
- {/* Top Banner / Controls */} -
-
-

- - Action Types -

-

- Manage action types within categories (e.g. Hotel Booking, Food Voucher, Flight Rebook) -

-
- -
- } - className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[10px] !gap-[10px] !h-[40px] font-semibold text-[14px]" - > - Add Action Type - -
-
- - {/* Success Notification */} - {successMsg && ( -
- - {successMsg} -
- )} - - {/* Action Type CRUD Popup Modal (Add/Edit) */} - - {errorMsg && ( -
- - {errorMsg} -
- )} - -
-
-
- - setFormData((prev) => ({ ...prev, categoryId: val }))} - placeholder="Select Category" - /> -
- -
- - { - const nameVal = e.target.value; - setFormData((prev) => ({ - ...prev, - name: nameVal, - code: editingType ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'), - })); - }} - placeholder="e.g. Voucher Refund" - /> -
- -
- - setFormData((prev) => ({ ...prev, code: e.target.value }))} - placeholder="e.g. voucher-refund" - /> -
-
- -
-
- - setFormData((prev) => ({ ...prev, description: e.target.value }))} - placeholder="Provide details about how this action type is executed..." - rows={3} - /> -
- -
- - setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))} - placeholder="1" - /> -
-
- -
-
- Active Status - setFormData((prev) => ({ ...prev, isActive: e.target.checked }))} - /> -
- -
- - Cancel - - - - {editingType ? 'Save Changes' : 'Create Action Type'} - -
-
-
-
- - {/* Action Types Table Card */} -
- {/* Table Filter Bar */} -
-
-
- -
- -
- handleSearchChange(e.target.value)} - placeholder="Search action types..." - leftIcon={} - className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]" - containerClassName="!gap-0" - /> -
-
- -
- Total Types: {actionTypes.length} - - Filtered: {filteredActionTypes.length} -
-
- - {/* Table Content */} - {loading ? ( -
- -
- ) : ( - <> -
- - - - - - - - - - - - - {paginatedActionTypes.length === 0 ? ( - - - - ) : ( - paginatedActionTypes.map((t) => { - const catName = t.categoryName || categoryMap.get(t.categoryId) || 'Uncategorized'; - return ( - - - - - - - - - - - - - - ); - }) - )} - -
OrderAction Type NameCategoryDescriptionStatusActions
- No action types found. Select a category or click "Add Action Type" to create one. -
- - {t.displayOrder ?? 0} - -
{t.name}
- {t.code} -
- - {catName} - - - {t.description || '—'} - - - -
- - - -
-
-
- - {/* Pagination Footer Bar */} -
-
- Showing {totalItems > 0 ? startIndex : 0} to{' '} - {endIndex} of {totalItems} action types -
- - {totalPages > 1 && ( -
- - - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - ))} - - -
- )} -
- - )} -
- - {/* Confirmation Modal for Delete Action Type */} - {deleteModalTarget && ( - setDeleteModalTarget(null)} - confirmText="Delete Action Type" - cancelText="Cancel" - variant="danger" - /> - )} -
- ); -} diff --git a/src/app/actionBuilder/components/DynamicFormRenderer.tsx b/src/app/actionBuilder/components/DynamicFormRenderer.tsx deleted file mode 100644 index cc66a2e..0000000 --- a/src/app/actionBuilder/components/DynamicFormRenderer.tsx +++ /dev/null @@ -1,663 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - LightningIcon, - CheckCircleIcon, - XCircleIcon, - PaperPlaneRightIcon, - CodeIcon, - PlayIcon, -} from '@phosphor-icons/react'; -import { - CustomInput, - CustomDropdown, - CustomButton, - CustomTextArea, - CustomSwitch, - CustomCheckBox, - CustomMultiSelect, - CustomLoader, -} from '../../../components/custom'; -import { - getActionCategories, - getActionTypesByCategory, - getFieldDefinitions, - getMasterDataOptions, - submitActionPayload, -} from '../ActionBuilderApi'; -import type { - ActionCategory, - ActionType, - FieldDefinition, - FieldWidth, - ActionSubmissionPayload, - ActionSubmissionValue, -} from '../ActionBuilderTypes'; - -const WIDTH_GRID_MAP: Record = { - full: 'col-span-12', - half: 'col-span-12 md:col-span-6', - third: 'col-span-12 md:col-span-4', - two_thirds: 'col-span-12 md:col-span-8', -}; - -export const DEFAULT_CURRENCIES = [ - { label: 'INR (Indian Rupee)', value: 'INR' }, - { label: 'USD (US Dollar)', value: 'USD' }, - { label: 'EUR (Euro)', value: 'EUR' }, - { label: 'GBP (British Pound)', value: 'GBP' }, - { label: 'AED (UAE Dirham)', value: 'AED' }, -]; - -export function DynamicFormRenderer() { - const [categories, setCategories] = useState([]); - const [selectedCategoryId, setSelectedCategoryId] = useState(''); - const [actionTypes, setActionTypes] = useState([]); - const [selectedActionTypeId, setSelectedActionTypeId] = useState(''); - - const [fields, setFields] = useState([]); - const [loadingFields, setLoadingFields] = useState(false); - - // Form values state map: key = fieldCode, value = field value - const [formValues, setFormValues] = useState>({}); - // Master options cache: key = lookupSource, value = options list - const [lookupOptionsMap, setLookupOptionsMap] = useState>({}); - - const [formErrors, setFormErrors] = useState>({}); - const [submitting, setSubmitting] = useState(false); - const [submissionResult, setSubmissionResult] = useState(null); - const [apiError, setApiError] = useState(null); - - // Load categories - useEffect(() => { - getActionCategories().then((res) => { - const activeCats = (res || []).filter((c) => c.isActive !== false); - setCategories(activeCats); - }); - }, []); - - // Load Action Types when Category changes - useEffect(() => { - if (!selectedCategoryId) { - setActionTypes([]); - setSelectedActionTypeId(''); - setFields([]); - return; - } - getActionTypesByCategory(selectedCategoryId).then((types) => { - const activeTypes = (types || []).filter((t) => t.isActive !== false); - setActionTypes(activeTypes); - setSelectedActionTypeId(''); - setFields([]); - }); - }, [selectedCategoryId]); - - // Load Field definitions and lookup sources - useEffect(() => { - if (!selectedActionTypeId) return; - setLoadingFields(true); - setFormErrors({}); - setSubmissionResult(null); - setApiError(null); - - getFieldDefinitions(selectedActionTypeId).then(async (res) => { - const activeFields = (res || []).filter((f) => f.isActive !== false); - setFields(activeFields); - - // Initialize default values - const initialValues: Record = {}; - const sourcesToFetch = new Set(); - - activeFields.forEach((f) => { - if (f.defaultValue !== undefined && f.defaultValue !== '') { - initialValues[f.fieldCode] = f.defaultValue; - } else if (f.fieldType === 'checkbox' || f.fieldType === 'switch') { - initialValues[f.fieldCode] = false; - } else if (f.fieldType === 'multi_select') { - initialValues[f.fieldCode] = []; - } else if (f.fieldType === 'currency') { - initialValues[f.fieldCode] = { amount: '', currency: 'INR' }; - } else { - initialValues[f.fieldCode] = ''; - } - - if (f.lookupSource) { - sourcesToFetch.add(f.lookupSource); - } - }); - - setFormValues(initialValues); - - // Fetch lookup options from master data - const optsMap: Record = {}; - for (const src of Array.from(sourcesToFetch)) { - if (src === 'CURRENCIES') { - optsMap[src] = DEFAULT_CURRENCIES; - } else { - const opts = await getMasterDataOptions(src); - optsMap[src] = opts.length > 0 ? opts : [ - { label: `${src} Option 1`, value: `${src}_OPT_1` }, - { label: `${src} Option 2`, value: `${src}_OPT_2` }, - ]; - } - } - setLookupOptionsMap(optsMap); - setLoadingFields(false); - }); - }, [selectedActionTypeId]); - - // Helper to normalize strings for comparison (case-insensitive, ignores spaces/hyphens/underscores) - const normalizeStr = (val: any): string => { - if (val === undefined || val === null) return ''; - if (typeof val === 'object' && !Array.isArray(val)) { - val = val.amount || val.value || JSON.stringify(val); - } - return String(val) - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, ''); - }; - - // Helper to get dependent field value flexibly by key or normalized key - const getFieldValue = (fieldCodeKey: string): any => { - if (formValues[fieldCodeKey] !== undefined) return formValues[fieldCodeKey]; - const normKey = normalizeStr(fieldCodeKey); - const matchingKey = Object.keys(formValues).find( - (k) => normalizeStr(k) === normKey, - ); - return matchingKey ? formValues[matchingKey] : undefined; - }; - - // Helper to check visibility of a field definition - const checkVisibility = (f: FieldDefinition): boolean => { - if (!f.visibilityConditionJson || !f.visibilityConditionJson.field) return true; - - const { field: depField, operator, value: targetVal } = f.visibilityConditionJson; - const depRawVal = getFieldValue(depField); - - // Find dependent field definition to check if it has lookup options - const depFieldDef = fields.find( - (item) => normalizeStr(item.fieldCode) === normalizeStr(depField), - ); - - // Get options if available - let depOptions: { label: string; value: string }[] = []; - if (depFieldDef?.lookupSource && lookupOptionsMap[depFieldDef.lookupSource]) { - depOptions = lookupOptionsMap[depFieldDef.lookupSource]; - } - - // Collect candidate values (both code and human label) - const candidates: string[] = []; - if (depRawVal !== undefined && depRawVal !== null && depRawVal !== '') { - if (Array.isArray(depRawVal)) { - depRawVal.forEach((v) => candidates.push(String(v))); - } else if (typeof depRawVal === 'object') { - if (depRawVal.amount) candidates.push(String(depRawVal.amount)); - if (depRawVal.currency) candidates.push(String(depRawVal.currency)); - if (depRawVal.value) candidates.push(String(depRawVal.value)); - } else { - candidates.push(String(depRawVal)); - } - - // Also add matching option label & value from lookup map - depOptions.forEach((opt) => { - if ( - normalizeStr(opt.value) === normalizeStr(depRawVal) || - normalizeStr(opt.label) === normalizeStr(depRawVal) - ) { - candidates.push(opt.label); - candidates.push(opt.value); - } - }); - } - - const normTarget = normalizeStr(targetVal); - const op = (operator || 'equals').toLowerCase(); - - if (op === 'equals') { - return candidates.some((c) => normalizeStr(c) === normTarget); - } else if (op === 'not_equals') { - return !candidates.some((c) => normalizeStr(c) === normTarget); - } else if (op === 'contains') { - return candidates.some( - (c) => normalizeStr(c).includes(normTarget) || normTarget.includes(normalizeStr(c)), - ); - } else if (op === 'is_empty') { - return candidates.length === 0 || candidates.every((c) => c.trim() === ''); - } else if (op === 'not_empty') { - return candidates.length > 0 && candidates.some((c) => c.trim() !== ''); - } - - return candidates.some((c) => normalizeStr(c) === normTarget); - }; - - const handleValueChange = (code: string, value: any) => { - setFormValues((prev) => ({ ...prev, [code]: value })); - if (formErrors[code]) { - setFormErrors((prev) => ({ ...prev, [code]: '' })); - } - }; - - // Frontend Validation - const validateForm = (): boolean => { - const errors: Record = {}; - - fields.forEach((f) => { - if (!checkVisibility(f)) return; // Skip hidden fields - - const val = formValues[f.fieldCode]; - - if (f.isRequired) { - if ( - val === undefined || - val === null || - (typeof val === 'string' && val.trim() === '') || - (Array.isArray(val) && val.length === 0) || - (f.fieldType === 'currency' && (!val.amount || String(val.amount).trim() === '')) - ) { - errors[f.fieldCode] = `"${f.fieldName}" is required.`; - return; - } - } - - if (val !== undefined && val !== null && val !== '') { - const vRules = f.validationJson || {}; - if (vRules.min !== undefined) { - if (typeof val === 'number' && val < vRules.min) { - errors[f.fieldCode] = `Must be at least ${vRules.min}.`; - } else if (typeof val === 'string' && val.length < vRules.min) { - errors[f.fieldCode] = `Must be at least ${vRules.min} characters.`; - } - } - if (vRules.max !== undefined) { - if (typeof val === 'number' && val > vRules.max) { - errors[f.fieldCode] = `Cannot exceed ${vRules.max}.`; - } else if (typeof val === 'string' && val.length > vRules.max) { - errors[f.fieldCode] = `Cannot exceed ${vRules.max} characters.`; - } - } - if (vRules.regex && typeof val === 'string') { - try { - const reg = new RegExp(vRules.regex); - if (!reg.test(val)) { - errors[f.fieldCode] = `Format is invalid.`; - } - } catch (e) { - // Ignored - } - } - } - }); - - setFormErrors(errors); - return Object.keys(errors).length === 0; - }; - - // Submit Handler - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setApiError(null); - setSubmissionResult(null); - - if (!validateForm()) return; - - const values: ActionSubmissionValue[] = []; - fields.filter(checkVisibility).forEach((field) => { - const value = formValues[field.fieldCode]; - if (value === undefined || value === null || value === '') return; - - const base = { field_definition_id: field.id }; - - if (field.fieldType === 'currency') { - const amt = typeof value === 'object' && value !== null ? value.amount : value; - const curr = typeof value === 'object' && value !== null ? (value.currency || value.id) : undefined; - values.push({ - ...base, - number_value: amt !== undefined && amt !== null && amt !== '' ? Number(amt) : undefined, - selected_value_id: curr ? String(curr) : undefined, - }); - } else if (field.fieldType === 'dropdown' || field.fieldType === 'radio') { - const valStr = typeof value === 'object' && value !== null ? String(value.value || value.id || '') : String(value); - values.push({ - ...base, - selected_value_id: valStr, - text_value: valStr, - }); - } else if (field.fieldType === 'multi_select') { - (Array.isArray(value) ? value : []).forEach((item, valueIndex) => { - const itemStr = typeof item === 'object' && item !== null ? String(item.value || item.id || '') : String(item); - values.push({ - ...base, - value_index: valueIndex, - selected_value_id: itemStr, - text_value: itemStr, - }); - }); - } else if (field.fieldType === 'checkbox' || field.fieldType === 'switch') { - values.push({ ...base, boolean_value: Boolean(value) }); - } else if (field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage') { - values.push({ ...base, number_value: Number(value) }); - } else if (field.fieldType === 'date') { - values.push({ ...base, date_value: String(value) }); - } else if (field.fieldType === 'time') { - values.push({ ...base, time_value: String(value) }); - } else if (field.fieldType === 'datetime') { - values.push({ ...base, timestamp_value: String(value) }); - } else if (field.lookupSource) { - const valStr = typeof value === 'object' && value !== null ? String(value.value || value.id || '') : String(value); - values.push({ - ...base, - selected_value_id: valStr, - text_value: valStr, - }); - } else { - values.push({ - ...base, - text_value: typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value), - }); - } - }); - - const payload: ActionSubmissionPayload = { - category_id: selectedCategoryId, - action_type_id: selectedActionTypeId, - values, - }; - - setSubmitting(true); - try { - const res = await submitActionPayload(payload); - setSubmissionResult({ payload, response: res }); - } catch (err: any) { - setApiError(err.message || err.response?.data?.message || 'Action submission failed on backend validation.'); - } finally { - setSubmitting(false); - } - }; - - // Group visible fields by section - const visibleFields = fields.filter(checkVisibility); - const sections = Array.from(new Set(visibleFields.map((f) => f.section || 'General Information'))); - - const selectedCategoryObj = categories.find((c) => c.id === selectedCategoryId); - const selectedTypeObj = actionTypes.find((t) => t.id === selectedActionTypeId); - - return ( -
- {/* Top Control Bar */} -
-
-
-

- - Dynamic User Form Test & Live Renderer -

-

- Select Category & Action Type to dynamically render configured user fields and test submission validation. -

-
-
- -
-
- - ({ label: `${c.name} (${c.code})`, value: c.id }))} - value={selectedCategoryId} - onChange={(val) => setSelectedCategoryId(val)} - /> -
- -
- - ({ label: `${t.name} (${t.code})`, value: t.id }))} - value={selectedActionTypeId} - onChange={(val) => setSelectedActionTypeId(val)} - placeholder="Select Action Type" - /> -
-
-
- - {/* Main Dynamic Form Card */} -
-
-
- - Form for: - - {selectedTypeObj ? `${selectedTypeObj.name} (${selectedCategoryObj?.name})` : 'Select Action Type'} - -
- - - Active Controls: {visibleFields.length} - -
- - {loadingFields ? ( -
- -
- ) : visibleFields.length === 0 ? ( -
-

No active fields configured for this Action Type.

-

Use the "Configuration Field" tab to add input fields.

-
- ) : ( -
- {apiError && ( -
- - {apiError} -
- )} - - {/* Sections */} - {sections.map((sectionName) => { - const sectionFields = visibleFields.filter((f) => (f.section || 'General Information') === sectionName); - return ( -
-
-

{sectionName}

-
- - {/* 12-Column Grid Layout */} -
- {sectionFields.map((field) => { - const widthClass = WIDTH_GRID_MAP[field.width || 'full']; - const fieldErr = formErrors[field.fieldCode]; - const lookupOpts = lookupOptionsMap[field.lookupSource || ''] || []; - - return ( -
- - - {/* Control Renderer */} - {field.fieldType === 'textarea' ? ( - handleValueChange(field.fieldCode, e.target.value)} - placeholder={field.placeholder} - rows={3} - /> - ) : field.fieldType === 'currency' ? ( -
-
- - handleValueChange(field.fieldCode, { - ...formValues[field.fieldCode], - amount: e.target.value, - }) - } - placeholder={field.placeholder || 'Amount'} - /> -
- 0 ? lookupOpts : DEFAULT_CURRENCIES} - value={formValues[field.fieldCode]?.currency || 'INR'} - onChange={(val) => - handleValueChange(field.fieldCode, { - ...formValues[field.fieldCode], - currency: val, - }) - } - /> -
- ) : field.fieldType === 'dropdown' ? ( - handleValueChange(field.fieldCode, val)} - placeholder={field.placeholder || 'Select option...'} - /> - ) : field.fieldType === 'radio' ? ( -
- {lookupOpts.map((opt) => ( - - ))} -
- ) : field.fieldType === 'multi_select' ? ( - handleValueChange(field.fieldCode, vals)} - placeholder={field.placeholder || 'Select multiple options...'} - /> - ) : field.fieldType === 'checkbox' ? ( - handleValueChange(field.fieldCode, e.target.checked)} - label={field.helpText || field.fieldName} - /> - ) : field.fieldType === 'switch' ? ( -
- handleValueChange(field.fieldCode, e.target.checked)} - /> - - {formValues[field.fieldCode] ? 'Enabled' : 'Disabled'} - -
- ) : field.fieldType === 'color' ? ( - handleValueChange(field.fieldCode, e.target.value)} - className="h-10 w-20 p-1 bg-white border border-gray-200 rounded-[10px] cursor-pointer" - /> - ) : field.fieldType === 'formula' ? ( -
- Computed Formula (Read-only) -
- ) : ( - handleValueChange(field.fieldCode, e.target.value)} - placeholder={field.placeholder} - /> - )} - - {field.helpText && field.fieldType !== 'checkbox' && ( -

{field.helpText}

- )} - - {fieldErr && ( -

- - {fieldErr} -

- )} -
- ); - })} -
-
- ); - })} - - {/* Submission Bar */} -
- } - className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] font-semibold px-8 py-3 text-[15px] shadow-sm" - > - Submit Action Payload - -
-
- )} -
- - {/* Submission Output Payload Card */} - {submissionResult && ( -
-
-

- - Action Payload Submitted & Verified Successfully! -

- API: POST /api/actions -
- -
-
- - Submitted JSON Payload - -
-                {JSON.stringify(submissionResult.payload, null, 2)}
-              
-
- -
- - Backend Response - -
-                {JSON.stringify(submissionResult.response, null, 2)}
-              
-
-
-
- )} -
- ); -} diff --git a/src/app/actionBuilder/components/FieldBuilderManager.tsx b/src/app/actionBuilder/components/FieldBuilderManager.tsx deleted file mode 100644 index 8b223b8..0000000 --- a/src/app/actionBuilder/components/FieldBuilderManager.tsx +++ /dev/null @@ -1,853 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - PlusIcon, - PencilSimpleIcon, - TrashIcon, - SlidersIcon, - CheckCircleIcon, - XCircleIcon, - CaretUpIcon, - CaretDownIcon, - EyeIcon, -} from '@phosphor-icons/react'; -import { - CustomInput, - CustomDropdown, - CustomButton, - CustomTextArea, - CustomSwitch, - CustomStatus, - CustomConfirmationModal, - CustomLoader, - CustomModal, - CustomCheckBox, -} from '../../../components/custom'; -import { - getActionCategories, - getActionTypesByCategory, - getFieldDefinitions, - createFieldDefinition, - updateFieldDefinition, - deleteFieldDefinition, - reorderFieldDefinitions, -} from '../ActionBuilderApi'; -import { getMasterCategories } from '../../masterData/MasterDataApi'; -import type { - ActionCategory, - ActionType, - FieldDefinition, - FieldDefinitionFormData, - FieldType, - FieldWidth, -} from '../ActionBuilderTypes'; - -const FIELD_TYPE_OPTIONS: { label: string; value: FieldType }[] = [ - { label: 'Text Box (Single Line)', value: 'textbox' }, - { label: 'Text Area (Multi Line)', value: 'textarea' }, - { label: 'Email Address', value: 'email' }, - { label: 'Phone Number', value: 'phone' }, - { label: 'URL / Link', value: 'url' }, - { label: 'Number (Integer)', value: 'number' }, - { label: 'Decimal / Price', value: 'decimal' }, - { label: 'Percentage (%)', value: 'percentage' }, - { label: 'Currency (Amount + Currency)', value: 'currency' }, - { label: 'Dropdown Selection', value: 'dropdown' }, - { label: 'Radio Selection', value: 'radio' }, - { label: 'Multi-Select Checkboxes', value: 'multi_select' }, - { label: 'Single Checkbox', value: 'checkbox' }, - { label: 'Toggle Switch', value: 'switch' }, - { label: 'Date Picker', value: 'date' }, - { label: 'Date & Time Picker', value: 'datetime' }, - { label: 'Time Picker', value: 'time' }, - { label: 'Color Picker', value: 'color' }, - { label: 'Formula (Read-only Computed)', value: 'formula' }, -]; - -const WIDTH_OPTIONS: { label: string; value: FieldWidth }[] = [ - { label: 'Full Width (12/12)', value: 'full' }, - { label: 'Half Width (6/12)', value: 'half' }, - { label: 'One-Third Width (4/12)', value: 'third' }, - { label: 'Two-Thirds Width (8/12)', value: 'two_thirds' }, -]; - -export function FieldBuilderManager() { - const [categories, setCategories] = useState([]); - const [selectedCategoryId, setSelectedCategoryId] = useState(''); - const [actionTypes, setActionTypes] = useState([]); - const [selectedActionTypeId, setSelectedActionTypeId] = useState(''); - - const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([ - { label: 'None (Manual / Free-text)', value: '' }, - { label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' }, - ]); - - const [fields, setFields] = useState([]); - const [loading, setLoading] = useState(false); - - // Form Modal State - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingField, setEditingField] = useState(null); - - const [formData, setFormData] = useState({ - fieldCode: '', - fieldName: '', - fieldType: '' as FieldType, - lookupSource: '', - isRequired: false, - defaultValue: '', - placeholder: '', - helpText: '', - width: 'full', - section: 'General Information', - displayOrder: 1, - isActive: true, - validationJson: { min: undefined, max: undefined, regex: '' }, - visibilityConditionJson: { field: '', operator: 'equals', value: '' }, - }); - - const [errorMsg, setErrorMsg] = useState(null); - const [successMsg, setSuccessMsg] = useState(null); - - // Delete Modal State - const [deleteTarget, setDeleteTarget] = useState(null); - - // Load categories and master table categories for lookup dropdown - useEffect(() => { - getActionCategories().then((res) => { - const activeCats = (res || []).filter((c) => c.isActive !== false); - setCategories(activeCats); - }); - - getMasterCategories() - .then((masterCats) => { - if (Array.isArray(masterCats) && masterCats.length > 0) { - const opts = masterCats.map((c) => ({ - label: `${c.name} (${c.code})`, - value: c.code, - })); - setMasterLookupOptions([ - { label: 'None (Manual / Free-text)', value: '' }, - { label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' }, - ...opts, - ]); - } - }) - .catch((e) => { - console.error('Failed to load master lookup categories', e); - }); - }, []); - - // Load Action Types when Category changes - useEffect(() => { - if (!selectedCategoryId) { - setActionTypes([]); - setSelectedActionTypeId(''); - setFields([]); - return; - } - getActionTypesByCategory(selectedCategoryId).then((types) => { - const activeTypes = (types || []).filter((t) => t.isActive !== false); - setActionTypes(activeTypes); - setSelectedActionTypeId(''); - setFields([]); - }); - }, [selectedCategoryId]); - - // Load Fields when Action Type changes - const fetchFields = async (atId: string) => { - if (!atId) return; - setLoading(true); - try { - const res = await getFieldDefinitions(atId); - setFields(res || []); - } catch (e) { - console.error('Failed to load field definitions', e); - setFields([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (selectedActionTypeId) { - fetchFields(selectedActionTypeId); - } - }, [selectedActionTypeId]); - - const resetForm = () => { - setFormData({ - fieldCode: '', - fieldName: '', - fieldType: '' as FieldType, - lookupSource: '', - isRequired: false, - defaultValue: '', - placeholder: '', - helpText: '', - width: 'full', - section: 'General Information', - displayOrder: fields.length + 1, - isActive: true, - validationJson: { min: undefined, max: undefined, regex: '' }, - visibilityConditionJson: { field: '', operator: 'equals', value: '' }, - }); - setEditingField(null); - setErrorMsg(null); - setIsModalOpen(false); - }; - - const handleOpenCreate = () => { - if (!selectedActionTypeId) { - alert('Please select an Action Type first.'); - return; - } - setEditingField(null); - setFormData({ - fieldCode: '', - fieldName: '', - fieldType: '' as FieldType, - lookupSource: '', - isRequired: false, - defaultValue: '', - placeholder: '', - helpText: '', - width: 'full', - section: 'General Information', - displayOrder: fields.length + 1, - isActive: true, - validationJson: { min: undefined, max: undefined, regex: '' }, - visibilityConditionJson: { field: '', operator: 'equals', value: '' }, - }); - setErrorMsg(null); - setIsModalOpen(true); - }; - - const handleOpenEdit = (field: FieldDefinition) => { - setEditingField(field); - setFormData({ - fieldCode: field.fieldCode, - fieldName: field.fieldName, - fieldType: field.fieldType, - lookupSource: field.lookupSource || '', - isRequired: field.isRequired, - defaultValue: field.defaultValue || '', - placeholder: field.placeholder || '', - helpText: field.helpText || '', - width: field.width || 'full', - section: field.section || 'General Information', - displayOrder: field.displayOrder || 1, - isActive: field.isActive !== false, - validationJson: field.validationJson || { min: undefined, max: undefined, regex: '' }, - visibilityConditionJson: field.visibilityConditionJson || { field: '', operator: 'equals', value: '' }, - }); - setErrorMsg(null); - setIsModalOpen(true); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setErrorMsg(null); - setSuccessMsg(null); - - if (!formData.fieldName.trim()) { - setErrorMsg('Field Name is required.'); - return; - } - if (!formData.fieldCode.trim()) { - setErrorMsg('Field Code is required.'); - return; - } - - // Clean up empty validation / visibility json before payload - const payloadData: FieldDefinitionFormData = { - ...formData, - fieldCode: formData.fieldCode.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'), - validationJson: - formData.validationJson?.min !== undefined || - formData.validationJson?.max !== undefined || - formData.validationJson?.regex - ? formData.validationJson - : undefined, - visibilityConditionJson: - formData.visibilityConditionJson?.field && formData.visibilityConditionJson?.value - ? formData.visibilityConditionJson - : undefined, - }; - - try { - if (editingField) { - await updateFieldDefinition(editingField.id, payloadData); - setSuccessMsg(`Field "${formData.fieldName}" updated successfully!`); - } else { - await createFieldDefinition(selectedActionTypeId, payloadData); - setSuccessMsg(`Field "${formData.fieldName}" created successfully!`); - } - await fetchFields(selectedActionTypeId); - resetForm(); - setTimeout(() => setSuccessMsg(null), 3000); - } catch (err: any) { - setErrorMsg(err.message || err.response?.data?.message || 'An error occurred saving field.'); - } - }; - - const handleConfirmDelete = async () => { - if (!deleteTarget) return; - try { - await deleteFieldDefinition(deleteTarget.id); - setDeleteTarget(null); - setSuccessMsg('Field definition deleted successfully.'); - await fetchFields(selectedActionTypeId); - setTimeout(() => setSuccessMsg(null), 3000); - } catch (err: any) { - setErrorMsg(err.message || 'Failed to delete field definition.'); - } - }; - - const handleMove = async (index: number, direction: 'up' | 'down') => { - const newFields = [...fields]; - const targetIndex = direction === 'up' ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= newFields.length) return; - - const temp = newFields[index]; - newFields[index] = newFields[targetIndex]; - newFields[targetIndex] = temp; - - setFields(newFields); - try { - await reorderFieldDefinitions(selectedActionTypeId, newFields.map((f) => f.id)); - } catch (e) { - fetchFields(selectedActionTypeId); - } - }; - - const selectedTypeObj = actionTypes.find((t) => t.id === selectedActionTypeId); - - return ( -
- {/* Top Banner / Selectors */} -
-
-
-

- - Action Type Configuration Fields -

-

- Define dynamic user form fields, layout widths, validation constraints, and visibility rules. -

-
- -
- } - className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[10px] !gap-[10px] !h-[40px] font-semibold text-[14px]" - > - Add Field Definition - -
-
- - {/* Category & Action Type Pickers */} -
-
- - ({ label: `${c.name} (${c.code})`, value: c.id }))} - value={selectedCategoryId} - onChange={(val) => setSelectedCategoryId(val)} - placeholder="Select Category" - /> -
- -
- - ({ label: `${t.name} (${t.code})`, value: t.id }))} - value={selectedActionTypeId} - onChange={(val) => setSelectedActionTypeId(val)} - placeholder={actionTypes.length === 0 ? 'No Action Types in Category' : 'Select Action Type'} - /> -
-
-
- - {/* Success Banner */} - {successMsg && ( -
- - {successMsg} -
- )} - - {/* Fields List Card */} -
-
-
- Configured Fields for: - - {selectedTypeObj ? `${selectedTypeObj.name} (${selectedTypeObj.code})` : 'Select an Action Type'} - -
- - - Total Fields: {fields.length} - -
- - {loading ? ( -
- -
- ) : fields.length === 0 ? ( -
-

No field definitions configured for this Action Type yet.

-

Click "Add Field Definition" above to add dynamic inputs.

-
- ) : ( -
- - - - - - - - - - - - - - - {fields.map((f, idx) => ( - - - - - - - - - - - - - - - - - - ))} - -
ReorderField Name & CodeControl TypeWidth & SectionMaster LookupValidation & VisibilityStatusActions
-
- - - #{f.displayOrder} -
-
-
- {f.fieldName} - {f.isRequired && ( - - Required - - )} -
- {f.fieldCode} -
- - {f.fieldType} - - -
{f.width || 'full'} width
-
{f.section || 'General'}
-
- {f.lookupSource ? ( - - {f.lookupSource} - - ) : ( - - )} - - {f.visibilityConditionJson?.field && ( -
- - Visible if {f.visibilityConditionJson.field} = {String(f.visibilityConditionJson.value)} -
- )} - {f.validationJson && (f.validationJson.min !== undefined || f.validationJson.max !== undefined) && ( -
- Min: {f.validationJson.min ?? '—'} | Max: {f.validationJson.max ?? '—'} -
- )} -
- - -
- - -
-
-
- )} -
- - {/* Field Definition Add/Edit Modal */} - - {errorMsg && ( -
- - {errorMsg} -
- )} - -
- {/* Section 1: Basic Identifiers */} -
-
- - { - const val = e.target.value; - setFormData((prev) => ({ - ...prev, - fieldName: val, - fieldCode: editingField ? prev.fieldCode : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'), - })); - }} - placeholder="e.g. Payout Amount" - /> -
- -
- - setFormData((prev) => ({ ...prev, fieldCode: e.target.value }))} - placeholder="e.g. payout_amount" - /> -
- -
- - setFormData((prev) => ({ ...prev, fieldType: val as FieldType }))} - placeholder="Select Control Type..." - /> -
-
- - {/* Section 2: Master Lookup & Layout Width */} - {(() => { - const needsLookup = ['dropdown', 'radio', 'multi_select', 'currency'].includes(formData.fieldType); - return ( -
- {needsLookup && ( -
- - setFormData((prev) => ({ ...prev, lookupSource: val }))} - placeholder="Select Master Data Lookup Source..." - /> -
- )} - -
- - setFormData((prev) => ({ ...prev, width: val as FieldWidth }))} - /> -
- -
- - setFormData((prev) => ({ ...prev, section: e.target.value }))} - placeholder="e.g. Financial Details" - /> -
-
- ); - })()} - - {/* Section 3: Help & Defaults */} -
-
- - setFormData((prev) => ({ ...prev, placeholder: e.target.value }))} - placeholder="e.g. Enter amount..." - /> -
- -
- - setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))} - placeholder="e.g. 600" - /> -
- -
- - setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))} - /> -
-
- -
- - setFormData((prev) => ({ ...prev, helpText: e.target.value }))} - placeholder="e.g. Max allowed compensation payout in selected currency" - rows={2} - /> -
- - {/* Section 4: Advanced Validation JSON */} -
-
Validation Rules (validation_json)
-
-
- - - setFormData((prev) => ({ - ...prev, - validationJson: { - ...prev.validationJson, - min: e.target.value !== '' ? Number(e.target.value) : undefined, - }, - })) - } - placeholder="e.g. 50" - /> -
- -
- - - setFormData((prev) => ({ - ...prev, - validationJson: { - ...prev.validationJson, - max: e.target.value !== '' ? Number(e.target.value) : undefined, - }, - })) - } - placeholder="e.g. 2000" - /> -
- -
- - - setFormData((prev) => ({ - ...prev, - validationJson: { - ...prev.validationJson, - regex: e.target.value, - }, - })) - } - placeholder="e.g. ^[A-Z0-9]+$" - /> -
-
-
- - {/* Section 5: Conditional Visibility JSON */} - {(() => { - const dependentFieldOptions = [ - { label: 'None (No Dependency)', value: '' }, - ...fields - .filter((f) => !editingField || (f.id !== editingField.id && f.fieldCode !== editingField.fieldCode)) - .map((f) => ({ - label: `${f.fieldName} (${f.fieldCode})`, - value: f.fieldCode, - })), - ]; - - return ( -
-
Conditional Visibility (visibility_condition_json)
-
-
- - - setFormData((prev) => ({ - ...prev, - visibilityConditionJson: { - ...(prev.visibilityConditionJson || { operator: 'equals', value: '' }), - field: val, - }, - })) - } - placeholder="Select dependent field..." - /> -
- -
- - - setFormData((prev) => ({ - ...prev, - visibilityConditionJson: { - ...(prev.visibilityConditionJson || { field: '', value: '' }), - operator: val as any, - }, - })) - } - /> -
- -
- - - setFormData((prev) => ({ - ...prev, - visibilityConditionJson: { - ...(prev.visibilityConditionJson || { field: '', operator: 'equals' }), - value: e.target.value, - }, - })) - } - placeholder="e.g. BANK_TRANSFER" - /> -
-
-
- ); - })()} - - {/* Controls Footer */} -
-
- setFormData((prev) => ({ ...prev, isRequired: e.target.checked }))} - label="Is Required Field" - /> - -
- Active - setFormData((prev) => ({ ...prev, isActive: e.target.checked }))} - /> -
-
- -
- - Cancel - - - - {editingField ? 'Save Changes' : 'Create Field'} - -
-
-
-
- - {/* Delete Confirmation Modal */} - {deleteTarget && ( - setDeleteTarget(null)} - confirmText="Delete Field" - cancelText="Cancel" - variant="danger" - /> - )} -
- ); -} diff --git a/src/app/actionBuilder/components/ManageCategoriesTypesModal.tsx b/src/app/actionBuilder/components/ManageCategoriesTypesModal.tsx deleted file mode 100644 index 8097029..0000000 --- a/src/app/actionBuilder/components/ManageCategoriesTypesModal.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useState } from 'react'; -import { CustomModal, CustomTabs } from '../../../components/custom'; -import { ActionCategoryManager } from './ActionCategoryManager'; -import { ActionTypeManager } from './ActionTypeManager'; - -interface ManageCategoriesTypesModalProps { - isOpen: boolean; - onClose: () => void; - defaultTab?: 'categories' | 'types'; -} - -export function ManageCategoriesTypesModal({ - isOpen, - onClose, - defaultTab = 'categories', -}: ManageCategoriesTypesModalProps) { - const [activeTab, setActiveTab] = useState(defaultTab); - - const tabs = [ - { id: 'categories', label: '1. Action Categories CRUD', content: }, - { id: 'types', label: '2. Action Types CRUD', content: }, - ]; - - return ( - -
- setActiveTab(tabId)} - /> -
-
- ); -} diff --git a/src/app/actionBuilder/index.tsx b/src/app/actionBuilder/index.tsx deleted file mode 100644 index e1ac518..0000000 --- a/src/app/actionBuilder/index.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useState, useEffect } from 'react'; -import { LightningIcon } from '@phosphor-icons/react'; -import { CustomTabs } from '../../components/custom'; -import { ActionCategoryManager } from './components/ActionCategoryManager'; -import { ActionTypeManager } from './components/ActionTypeManager'; -import { FieldBuilderManager } from './components/FieldBuilderManager'; -import { DynamicFormRenderer } from './components/DynamicFormRenderer'; -import { getActionCategories, getActionTypes } from './ActionBuilderApi'; - -export default function ActionBuilderPage() { - const [categoriesCount, setCategoriesCount] = useState(0); - const [typesCount, setTypesCount] = useState(0); - const [activeTab, setActiveTab] = useState('categories'); - - useEffect(() => { - Promise.all([ - getActionCategories().catch(() => []), - getActionTypes().catch(() => []), - ]).then(([cats, types]) => { - setCategoriesCount((cats || []).length); - setTypesCount((types || []).length); - }); - }, [activeTab]); - - const tabs = [ - { - id: 'categories', - label: 'Action Categories', - content: , - }, - { - id: 'types', - label: 'Action Types', - content: , - }, - { - id: 'configuration_fields', - label: 'Configuration Field', - content: , - }, - { - id: 'dynamic_user_form', - label: 'Dynamic User Form', - content: , - }, - ]; - - return ( -
- {/* Top Header */} -
-
-
- -
-
-

Action Builder

-

- Configure dynamic categories, action types, metadata fields, and dynamic user form workflows -

-
-
- - {/* Stats Pills */} -
-
- Categories - {categoriesCount} -
- -
- Action Types - {typesCount} -
-
-
- - {/* Main Tabs Navigation */} -
- setActiveTab(tabId)} - /> -
-
- ); -} diff --git a/src/app/cohartManage/components/AddCohart.tsx b/src/app/cohartManage/components/AddCohart.tsx index 6cc12bd..835642a 100644 --- a/src/app/cohartManage/components/AddCohart.tsx +++ b/src/app/cohartManage/components/AddCohart.tsx @@ -22,8 +22,8 @@ import { getRevenueSegments, getRegions, getTripPurposes, -} from '../../masterData/MasterDataApi'; -import type { MasterDataItem } from '../../masterData/MasterDataTypes'; +} from '../../configuration/masterData/MasterDataApi'; +import type { MasterDataItem } from '../../configuration/masterData/MasterDataTypes'; import type { CohartResponse, CreateCohartPayload, diff --git a/src/app/actionBuilder/ActionBuilderApi.ts b/src/app/configuration/actionBuilder/ActionBuilderApi.ts similarity index 99% rename from src/app/actionBuilder/ActionBuilderApi.ts rename to src/app/configuration/actionBuilder/ActionBuilderApi.ts index 9145fc7..0366661 100644 --- a/src/app/actionBuilder/ActionBuilderApi.ts +++ b/src/app/configuration/actionBuilder/ActionBuilderApi.ts @@ -1,4 +1,4 @@ -import { ApiClient } from '../api/ApiClient'; +import { ApiClient } from '../../api/ApiClient'; import type { ActionCategory, ActionType, diff --git a/src/app/actionBuilder/ActionBuilderTypes.ts b/src/app/configuration/actionBuilder/ActionBuilderTypes.ts similarity index 100% rename from src/app/actionBuilder/ActionBuilderTypes.ts rename to src/app/configuration/actionBuilder/ActionBuilderTypes.ts diff --git a/src/app/configuration/actionBuilder/components/ActionTypeFormModal.tsx b/src/app/configuration/actionBuilder/components/ActionTypeFormModal.tsx new file mode 100644 index 0000000..2541f4a --- /dev/null +++ b/src/app/configuration/actionBuilder/components/ActionTypeFormModal.tsx @@ -0,0 +1,150 @@ +import { XCircleIcon } from '@phosphor-icons/react'; + +import { + CustomModal, + CustomInput, + CustomDropdown, + CustomTextArea, + CustomSwitch, + CustomButton, +} from '../../../../components/custom'; +import type { ActionCategory, ActionType, ActionTypeFormData } from '../ActionBuilderTypes'; + +interface ActionTypeFormModalProps { + isOpen: boolean; + editingType: ActionType | null; + categories: ActionCategory[]; + formData: ActionTypeFormData; + setFormData: React.Dispatch>; + error: string | null; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; +} + +export function ActionTypeFormModal({ + isOpen, + editingType, + categories, + formData, + setFormData, + error, + onClose, + onSubmit, +}: ActionTypeFormModalProps) { + return ( + + {error && ( +
+ + {error} +
+ )} + +
+
+
+ + ({ label: `${c.name} (${c.code})`, value: c.id }))} + value={formData.categoryId} + onChange={(val) => setFormData((prev) => ({ ...prev, categoryId: val }))} + placeholder="Select Category" + /> +
+ +
+ + { + const nameVal = e.target.value; + setFormData((prev) => ({ + ...prev, + name: nameVal, + code: editingType ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '_'), + })); + }} + placeholder="e.g. Award Miles" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, code: e.target.value }))} + placeholder="e.g. award_miles" + /> +
+
+ +
+
+ + setFormData((prev) => ({ ...prev, description: e.target.value }))} + placeholder="Provide details about how this action type is executed..." + rows={3} + /> +
+ +
+ + setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))} + placeholder="1" + /> +
+
+ +
+
+ Active Status + setFormData((prev) => ({ ...prev, isActive: e.target.checked }))} + /> +
+ +
+ + Cancel + + + + {editingType ? 'Save Changes' : 'Create Action Type'} + +
+
+
+
+ ); +} diff --git a/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx b/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx new file mode 100644 index 0000000..88b06d0 --- /dev/null +++ b/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; + +import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react'; +import { CustomInput, CustomButton } from '../../../../components/custom'; +import type { ActionType } from '../ActionBuilderTypes'; + +interface ActionTypesColumnProps { + actionTypes: ActionType[]; + selectedCategoryId: string; + selectedActionTypeId: string; + onSelectActionType: (id: string) => void; + fieldCountMap: Record; + onOpenAdd: () => void; + onOpenEdit: (type: ActionType, e: React.MouseEvent) => void; + onDelete: (type: ActionType, e: React.MouseEvent) => void; +} + +export function ActionTypesColumn({ + actionTypes, + selectedCategoryId, + selectedActionTypeId, + onSelectActionType, + fieldCountMap, + onOpenAdd, + onOpenEdit, + onDelete, +}: ActionTypesColumnProps) { + const [search, setSearch] = useState(''); + + const categoryActionTypes = actionTypes.filter((t) => t.categoryId === selectedCategoryId); + + const filteredActionTypes = categoryActionTypes.filter( + (t) => + t.name.toLowerCase().includes(search.toLowerCase()) || + t.code.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ {/* Column Header */} +
+

Action Types

+ } + > + Add + +
+ + + {/* Search Input */} +
+ setSearch(e.target.value)} + placeholder="Search Type by name or code..." + leftIcon={} + className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]" + containerClassName="!gap-0" + /> +
+ + {/* Action Types Cards List */} +
+ {!selectedCategoryId ? ( +
+ Select a category to view action types. +
+ ) : filteredActionTypes.length === 0 ? ( +
+ No action types in this category. Click "+ Add" to create one. +
+ ) : ( + filteredActionTypes.map((t) => { + const isSelected = t.id === selectedActionTypeId; + const fieldCount = fieldCountMap[t.id] ?? 0; + + return ( +
onSelectActionType(t.id)} + className={`group relative p-3.5 rounded-[14px] flex items-center justify-between cursor-pointer transition-all duration-150 ${isSelected + ? 'bg-[#1E7D5C] text-white shadow-sm' + : 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800' + }`} + > +
+
+ {t.name} +
+
+ {t.code} +
+
+ +
+ {/* Hover Actions */} +
+ + +
+ + {/* Count Badge */} +
+ {fieldCount} +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx b/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx new file mode 100644 index 0000000..0ea9b72 --- /dev/null +++ b/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; + +import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react'; +import { CustomInput, CustomButton } from '../../../../components/custom'; +import type { ActionCategory, ActionType } from '../ActionBuilderTypes'; + +interface CategoriesColumnProps { + categories: ActionCategory[]; + selectedCategoryId: string; + onSelectCategory: (id: string) => void; + actionTypes: ActionType[]; + onOpenAdd: () => void; + onOpenEdit: (cat: ActionCategory, e: React.MouseEvent) => void; + onDelete: (cat: ActionCategory, e: React.MouseEvent) => void; +} + +export function CategoriesColumn({ + categories, + selectedCategoryId, + onSelectCategory, + actionTypes, + onOpenAdd, + onOpenEdit, + onDelete, +}: CategoriesColumnProps) { + const [search, setSearch] = useState(''); + + const filteredCategories = categories.filter( + (c) => + c.name.toLowerCase().includes(search.toLowerCase()) || + c.code.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ {/* Column Header */} +
+

Categories

+ } + > + Add + +
+ + + {/* Search Input */} +
+ setSearch(e.target.value)} + placeholder="Search Category by name or code..." + leftIcon={} + className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]" + containerClassName="!gap-0" + /> +
+ + {/* Category Cards List */} +
+ {filteredCategories.length === 0 ? ( +
+ No categories found. +
+ ) : ( + filteredCategories.map((cat) => { + const isSelected = cat.id === selectedCategoryId; + const childCount = actionTypes.filter((t) => t.categoryId === cat.id).length; + + return ( +
onSelectCategory(cat.id)} + className={`group relative p-3.5 rounded-[14px] flex items-center justify-between cursor-pointer transition-all duration-150 ${isSelected + ? 'bg-[#1E7D5C] text-white shadow-sm' + : 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800' + }`} + > +
+
+ {cat.name} +
+
+ {cat.code} +
+
+ +
+ {/* Hover Actions */} +
+ + +
+ + {/* Count Badge */} +
+ {childCount} +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/app/configuration/actionBuilder/components/CategoryFormModal.tsx b/src/app/configuration/actionBuilder/components/CategoryFormModal.tsx new file mode 100644 index 0000000..5bebd02 --- /dev/null +++ b/src/app/configuration/actionBuilder/components/CategoryFormModal.tsx @@ -0,0 +1,133 @@ +import { XCircleIcon } from '@phosphor-icons/react'; + +import { + CustomModal, + CustomInput, + CustomTextArea, + CustomSwitch, + CustomButton, +} from '../../../../components/custom'; +import type { ActionCategory, ActionCategoryFormData } from '../ActionBuilderTypes'; + +interface CategoryFormModalProps { + isOpen: boolean; + editingCategory: ActionCategory | null; + formData: ActionCategoryFormData; + setFormData: React.Dispatch>; + error: string | null; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; +} + +export function CategoryFormModal({ + isOpen, + editingCategory, + formData, + setFormData, + error, + onClose, + onSubmit, +}: CategoryFormModalProps) { + return ( + + {error && ( +
+ + {error} +
+ )} + +
+
+
+ + { + const nameVal = e.target.value; + setFormData((prev) => ({ + ...prev, + name: nameVal, + code: editingCategory ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'), + })); + }} + placeholder="e.g. Refunds" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, code: e.target.value }))} + placeholder="e.g. refunds" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))} + placeholder="1" + /> +
+
+ +
+ + setFormData((prev) => ({ ...prev, description: e.target.value }))} + placeholder="Briefly describe what actions belong in this category..." + rows={3} + /> +
+ +
+
+ Active Status + setFormData((prev) => ({ ...prev, isActive: e.target.checked }))} + /> +
+ +
+ + Cancel + + + + {editingCategory ? 'Save Changes' : 'Create Category'} + +
+
+
+
+ ); +} diff --git a/src/app/configuration/actionBuilder/components/ConfigurationFieldsColumn.tsx b/src/app/configuration/actionBuilder/components/ConfigurationFieldsColumn.tsx new file mode 100644 index 0000000..de8a510 --- /dev/null +++ b/src/app/configuration/actionBuilder/components/ConfigurationFieldsColumn.tsx @@ -0,0 +1,274 @@ +import { useState } from 'react'; + +import { + PlusIcon, + MagnifyingGlassIcon, + PencilSimpleLineIcon, + TrashIcon, + DotsSixVerticalIcon, +} from '@phosphor-icons/react'; +import { CustomInput, CustomLoader, CustomButton } from '../../../../components/custom'; +import type { FieldDefinition } from '../ActionBuilderTypes'; + +interface ConfigurationFieldsColumnProps { + fields: FieldDefinition[]; + selectedActionTypeId: string; + loading: boolean; + onOpenAdd: () => void; + onOpenEdit: (field: FieldDefinition) => void; + onDelete: (field: FieldDefinition) => void; + onReorderFields?: (fromIndex: number, toIndex: number) => void; +} + +export function ConfigurationFieldsColumn({ + fields, + selectedActionTypeId, + loading, + onOpenAdd, + onOpenEdit, + onDelete, + onReorderFields, +}: ConfigurationFieldsColumnProps) { + const [search, setSearch] = useState(''); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const filteredFields = fields.filter( + (f) => + f.fieldName.toLowerCase().includes(search.toLowerCase()) || + f.fieldCode.toLowerCase().includes(search.toLowerCase()) || + (f.section || '').toLowerCase().includes(search.toLowerCase()) + ); + + const fieldNameByCode = new Map(fields.map((f) => [f.fieldCode, f.fieldName])); + + return ( +
+ {/* Column Header */} +
+

Configuration Field

+ } + > + Add + +
+ + {/* Search Input */} +
+ setSearch(e.target.value)} + placeholder="Search by name or code..." + leftIcon={} + className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]" + containerClassName="!gap-0" + /> +
+ + {/* Field Cards List */} +
+ {loading ? ( +
+ +
+ ) : !selectedActionTypeId ? ( +
+ Select an Action Type to view configuration fields. +
+ ) : filteredFields.length === 0 ? ( +
+ No fields configured for this action type yet. Click "+ Add" above to create one. +
+ ) : ( + filteredFields.map((f, idx) => { + const hasVisibility = f.visibilityConditionJson?.field && f.visibilityConditionJson?.value; + const depFieldName = hasVisibility + ? fieldNameByCode.get(f.visibilityConditionJson!.field) || f.visibilityConditionJson!.field + : 'Field Name'; + + const minVal = f.validationJson?.min; + const maxVal = f.validationJson?.max; + const hasMinMax = minVal !== undefined || maxVal !== undefined; + + + const isDragging = draggedIndex === idx; + const isDragOver = dragOverIndex === idx; + + return ( +
{ + setDraggedIndex(idx); + e.dataTransfer.setData('text/plain', String(idx)); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragOverIndex !== idx) { + setDragOverIndex(idx); + } + }} + onDragLeave={() => { + if (dragOverIndex === idx) { + setDragOverIndex(null); + } + }} + onDrop={(e) => { + e.preventDefault(); + if (draggedIndex !== null && draggedIndex !== idx) { + const fromOriginalIndex = fields.findIndex((item) => item.id === filteredFields[draggedIndex].id); + const toOriginalIndex = fields.findIndex((item) => item.id === f.id); + if (fromOriginalIndex !== -1 && toOriginalIndex !== -1) { + onReorderFields?.(fromOriginalIndex, toOriginalIndex); + } + } + setDraggedIndex(null); + setDragOverIndex(null); + }} + onDragEnd={() => { + setDraggedIndex(null); + setDragOverIndex(null); + }} + className={`bg-[#EFF3F7] rounded-[16px] border border-[#E2E8F0] overflow-hidden flex items-stretch transition-all duration-150 ${isDragOver ? 'ring-2 ring-[#1E7D5C] scale-[1.01]' : 'hover:border-slate-300' + } ${isDragging ? 'opacity-40' : ''}`} + > + {/* Left Drag Handle Bar */} +
+ +
+ + {/* Inner White Card Content */} +
+ {/* Top Header Row */} +
+
+
+ {f.fieldName} +
+
+ {f.fieldCode} +
+
+ +
+ {/* Required Badge */} + {f.isRequired && ( + + Required + + )} + + {/* Active Badge */} + + {f.isActive !== false ? 'Active' : 'Inactive'} + + + {/* Divider */} + | + + {/* Action Buttons */} + + +
+
+ +
+ + {/* Details Grid (2 columns x 2 rows) */} +
+
+
+ CONTROL TYPE +
+
+ {f.fieldType} +
+
+ +
+
+ WIDTH +
+
+ {f.width ? `${f.width} width` : 'full width'} +
+
+ +
+
+ MASTER LOOKUP +
+
+ {f.lookupSource || '--'} +
+
+ +
+
+ SECTION +
+
+ {f.section || 'General Information'} +
+
+
+ + {/* Visibility Condition Banner & Min/Max Footer */} +
+ {hasVisibility ? ( +
+
Visible if {depFieldName} =
+
+ {String(f.visibilityConditionJson?.value)} +
+
+ ) : ( +
+ )} + + {hasMinMax && ( +
+ Min: {minVal ?? '—'} | Max: {maxVal ?? '—'} +
+ )} +
+
+
+ ); + }) + )} +
+
+ ); +} + + diff --git a/src/app/configuration/actionBuilder/components/FieldDefinitionFormModal.tsx b/src/app/configuration/actionBuilder/components/FieldDefinitionFormModal.tsx new file mode 100644 index 0000000..e888c53 --- /dev/null +++ b/src/app/configuration/actionBuilder/components/FieldDefinitionFormModal.tsx @@ -0,0 +1,376 @@ +import { XCircleIcon } from '@phosphor-icons/react'; + +import { + CustomModal, + CustomInput, + CustomDropdown, + CustomTextArea, + CustomSwitch, + CustomButton, +} from '../../../../components/custom'; +import type { + FieldDefinition, + FieldDefinitionFormData, + FieldType, + FieldWidth, +} from '../ActionBuilderTypes'; + +const FIELD_TYPE_OPTIONS: { label: string; value: FieldType }[] = [ + { label: 'Text Box (Single Line)', value: 'textbox' }, + { label: 'Text Area (Multi Line)', value: 'textarea' }, + { label: 'Email Address', value: 'email' }, + { label: 'Phone Number', value: 'phone' }, + { label: 'URL / Link', value: 'url' }, + { label: 'Number (Integer)', value: 'number' }, + { label: 'Decimal / Price', value: 'decimal' }, + { label: 'Percentage (%)', value: 'percentage' }, + { label: 'Currency (Amount + Currency)', value: 'currency' }, + { label: 'Dropdown Selection', value: 'dropdown' }, + { label: 'Radio Selection', value: 'radio' }, + { label: 'Multi-Select Checkboxes', value: 'multi_select' }, + { label: 'Single Checkbox', value: 'checkbox' }, + { label: 'Toggle Switch', value: 'switch' }, + { label: 'Date Picker', value: 'date' }, + { label: 'Date & Time Picker', value: 'datetime' }, + { label: 'Time Picker', value: 'time' }, + { label: 'Color Picker', value: 'color' }, + { label: 'Formula (Read-only Computed)', value: 'formula' }, +]; + +const WIDTH_OPTIONS: { label: string; value: FieldWidth }[] = [ + { label: 'Full Width (12/12)', value: 'full' }, + { label: 'Half Width (6/12)', value: 'half' }, + { label: 'One-Third Width (4/12)', value: 'third' }, + { label: 'Two-Thirds Width (8/12)', value: 'two_thirds' }, +]; + +interface FieldDefinitionFormModalProps { + isOpen: boolean; + editingField: FieldDefinition | null; + fields: FieldDefinition[]; + masterLookupOptions: { label: string; value: string }[]; + formData: FieldDefinitionFormData; + setFormData: React.Dispatch>; + error: string | null; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; +} + +export function FieldDefinitionFormModal({ + isOpen, + editingField, + fields, + masterLookupOptions, + formData, + setFormData, + error, + onClose, + onSubmit, +}: FieldDefinitionFormModalProps) { + const dependentOptions = [ + { label: 'None (No Dependency)', value: '' }, + ...fields + .filter((f) => !editingField || f.id !== editingField.id) + .map((f) => ({ + label: `${f.fieldName} (${f.fieldCode})`, + value: f.fieldCode, + })), + ]; + + return ( + + {error && ( +
+ + {error} +
+ )} + +
+
+
+ + { + const val = e.target.value; + setFormData((prev) => ({ + ...prev, + fieldName: val, + fieldCode: editingField ? prev.fieldCode : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'), + })); + }} + placeholder="e.g. Field Name" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, fieldCode: e.target.value }))} + placeholder="e.g. field_code" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, fieldType: val as FieldType }))} + placeholder="Select Control Type..." + /> +
+
+ +
+
+ + setFormData((prev) => ({ ...prev, lookupSource: val }))} + placeholder="Select Lookup Source..." + /> +
+ +
+ + setFormData((prev) => ({ ...prev, width: val as FieldWidth }))} + /> +
+ +
+ + setFormData((prev) => ({ ...prev, section: e.target.value }))} + placeholder="e.g. General Information" + /> +
+
+ +
+
+ + setFormData((prev) => ({ ...prev, placeholder: e.target.value }))} + placeholder="e.g. Enter value..." + /> +
+ +
+ + setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))} + placeholder="e.g. Default" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))} + /> +
+
+ +
+ + setFormData((prev) => ({ ...prev, helpText: e.target.value }))} + placeholder="e.g. Instructions for end users filling this field" + rows={2} + /> +
+ + {/* Validation JSON Rules */} +
+
Validation Rules (validation_json)
+
+
+ + + setFormData((prev) => ({ + ...prev, + validationJson: { + ...prev.validationJson, + min: e.target.value !== '' ? Number(e.target.value) : undefined, + }, + })) + } + placeholder="e.g. 312" + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + validationJson: { + ...prev.validationJson, + max: e.target.value !== '' ? Number(e.target.value) : undefined, + }, + })) + } + placeholder="e.g. 245" + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + validationJson: { + ...prev.validationJson, + regex: e.target.value, + }, + })) + } + placeholder="e.g. ^[A-Z0-9]+$" + /> +
+
+
+ + {/* Conditional Visibility */} +
+
Conditional Visibility (visibility_condition_json)
+
+
+ + + setFormData((prev) => ({ + ...prev, + visibilityConditionJson: { + ...(prev.visibilityConditionJson || { operator: 'equals', value: '' }), + field: val, + }, + })) + } + placeholder="Select dependent field..." + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + visibilityConditionJson: { + ...(prev.visibilityConditionJson || { field: '', value: '' }), + operator: val as any, + }, + })) + } + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + visibilityConditionJson: { + ...(prev.visibilityConditionJson || { field: '', operator: 'equals' }), + value: e.target.value, + }, + })) + } + placeholder="e.g. DGYRHTFUJKYHUSDERTYUIK" + /> +
+
+
+ +
+
+
+ Required Field + setFormData((prev) => ({ ...prev, isRequired: e.target.checked }))} + /> +
+ +
+ Active Status + setFormData((prev) => ({ ...prev, isActive: e.target.checked }))} + /> +
+
+ +
+ + Cancel + + + + {editingField ? 'Save Changes' : 'Create Field Definition'} + +
+
+
+
+ ); +} diff --git a/src/app/configuration/actionBuilder/components/index.ts b/src/app/configuration/actionBuilder/components/index.ts new file mode 100644 index 0000000..0d6bcbb --- /dev/null +++ b/src/app/configuration/actionBuilder/components/index.ts @@ -0,0 +1,6 @@ +export { CategoriesColumn } from './CategoriesColumn'; +export { ActionTypesColumn } from './ActionTypesColumn'; +export { ConfigurationFieldsColumn } from './ConfigurationFieldsColumn'; +export { CategoryFormModal } from './CategoryFormModal'; +export { ActionTypeFormModal } from './ActionTypeFormModal'; +export { FieldDefinitionFormModal } from './FieldDefinitionFormModal'; diff --git a/src/app/configuration/actionBuilder/index.tsx b/src/app/configuration/actionBuilder/index.tsx new file mode 100644 index 0000000..90f05bb --- /dev/null +++ b/src/app/configuration/actionBuilder/index.tsx @@ -0,0 +1,648 @@ +import { useState, useEffect } from 'react'; +import { CheckCircleIcon } from '@phosphor-icons/react'; +import { CustomConfirmationModal, CustomLoader } from '../../../components/custom'; + +import { + CategoriesColumn, + ActionTypesColumn, + ConfigurationFieldsColumn, + CategoryFormModal, + ActionTypeFormModal, + FieldDefinitionFormModal, +} from './components'; +import { + getActionCategories, + getActionTypes, + createActionCategory, + updateActionCategory, + deleteActionCategory, + createActionType, + updateActionType, + deleteActionType, + getFieldDefinitions, + createFieldDefinition, + updateFieldDefinition, + deleteFieldDefinition, + reorderFieldDefinitions, +} from './ActionBuilderApi'; +import { getMasterCategories } from '../masterData/MasterDataApi'; +import type { + ActionCategory, + ActionType, + FieldDefinition, + ActionCategoryFormData, + ActionTypeFormData, + FieldDefinitionFormData, + FieldType, +} from './ActionBuilderTypes'; + +export default function ActionBuilderPage() { + // Data States + const [categories, setCategories] = useState([]); + const [selectedCategoryId, setSelectedCategoryId] = useState(''); + + const [actionTypes, setActionTypes] = useState([]); + const [selectedActionTypeId, setSelectedActionTypeId] = useState(''); + + const [fields, setFields] = useState([]); + const [fieldCountMap, setFieldCountMap] = useState>({}); + + const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([ + { label: 'None (Manual / Free-text)', value: '' }, + { label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' }, + ]); + + // Loading & Notification States + const [loadingCategories, setLoadingCategories] = useState(true); + const [loadingFields, setLoadingFields] = useState(false); + const [successMsg, setSuccessMsg] = useState(null); + + // ─── Modal States ────────────────────────────────────────────────────────── + // Category Modal + const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [categoryFormData, setCategoryFormData] = useState({ + code: '', + name: '', + description: '', + displayOrder: 1, + isActive: true, + }); + const [categoryError, setCategoryError] = useState(null); + + // Action Type Modal + const [isTypeModalOpen, setIsTypeModalOpen] = useState(false); + const [editingType, setEditingType] = useState(null); + const [typeFormData, setTypeFormData] = useState({ + categoryId: '', + code: '', + name: '', + description: '', + displayOrder: 1, + isActive: true, + }); + const [typeError, setTypeError] = useState(null); + + // Field Definition Modal + const [isFieldModalOpen, setIsFieldModalOpen] = useState(false); + const [editingField, setEditingField] = useState(null); + const [fieldFormData, setFieldFormData] = useState({ + fieldCode: '', + fieldName: '', + fieldType: 'textbox' as FieldType, + lookupSource: '', + isRequired: false, + defaultValue: '', + placeholder: '', + helpText: '', + width: 'full', + section: 'General Information', + displayOrder: 1, + isActive: true, + validationJson: { min: undefined, max: undefined, regex: '' }, + visibilityConditionJson: { field: '', operator: 'equals', value: '' }, + }); + const [fieldError, setFieldError] = useState(null); + + // Delete Confirmation Modals + const [deleteCategoryTarget, setDeleteCategoryTarget] = useState(null); + const [deleteCategoryError, setDeleteCategoryError] = useState(null); + + const [deleteTypeTarget, setDeleteTypeTarget] = useState(null); + const [deleteFieldTarget, setDeleteFieldTarget] = useState(null); + + // ─── Data Fetching ──────────────────────────────────────────────────────── + const fetchCategoriesAndTypes = async () => { + setLoadingCategories(true); + try { + const [catsRes, typesRes] = await Promise.all([ + getActionCategories().catch(() => []), + getActionTypes().catch(() => []), + ]); + const catsList = Array.isArray(catsRes) ? catsRes : []; + const typesList = Array.isArray(typesRes) ? typesRes : []; + + setCategories(catsList); + setActionTypes(typesList); + + // Default selection if none selected + if (catsList.length > 0 && !selectedCategoryId) { + setSelectedCategoryId(catsList[0].id); + } + + // Pre-fetch field counts for each type + const counts: Record = {}; + await Promise.all( + typesList.map(async (t) => { + try { + const fList = await getFieldDefinitions(t.id); + counts[t.id] = fList ? fList.length : 0; + } catch { + counts[t.id] = 0; + } + }) + ); + setFieldCountMap(counts); + } catch (e) { + console.error('Error fetching categories/types data', e); + } finally { + setLoadingCategories(false); + } + }; + + useEffect(() => { + fetchCategoriesAndTypes(); + + getMasterCategories() + .then((masterCats) => { + if (Array.isArray(masterCats) && masterCats.length > 0) { + const opts = masterCats.map((c) => ({ + label: `${c.name} (${c.code})`, + value: c.code, + })); + setMasterLookupOptions([ + { label: 'None (Manual / Free-text)', value: '' }, + { label: 'Currencies (CURRENCIES)', value: 'CURRENCIES' }, + ...opts, + ]); + } + }) + .catch(() => { }); + }, []); + + // When selected category changes, auto-select first action type in that category + useEffect(() => { + if (!selectedCategoryId) { + setSelectedActionTypeId(''); + setFields([]); + return; + } + const catTypes = actionTypes.filter((t) => t.categoryId === selectedCategoryId); + if (catTypes.length > 0) { + if (!catTypes.some((t) => t.id === selectedActionTypeId)) { + setSelectedActionTypeId(catTypes[0].id); + } + } else { + setSelectedActionTypeId(''); + setFields([]); + } + }, [selectedCategoryId, actionTypes]); + + // When selected action type changes, fetch fields + const fetchFieldsForType = async (atId: string) => { + if (!atId) { + setFields([]); + return; + } + setLoadingFields(true); + try { + const res = await getFieldDefinitions(atId); + const fieldList = res || []; + setFields(fieldList); + setFieldCountMap((prev) => ({ ...prev, [atId]: fieldList.length })); + } catch (e) { + console.error('Failed loading field definitions', e); + setFields([]); + } finally { + setLoadingFields(false); + } + }; + + useEffect(() => { + if (selectedActionTypeId) { + fetchFieldsForType(selectedActionTypeId); + } else { + setFields([]); + } + }, [selectedActionTypeId]); + + const showSuccess = (msg: string) => { + setSuccessMsg(msg); + setTimeout(() => setSuccessMsg(null), 3000); + }; + + // ─── Category CRUD Handlers ─────────────────────────────────────────────── + const handleOpenAddCategory = () => { + setEditingCategory(null); + setCategoryFormData({ + code: '', + name: '', + description: '', + displayOrder: categories.length + 1, + isActive: true, + }); + setCategoryError(null); + setIsCategoryModalOpen(true); + }; + + const handleOpenEditCategory = (cat: ActionCategory, e?: React.MouseEvent) => { + if (e) e.stopPropagation(); + setEditingCategory(cat); + setCategoryFormData({ + code: cat.code, + name: cat.name, + description: cat.description || '', + displayOrder: cat.displayOrder || 1, + isActive: cat.isActive !== false, + }); + setCategoryError(null); + setIsCategoryModalOpen(true); + }; + + const handleSaveCategory = async (e: React.FormEvent) => { + e.preventDefault(); + setCategoryError(null); + + if (!categoryFormData.name.trim()) { + setCategoryError('Category Name is required.'); + return; + } + if (!categoryFormData.code.trim()) { + setCategoryError('Category Code is required.'); + return; + } + + try { + if (editingCategory) { + await updateActionCategory(editingCategory.id, categoryFormData); + showSuccess(`Category "${categoryFormData.name}" updated successfully.`); + } else { + const created = await createActionCategory(categoryFormData); + showSuccess(`Category "${categoryFormData.name}" created successfully.`); + if (created?.id) setSelectedCategoryId(created.id); + } + setIsCategoryModalOpen(false); + await fetchCategoriesAndTypes(); + } catch (err: any) { + setCategoryError(err.message || 'Failed to save category.'); + } + }; + + const handleConfirmDeleteCategory = async () => { + if (!deleteCategoryTarget) return; + setDeleteCategoryError(null); + const hasChildTypes = actionTypes.some((t) => t.categoryId === deleteCategoryTarget.id); + if (hasChildTypes) { + setDeleteCategoryError('Cannot delete category because it has associated Action Types.'); + return; + } + + try { + await deleteActionCategory(deleteCategoryTarget.id); + showSuccess(`Category deleted successfully.`); + setDeleteCategoryTarget(null); + await fetchCategoriesAndTypes(); + } catch (err: any) { + setDeleteCategoryError(err.message || 'Failed to delete category.'); + } + }; + + // ─── Action Type CRUD Handlers ──────────────────────────────────────────── + const handleOpenAddType = () => { + setEditingType(null); + setTypeFormData({ + categoryId: selectedCategoryId || (categories[0]?.id ?? ''), + code: '', + name: '', + description: '', + displayOrder: actionTypes.length + 1, + isActive: true, + }); + setTypeError(null); + setIsTypeModalOpen(true); + }; + + const handleOpenEditType = (t: ActionType, e?: React.MouseEvent) => { + if (e) e.stopPropagation(); + setEditingType(t); + setTypeFormData({ + categoryId: t.categoryId, + code: t.code, + name: t.name, + description: t.description || '', + displayOrder: t.displayOrder || 1, + isActive: t.isActive !== false, + }); + setTypeError(null); + setIsTypeModalOpen(true); + }; + + const handleSaveType = async (e: React.FormEvent) => { + e.preventDefault(); + setTypeError(null); + + if (!typeFormData.categoryId) { + setTypeError('Please select a Category.'); + return; + } + if (!typeFormData.name.trim()) { + setTypeError('Action Type Name is required.'); + return; + } + if (!typeFormData.code.trim()) { + setTypeError('Action Type Code is required.'); + return; + } + + try { + if (editingType) { + await updateActionType(editingType.id, typeFormData); + showSuccess(`Action Type "${typeFormData.name}" updated successfully.`); + } else { + const created = await createActionType(typeFormData); + showSuccess(`Action Type "${typeFormData.name}" created successfully.`); + if (created?.id) setSelectedActionTypeId(created.id); + } + setIsTypeModalOpen(false); + await fetchCategoriesAndTypes(); + } catch (err: any) { + setTypeError(err.message || 'Failed to save action type.'); + } + }; + + const handleConfirmDeleteType = async () => { + if (!deleteTypeTarget) return; + try { + await deleteActionType(deleteTypeTarget.id); + showSuccess('Action Type deleted successfully.'); + setDeleteTypeTarget(null); + await fetchCategoriesAndTypes(); + } catch (err: any) { + alert(err.message || 'Failed to delete action type.'); + } + }; + + // ─── Field Definition CRUD Handlers ─────────────────────────────────────── + const handleOpenAddField = () => { + if (!selectedActionTypeId) { + alert('Please select an Action Type first.'); + return; + } + setEditingField(null); + setFieldFormData({ + fieldCode: '', + fieldName: '', + fieldType: 'textbox' as FieldType, + lookupSource: '', + isRequired: false, + defaultValue: '', + placeholder: '', + helpText: '', + width: 'full', + section: 'General Information', + displayOrder: fields.length + 1, + isActive: true, + validationJson: { min: undefined, max: undefined, regex: '' }, + visibilityConditionJson: { field: '', operator: 'equals', value: '' }, + }); + setFieldError(null); + setIsFieldModalOpen(true); + }; + + const handleOpenEditField = (f: FieldDefinition) => { + setEditingField(f); + setFieldFormData({ + fieldCode: f.fieldCode, + fieldName: f.fieldName, + fieldType: f.fieldType, + lookupSource: f.lookupSource || '', + isRequired: f.isRequired, + defaultValue: f.defaultValue || '', + placeholder: f.placeholder || '', + helpText: f.helpText || '', + width: f.width || 'full', + section: f.section || 'General Information', + displayOrder: f.displayOrder || 1, + isActive: f.isActive !== false, + validationJson: f.validationJson || { min: undefined, max: undefined, regex: '' }, + visibilityConditionJson: f.visibilityConditionJson || { field: '', operator: 'equals', value: '' }, + }); + setFieldError(null); + setIsFieldModalOpen(true); + }; + + const handleSaveField = async (e: React.FormEvent) => { + e.preventDefault(); + setFieldError(null); + + if (!fieldFormData.fieldName.trim()) { + setFieldError('Field Name is required.'); + return; + } + if (!fieldFormData.fieldCode.trim()) { + setFieldError('Field Code is required.'); + return; + } + + const payloadData: FieldDefinitionFormData = { + ...fieldFormData, + fieldCode: fieldFormData.fieldCode.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'), + validationJson: + fieldFormData.validationJson?.min !== undefined || + fieldFormData.validationJson?.max !== undefined || + fieldFormData.validationJson?.regex + ? fieldFormData.validationJson + : undefined, + visibilityConditionJson: + fieldFormData.visibilityConditionJson?.field && fieldFormData.visibilityConditionJson?.value + ? fieldFormData.visibilityConditionJson + : undefined, + }; + + try { + if (editingField) { + await updateFieldDefinition(editingField.id, payloadData); + showSuccess(`Field "${fieldFormData.fieldName}" updated successfully.`); + } else { + await createFieldDefinition(selectedActionTypeId, payloadData); + showSuccess(`Field "${fieldFormData.fieldName}" created successfully.`); + } + setIsFieldModalOpen(false); + await fetchFieldsForType(selectedActionTypeId); + } catch (err: any) { + setFieldError(err.message || 'Failed to save field definition.'); + } + }; + + const handleConfirmDeleteField = async () => { + if (!deleteFieldTarget) return; + try { + await deleteFieldDefinition(deleteFieldTarget.id); + showSuccess('Field definition deleted successfully.'); + setDeleteFieldTarget(null); + await fetchFieldsForType(selectedActionTypeId); + } catch (err: any) { + alert(err.message || 'Failed to delete field definition.'); + } + }; + + const handleReorderFields = async (fromIndex: number, toIndex: number) => { + if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return; + const newFields = [...fields]; + const [movedItem] = newFields.splice(fromIndex, 1); + newFields.splice(toIndex, 0, movedItem); + + const updatedFields = newFields.map((f, idx) => ({ ...f, displayOrder: idx + 1 })); + setFields(updatedFields); + + try { + await reorderFieldDefinitions( + selectedActionTypeId, + updatedFields.map((f) => f.id) + ); + showSuccess('Fields reordered successfully.'); + } catch (err: any) { + console.error('Failed to persist field order:', err); + fetchFieldsForType(selectedActionTypeId); + } + }; + + return ( +
+ {/* Top Banner / Notification */} + {successMsg && ( +
+ + {successMsg} +
+ )} + + {/* 3-Column Drilldown Layout */} + {loadingCategories && categories.length === 0 ? ( +
+ +
+ ) : ( +
+ {/* COLUMN 1: CATEGORIES */} +
+ { + e.stopPropagation(); + setDeleteCategoryTarget(cat); + }} + /> +
+ + {/* COLUMN 2: ACTION TYPES */} +
+ { + e.stopPropagation(); + setDeleteTypeTarget(type); + }} + /> +
+ + {/* COLUMN 3: CONFIGURATION FIELDS */} +
+ +
+
+ + )} + + {/* ─── MODALS ───────────────────────────────────────────────────────────── */} + setIsCategoryModalOpen(false)} + onSubmit={handleSaveCategory} + /> + + setIsTypeModalOpen(false)} + onSubmit={handleSaveType} + /> + + setIsFieldModalOpen(false)} + onSubmit={handleSaveField} + /> + + {/* ─── CONFIRMATION DELETE MODALS ──────────────────────────────────────── */} + {deleteCategoryTarget && ( + { + setDeleteCategoryTarget(null); + setDeleteCategoryError(null); + }} + confirmText="Delete Category" + cancelText="Cancel" + variant="danger" + /> + )} + + {deleteTypeTarget && ( + setDeleteTypeTarget(null)} + confirmText="Delete Action Type" + cancelText="Cancel" + variant="danger" + /> + )} + + {deleteFieldTarget && ( + setDeleteFieldTarget(null)} + confirmText="Delete Field Definition" + cancelText="Cancel" + variant="danger" + /> + )} +
+ ); +} diff --git a/src/app/configuration/index.tsx b/src/app/configuration/index.tsx new file mode 100644 index 0000000..1523461 --- /dev/null +++ b/src/app/configuration/index.tsx @@ -0,0 +1,38 @@ +import { useSearchParams } from 'react-router-dom'; +import ActionBuilderPage from './actionBuilder'; +import MasterDataManagement from './masterData'; +import { CustomTabs } from '../../components/custom'; + +export default function ConfigurationPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const currentTabParam = searchParams.get('tab'); + + const activeTab = currentTabParam === 'master-data' ? 'master-data' : 'action-builder'; + + const handleTabChange = (tabId: string) => { + setSearchParams({ tab: tabId }); + }; + + const tabs = [ + { + id: 'action-builder', + label: 'Action Builder', + content: , + }, + { + id: 'master-data', + label: 'Master Data Configurations', + content: , + }, + ]; + + return ( +
+ +
+ ); +} diff --git a/src/app/masterData/MasterDataApi.ts b/src/app/configuration/masterData/MasterDataApi.ts similarity index 98% rename from src/app/masterData/MasterDataApi.ts rename to src/app/configuration/masterData/MasterDataApi.ts index 1b9804d..c773604 100644 --- a/src/app/masterData/MasterDataApi.ts +++ b/src/app/configuration/masterData/MasterDataApi.ts @@ -1,4 +1,4 @@ -import { ApiClient } from '../api/ApiClient'; +import { ApiClient } from '../../api/ApiClient'; import type { MasterDataCategoryItem, MasterDataItem, diff --git a/src/app/masterData/MasterDataTypes.ts b/src/app/configuration/masterData/MasterDataTypes.ts similarity index 100% rename from src/app/masterData/MasterDataTypes.ts rename to src/app/configuration/masterData/MasterDataTypes.ts diff --git a/src/app/configuration/masterData/components/CategorySidebar.tsx b/src/app/configuration/masterData/components/CategorySidebar.tsx new file mode 100644 index 0000000..7bfa628 --- /dev/null +++ b/src/app/configuration/masterData/components/CategorySidebar.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { ListBulletsIcon, MagnifyingGlassIcon, DatabaseIcon } from '@phosphor-icons/react'; +import { CustomInput, CustomLoader } from '../../../../components/custom'; +import type { MasterDataCategoryItem } from '../MasterDataTypes'; + +interface CategorySidebarProps { + filteredCategories: MasterDataCategoryItem[]; + selectedCategory: MasterDataCategoryItem | null; + onSelectCategory: (category: MasterDataCategoryItem) => void; + loading: boolean; + searchQuery: string; + onSearchChange: (query: string) => void; +} + +export const CategorySidebar: React.FC = ({ + filteredCategories, + selectedCategory, + onSelectCategory, + loading, + searchQuery, + onSearchChange, +}) => { + return ( +
+
+

+ + Master Categories +

+ + {filteredCategories.length} + +
+ + {/* Search Input */} + onSearchChange(e.target.value)} + placeholder="Search category name or code..." + leftIcon={} + className="!bg-[#F8FAFC] !rounded-[12px] !h-[38px] !border-[#E2E8F0] text-[13px]" + /> + + {/* Categories Selector List */} + {loading ? ( +
+ +
+ ) : filteredCategories.length === 0 ? ( +
+ No matching master categories found. +
+ ) : ( +
+ {filteredCategories.map((category) => { + const isSelected = selectedCategory?.code === category.code; + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/src/app/masterData/components/MasterItemFormModal.tsx b/src/app/configuration/masterData/components/MasterItemFormModal.tsx similarity index 57% rename from src/app/masterData/components/MasterItemFormModal.tsx rename to src/app/configuration/masterData/components/MasterItemFormModal.tsx index 62c374e..3d7c5ec 100644 --- a/src/app/masterData/components/MasterItemFormModal.tsx +++ b/src/app/configuration/masterData/components/MasterItemFormModal.tsx @@ -5,7 +5,7 @@ import { CustomInput, CustomSwitch, CustomButton, -} from '../../../components/custom'; +} from '../../../../components/custom'; import type { MasterDataCategoryItem, MasterDataItem, @@ -37,8 +37,12 @@ export const MasterItemFormModal: React.FC = ({ {errorMsg && ( @@ -51,7 +55,7 @@ export const MasterItemFormModal: React.FC = ({
= ({ setFormData((prev) => ({ ...prev, value: val, - code: editingItem ? prev.code : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'), + code: editingItem ? prev.code : val.toLowerCase().trim().replace(/[^a-z0-9_]+/g, '_'), })); }} - placeholder="e.g. Economy Class" + placeholder="e.g. Platinum Tier / USD" />
-
- - setFormData((prev) => ({ ...prev, code: e.target.value }))} - placeholder="e.g. economy_class" - /> +
+
+ + setFormData((prev) => ({ ...prev, code: e.target.value }))} + placeholder="e.g. platinum_tier" + /> +
+ +
+ + + setFormData((prev) => ({ + ...prev, + displayOrder: parseInt(e.target.value) || 1, + })) + } + placeholder="1" + /> +
-
- - - setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 })) - } - /> -
- -
-
- Active +
+
+ Active Status setFormData((prev) => ({ ...prev, isActive: e.target.checked }))} @@ -101,7 +115,7 @@ export const MasterItemFormModal: React.FC = ({ variant="outlined" type="button" onClick={onClose} - className="!border-gray-300 !text-gray-600 px-4" + className="!border-gray-300 !text-gray-600 font-semibold px-5" > Cancel @@ -109,7 +123,7 @@ export const MasterItemFormModal: React.FC = ({ {editingItem ? 'Save Changes' : 'Create Item'} diff --git a/src/app/configuration/masterData/components/MasterItemTable.tsx b/src/app/configuration/masterData/components/MasterItemTable.tsx new file mode 100644 index 0000000..4a7a236 --- /dev/null +++ b/src/app/configuration/masterData/components/MasterItemTable.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { + PlusIcon, + MagnifyingGlassIcon, + PencilSimpleIcon, + TrashIcon, + CaretLeftIcon, + CaretRightIcon, +} from '@phosphor-icons/react'; +import { + CustomButton, + CustomInput, + CustomLoader, + CustomStatus, +} from '../../../../components/custom'; +import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes'; + +interface MasterItemTableProps { + selectedCategory: MasterDataCategoryItem | null; + filteredItems: MasterDataItem[]; + paginatedItems: MasterDataItem[]; + loading: boolean; + searchQuery: string; + onSearchChange: (query: string) => void; + currentPage: number; + totalPages: number; + pageSize: number; + onPageChange: (page: number) => void; + onOpenCreate: () => void; + onOpenEdit: (item: MasterDataItem) => void; + onOpenDelete: (item: MasterDataItem) => void; +} + +export const MasterItemTable: React.FC = ({ + selectedCategory, + filteredItems, + paginatedItems, + loading, + searchQuery, + onSearchChange, + currentPage, + totalPages, + pageSize, + onPageChange, + onOpenCreate, + onOpenEdit, + onOpenDelete, +}) => { + const startIndex = filteredItems.length > 0 ? (currentPage - 1) * pageSize + 1 : 0; + const endIndex = Math.min(currentPage * pageSize, filteredItems.length); + + return ( +
+
+ {/* Panel Header */} +
+
+

+ {selectedCategory ? `${selectedCategory.name} Values` : 'Category Items'} +

+

+ {selectedCategory?.description || 'View and manage configured entries for this category.'} +

+
+ + } + className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] !gap-[8px] !h-[40px] font-semibold text-[14px] shrink-0" + > + Add Master Value + +
+ + {/* Search Bar */} +
+ onSearchChange(e.target.value)} + placeholder="Search items by value or code..." + leftIcon={} + className="!bg-[#F8FAFC] !rounded-[12px] !h-[38px] !border-[#E2E8F0] text-[13px]" + /> +
+ + {/* Items Table */} + {loading ? ( +
+ +
+ ) : !selectedCategory ? ( +
+ Select a master category from the left sidebar to view items. +
+ ) : ( +
+ + + + + + + + + + + + {paginatedItems.length === 0 ? ( + + + + ) : ( + paginatedItems.map((item, idx) => ( + + + + + + + + + + + + )) + )} + +
SeqValue / LabelCodeStatusActions
+ No items found in {selectedCategory.name}. Click "Add Master Value" to create one. +
+ #{item.displayOrder || idx + 1} + + {item.value || item.label || item.name} + + {item.code || '—'} + + + +
+ + + +
+
+
+ )} +
+ + {/* Pagination Footer Bar */} + {selectedCategory && ( +
+
+ Showing {filteredItems.length > 0 ? startIndex : 0} to{' '} + {endIndex} of {filteredItems.length} items +
+ + {totalPages > 1 && ( +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} + + +
+ )} +
+ )} +
+ ); +}; diff --git a/src/app/masterData/components/index.ts b/src/app/configuration/masterData/components/index.ts similarity index 75% rename from src/app/masterData/components/index.ts rename to src/app/configuration/masterData/components/index.ts index 839641c..999120d 100644 --- a/src/app/masterData/components/index.ts +++ b/src/app/configuration/masterData/components/index.ts @@ -1,4 +1,4 @@ -export { MasterDataHeader } from './MasterDataHeader'; export { CategorySidebar } from './CategorySidebar'; export { MasterItemTable } from './MasterItemTable'; export { MasterItemFormModal } from './MasterItemFormModal'; + diff --git a/src/app/masterData/index.tsx b/src/app/configuration/masterData/index.tsx similarity index 97% rename from src/app/masterData/index.tsx rename to src/app/configuration/masterData/index.tsx index 510bc31..f7f70db 100644 --- a/src/app/masterData/index.tsx +++ b/src/app/configuration/masterData/index.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { CheckCircleIcon } from '@phosphor-icons/react'; -import { CustomConfirmationModal } from '../../components/custom'; +import { CustomConfirmationModal } from '../../../components/custom'; import { getMasterCategories, getCategoryValues, @@ -14,7 +14,6 @@ import type { MasterDataValueFormData, } from './MasterDataTypes'; import { - MasterDataHeader, CategorySidebar, MasterItemTable, MasterItemFormModal, @@ -22,6 +21,7 @@ import { const PAGE_SIZE = 10; + export default function MasterDataManagement() { const [categories, setCategories] = useState([]); const [loadingCategories, setLoadingCategories] = useState(true); @@ -196,12 +196,6 @@ export default function MasterDataManagement() { return (
- {/* Top Header Component */} - - {/* Success Notification Banner */} {successMsg && (
diff --git a/src/app/masterData/components/CategorySidebar.tsx b/src/app/masterData/components/CategorySidebar.tsx deleted file mode 100644 index 2a325e7..0000000 --- a/src/app/masterData/components/CategorySidebar.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react'; -import { ListBulletsIcon, MagnifyingGlassIcon, DatabaseIcon } from '@phosphor-icons/react'; -import { CustomInput, CustomLoader } from '../../../components/custom'; -import type { MasterDataCategoryItem } from '../MasterDataTypes'; - -interface CategorySidebarProps { - filteredCategories: MasterDataCategoryItem[]; - selectedCategory: MasterDataCategoryItem | null; - onSelectCategory: (category: MasterDataCategoryItem) => void; - loading: boolean; - searchQuery: string; - onSearchChange: (query: string) => void; -} - -export const CategorySidebar: React.FC = ({ - filteredCategories, - selectedCategory, - onSelectCategory, - loading, - searchQuery, - onSearchChange, -}) => { - return ( -
-
-

- - Master Categories -

- - {filteredCategories.length} Categories - -
- - {/* Search Category */} - onSearchChange(e.target.value)} - placeholder="Search categories..." - leftIcon={} - size="sm" - /> - - {/* Category List */} - {loading ? ( -
- -
- ) : filteredCategories.length === 0 ? ( -
- No matching categories found. -
- ) : ( -
- {filteredCategories.map((cat) => { - const isSelected = selectedCategory?.code === cat.code; - return ( - - ); - })} -
- )} -
- ); -}; diff --git a/src/app/masterData/components/MasterDataHeader.tsx b/src/app/masterData/components/MasterDataHeader.tsx deleted file mode 100644 index f0fa1c5..0000000 --- a/src/app/masterData/components/MasterDataHeader.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { GearIcon } from '@phosphor-icons/react'; - -interface MasterDataHeaderProps { - categoriesCount: number; - itemsCount: number; -} - -export const MasterDataHeader: React.FC = ({ - categoriesCount, - itemsCount, -}) => { - return ( -
-
-
- -
-
-

Master Data Configurations

-

- Manage system rule categories, lookup values, and master data parameters for AeroResolve -

-
-
- - {/* Stats Pills */} -
-
- - Categories - - {categoriesCount} -
- -
- - Loaded Items - - {itemsCount} -
-
-
- ); -}; diff --git a/src/app/masterData/components/MasterItemTable.tsx b/src/app/masterData/components/MasterItemTable.tsx deleted file mode 100644 index 7bc27b3..0000000 --- a/src/app/masterData/components/MasterItemTable.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React from 'react'; -import { - PlusIcon, - MagnifyingGlassIcon, - PencilSimpleIcon, - TrashIcon, - CaretLeftIcon, - CaretRightIcon, -} from '@phosphor-icons/react'; -import { - CustomButton, - CustomInput, - CustomLoader, - CustomStatus, -} from '../../../components/custom'; -import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes'; - -interface MasterItemTableProps { - selectedCategory: MasterDataCategoryItem | null; - filteredItems: MasterDataItem[]; - paginatedItems: MasterDataItem[]; - loading: boolean; - searchQuery: string; - onSearchChange: (query: string) => void; - currentPage: number; - totalPages: number; - pageSize: number; - onPageChange: (pageUpdater: (prev: number) => number) => void; - onOpenCreate: () => void; - onOpenEdit: (item: MasterDataItem) => void; - onOpenDelete: (item: MasterDataItem) => void; -} - -export const MasterItemTable: React.FC = ({ - selectedCategory, - filteredItems, - paginatedItems, - loading, - searchQuery, - onSearchChange, - currentPage, - totalPages, - pageSize, - onPageChange, - onOpenCreate, - onOpenEdit, - onOpenDelete, -}) => { - return ( -
-
- {/* Header for Selected Category */} -
-
-

- {selectedCategory ? selectedCategory.name : 'Select a Category'} -

-

- {selectedCategory - ? `Code: ${selectedCategory.code} | Table: ${selectedCategory.tableName || 'masters'}` - : ''} -

-
- - } - className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] text-[13px] font-semibold" - > - Add Master Item - -
- - {/* Filter Search Bar */} -
- onSearchChange(e.target.value)} - placeholder={`Search ${selectedCategory?.name || 'items'}...`} - leftIcon={} - size="sm" - /> -
- - {/* Items Table */} - {loading ? ( -
- -
- ) : filteredItems.length === 0 ? ( -
-

No master data items found in this category.

-

Click "Add Master Item" above to create an entry.

-
- ) : ( -
- - - - - - - - - - - - {paginatedItems.map((item, idx) => { - const displayVal = item.value || item.label || item.name || ''; - const displayCode = - item.code || displayVal.toLowerCase().replace(/[^a-z0-9_]+/g, '_'); - const orderNum = item.displayOrder || (currentPage - 1) * pageSize + idx + 1; - - return ( - - - - - - - - - - - - ); - })} - -
#Label / ValueCode KeyStatusActions
- #{orderNum} - - {displayVal} - - - {displayCode} - - - - -
- - -
-
-
- )} -
- - {/* Pagination Footer */} - {filteredItems.length > 0 && ( -
- - Showing {Math.min((currentPage - 1) * pageSize + 1, filteredItems.length)} to{' '} - {Math.min(currentPage * pageSize, filteredItems.length)} of {filteredItems.length} entries - - -
- - - {currentPage} / {totalPages} - - -
-
- )} -
- ); -}; diff --git a/src/app/recoveryIncidents/RecoveryIncidentsApi.ts b/src/app/recoveryIncidents/RecoveryIncidentsApi.ts index 27f2452..9f064ee 100644 --- a/src/app/recoveryIncidents/RecoveryIncidentsApi.ts +++ b/src/app/recoveryIncidents/RecoveryIncidentsApi.ts @@ -1,10 +1,54 @@ import { ApiClient } from '../api/ApiClient'; -import type { RecoveryIncident } from './RecoveryIncidentsTypes'; +import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes'; + + export function getRecoveryIncidents(): Promise { return ApiClient.get('/recovery-incidents'); } +export function getRecoveryMetrics(): Promise { + return ApiClient.get('/recovery-incidents/metrics') + .catch(() => [ + { + id: 'total-recoveries', + title: "Total Recoveries", + value: "1,284", + trendValue: "40%", + trendText: "since last week", + trendType: "positive", + sparklineColor: "green", + }, + { + id: 'pending-approval', + title: "Pending Approval", + value: "274", + trendValue: "High Priority", + trendText: "since last week", + trendType: "positive", + sparklineColor: "green", + }, + { + id: 'refund-value', + title: "Refund Value", + value: "$412k", + trendValue: "MTD", + trendText: "since last week", + trendType: "negative", + sparklineColor: "red", + }, + { + id: 'customer-satisfaction', + title: "Customer Satisfaction", + value: "94%", + trendValue: "+2.1%", + trendText: "since last week", + trendType: "positive", + sparklineColor: "green", + }, + ]); +} + export function getRecoveryIncident(id: string): Promise { return ApiClient.get(`/recovery-incidents/${id}`); } diff --git a/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts b/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts index 492f526..22b6d4d 100644 --- a/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts +++ b/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts @@ -12,8 +12,20 @@ export interface RecoveryIncident { flightNumber: string; flightRoute: string; category?: string; - statuses: IncidentStatus[]; + statuses?: IncidentStatus[]; + status?: string; value: string; isPerksClaimed?: boolean; isGroupHeader?: boolean; } + +export interface MetricCardData { + id?: string; + title: string; + value?: string; + trendText: string; + trendValue: string; + trendType: "positive" | "negative" | "neutral"; + sparklineColor: "green" | "red"; +} + diff --git a/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx b/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx index 6466242..5858fa1 100644 --- a/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx +++ b/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx @@ -7,6 +7,7 @@ import { CustomCheckBox, } from "../../../components/custom"; import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi'; +import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi'; import type { RecoveryIncident } from '../RecoveryIncidentsTypes'; interface AddRecoveryIncidentsProps { @@ -19,6 +20,9 @@ const SECTION_TITLE_CLASS = "flex items-center gap-2 mb-4 text-[#4A5568] font-bo const SECTION_CONTAINER_CLASS = "bg-[#F9FAFB] rounded-[14px] p-5 border border-gray-100"; export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddRecoveryIncidentsProps) { + const [loyaltyTierOptions, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]); + const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>([]); + const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>([]); const [formData, setFormData] = useState({ passengerName: "", pnr: "", @@ -34,13 +38,70 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR isPerksClaimed: false, }); + useEffect(() => { + if (isOpen) { + // 1. Fetch Loyalty Tier Master Data + getMembershipTiers() + .then((items) => { + if (Array.isArray(items) && items.length > 0) { + const activeOptions = items + .filter((m) => m.isActive !== false) + .map((m) => ({ + label: m.label || m.value, + value: m.value || m.id || m.label, + })); + if (activeOptions.length > 0) { + setLoyaltyTierOptions(activeOptions); + } + } + }) + .catch((err) => { + console.error("Failed to fetch loyalty tier master data:", err); + }); + + // 2. Fetch Jurisdiction Master Data + getCategoryValues('jurisdiction') + .then((items) => { + if (Array.isArray(items) && items.length > 0) { + const activeOptions = items + .filter((m) => m.isActive !== false) + .map((m) => ({ + label: m.label || m.name || m.value, + value: m.value || m.code || m.id || m.label, + })); + if (activeOptions.length > 0) { + setJurisdictionOptions(activeOptions); + } + } + }) + .catch(() => { }); + + // 3. Fetch Scenario Master Data + getCategoryValues('flight-disruption-type') + .then((items) => { + if (Array.isArray(items) && items.length > 0) { + const activeOptions = items + .filter((m) => m.isActive !== false) + .map((m) => ({ + label: m.label || m.name || m.value, + value: m.value || m.code || m.id || m.label, + })); + if (activeOptions.length > 0) { + setScenarioOptions(activeOptions); + } + } + }) + .catch(() => { }); + } + }, [isOpen]); + useEffect(() => { if (incident && isOpen) { const [origin, destination] = incident.flightRoute ? incident.flightRoute.split(' → ') : ["", ""]; setFormData({ passengerName: incident.passengerName || "", pnr: incident.pnr || "", - loyaltyTier: "", + loyaltyTier: (incident as any).loyaltyTier || "", flightNumber: incident.flightNumber || "", date: incident.date ? incident.date.split('T')[0] : "", origin: origin?.trim() || "", @@ -83,18 +144,18 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR setLoading(true); try { const payload = { - recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 10000), + recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 10000), passengerName: formData.passengerName || "Unknown", pnr: formData.pnr || "N/A", flightNumber: formData.flightNumber || "TBD", flightRoute: `${formData.origin || 'UNK'} → ${formData.destination || 'UNK'}`, date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(), category: formData.category || "General", - statuses: incident ? incident.statuses : [{ text: "New", variant: "info" as const }], + status: incident ? (incident.status || "Pending") : "Pending", value: incident ? incident.value : "$0", isPerksClaimed: formData.isPerksClaimed, }; - + console.log("Submitting payload:", payload); if (incident) { await updateRecoveryIncident(incident.id, payload); @@ -150,14 +211,10 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR /> handleInputChange("loyaltyTier", val as string)} - options={[ - { label: "Gold", value: "gold" }, - { label: "Silver", value: "silver" }, - { label: "Bronze", value: "bronze" }, - ]} + options={loyaltyTierOptions} />
@@ -235,21 +292,14 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR placeholder="Selected Option" value={formData.scenario} onChange={(val) => handleInputChange("scenario", val as string)} - options={[ - { label: "Delay", value: "delay" }, - { label: "Cancellation", value: "cancellation" }, - { label: "Denied Boarding", value: "denied_boarding" }, - ]} + options={scenarioOptions} /> handleInputChange("jurisdiction", val as string)} - options={[ - { label: "EU261", value: "eu261" }, - { label: "US DOT", value: "us_dot" }, - ]} + options={jurisdictionOptions} /> +
+ {title} +
+
+
+ {value && ( + + {value} + + )} +
+ + {trendValue} + + + {trendText} + +
+
+ + {/* Simple SVG Sparkline placeholder based on color */} +
+ {sparklineColor === "green" ? ( + + + + ) : ( + + + + )} +
+
+
+ ); +} + +export default MetricCard; diff --git a/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx b/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx index 133b4ed..40e3d1d 100644 --- a/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx +++ b/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx @@ -1,17 +1,17 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { - Plus, - MagnifyingGlass, - CaretUp, - CaretDown, - FadersHorizontal, - SquaresFour, - Eye, - PencilSimple, - CheckCircle, - XCircle, - Clock, + EyeIcon, + PencilSimpleIcon, + CheckCircleIcon, + XCircleIcon, + ClockIcon, + FunnelSimpleIcon, + SquaresFourIcon, + PlusIcon, + CaretDownIcon, + CaretUpIcon, + MagnifyingGlassIcon, } from "@phosphor-icons/react"; import { CustomTable, @@ -24,9 +24,11 @@ import { CustomActionItem, } from "../../../components/custom"; import type { Column } from "../../../components/custom/CustomTable"; -import type { RecoveryIncident } from "../RecoveryIncidentsTypes"; +import type { RecoveryIncident, MetricCardData } from "../RecoveryIncidentsTypes"; import AddRecoveryIncidents from "./AddRecoveryIncidents"; -import { getRecoveryIncidents } from "../RecoveryIncidentsApi"; +import { MetricCard } from "./MetricCard"; +import { getRecoveryIncidents, getRecoveryMetrics } from "../RecoveryIncidentsApi"; +import { formatDate } from "../../../utils/formatDate"; const PAGE_SIZE = 10; @@ -71,94 +73,14 @@ function BadgeLabel({ text }: { text: string }) { ); } -// ─── Metric Card Component ─────────────────────────────────────────────────── - -interface MetricCardProps { - title: string; - value?: string; - trendText: string; - trendValue: string; - trendType: "positive" | "negative" | "neutral"; - sparklineColor: "green" | "red"; -} - -function MetricCard({ - title, - value, - trendText, - trendValue, - trendType, - sparklineColor, -}: MetricCardProps) { - return ( -
-
- {title} -
-
-
- {value && ( - - {value} - - )} -
- - {trendValue} - - - {trendText} - -
-
- - {/* Simple SVG Sparkline placeholder based on color */} -
- {sparklineColor === "green" ? ( - - - - ) : ( - - - - )} -
-
-
- ); +function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" | "brand" { + if (!status) return "neutral"; + const s = status.toLowerCase(); + if (s.includes("appr") || s.includes("active") || s.includes("success")) return "success"; + if (s.includes("reject") || s.includes("denied") || s.includes("error")) return "error"; + if (s.includes("pend") || s.includes("review") || s.includes("warn")) return "warning"; + if (s.includes("new") || s.includes("info")) return "info"; + return "neutral"; } // ─── Component ─────────────────────────────────────────────────────────────── @@ -166,12 +88,17 @@ function MetricCard({ export default function RecoveryIncidentsList() { const navigate = useNavigate(); const [incidents, setIncidents] = useState([]); + const [metrics, setMetrics] = useState([]); const [loading, setLoading] = useState(true); + useEffect(() => { + getRecoveryMetrics() + .then((data) => setMetrics(data)) + .catch((err) => console.error("Failed to fetch recovery metrics:", err)); + }, []); + // Pagination const [currentPage, setCurrentPage] = useState(1); - const [totalItems, setTotalItems] = useState(0); - const [totalPages, setTotalPages] = useState(1); // Filters const [search, setSearch] = useState(""); @@ -185,41 +112,29 @@ export default function RecoveryIncidentsList() { // ─── Fetch data ───────────────────────────────────────────── - const fetchIncidents = useCallback( - async (page: number) => { - setLoading(true); - - try { - const data = await getRecoveryIncidents(); - - const filteredData = data.filter((p) => { - const matchesSearch = p.recoveryCode.toLowerCase().includes(search.toLowerCase()) || - p.flightNumber.toLowerCase().includes(search.toLowerCase()); - const matchesGroup = isGrouped ? true : !p.isGroupHeader; - return matchesSearch && matchesGroup; - }); - - const total = filteredData.length; - const pages = Math.ceil(total / PAGE_SIZE); - const start = (page - 1) * PAGE_SIZE; - const paginatedData = filteredData.slice(start, start + PAGE_SIZE); - - setIncidents(paginatedData); - setTotalItems(total); - setTotalPages(pages || 1); - } catch (error) { - console.error("Failed to fetch recovery incidents", error); - setIncidents([]); - } finally { - setLoading(false); - } - }, - [search, isGrouped], - ); + const fetchIncidents = useCallback(async () => { + setLoading(true); + try { + const data = await getRecoveryIncidents(); + setIncidents(data); + } catch (error) { + console.error("Failed to fetch recovery incidents", error); + setIncidents([]); + } finally { + setLoading(false); + } + }, []); useEffect(() => { - fetchIncidents(currentPage); - }, [currentPage, search, isGrouped, fetchIncidents]); + fetchIncidents(); + }, [fetchIncidents]); + + useEffect(() => { + if (incidents.length > 0) { + const keys = new Set(incidents.map((i) => i.flightNumber || "Other")); + setCollapsedGroups(keys); + } + }, [incidents]); // ─── Handlers ────────────────────────────────────────────────────────────── @@ -250,40 +165,123 @@ export default function RecoveryIncidentsList() { setSelectedIds(newSelected); }; - const toggleGroup = (id: string, e: React.MouseEvent) => { - e.stopPropagation(); + const toggleGroup = (groupKey: string, e?: React.MouseEvent) => { + if (e) e.stopPropagation(); setCollapsedGroups((prev) => { const next = new Set(prev); - if (next.has(id)) { - next.delete(id); + if (next.has(groupKey)) { + next.delete(groupKey); } else { - next.add(id); + next.add(groupKey); } return next; }); }; - const handleStatusChange = async (incident: RecoveryIncident, text: string, variant: "success" | "error" | "warning" | "info" | "neutral" | "brand") => { + const handleStatusChange = async (incident: RecoveryIncident, text: string) => { try { const { updateRecoveryIncident } = await import('../RecoveryIncidentsApi'); await updateRecoveryIncident(incident.id, { - statuses: [{ text, variant }] + status: text }); - fetchIncidents(currentPage); + fetchIncidents(); } catch (error) { console.error("Failed to update status", error); } }; + const filteredIncidents = useMemo(() => { + return incidents.filter((p) => { + const q = search.toLowerCase(); + return ( + (p.recoveryCode || "").toLowerCase().includes(q) || + (p.flightNumber || "").toLowerCase().includes(q) || + (p.passengerName || "").toLowerCase().includes(q) || + (p.pnr || "").toLowerCase().includes(q) + ); + }); + }, [incidents, search]); + + const displayData = useMemo(() => { + if (!isGrouped) { + return filteredIncidents; + } + + // Group items by flight number + const groupMap = new Map(); + filteredIncidents.forEach((item) => { + const key = item.flightNumber || "Other"; + if (!groupMap.has(key)) { + groupMap.set(key, []); + } + groupMap.get(key)!.push(item); + }); + + const result: (RecoveryIncident & { groupKey?: string })[] = []; + + groupMap.forEach((groupItems, flightNo) => { + const firstItem = groupItems[0]; + + // Calculate status summary for group header + const pendingCount = groupItems.filter(i => { + const s = (i.status || "").toLowerCase(); + return s.includes("pending") || s.includes("review") || s.includes("new"); + }).length; + const approvedCount = groupItems.filter(i => { + const s = (i.status || "").toLowerCase(); + return s.includes("approved") || s.includes("active"); + }).length; + + const headerStatuses = [ + { text: `${pendingCount} Pending`, variant: "warning" as const }, + { text: `${approvedCount} Approved`, variant: "success" as const }, + ]; + + // Calculate sum of values + const sumVal = groupItems.reduce((acc, curr) => { + const num = parseFloat((curr.value || "").replace(/[^0-9.]/g, "")) || 0; + return acc + num; + }, 0); + + const groupKey = flightNo; + + // Group Header Row + result.push({ + id: `header-${groupKey}`, + recoveryCode: firstItem.recoveryCode, + date: firstItem.date, + flightNumber: flightNo, + flightRoute: firstItem.flightRoute, + category: firstItem.category, + statuses: headerStatuses, + value: `$${sumVal}`, + isGroupHeader: true, + groupKey: groupKey, + }); + + // Child Rows (if group is expanded) + if (!collapsedGroups.has(groupKey)) { + groupItems.forEach((child) => { + result.push({ + ...child, + groupKey: groupKey, + }); + }); + } + }); + + return result; + }, [filteredIncidents, isGrouped, collapsedGroups]); + + const totalItems = displayData.length; + const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1; const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0; const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems); - const displayData = useMemo(() => { - if (!isGrouped) return incidents; - return incidents.filter( - (p) => p.isGroupHeader || !collapsedGroups.has(p.recoveryCode) - ); - }, [incidents, isGrouped, collapsedGroups]); + const paginatedDisplayData = useMemo(() => { + const start = (currentPage - 1) * PAGE_SIZE; + return displayData.slice(start, start + PAGE_SIZE); + }, [displayData, currentPage]); // ─── Table columns ───────────────────────────────────────────────────────── @@ -311,7 +309,7 @@ export default function RecoveryIncidentsList() { accessor: (row) => (
- +
), }, @@ -319,7 +317,7 @@ export default function RecoveryIncidentsList() { header: ( } + rightIcon={} /> ), accessor: (row) => @@ -334,7 +332,7 @@ export default function RecoveryIncidentsList() { header: ( } + rightIcon={} /> ), accessor: (row) => ( @@ -351,23 +349,34 @@ export default function RecoveryIncidentsList() { }, { header: , - accessor: (row) => ( -
- {row.statuses.map((status, idx) => ( - - ))} -
- ), + accessor: (row) => { + if (row.isGroupHeader) { + return ( +
+ {(row.statuses || []).map((status, idx) => ( + + ))} +
+ ); + } + const statusText = row.status || "Pending"; + return ( + + ); + }, }, { header: ( } + rightIcon={} /> ), accessor: (row) => , @@ -382,62 +391,90 @@ export default function RecoveryIncidentsList() { { header: , className: "text-right", - accessor: (row) => ( -
- {row.isGroupHeader ? ( -
toggleGroup(row.recoveryCode, e)} - > - {collapsedGroups.has(row.recoveryCode) ? ( - - ) : ( - - )} -
- ) : ( - - navigate(`/recovery/${row.id}`)} - icon={} + accessor: (row) => { + const key = (row as any).groupKey || row.flightNumber || row.recoveryCode; + return ( +
+ {row.isGroupHeader ? ( +
toggleGroup(key, e)} > - View Details - - { - setEditingIncident(row); - setIsModalOpen(true); - }} - icon={} - > - Edit Incident - - } - onClick={() => handleStatusChange(row, "Approved", "success")} - > - Approve - - } - onClick={() => handleStatusChange(row, "Rejected", "error")} - > - Reject - - } - onClick={() => handleStatusChange(row, "Under Review", "warning")} - > - Mark for Review - - - )} -
- ), + {collapsedGroups.has(key) ? ( + + ) : ( + + )} +
+ ) : ( + + navigate(`/recovery/${row.id}`)} + icon={} + > + View Details + + { + setEditingIncident(row); + setIsModalOpen(true); + }} + icon={} + > + Edit Incident + + } + onClick={() => handleStatusChange(row, "Approved")} + > + Approve + + } + onClick={() => handleStatusChange(row, "Rejected")} + > + Reject + + } + onClick={() => handleStatusChange(row, "Under Review")} + > + Mark for Review + + + )} +
+ ); + }, }, ]; + const isAnyGroupExpanded = useMemo(() => { + if (!isGrouped) return true; + const allGroupKeys = Array.from( + new Set(incidents.map((i) => i.flightNumber || "Other")) + ); + return allGroupKeys.some((key) => !collapsedGroups.has(key)); + }, [isGrouped, incidents, collapsedGroups]); + + const activeColumns = useMemo(() => { + if (!isGrouped || isAnyGroupExpanded) { + return columns; + } + return columns.filter((col) => { + const headerText = + typeof col.header === "object" && col.header !== null && "props" in col.header + ? (col.header as any).props.text + : ""; + return ( + headerText !== "Passenger / PNR" && + headerText !== "Category" && + headerText !== "Perks Claimed" + ); + }); + }, [columns, isGrouped, isAnyGroupExpanded]); + // ─── Loading skeleton ────────────────────────────────────────────────────── if (loading) { @@ -464,52 +501,22 @@ export default function RecoveryIncidentsList() {
{/* Metrics Row */}
- - - - + {metrics.map((metric) => ( + + ))}
{/* Table Section */} - columns={columns} - data={displayData} + columns={activeColumns} + data={paginatedDisplayData} leftHeaderActions={
handleSearchChange(e.target.value)} - leftIcon={} + leftIcon={} className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]" containerClassName="!gap-0" /> @@ -520,15 +527,7 @@ export default function RecoveryIncidentsList() { } - className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5" - > - Filters - - } + leftIcon={} className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5" onClick={() => setIsGrouped(!isGrouped)} > @@ -537,7 +536,7 @@ export default function RecoveryIncidentsList() { } + leftIcon={} className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" onClick={() => { setEditingIncident(null); @@ -554,20 +553,27 @@ export default function RecoveryIncidentsList() { startIndex={startIndex} endIndex={endIndex} onPageChange={handlePageChange} - itemName="Policies" - onRowClick={(row) => !row.isGroupHeader && navigate(`/recovery/${row.id}`)} - rowClassName={(row) => row.isGroupHeader ? "bg-white" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"} + itemName="Recovery Incidents" + onRowClick={(row) => { + if (row.isGroupHeader) { + const key = (row as any).groupKey || row.flightNumber || row.recoveryCode; + toggleGroup(key); + } else { + navigate(`/recovery/${row.id}`); + } + }} + rowClassName={(row) => row.isGroupHeader ? "bg-white border-b border-gray-100 hover:bg-gray-50/60" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"} /> - + {/* Modal */} - { setIsModalOpen(false); setEditingIncident(null); - fetchIncidents(currentPage); // Refresh list - }} + fetchIncidents(); // Refresh list + }} />
); diff --git a/src/layout/AppHeader.tsx b/src/layout/AppHeader.tsx index 4880572..fe8f96d 100644 --- a/src/layout/AppHeader.tsx +++ b/src/layout/AppHeader.tsx @@ -6,8 +6,8 @@ const PAGE_META: Record = { '/cohorts': { title: 'Cohort Management', subtitle: 'Dynamic passenger segmentation for targeted recovery and recovery intelligence.' }, '/policy-engine': { title: 'Policy Engine Framework Registry', subtitle: 'Manage framework policies, conditions, and automated actions.' }, '/policy-engine/add': { title: 'Deploy New Policy', subtitle: 'Configure policy framework details, targeting rules, and action payloads.' }, - '/action-builder': { title: 'Action Builder', subtitle: 'Configure dynamic categories, action types, metadata fields, and dynamic user form workflows.' }, - '/config': { title: 'Master Data Configurations', subtitle: 'Manage system rule categories, lookup values, and master data parameters.' }, + '/action-builder': { title: 'Configuration - Action Builder', subtitle: 'Configure dynamic categories, action types, metadata fields, and dynamic user form workflows.' }, + '/config': { title: 'Configuration', subtitle: 'Manage dynamic action workflows, categories, metadata fields, and system master data.' }, '/recovery': { title: 'Recovery Incidents', subtitle: 'Operational workspace for managing passenger disruption cases.' }, }; diff --git a/src/layout/AppSidebar.tsx b/src/layout/AppSidebar.tsx index 783c2ca..ef272aa 100644 --- a/src/layout/AppSidebar.tsx +++ b/src/layout/AppSidebar.tsx @@ -11,7 +11,6 @@ import { GearIcon, ClockCounterClockwiseIcon, CaretDoubleRightIcon, - LightningIcon, } from "@phosphor-icons/react"; import { ShieldCheckIcon } from "lucide-react"; @@ -21,7 +20,6 @@ const NAV_ITEMS = [ { label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon }, { label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon }, { label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon }, - { label: "Action Builder", path: "/action-builder", icon: LightningIcon }, { label: "Configuration", path: "/config", icon: GearIcon }, { label: "Audit Logs", path: "/audit", icon: ClockCounterClockwiseIcon }, ]; @@ -111,11 +109,10 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) { onClick={() => { if (window.innerWidth < 1024) onClose(); }} - className={`group flex items-center ${isCollapsed ? "justify-center px-0 w-12 mx-auto" : "gap-3 px-3.5"} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${ - isActive + className={`group flex items-center ${isCollapsed ? "justify-center px-0 w-12 mx-auto" : "gap-3 px-3.5"} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${isActive ? "bg-gradient-to-b from-primary to-primary-dark text-white shadow-md shadow-primary/20 font-semibold" : "text-[#475569] font-medium hover:bg-slate-200/40 hover:text-slate-900" - }`} + }`} > = 12 ? 'pm' : 'am'; + hours = hours % 12; + hours = hours ? hours : 12; + + return `${day} ${month} ${year}, ${hours}:${minutes}${ampm}`; +} + +export default formatDate;