From cee27a61894764fd6ce1746739762939eb1a0c8a Mon Sep 17 00:00:00 2001 From: renee-png Date: Sat, 18 Jul 2026 22:21:38 -0400 Subject: [PATCH] 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 --- src/integrations/supabase/types.ts | 194 ++++++++++ src/routeTree.gen.ts | 22 ++ src/routes/_authenticated/students.new.tsx | 354 ++++++++++++++++++ src/routes/_authenticated/students.tsx | 62 +-- .../20260719000000_expand_student_intake.sql | 89 +++++ 5 files changed, 663 insertions(+), 58 deletions(-) create mode 100644 src/routes/_authenticated/students.new.tsx create mode 100644 supabase/migrations/20260719000000_expand_student_intake.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 39f3455..5b3c21a 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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"], diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b4cf9a0..9847ac5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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 = diff --git a/src/routes/_authenticated/students.new.tsx b/src/routes/_authenticated/students.new.tsx new file mode 100644 index 0000000..5884648 --- /dev/null +++ b/src/routes/_authenticated/students.new.tsx @@ -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
{children}
; +} +function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {children} +
+ ); +} + +// 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) => setG({ ...g, [k]: e.target.value }); + return ( +
+ + + + + + + + + +
+ ); +} + +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) => setS({ ...s, [k]: e.target.value }); + + const [primary, setPrimary] = useState({ ...emptyGuardian }); + 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([]); + 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

Admins only

Only admins can add students.

; + } + + const canSave = s.first_name.trim() && s.last_name.trim() && !save.isPending; + + return ( +
+ Back to students +

New student

+

Student Information & Authorized Pick-Up — Bayside Academy

+ +
{ e.preventDefault(); if (canSave) save.mutate(); }} className="space-y-5"> + {/* Student */} +
+
+ + + + + + + + + + + +
+
+ + {/* Guardians */} +
+
+ + {/* Health */} +
+