This commit is contained in:
Sabda Yagra 2025-10-02 12:08:42 +07:00
commit 07742a0732
14 changed files with 3172 additions and 0 deletions

View File

@ -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<any>(null);
const [userLevels, setUserLevels] = useState<UserLevel[]>([]);
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 (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Tenant Settings</h1>
<p className="text-gray-600 mt-2">
Manage approval workflows and user levels for your tenant
</p>
</div>
<div className="flex items-center gap-2">
<SettingsIcon className="h-6 w-6 text-gray-500" />
<Button
variant="outline"
size="sm"
onClick={checkWorkflowStatus}
>
Check Workflow Status
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="workflows" className="flex items-center gap-2">
<WorkflowIcon className="h-4 w-4" />
Approval Workflows
</TabsTrigger>
<TabsTrigger value="user-levels" className="flex items-center gap-2">
<UsersIcon className="h-4 w-4" />
User Levels
</TabsTrigger>
</TabsList>
{/* Approval Workflows Tab */}
<TabsContent value="workflows" className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">Approval Workflow Setup</h2>
{workflow && !isEditingWorkflow && (
<Button
onClick={() => setIsEditingWorkflow(true)}
className="flex items-center gap-2"
>
<SettingsIcon className="h-4 w-4" />
Edit Workflow
</Button>
)}
</div>
{isEditingWorkflow ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Setup Approval Workflow</span>
<Button
variant="outline"
onClick={() => setIsEditingWorkflow(false)}
>
Cancel
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<ApprovalWorkflowForm
initialData={workflow}
onSave={handleWorkflowSave}
onCancel={() => setIsEditingWorkflow(false)}
/>
</CardContent>
</Card>
) : workflow ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{workflow.name}</span>
<div className="flex items-center gap-2">
{workflow.isDefault && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
Default
</span>
)}
{workflow.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm mb-4">
{workflow.description}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{workflow.steps?.length || 0}</div>
<div className="text-sm text-gray-600">Workflow Steps</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className={`text-2xl font-bold ${workflow.requiresApproval ? 'text-green-600' : 'text-red-600'}`}>
{workflow.requiresApproval ? 'Yes' : 'No'}
</div>
<div className="text-sm text-gray-600">Requires Approval</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className={`text-2xl font-bold ${workflow.autoPublish ? 'text-green-600' : 'text-red-600'}`}>
{workflow.autoPublish ? 'Yes' : 'No'}
</div>
<div className="text-sm text-gray-600">Auto Publish</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{workflow.clientApprovalSettings?.approvalExemptUsers?.length || 0}
</div>
<div className="text-sm text-gray-600">Exempt Users</div>
</div>
</div>
{/* Workflow Steps Overview */}
{workflow.steps && workflow.steps.length > 0 && (
<div className="mb-6">
<h4 className="text-lg font-medium mb-3">Workflow Steps</h4>
<div className="space-y-2">
{workflow.steps.map((step: any, index: number) => (
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">
{step.stepOrder}
</div>
<div>
<div className="font-medium">{step.stepName}</div>
<div className="text-sm text-gray-500">
{step.conditionType && `Condition: ${step.conditionType}`}
{step.autoApproveAfterHours && ` • Auto-approve after ${step.autoApproveAfterHours}h`}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{step.canSkip && (
<span className="px-2 py-1 text-xs bg-yellow-100 text-yellow-800 rounded-full">
Can Skip
</span>
)}
{step.isParallel && (
<span className="px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
Parallel
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Client Settings Overview */}
{workflow.clientApprovalSettings && (
<div className="mb-6">
<h4 className="text-lg font-medium mb-3">Client Approval Settings</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">Exempt Users</div>
<div className="text-sm text-gray-600">
{workflow.clientApprovalSettings.approvalExemptUsers?.length || 0} users
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">Exempt Roles</div>
<div className="text-sm text-gray-600">
{workflow.clientApprovalSettings.approvalExemptRoles?.length || 0} roles
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">Exempt Categories</div>
<div className="text-sm text-gray-600">
{workflow.clientApprovalSettings.approvalExemptCategories?.length || 0} categories
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">Content Types</div>
<div className="text-sm text-gray-600">
{workflow.clientApprovalSettings.requireApprovalFor?.length || 0} requiring approval
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<WorkflowIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Workflow Configured</h3>
<p className="text-gray-500 mb-4">
Set up your approval workflow to manage content approval process
</p>
<Button onClick={() => setIsEditingWorkflow(true)}>
<PlusIcon className="h-4 w-4 mr-2" />
Setup Workflow
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* User Levels Tab */}
<TabsContent value="user-levels" className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">User Levels</h2>
<Dialog open={isUserLevelDialogOpen} onOpenChange={setIsUserLevelDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
Create User Level
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New User Level</DialogTitle>
</DialogHeader>
<UserLevelsForm
mode="single"
onSave={handleUserLevelSave}
onCancel={() => setIsUserLevelDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
{/* User Levels Summary */}
{userLevels.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{userLevels.length}</div>
<div className="text-sm text-gray-600">Total User Levels</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{userLevels.filter(ul => ul.isActive).length}
</div>
<div className="text-sm text-gray-600">Active Levels</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{userLevels.filter(ul => ul.isApprovalActive).length}
</div>
<div className="text-sm text-gray-600">Approval Active</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{userLevels.filter(ul => ul.parentLevelId).length}
</div>
<div className="text-sm text-gray-600">Child Levels</div>
</div>
</div>
)}
{/* User Levels Hierarchy */}
{userLevels.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersIcon className="h-5 w-5" />
User Levels Hierarchy
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{userLevels
.filter(ul => !ul.parentLevelId) // Root levels
.sort((a, b) => a.levelNumber - b.levelNumber)
.map(rootLevel => (
<div key={rootLevel.id} className="space-y-2">
{/* Root Level */}
<div className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg border-l-4 border-blue-500">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">
{rootLevel.levelNumber}
</div>
<div className="flex-1">
<div className="font-medium">{rootLevel.name}</div>
<div className="text-sm text-gray-500">
{rootLevel.aliasName} {rootLevel.group || 'No group'}
</div>
</div>
<div className="flex items-center gap-2">
{rootLevel.isActive && (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
)}
{rootLevel.isApprovalActive && (
<span className="px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
Approval Active
</span>
)}
</div>
</div>
{/* Child Levels */}
{userLevels
.filter(ul => ul.parentLevelId === rootLevel.id)
.sort((a, b) => a.levelNumber - b.levelNumber)
.map(childLevel => (
<div key={childLevel.id} className="ml-8 flex items-center gap-3 p-3 bg-gray-50 rounded-lg border-l-4 border-gray-300">
<div className="w-6 h-6 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center text-xs font-medium">
{childLevel.levelNumber}
</div>
<div className="flex-1">
<div className="font-medium text-sm">{childLevel.name}</div>
<div className="text-xs text-gray-500">
{childLevel.aliasName} {childLevel.group || 'No group'}
</div>
</div>
<div className="flex items-center gap-1">
{childLevel.isActive && (
<span className="px-1 py-0.5 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
)}
{childLevel.isApprovalActive && (
<span className="px-1 py-0.5 text-xs bg-purple-100 text-purple-800 rounded-full">
Approval
</span>
)}
</div>
</div>
))}
</div>
))}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{userLevels.length > 0 ? userLevels.map((userLevel) => (
<Card key={userLevel.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="truncate">{userLevel.name}</span>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
Level {userLevel.levelNumber}
</span>
{userLevel.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Alias:</span>
<span className="font-mono text-xs bg-gray-100 px-2 py-1 rounded">
{userLevel.aliasName}
</span>
</div>
{userLevel.group && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Group:</span>
<span className="font-medium">{userLevel.group}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Approval Active:</span>
<span className={`font-medium ${userLevel.isApprovalActive ? 'text-green-600' : 'text-red-600'}`}>
{userLevel.isApprovalActive ? 'Yes' : 'No'}
</span>
</div>
{userLevel.parentLevelId && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Parent Level:</span>
<span className="font-medium">
{userLevels.find(ul => ul.id === userLevel.parentLevelId)?.name || `Level ${userLevel.parentLevelId}`}
</span>
</div>
)}
{userLevel.provinceId && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Province:</span>
<span className="font-medium">Province {userLevel.provinceId}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Created:</span>
<span className="font-medium text-xs">
{userLevel.createdAt ? new Date(userLevel.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="flex-1">
Edit
</Button>
<Button variant="outline" size="sm" className="flex-1">
Users
</Button>
</div>
</CardContent>
</Card>
)) : ''}
</div>
{userLevels.length === 0 && !isLoading && (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<UsersIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No User Levels Found</h3>
<p className="text-gray-500 mb-4">
Create your first user level to define approval hierarchy
</p>
<Button onClick={() => setIsUserLevelDialogOpen(true)}>
<PlusIcon className="h-4 w-4 mr-2" />
Create User Level
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}
export default function TenantSettingsPage() {
return (
<WorkflowModalProvider>
<TenantSettingsContent />
</WorkflowModalProvider>
);
}

View File

@ -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<ApprovalWorkflowFormProps> = ({
initialData,
onSave,
onCancel,
isLoading = false,
}) => {
// Form state
const [formData, setFormData] = useState<CreateApprovalWorkflowWithClientSettingsRequest>({
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<UserLevel[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [articleCategories, setArticleCategories] = useState<ArticleCategory[]>([]);
// UI state
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [expandedSteps, setExpandedSteps] = useState<Set<number>>(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<number>();
// 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<string, string> = {};
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 (
<div className="space-y-4">
{/* Parallel Step Indicator */}
{isParallelStep && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-sm font-medium text-blue-800">
Parallel Step - Order {step.stepOrder}
</span>
<span className="text-xs text-blue-600">
({parallelSteps.length + 1} steps running simultaneously)
</span>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="Step Order"
name={`stepOrder-${index}`}
type="number"
placeholder="1"
value={step.stepOrder}
onChange={(value) => onUpdate({ ...step, stepOrder: value ? Number(value) : index + 1 })}
error={errors[`steps.${index}.stepOrder`]}
min={1}
helpText="Same order = parallel steps"
/>
<FormField
label="Step Name"
name={`stepName-${index}`}
type="text"
placeholder="e.g., Editor Review, Manager Approval"
value={step.stepName}
onChange={(value) => onUpdate({ ...step, stepName: value })}
error={errors[`steps.${index}.stepName`]}
required
/>
<FormField
label="Required User Level"
name={`requiredUserLevelId-${index}`}
type="select"
placeholder={userLevels.length > 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}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="Auto Approve After Hours"
name={`autoApproveAfterHours-${index}`}
type="number"
placeholder="Leave empty for manual approval"
value={step.autoApproveAfterHours}
onChange={(value) => onUpdate({ ...step, autoApproveAfterHours: value ? Number(value) : undefined })}
helpText="Automatically approve after specified hours"
min={1}
/>
<div className="space-y-2">
<FormField
label="Can Skip This Step"
name={`canSkip-${index}`}
type="checkbox"
value={step.canSkip || false}
onChange={(value) => onUpdate({ ...step, canSkip: value })}
/>
<FormField
label="Is Active"
name={`isActive-${index}`}
type="checkbox"
value={step.isActive !== false}
onChange={(value) => onUpdate({ ...step, isActive: value })}
/>
</div>
</div>
{/* Condition Settings */}
<div className="space-y-4">
<MultiSelect
label="Applies to User Levels"
placeholder={(() => {
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";
}
})()}
/>
</div>
</div>
);
};
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 (
<form onSubmit={handleSubmit} className="space-y-6">
{isLoadingData && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading form data...</span>
</div>
)}
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic" disabled={isLoadingData}>Basic Information</TabsTrigger>
<TabsTrigger value="steps" disabled={isLoadingData}>Workflow Steps</TabsTrigger>
<TabsTrigger value="settings" disabled={isLoadingData}>Client Settings</TabsTrigger>
</TabsList>
{/* Basic Information Tab */}
<TabsContent value="basic" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Workflow Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
label="Workflow Name"
name="name"
type="text"
placeholder="e.g., Standard Article Approval"
value={formData.name}
onChange={(value) => handleBasicInfoChange("name", value)}
error={errors.name}
required
/>
<FormField
label="Workflow Description"
name="description"
type="textarea"
placeholder="Describe the purpose and process of this workflow"
value={formData.description}
onChange={(value) => handleBasicInfoChange("description", value)}
error={errors.description}
required
rows={4}
/>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormField
label="Is Active"
name="isActive"
type="checkbox"
value={formData.isActive}
onChange={(value) => handleBasicInfoChange("isActive", value)}
/>
<FormField
label="Set as Default Workflow"
name="isDefault"
type="checkbox"
value={formData.isDefault}
onChange={(value) => handleBasicInfoChange("isDefault", value)}
/>
<FormField
label="Requires Approval"
name="requiresApproval"
type="checkbox"
value={formData.requiresApproval}
onChange={(value) => handleBasicInfoChange("requiresApproval", value)}
/>
<FormField
label="Auto Publish After Approval"
name="autoPublish"
type="checkbox"
value={formData.autoPublish}
onChange={(value) => handleBasicInfoChange("autoPublish", value)}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Workflow Steps Tab */}
<TabsContent value="steps" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Workflow Steps Configuration</span>
<div className="text-sm text-gray-500">
{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<number, number>);
const parallelCount = Object.values(parallelGroups).filter(count => count > 1).length;
return parallelCount > 0 ? `${parallelCount} parallel group${parallelCount !== 1 ? 's' : ''}` : '';
})()}
</div>
</CardTitle>
</CardHeader>
<CardContent>
<DynamicArray
items={formData.steps}
onItemsChange={handleStepsChange}
renderItem={renderStepForm}
addItemLabel="Add Workflow Step"
emptyStateMessage="No workflow steps configured yet. Add at least one step to define the approval process."
allowReorder={true}
allowDuplicate={true}
/>
{errors.steps && (
<p className="text-red-500 text-sm mt-2">{errors.steps}</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Client Settings Tab */}
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Client Approval Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<div className="text-gray-500 mb-4">
<div className="h-12 w-12 mx-auto mb-2 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-400 text-xl"></span>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Settings Pre-configured</h3>
<p className="text-gray-600">
Client approval settings are automatically configured with optimal defaults.
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 text-left max-w-md mx-auto">
<h4 className="font-medium text-gray-900 mb-2">Default Settings:</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> Requires Approval: <span className="font-medium text-green-600">Yes</span></li>
<li> Auto Publish Articles: <span className="font-medium text-green-600">Yes</span></li>
<li> Is Active: <span className="font-medium text-green-600">Yes</span></li>
<li> Exempt Users: <span className="font-medium text-gray-500">None</span></li>
<li> Exempt Roles: <span className="font-medium text-gray-500">None</span></li>
<li> Exempt Categories: <span className="font-medium text-gray-500">None</span></li>
</ul>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Form Actions */}
<div className="flex items-center justify-between pt-6 border-t">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleReset}
disabled={isSubmitting}
>
<RotateCcwIcon className="h-4 w-4 mr-2" />
Reset
</Button>
</div>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting || isLoading || isLoadingData}
className="flex items-center gap-2"
>
<SaveIcon className="h-4 w-4" />
{isSubmitting ? "Saving..." : isLoadingData ? "Loading..." : "Save Workflow"}
</Button>
</div>
</div>
</form>
);
};

View File

@ -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<UserLevelsFormProps> = ({
initialData,
onSave,
onCancel,
isLoading = false,
mode = "single",
}) => {
// Form state
const [formData, setFormData] = useState<UserLevelsCreateRequest>({
name: "",
aliasName: "",
levelNumber: 1,
parentLevelId: undefined,
provinceId: undefined,
group: "",
isApprovalActive: true,
isActive: true,
});
// Bulk form state
const [bulkFormData, setBulkFormData] = useState<UserLevelsCreateRequest[]>([]);
// API data
const [userLevels, setUserLevels] = useState<UserLevel[]>([]);
const [provinces, setProvinces] = useState<Province[]>([]);
// UI state
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, string> => {
const newErrors: Record<string, string> = {};
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<string, string> = {};
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 (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="Level Name"
name={`name-${index}`}
type="text"
placeholder="e.g., Senior Editor, Manager, Publisher"
value={item.name}
onChange={(value) => onUpdate({ ...item, name: value })}
error={errors[`${index}.name`]}
required
/>
<FormField
label="Alias Name"
name={`aliasName-${index}`}
type="text"
placeholder="e.g., SENIOR_EDITOR, MANAGER, PUBLISHER"
value={item.aliasName}
onChange={(value) => onUpdate({ ...item, aliasName: value.toUpperCase() })}
error={errors[`${index}.aliasName`]}
required
helpText="Short identifier for system use"
/>
<FormField
label="Level Number"
name={`levelNumber-${index}`}
type="number"
placeholder="e.g., 1, 2, 3"
value={item.levelNumber}
onChange={(value) => onUpdate({ ...item, levelNumber: value ? Number(value) : 1 })}
error={errors[`${index}.levelNumber`]}
required
min={1}
helpText="Higher number = higher authority"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="Parent Level"
name={`parentLevelId-${index}`}
type="select"
placeholder={userLevels.length > 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}
/>
<FormField
label="Province"
name={`provinceId-${index}`}
type="select"
placeholder={provinces.length > 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}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="Group"
name={`group-${index}`}
type="text"
placeholder="e.g., Editorial, Management, Technical"
value={item.group || ""}
onChange={(value) => onUpdate({ ...item, group: value })}
helpText="Group classification for organization"
/>
<FormField
label="Is Approval Active"
name={`isApprovalActive-${index}`}
type="checkbox"
value={item.isApprovalActive}
onChange={(value) => onUpdate({ ...item, isApprovalActive: value })}
helpText="Users with this level can participate in approval process"
/>
<FormField
label="Is Active"
name={`isActive-${index}`}
type="checkbox"
value={item.isActive}
onChange={(value) => onUpdate({ ...item, isActive: value })}
helpText="Level is available for assignment"
/>
</div>
</div>
);
};
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) => (
<div key={node.id} className="ml-4">
<div className="flex items-center gap-2 py-1">
<div className="w-4 h-4 border-l border-b border-gray-300"></div>
<span className="text-sm">
{node.name} (Level {node.levelNumber})
{node.group && <span className="text-gray-500 ml-2">- {node.group}</span>}
</span>
</div>
{node.children?.map((child: any) => renderNode(child, depth + 1))}
</div>
);
return (
<div className="space-y-1">
{tree.map(node => renderNode(node))}
</div>
);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{isLoadingData && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading form data...</span>
</div>
)}
<Tabs defaultValue={mode === "single" ? "basic" : "bulk"} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic" disabled={isLoadingData}>Basic Information</TabsTrigger>
<TabsTrigger value="hierarchy" disabled={isLoadingData}>Hierarchy</TabsTrigger>
<TabsTrigger value="bulk" disabled={isLoadingData}>Bulk Operations</TabsTrigger>
</TabsList>
{/* Basic Information Tab */}
<TabsContent value="basic" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>User Level Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="Level Name"
name="name"
type="text"
placeholder="e.g., Senior Editor, Manager, Publisher"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
error={errors.name}
required
/>
<FormField
label="Alias Name"
name="aliasName"
type="text"
placeholder="e.g., SENIOR_EDITOR, MANAGER, PUBLISHER"
value={formData.aliasName}
onChange={(value) => handleFieldChange("aliasName", value.toUpperCase())}
error={errors.aliasName}
required
helpText="Short identifier for system use"
/>
<FormField
label="Level Number"
name="levelNumber"
type="number"
placeholder="e.g., 1, 2, 3"
value={formData.levelNumber}
onChange={(value) => handleFieldChange("levelNumber", value ? Number(value) : 1)}
error={errors.levelNumber}
required
min={1}
helpText="Higher number = higher authority"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="Parent Level"
name="parentLevelId"
type="select"
placeholder={userLevels.length > 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}
/>
<FormField
label="Province"
name="provinceId"
type="select"
placeholder={provinces.length > 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}
/>
</div>
<FormField
label="Group"
name="group"
type="text"
placeholder="e.g., Editorial, Management, Technical"
value={formData.group || ""}
onChange={(value) => handleFieldChange("group", value)}
helpText="Group classification for organization"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="Is Approval Active"
name="isApprovalActive"
type="checkbox"
value={formData.isApprovalActive}
onChange={(value) => handleFieldChange("isApprovalActive", value)}
helpText="Users with this level can participate in approval process"
/>
<FormField
label="Is Active"
name="isActive"
type="checkbox"
value={formData.isActive}
onChange={(value) => handleFieldChange("isActive", value)}
helpText="Level is available for assignment"
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Hierarchy Tab */}
<TabsContent value="hierarchy" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HierarchyIcon className="h-5 w-5" />
Level Hierarchy Visualization
</CardTitle>
</CardHeader>
<CardContent>
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
{expandedHierarchy ? "Hide" : "Show"} Hierarchy Tree
{expandedHierarchy ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
{userLevels.length > 0 ? (
<div className="border rounded-lg p-4 bg-gray-50">
{renderHierarchyTree()}
</div>
) : (
<p className="text-gray-500 text-center py-8">No user levels found</p>
)}
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
</TabsContent>
{/* Bulk Operations Tab */}
<TabsContent value="bulk" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersIcon className="h-5 w-5" />
Bulk User Level Creation
</CardTitle>
</CardHeader>
<CardContent>
<DynamicArray
items={bulkFormData}
onItemsChange={handleBulkItemsChange}
renderItem={renderBulkItemForm}
addItemLabel="Add User Level"
emptyStateMessage="No user levels added yet. Add multiple levels to create them in bulk."
allowReorder={true}
allowDuplicate={true}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Form Actions */}
<div className="flex items-center justify-between pt-6 border-t">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleReset}
disabled={isSubmitting}
>
<RotateCcwIcon className="h-4 w-4 mr-2" />
Reset
</Button>
</div>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting || isLoading || isLoadingData}
className="flex items-center gap-2"
>
<SaveIcon className="h-4 w-4" />
{isSubmitting ? "Saving..." : isLoadingData ? "Loading..." : `Save ${mode === "single" ? "User Level" : "User Levels"}`}
</Button>
</div>
</div>
</form>
);
};

View File

@ -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<T> {
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<T>({
items,
onItemsChange,
renderItem,
addItemLabel = "Add Item",
emptyStateMessage = "No items added yet",
allowReorder = true,
allowDuplicate = true,
maxItems,
className = "",
}: DynamicArrayProps<T>) {
const [expandedItems, setExpandedItems] = useState<Set<number>>(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 (
<div className={`space-y-4 ${className}`}>
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Items ({items.length})</h3>
<Button
type="button"
onClick={addItem}
disabled={maxItems ? items.length >= maxItems : false}
className="flex items-center gap-2"
>
<PlusIcon className="h-4 w-4" />
{addItemLabel}
</Button>
</div>
{items.length === 0 ? (
<Card>
<CardContent className="flex items-center justify-center py-8">
<p className="text-gray-500">{emptyStateMessage}</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{items.map((item, index) => (
<Card key={index} className="border border-gray-200">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
Item {index + 1}
</CardTitle>
<div className="flex items-center gap-2">
{allowReorder && (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveItem(index, 'up')}
disabled={index === 0}
>
<ArrowUpIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveItem(index, 'down')}
disabled={index === items.length - 1}
>
<ArrowDownIcon className="h-4 w-4" />
</Button>
</>
)}
{allowDuplicate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => duplicateItem(index)}
disabled={maxItems ? items.length >= maxItems : false}
>
<CopyIcon className="h-4 w-4" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => deleteItem(index)}
className="text-red-600 hover:text-red-700"
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{renderItem(item, index, (updatedItem) => updateItem(index, updatedItem), () => deleteItem(index))}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -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<FormFieldProps> = ({
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 (
<Textarea
id={fieldId}
placeholder={placeholder}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={`${hasError ? "border-red-500" : ""} ${className}`}
rows={rows}
/>
);
case "select":
return (
<Select
value={value?.toString() || ""}
onValueChange={(val) => onChange(val)}
disabled={disabled}
>
<SelectTrigger className={`${hasError ? "border-red-500" : ""} ${className}`}>
<SelectValue placeholder={placeholder || `Select ${label}`} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "checkbox":
return (
<div className="flex items-center space-x-2">
<Checkbox
id={fieldId}
checked={!!value}
onCheckedChange={(checked) => onChange(checked)}
disabled={disabled}
className={hasError ? "border-red-500" : ""}
/>
<Label htmlFor={fieldId} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
</Label>
</div>
);
case "number":
return (
<Input
id={fieldId}
type="number"
placeholder={placeholder}
value={value || ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
disabled={disabled}
className={`${hasError ? "border-red-500" : ""} ${className}`}
min={min}
max={max}
step={step}
/>
);
default:
return (
<Input
id={fieldId}
type={type}
placeholder={placeholder}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={`${hasError ? "border-red-500" : ""} ${className}`}
/>
);
}
};
return (
<div className="space-y-2">
{type !== "checkbox" && (
<Label htmlFor={fieldId} className="text-sm font-medium">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
)}
{renderField()}
{helpText && (
<p className="text-xs text-gray-500">{helpText}</p>
)}
{hasError && (
<p className="text-red-500 text-xs">{error}</p>
)}
</div>
);
};

View File

@ -0,0 +1,211 @@
"use client";
import React, { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CheckIcon, ChevronDownIcon, XIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
interface MultiSelectOption {
value: string | number;
label: string;
description?: string;
}
interface MultiSelectProps {
label: string;
placeholder?: string;
options: MultiSelectOption[];
value: (string | number)[];
onChange: (value: (string | number)[]) => void;
error?: string;
required?: boolean;
disabled?: boolean;
searchable?: boolean;
maxSelections?: number;
className?: string;
helpText?: string;
}
export const MultiSelect: React.FC<MultiSelectProps> = ({
label,
placeholder = "Select options...",
options,
value = [],
onChange,
error,
required = false,
disabled = false,
searchable = true,
maxSelections,
className = "",
helpText,
}) => {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const hasError = !!error;
const filteredOptions = useMemo(() => {
if (!searchable || !searchValue) return options;
return options.filter(option =>
option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
(option.description && option.description.toLowerCase().includes(searchValue.toLowerCase()))
);
}, [options, searchValue, searchable]);
const selectedOptions = useMemo(() => {
return options.filter(option => value.includes(option.value));
}, [options, value]);
const handleSelect = (optionValue: string | number) => {
if (disabled) return;
const newValue = value.includes(optionValue)
? value.filter(v => v !== optionValue)
: maxSelections && value.length >= maxSelections
? value
: [...value, optionValue];
onChange(newValue);
};
const handleRemove = (optionValue: string | number) => {
if (disabled) return;
onChange(value.filter(v => v !== optionValue));
};
const handleClearAll = () => {
if (disabled) return;
onChange([]);
};
return (
<div className={`space-y-2 ${className}`}>
<Label className="text-sm font-medium">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={`w-full justify-between ${hasError ? "border-red-500" : ""}`}
disabled={disabled}
>
<span className="truncate">
{selectedOptions.length === 0
? placeholder
: `${selectedOptions.length} selected`}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
{searchable && (
<CommandInput
placeholder="Search options..."
value={searchValue}
onValueChange={setSearchValue}
/>
)}
<CommandList>
<CommandEmpty>
{searchable && searchValue ? "No options found." : "No options available."}
</CommandEmpty>
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value.toString()}
onSelect={() => handleSelect(option.value)}
className="flex items-center justify-between"
>
<div className="flex items-center">
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
value.includes(option.value)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-3 w-3" />
</div>
<div>
<div className="font-medium">{option.label}</div>
{option.description && (
<div className="text-xs text-gray-500">{option.description}</div>
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Selected Items Display */}
{selectedOptions.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Selected:</span>
{!disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearAll}
className="text-xs text-red-600 hover:text-red-700"
>
Clear All
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{selectedOptions.map((option) => (
<Badge
key={option.value}
className="flex items-center gap-1 pr-1"
>
<span className="truncate max-w-[200px]">{option.label}</span>
{!disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemove(option.value)}
className="h-4 w-4 p-0 hover:bg-transparent"
>
<XIcon className="h-3 w-3" />
</Button>
)}
</Badge>
))}
</div>
</div>
)}
{helpText && (
<p className="text-xs text-gray-500">{helpText}</p>
)}
{hasError && (
<p className="text-red-500 text-xs">{error}</p>
)}
</div>
);
};

View File

@ -0,0 +1,138 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { XIcon, PlusIcon } from "@/components/icons";
interface TagInputProps {
label: string;
placeholder?: string;
value: string[];
onChange: (value: string[]) => void;
error?: string;
required?: boolean;
disabled?: boolean;
maxTags?: number;
className?: string;
helpText?: string;
validation?: (tag: string) => string | null;
}
export const TagInput: React.FC<TagInputProps> = ({
label,
placeholder = "Type and press Enter to add",
value = [],
onChange,
error,
required = false,
disabled = false,
maxTags,
className = "",
helpText,
validation,
}) => {
const [inputValue, setInputValue] = useState("");
const hasError = !!error;
const handleAddTag = (tag: string) => {
if (disabled) return;
const trimmedTag = tag.trim();
if (!trimmedTag) return;
if (maxTags && value.length >= maxTags) return;
if (value.includes(trimmedTag)) return;
if (validation) {
const validationError = validation(trimmedTag);
if (validationError) return;
}
onChange([...value, trimmedTag]);
setInputValue("");
};
const handleRemoveTag = (tagToRemove: string) => {
if (disabled) return;
onChange(value.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag(inputValue);
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
handleRemoveTag(value[value.length - 1]);
}
};
const handleAddClick = () => {
handleAddTag(inputValue);
};
return (
<div className={`space-y-2 ${className}`}>
<Label className="text-sm font-medium">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<div className="space-y-2">
<div className="flex gap-2">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || (maxTags ? value.length >= maxTags : false)}
className={hasError ? "border-red-500" : ""}
/>
<Button
type="button"
onClick={handleAddClick}
disabled={disabled || !inputValue.trim() || (maxTags ? value.length >= maxTags : false)}
size="sm"
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
{/* Tags Display */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2">
{value.map((tag, index) => (
<Badge
key={index}
className="flex items-center gap-1 pr-1"
>
<span>{tag}</span>
{!disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveTag(tag)}
className="h-4 w-4 p-0 hover:bg-transparent"
>
<XIcon className="h-3 w-3" />
</Button>
)}
</Badge>
))}
</div>
)}
</div>
{helpText && (
<p className="text-xs text-gray-500">{helpText}</p>
)}
{hasError && (
<p className="text-red-500 text-xs">{error}</p>
)}
</div>
);
};

View File

@ -2736,3 +2736,229 @@ export const VideoIcon = ({
</g> </g>
</svg> </svg>
); );
// Additional icons needed for forms
export const CheckIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20,6 9,17 4,12" />
</svg>
);
export const XIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
export const PlusIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
export const SaveIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17,21 17,13 7,13 7,21" />
<polyline points="7,3 7,8 15,8" />
</svg>
);
export const TrashIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3,6 5,6 21,6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
export const ArrowUpIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5,12 12,5 19,12" />
</svg>
);
export const ArrowDownIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<polyline points="19,12 12,19 5,12" />
</svg>
);
export const SettingsIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
export const UsersIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
export const WorkflowIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<circle cx="15" cy="9" r="2" />
<circle cx="9" cy="15" r="2" />
<circle cx="15" cy="15" r="2" />
</svg>
);
export const HierarchyIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 3v18h18" />
<path d="M18 17H9" />
<path d="M18 12H6" />
<path d="M18 7H3" />
</svg>
);
export const RotateCcwIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
);

