Merge branch 'main' of https://gitlab.com/hanifsalafi/new-netidhub-public into dev-1
This commit is contained in:
commit
07742a0732
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue