mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
457 lines
18 KiB
TypeScript
457 lines
18 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
Plus,
|
|
Trash,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
Check,
|
|
X,
|
|
PlusCircle,
|
|
MinusCircle
|
|
} from '@phosphor-icons/react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue
|
|
} from '@/components/ui/select'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { ParallelApprovalStepEditor } from './ParallelApprovalStepEditor'
|
|
import type { WorkflowTemplate, ApprovalStepTemplate, EscalationRule } from '@/hooks/use-approval-workflow-templates'
|
|
|
|
interface WorkflowTemplateEditorProps {
|
|
template: WorkflowTemplate
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onSave: (template: WorkflowTemplate) => void
|
|
}
|
|
|
|
export function WorkflowTemplateEditor({
|
|
template,
|
|
open,
|
|
onOpenChange,
|
|
onSave
|
|
}: WorkflowTemplateEditorProps) {
|
|
const [editedTemplate, setEditedTemplate] = useState<WorkflowTemplate>(template)
|
|
const [editingStepId, setEditingStepId] = useState<string | null>(null)
|
|
|
|
const handleSave = () => {
|
|
onSave(editedTemplate)
|
|
onOpenChange(false)
|
|
}
|
|
|
|
const addStep = () => {
|
|
const newStep: ApprovalStepTemplate = {
|
|
id: `STEP-${Date.now()}`,
|
|
order: editedTemplate.steps.length,
|
|
name: 'New Step',
|
|
approverRole: 'Manager',
|
|
requiresComments: false,
|
|
canSkip: false
|
|
}
|
|
setEditedTemplate({
|
|
...editedTemplate,
|
|
steps: [...editedTemplate.steps, newStep]
|
|
})
|
|
setEditingStepId(newStep.id)
|
|
}
|
|
|
|
const updateStep = (stepId: string, updates: Partial<ApprovalStepTemplate>) => {
|
|
setEditedTemplate({
|
|
...editedTemplate,
|
|
steps: editedTemplate.steps.map(step =>
|
|
step.id === stepId ? { ...step, ...updates } : step
|
|
)
|
|
})
|
|
}
|
|
|
|
const removeStep = (stepId: string) => {
|
|
setEditedTemplate({
|
|
...editedTemplate,
|
|
steps: editedTemplate.steps
|
|
.filter(step => step.id !== stepId)
|
|
.map((step, index) => ({ ...step, order: index }))
|
|
})
|
|
if (editingStepId === stepId) {
|
|
setEditingStepId(null)
|
|
}
|
|
}
|
|
|
|
const moveStep = (stepId: string, direction: 'up' | 'down') => {
|
|
const stepIndex = editedTemplate.steps.findIndex(s => s.id === stepId)
|
|
if (stepIndex === -1) return
|
|
|
|
if (direction === 'up' && stepIndex === 0) return
|
|
if (direction === 'down' && stepIndex === editedTemplate.steps.length - 1) return
|
|
|
|
const newSteps = [...editedTemplate.steps]
|
|
const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1
|
|
|
|
const temp = newSteps[stepIndex]
|
|
newSteps[stepIndex] = newSteps[targetIndex]
|
|
newSteps[targetIndex] = temp
|
|
|
|
const reorderedSteps = newSteps.map((step, index) => ({
|
|
...step,
|
|
order: index
|
|
}))
|
|
|
|
setEditedTemplate({
|
|
...editedTemplate,
|
|
steps: reorderedSteps
|
|
})
|
|
}
|
|
|
|
const addEscalationRule = (stepId: string) => {
|
|
const newRule: EscalationRule = {
|
|
id: `ESC-${Date.now()}`,
|
|
hoursUntilEscalation: 24,
|
|
escalateTo: 'Senior Manager',
|
|
notifyOriginalApprover: true
|
|
}
|
|
|
|
updateStep(stepId, {
|
|
escalationRules: [
|
|
...(editedTemplate.steps.find(s => s.id === stepId)?.escalationRules || []),
|
|
newRule
|
|
]
|
|
})
|
|
}
|
|
|
|
const updateEscalationRule = (stepId: string, ruleId: string, updates: Partial<EscalationRule>) => {
|
|
const step = editedTemplate.steps.find(s => s.id === stepId)
|
|
if (!step?.escalationRules) return
|
|
|
|
updateStep(stepId, {
|
|
escalationRules: step.escalationRules.map(rule =>
|
|
rule.id === ruleId ? { ...rule, ...updates } : rule
|
|
)
|
|
})
|
|
}
|
|
|
|
const removeEscalationRule = (stepId: string, ruleId: string) => {
|
|
const step = editedTemplate.steps.find(s => s.id === stepId)
|
|
if (!step?.escalationRules) return
|
|
|
|
updateStep(stepId, {
|
|
escalationRules: step.escalationRules.filter(rule => rule.id !== ruleId)
|
|
})
|
|
}
|
|
|
|
const approverRoles = [
|
|
'Manager',
|
|
'Senior Manager',
|
|
'Director',
|
|
'Finance Manager',
|
|
'HR Manager',
|
|
'Payroll Manager',
|
|
'Compliance Officer',
|
|
'Operations Manager',
|
|
'CEO',
|
|
'CFO'
|
|
]
|
|
|
|
const batchTypes: Array<{ value: WorkflowTemplate['batchType']; label: string }> = [
|
|
{ value: 'payroll', label: 'Payroll' },
|
|
{ value: 'invoice', label: 'Invoice' },
|
|
{ value: 'timesheet', label: 'Timesheet' },
|
|
{ value: 'expense', label: 'Expense' },
|
|
{ value: 'compliance', label: 'Compliance' },
|
|
{ value: 'purchase-order', label: 'Purchase Order' }
|
|
]
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh]">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Workflow Template</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<ScrollArea className="max-h-[calc(90vh-180px)] pr-4">
|
|
<div className="space-y-6 pb-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Template Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="template-name">Template Name</Label>
|
|
<Input
|
|
id="template-name"
|
|
value={editedTemplate.name}
|
|
onChange={(e) => setEditedTemplate({ ...editedTemplate, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="batch-type">Batch Type</Label>
|
|
<Select
|
|
value={editedTemplate.batchType}
|
|
onValueChange={(value) => setEditedTemplate({ ...editedTemplate, batchType: value as WorkflowTemplate['batchType'] })}
|
|
>
|
|
<SelectTrigger id="batch-type">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{batchTypes.map(type => (
|
|
<SelectItem key={type.value} value={type.value}>
|
|
{type.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={editedTemplate.description}
|
|
onChange={(e) => setEditedTemplate({ ...editedTemplate, description: e.target.value })}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="is-active"
|
|
checked={editedTemplate.isActive}
|
|
onCheckedChange={(checked) => setEditedTemplate({ ...editedTemplate, isActive: checked })}
|
|
/>
|
|
<Label htmlFor="is-active" className="cursor-pointer">Active</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="is-default"
|
|
checked={editedTemplate.isDefault}
|
|
onCheckedChange={(checked) => setEditedTemplate({ ...editedTemplate, isDefault: checked })}
|
|
/>
|
|
<Label htmlFor="is-default" className="cursor-pointer">Set as Default</Label>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base">Approval Steps</CardTitle>
|
|
<Button size="sm" onClick={addStep}>
|
|
<Plus size={16} className="mr-1" />
|
|
Add Step
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{editedTemplate.steps.map((step, index) => (
|
|
<Card key={step.id} className={editingStepId === step.id ? 'border-primary' : ''}>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">{index + 1}</Badge>
|
|
<span className="font-medium text-sm">{step.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => moveStep(step.id, 'up')}
|
|
disabled={index === 0}
|
|
>
|
|
<ArrowUp size={16} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => moveStep(step.id, 'down')}
|
|
disabled={index === editedTemplate.steps.length - 1}
|
|
>
|
|
<ArrowDown size={16} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditingStepId(editingStepId === step.id ? null : step.id)}
|
|
>
|
|
{editingStepId === step.id ? <MinusCircle size={16} /> : <PlusCircle size={16} />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeStep(step.id)}
|
|
>
|
|
<Trash size={16} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{editingStepId === step.id && (
|
|
<CardContent className="space-y-4 pt-0">
|
|
<Separator />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Step Name</Label>
|
|
<Input
|
|
value={step.name}
|
|
onChange={(e) => updateStep(step.id, { name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Approver Role</Label>
|
|
<Select
|
|
value={step.approverRole}
|
|
onValueChange={(value) => updateStep(step.id, { approverRole: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{approverRoles.map(role => (
|
|
<SelectItem key={role} value={role}>
|
|
{role}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Description (Optional)</Label>
|
|
<Input
|
|
value={step.description || ''}
|
|
onChange={(e) => updateStep(step.id, { description: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={step.requiresComments}
|
|
onCheckedChange={(checked) => updateStep(step.id, { requiresComments: checked })}
|
|
/>
|
|
<Label className="cursor-pointer">Requires Comments</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={step.canSkip}
|
|
onCheckedChange={(checked) => updateStep(step.id, { canSkip: checked })}
|
|
/>
|
|
<Label className="cursor-pointer">Can Skip</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<ParallelApprovalStepEditor
|
|
step={step}
|
|
onChange={(updates) => updateStep(step.id, updates)}
|
|
/>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium">Escalation Rules</Label>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => addEscalationRule(step.id)}
|
|
>
|
|
<Plus size={14} className="mr-1" />
|
|
Add Rule
|
|
</Button>
|
|
</div>
|
|
|
|
{step.escalationRules?.map((rule) => (
|
|
<Card key={rule.id} className="bg-muted/30">
|
|
<CardContent className="p-3 space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Hours Until Escalation</Label>
|
|
<Input
|
|
type="number"
|
|
value={rule.hoursUntilEscalation}
|
|
onChange={(e) => updateEscalationRule(step.id, rule.id, { hoursUntilEscalation: parseInt(e.target.value) })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Escalate To</Label>
|
|
<Select
|
|
value={rule.escalateTo}
|
|
onValueChange={(value) => updateEscalationRule(step.id, rule.id, { escalateTo: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{approverRoles.map(role => (
|
|
<SelectItem key={role} value={role}>
|
|
{role}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={rule.notifyOriginalApprover}
|
|
onCheckedChange={(checked) => updateEscalationRule(step.id, rule.id, { notifyOriginalApprover: checked })}
|
|
/>
|
|
<Label className="text-xs cursor-pointer">Notify Original Approver</Label>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeEscalationRule(step.id, rule.id)}
|
|
>
|
|
<Trash size={14} />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
<X size={16} className="mr-1" />
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
<Check size={16} className="mr-1" />
|
|
Save Template
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|