diff --git a/src/routes/_authenticated/forms.tsx b/src/routes/_authenticated/forms.tsx new file mode 100644 index 0000000..bef5bd6 --- /dev/null +++ b/src/routes/_authenticated/forms.tsx @@ -0,0 +1,512 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +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 { 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 { 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", 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. +

+
+ {isAdmin && ( + + )} +
+ +
+ {(forms ?? []).map((f) => { + const n = counts?.[f.id] ?? 0; + return ( +
+ + + {isAdmin && ( +
+ + +
+ +
+ )} +
+ ); + })} + {forms?.length === 0 &&
No forms yet.
} +
+ + {creating && ( + { + setCreating(false); + refresh(); + }} + /> + )} + {editing && ( + { + setEditing(null); + refresh(); + }} + /> + )} + {activeForm && ( + setActiveForm(null)} isAdmin={isAdmin} /> + )} +
+ ); +} + +// 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 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 save = useMutation({ + mutationFn: async () => { + 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(); + }, + 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()}> + + + {isEdit ? "Edit form" : "Create form"} + +
+ {/* Responses store answers keyed by field label, so a rename leaves the + old answers stranded under the old key. Say so rather than silently + breaking the response view. */} + {isEdit && responseCount > 0 && ( +
+ + + This form already has {responseCount} response{responseCount === 1 ? "" : "s"}. + Answers are stored under their field label, so renaming or removing a field won't + change what was already submitted — those answers stay under the old label. Adding + fields is always safe. + +
+ )} + +
+ + setTitle(e.target.value)} /> +
+
+ +