View File

@ -0,0 +1,18 @@
"use client";
import React from "react";
import { useAutoWorkflowCheck } from "@/hooks/useWorkflowStatusCheck";
interface HeaderProps {
children: React.ReactNode;
}
export default function Header({ children }: HeaderProps) {
// Auto-check workflow status when header mounts
useAutoWorkflowCheck();
return (
<header className="bg-white shadow-sm border-b">
{children}
</header>
);
}

View File

@ -0,0 +1,23 @@
"use client";
import React from "react";
import { WorkflowModalProvider } from "@/components/modals/WorkflowModalProvider";
import Header from "@/components/layout/Header";
interface LayoutProps {
children: React.ReactNode;
}
export default function Layout({ children }: LayoutProps) {
return (
<WorkflowModalProvider>
<div className="min-h-screen bg-gray-50">
<Header>
{/* Header content will be provided by parent components */}
</Header>
<main className="flex-1">
{children}
</main>
</div>
</WorkflowModalProvider>
);
}

View File

@ -0,0 +1,57 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from "react";
import WorkflowSetupModal from "./WorkflowSetupModal";
interface WorkflowInfo {
hasWorkflowSetup: boolean;
defaultWorkflowId?: number;
defaultWorkflowName?: string;
requiresApproval?: boolean;
autoPublishArticles?: boolean;
isApprovalActive?: boolean;
}
interface WorkflowModalContextType {
showWorkflowModal: (workflowInfo: WorkflowInfo) => void;
hideWorkflowModal: () => void;
}
const WorkflowModalContext = createContext<WorkflowModalContextType | undefined>(undefined);
export function useWorkflowModal() {
const context = useContext(WorkflowModalContext);
if (context === undefined) {
throw new Error('useWorkflowModal must be used within a WorkflowModalProvider');
}
return context;
}
interface WorkflowModalProviderProps {
children: ReactNode;
}
export function WorkflowModalProvider({ children }: WorkflowModalProviderProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [workflowInfo, setWorkflowInfo] = useState<WorkflowInfo | undefined>(undefined);
const showWorkflowModal = (info: WorkflowInfo) => {
setWorkflowInfo(info);
setIsModalOpen(true);
};
const hideWorkflowModal = () => {
setIsModalOpen(false);
setWorkflowInfo(undefined);
};
return (
<WorkflowModalContext.Provider value={{ showWorkflowModal, hideWorkflowModal }}>
{children}
<WorkflowSetupModal
isOpen={isModalOpen}
onClose={hideWorkflowModal}
workflowInfo={workflowInfo}
/>
</WorkflowModalContext.Provider>
);
}

