# Dynamic Multi-Step Template System ## Enhanced Admin Template Configuration with Dynamic User Field References ## Overview This document outlines an enhanced template system that allows admins to: 1. **Create multi-step forms** (not just single-step) 2. **Dynamically reference user types** (initiator, dealer, approvers, team lead, etc.) 3. **Configure user details capture** per step 4. **Auto-populate user fields** based on workflow participants --- ## Enhanced Database Schema ### 1. Enhanced `workflow_templates` Table ```sql -- Migration: 20251210-enhance-workflow-templates.ts ALTER TABLE workflow_templates ADD COLUMN IF NOT EXISTS form_steps_config JSONB, ADD COLUMN IF NOT EXISTS user_field_mappings JSONB, ADD COLUMN IF NOT EXISTS dynamic_approver_config JSONB; -- Example structure: -- form_steps_config: Array of step definitions -- user_field_mappings: Maps user roles to field references -- dynamic_approver_config: Configuration for dynamic approver selection ``` ### 2. Template Form Steps Configuration ```typescript // Structure for form_steps_config JSONB interface FormStepConfig { stepNumber: number; stepName: string; stepDescription?: string; fields: FormFieldConfig[]; validationRules?: ValidationRule[]; conditionalLogic?: ConditionalLogic[]; userReferences?: UserReferenceConfig[]; } interface FormFieldConfig { fieldId: string; // Unique identifier fieldType: 'text' | 'textarea' | 'select' | 'date' | 'number' | 'file' | 'user_picker' | 'dealer_picker' | 'approver_picker'; label: string; placeholder?: string; required: boolean; defaultValue?: any; options?: Array<{ value: string; label: string }>; // For select fields validation?: { min?: number; max?: number; pattern?: string; customValidator?: string; }; userReference?: { role: 'initiator' | 'dealer' | 'approver' | 'team_lead' | 'department_lead' | 'current_approver' | 'previous_approver'; level?: number; // For approver: which approval level field: 'name' | 'email' | 'phone' | 'department' | 'employee_id' | 'all'; // Which user field to reference autoPopulate: boolean; // Auto-fill from user data editable: boolean; // Can user edit the auto-populated value }; visibility?: { dependsOn?: string; // Field ID this depends on condition: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than'; value: any; }; } interface UserReferenceConfig { role: string; captureFields: string[]; // Which fields to capture: ['name', 'email', 'phone', 'department'] autoPopulateFrom: 'workflow' | 'user_profile' | 'approval_level'; // Source of user data allowOverride: boolean; // Can user override auto-populated values } ``` ### 3. Dynamic Approver Configuration ```typescript interface DynamicApproverConfig { enabled: boolean; approverSelection: { mode: 'static' | 'dynamic' | 'role_based' | 'department_based'; staticApprovers?: Array<{ level: number; userId?: string; userEmail?: string; role?: string; }>; dynamicRules?: Array<{ level: number; selectionCriteria: { type: 'role' | 'department' | 'manager' | 'custom'; value: string; fallback?: string; }; captureUserDetails: boolean; // Capture approver details in form captureFields: string[]; // Which fields to capture }>; }; approverDetailsInSteps: Array<{ stepNumber: number; approverLevel: number; captureFields: string[]; displayMode: 'readonly' | 'editable' | 'hidden'; }>; } ``` --- ## Example Template Configurations ### Example 1: Claim Management Template (8-Step) ```json { "template_name": "Dealer Claim Management", "template_code": "CLAIM_MANAGEMENT", "workflow_type": "CLAIM_MANAGEMENT", "form_steps_config": [ { "stepNumber": 1, "stepName": "Basic Information", "stepDescription": "Capture activity and dealer information", "fields": [ { "fieldId": "activity_name", "fieldType": "text", "label": "Activity Name", "required": true, "validation": { "min": 3, "max": 500 } }, { "fieldId": "activity_type", "fieldType": "select", "label": "Activity Type", "required": true, "options": [ { "value": "RIDERS_MANIA", "label": "Riders Mania Claims" }, { "value": "MARKETING_COST", "label": "Marketing Cost – Bike to Vendor" }, { "value": "MEDIA_BIKE_SERVICE", "label": "Media Bike Service" } ] }, { "fieldId": "dealer_code", "fieldType": "dealer_picker", "label": "Dealer Code", "required": true, "userReference": { "role": "dealer", "field": "all", "autoPopulate": false, "editable": false } }, { "fieldId": "dealer_name", "fieldType": "text", "label": "Dealer Name", "required": true, "userReference": { "role": "dealer", "field": "name", "autoPopulate": true, "editable": false } }, { "fieldId": "dealer_email", "fieldType": "text", "label": "Dealer Email", "required": true, "userReference": { "role": "dealer", "field": "email", "autoPopulate": true, "editable": true } }, { "fieldId": "initiator_name", "fieldType": "text", "label": "Request Initiator", "required": false, "userReference": { "role": "initiator", "field": "name", "autoPopulate": true, "editable": false }, "visibility": { "dependsOn": null, "condition": "always_visible" } }, { "fieldId": "activity_date", "fieldType": "date", "label": "Activity Date", "required": true }, { "fieldId": "location", "fieldType": "text", "label": "Location", "required": true } ], "userReferences": [ { "role": "initiator", "captureFields": ["name", "email", "department"], "autoPopulateFrom": "user_profile", "allowOverride": false }, { "role": "dealer", "captureFields": ["name", "email", "phone", "address"], "autoPopulateFrom": "workflow", "allowOverride": true } ] }, { "stepNumber": 2, "stepName": "Dealer Proposal Submission", "stepDescription": "Dealer submits proposal with cost breakdown", "fields": [ { "fieldId": "proposal_document", "fieldType": "file", "label": "Proposal Document", "required": true, "validation": { "accept": ".pdf,.doc,.docx", "maxSize": 10485760 } }, { "fieldId": "cost_breakup", "fieldType": "textarea", "label": "Cost Breakup (JSON)", "required": true }, { "fieldId": "dealer_comments", "fieldType": "textarea", "label": "Dealer Comments", "required": true }, { "fieldId": "dealer_submitted_by", "fieldType": "text", "label": "Submitted By", "required": false, "userReference": { "role": "dealer", "field": "name", "autoPopulate": true, "editable": false } } ], "userReferences": [ { "role": "dealer", "captureFields": ["name", "email"], "autoPopulateFrom": "workflow", "allowOverride": false } ] }, { "stepNumber": 3, "stepName": "Requestor Evaluation", "stepDescription": "Initiator reviews and confirms proposal", "fields": [ { "fieldId": "evaluation_comments", "fieldType": "textarea", "label": "Evaluation Comments", "required": true }, { "fieldId": "requestor_name", "fieldType": "text", "label": "Evaluated By", "required": false, "userReference": { "role": "initiator", "field": "name", "autoPopulate": true, "editable": false } }, { "fieldId": "requestor_department", "fieldType": "text", "label": "Department", "required": false, "userReference": { "role": "initiator", "field": "department", "autoPopulate": true, "editable": false } } ], "userReferences": [ { "role": "initiator", "captureFields": ["name", "email", "department"], "autoPopulateFrom": "user_profile", "allowOverride": false } ] }, { "stepNumber": 4, "stepName": "Department Lead Approval", "stepDescription": "Department lead approves and blocks IO budget", "fields": [ { "fieldId": "io_number", "fieldType": "text", "label": "IO Number", "required": true }, { "fieldId": "amount_to_block", "fieldType": "number", "label": "Amount to Block", "required": true, "validation": { "min": 0 } }, { "fieldId": "dept_lead_name", "fieldType": "text", "label": "Approved By", "required": false, "userReference": { "role": "approver", "level": 3, "field": "name", "autoPopulate": true, "editable": false } }, { "fieldId": "dept_lead_email", "fieldType": "text", "label": "Department Lead Email", "required": false, "userReference": { "role": "approver", "level": 3, "field": "email", "autoPopulate": true, "editable": false } }, { "fieldId": "dept_lead_department", "fieldType": "text", "label": "Department", "required": false, "userReference": { "role": "approver", "level": 3, "field": "department", "autoPopulate": true, "editable": false } } ], "userReferences": [ { "role": "approver", "captureFields": ["name", "email", "department"], "autoPopulateFrom": "approval_level", "allowOverride": false } ] } // ... more steps ], "dynamic_approver_config": { "enabled": true, "approverSelection": { "mode": "role_based", "dynamicRules": [ { "level": 3, "selectionCriteria": { "type": "role", "value": "DEPARTMENT_LEAD", "fallback": "MANAGEMENT" }, "captureUserDetails": true, "captureFields": ["name", "email", "department", "employee_id"] }, { "level": 4, "selectionCriteria": { "type": "department", "value": "${initiator.department}", "fallback": null }, "captureUserDetails": true, "captureFields": ["name", "email"] } ] }, "approverDetailsInSteps": [ { "stepNumber": 4, "approverLevel": 3, "captureFields": ["name", "email", "department"], "displayMode": "readonly" } ] } } ``` ### Example 2: Simple Vendor Payment Template (Admin-Created) ```json { "template_name": "Vendor Payment Request", "template_code": "VENDOR_PAYMENT", "workflow_type": "VENDOR_PAYMENT", "form_steps_config": [ { "stepNumber": 1, "stepName": "Vendor Details", "fields": [ { "fieldId": "vendor_name", "fieldType": "text", "label": "Vendor Name", "required": true }, { "fieldId": "invoice_number", "fieldType": "text", "label": "Invoice Number", "required": true }, { "fieldId": "initiator_info", "fieldType": "text", "label": "Requested By", "required": false, "userReference": { "role": "initiator", "field": "all", "autoPopulate": true, "editable": false } } ] }, { "stepNumber": 2, "stepName": "Team Lead Approval", "fields": [ { "fieldId": "team_lead_name", "fieldType": "text", "label": "Team Lead", "required": false, "userReference": { "role": "approver", "level": 1, "field": "name", "autoPopulate": true, "editable": false } }, { "fieldId": "team_lead_approval", "fieldType": "select", "label": "Approval Status", "required": true, "options": [ { "value": "approved", "label": "Approved" }, { "value": "rejected", "label": "Rejected" } ] } ] } ], "dynamic_approver_config": { "enabled": true, "approverSelection": { "mode": "role_based", "dynamicRules": [ { "level": 1, "selectionCriteria": { "type": "role", "value": "TEAM_LEAD", "fallback": "MANAGEMENT" }, "captureUserDetails": true, "captureFields": ["name", "email", "department"] } ] } } } ``` --- ## Service Layer Implementation ### 1. Template Service with Dynamic Field Resolution ```typescript // Re_Backend/src/services/templateFieldResolver.service.ts import { WorkflowRequest } from '../models/WorkflowRequest'; import { ApprovalLevel } from '../models/ApprovalLevel'; import { User } from '../models/User'; import { Participant } from '../models/Participant'; export class TemplateFieldResolver { /** * Resolve user reference fields in a step */ async resolveUserReferences( stepConfig: FormStepConfig, request: WorkflowRequest, currentUserId: string, context?: { currentLevel?: number; approvers?: Map; } ): Promise> { const resolvedFields: Record = {}; for (const field of stepConfig.fields) { if (field.userReference) { const userData = await this.getUserDataForReference( field.userReference, request, currentUserId, context ); if (field.userReference.autoPopulate) { resolvedFields[field.fieldId] = this.extractUserField( userData, field.userReference.field ); } } } return resolvedFields; } /** * Get user data based on reference configuration */ private async getUserDataForReference( userRef: UserReference, request: WorkflowRequest, currentUserId: string, context?: any ): Promise { switch (userRef.role) { case 'initiator': return await User.findByPk(request.initiatorId); case 'dealer': // Get dealer from participants or claim details const dealerParticipant = await Participant.findOne({ where: { requestId: request.requestId, participantType: 'DEALER' }, include: [{ model: User, as: 'user' }] }); return dealerParticipant?.user || null; case 'approver': if (userRef.level && context?.approvers) { const approverLevel = context.approvers.get(userRef.level); if (approverLevel?.approverId) { return await User.findByPk(approverLevel.approverId); } } // Fallback to current approver const currentLevel = await ApprovalLevel.findOne({ where: { requestId: request.requestId, levelNumber: context?.currentLevel || request.currentLevel } }); if (currentLevel?.approverId) { return await User.findByPk(currentLevel.approverId); } return null; case 'team_lead': // Find team lead based on initiator's department or manager const initiator = await User.findByPk(request.initiatorId); if (initiator?.manager) { return await User.findOne({ where: { email: initiator.manager, role: 'MANAGEMENT' // or specific role check } }); } return null; case 'department_lead': const initiatorUser = await User.findByPk(request.initiatorId); if (initiatorUser?.department) { return await User.findOne({ where: { department: initiatorUser.department, role: 'MANAGEMENT' }, order: [['created_at', 'DESC']] }); } return null; case 'current_approver': const currentApprovalLevel = await ApprovalLevel.findOne({ where: { requestId: request.requestId, status: 'PENDING' }, order: [['level_number', 'ASC']] }); if (currentApprovalLevel?.approverId) { return await User.findByPk(currentApprovalLevel.approverId); } return null; case 'previous_approver': const previousLevel = request.currentLevel - 1; const previousApprovalLevel = await ApprovalLevel.findOne({ where: { requestId: request.requestId, levelNumber: previousLevel } }); if (previousApprovalLevel?.approverId) { return await User.findByPk(previousApprovalLevel.approverId); } return null; default: return null; } } /** * Extract specific field from user data */ private extractUserField(user: User | null, field: string): any { if (!user) return null; switch (field) { case 'name': return user.displayName || `${user.firstName} ${user.lastName}`; case 'email': return user.email; case 'phone': return user.phone || user.mobilePhone; case 'department': return user.department; case 'employee_id': return user.employeeId; case 'all': return { name: user.displayName, email: user.email, phone: user.phone || user.mobilePhone, department: user.department, employeeId: user.employeeId }; default: return null; } } /** * Resolve dynamic approver based on configuration */ async resolveDynamicApprover( level: number, config: DynamicApproverConfig, request: WorkflowRequest ): Promise { if (!config.enabled || !config.approverSelection.dynamicRules) { return null; } const rule = config.approverSelection.dynamicRules.find(r => r.level === level); if (!rule) return null; const criteria = rule.selectionCriteria; switch (criteria.type) { case 'role': return await User.findOne({ where: { role: criteria.value as any }, order: [['created_at', 'DESC']] }); case 'department': const initiator = await User.findByPk(request.initiatorId); const deptValue = criteria.value.replace('${initiator.department}', initiator?.department || ''); return await User.findOne({ where: { department: deptValue, role: 'MANAGEMENT' } }); case 'manager': const initiatorUser = await User.findByPk(request.initiatorId); if (initiatorUser?.manager) { return await User.findOne({ where: { email: initiatorUser.manager } }); } return null; default: return null; } } } ``` ### 2. Enhanced Template Service ```typescript // Re_Backend/src/services/enhancedTemplate.service.ts import { WorkflowTemplate } from '../models/WorkflowTemplate'; import { TemplateFieldResolver } from './templateFieldResolver.service'; export class EnhancedTemplateService { private fieldResolver = new TemplateFieldResolver(); /** * Get form configuration for a template with resolved user references */ async getFormConfig( templateId: string, requestId?: string, currentUserId?: string ): Promise { const template = await WorkflowTemplate.findByPk(templateId); if (!template) throw new Error('Template not found'); const stepsConfig = template.formStepsConfig as FormStepConfig[]; // If request exists, resolve user references if (requestId && currentUserId) { const request = await WorkflowRequest.findByPk(requestId); if (request) { return await this.resolveStepsWithUserData(stepsConfig, request, currentUserId); } } return stepsConfig; } /** * Resolve user references in all steps */ private async resolveStepsWithUserData( steps: FormStepConfig[], request: WorkflowRequest, currentUserId: string ): Promise { // Get all approvers for context const approvers = await ApprovalLevel.findAll({ where: { requestId: request.requestId } }); const approverMap = new Map( approvers.map(a => [a.levelNumber, a]) ); const resolvedSteps = await Promise.all( steps.map(async (step) => { const resolvedFields = await this.fieldResolver.resolveUserReferences( step, request, currentUserId, { currentLevel: request.currentLevel, approvers: approverMap } ); // Merge resolved values into field defaults const enrichedFields = step.fields.map(field => ({ ...field, defaultValue: resolvedFields[field.fieldId] || field.defaultValue })); return { ...step, fields: enrichedFields }; }) ); return resolvedSteps; } /** * Validate and save form data for a step */ async saveStepData( templateId: string, requestId: string, stepNumber: number, formData: Record, userId: string ): Promise { const template = await WorkflowTemplate.findByPk(templateId); const stepsConfig = template.formStepsConfig as FormStepConfig[]; const stepConfig = stepsConfig.find(s => s.stepNumber === stepNumber); if (!stepConfig) { throw new Error(`Step ${stepNumber} not found in template`); } // Validate required fields this.validateStepData(stepConfig, formData); // Save to template-specific storage await this.saveToTemplateStorage(template.workflowType, requestId, stepNumber, formData); } private validateStepData(stepConfig: FormStepConfig, formData: Record): void { for (const field of stepConfig.fields) { if (field.required && !formData[field.fieldId]) { throw new Error(`Field ${field.label} is required`); } // Apply validation rules if (field.validation && formData[field.fieldId]) { const value = formData[field.fieldId]; if (field.validation.min && value < field.validation.min) { throw new Error(`${field.label} must be at least ${field.validation.min}`); } if (field.validation.max && value > field.validation.max) { throw new Error(`${field.label} must be at most ${field.validation.max}`); } } } } private async saveToTemplateStorage( workflowType: string, requestId: string, stepNumber: number, formData: Record ): Promise { // Save to appropriate extension table based on workflow type switch (workflowType) { case 'CLAIM_MANAGEMENT': await this.saveClaimManagementStepData(requestId, stepNumber, formData); break; case 'VENDOR_PAYMENT': await this.saveVendorPaymentStepData(requestId, stepNumber, formData); break; default: // Generic storage for custom templates await this.saveGenericStepData(requestId, stepNumber, formData); } } private async saveClaimManagementStepData( requestId: string, stepNumber: number, formData: Record ): Promise { switch (stepNumber) { case 1: // Save to dealer_claim_details await DealerClaimDetails.upsert({ requestId, activityName: formData.activity_name, activityType: formData.activity_type, dealerCode: formData.dealer_code, dealerName: formData.dealer_name, dealerEmail: formData.dealer_email, // ... map all fields }); break; case 2: // Save to dealer_proposal_details await DealerProposalDetails.upsert({ requestId, costBreakup: formData.cost_breakup, dealerComments: formData.dealer_comments, // ... }); break; // ... other steps } } } ``` --- ## API Endpoints ### 1. Get Template Form Configuration ```typescript // GET /api/v1/templates/:templateId/form-config // Query params: ?requestId=xxx&stepNumber=1 async getTemplateFormConfig(req: Request, res: Response) { const { templateId } = req.params; const { requestId, stepNumber } = req.query; const userId = req.user?.userId; const templateService = new EnhancedTemplateService(); const formConfig = await templateService.getFormConfig( templateId, requestId as string, userId ); // Filter by step if specified const steps = stepNumber ? formConfig.filter(s => s.stepNumber === parseInt(stepNumber as string)) : formConfig; return ResponseHandler.success(res, { steps }); } ``` ### 2. Save Step Data ```typescript // POST /api/v1/templates/:templateId/requests/:requestId/steps/:stepNumber async saveStepData(req: AuthenticatedRequest, res: Response) { const { templateId, requestId, stepNumber } = req.params; const formData = req.body; const userId = req.user?.userId; const templateService = new EnhancedTemplateService(); await templateService.saveStepData( templateId, requestId, parseInt(stepNumber), formData, userId ); return ResponseHandler.success(res, { message: 'Step data saved' }); } ``` ### 3. Get Resolved User Data ```typescript // GET /api/v1/templates/:templateId/user-references // Query: ?role=approver&level=3&requestId=xxx async getUserReferenceData(req: Request, res: Response) { const { templateId } = req.params; const { role, level, requestId } = req.query; const template = await WorkflowTemplate.findByPk(templateId); const request = requestId ? await WorkflowRequest.findByPk(requestId as string) : null; const fieldResolver = new TemplateFieldResolver(); const userData = await fieldResolver.getUserDataForReference( { role: role as string, level: level ? parseInt(level as string) : undefined, field: 'all', autoPopulate: true, editable: false }, request!, req.user?.userId || '' ); return ResponseHandler.success(res, { userData }); } ``` --- ## Frontend Integration ### 1. Dynamic Form Renderer ```typescript // Frontend: components/DynamicFormRenderer.tsx interface DynamicFormRendererProps { stepConfig: FormStepConfig; formData: Record; onChange: (fieldId: string, value: any) => void; userReferences?: Record; } export function DynamicFormRenderer({ stepConfig, formData, onChange, userReferences }: DynamicFormRendererProps) { return (
{stepConfig.fields.map(field => { // Auto-populate from user references const value = userReferences?.[field.fieldId] || formData[field.fieldId] || field.defaultValue; return (
{field.userReference?.autoPopulate && userReferences?.[field.fieldId] && ( Auto-filled from {field.userReference.role} )} {renderField(field, value, onChange, field.userReference?.editable !== false)}
); })}
); } ``` ### 2. Multi-Step Wizard ```typescript // Frontend: components/TemplateWizard.tsx export function TemplateWizard({ templateId, requestId }: Props) { const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState>({}); const [userReferences, setUserReferences] = useState>({}); // Fetch form config with resolved user references const { data: formConfig } = useQuery( ['template-form-config', templateId, requestId], () => api.getTemplateFormConfig(templateId, { requestId }) ); // Auto-populate user references when step changes useEffect(() => { if (formConfig?.steps) { const step = formConfig.steps.find(s => s.stepNumber === currentStep); if (step?.userReferences) { // Fetch user reference data step.userReferences.forEach(ref => { api.getUserReferenceData(templateId, { role: ref.role, level: ref.level, requestId }).then(data => { setUserReferences(prev => ({ ...prev, ...data.userData })); }); }); } } }, [currentStep, formConfig]); const handleSubmit = async () => { await api.saveStepData(templateId, requestId, currentStep, formData); // Move to next step or submit }; return ( {formConfig?.steps.map(step => ( { setFormData(prev => ({ ...prev, [fieldId]: value })); }} userReferences={userReferences} /> ))} ); } ``` --- ## Admin UI for Template Creation ### Template Builder Component ```typescript // Frontend: admin/TemplateBuilder.tsx export function TemplateBuilder() { const [steps, setSteps] = useState([]); const [currentStep, setCurrentStep] = useState(1); const addField = (stepNumber: number) => { const newField: FormFieldConfig = { fieldId: `field_${Date.now()}`, fieldType: 'text', label: 'New Field', required: false }; setSteps(prev => prev.map(step => step.stepNumber === stepNumber ? { ...step, fields: [...step.fields, newField] } : step )); }; const configureUserReference = (stepNumber: number, fieldId: string) => { // Open modal to configure user reference // Allow admin to select: // - Role (initiator, dealer, approver, team_lead, etc.) // - Level (for approver) // - Field to capture (name, email, all, etc.) // - Auto-populate toggle // - Editable toggle }; return (
{/* ... */}} onAddField={addField} onConfigureUserReference={configureUserReference} />
); } ``` --- ## Summary This enhanced system provides: 1. ✅ **Multi-step forms** - Admins can configure multiple steps per template 2. ✅ **Dynamic user references** - Fields can auto-populate from initiator, dealer, approvers, team lead, etc. 3. ✅ **Flexible approver selection** - Dynamic approver assignment based on role, department, manager 4. ✅ **Auto-population** - User details automatically filled based on workflow context 5. ✅ **Extensibility** - Easy to add new user reference types 6. ✅ **Admin-friendly** - Visual template builder for creating templates 7. ✅ **Type-safe** - Structured configuration with validation The system automatically: - Captures initiator details when request is created - Captures dealer details when dealer is selected - Captures approver details when approver is assigned (dynamically or statically) - Updates fields when approvers change - Supports team lead, department lead, and custom role references