Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 04:15:48 +00:00
parent 80d8a302e5
commit c1b05938a6
+512
View File
@@ -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<string | null>(null);
const [editing, setEditing] = useState<FormRow | null>(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<string, number> = {};
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 (
<div className="p-8 max-w-5xl">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-semibold">Forms</h1>
<p className="text-muted-foreground text-sm">
Push a form to families; they complete it in the portal.
</p>
</div>
{isAdmin && (
<Button onClick={() => setCreating(true)}>
<Plus className="h-4 w-4 mr-1" /> New form
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(forms ?? []).map((f) => {
const n = counts?.[f.id] ?? 0;
return (
<div
key={f.id}
className={`bg-card border rounded-lg p-4 ${f.active ? "" : "opacity-70"}`}
>
<button onClick={() => setActiveForm(f.id)} className="text-left w-full">
<FileText className="h-5 w-5 text-primary mb-2" />
<div className="font-medium flex items-center gap-2">
{f.title}
{!f.active && <Badge variant="secondary">Inactive</Badge>}
</div>
{f.description && (
<div className="text-sm text-muted-foreground mt-1">{f.description}</div>
)}
<div className="text-xs text-muted-foreground mt-2">
{(f.schema_json as Field[])?.length ?? 0} field(s)
{isAdmin && ` · ${n} response${n === 1 ? "" : "s"}`}
</div>
</button>
{isAdmin && (
<div className="flex gap-1 mt-3 pt-3 border-t">
<Button size="sm" variant="outline" onClick={() => setEditing(f)}>
<Pencil className="h-4 w-4 mr-1" /> Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setActive.mutate({ id: f.id, active: !f.active })}
disabled={setActive.isPending}
>
{f.active ? (
<>
<EyeOff className="h-4 w-4 mr-1" /> Deactivate
</>
) : (
<>
<Eye className="h-4 w-4 mr-1" /> Activate
</>
)}
</Button>
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() => confirmDelete(f)}
disabled={remove.isPending}
title={n > 0 ? `Also deletes ${n} response(s)` : "Delete form"}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
})}
{forms?.length === 0 && <div className="text-sm text-muted-foreground">No forms yet.</div>}
</div>
{creating && (
<FormEditor
onDone={() => {
setCreating(false);
refresh();
}}
/>
)}
{editing && (
<FormEditor
form={editing}
responseCount={counts?.[editing.id] ?? 0}
onDone={() => {
setEditing(null);
refresh();
}}
/>
)}
{activeForm && (
<FormFillDialog formId={activeForm} onClose={() => setActiveForm(null)} isAdmin={isAdmin} />
)}
</div>
);
}
// 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<Field[]>(
(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<Field>) =>
setFields((fs) => fs.map((f, j) => (j === i ? { ...f, ...patch } : f)));
return (
<Dialog open onOpenChange={(o) => !o && onDone()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit form" : "Create form"}</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[70vh] overflow-auto">
{/* 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 && (
<div className="flex items-start gap-2 text-xs border rounded-md p-3">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 mt-0.5" />
<span>
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.
</span>
</div>
)}
<div>
<Label>Title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<Label>Description</Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="flex items-center justify-between max-w-sm">
<Label className="text-xs">Active — visible to families</Label>
<Switch checked={active} onCheckedChange={setActive} />
</div>
<div>
<Label>Fields</Label>
<div className="space-y-2 mt-1">
{fields.map((f, i) => (
<div key={i} className="flex gap-2">
<Input
className="flex-1"
value={f.label}
onChange={(e) => setField(i, { label: e.target.value })}
placeholder="Field label"
/>
<select
className="border rounded-md px-2 text-sm"
value={f.type}
onChange={(e) => setField(i, { type: e.target.value as Field["type"] })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
<option value="yesno">Yes / No</option>
<option value="date">Date</option>
</select>
<Button
size="icon"
variant="ghost"
onClick={() => setFields(fields.filter((_, j) => j !== i))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setFields([...fields, { ...emptyField }])}
>
<Plus className="h-4 w-4 mr-1" /> Add field
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
className="flex-1"
disabled={!title || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : isEdit ? "Save changes" : "Create form"}
</Button>
<Button variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function FormFillDialog({
formId,
onClose,
isAdmin,
}: {
formId: string;
onClose: () => void;
isAdmin: boolean;
}) {
const { user, roles } = useAuth();
const isParent = roles.includes("parent");
const qc = useQueryClient();
const [values, setValues] = useState<Record<string, string>>({});
const [studentId, setStudentId] = useState<string>("");
const { data: form } = useQuery({
queryKey: ["form", formId],
queryFn: async () => (await supabase.from("forms").select("*").eq("id", formId).single()).data,
});
const { data: myStudents } = useQuery({
queryKey: ["my-students", user?.id],
queryFn: async () => {
const { data } = await supabase
.from("parent_students")
.select("student_id, students(id, first_name, last_name)")
.eq("parent_id", user!.id);
return (data ?? []).map(
(d) => d.students as { id: string; first_name: string; last_name: string },
);
},
enabled: !!user,
});
const { data: responses } = useQuery({
queryKey: ["form-responses", formId],
queryFn: async () => {
const { data } = await supabase
.from("form_responses")
.select("*, students(first_name, last_name)")
.eq("form_id", formId)
.order("submitted_at", { ascending: false });
const ids = Array.from(new Set((data ?? []).map((r) => r.submitted_by)));
const { data: profs } = ids.length
? await supabase.from("profiles").select("id, full_name").in("id", ids)
: { data: [] };
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name]));
return (data ?? []).map((r) => ({ ...r, submitter_name: map[r.submitted_by] ?? "—" }));
},
enabled: isAdmin,
});
const submit = useMutation({
mutationFn: async () => {
const { error } = await supabase.from("form_responses").insert({
form_id: formId,
student_id: studentId || null,
submitted_by: user!.id,
data_json: values,
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Submitted");
qc.invalidateQueries({ queryKey: ["form-responses", formId] });
qc.invalidateQueries({ queryKey: ["form-response-counts"] });
onClose();
},
onError: (e: Error) => toast.error(e.message),
});
const fields = (form?.schema_json ?? []) as Field[];
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto">
<DialogHeader>
<DialogTitle>{form?.title}</DialogTitle>
</DialogHeader>
{form?.description && <p className="text-sm text-muted-foreground">{form.description}</p>}
{form && !form.active && (
<p className="text-xs text-muted-foreground">
This form is inactive — families cannot see or submit it.
</p>
)}
<div className="space-y-3 mt-2">
{isParent && myStudents && myStudents.length > 0 && (
<div>
<Label>For student</Label>
<select
className="w-full border rounded-md p-2 text-sm"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
>
<option value="">Select…</option>
{myStudents.map((s) => (
<option key={s.id} value={s.id}>
{s.first_name} {s.last_name}
</option>
))}
</select>
</div>
)}
{fields.map((f, i) => (
<div key={i}>
<Label>{f.label}</Label>
{f.type === "textarea" ? (
<Textarea
value={values[f.label] ?? ""}
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
/>
) : f.type === "yesno" ? (
<select
className="w-full border rounded-md p-2 text-sm"
value={values[f.label] ?? ""}
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
>
<option value="">—</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : (
<Input
type={f.type === "date" ? "date" : "text"}
value={values[f.label] ?? ""}
onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}
/>
)}
</div>
))}
<Button className="w-full" onClick={() => submit.mutate()} disabled={submit.isPending}>
Submit
</Button>
</div>
{isAdmin && (
<div className="mt-6 border-t pt-4">
<h3 className="font-medium text-sm mb-2">Responses ({responses?.length ?? 0})</h3>
<div className="space-y-2">
{(responses ?? []).map((r) => (
<div key={r.id} className="border rounded-md p-3 text-sm">
<div className="font-medium">
{(r.students as { first_name: string; last_name: string } | null)?.first_name ??
"—"}{" "}
{(r.students as { last_name?: string } | null)?.last_name ?? ""}
<span className="text-muted-foreground font-normal ml-2">
by {r.submitter_name} · {new Date(r.submitted_at).toLocaleDateString()}
</span>
</div>
<div className="mt-1 text-xs space-y-0.5">
{Object.entries(r.data_json as Record<string, string>).map(([k, v]) => (
<div key={k}>
<span className="text-muted-foreground">{k}:</span> {v}
</div>
))}
</div>
</div>
))}
{responses?.length === 0 && (
<p className="text-sm text-muted-foreground">No responses yet.</p>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}