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:
@@ -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"],
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user