kontenhumas-fe/components/form/ApprovalWorkflowForm.tsx

701 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
UpdateApprovalWorkflowWithClientSettingsRequest,
ApprovalWorkflowStepRequest,
ClientApprovalSettingsRequest,
UserLevel,
User,
UserRole,
ArticleCategory,
createApprovalWorkflowWithClientSettings,
updateApprovalWorkflowWithClientSettings,
getUserLevels,
getUsers,
getUserRoles,
getArticleCategories,
} from "@/service/approval-workflows";
import Swal from "sweetalert2";
interface ApprovalWorkflowFormProps {
initialData?: CreateApprovalWorkflowWithClientSettingsRequest;
workflowId?: number; // For update mode
onSave?: (data: CreateApprovalWorkflowWithClientSettingsRequest) => void;
onCancel?: () => void;
isLoading?: boolean;
}
export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
initialData,
workflowId,
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 < 3) {
newErrors.description = "Description must be at least 3 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",
customClass: {
popup: 'swal-z-index-9999'
}
});
return;
}
setIsSubmitting(true);
try {
// Hardcoded client approval settings
const hardcodedClientSettings = {
approvalExemptCategories: [],
approvalExemptRoles: [],
approvalExemptUsers: [],
autoPublishArticles: true,
isActive: true,
requireApprovalFor: [],
requiresApproval: true,
skipApprovalFor: []
};
if (workflowId) {
// Update mode
const updateData: UpdateApprovalWorkflowWithClientSettingsRequest = {
workflowId,
name: formData.name,
description: formData.description,
isActive: formData.isActive || false,
isDefault: formData.isDefault || false,
steps: formData.steps.map(step => ({
...step,
branchName: step.stepName, // branchName should be same as stepName
})),
clientSettings: hardcodedClientSettings
};
console.log("Update Data: ", updateData);
const response = await updateApprovalWorkflowWithClientSettings(updateData);
console.log("Update Response: ", response);
if (response?.error) {
Swal.fire({
title: "Error",
text: response?.message?.messages?.[0] || "Failed to update approval workflow",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Approval workflow updated successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
}).then(() => {
// Call onSave to trigger parent refresh
if (onSave) {
onSave(formData);
}
});
}
} else {
// Create mode
const submitData = {
...formData,
clientApprovalSettings: hardcodedClientSettings
};
console.log("Create Data: ", submitData);
const response = await createApprovalWorkflowWithClientSettings(submitData);
console.log("Create Response: ", response);
if (response?.error) {
Swal.fire({
title: "Error",
text: response?.message?.messages?.[0] || "Failed to create approval workflow",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Approval workflow created successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
}).then(() => {
// Call onSave to trigger parent refresh
if (onSave) {
onSave(submitData);
}
});
}
}
} catch (error) {
console.error("Error submitting form:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} 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",
customClass: {
popup: 'swal-z-index-9999'
}
}).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..." : workflowId ? "Update Workflow" : "Save Workflow"}
</Button>
</div>
</div>
</form>
);
};