Add full-page student intake form matching Bayside paper forms

- Migration: student intake fields (health, academic, dismissal, agreement),
  student_guardians + student_curriculum_logins tables, extend authorized_pickups
  (alt_phone, notes, kind) with RLS mirroring existing policies
- New /students/new full-page multi-section form (replaces the add dialog)
- Students list links to the full page; regenerate Supabase types

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:21:38 -04:00
co-authored by Claude Opus 4.8
parent f509fa6528
commit cee27a6189
5 changed files with 663 additions and 58 deletions
+194
View File
@@ -12,6 +12,31 @@ export type Database = {
__InternalSupabase: {
PostgrestVersion: "14.5"
}
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
extensions?: Json
operationName?: string
query?: string
variables?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
attendance: {
@@ -54,27 +79,39 @@ export type Database = {
}
authorized_pickups: {
Row: {
alt_phone: string | null
created_at: string
id: string
kind: string
name: string
notes: string | null
phone: string | null
relationship: string | null
sort_order: number
student_id: string
}
Insert: {
alt_phone?: string | null
created_at?: string
id?: string
kind?: string
name: string
notes?: string | null
phone?: string | null
relationship?: string | null
sort_order?: number
student_id: string
}
Update: {
alt_phone?: string | null
created_at?: string
id?: string
kind?: string
name?: string
notes?: string | null
phone?: string | null
relationship?: string | null
sort_order?: number
student_id?: string
}
Relationships: [
@@ -415,41 +452,195 @@ export type Database = {
}
Relationships: []
}
student_curriculum_logins: {
Row: {
created_at: string
id: string
parent_account: string | null
parent_password: string | null
student_id: string
student_login: string | null
student_password: string | null
website: string | null
}
Insert: {
created_at?: string
id?: string
parent_account?: string | null
parent_password?: string | null
student_id: string
student_login?: string | null
student_password?: string | null
website?: string | null
}
Update: {
created_at?: string
id?: string
parent_account?: string | null
parent_password?: string | null
student_id?: string
student_login?: string | null
student_password?: string | null
website?: string | null
}
Relationships: [
{
foreignKeyName: "student_curriculum_logins_student_id_fkey"
columns: ["student_id"]
isOneToOne: false
referencedRelation: "students"
referencedColumns: ["id"]
},
]
}
student_guardians: {
Row: {
address: string | null
city: string | null
created_at: string
email: string | null
employer: string | null
full_name: string
id: string
is_primary: boolean
phone: string | null
relationship: string | null
state: string | null
student_id: string
zip: string | null
}
Insert: {
address?: string | null
city?: string | null
created_at?: string
email?: string | null
employer?: string | null
full_name: string
id?: string
is_primary?: boolean
phone?: string | null
relationship?: string | null
state?: string | null
student_id: string
zip?: string | null
}
Update: {
address?: string | null
city?: string | null
created_at?: string
email?: string | null
employer?: string | null
full_name?: string
id?: string
is_primary?: boolean
phone?: string | null
relationship?: string | null
state?: string | null
student_id?: string
zip?: string | null
}
Relationships: [
{
foreignKeyName: "student_guardians_student_id_fkey"
columns: ["student_id"]
isOneToOne: false
referencedRelation: "students"
referencedColumns: ["id"]
},
]
}
students: {
Row: {
agreement_signed_by: string | null
agreement_signed_date: string | null
allergies: string | null
backup_transport_plan: string | null
chronic_conditions: string | null
class_id: string | null
created_at: string
custody_agreement: string | null
disciplinary_history: string | null
dismissal_methods: string[]
dismissal_other: string | null
dob: string | null
first_name: string
gender: string | null
grade_level: string | null
home_ed_eval_due: string | null
id: string
interests: string | null
last_name: string
notes: string | null
other_info: string | null
photo_release: boolean
physician_phone: string | null
portfolio_keeper: string | null
preferred_start_date: string | null
previous_schools: string | null
primary_physician: string | null
special_needs: string | null
updated_at: string
}
Insert: {
agreement_signed_by?: string | null
agreement_signed_date?: string | null
allergies?: string | null
backup_transport_plan?: string | null
chronic_conditions?: string | null
class_id?: string | null
created_at?: string
custody_agreement?: string | null
disciplinary_history?: string | null
dismissal_methods?: string[]
dismissal_other?: string | null
dob?: string | null
first_name: string
gender?: string | null
grade_level?: string | null
home_ed_eval_due?: string | null
id?: string
interests?: string | null
last_name: string
notes?: string | null
other_info?: string | null
photo_release?: boolean
physician_phone?: string | null
portfolio_keeper?: string | null
preferred_start_date?: string | null
previous_schools?: string | null
primary_physician?: string | null
special_needs?: string | null
updated_at?: string
}
Update: {
agreement_signed_by?: string | null
agreement_signed_date?: string | null
allergies?: string | null
backup_transport_plan?: string | null
chronic_conditions?: string | null
class_id?: string | null
created_at?: string
custody_agreement?: string | null
disciplinary_history?: string | null
dismissal_methods?: string[]
dismissal_other?: string | null
dob?: string | null
first_name?: string
gender?: string | null
grade_level?: string | null
home_ed_eval_due?: string | null
id?: string
interests?: string | null
last_name?: string
notes?: string | null
other_info?: string | null
photo_release?: boolean
physician_phone?: string | null
portfolio_keeper?: string | null
preferred_start_date?: string | null
previous_schools?: string | null
primary_physician?: string | null
special_needs?: string | null
updated_at?: string
}
Relationships: [
@@ -659,6 +850,9 @@ export type CompositeTypes<
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
app_role: ["admin", "teacher", "parent"],
+22
View File
@@ -20,6 +20,7 @@ import { Route as AuthenticatedDashboardRouteImport } from './routes/_authentica
import { Route as AuthenticatedCalendarRouteImport } from './routes/_authenticated/calendar'
import { Route as AuthenticatedAttendanceRouteImport } from './routes/_authenticated/attendance'
import { Route as AuthenticatedAdminRouteImport } from './routes/_authenticated/admin'
import { Route as AuthenticatedStudentsNewRouteImport } from './routes/_authenticated/students.new'
import { Route as AuthenticatedStudentsIdRouteImport } from './routes/_authenticated/students.$id'
const AuthRoute = AuthRouteImport.update({
@@ -76,6 +77,12 @@ const AuthenticatedAdminRoute = AuthenticatedAdminRouteImport.update({
path: '/admin',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedStudentsNewRoute =
AuthenticatedStudentsNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => AuthenticatedStudentsRoute,
} as any)
const AuthenticatedStudentsIdRoute = AuthenticatedStudentsIdRouteImport.update({
id: '/$id',
path: '/$id',
@@ -94,6 +101,7 @@ export interface FileRoutesByFullPath {
'/messages': typeof AuthenticatedMessagesRoute
'/students': typeof AuthenticatedStudentsRouteWithChildren
'/students/$id': typeof AuthenticatedStudentsIdRoute
'/students/new': typeof AuthenticatedStudentsNewRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -107,6 +115,7 @@ export interface FileRoutesByTo {
'/messages': typeof AuthenticatedMessagesRoute
'/students': typeof AuthenticatedStudentsRouteWithChildren
'/students/$id': typeof AuthenticatedStudentsIdRoute
'/students/new': typeof AuthenticatedStudentsNewRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -122,6 +131,7 @@ export interface FileRoutesById {
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
'/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute
'/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -137,6 +147,7 @@ export interface FileRouteTypes {
| '/messages'
| '/students'
| '/students/$id'
| '/students/new'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -150,6 +161,7 @@ export interface FileRouteTypes {
| '/messages'
| '/students'
| '/students/$id'
| '/students/new'
id:
| '__root__'
| '/'
@@ -164,6 +176,7 @@ export interface FileRouteTypes {
| '/_authenticated/messages'
| '/_authenticated/students'
| '/_authenticated/students/$id'
| '/_authenticated/students/new'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -251,6 +264,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAdminRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/students/new': {
id: '/_authenticated/students/new'
path: '/new'
fullPath: '/students/new'
preLoaderRoute: typeof AuthenticatedStudentsNewRouteImport
parentRoute: typeof AuthenticatedStudentsRoute
}
'/_authenticated/students/$id': {
id: '/_authenticated/students/$id'
path: '/$id'
@@ -263,10 +283,12 @@ declare module '@tanstack/react-router' {
interface AuthenticatedStudentsRouteChildren {
AuthenticatedStudentsIdRoute: typeof AuthenticatedStudentsIdRoute
AuthenticatedStudentsNewRoute: typeof AuthenticatedStudentsNewRoute
}
const AuthenticatedStudentsRouteChildren: AuthenticatedStudentsRouteChildren = {
AuthenticatedStudentsIdRoute: AuthenticatedStudentsIdRoute,
AuthenticatedStudentsNewRoute: AuthenticatedStudentsNewRoute,
}
const AuthenticatedStudentsRouteWithChildren =
+354
View File
@@ -0,0 +1,354 @@
import { createFileRoute, useNavigate, Link } 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 { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ArrowLeft, Plus, Trash2, Lock, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/_authenticated/students/new")({
head: () => ({ meta: [{ title: "New student — School Portal" }] }),
component: NewStudentPage,
});
type Guardian = {
full_name: string; relationship: string; phone: string; address: string;
city: string; state: string; zip: string; email: string; employer: string;
};
type Contact = { name: string; relationship: string; phone: string; alt_phone: string };
type Pickup = { name: string; relationship: string; phone: string; notes: string };
type Login = { website: string; student_login: string; student_password: string; parent_account: string; parent_password: string };
const emptyGuardian: Guardian = { full_name: "", relationship: "", phone: "", address: "", city: "", state: "", zip: "", email: "", employer: "" };
const emptyLogin: Login = { website: "", student_login: "", student_password: "", parent_account: "", parent_password: "" };
const DISMISSAL_OPTIONS = [
{ 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" },
];
// Small labeled-field helpers to keep the long form readable.
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 (not defined inside the page) so the inputs don't remount / lose focus on each keystroke.
function GuardianFields({ g, setG }: { g: Guardian; setG: (v: Guardian) => void }) {
const f = (k: keyof Guardian) => (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 to student"><Input value={g.relationship} onChange={f("relationship")} /></Field>
<Field label="Phone number"><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 code"><Input value={g.zip} onChange={f("zip")} /></Field>
<Field label="Email address"><Input type="email" value={g.email} onChange={f("email")} /></Field>
<Field label="Employer"><Input value={g.employer} onChange={f("employer")} /></Field>
</div>
);
}
function NewStudentPage() {
const { roles } = useAuth();
const navigate = useNavigate();
const qc = useQueryClient();
const { data: classes } = useQuery({
queryKey: ["classes"],
queryFn: async () => (await supabase.from("classes").select("id, name").order("name")).data ?? [],
});
// ── form state ─────────────────────────────────────────────────────────────
const [s, setS] = useState({
first_name: "", last_name: "", dob: "", gender: "", grade_level: "", preferred_start_date: "", class_id: "",
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({ ...s, [k]: e.target.value });
const [primary, setPrimary] = useState<Guardian>({ ...emptyGuardian });
const [secondary, setSecondary] = useState<Guardian>({ ...emptyGuardian });
const [contacts, setContacts] = useState<Contact[]>([
{ name: "", relationship: "", phone: "", alt_phone: "" },
{ name: "", relationship: "", phone: "", alt_phone: "" },
]);
const [pickups, setPickups] = useState<Pickup[]>([
{ name: "", relationship: "", phone: "", notes: "" },
{ name: "", relationship: "", phone: "", notes: "" },
{ name: "", relationship: "", phone: "", notes: "" },
]);
const [logins, setLogins] = useState<Login[]>([{ ...emptyLogin }]);
const [dismissal, setDismissal] = useState<string[]>([]);
const [agree, setAgree] = useState(false);
const toggleDismissal = (v: string) =>
setDismissal((d) => (d.includes(v) ? d.filter((x) => x !== v) : [...d, v]));
const save = useMutation({
mutationFn: async () => {
// 1. student
const { data: student, error } = await supabase
.from("students")
.insert({
first_name: s.first_name.trim(),
last_name: s.last_name.trim(),
dob: s.dob || null,
gender: s.gender || null,
grade_level: s.grade_level || null,
preferred_start_date: s.preferred_start_date || null,
class_id: s.class_id || null,
allergies: s.allergies || null,
chronic_conditions: s.chronic_conditions || null,
primary_physician: s.primary_physician || null,
physician_phone: s.physician_phone || null,
home_ed_eval_due: s.home_ed_eval_due || null,
previous_schools: s.previous_schools || null,
special_needs: s.special_needs || null,
portfolio_keeper: s.portfolio_keeper || null,
disciplinary_history: s.disciplinary_history || null,
custody_agreement: s.custody_agreement || null,
dismissal_methods: dismissal,
dismissal_other: s.dismissal_other || null,
backup_transport_plan: s.backup_transport_plan || null,
interests: s.interests || null,
other_info: s.other_info || null,
agreement_signed_by: s.agreement_signed_by || null,
agreement_signed_date: s.agreement_signed_date || null,
})
.select("id")
.single();
if (error) throw error;
const studentId = student.id;
// 2. guardians
const guardianRows = [
{ g: primary, is_primary: true },
{ g: secondary, is_primary: false },
]
.filter(({ g }) => g.full_name.trim())
.map(({ g, is_primary }) => ({
student_id: studentId, is_primary,
full_name: g.full_name.trim(), relationship: g.relationship || null, phone: g.phone || null,
address: g.address || null, city: g.city || null, state: g.state || null, zip: g.zip || null,
email: g.email || null, employer: g.employer || null,
}));
if (guardianRows.length) {
const { error: gErr } = await supabase.from("student_guardians").insert(guardianRows);
if (gErr) throw gErr;
}
// 3. authorized pickups + emergency contacts (one table, distinguished by kind)
const pickupRows = [
...pickups.filter((p) => p.name.trim()).map((p, i) => ({
student_id: studentId, kind: "pickup", sort_order: i,
name: p.name.trim(), relationship: p.relationship || null, phone: p.phone || null,
alt_phone: null as string | null, notes: p.notes || null,
})),
...contacts.filter((c) => c.name.trim()).map((c, i) => ({
student_id: studentId, kind: "emergency", sort_order: i,
name: c.name.trim(), relationship: c.relationship || null, phone: c.phone || null,
alt_phone: c.alt_phone || null, notes: null as string | null,
})),
];
if (pickupRows.length) {
const { error: pErr } = await supabase.from("authorized_pickups").insert(pickupRows);
if (pErr) throw pErr;
}
// 4. curriculum logins
const loginRows = logins
.filter((l) => Object.values(l).some((v) => v.trim()))
.map((l) => ({
student_id: studentId,
website: l.website || null, student_login: l.student_login || null, student_password: l.student_password || null,
parent_account: l.parent_account || null, parent_password: l.parent_password || null,
}));
if (loginRows.length) {
const { error: lErr } = await supabase.from("student_curriculum_logins").insert(loginRows);
if (lErr) throw lErr;
}
return studentId as string;
},
onSuccess: (id) => {
toast.success("Student added");
qc.invalidateQueries({ queryKey: ["students"] });
navigate({ to: "/students/$id", params: { id } });
},
onError: (e: Error) => toast.error(e.message),
});
if (!roles.includes("admin")) {
return <div className="p-8"><Lock className="h-6 w-6 text-muted-foreground" /><h1 className="mt-2 text-xl font-semibold">Admins only</h1><p className="text-sm text-muted-foreground">Only admins can add students.</p></div>;
}
const canSave = s.first_name.trim() && s.last_name.trim() && !save.isPending;
return (
<div className="p-6 md:p-8 max-w-4xl mx-auto pb-24">
<Link to="/students" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"><ArrowLeft className="h-4 w-4" /> Back to students</Link>
<h1 className="text-2xl font-semibold mb-1">New student</h1>
<p className="text-muted-foreground text-sm mb-6">Student Information & Authorized Pick-Up — Bayside Academy</p>
<form onSubmit={(e) => { e.preventDefault(); if (canSave) save.mutate(); }} className="space-y-5">
{/* Student */}
<Section title="Student information">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="First name"><Input value={s.first_name} onChange={set("first_name")} required /></Field>
<Field label="Last name"><Input value={s.last_name} onChange={set("last_name")} required /></Field>
<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({ ...s, 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>
<Field label="Class (optional)">
<Select value={s.class_id} onValueChange={(v) => setS({ ...s, class_id: v })}>
<SelectTrigger><SelectValue placeholder="Assign class" /></SelectTrigger>
<SelectContent>{(classes ?? []).map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
</div>
</Section>
{/* Guardians */}
<Section title="Primary parent / guardian"><GuardianFields g={primary} setG={setPrimary} /></Section>
<Section title="Secondary parent / guardian" description="Optional"><GuardianFields g={secondary} setG={setSecondary} /></Section>
{/* Health */}
<Section title="Health information">
<Field label="Allergies (leave blank if none)"><Textarea rows={2} value={s.allergies} onChange={set("allergies")} /></Field>
<Field label="Chronic illnesses or medical conditions (leave 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's phone"><Input value={s.physician_phone} onChange={set("physician_phone")} /></Field>
</div>
</Section>
{/* Emergency contacts */}
<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="Full 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="Alternate 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>
{/* Authorized pickups */}
<Section title="Authorized persons for pick-up" description="Only these individuals will be allowed to pick up the student. All must show valid ID.">
{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>
{/* Academic */}
<Section title="Academic information">
<div className="space-y-3">
<Label className="text-xs">Curriculum platform logins</Label>
{logins.map((l, i) => (
<div key={i} className="border rounded-md p-3 space-y-2 relative">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
<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>
{logins.length > 1 && <Button type="button" size="icon" variant="ghost" className="absolute top-1 right-1" onClick={() => setLogins(logins.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>}
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setLogins([...logins, { ...emptyLogin }])}><Plus className="h-4 w-4 mr-1" /> Add another platform</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Home Education 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) attended"><Input value={s.previous_schools} onChange={set("previous_schools")} /></Field>
</div>
<Field label="Special academic needs or accommodations (leave blank if none)"><Textarea rows={2} value={s.special_needs} onChange={set("special_needs")} /></Field>
<Field label="Who will keep a portfolio of work">
<Select value={s.portfolio_keeper} onValueChange={(v) => setS({ ...s, 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 (suspension, expulsion, asked not to return, etc.) — describe circumstances & supports, or leave blank"><Textarea rows={3} value={s.disciplinary_history} onChange={set("disciplinary_history")} /></Field>
<Field label="Custody agreement we should be aware of (leave blank if none — please provide documentation)"><Textarea rows={2} value={s.custody_agreement} onChange={set("custody_agreement")} /></Field>
</Section>
{/* Independent dismissal */}
<Section title="Independent dismissal permission" 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_OPTIONS.map((o) => (
<label key={o.value} className="flex items-center gap-2 text-sm">
<Checkbox checked={dismissal.includes(o.value)} onCheckedChange={() => toggleDismissal(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 (required if independent dismissal is allowed)"><Textarea rows={2} value={s.backup_transport_plan} onChange={set("backup_transport_plan")} /></Field>
</Section>
{/* Additional */}
<Section title="Additional information">
<Field label="Interests, hobbies, or extracurricular activities"><Textarea rows={2} value={s.interests} onChange={set("interests")} /></Field>
<Field label="Anything else we should know about the student"><Textarea rows={2} value={s.other_info} onChange={set("other_info")} /></Field>
</Section>
{/* Agreement */}
<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, and I understand false statements may result in discontinuation of enrollment. I understand the student will only be released to authorized individuals and I am responsible for keeping this information current.</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={!canSave}>{save.isPending ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Saving…</> : "Save student"}</Button>
<Link to="/students"><Button type="button" variant="ghost">Cancel</Button></Link>
{!s.first_name.trim() || !s.last_name.trim() ? <span className="text-xs text-muted-foreground">First and last name are required.</span> : null}
</div>
</form>
</div>
);
}
+4 -58
View File
@@ -1,15 +1,9 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, ChevronRight } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/_authenticated/students")({
head: () => ({ meta: [{ title: "Students — School Portal" }] }),
@@ -19,7 +13,6 @@ export const Route = createFileRoute("/_authenticated/students")({
function StudentsPage() {
const { roles } = useAuth();
const isAdmin = roles.includes("admin");
const qc = useQueryClient();
const { data: students } = useQuery({
queryKey: ["students"],
@@ -33,34 +26,6 @@ function StudentsPage() {
},
});
const { data: classes } = useQuery({
queryKey: ["classes"],
queryFn: async () => (await supabase.from("classes").select("*").order("name")).data ?? [],
});
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ first_name: "", last_name: "", dob: "", class_id: "", allergies: "" });
const createStudent = useMutation({
mutationFn: async () => {
const { error } = await supabase.from("students").insert({
first_name: form.first_name,
last_name: form.last_name,
dob: form.dob || null,
class_id: form.class_id || null,
allergies: form.allergies || null,
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Student added");
setOpen(false);
setForm({ first_name: "", last_name: "", dob: "", class_id: "", allergies: "" });
qc.invalidateQueries({ queryKey: ["students"] });
},
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="p-8 max-w-6xl">
<div className="flex justify-between items-center mb-6">
@@ -69,28 +34,9 @@ function StudentsPage() {
<p className="text-muted-foreground text-sm">{students?.length ?? 0} students visible to you</p>
</div>
{isAdmin && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> Add student</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New student</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><Label>First name</Label><Input value={form.first_name} onChange={(e) => setForm({ ...form, first_name: e.target.value })} /></div>
<div><Label>Last name</Label><Input value={form.last_name} onChange={(e) => setForm({ ...form, last_name: e.target.value })} /></div>
</div>
<div><Label>Date of birth</Label><Input type="date" value={form.dob} onChange={(e) => setForm({ ...form, dob: e.target.value })} /></div>
<div>
<Label>Class</Label>
<Select value={form.class_id} onValueChange={(v) => setForm({ ...form, class_id: v })}>
<SelectTrigger><SelectValue placeholder="Choose class" /></SelectTrigger>
<SelectContent>{(classes ?? []).map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div><Label>Allergies</Label><Input value={form.allergies} onChange={(e) => setForm({ ...form, allergies: e.target.value })} placeholder="None" /></div>
<Button className="w-full" disabled={!form.first_name || !form.last_name || createStudent.isPending} onClick={() => createStudent.mutate()}>Create student</Button>
</div>
</DialogContent>
</Dialog>
<Link to="/students/new">
<Button><Plus className="h-4 w-4 mr-1" /> Add student</Button>
</Link>
)}
</div>
@@ -0,0 +1,89 @@
-- Expand student intake to match Bayside Academy's paper forms:
-- Student Information Form + Authorized Persons Pick-Up Form.
-- ── Extra columns on students ────────────────────────────────────────────────
ALTER TABLE public.students
ADD COLUMN IF NOT EXISTS gender TEXT,
ADD COLUMN IF NOT EXISTS grade_level TEXT,
ADD COLUMN IF NOT EXISTS preferred_start_date DATE,
-- health (allergies TEXT already exists)
ADD COLUMN IF NOT EXISTS chronic_conditions TEXT,
ADD COLUMN IF NOT EXISTS primary_physician TEXT,
ADD COLUMN IF NOT EXISTS physician_phone TEXT,
-- academic
ADD COLUMN IF NOT EXISTS home_ed_eval_due DATE,
ADD COLUMN IF NOT EXISTS previous_schools TEXT,
ADD COLUMN IF NOT EXISTS special_needs TEXT,
ADD COLUMN IF NOT EXISTS portfolio_keeper TEXT, -- 'parent' | 'bayside'
ADD COLUMN IF NOT EXISTS disciplinary_history TEXT,
ADD COLUMN IF NOT EXISTS custody_agreement TEXT, -- explanation; blank = none
-- independent dismissal
ADD COLUMN IF NOT EXISTS dismissal_methods TEXT[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS dismissal_other TEXT,
ADD COLUMN IF NOT EXISTS backup_transport_plan TEXT,
-- additional
ADD COLUMN IF NOT EXISTS interests TEXT,
ADD COLUMN IF NOT EXISTS other_info TEXT,
-- agreement
ADD COLUMN IF NOT EXISTS agreement_signed_by TEXT,
ADD COLUMN IF NOT EXISTS agreement_signed_date DATE;
-- ── Primary / secondary guardians ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.student_guardians (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT TRUE,
full_name TEXT NOT NULL,
relationship TEXT,
phone TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
email TEXT,
employer TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_guardians_student ON public.student_guardians(student_id);
ALTER TABLE public.student_guardians ENABLE ROW LEVEL SECURITY;
CREATE POLICY "guardians read" ON public.student_guardians FOR SELECT TO authenticated
USING (
public.current_user_has_role('admin')
OR public.is_parent_of(student_id)
OR public.teaches_student(student_id)
);
CREATE POLICY "guardians write parent or admin" ON public.student_guardians FOR ALL TO authenticated
USING (public.current_user_has_role('admin') OR public.is_parent_of(student_id))
WITH CHECK (public.current_user_has_role('admin') OR public.is_parent_of(student_id));
-- ── Curriculum platform logins (repeatable) ──────────────────────────────────
CREATE TABLE IF NOT EXISTS public.student_curriculum_logins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
website TEXT,
student_login TEXT,
student_password TEXT,
parent_account TEXT,
parent_password TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_curriculum_logins_student ON public.student_curriculum_logins(student_id);
ALTER TABLE public.student_curriculum_logins ENABLE ROW LEVEL SECURITY;
CREATE POLICY "curriculum read" ON public.student_curriculum_logins FOR SELECT TO authenticated
USING (
public.current_user_has_role('admin')
OR public.is_parent_of(student_id)
OR public.teaches_student(student_id)
);
CREATE POLICY "curriculum write parent or admin" ON public.student_curriculum_logins FOR ALL TO authenticated
USING (public.current_user_has_role('admin') OR public.is_parent_of(student_id))
WITH CHECK (public.current_user_has_role('admin') OR public.is_parent_of(student_id));
-- ── Authorized pickups: also hold emergency contacts + notes ─────────────────
ALTER TABLE public.authorized_pickups
ADD COLUMN IF NOT EXISTS alt_phone TEXT,
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'pickup', -- 'pickup' | 'emergency'
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;