Files
encoach_frontend_new_v2/src/pages/ApprovalWorkflowsPage.tsx
2026-04-26 03:12:52 +04:00

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} &middot;{" "}
{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} &middot; {req.res_model.replace("encoach.", "")} #
{req.res_id}
{req.created_at && (
<>
{" "}
&middot; {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>
);
}