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:
@@ -439,6 +439,44 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
intake_tokens: {
|
||||
Row: {
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
expires_at: string
|
||||
id: string
|
||||
student_id: string
|
||||
token: string
|
||||
used_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
expires_at: string
|
||||
id?: string
|
||||
student_id: string
|
||||
token: string
|
||||
used_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
expires_at?: string
|
||||
id?: string
|
||||
student_id?: string
|
||||
token?: string
|
||||
used_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "intake_tokens_student_id_fkey"
|
||||
columns: ["student_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "students"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
ledger_entries: {
|
||||
Row: {
|
||||
amount_cents: number
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware";
|
||||
|
||||
export type IntakePayload = {
|
||||
student: {
|
||||
dob: string; gender: string; grade_level: string; preferred_start_date: string;
|
||||
allergies: string; chronic_conditions: string; primary_physician: string; physician_phone: string;
|
||||
home_ed_eval_due: string; previous_schools: string; special_needs: string; portfolio_keeper: string;
|
||||
disciplinary_history: string; custody_agreement: string;
|
||||
dismissal_methods: string[]; dismissal_other: string; backup_transport_plan: string;
|
||||
interests: string; other_info: string; agreement_signed_by: string; agreement_signed_date: string;
|
||||
};
|
||||
guardians: { is_primary: boolean; full_name: string; relationship: string; phone: string; address: string; city: string; state: string; zip: string; email: string; employer: string }[];
|
||||
contacts: { name: string; relationship: string; phone: string; alt_phone: string }[];
|
||||
pickups: { name: string; relationship: string; phone: string; notes: string }[];
|
||||
logins: { website: string; student_login: string; student_password: string; parent_account: string; parent_password: string }[];
|
||||
};
|
||||
|
||||
const orNull = (v: string) => (v && v.trim() ? v.trim() : null);
|
||||
|
||||
// ── Admin: create a one-time intake link for a student ────────────────────────
|
||||
export const createIntakeToken = createServerFn({ method: "POST" })
|
||||
.middleware([requireSupabaseAuth])
|
||||
.validator((d: { studentId: string }) => d)
|
||||
.handler(async ({ data, context }) => {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const callerId = (context as { userId: string }).userId;
|
||||
const { data: adminRow } = await supabaseAdmin.from("user_roles").select("role").eq("user_id", callerId).eq("role", "admin").maybeSingle();
|
||||
if (!adminRow) throw new Error("Only admins can create intake links.");
|
||||
|
||||
const token = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
const expires_at = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const { error } = await supabaseAdmin.from("intake_tokens").insert({ token, student_id: data.studentId, created_by: callerId, expires_at });
|
||||
if (error) throw new Error(error.message);
|
||||
return { token };
|
||||
});
|
||||
|
||||
// ── Public: validate a token, return the student's name for the form ──────────
|
||||
export const getIntakeToken = createServerFn({ method: "POST" })
|
||||
.validator((d: { token: string }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data: row } = await supabaseAdmin.from("intake_tokens").select("student_id, expires_at, used_at").eq("token", data.token).maybeSingle();
|
||||
if (!row) return { status: "invalid" as const };
|
||||
if (row.used_at) return { status: "used" as const };
|
||||
if (new Date(row.expires_at) < new Date()) return { status: "expired" as const };
|
||||
const { data: student } = await supabaseAdmin.from("students").select("first_name, last_name").eq("id", row.student_id).maybeSingle();
|
||||
return { status: "ok" as const, firstName: student?.first_name ?? "", lastName: student?.last_name ?? "" };
|
||||
});
|
||||
|
||||
// ── Public: submit the intake, write to the student's record, mark used ───────
|
||||
export const submitIntake = createServerFn({ method: "POST" })
|
||||
.validator((d: { token: string; payload: IntakePayload }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data: row } = await supabaseAdmin.from("intake_tokens").select("id, student_id, expires_at, used_at").eq("token", data.token).maybeSingle();
|
||||
if (!row) throw new Error("This link is not valid.");
|
||||
if (row.used_at) throw new Error("This link has already been used.");
|
||||
if (new Date(row.expires_at) < new Date()) throw new Error("This link has expired.");
|
||||
|
||||
const sid = row.student_id;
|
||||
const s = data.payload.student;
|
||||
const { error: e1 } = await supabaseAdmin.from("students").update({
|
||||
dob: orNull(s.dob), gender: orNull(s.gender), grade_level: orNull(s.grade_level), preferred_start_date: orNull(s.preferred_start_date),
|
||||
allergies: orNull(s.allergies), chronic_conditions: orNull(s.chronic_conditions), primary_physician: orNull(s.primary_physician), physician_phone: orNull(s.physician_phone),
|
||||
home_ed_eval_due: orNull(s.home_ed_eval_due), previous_schools: orNull(s.previous_schools), special_needs: orNull(s.special_needs), portfolio_keeper: orNull(s.portfolio_keeper),
|
||||
disciplinary_history: orNull(s.disciplinary_history), custody_agreement: orNull(s.custody_agreement),
|
||||
dismissal_methods: s.dismissal_methods ?? [], dismissal_other: orNull(s.dismissal_other), backup_transport_plan: orNull(s.backup_transport_plan),
|
||||
interests: orNull(s.interests), other_info: orNull(s.other_info), agreement_signed_by: orNull(s.agreement_signed_by), agreement_signed_date: orNull(s.agreement_signed_date),
|
||||
}).eq("id", sid);
|
||||
if (e1) throw new Error(e1.message);
|
||||
|
||||
// Replace collections with what was submitted (initial intake is the source of truth here).
|
||||
await supabaseAdmin.from("student_guardians").delete().eq("student_id", sid);
|
||||
await supabaseAdmin.from("authorized_pickups").delete().eq("student_id", sid);
|
||||
await supabaseAdmin.from("student_curriculum_logins").delete().eq("student_id", sid);
|
||||
|
||||
const guardianRows = data.payload.guardians.filter((g) => g.full_name.trim()).map((g) => ({
|
||||
student_id: sid, is_primary: g.is_primary, full_name: g.full_name.trim(), relationship: orNull(g.relationship), phone: orNull(g.phone),
|
||||
address: orNull(g.address), city: orNull(g.city), state: orNull(g.state), zip: orNull(g.zip), email: orNull(g.email), employer: orNull(g.employer),
|
||||
}));
|
||||
if (guardianRows.length) { const { error } = await supabaseAdmin.from("student_guardians").insert(guardianRows); if (error) throw new Error(error.message); }
|
||||
|
||||
const pickupRows = [
|
||||
...data.payload.pickups.filter((p) => p.name.trim()).map((p, i) => ({ student_id: sid, kind: "pickup", sort_order: i, name: p.name.trim(), relationship: orNull(p.relationship), phone: orNull(p.phone), alt_phone: null as string | null, notes: orNull(p.notes) })),
|
||||
...data.payload.contacts.filter((c) => c.name.trim()).map((c, i) => ({ student_id: sid, kind: "emergency", sort_order: i, name: c.name.trim(), relationship: orNull(c.relationship), phone: orNull(c.phone), alt_phone: orNull(c.alt_phone), notes: null as string | null })),
|
||||
];
|
||||
if (pickupRows.length) { const { error } = await supabaseAdmin.from("authorized_pickups").insert(pickupRows); if (error) throw new Error(error.message); }
|
||||
|
||||
const loginRows = data.payload.logins.filter((l) => Object.values(l).some((v) => v.trim())).map((l) => ({
|
||||
student_id: sid, website: orNull(l.website), student_login: orNull(l.student_login), student_password: orNull(l.student_password), parent_account: orNull(l.parent_account), parent_password: orNull(l.parent_password),
|
||||
}));
|
||||
if (loginRows.length) { const { error } = await supabaseAdmin.from("student_curriculum_logins").insert(loginRows); if (error) throw new Error(error.message); }
|
||||
|
||||
await supabaseAdmin.from("intake_tokens").update({ used_at: new Date().toISOString() }).eq("id", row.id);
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthRouteImport } from './routes/auth'
|
||||
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as IntakeTokenRouteImport } from './routes/intake.$token'
|
||||
import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students'
|
||||
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
|
||||
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
|
||||
@@ -41,6 +42,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IntakeTokenRoute = IntakeTokenRouteImport.update({
|
||||
id: '/intake/$token',
|
||||
path: '/intake/$token',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedStudentsRoute = AuthenticatedStudentsRouteImport.update({
|
||||
id: '/students',
|
||||
path: '/students',
|
||||
@@ -127,6 +133,7 @@ export interface FileRoutesByFullPath {
|
||||
'/ledger': typeof AuthenticatedLedgerRoute
|
||||
'/messages': typeof AuthenticatedMessagesRoute
|
||||
'/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||
'/intake/$token': typeof IntakeTokenRoute
|
||||
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||
'/students/new': typeof AuthenticatedStudentsNewRoute
|
||||
@@ -143,6 +150,7 @@ export interface FileRoutesByTo {
|
||||
'/forms': typeof AuthenticatedFormsRoute
|
||||
'/ledger': typeof AuthenticatedLedgerRoute
|
||||
'/messages': typeof AuthenticatedMessagesRoute
|
||||
'/intake/$token': typeof IntakeTokenRoute
|
||||
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||
'/students/new': typeof AuthenticatedStudentsNewRoute
|
||||
@@ -163,6 +171,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
|
||||
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
|
||||
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||
'/intake/$token': typeof IntakeTokenRoute
|
||||
'/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||
'/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||
'/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute
|
||||
@@ -183,6 +192,7 @@ export interface FileRouteTypes {
|
||||
| '/ledger'
|
||||
| '/messages'
|
||||
| '/students'
|
||||
| '/intake/$token'
|
||||
| '/classes/$id'
|
||||
| '/students/$id'
|
||||
| '/students/new'
|
||||
@@ -199,6 +209,7 @@ export interface FileRouteTypes {
|
||||
| '/forms'
|
||||
| '/ledger'
|
||||
| '/messages'
|
||||
| '/intake/$token'
|
||||
| '/classes/$id'
|
||||
| '/students/$id'
|
||||
| '/students/new'
|
||||
@@ -218,6 +229,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/ledger'
|
||||
| '/_authenticated/messages'
|
||||
| '/_authenticated/students'
|
||||
| '/intake/$token'
|
||||
| '/_authenticated/classes/$id'
|
||||
| '/_authenticated/students/$id'
|
||||
| '/_authenticated/students/new'
|
||||
@@ -229,6 +241,7 @@ export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
|
||||
AuthRoute: typeof AuthRoute
|
||||
IntakeTokenRoute: typeof IntakeTokenRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -254,6 +267,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/intake/$token': {
|
||||
id: '/intake/$token'
|
||||
path: '/intake/$token'
|
||||
fullPath: '/intake/$token'
|
||||
preLoaderRoute: typeof IntakeTokenRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/students': {
|
||||
id: '/_authenticated/students'
|
||||
path: '/students'
|
||||
@@ -416,6 +436,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
|
||||
AuthRoute: AuthRoute,
|
||||
IntakeTokenRoute: IntakeTokenRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- One-time intake links: a token lets an unauthenticated parent fill out a
|
||||
-- student's intake. Reached only via server functions (service role); RLS on
|
||||
-- with no policies blocks all direct client access.
|
||||
CREATE TABLE IF NOT EXISTS public.intake_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_intake_tokens_token ON public.intake_tokens(token);
|
||||
ALTER TABLE public.intake_tokens ENABLE ROW LEVEL SECURITY;
|
||||
Reference in New Issue
Block a user