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>
|
||||
</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