557 lines
20 KiB
TypeScript
557 lines
20 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Plus,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
Loader2,
|
|
Trash2,
|
|
ChevronRight,
|
|
ShieldCheck,
|
|
FileText,
|
|
} from "lucide-react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { api } from "@/lib/api-client";
|
|
|
|
interface WorkflowStep {
|
|
id: number;
|
|
sequence: number;
|
|
approver_id: number | null;
|
|
approver_name: string;
|
|
status: string;
|
|
comment: string;
|
|
auto_escalate: boolean;
|
|
max_days: number;
|
|
acted_at: string | null;
|
|
}
|
|
|
|
interface Workflow {
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
entity_id: number | null;
|
|
entity_name: string | null;
|
|
allow_bypass: boolean;
|
|
active: boolean;
|
|
steps: WorkflowStep[];
|
|
created: string;
|
|
}
|
|
|
|
interface ApprovalRequest {
|
|
id: number;
|
|
workflow_id: number | null;
|
|
workflow_name: string;
|
|
res_model: string;
|
|
res_id: number;
|
|
state: string;
|
|
requester_id: number | null;
|
|
requester_name: string;
|
|
current_stage_id: number | null;
|
|
current_stage_sequence: number | null;
|
|
bypass_reason: string;
|
|
created_at: string | null;
|
|
}
|
|
|
|
interface UserItem {
|
|
id: number;
|
|
name: string;
|
|
login: string;
|
|
}
|
|
|
|
const statusIcon = (s: string) => {
|
|
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
|
|
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
|
return <Clock className="h-4 w-4 text-amber-500" />;
|
|
};
|
|
|
|
const stateBadge = (state: string) => {
|
|
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
|
approved: "default",
|
|
in_progress: "secondary",
|
|
rejected: "destructive",
|
|
draft: "outline",
|
|
};
|
|
return (
|
|
<Badge variant={map[state] || "outline"} className="capitalize">
|
|
{state.replace(/_/g, " ")}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
export default function ApprovalWorkflowsPage() {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [formName, setFormName] = useState("");
|
|
const [formType, setFormType] = useState("custom");
|
|
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
|
|
{ approver_id: "" },
|
|
{ approver_id: "" },
|
|
]);
|
|
|
|
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
|
|
const [rejectComment, setRejectComment] = useState("");
|
|
|
|
const workflowsQ = useQuery({
|
|
queryKey: ["approval-workflows"],
|
|
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
|
|
});
|
|
|
|
const requestsQ = useQuery({
|
|
queryKey: ["approval-requests"],
|
|
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
|
|
});
|
|
|
|
const usersQ = useQuery({
|
|
queryKey: ["approval-users"],
|
|
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
|
|
});
|
|
|
|
const workflows = workflowsQ.data?.items ?? [];
|
|
const requests = requestsQ.data?.items ?? [];
|
|
const users = usersQ.data?.items ?? [];
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
|
setCreateOpen(false);
|
|
resetForm();
|
|
toast({ title: "Workflow created" });
|
|
},
|
|
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
|
|
});
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
|
toast({ title: "Workflow deleted" });
|
|
},
|
|
});
|
|
|
|
const approveMut = useMutation({
|
|
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
|
toast({ title: "Request approved" });
|
|
},
|
|
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
|
|
});
|
|
|
|
const rejectMut = useMutation({
|
|
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
|
|
api.post(`/approval-requests/${id}/reject`, { comment }),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
|
setRejectDialog(null);
|
|
setRejectComment("");
|
|
toast({ title: "Request rejected" });
|
|
},
|
|
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
|
|
});
|
|
|
|
const resetForm = () => {
|
|
setFormName("");
|
|
setFormType("custom");
|
|
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
createMut.mutate({
|
|
name: formName.trim(),
|
|
type: formType,
|
|
steps: formSteps
|
|
.filter((s) => s.approver_id)
|
|
.map((s) => ({ approver_id: Number(s.approver_id) })),
|
|
});
|
|
};
|
|
|
|
const pendingRequests = requests.filter((r) => r.state === "in_progress");
|
|
const completedRequests = requests.filter((r) => r.state !== "in_progress");
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
|
<p className="text-muted-foreground">
|
|
Manage multi-step approval processes for exam content.
|
|
</p>
|
|
</div>
|
|
<Dialog
|
|
open={createOpen}
|
|
onOpenChange={(open) => {
|
|
setCreateOpen(open);
|
|
if (!open) resetForm();
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm">
|
|
<Plus className="h-4 w-4 mr-1" /> Create Workflow
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create Workflow</DialogTitle>
|
|
<DialogDescription>Define a multi-step approval process.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>
|
|
Workflow Name <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
value={formName}
|
|
onChange={(e) => setFormName(e.target.value)}
|
|
placeholder="e.g. Exam Content Review"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Type</Label>
|
|
<Select value={formType} onValueChange={setFormType}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="custom">Custom</SelectItem>
|
|
<SelectItem value="exam_publish">Exam Publication</SelectItem>
|
|
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
|
|
<SelectItem value="content_publish">Content Publication</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{formSteps.map((step, i) => (
|
|
<div key={i} className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Step {i + 1} — Approver</Label>
|
|
{formSteps.length > 1 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
|
onClick={() =>
|
|
setFormSteps((s) => s.filter((_, idx) => idx !== i))
|
|
}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Select
|
|
value={step.approver_id}
|
|
onValueChange={(v) =>
|
|
setFormSteps((s) =>
|
|
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select approver" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{users.map((u) => (
|
|
<SelectItem key={u.id} value={String(u.id)}>
|
|
{u.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" /> Add Step
|
|
</Button>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={!formName.trim() || createMut.isPending}
|
|
onClick={handleCreate}
|
|
>
|
|
{createMut.isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
"Create"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{(workflowsQ.isLoading || requestsQ.isLoading) && (
|
|
<div className="flex justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Pending Requests */}
|
|
{pendingRequests.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<Clock className="h-5 w-5 text-amber-500" />
|
|
Pending Approval ({pendingRequests.length})
|
|
</h2>
|
|
{pendingRequests.map((req) => {
|
|
const wf = workflows.find((w) => w.id === req.workflow_id);
|
|
return (
|
|
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<p className="font-semibold">{req.workflow_name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Requested by {req.requester_name} ·{" "}
|
|
{req.res_model.replace("encoach.", "")} #{req.res_id}
|
|
</p>
|
|
</div>
|
|
{stateBadge(req.state)}
|
|
</div>
|
|
|
|
{wf && (
|
|
<div className="flex items-center gap-2 flex-wrap mb-3">
|
|
{wf.steps.map((step, i) => {
|
|
const isActive = step.id === req.current_stage_id;
|
|
return (
|
|
<div key={step.id} className="flex items-center gap-2">
|
|
<div
|
|
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
|
|
isActive
|
|
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
|
|
: step.status === "approved"
|
|
? "border-green-200 bg-green-50/50"
|
|
: step.status === "rejected"
|
|
? "border-red-200 bg-red-50/50"
|
|
: "bg-muted/30"
|
|
}`}
|
|
>
|
|
{statusIcon(step.status)}
|
|
<div>
|
|
<p className="text-sm font-medium">Step {i + 1}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{step.approver_name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{i < wf.steps.length - 1 && (
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
disabled={approveMut.isPending}
|
|
onClick={() => approveMut.mutate(req.id)}
|
|
>
|
|
{approveMut.isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
|
) : (
|
|
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
|
)}
|
|
Approve
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setRejectDialog(req.id)}
|
|
>
|
|
<XCircle className="h-3.5 w-3.5 mr-1" />
|
|
Reject
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Workflow Templates */}
|
|
<div className="space-y-3">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<ShieldCheck className="h-5 w-5" />
|
|
Workflow Templates ({workflows.length})
|
|
</h2>
|
|
{workflows.length === 0 && !workflowsQ.isLoading && (
|
|
<Card className="border-dashed">
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
No workflows defined yet. Create one to get started.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{workflows.map((wf) => (
|
|
<Card key={wf.id} className="border-0 shadow-sm">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
|
<Badge variant="outline" className="text-xs capitalize">
|
|
{wf.type.replace(/_/g, " ")}
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
onClick={() => {
|
|
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{wf.steps.map((step, i) => (
|
|
<div key={step.id} className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
|
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
|
{i + 1}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">{step.approver_name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{step.max_days}d limit
|
|
{step.auto_escalate ? " · auto-escalate" : ""}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{i < wf.steps.length - 1 && (
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Completed Requests */}
|
|
{completedRequests.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<FileText className="h-5 w-5" />
|
|
Recent Decisions ({completedRequests.length})
|
|
</h2>
|
|
{completedRequests.map((req) => (
|
|
<Card key={req.id} className="border-0 shadow-sm">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium">{req.workflow_name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{req.requester_name} · {req.res_model.replace("encoach.", "")} #
|
|
{req.res_id}
|
|
{req.created_at && (
|
|
<>
|
|
{" "}
|
|
· {new Date(req.created_at).toLocaleDateString()}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
{stateBadge(req.state)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Reject Dialog */}
|
|
<Dialog
|
|
open={rejectDialog !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setRejectDialog(null);
|
|
setRejectComment("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Reject Request</DialogTitle>
|
|
<DialogDescription>Provide a reason for rejection.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<Label>Comment</Label>
|
|
<Textarea
|
|
value={rejectComment}
|
|
onChange={(e) => setRejectComment(e.target.value)}
|
|
placeholder="Reason for rejection..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={rejectMut.isPending}
|
|
onClick={() => {
|
|
if (rejectDialog !== null) {
|
|
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
|
|
}
|
|
}}
|
|
>
|
|
{rejectMut.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : null}
|
|
Reject
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|