Audit of the admin Configuration section (FAQ Manager, Notification Rules,
Approval Config) surfaced three contract mismatches and one missing schema.
FAQ
* Backend `encoach.faq.category.audience` / `encoach.faq.item.audience`
rejected the `both` and `entity` values emitted by the FAQ Manager
UI. Widened the Selection so the UI's full vocabulary round-trips.
* FAQ item `video_url` was already accepted by the UI but silently
dropped by the controller; now persisted via a new `video_url`
field on `encoach.faq.item` and serialized on list/get responses.
Notification Rules
* Added `days_before`, `frequency`, `channel`, `entity_id` to
`encoach.notification.rule`. These are the exact fields the admin
form collects; prior to this change they were serialized into the
JSON body, ignored by the controller, and omitted from responses,
leaving the Active switch permanently off and the table columns
blank.
* Controller now translates `active` <-> `is_active` both ways so the
new frontend contract and legacy callers coexist. Missing required
fields return HTTP 400 instead of 500.
Approval Workflows
* The controller had been hand-rolling raw SQL against three tables
(`encoach_approval_workflow`, `_stage`, `_request`) that no Odoo
model declared, so the tables never existed. List() was guarded and
returned empty; create() would 500 with "relation does not exist".
* Introduced real ORM models in `encoach_exam_template/models/approval.py`
plus access rights, which auto-provision the tables on -u.
* Rewrote the controller to use ORM, added PATCH, and emitted both
`items` and `results` in the list envelope so the frontend's
PaginatedResponse reader and legacy callers both work. Step
payloads now carry `max_days`, `auto_escalate`, and
`notification_email` end to end.
* Frontend `approvalsService` and `ApprovalWorkflowConfig` updated to
send the full stage shape + `allow_bypass`, tolerate both envelope
keys, and validate at least one approver before submit.
Schema delta applied via `./run.sh -u encoach_exam_template,encoach_lms_api`.
Verified with new `test_config_flows.py`: 24/24 passing. Regression runs
on `test_support_flows.py` (29/29) and `test_training_flows.py` (26/26)
remain green.
Made-with: Cursor
239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
import { useState } from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
import { Plus, Trash2, Loader2, Settings2, ArrowRight } from "lucide-react";
|
|
import { approvalsService, type ApprovalWorkflow, type ApprovalStage } from "@/services/approvals.service";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
type WorkflowStage = Omit<ApprovalStage, "order"> & { order: number };
|
|
|
|
export default function ApprovalWorkflowConfig() {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
|
|
const { data: workflowsData, isLoading } = useQuery({
|
|
queryKey: ["approvals", "list"],
|
|
queryFn: () => approvalsService.list(),
|
|
});
|
|
const workflows: ApprovalWorkflow[] = workflowsData?.results ?? workflowsData?.items ?? [];
|
|
|
|
type WorkflowWriteInput = Partial<ApprovalWorkflow> & {
|
|
steps?: Partial<ApprovalStage>[];
|
|
bypass_enabled?: boolean;
|
|
};
|
|
|
|
const createWorkflow = useMutation({
|
|
mutationFn: (data: WorkflowWriteInput) => approvalsService.create(data),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
|
|
onError: () => toast({ title: "Error", description: "Failed to create workflow", variant: "destructive" }),
|
|
});
|
|
|
|
const updateWorkflow = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: WorkflowWriteInput }) => approvalsService.update(id, data),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
|
|
onError: () => toast({ title: "Error", description: "Failed to update workflow", variant: "destructive" }),
|
|
});
|
|
|
|
const deleteWorkflow = useMutation({
|
|
mutationFn: (id: number) => approvalsService.delete(id),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
|
|
onError: () => toast({ title: "Error", description: "Failed to delete workflow", variant: "destructive" }),
|
|
});
|
|
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [wfName, setWfName] = useState("");
|
|
const [stages, setStages] = useState<WorkflowStage[]>([{ order: 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
|
|
const [bypassEnabled, setBypassEnabled] = useState(false);
|
|
|
|
const resetForm = () => {
|
|
setWfName("");
|
|
setStages([{ order: 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
|
|
setBypassEnabled(false);
|
|
};
|
|
|
|
const addStage = () => {
|
|
setStages(prev => [...prev, { order: prev.length + 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
|
|
};
|
|
|
|
const removeStage = (idx: number) => {
|
|
setStages(prev => prev.filter((_, i) => i !== idx).map((s, i) => ({ ...s, order: i + 1 })));
|
|
};
|
|
|
|
const updateStage = (idx: number, field: keyof WorkflowStage, value: string | number | boolean) => {
|
|
setStages(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s));
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
const valid = stages.filter(s => s.approver_id > 0);
|
|
if (valid.length === 0) {
|
|
toast({ title: "Validation", description: "Add at least one stage with an approver ID", variant: "destructive" });
|
|
return;
|
|
}
|
|
createWorkflow.mutate(
|
|
{
|
|
name: wfName,
|
|
status: "draft",
|
|
allow_bypass: bypassEnabled,
|
|
bypass_enabled: bypassEnabled,
|
|
steps: valid.map(s => ({
|
|
order: s.order,
|
|
approver_id: s.approver_id,
|
|
approver_name: s.approver_name,
|
|
max_days: s.max_days,
|
|
auto_escalate: s.auto_escalate,
|
|
notification_email: s.notification_email,
|
|
})),
|
|
},
|
|
{ onSuccess: () => { toast({ title: "Workflow Created" }); setShowCreate(false); resetForm(); } },
|
|
);
|
|
};
|
|
|
|
const handleDelete = (id: number) => {
|
|
if (!window.confirm("Delete this approval workflow?")) return;
|
|
deleteWorkflow.mutate(id, {
|
|
onSuccess: () => toast({ title: "Workflow Deleted" }),
|
|
});
|
|
};
|
|
|
|
const handleToggleStatus = (wf: ApprovalWorkflow) => {
|
|
const newStatus = wf.status === "active" ? "draft" : "active";
|
|
updateWorkflow.mutate(
|
|
{ id: wf.id, data: { status: newStatus } },
|
|
{ onSuccess: () => toast({ title: `Workflow ${newStatus === "active" ? "Activated" : "Deactivated"}` }) },
|
|
);
|
|
};
|
|
|
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Approval Workflows</h1>
|
|
<p className="text-muted-foreground">Configure multi-stage approval workflows with escalation rules.</p>
|
|
</div>
|
|
<Dialog open={showCreate} onOpenChange={open => { setShowCreate(open); if (!open) resetForm(); }}>
|
|
<DialogTrigger asChild>
|
|
<Button><Plus className="mr-2 h-4 w-4" /> Create Workflow</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader><DialogTitle>Create Approval Workflow</DialogTitle></DialogHeader>
|
|
<div className="space-y-4 pt-4">
|
|
<div className="space-y-2">
|
|
<Label>Workflow Name</Label>
|
|
<Input placeholder="e.g. Leave Approval" value={wfName} onChange={e => setWfName(e.target.value)} />
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Stages</Label>
|
|
<Button variant="outline" size="sm" onClick={addStage}><Plus className="mr-2 h-3 w-3" /> Add Stage</Button>
|
|
</div>
|
|
{stages.map((stage, idx) => (
|
|
<Card key={idx}>
|
|
<CardContent className="py-3 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="outline">Stage {stage.order}</Badge>
|
|
{stages.length > 1 && (
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStage(idx)}>
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Approver Name</Label>
|
|
<Input value={stage.approver_name} onChange={e => updateStage(idx, "approver_name", e.target.value)} placeholder="Name" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Approver ID</Label>
|
|
<Input type="number" value={stage.approver_id || ""} onChange={e => updateStage(idx, "approver_id", Number(e.target.value))} placeholder="ID" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Max Days</Label>
|
|
<Input type="number" min={1} value={stage.max_days ?? 3} onChange={e => updateStage(idx, "max_days", Number(e.target.value))} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Email</Label>
|
|
<Input type="email" value={stage.notification_email ?? ""} onChange={e => updateStage(idx, "notification_email", e.target.value)} placeholder="email@..." />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch checked={!!stage.auto_escalate} onCheckedChange={v => updateStage(idx, "auto_escalate", v)} />
|
|
<Label className="text-xs">Auto-escalate after max days</Label>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 p-3 rounded-lg border">
|
|
<Switch checked={bypassEnabled} onCheckedChange={setBypassEnabled} />
|
|
<Label>Allow bypass (skip all stages)</Label>
|
|
</div>
|
|
|
|
<Button className="w-full" onClick={handleCreate} disabled={createWorkflow.isPending || !wfName || stages.length === 0}>
|
|
{createWorkflow.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Create Workflow
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{workflows.map(wf => (
|
|
<Card key={wf.id}>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Settings2 className="h-5 w-5 text-primary" />
|
|
<div>
|
|
<CardTitle className="text-base">{wf.name}</CardTitle>
|
|
<CardDescription>{wf.entity_name}</CardDescription>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={wf.status === "active" ? "default" : "secondary"}>{wf.status}</Badge>
|
|
<Switch checked={wf.status === "active"} onCheckedChange={() => handleToggleStatus(wf)} />
|
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(wf.id)} disabled={deleteWorkflow.isPending}>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{wf.steps.map((step, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1 px-3 py-1.5 rounded-full border bg-muted/50 text-sm">
|
|
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
|
|
<span>{step.approver_name}</span>
|
|
</div>
|
|
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-2">Created: {new Date(wf.created_at).toLocaleDateString()}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
{workflows.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-12 text-center text-muted-foreground">
|
|
<Settings2 className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
|
<p>No approval workflows configured.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|