diff --git a/app/[locale]/(admin)/admin/settings/tenant/page.tsx b/app/[locale]/(admin)/admin/settings/tenant/page.tsx new file mode 100644 index 0000000..d07b0bd --- /dev/null +++ b/app/[locale]/(admin)/admin/settings/tenant/page.tsx @@ -0,0 +1,530 @@ +"use client"; +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { PlusIcon, SettingsIcon, UsersIcon, WorkflowIcon } from "@/components/icons"; +import { ApprovalWorkflowForm } from "@/components/form/ApprovalWorkflowForm"; +import { UserLevelsForm } from "@/components/form/UserLevelsForm"; +import { WorkflowModalProvider } from "@/components/modals/WorkflowModalProvider"; +import { useWorkflowStatusCheck } from "@/hooks/useWorkflowStatusCheck"; +import { + CreateApprovalWorkflowWithClientSettingsRequest, + UserLevelsCreateRequest, + UserLevel, + getUserLevels, + getApprovalWorkflows, +} from "@/service/approval-workflows"; + +function TenantSettingsContent() { + const [activeTab, setActiveTab] = useState("workflows"); + const [isUserLevelDialogOpen, setIsUserLevelDialogOpen] = useState(false); + const [workflow, setWorkflow] = useState(null); + const [userLevels, setUserLevels] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isEditingWorkflow, setIsEditingWorkflow] = useState(false); + const { checkWorkflowStatus } = useWorkflowStatusCheck(); + + // Load data on component mount + React.useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setIsLoading(true); + try { + const [workflowsRes, userLevelsRes] = await Promise.all([ + getApprovalWorkflows(), + getUserLevels(), + ]); + + if (!workflowsRes?.error) { + const workflows = workflowsRes?.data || []; + // Set the first workflow as the single workflow for this tenant + setWorkflow(workflows.length > 0 ? workflows[0] : null); + } + if (!userLevelsRes?.error) { + setUserLevels(userLevelsRes?.data?.data || []); + } + } catch (error) { + console.error("Error loading data:", error); + } finally { + setIsLoading(false); + } + }; + + const handleWorkflowSave = async (data: CreateApprovalWorkflowWithClientSettingsRequest) => { + setIsEditingWorkflow(false); + await loadData(); // Reload data after saving + }; + + const handleUserLevelSave = async (data: UserLevelsCreateRequest) => { + setIsUserLevelDialogOpen(false); + await loadData(); // Reload data after saving + }; + + const handleBulkUserLevelSave = async (data: UserLevelsCreateRequest[]) => { + setIsUserLevelDialogOpen(false); + await loadData(); // Reload data after saving + }; + + return ( +
+
+
+

Tenant Settings

+

+ Manage approval workflows and user levels for your tenant +

+
+
+ + +
+
+ + + + + + Approval Workflows + + + + User Levels + + + + {/* Approval Workflows Tab */} + +
+

Approval Workflow Setup

+ {workflow && !isEditingWorkflow && ( + + )} +
+ + {isEditingWorkflow ? ( + + + + Setup Approval Workflow + + + + + setIsEditingWorkflow(false)} + /> + + + ) : workflow ? ( + + + + {workflow.name} +
+ {workflow.isDefault && ( + + Default + + )} + {workflow.isActive ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ +

+ {workflow.description} +

+ +
+
+
{workflow.steps?.length || 0}
+
Workflow Steps
+
+ +
+
+ {workflow.requiresApproval ? 'Yes' : 'No'} +
+
Requires Approval
+
+ +
+
+ {workflow.autoPublish ? 'Yes' : 'No'} +
+
Auto Publish
+
+ +
+
+ {workflow.clientApprovalSettings?.approvalExemptUsers?.length || 0} +
+
Exempt Users
+
+
+ + {/* Workflow Steps Overview */} + {workflow.steps && workflow.steps.length > 0 && ( +
+

Workflow Steps

+
+ {workflow.steps.map((step: any, index: number) => ( +
+
+
+ {step.stepOrder} +
+
+
{step.stepName}
+
+ {step.conditionType && `Condition: ${step.conditionType}`} + {step.autoApproveAfterHours && ` • Auto-approve after ${step.autoApproveAfterHours}h`} +
+
+
+
+ {step.canSkip && ( + + Can Skip + + )} + {step.isParallel && ( + + Parallel + + )} +
+
+ ))} +
+
+ )} + + {/* Client Settings Overview */} + {workflow.clientApprovalSettings && ( +
+

Client Approval Settings

+
+
+
Exempt Users
+
+ {workflow.clientApprovalSettings.approvalExemptUsers?.length || 0} users +
+
+
+
Exempt Roles
+
+ {workflow.clientApprovalSettings.approvalExemptRoles?.length || 0} roles +
+
+
+
Exempt Categories
+
+ {workflow.clientApprovalSettings.approvalExemptCategories?.length || 0} categories +
+
+
+
Content Types
+
+ {workflow.clientApprovalSettings.requireApprovalFor?.length || 0} requiring approval +
+
+
+
+ )} +
+
+ ) : ( + + +
+ +

No Workflow Configured

+

+ Set up your approval workflow to manage content approval process +

+ +
+
+
+ )} +
+ + {/* User Levels Tab */} + +
+

