Add admin-emailed one-time intake links

- intake_tokens table (server-only via service role)
- Server functions: createIntakeToken (admin), getIntakeToken (public validate),
  submitIntake (public write + mark used, 14-day one-time tokens)
- Public /intake/$token full intake form (no login) with valid/used/expired states
- Student profile (admin): generate link, copy, and email-to-parent (mailto)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:00:39 -04:00
co-authored by Claude Opus 4.8
parent eb94c7ec34
commit 90e3c2c188
6 changed files with 393 additions and 1 deletions
+39 -1
View File
@@ -10,10 +10,11 @@ import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy } from "lucide-react";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy, Link2, Mail } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createUserFn } from "@/lib/user-admin.functions";
import { createIntakeToken } from "@/lib/intake.functions";
import { genTempPassword } from "@/lib/temp-password";
import { StudentGradeReport } from "@/components/gradebook";
@@ -323,6 +324,43 @@ function FamilyTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit
<PickupList studentId={studentId} editing={editing} kind="pickup" title="Authorized pick-up" subtitle="Only these individuals may pick up the student (valid ID required)." />
<PickupList studentId={studentId} editing={editing} kind="emergency" title="Emergency contacts" subtitle="Contacted if guardians are unreachable." />
{isAdmin && <ParentAccessSection studentId={studentId} />}
{isAdmin && <IntakeLinkSection studentId={studentId} />}
</div>
);
}
function IntakeLinkSection({ studentId }: { studentId: string }) {
const { data: student } = useQuery({
queryKey: ["student-name", studentId],
queryFn: async () => (await supabase.from("students").select("first_name, last_name").eq("id", studentId).maybeSingle()).data,
});
const name = student ? `${student.first_name} ${student.last_name}` : "this student";
const [link, setLink] = useState<string | null>(null);
const gen = useMutation({
mutationFn: async () => {
const { token } = await createIntakeToken({ data: { studentId } });
return `${window.location.origin}/intake/${token}`;
},
onSuccess: (url) => { setLink(url); toast.success("Intake link created"); },
onError: (e: Error) => toast.error(e.message),
});
const mailto = link
? `mailto:?subject=${encodeURIComponent(`Student intake for ${name} — Bayside Academy`)}&body=${encodeURIComponent(`Please complete the student intake form for ${name} using this one-time link (valid 14 days):\n\n${link}\n\nThank you,\nBayside Academy`)}`
: "#";
return (
<div className="space-y-3 border-t pt-6">
<div><h3 className="font-semibold flex items-center gap-2"><Link2 className="h-4 w-4" /> Intake form link</h3><p className="text-xs text-muted-foreground">Generate a one-time link a parent can use to fill out this student's intake — no login required, valid 14 days.</p></div>
<Button size="sm" variant="outline" onClick={() => gen.mutate()} disabled={gen.isPending}>{gen.isPending ? "Creating…" : link ? "Generate a new link" : "Generate intake link"}</Button>
{link && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm space-y-2">
<div className="font-mono text-xs break-all">{link}</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => { navigator.clipboard.writeText(link); toast.success("Copied"); }}><Copy className="h-4 w-4 mr-1" /> Copy link</Button>
<a href={mailto}><Button size="sm"><Mail className="h-4 w-4 mr-1" /> Email to parent</Button></a>
</div>
<p className="text-xs text-muted-foreground">Note: this link lets whoever opens it fill out {name}'s intake once. Share it only with the parent.</p>
</div>
)}
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useMutation } from "@tanstack/react-query";
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 { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GraduationCap, Loader2, CheckCircle2, Plus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { getIntakeToken, submitIntake, type IntakePayload } from "@/lib/intake.functions";
export const Route = createFileRoute("/intake/$token")({
head: () => ({ meta: [{ title: "Student intake — Bayside Academy" }] }),
component: IntakePage,
});
const emptyGuardian = { is_primary: false, full_name: "", relationship: "", phone: "", address: "", city: "", state: "", zip: "", email: "", employer: "" };
const emptyLogin = { website: "", student_login: "", student_password: "", parent_account: "", parent_password: "" };
const DISMISSAL = [
{ value: "not_allowed", label: "NOT ALLOWED" }, { value: "bicycle", label: "Bicycle" }, { value: "scooter", label: "Scooter" },
{ value: "walking", label: "Walking" }, { value: "rideshare", label: "Rideshare (Uber/Lyft)" }, { value: "other", label: "Other" },
];
function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) {
return <div className={className}><Label className="text-xs">{label}</Label>{children}</div>;
}
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return <section className="bg-card border rounded-lg p-5 space-y-4"><div><h2 className="font-semibold">{title}</h2>{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}</div>{children}</section>;
}
// Top-level so the inputs don't remount / lose focus on each keystroke.
function GuardianFields({ g, setG }: { g: typeof emptyGuardian; setG: (v: typeof emptyGuardian) => void }) {
const f = (k: keyof typeof emptyGuardian) => (e: React.ChangeEvent<HTMLInputElement>) => setG({ ...g, [k]: e.target.value });
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full name" className="col-span-2 md:col-span-1"><Input value={g.full_name} onChange={f("full_name")} /></Field>
<Field label="Relationship"><Input value={g.relationship} onChange={f("relationship")} /></Field>
<Field label="Phone"><Input value={g.phone} onChange={f("phone")} /></Field>
<Field label="Address" className="col-span-2 md:col-span-3"><Input value={g.address} onChange={f("address")} /></Field>
<Field label="City"><Input value={g.city} onChange={f("city")} /></Field>
<Field label="State"><Input value={g.state} onChange={f("state")} /></Field>
<Field label="Zip"><Input value={g.zip} onChange={f("zip")} /></Field>
<Field label="Email"><Input type="email" value={g.email} onChange={f("email")} /></Field>
<Field label="Employer"><Input value={g.employer} onChange={f("employer")} /></Field>
</div>
);
}
function IntakePage() {
const { token } = Route.useParams();
const [done, setDone] = useState(false);
const { data: info, isLoading } = useQuery({
queryKey: ["intake-token", token],
queryFn: async () => getIntakeToken({ data: { token } }),
retry: false,
});
const [s, setS] = useState({
dob: "", gender: "", grade_level: "", preferred_start_date: "", allergies: "", chronic_conditions: "", primary_physician: "", physician_phone: "",
home_ed_eval_due: "", previous_schools: "", special_needs: "", portfolio_keeper: "", disciplinary_history: "", custody_agreement: "",
dismissal_other: "", backup_transport_plan: "", interests: "", other_info: "", agreement_signed_by: "", agreement_signed_date: "",
});
const set = (k: keyof typeof s) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setS((f) => ({ ...f, [k]: e.target.value }));
const [primary, setPrimary] = useState({ ...emptyGuardian, is_primary: true });
const [secondary, setSecondary] = useState({ ...emptyGuardian });
const [contacts, setContacts] = useState([{ name: "", relationship: "", phone: "", alt_phone: "" }, { name: "", relationship: "", phone: "", alt_phone: "" }]);
const [pickups, setPickups] = useState([{ name: "", relationship: "", phone: "", notes: "" }, { name: "", relationship: "", phone: "", notes: "" }, { name: "", relationship: "", phone: "", notes: "" }]);
const [logins, setLogins] = useState([{ ...emptyLogin }]);
const [dismissal, setDismissal] = useState<string[]>([]);
const [agree, setAgree] = useState(false);
const submit = useMutation({
mutationFn: async () => {
const payload: IntakePayload = {
student: { ...s, dismissal_methods: dismissal },
guardians: [primary, secondary],
contacts, pickups, logins,
};
return submitIntake({ data: { token, payload } });
},
onSuccess: () => setDone(true),
onError: (e: Error) => toast.error(e.message),
});
if (isLoading) return <Centered><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></Centered>;
if (!info || info.status !== "ok") {
const msg = info?.status === "used" ? "This intake link has already been used." : info?.status === "expired" ? "This intake link has expired. Please ask the school for a new one." : "This intake link is not valid.";
return <Centered><div className="text-center max-w-sm"><h1 className="text-lg font-semibold">Link unavailable</h1><p className="text-sm text-muted-foreground mt-1">{msg}</p></div></Centered>;
}
if (done) return <Centered><div className="text-center max-w-sm"><CheckCircle2 className="h-10 w-10 text-green-600 mx-auto" /><h1 className="text-lg font-semibold mt-3">Thank you!</h1><p className="text-sm text-muted-foreground mt-1">{info.firstName}'s information has been submitted to Bayside Academy.</p></div></Centered>;
return (
<div className="min-h-screen bg-muted/20">
<div className="max-w-3xl mx-auto p-6 md:p-8 pb-24">
<div className="flex items-center gap-2 mb-1"><GraduationCap className="h-6 w-6 text-primary" /><h1 className="text-2xl font-semibold">Student intake</h1></div>
<p className="text-muted-foreground text-sm mb-6">For <span className="font-medium">{info.firstName} {info.lastName}</span> — Bayside Academy. Please complete and submit.</p>
<form onSubmit={(e) => { e.preventDefault(); submit.mutate(); }} className="space-y-5">
<Section title="Student information">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Date of birth"><Input type="date" value={s.dob} onChange={set("dob")} /></Field>
<Field label="Gender"><Select value={s.gender} onValueChange={(v) => setS((f) => ({ ...f, gender: v }))}><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="male">Male</SelectItem><SelectItem value="female">Female</SelectItem><SelectItem value="other">Other</SelectItem></SelectContent></Select></Field>
<Field label="Grade level applying for"><Input value={s.grade_level} onChange={set("grade_level")} /></Field>
<Field label="Preferred start date"><Input type="date" value={s.preferred_start_date} onChange={set("preferred_start_date")} /></Field>
</div>
</Section>
<Section title="Primary parent / guardian"><GuardianFields g={primary} setG={setPrimary} /></Section>
<Section title="Secondary parent / guardian" description="Optional"><GuardianFields g={secondary} setG={setSecondary} /></Section>
<Section title="Health">
<Field label="Allergies (blank if none)"><Textarea rows={2} value={s.allergies} onChange={set("allergies")} /></Field>
<Field label="Chronic conditions (blank if none)"><Textarea rows={2} value={s.chronic_conditions} onChange={set("chronic_conditions")} /></Field>
<div className="grid grid-cols-2 gap-3"><Field label="Primary physician"><Input value={s.primary_physician} onChange={set("primary_physician")} /></Field><Field label="Physician phone"><Input value={s.physician_phone} onChange={set("physician_phone")} /></Field></div>
</Section>
<Section title="Emergency contacts" description="If different from the guardians above.">
{contacts.map((c, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Name"><Input value={c.name} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /></Field>
<Field label="Relationship"><Input value={c.relationship} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, relationship: e.target.value } : x))} /></Field>
<Field label="Phone"><Input value={c.phone} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, phone: e.target.value } : x))} /></Field>
<Field label="Alt phone"><Input value={c.alt_phone} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, alt_phone: e.target.value } : x))} /></Field>
</div>
))}
</Section>
<Section title="Authorized persons for pick-up" description="Only these individuals may pick up the student (valid ID required).">
{pickups.map((p, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Name"><Input value={p.name} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /></Field>
<Field label="Relationship"><Input value={p.relationship} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, relationship: e.target.value } : x))} /></Field>
<Field label="Phone"><Input value={p.phone} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, phone: e.target.value } : x))} /></Field>
<Field label="Notes / restrictions"><Input value={p.notes} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, notes: e.target.value } : x))} /></Field>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setPickups([...pickups, { name: "", relationship: "", phone: "", notes: "" }])}><Plus className="h-4 w-4 mr-1" /> Add another</Button>
</Section>
<Section title="Academics">
<div className="space-y-2">
<Label className="text-xs">Curriculum platform logins</Label>
{logins.map((l, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-3 gap-2 border rounded-md p-3">
<Field label="Website"><Input value={l.website} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, website: e.target.value } : x))} /></Field>
<Field label="Student login"><Input value={l.student_login} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, student_login: e.target.value } : x))} /></Field>
<Field label="Student password"><Input value={l.student_password} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, student_password: e.target.value } : x))} /></Field>
<Field label="Parent account"><Input value={l.parent_account} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, parent_account: e.target.value } : x))} /></Field>
<Field label="Parent password"><Input value={l.parent_password} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, parent_password: e.target.value } : x))} /></Field>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setLogins([...logins, { ...emptyLogin }])}><Plus className="h-4 w-4 mr-1" /> Add platform</Button>
</div>
<div className="grid grid-cols-2 gap-3"><Field label="Home Ed. Annual Evaluation due to BPS"><Input type="date" value={s.home_ed_eval_due} onChange={set("home_ed_eval_due")} /></Field><Field label="Previous school(s)"><Input value={s.previous_schools} onChange={set("previous_schools")} /></Field></div>
<Field label="Special academic needs / accommodations"><Textarea rows={2} value={s.special_needs} onChange={set("special_needs")} /></Field>
<Field label="Who keeps the portfolio"><Select value={s.portfolio_keeper} onValueChange={(v) => setS((f) => ({ ...f, portfolio_keeper: v }))}><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="parent">Parent/Guardian</SelectItem><SelectItem value="bayside">Bayside Academy</SelectItem></SelectContent></Select></Field>
<Field label="Disciplinary history (blank if none)"><Textarea rows={2} value={s.disciplinary_history} onChange={set("disciplinary_history")} /></Field>
<Field label="Custody agreement (blank if none)"><Textarea rows={2} value={s.custody_agreement} onChange={set("custody_agreement")} /></Field>
</Section>
<Section title="Independent dismissal" description="Check all methods by which the student may leave campus independently.">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">{DISMISSAL.map((o) => <label key={o.value} className="flex items-center gap-2 text-sm"><Checkbox checked={dismissal.includes(o.value)} onCheckedChange={() => setDismissal((d) => d.includes(o.value) ? d.filter((x) => x !== o.value) : [...d, o.value])} />{o.label}</label>)}</div>
{dismissal.includes("other") && <Field label="Other (describe)"><Input value={s.dismissal_other} onChange={set("dismissal_other")} /></Field>}
<Field label="Backup transportation plan"><Textarea rows={2} value={s.backup_transport_plan} onChange={set("backup_transport_plan")} /></Field>
</Section>
<Section title="Additional information">
<Field label="Interests / hobbies / activities"><Textarea rows={2} value={s.interests} onChange={set("interests")} /></Field>
<Field label="Anything else we should know"><Textarea rows={2} value={s.other_info} onChange={set("other_info")} /></Field>
</Section>
<Section title="Agreement & signature">
<label className="flex items-start gap-2 text-sm"><Checkbox checked={agree} onCheckedChange={(v) => setAgree(!!v)} className="mt-0.5" /><span>I certify that the information provided is accurate and complete to the best of my knowledge.</span></label>
<div className="grid grid-cols-2 gap-3"><Field label="Signed by (parent/guardian name)"><Input value={s.agreement_signed_by} onChange={set("agreement_signed_by")} /></Field><Field label="Date"><Input type="date" value={s.agreement_signed_date} onChange={set("agreement_signed_date")} /></Field></div>
</Section>
<div className="sticky bottom-0 -mx-6 md:-mx-8 px-6 md:px-8 py-3 bg-background/90 backdrop-blur border-t flex items-center gap-3">
<Button type="submit" disabled={!agree || submit.isPending}>{submit.isPending ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Submitting…</> : "Submit intake"}</Button>
{!agree && <span className="text-xs text-muted-foreground">Check the agreement box to submit.</span>}
</div>
</form>
</div>
</div>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen flex items-center justify-center bg-muted/20 p-6">{children}</div>;
}