1198 lines
33 KiB
Markdown
1198 lines
33 KiB
Markdown
# 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<number, ApprovalLevel>;
|
||
}
|
||
): Promise<Record<string, any>> {
|
||
const resolvedFields: Record<string, any> = {};
|
||
|
||
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<User | null> {
|
||
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<User | null> {
|
||
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<FormStepConfig[]> {
|
||
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<FormStepConfig[]> {
|
||
// 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<string, any>,
|
||
userId: string
|
||
): Promise<void> {
|
||
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<string, any>): 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<string, any>
|
||
): Promise<void> {
|
||
// 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<string, any>
|
||
): Promise<void> {
|
||
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<string, any>;
|
||
onChange: (fieldId: string, value: any) => void;
|
||
userReferences?: Record<string, any>;
|
||
}
|
||
|
||
export function DynamicFormRenderer({
|
||
stepConfig,
|
||
formData,
|
||
onChange,
|
||
userReferences
|
||
}: DynamicFormRendererProps) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{stepConfig.fields.map(field => {
|
||
// Auto-populate from user references
|
||
const value = userReferences?.[field.fieldId] || formData[field.fieldId] || field.defaultValue;
|
||
|
||
return (
|
||
<div key={field.fieldId}>
|
||
<Label>{field.label}</Label>
|
||
{field.userReference?.autoPopulate && userReferences?.[field.fieldId] && (
|
||
<Badge variant="info">Auto-filled from {field.userReference.role}</Badge>
|
||
)}
|
||
{renderField(field, value, onChange, field.userReference?.editable !== false)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 2. Multi-Step Wizard
|
||
|
||
```typescript
|
||
// Frontend: components/TemplateWizard.tsx
|
||
|
||
export function TemplateWizard({ templateId, requestId }: Props) {
|
||
const [currentStep, setCurrentStep] = useState(1);
|
||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||
const [userReferences, setUserReferences] = useState<Record<string, any>>({});
|
||
|
||
// 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 (
|
||
<Wizard currentStep={currentStep} totalSteps={formConfig?.steps.length || 0}>
|
||
{formConfig?.steps.map(step => (
|
||
<WizardStep key={step.stepNumber} stepNumber={step.stepNumber}>
|
||
<DynamicFormRenderer
|
||
stepConfig={step}
|
||
formData={formData}
|
||
onChange={(fieldId, value) => {
|
||
setFormData(prev => ({ ...prev, [fieldId]: value }));
|
||
}}
|
||
userReferences={userReferences}
|
||
/>
|
||
</WizardStep>
|
||
))}
|
||
</Wizard>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Admin UI for Template Creation
|
||
|
||
### Template Builder Component
|
||
|
||
```typescript
|
||
// Frontend: admin/TemplateBuilder.tsx
|
||
|
||
export function TemplateBuilder() {
|
||
const [steps, setSteps] = useState<FormStepConfig[]>([]);
|
||
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 (
|
||
<div>
|
||
<StepBuilder
|
||
steps={steps}
|
||
onAddStep={() => {/* ... */}}
|
||
onAddField={addField}
|
||
onConfigureUserReference={configureUserReference}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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
|
||
|