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 ; if (s === "rejected") return ; return ; }; const stateBadge = (state: string) => { const map: Record = { approved: "default", in_progress: "secondary", rejected: "destructive", draft: "outline", }; return ( {state.replace(/_/g, " ")} ); }; 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(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) => 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 (
{/* Header */}

Approval Workflows

Manage multi-step approval processes for exam content.

{ setCreateOpen(open); if (!open) resetForm(); }} > Create Workflow Define a multi-step approval process.
setFormName(e.target.value)} placeholder="e.g. Exam Content Review" />
{formSteps.map((step, i) => (
{formSteps.length > 1 && ( )}
))}
{/* Loading */} {(workflowsQ.isLoading || requestsQ.isLoading) && (
)} {/* Pending Requests */} {pendingRequests.length > 0 && (

Pending Approval ({pendingRequests.length})

{pendingRequests.map((req) => { const wf = workflows.find((w) => w.id === req.workflow_id); return (

{req.workflow_name}

Requested by {req.requester_name} ·{" "} {req.res_model.replace("encoach.", "")} #{req.res_id}

{stateBadge(req.state)}
{wf && (
{wf.steps.map((step, i) => { const isActive = step.id === req.current_stage_id; return (
{statusIcon(step.status)}

Step {i + 1}

{step.approver_name}

{i < wf.steps.length - 1 && ( )}
); })}
)}
); })}
)} {/* Workflow Templates */}

Workflow Templates ({workflows.length})

{workflows.length === 0 && !workflowsQ.isLoading && ( No workflows defined yet. Create one to get started. )} {workflows.map((wf) => (
{wf.name} {wf.type.replace(/_/g, " ")}
{wf.steps.map((step, i) => (
{i + 1}

{step.approver_name}

{step.max_days}d limit {step.auto_escalate ? " · auto-escalate" : ""}

{i < wf.steps.length - 1 && ( )}
))}
))}
{/* Completed Requests */} {completedRequests.length > 0 && (

Recent Decisions ({completedRequests.length})

{completedRequests.map((req) => (

{req.workflow_name}

{req.requester_name} · {req.res_model.replace("encoach.", "")} # {req.res_id} {req.created_at && ( <> {" "} · {new Date(req.created_at).toLocaleDateString()} )}

{stateBadge(req.state)}
))}
)} {/* Reject Dialog */} { if (!open) { setRejectDialog(null); setRejectComment(""); } }} > Reject Request Provide a reason for rejection.