Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
202 lines
10 KiB
TypeScript
202 lines
10 KiB
TypeScript
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
import { Plus, Trash2, FileText } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
type Field = { label: string; type: "text" | "textarea" | "yesno" | "date" };
|
|
|
|
export const Route = createFileRoute("/_authenticated/forms")({
|
|
head: () => ({ meta: [{ title: "Forms — School Portal" }] }),
|
|
component: FormsPage,
|
|
});
|
|
|
|
function FormsPage() {
|
|
const { user, roles } = useAuth();
|
|
const isAdmin = roles.includes("admin");
|
|
const qc = useQueryClient();
|
|
const [activeForm, setActiveForm] = useState<string | null>(null);
|
|
|
|
const { data: forms } = useQuery({
|
|
queryKey: ["forms"],
|
|
queryFn: async () => (await supabase.from("forms").select("*").eq("active", true).order("created_at", { ascending: false })).data ?? [],
|
|
});
|
|
|
|
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 && <FormBuilder onCreated={() => qc.invalidateQueries({ queryKey: ["forms"] })} />}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{(forms ?? []).map((f) => (
|
|
<button key={f.id} onClick={() => setActiveForm(f.id)} className="text-left bg-card border rounded-lg p-4 hover:bg-muted/30">
|
|
<FileText className="h-5 w-5 text-primary mb-2" />
|
|
<div className="font-medium">{f.title}</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)</div>
|
|
</button>
|
|
))}
|
|
{forms?.length === 0 && <div className="text-sm text-muted-foreground">No active forms.</div>}
|
|
</div>
|
|
|
|
{activeForm && <FormFillDialog formId={activeForm} onClose={() => setActiveForm(null)} isAdmin={isAdmin} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FormBuilder({ onCreated }: { onCreated: () => void }) {
|
|
const { user } = useAuth();
|
|
const [open, setOpen] = useState(false);
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [fields, setFields] = useState<Field[]>([{ label: "Full name", type: "text" }]);
|
|
|
|
const create = useMutation({
|
|
mutationFn: async () => {
|
|
const { error } = await supabase.from("forms").insert({ title, description, schema_json: fields, created_by: user!.id });
|
|
if (error) throw error;
|
|
},
|
|
onSuccess: () => { setOpen(false); setTitle(""); setDescription(""); setFields([{ label: "Full name", type: "text" }]); onCreated(); toast.success("Form created"); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> New form</Button></DialogTrigger>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader><DialogTitle>Create form</DialogTitle></DialogHeader>
|
|
<div className="space-y-3 max-h-[70vh] overflow-auto">
|
|
<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>
|
|
<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) => { const c = [...fields]; c[i] = { ...c[i], label: e.target.value }; setFields(c); }} placeholder="Field label" />
|
|
<select className="border rounded-md px-2 text-sm" value={f.type} onChange={(e) => { const c = [...fields]; c[i] = { ...c[i], type: e.target.value as Field["type"] }; setFields(c); }}>
|
|
<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, { label: "", type: "text" }])}><Plus className="h-4 w-4 mr-1" /> Add field</Button>
|
|
</div>
|
|
</div>
|
|
<Button className="w-full" disabled={!title || create.isPending} onClick={() => create.mutate()}>Create form</Button>
|
|
</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] }); 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>}
|
|
|
|
<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.profiles as { full_name?: string } | null)?.full_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>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|