diff --git a/src/routes/_authenticated/forms.tsx b/src/routes/_authenticated/forms.tsx index 1740f47..bef5bd6 100644 --- a/src/routes/_authenticated/forms.tsx +++ b/src/routes/_authenticated/forms.tsx @@ -6,103 +6,350 @@ 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Plus, Trash2, FileText } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Plus, Trash2, FileText, Pencil, EyeOff, Eye, AlertTriangle } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; type Field = { label: string; type: "text" | "textarea" | "yesno" | "date" }; +type FormRow = { + id: string; + title: string; + description: string | null; + schema_json: unknown; + active: boolean; +}; export const Route = createFileRoute("/_authenticated/forms")({ head: () => ({ meta: [{ title: "Forms — School Portal" }] }), component: FormsPage, }); +const emptyField: Field = { label: "", type: "text" }; + function FormsPage() { - const { user, roles } = useAuth(); + const { roles } = useAuth(); const isAdmin = roles.includes("admin"); const qc = useQueryClient(); const [activeForm, setActiveForm] = useState(null); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + // Admins see inactive forms too, otherwise deactivating one would hide it from + // the only people who could bring it back. const { data: forms } = useQuery({ - queryKey: ["forms"], - queryFn: async () => (await supabase.from("forms").select("*").eq("active", true).order("created_at", { ascending: false })).data ?? [], + queryKey: ["forms", isAdmin], + queryFn: async () => { + let q = supabase.from("forms").select("*"); + if (!isAdmin) q = q.eq("active", true); + return ((await q.order("created_at", { ascending: false })).data ?? []) as FormRow[]; + }, }); + // Response counts drive the delete warning — form_responses cascades on + // delete, so removing a form destroys its submissions. + const { data: counts } = useQuery({ + queryKey: ["form-response-counts"], + enabled: isAdmin, + queryFn: async () => { + const { data } = await supabase.from("form_responses").select("form_id"); + const map: Record = {}; + for (const r of data ?? []) map[r.form_id] = (map[r.form_id] ?? 0) + 1; + return map; + }, + }); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["forms"] }); + qc.invalidateQueries({ queryKey: ["form-response-counts"] }); + }; + + const setActive = useMutation({ + mutationFn: async ({ id, active }: { id: string; active: boolean }) => { + const { error } = await supabase.from("forms").update({ active }).eq("id", id); + if (error) throw error; + return active; + }, + onSuccess: (active) => { + toast.success(active ? "Form activated" : "Form deactivated"); + refresh(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const remove = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase.from("forms").delete().eq("id", id); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Form deleted"); + refresh(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const confirmDelete = (f: FormRow) => { + const n = counts?.[f.id] ?? 0; + const msg = + n > 0 + ? `Delete "${f.title}"?\n\nThis also permanently deletes ${n} submitted response${n === 1 ? "" : "s"}. That cannot be undone.\n\nTo stop families seeing the form while keeping its responses, deactivate it instead.` + : `Delete "${f.title}"? This cannot be undone.`; + if (confirm(msg)) remove.mutate(f.id); + }; + return (

Forms

-

Push a form to families; they complete it in the portal.

+

+ Push a form to families; they complete it in the portal. +

- {isAdmin && qc.invalidateQueries({ queryKey: ["forms"] })} />} + {isAdmin && ( + + )}
- {(forms ?? []).map((f) => ( - - ))} - {forms?.length === 0 &&
No active forms.
} + {(forms ?? []).map((f) => { + const n = counts?.[f.id] ?? 0; + return ( +
+ + + {isAdmin && ( +
+ + +
+ +
+ )} +
+ ); + })} + {forms?.length === 0 &&
No forms yet.
}
- {activeForm && setActiveForm(null)} isAdmin={isAdmin} />} + {creating && ( + { + setCreating(false); + refresh(); + }} + /> + )} + {editing && ( + { + setEditing(null); + refresh(); + }} + /> + )} + {activeForm && ( + setActiveForm(null)} isAdmin={isAdmin} /> + )}
); } -function FormBuilder({ onCreated }: { onCreated: () => void }) { +// Create and edit share one editor: the only differences are the initial values +// and whether we insert or update. +function FormEditor({ + form, + responseCount = 0, + onDone, +}: { + form?: FormRow; + responseCount?: number; + onDone: () => void; +}) { const { user } = useAuth(); - const [open, setOpen] = useState(false); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [fields, setFields] = useState([{ label: "Full name", type: "text" }]); + const isEdit = !!form; + const [title, setTitle] = useState(form?.title ?? ""); + const [description, setDescription] = useState(form?.description ?? ""); + const [active, setActive] = useState(form?.active ?? true); + const [fields, setFields] = useState( + (form?.schema_json as Field[]) ?? [{ label: "Full name", type: "text" }], + ); - const create = useMutation({ + const save = useMutation({ mutationFn: async () => { - const { error } = await supabase.from("forms").insert({ title, description, schema_json: fields, created_by: user!.id }); - if (error) throw error; + const clean = fields.filter((f) => f.label.trim() !== ""); + if (clean.length === 0) throw new Error("Add at least one field with a label"); + if (isEdit) { + const { error } = await supabase + .from("forms") + .update({ title, description, schema_json: clean, active }) + .eq("id", form!.id); + if (error) throw error; + } else { + const { error } = await supabase + .from("forms") + .insert({ title, description, schema_json: clean, active, created_by: user!.id }); + if (error) throw error; + } + }, + onSuccess: () => { + toast.success(isEdit ? "Form updated" : "Form created"); + onDone(); }, - onSuccess: () => { setOpen(false); setTitle(""); setDescription(""); setFields([{ label: "Full name", type: "text" }]); onCreated(); toast.success("Form created"); }, onError: (e: Error) => toast.error(e.message), }); + const setField = (i: number, patch: Partial) => + setFields((fs) => fs.map((f, j) => (j === i ? { ...f, ...patch } : f))); + return ( - - + !o && onDone()}> - Create form + + {isEdit ? "Edit form" : "Create form"} +
-
setTitle(e.target.value)} />
-