View File

@ -0,0 +1,155 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { AlertTriangleIcon, CheckCircleIcon, SettingsIcon } from "@/components/icons";
import { useRouter } from "next/navigation";
interface WorkflowSetupModalProps {
isOpen: boolean;
onClose: () => void;
workflowInfo?: {
hasWorkflowSetup: boolean;
defaultWorkflowId?: number;
defaultWorkflowName?: string;
requiresApproval?: boolean;
autoPublishArticles?: boolean;
isApprovalActive?: boolean;
};
}
export default function WorkflowSetupModal({ isOpen, onClose, workflowInfo }: WorkflowSetupModalProps) {
const router = useRouter();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
}
}, [isOpen]);
const handleClose = () => {
setIsVisible(false);
setTimeout(() => {
onClose();
}, 200);
};
const handleSetupWorkflow = () => {
handleClose();
router.push("/admin/settings/tenant");
};
if (!isOpen) return null;
return (
<Dialog open={isVisible} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{workflowInfo?.hasWorkflowSetup ? (
<CheckCircleIcon className="h-5 w-5 text-green-600" />
) : (
<AlertTriangleIcon className="h-5 w-5 text-orange-600" />
)}
Workflow Status
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{!workflowInfo?.hasWorkflowSetup ? (
// No Workflow Setup
<Card className="border-orange-200 bg-orange-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangleIcon className="h-6 w-6 text-orange-600 mt-1" />
<div className="flex-1">
<h3 className="font-medium text-orange-900 mb-2">
Workflow Belum Dikonfigurasi
</h3>
<p className="text-sm text-orange-700 mb-4">
Anda belum melakukan setup workflow, silahkan setup terlebih dahulu.
</p>
<div className="flex gap-2">
<Button
onClick={handleSetupWorkflow}
className="bg-orange-600 hover:bg-orange-700 text-white"
size="sm"
>
<SettingsIcon className="h-4 w-4 mr-2" />
Setup Workflow
</Button>
<Button
variant="outline"
onClick={handleClose}
size="sm"
>
Nanti
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
) : (
// Workflow Setup Complete
<Card className="border-green-200 bg-green-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<CheckCircleIcon className="h-6 w-6 text-green-600 mt-1" />
<div className="flex-1">
<h3 className="font-medium text-green-900 mb-2">
Workflow Sudah Dikonfigurasi
</h3>
<div className="space-y-2 text-sm text-green-700">
<div className="flex items-center justify-between">
<span>Workflow:</span>
<span className="font-medium">{workflowInfo.defaultWorkflowName}</span>
</div>
<div className="flex items-center justify-between">
<span>Requires Approval:</span>
<span className={`font-medium ${workflowInfo.requiresApproval ? 'text-green-600' : 'text-gray-500'}`}>
{workflowInfo.requiresApproval ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between">
<span>Auto Publish:</span>
<span className={`font-medium ${workflowInfo.autoPublishArticles ? 'text-green-600' : 'text-gray-500'}`}>
{workflowInfo.autoPublishArticles ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status:</span>
<span className={`font-medium ${workflowInfo.isApprovalActive ? 'text-green-600' : 'text-gray-500'}`}>
{workflowInfo.isApprovalActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
onClick={handleSetupWorkflow}
variant="outline"
size="sm"
>
<SettingsIcon className="h-4 w-4 mr-2" />
Manage Workflow
</Button>
<Button
onClick={handleClose}
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
>
OK
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,81 @@
"use client";
import { useEffect } from "react";
import { useWorkflowModal } from "./WorkflowModalProvider";
import { httpGetInterceptor } from "@/service/http-config/http-interceptor-service";
interface UserInfo {
id: number;
username: string;
email: string;
fullname: string;
address: string;
phoneNumber: string;
workType?: string;
genderType?: string;
identityType?: string;
identityNumber?: string;
dateOfBirth?: string;
lastEducation?: string;
keycloakId: string;
userRoleId: number;
userLevelId: number;
userLevelGroup: string;
statusId: number;
createdById?: number;
profilePicturePath?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
approvalWorkflowInfo: {
hasWorkflowSetup: boolean;
defaultWorkflowId?: number;
defaultWorkflowName?: string;
requiresApproval?: boolean;
autoPublishArticles?: boolean;
isApprovalActive?: boolean;
};
}
interface ApiResponse {
success: boolean;
code: number;
messages: string[];
data: UserInfo;
}
export function useWorkflowStatusCheck() {
const { showWorkflowModal } = useWorkflowModal();
const checkWorkflowStatus = async () => {
try {
const response = await httpGetInterceptor("users/info") as ApiResponse;
if (response?.success && response?.data?.approvalWorkflowInfo) {
const workflowInfo = response.data.approvalWorkflowInfo;
// Show modal if workflow is not setup
if (!workflowInfo.hasWorkflowSetup) {
showWorkflowModal(workflowInfo);
}
// Optional: Also show modal if workflow is setup (for confirmation)
// else {
// showWorkflowModal(workflowInfo);
// }
}
} catch (error) {
console.error("Error checking workflow status:", error);
}
};
return { checkWorkflowStatus };
}
// Hook untuk auto-check saat component mount
export function useAutoWorkflowCheck() {
const { checkWorkflowStatus } = useWorkflowStatusCheck();
useEffect(() => {
// Check workflow status when component mounts
checkWorkflowStatus();
}, [checkWorkflowStatus]);
}

View File

@ -0,0 +1,146 @@
import { httpPost, httpGet, httpPut } from "./http-config/http-base-service";
import { httpPostInterceptor, httpGetInterceptor, httpPutInterceptor, httpDeleteInterceptor } from "./http-config/http-interceptor-service";
// Types
export interface ApprovalWorkflowStepRequest {
stepOrder: number;
stepName: string;
requiredUserLevelId: number;
canSkip?: boolean;
autoApproveAfterHours?: number;
isActive?: boolean;
conditionType?: string;
conditionValue?: string;
}
export interface ClientApprovalSettingsRequest {
requiresApproval?: boolean;
autoPublishArticles?: boolean;
approvalExemptUsers: number[];
approvalExemptRoles: number[];
approvalExemptCategories: number[];
requireApprovalFor: string[];
skipApprovalFor: string[];
isActive?: boolean;
}
export interface CreateApprovalWorkflowWithClientSettingsRequest {
name: string;
description: string;
isActive?: boolean;
isDefault?: boolean;
requiresApproval?: boolean;
autoPublish?: boolean;
steps: ApprovalWorkflowStepRequest[];
clientApprovalSettings: ClientApprovalSettingsRequest;
}
export interface UserLevelsCreateRequest {
name: string;
aliasName: string;
levelNumber: number;
parentLevelId?: number;
provinceId?: number;
group?: string;
isApprovalActive?: boolean;
isActive?: boolean;
}
export interface UserLevel {
id: number;
name: string;
aliasName: string;
levelNumber: number;
parentLevelId?: number;
provinceId?: number;
group?: string;
isApprovalActive: boolean;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface User {
id: number;
fullname: string;
username: string;
email: string;
}
export interface UserRole {
id: number;
roleName: string;
description?: string;
}
export interface ArticleCategory {
id: number;
categoryName: string;
description?: string;
}
export interface Province {
id: number;
provinceName: string;
}
// API Functions
export async function createApprovalWorkflowWithClientSettings(data: CreateApprovalWorkflowWithClientSettingsRequest) {
const url = "approval-workflows/with-client-settings";
return httpPostInterceptor(url, data);
}
export async function getUserLevels() {
const url = "user-levels";
return httpGetInterceptor(url);
}
export async function createUserLevel(data: UserLevelsCreateRequest) {
const url = "user-levels";
return httpPostInterceptor(url, data);
}
export async function updateUserLevel(id: number, data: UserLevelsCreateRequest) {
const url = `user-levels/${id}`;
return httpPutInterceptor(url, data);
}
export async function deleteUserLevel(id: number) {
const url = `user-levels/${id}`;
return httpDeleteInterceptor(url);
}
export async function getUsers() {
const url = "users";
return httpGetInterceptor(url);
}
export async function getUserRoles() {
const url = "user-roles";
return httpGetInterceptor(url);
}
export async function getArticleCategories() {
const url = "article-categories";
return httpGetInterceptor(url);
}
export async function getProvinces() {
const url = "provinces";
return httpGetInterceptor(url);
}
export async function getApprovalWorkflows() {
const url = "approval-workflows";
return httpGetInterceptor(url);
}
export async function updateApprovalWorkflow(id: number, data: CreateApprovalWorkflowWithClientSettingsRequest) {
const url = `approval-workflows/${id}`;
return httpPutInterceptor(url, data);
}
export async function deleteApprovalWorkflow(id: number) {
const url = `approval-workflows/${id}`;
return httpDeleteInterceptor(url);
}