User Levels

+ + + + + + + Create New User Level + + setIsUserLevelDialogOpen(false)} + /> + + +
+ + {/* User Levels Summary */} + {userLevels.length > 0 && ( +
+
+
{userLevels.length}
+
Total User Levels
+
+ +
+
+ {userLevels.filter(ul => ul.isActive).length} +
+
Active Levels
+
+ +
+
+ {userLevels.filter(ul => ul.isApprovalActive).length} +
+
Approval Active
+
+ +
+
+ {userLevels.filter(ul => ul.parentLevelId).length} +
+
Child Levels
+
+
+ )} + + {/* User Levels Hierarchy */} + {userLevels.length > 0 && ( + + + + + User Levels Hierarchy + + + +
+ {userLevels + .filter(ul => !ul.parentLevelId) // Root levels + .sort((a, b) => a.levelNumber - b.levelNumber) + .map(rootLevel => ( +
+ {/* Root Level */} +
+
+ {rootLevel.levelNumber} +
+
+
{rootLevel.name}
+
+ {rootLevel.aliasName} • {rootLevel.group || 'No group'} +
+
+
+ {rootLevel.isActive && ( + + Active + + )} + {rootLevel.isApprovalActive && ( + + Approval Active + + )} +
+
+ + {/* Child Levels */} + {userLevels + .filter(ul => ul.parentLevelId === rootLevel.id) + .sort((a, b) => a.levelNumber - b.levelNumber) + .map(childLevel => ( +
+
+ {childLevel.levelNumber} +
+
+
{childLevel.name}
+
+ {childLevel.aliasName} • {childLevel.group || 'No group'} +
+
+
+ {childLevel.isActive && ( + + Active + + )} + {childLevel.isApprovalActive && ( + + Approval + + )} +
+
+ ))} +
+ ))} +
+
+
+ )} + +
+ {userLevels.length > 0 ? userLevels.map((userLevel) => ( + + + + {userLevel.name} +
+ + Level {userLevel.levelNumber} + + {userLevel.isActive ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ +
+
+ Alias: + + {userLevel.aliasName} + +
+ + {userLevel.group && ( +
+ Group: + {userLevel.group} +
+ )} + +
+ Approval Active: + + {userLevel.isApprovalActive ? 'Yes' : 'No'} + +
+ + {userLevel.parentLevelId && ( +
+ Parent Level: + + {userLevels.find(ul => ul.id === userLevel.parentLevelId)?.name || `Level ${userLevel.parentLevelId}`} + +
+ )} + + {userLevel.provinceId && ( +
+ Province: + Province {userLevel.provinceId} +
+ )} + +
+ Created: + + {userLevel.createdAt ? new Date(userLevel.createdAt).toLocaleDateString() : 'N/A'} + +
+
+ +
+ + +
+
+
+ )) : ''} +
+ + {userLevels.length === 0 && !isLoading && ( + + +
+ +

No User Levels Found

+

+ Create your first user level to define approval hierarchy +

+ +
+
+
+ )} +
+
+
+ ); +} + +export default function TenantSettingsPage() { + return ( + + + + ); +} diff --git a/components/form/ApprovalWorkflowForm.tsx b/components/form/ApprovalWorkflowForm.tsx new file mode 100644 index 0000000..289b5f2 --- /dev/null +++ b/components/form/ApprovalWorkflowForm.tsx @@ -0,0 +1,626 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { SaveIcon, EyeIcon, RotateCcwIcon } from "@/components/icons"; +import { FormField } from "./common/FormField"; +import { DynamicArray } from "./common/DynamicArray"; +import { MultiSelect } from "./common/MultiSelect"; +import { + CreateApprovalWorkflowWithClientSettingsRequest, + ApprovalWorkflowStepRequest, + ClientApprovalSettingsRequest, + UserLevel, + User, + UserRole, + ArticleCategory, + createApprovalWorkflowWithClientSettings, + getUserLevels, + getUsers, + getUserRoles, + getArticleCategories, +} from "@/service/approval-workflows"; +import Swal from "sweetalert2"; + +interface ApprovalWorkflowFormProps { + initialData?: CreateApprovalWorkflowWithClientSettingsRequest; + onSave?: (data: CreateApprovalWorkflowWithClientSettingsRequest) => void; + onCancel?: () => void; + isLoading?: boolean; +} + +export const ApprovalWorkflowForm: React.FC = ({ + initialData, + onSave, + onCancel, + isLoading = false, +}) => { + // Form state + const [formData, setFormData] = useState({ + name: "", + description: "", + isActive: true, + isDefault: true, + requiresApproval: true, + autoPublish: false, + steps: [], + clientApprovalSettings: { + requiresApproval: true, + autoPublishArticles: false, + approvalExemptUsers: [], + approvalExemptRoles: [], + approvalExemptCategories: [], + requireApprovalFor: [], + skipApprovalFor: [], + isActive: true, + }, + }); + + // API data + const [userLevels, setUserLevels] = useState([]); + const [users, setUsers] = useState([]); + const [userRoles, setUserRoles] = useState([]); + const [articleCategories, setArticleCategories] = useState([]); + + // UI state + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [expandedSteps, setExpandedSteps] = useState>(new Set()); + const [isLoadingData, setIsLoadingData] = useState(true); + + // Get available user levels for a specific step (excluding already selected ones) + const getAvailableUserLevels = (currentStepIndex: number) => { + const usedLevelIds = new Set(); + + // Collect all user level IDs that are already used by other steps + formData.steps.forEach((step, stepIndex) => { + if (stepIndex !== currentStepIndex && step.conditionValue) { + try { + const conditionData = JSON.parse(step.conditionValue); + if (conditionData.applies_to_levels && Array.isArray(conditionData.applies_to_levels)) { + conditionData.applies_to_levels.forEach((levelId: number) => { + usedLevelIds.add(levelId); + }); + } + } catch (error) { + console.error('Error parsing conditionValue:', error); + } + } + }); + + // Filter out used levels and return available ones + return userLevels.filter(level => !usedLevelIds.has(level.id)); + }; + + // Load initial data + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + // Load API data + useEffect(() => { + const loadData = async () => { + try { + const [userLevelsRes, usersRes, userRolesRes, categoriesRes] = await Promise.all([ + getUserLevels(), + getUsers(), + getUserRoles(), + getArticleCategories(), + ]); + + if (!userLevelsRes?.error) setUserLevels(userLevelsRes?.data?.data || []); + if (!usersRes?.error) setUsers(usersRes?.data || []); + if (!userRolesRes?.error) setUserRoles(userRolesRes?.data || []); + if (!categoriesRes?.error) setArticleCategories(categoriesRes?.data || []); + } catch (error) { + console.error("Error loading form data:", error); + } finally { + setIsLoadingData(false); + } + }; + + loadData(); + }, []); + + // Validation + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Workflow name is required"; + } else if (formData.name.trim().length < 3) { + newErrors.name = "Workflow name must be at least 3 characters"; + } + + if (!formData.description.trim()) { + newErrors.description = "Description is required"; + } else if (formData.description.trim().length < 10) { + newErrors.description = "Description must be at least 10 characters"; + } + + if (formData.steps.length === 0) { + newErrors.steps = "At least one workflow step is required"; + } + + // Validate steps + formData.steps.forEach((step, index) => { + if (!step.stepName.trim()) { + newErrors[`steps.${index}.stepName`] = "Step name is required"; + } + if (!step.requiredUserLevelId) { + newErrors[`steps.${index}.requiredUserLevelId`] = "Required user level is required"; + } + if (step.stepOrder <= 0) { + newErrors[`steps.${index}.stepOrder`] = "Step order must be greater than 0"; + } + }); + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Form handlers + const handleBasicInfoChange = (field: keyof CreateApprovalWorkflowWithClientSettingsRequest, value: any) => { + setFormData(prev => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: "" })); + } + }; + + const handleStepsChange = (steps: ApprovalWorkflowStepRequest[]) => { + // Allow manual step order assignment for parallel steps + // Don't auto-assign if user has manually set stepOrder + const updatedSteps = steps.map((step, index) => { + // If stepOrder is not set or is 0, auto-assign based on index + if (!step.stepOrder || step.stepOrder === 0) { + return { + ...step, + stepOrder: index + 1, + }; + } + // Keep existing stepOrder if manually set + return step; + }); + + setFormData(prev => ({ ...prev, steps: updatedSteps })); + }; + + const renderStepForm = (step: ApprovalWorkflowStepRequest, index: number, onUpdate: (step: ApprovalWorkflowStepRequest) => void, onDelete: () => void) => { + const stepErrors = Object.keys(errors).filter(key => key.startsWith(`steps.${index}`)); + + // Check if this step has parallel steps (same stepOrder) + const parallelSteps = formData.steps.filter((s, i) => s.stepOrder === step.stepOrder && i !== index); + const isParallelStep = parallelSteps.length > 0; + + return ( +
+ {/* Parallel Step Indicator */} + {isParallelStep && ( +
+
+
+ + Parallel Step - Order {step.stepOrder} + + + ({parallelSteps.length + 1} steps running simultaneously) + +
+
+ )} +
+ onUpdate({ ...step, stepOrder: value ? Number(value) : index + 1 })} + error={errors[`steps.${index}.stepOrder`]} + min={1} + helpText="Same order = parallel steps" + /> + + onUpdate({ ...step, stepName: value })} + error={errors[`steps.${index}.stepName`]} + required + /> + + 0 ? "Select user level" : "No user levels available"} + value={step.requiredUserLevelId} + onChange={(value) => onUpdate({ ...step, requiredUserLevelId: Number(value) })} + error={errors[`steps.${index}.requiredUserLevelId`]} + options={userLevels.length > 0 ? userLevels.map(level => ({ + value: level.id, + label: `${level.name} (Level ${level.levelNumber})`, + })) : []} + required + disabled={userLevels.length === 0} + helpText={userLevels.length === 0 ? "No user levels found. Please create user levels first." : undefined} + /> +
+ +
+ onUpdate({ ...step, autoApproveAfterHours: value ? Number(value) : undefined })} + helpText="Automatically approve after specified hours" + min={1} + /> + +
+ onUpdate({ ...step, canSkip: value })} + /> + + onUpdate({ ...step, isActive: value })} + /> +
+
+ + {/* Condition Settings */} +
+ { + const availableLevels = getAvailableUserLevels(index); + return availableLevels.length > 0 ? "Select user levels..." : "No available user levels"; + })()} + options={(() => { + const availableLevels = getAvailableUserLevels(index); + return availableLevels.map(level => ({ + value: level.id, + label: `${level.name} (Level ${level.levelNumber})`, + })); + })()} + value={(() => { + try { + return step.conditionValue ? JSON.parse(step.conditionValue).applies_to_levels || [] : []; + } catch { + return []; + } + })()} + onChange={(value) => { + const conditionValue = JSON.stringify({ applies_to_levels: value }); + onUpdate({ ...step, conditionType: "user_level_hierarchy", conditionValue }); + }} + searchable={true} + disabled={getAvailableUserLevels(index).length === 0} + helpText={(() => { + const availableLevels = getAvailableUserLevels(index); + const usedLevels = userLevels.length - availableLevels.length; + + if (availableLevels.length === 0) { + return "All user levels are already assigned to other steps"; + } else if (usedLevels > 0) { + return `${usedLevels} user level(s) already assigned to other steps. ${availableLevels.length} available.`; + } else { + return "Select which user levels this step applies to"; + } + })()} + /> +
+
+ ); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + Swal.fire({ + title: "Validation Error", + text: "Please fix the errors before submitting", + icon: "error", + confirmButtonText: "OK" + }); + return; + } + + setIsSubmitting(true); + + try { + // Hardcoded client approval settings + const hardcodedClientSettings = { + approvalExemptCategories: [], + approvalExemptRoles: [], + approvalExemptUsers: [], + autoPublishArticles: true, + isActive: true, + requireApprovalFor: [], + requiresApproval: true, + skipApprovalFor: [] + }; + + const submitData = { + ...formData, + clientApprovalSettings: hardcodedClientSettings + }; + + console.log("Submit Data: ", submitData); + + const response = await createApprovalWorkflowWithClientSettings(submitData); + + console.log("Response: ", response); + + if (response?.error) { + Swal.fire({ + title: "Error", + text: response?.message?.messages[0] || "Failed to create approval workflow", + icon: "error", + confirmButtonText: "OK" + }); + } else { + Swal.fire({ + title: "Success", + text: "Approval workflow created successfully", + icon: "success", + confirmButtonText: "OK" + }); + } + } catch (error) { + console.error("Error submitting form:", error); + Swal.fire({ + title: "Error", + text: "An unexpected error occurred", + icon: "error", + confirmButtonText: "OK" + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleReset = () => { + Swal.fire({ + title: "Reset Form", + text: "Are you sure you want to reset all form data?", + icon: "warning", + showCancelButton: true, + confirmButtonText: "Yes, reset", + cancelButtonText: "Cancel" + }).then((result) => { + if (result.isConfirmed) { + setFormData({ + name: "", + description: "", + isActive: true, + isDefault: false, + requiresApproval: true, + autoPublish: false, + steps: [], + clientApprovalSettings: { + requiresApproval: true, + autoPublishArticles: false, + approvalExemptUsers: [], + approvalExemptRoles: [], + approvalExemptCategories: [], + requireApprovalFor: [], + skipApprovalFor: [], + isActive: true, + }, + }); + setErrors({}); + } + }); + }; + + return ( +
+ {isLoadingData && ( +
+
+ Loading form data... +
+ )} + + + + Basic Information + Workflow Steps + Client Settings + + + {/* Basic Information Tab */} + + + + Workflow Basic Information + + + handleBasicInfoChange("name", value)} + error={errors.name} + required + /> + + handleBasicInfoChange("description", value)} + error={errors.description} + required + rows={4} + /> + +
+ handleBasicInfoChange("isActive", value)} + /> + + handleBasicInfoChange("isDefault", value)} + /> + + handleBasicInfoChange("requiresApproval", value)} + /> + + handleBasicInfoChange("autoPublish", value)} + /> +
+
+
+
+ + {/* Workflow Steps Tab */} + + + + + Workflow Steps Configuration +
+ {formData.steps.length} step{formData.steps.length !== 1 ? 's' : ''} + {(() => { + const parallelGroups = formData.steps.reduce((acc, step) => { + acc[step.stepOrder] = (acc[step.stepOrder] || 0) + 1; + return acc; + }, {} as Record); + const parallelCount = Object.values(parallelGroups).filter(count => count > 1).length; + return parallelCount > 0 ? ` • ${parallelCount} parallel group${parallelCount !== 1 ? 's' : ''}` : ''; + })()} +
+
+
+ + + {errors.steps && ( +

{errors.steps}

+ )} +
+
+
+ + {/* Client Settings Tab */} + + + + Client Approval Settings + + +
+
+
+ ⚙️ +
+

