Files
info-share-spot/src/routes/_authenticated/forms.tsx
T
2026-06-30 20:44:37 +00:00

196 lines
9.8 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 () => (await supabase.from("form_responses").select("*, students(first_name, last_name), profiles!form_responses_submitted_by_fkey(full_name)").eq("form_id", formId).order("submitted_at", { ascending: false })).data ?? [],
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>
);
}