Settings Pre-configured

+

+ Client approval settings are automatically configured with optimal defaults. +

+
+ +
+

Default Settings:

+
    +
  • • Requires Approval: Yes
  • +
  • • Auto Publish Articles: Yes
  • +
  • • Is Active: Yes
  • +
  • • Exempt Users: None
  • +
  • • Exempt Roles: None
  • +
  • • Exempt Categories: None
  • +
+
+
+
+
+
+
+ + {/* Form Actions */} +
+
+ +
+ +
+ {onCancel && ( + + )} + + +
+
+
+ ); +}; diff --git a/components/form/UserLevelsForm.tsx b/components/form/UserLevelsForm.tsx new file mode 100644 index 0000000..510c1f7 --- /dev/null +++ b/components/form/UserLevelsForm.tsx @@ -0,0 +1,639 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ChevronDownIcon, ChevronUpIcon, SaveIcon, EyeIcon, RotateCcwIcon, UsersIcon, HierarchyIcon } from "@/components/icons"; +import { FormField } from "./common/FormField"; +import { DynamicArray } from "./common/DynamicArray"; +import { + UserLevelsCreateRequest, + UserLevel, + Province, + createUserLevel, + getUserLevels, + getProvinces, +} from "@/service/approval-workflows"; +import Swal from "sweetalert2"; + +interface UserLevelsFormProps { + initialData?: UserLevelsCreateRequest; + onSave?: (data: UserLevelsCreateRequest) => void; + onCancel?: () => void; + isLoading?: boolean; + mode?: "single" | "bulk"; +} + +export const UserLevelsForm: React.FC = ({ + initialData, + onSave, + onCancel, + isLoading = false, + mode = "single", +}) => { + // Form state + const [formData, setFormData] = useState({ + name: "", + aliasName: "", + levelNumber: 1, + parentLevelId: undefined, + provinceId: undefined, + group: "", + isApprovalActive: true, + isActive: true, + }); + + // Bulk form state + const [bulkFormData, setBulkFormData] = useState([]); + + // API data + const [userLevels, setUserLevels] = useState([]); + const [provinces, setProvinces] = useState([]); + + // UI state + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [expandedHierarchy, setExpandedHierarchy] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(true); + + // Load initial data + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + // Load API data + useEffect(() => { + const loadData = async () => { + try { + const [userLevelsRes, provincesRes] = await Promise.all([ + getUserLevels(), + getProvinces(), + ]); + + if (!userLevelsRes?.error) setUserLevels(userLevelsRes?.data || []); + if (!provincesRes?.error) setProvinces(provincesRes?.data || []); + } catch (error) { + console.error("Error loading form data:", error); + } finally { + setIsLoadingData(false); + } + }; + + loadData(); + }, []); + + // Validation + const validateForm = (data: UserLevelsCreateRequest): Record => { + const newErrors: Record = {}; + + if (!data.name.trim()) { + newErrors.name = "Level name is required"; + } else if (data.name.trim().length < 3) { + newErrors.name = "Level name must be at least 3 characters"; + } + + if (!data.aliasName.trim()) { + newErrors.aliasName = "Alias name is required"; + } else if (data.aliasName.trim().length < 3) { + newErrors.aliasName = "Alias name must be at least 3 characters"; + } else if (!/^[A-Z_]+$/.test(data.aliasName.trim())) { + newErrors.aliasName = "Alias name must contain only uppercase letters and underscores"; + } + + if (!data.levelNumber || data.levelNumber <= 0) { + newErrors.levelNumber = "Level number must be a positive integer"; + } + + // Check for duplicate level numbers + const existingLevel = userLevels.find(level => level.levelNumber === data.levelNumber); + if (existingLevel && (!initialData || existingLevel.id !== (initialData as any).id)) { + newErrors.levelNumber = "Level number already exists"; + } + + return newErrors; + }; + + const validateBulkForm = (): boolean => { + let isValid = true; + const newErrors: Record = {}; + + bulkFormData.forEach((data, index) => { + const itemErrors = validateForm(data); + Object.keys(itemErrors).forEach(key => { + newErrors[`${index}.${key}`] = itemErrors[key]; + }); + if (Object.keys(itemErrors).length > 0) { + isValid = false; + } + }); + + setErrors(newErrors); + return isValid; + }; + + // Form handlers + const handleFieldChange = (field: keyof UserLevelsCreateRequest, value: any) => { + setFormData(prev => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: "" })); + } + }; + + const handleBulkFieldChange = (index: number, field: keyof UserLevelsCreateRequest, value: any) => { + setBulkFormData((prev: UserLevelsCreateRequest[]) => { + const newData = [...prev]; + newData[index] = { ...newData[index], [field]: value }; + return newData; + }); + }; + + const handleBulkItemsChange = (items: UserLevelsCreateRequest[]) => { + setBulkFormData(items); + }; + + const renderBulkItemForm = (item: UserLevelsCreateRequest, index: number, onUpdate: (item: UserLevelsCreateRequest) => void, onDelete: () => void) => { + return ( +
+
+ onUpdate({ ...item, name: value })} + error={errors[`${index}.name`]} + required + /> + + onUpdate({ ...item, aliasName: value.toUpperCase() })} + error={errors[`${index}.aliasName`]} + required + helpText="Short identifier for system use" + /> + + onUpdate({ ...item, levelNumber: value ? Number(value) : 1 })} + error={errors[`${index}.levelNumber`]} + required + min={1} + helpText="Higher number = higher authority" + /> +
+ +
+ 0 ? "Select parent level" : "No parent levels available"} + value={item.parentLevelId} + onChange={(value) => onUpdate({ ...item, parentLevelId: value ? Number(value) : undefined })} + options={userLevels.map(level => ({ + value: level.id, + label: `${level.name} (Level ${level.levelNumber})`, + }))} + helpText={userLevels.length === 0 ? "No parent levels found. This will be a root level." : "Select parent level for hierarchy"} + disabled={userLevels.length === 0} + /> + + 0 ? "Select province" : "No provinces available"} + value={item.provinceId} + onChange={(value) => onUpdate({ ...item, provinceId: value ? Number(value) : undefined })} + options={provinces.map(province => ({ + value: province.id, + label: province.provinceName, + }))} + helpText={provinces.length === 0 ? "No provinces found. Please ensure provinces are available in the system." : "Geographic scope for this level"} + disabled={provinces.length === 0} + /> +
+ +
+ onUpdate({ ...item, group: value })} + helpText="Group classification for organization" + /> + + onUpdate({ ...item, isApprovalActive: value })} + helpText="Users with this level can participate in approval process" + /> + + onUpdate({ ...item, isActive: value })} + helpText="Level is available for assignment" + /> +
+
+ ); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (mode === "single") { + const validationErrors = validateForm(formData); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + Swal.fire({ + title: "Validation Error", + text: "Please fix the errors before submitting", + icon: "error", + confirmButtonText: "OK" + }); + return; + } + } else { + if (!validateBulkForm()) { + Swal.fire({ + title: "Validation Error", + text: "Please fix the errors before submitting", + icon: "error", + confirmButtonText: "OK" + }); + return; + } + } + + setIsSubmitting(true); + + try { + if (onSave) { + if (mode === "single") { + onSave(formData); + } else { + // For bulk mode, save each item individually + for (const item of bulkFormData) { + await createUserLevel(item); + } + } + } else { + if (mode === "single") { + const response = await createUserLevel(formData); + + if (response?.error) { + Swal.fire({ + title: "Error", + text: response?.message || "Failed to create user level", + icon: "error", + confirmButtonText: "OK" + }); + } else { + Swal.fire({ + title: "Success", + text: "User level created successfully", + icon: "success", + confirmButtonText: "OK" + }); + } + } else { + // Bulk creation + const promises = bulkFormData.map(item => createUserLevel(item)); + const responses = await Promise.all(promises); + + const failedCount = responses.filter((r: any) => r.error).length; + const successCount = responses.length - failedCount; + + if (failedCount === 0) { + Swal.fire({ + title: "Success", + text: `All ${successCount} user levels created successfully`, + icon: "success", + confirmButtonText: "OK" + }); + } else { + Swal.fire({ + title: "Partial Success", + text: `${successCount} user levels created successfully, ${failedCount} failed`, + icon: "warning", + confirmButtonText: "OK" + }); + } + } + } + } catch (error) { + console.error("Error submitting form:", error); + Swal.fire({ + title: "Error", + text: "An unexpected error occurred", + icon: "error", + confirmButtonText: "OK" + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleReset = () => { + Swal.fire({ + title: "Reset Form", + text: "Are you sure you want to reset all form data?", + icon: "warning", + showCancelButton: true, + confirmButtonText: "Yes, reset", + cancelButtonText: "Cancel" + }).then((result) => { + if (result.isConfirmed) { + if (mode === "single") { + setFormData({ + name: "", + aliasName: "", + levelNumber: 1, + parentLevelId: undefined, + provinceId: undefined, + group: "", + isApprovalActive: true, + isActive: true, + }); + } else { + setBulkFormData([]); + } + setErrors({}); + } + }); + }; + + const renderHierarchyTree = () => { + const buildTree = (levels: UserLevel[], parentId?: number): UserLevel[] => { + return levels + .filter(level => level.parentLevelId === parentId) + .map(level => ({ + ...level, + children: buildTree(levels, level.id) + })); + }; + + const tree = buildTree(userLevels); + + const renderNode = (node: UserLevel & { children?: UserLevel[] }, depth = 0) => ( +
+
+
+ + {node.name} (Level {node.levelNumber}) + {node.group && - {node.group}} + +
+ {node.children?.map((child: any) => renderNode(child, depth + 1))} +
+ ); + + return ( +
+ {tree.map(node => renderNode(node))} +
+ ); + }; + + return ( +
+ {isLoadingData && ( +
+
+ Loading form data... +
+ )} + + + + Basic Information + Hierarchy + Bulk Operations + + + {/* Basic Information Tab */} + + + + User Level Basic Information + + +
+ handleFieldChange("name", value)} + error={errors.name} + required + /> + + handleFieldChange("aliasName", value.toUpperCase())} + error={errors.aliasName} + required + helpText="Short identifier for system use" + /> + + handleFieldChange("levelNumber", value ? Number(value) : 1)} + error={errors.levelNumber} + required + min={1} + helpText="Higher number = higher authority" + /> +
+ +
+ 0 ? "Select parent level" : "No parent levels available"} + value={formData.parentLevelId} + onChange={(value) => handleFieldChange("parentLevelId", value ? Number(value) : undefined)} + options={userLevels.map(level => ({ + value: level.id, + label: `${level.name} (Level ${level.levelNumber})`, + }))} + helpText={userLevels.length === 0 ? "No parent levels found. This will be a root level." : "Select parent level for hierarchy"} + disabled={userLevels.length === 0} + /> + + 0 ? "Select province" : "No provinces available"} + value={formData.provinceId} + onChange={(value) => handleFieldChange("provinceId", value ? Number(value) : undefined)} + options={provinces.map(province => ({ + value: province.id, + label: province.provinceName, + }))} + helpText={provinces.length === 0 ? "No provinces found. Please ensure provinces are available in the system." : "Geographic scope for this level"} + disabled={provinces.length === 0} + /> +
+ + handleFieldChange("group", value)} + helpText="Group classification for organization" + /> + +
+ handleFieldChange("isApprovalActive", value)} + helpText="Users with this level can participate in approval process" + /> + + handleFieldChange("isActive", value)} + helpText="Level is available for assignment" + /> +
+
+
+
+ + {/* Hierarchy Tab */} + + + + + + Level Hierarchy Visualization + + + + + + + + + {userLevels.length > 0 ? ( +
+ {renderHierarchyTree()} +
+ ) : ( +

No user levels found

+ )} +
+
+
+
+
+ + {/* Bulk Operations Tab */} + + + + + + Bulk User Level Creation + + + + + + + +
+ + {/* Form Actions */} +
+
+ +
+ +
+ {onCancel && ( + + )} + + +
+
+
+ ); +}; diff --git a/components/form/common/DynamicArray.tsx b/components/form/common/DynamicArray.tsx new file mode 100644 index 0000000..513425a --- /dev/null +++ b/components/form/common/DynamicArray.tsx @@ -0,0 +1,171 @@ +"use client"; +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon, CopyIcon, ArrowUpIcon, ArrowDownIcon } from "@/components/icons"; +import { FormField } from "./FormField"; + +interface DynamicArrayProps { + items: T[]; + onItemsChange: (items: T[]) => void; + renderItem: (item: T, index: number, onUpdate: (item: T) => void, onDelete: () => void) => React.ReactNode; + addItemLabel?: string; + emptyStateMessage?: string; + allowReorder?: boolean; + allowDuplicate?: boolean; + maxItems?: number; + className?: string; +} + +export function DynamicArray({ + items, + onItemsChange, + renderItem, + addItemLabel = "Add Item", + emptyStateMessage = "No items added yet", + allowReorder = true, + allowDuplicate = true, + maxItems, + className = "", +}: DynamicArrayProps) { + const [expandedItems, setExpandedItems] = useState>(new Set()); + + const addItem = () => { + if (maxItems && items.length >= maxItems) return; + const newItem = {} as T; + onItemsChange([...items, newItem]); + }; + + const updateItem = (index: number, updatedItem: T) => { + const newItems = [...items]; + newItems[index] = updatedItem; + onItemsChange(newItems); + }; + + const deleteItem = (index: number) => { + const newItems = items.filter((_, i) => i !== index); + onItemsChange(newItems); + }; + + const duplicateItem = (index: number) => { + if (maxItems && items.length >= maxItems) return; + const duplicatedItem = { ...items[index] }; + const newItems = [...items]; + newItems.splice(index + 1, 0, duplicatedItem); + onItemsChange(newItems); + }; + + const moveItem = (index: number, direction: 'up' | 'down') => { + const newItems = [...items]; + const targetIndex = direction === 'up' ? index - 1 : index + 1; + + if (targetIndex < 0 || targetIndex >= items.length) return; + + [newItems[index], newItems[targetIndex]] = [newItems[targetIndex], newItems[index]]; + onItemsChange(newItems); + }; + + const toggleExpanded = (index: number) => { + const newExpanded = new Set(expandedItems); + if (newExpanded.has(index)) { + newExpanded.delete(index); + } else { + newExpanded.add(index); + } + setExpandedItems(newExpanded); + }; + + return ( +
+
+

Items ({items.length})

+ +
+ + {items.length === 0 ? ( + + +

{emptyStateMessage}

+
+
+ ) : ( +
+ {items.map((item, index) => ( + + +
+ + Item {index + 1} + +
+ {allowReorder && ( + <> + + + + )} + + {allowDuplicate && ( + + )} + + +
+
+
+ + + {renderItem(item, index, (updatedItem) => updateItem(index, updatedItem), () => deleteItem(index))} + +
+ ))} +
+ )} +
+ ); +} diff --git a/components/form/common/FormField.tsx b/components/form/common/FormField.tsx new file mode 100644 index 0000000..1027ca0 --- /dev/null +++ b/components/form/common/FormField.tsx @@ -0,0 +1,151 @@ +"use client"; +import React from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; + +interface FormFieldProps { + label: string; + name: string; + type?: "text" | "email" | "password" | "number" | "tel" | "url" | "textarea" | "select" | "checkbox"; + placeholder?: string; + value: any; + onChange: (value: any) => void; + error?: string; + required?: boolean; + disabled?: boolean; + options?: Array<{ value: string | number; label: string }>; + helpText?: string; + className?: string; + min?: number; + max?: number; + step?: number; + rows?: number; +} + +export const FormField: React.FC = ({ + label, + name, + type = "text", + placeholder, + value, + onChange, + error, + required = false, + disabled = false, + options = [], + helpText, + className = "", + min, + max, + step, + rows = 3, +}) => { + const fieldId = `field-${name}`; + const hasError = !!error; + + const renderField = () => { + switch (type) { + case "textarea": + return ( +