Built school portal app
X-Lovable-Edit-ID: edt-17ee7c90-d59b-4379-9e6e-d262d1b2cabe Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
SUPABASE_PROJECT_ID="fvjpfhfgkhgtwtzrgcgx"
|
||||||
|
SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ2anBmaGZna2hndHd0enJnY2d4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODI4NDE0NDIsImV4cCI6MjA5ODQxNzQ0Mn0.B7dh8P2ZfUbJMtAGVbbRyyNFk71oqhJuqd0BNki-Dqk"
|
||||||
|
SUPABASE_URL="https://fvjpfhfgkhgtwtzrgcgx.supabase.co"
|
||||||
|
VITE_SUPABASE_PROJECT_ID="fvjpfhfgkhgtwtzrgcgx"
|
||||||
|
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ2anBmaGZna2hndHd0enJnY2d4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODI4NDE0NDIsImV4cCI6MjA5ODQxNzQ0Mn0.B7dh8P2ZfUbJMtAGVbbRyyNFk71oqhJuqd0BNki-Dqk"
|
||||||
|
VITE_SUPABASE_URL="https://fvjpfhfgkhgtwtzrgcgx.supabase.co"
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"name": "tanstack_start_ts",
|
"name": "tanstack_start_ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@supabase/supabase-js": "^2.108.2",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
@@ -230,6 +232,8 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
|
||||||
|
|
||||||
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.0.2", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-1c4XLFkpS614VxPyq+IX0us52vm5JWxdO1NfG2L1FjH0asKvHNu5//6wpnKHWmx60Y4HR1gcLaIiLACXIdeFyg=="],
|
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.0.2", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-1c4XLFkpS614VxPyq+IX0us52vm5JWxdO1NfG2L1FjH0asKvHNu5//6wpnKHWmx60Y4HR1gcLaIiLACXIdeFyg=="],
|
||||||
|
|
||||||
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.1.1", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.1.1.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-ySRUzOoGQYhj4Pd9EjaA7i1+2RiaS5lSVZasun72wVZZmJfFIciGYGQQDUu88HISn4icz0sugQxFRJGCNlTggg=="],
|
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.1.1", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.1.1.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-ySRUzOoGQYhj4Pd9EjaA7i1+2RiaS5lSVZasun72wVZZmJfFIciGYGQQDUu88HISn4icz0sugQxFRJGCNlTggg=="],
|
||||||
@@ -448,6 +452,20 @@
|
|||||||
|
|
||||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
|
"@supabase/auth-js": ["@supabase/auth-js@2.108.2", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/auth-js/-/auth-js-2.108.2.tgz", { "dependencies": { "tslib": "2.8.1" } }, "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag=="],
|
||||||
|
|
||||||
|
"@supabase/functions-js": ["@supabase/functions-js@2.108.2", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/functions-js/-/functions-js-2.108.2.tgz", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw=="],
|
||||||
|
|
||||||
|
"@supabase/phoenix": ["@supabase/phoenix@0.4.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/phoenix/-/phoenix-0.4.4.tgz", {}, "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ=="],
|
||||||
|
|
||||||
|
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.108.2", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw=="],
|
||||||
|
|
||||||
|
"@supabase/realtime-js": ["@supabase/realtime-js@2.108.2", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/realtime-js/-/realtime-js-2.108.2.tgz", { "dependencies": { "@supabase/phoenix": "^0.4.2", "tslib": "2.8.1" } }, "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g=="],
|
||||||
|
|
||||||
|
"@supabase/storage-js": ["@supabase/storage-js@2.108.2", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/storage-js/-/storage-js-2.108.2.tgz", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g=="],
|
||||||
|
|
||||||
|
"@supabase/supabase-js": ["@supabase/supabase-js@2.108.2", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/supabase-js/-/supabase-js-2.108.2.tgz", { "dependencies": { "@supabase/auth-js": "2.108.2", "@supabase/functions-js": "2.108.2", "@supabase/postgrest-js": "2.108.2", "@supabase/realtime-js": "2.108.2", "@supabase/storage-js": "2.108.2" } }, "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ=="],
|
||||||
|
|
||||||
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
|
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||||
@@ -804,6 +822,8 @@
|
|||||||
|
|
||||||
"httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="],
|
"httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="],
|
||||||
|
|
||||||
|
"iceberg-js": ["iceberg-js@0.8.1", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/iceberg-js/-/iceberg-js-0.8.1.tgz", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|||||||
Generated
+8328
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@supabase/supabase-js": "^2.108.2",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
|
export type Role = "admin" | "teacher" | "parent";
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
roles: Role[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const loadRoles = async (uid: string) => {
|
||||||
|
const { data } = await supabase.from("user_roles").select("role").eq("user_id", uid);
|
||||||
|
if (mounted) setRoles((data ?? []).map((r) => r.role as Role));
|
||||||
|
};
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setUser(data.session?.user ?? null);
|
||||||
|
if (data.session?.user) loadRoles(data.session.user.id).finally(() => mounted && setLoading(false));
|
||||||
|
else setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_e, session) => {
|
||||||
|
setUser(session?.user ?? null);
|
||||||
|
if (session?.user) {
|
||||||
|
setTimeout(() => loadRoles(session.user.id), 0);
|
||||||
|
} else {
|
||||||
|
setRoles([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
sub.subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { user, roles, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highestRole(roles: Role[]): Role | null {
|
||||||
|
if (roles.includes("admin")) return "admin";
|
||||||
|
if (roles.includes("teacher")) return "teacher";
|
||||||
|
if (roles.includes("parent")) return "parent";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// This file is auto-generated by Lovable. Do not modify it.
|
||||||
|
|
||||||
|
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
|
||||||
|
import { supabase } from "../supabase/client";
|
||||||
|
const lovableAuth = createLovableAuth();
|
||||||
|
|
||||||
|
type SignInOptions = {
|
||||||
|
redirect_uri?: string;
|
||||||
|
extraParams?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lovable = {
|
||||||
|
auth: {
|
||||||
|
signInWithOAuth: async (provider: "google" | "apple" | "microsoft" | "lovable", opts?: SignInOptions) => {
|
||||||
|
const result = await lovableAuth.signInWithOAuth(provider, {
|
||||||
|
redirect_uri: opts?.redirect_uri,
|
||||||
|
extraParams: {
|
||||||
|
...opts?.extraParams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.redirected) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await supabase.auth.setSession(result.tokens);
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e instanceof Error ? e : new Error(String(e)) };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// This file is automatically generated. Do not edit it directly.
|
||||||
|
import { createMiddleware } from '@tanstack/react-start'
|
||||||
|
import { supabase } from './client'
|
||||||
|
|
||||||
|
// Must be registered as a global `functionMiddleware` in `src/start.ts`; otherwise
|
||||||
|
// the browser never attaches the bearer token to serverFn RPCs.
|
||||||
|
export const attachSupabaseAuth = createMiddleware({ type: 'function' }).client(
|
||||||
|
async ({ next }) => {
|
||||||
|
const { data } = await supabase.auth.getSession()
|
||||||
|
const token = data.session?.access_token
|
||||||
|
return next({
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// This file is automatically generated. Do not edit it directly.
|
||||||
|
import { createMiddleware } from '@tanstack/react-start'
|
||||||
|
import { getRequest } from '@tanstack/react-start/server'
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
import type { Database } from './types'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function isNewSupabaseApiKey(value: string): boolean {
|
||||||
|
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||||
|
return (input, init) => {
|
||||||
|
const headers = new Headers(
|
||||||
|
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (init?.headers) {
|
||||||
|
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Supabase API keys are opaque strings, not bearer JWTs.
|
||||||
|
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
|
||||||
|
headers.delete('Authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('apikey', supabaseKey);
|
||||||
|
return fetch(input, { ...init, headers });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireSupabaseAuth = createMiddleware({ type: 'function' }).server(
|
||||||
|
async ({ next }) => {
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||||
|
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
|
||||||
|
const missing = [
|
||||||
|
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
|
||||||
|
...(!SUPABASE_PUBLISHABLE_KEY ? ['SUPABASE_PUBLISHABLE_KEY'] : []),
|
||||||
|
];
|
||||||
|
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
|
||||||
|
console.error(`[Supabase] ${message}`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = getRequest();
|
||||||
|
|
||||||
|
if (!request?.headers) {
|
||||||
|
throw new Error('Unauthorized: No request headers available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new Error('Unauthorized: No authorization header provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authHeader.startsWith('Bearer ')) {
|
||||||
|
throw new Error('Unauthorized: Only Bearer tokens are supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Unauthorized: No token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.split('.').length !== 3) {
|
||||||
|
throw new Error('Unauthorized: Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient<Database>(
|
||||||
|
SUPABASE_URL!,
|
||||||
|
SUPABASE_PUBLISHABLE_KEY!,
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY!),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
storage: undefined,
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.getClaims(token);
|
||||||
|
if (error || !data?.claims) {
|
||||||
|
throw new Error('Unauthorized: Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.claims.sub) {
|
||||||
|
throw new Error('Unauthorized: No user ID found in token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
context: {
|
||||||
|
supabase,
|
||||||
|
userId: data.claims.sub,
|
||||||
|
claims: data.claims,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// This file is automatically generated. Do not edit it directly.
|
||||||
|
// Server-side Supabase client with service role key - bypasses RLS.
|
||||||
|
// Use this for admin operations in server functions and server routes only.
|
||||||
|
// For user-authenticated queries (with RLS), use the auth middleware instead.
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from './types';
|
||||||
|
|
||||||
|
function isNewSupabaseApiKey(value: string): boolean {
|
||||||
|
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||||
|
return (input, init) => {
|
||||||
|
const headers = new Headers(
|
||||||
|
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (init?.headers) {
|
||||||
|
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Supabase API keys are opaque strings, not bearer JWTs.
|
||||||
|
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
|
||||||
|
headers.delete('Authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('apikey', supabaseKey);
|
||||||
|
return fetch(input, { ...init, headers });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSupabaseAdminClient() {
|
||||||
|
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||||
|
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||||
|
const missing = [
|
||||||
|
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
|
||||||
|
...(!SUPABASE_SERVICE_ROLE_KEY ? ['SUPABASE_SERVICE_ROLE_KEY'] : []),
|
||||||
|
];
|
||||||
|
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
|
||||||
|
console.error(`[Supabase] ${message}`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createClient<Database>(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
|
global: {
|
||||||
|
fetch: createSupabaseFetch(SUPABASE_SERVICE_ROLE_KEY),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
storage: undefined,
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _supabaseAdmin: ReturnType<typeof createSupabaseAdminClient> | undefined;
|
||||||
|
|
||||||
|
// Server-side Supabase client with service role - bypasses RLS
|
||||||
|
// SECURITY: Only use this for trusted server-side operations, never expose to client code
|
||||||
|
// Load inside server handlers: const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||||
|
// Top-level import is safe only in other .server.ts modules - route files and *.functions.ts ship to the client bundle.
|
||||||
|
export const supabaseAdmin = new Proxy({} as ReturnType<typeof createSupabaseAdminClient>, {
|
||||||
|
get(_, prop, receiver) {
|
||||||
|
if (!_supabaseAdmin) _supabaseAdmin = createSupabaseAdminClient();
|
||||||
|
return Reflect.get(_supabaseAdmin, prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// This file is automatically generated. Do not edit it directly.
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from './types';
|
||||||
|
|
||||||
|
function isNewSupabaseApiKey(value: string): boolean {
|
||||||
|
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||||
|
return (input, init) => {
|
||||||
|
const headers = new Headers(
|
||||||
|
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (init?.headers) {
|
||||||
|
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Supabase API keys are opaque strings, not bearer JWTs.
|
||||||
|
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
|
||||||
|
headers.delete('Authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('apikey', supabaseKey);
|
||||||
|
return fetch(input, { ...init, headers });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function createSupabaseClient() {
|
||||||
|
// Use import.meta.env for client-side (Vite build-time replacement)
|
||||||
|
// Fall back to process.env for SSR (server-side rendering)
|
||||||
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||||
|
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY;
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
|
||||||
|
const missing = [
|
||||||
|
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
|
||||||
|
...(!SUPABASE_PUBLISHABLE_KEY ? ['SUPABASE_PUBLISHABLE_KEY'] : []),
|
||||||
|
];
|
||||||
|
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
|
||||||
|
console.error(`[Supabase] ${message}`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
||||||
|
global: {
|
||||||
|
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _supabase: ReturnType<typeof createSupabaseClient> | undefined;
|
||||||
|
|
||||||
|
// Import the supabase client like this:
|
||||||
|
// import { supabase } from "@/integrations/supabase/client";
|
||||||
|
export const supabase = new Proxy({} as ReturnType<typeof createSupabaseClient>, {
|
||||||
|
get(_, prop, receiver) {
|
||||||
|
if (!_supabase) _supabase = createSupabaseClient();
|
||||||
|
return Reflect.get(_supabase, prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
// Allows to automatically instantiate createClient with right options
|
||||||
|
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||||
|
__InternalSupabase: {
|
||||||
|
PostgrestVersion: "14.5"
|
||||||
|
}
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
attendance: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
date: string
|
||||||
|
id: string
|
||||||
|
note: string | null
|
||||||
|
recorded_by: string | null
|
||||||
|
status: Database["public"]["Enums"]["attendance_status"]
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
date: string
|
||||||
|
id?: string
|
||||||
|
note?: string | null
|
||||||
|
recorded_by?: string | null
|
||||||
|
status?: Database["public"]["Enums"]["attendance_status"]
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
date?: string
|
||||||
|
id?: string
|
||||||
|
note?: string | null
|
||||||
|
recorded_by?: string | null
|
||||||
|
status?: Database["public"]["Enums"]["attendance_status"]
|
||||||
|
student_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "attendance_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
authorized_pickups: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
phone: string | null
|
||||||
|
relationship: string | null
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
phone?: string | null
|
||||||
|
relationship?: string | null
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
phone?: string | null
|
||||||
|
relationship?: string | null
|
||||||
|
student_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "authorized_pickups_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
calendar_events: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
created_by: string | null
|
||||||
|
date: string
|
||||||
|
description: string | null
|
||||||
|
end_date: string | null
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
date: string
|
||||||
|
description?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
date?: string
|
||||||
|
description?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
classes: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
teacher_id: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
teacher_id?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
teacher_id?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
contracts: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
file_path: string
|
||||||
|
id: string
|
||||||
|
student_id: string
|
||||||
|
title: string | null
|
||||||
|
uploaded_by: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
file_path: string
|
||||||
|
id?: string
|
||||||
|
student_id: string
|
||||||
|
title?: string | null
|
||||||
|
uploaded_by?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
file_path?: string
|
||||||
|
id?: string
|
||||||
|
student_id?: string
|
||||||
|
title?: string | null
|
||||||
|
uploaded_by?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "contracts_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
form_responses: {
|
||||||
|
Row: {
|
||||||
|
data_json: Json
|
||||||
|
form_id: string
|
||||||
|
id: string
|
||||||
|
student_id: string | null
|
||||||
|
submitted_at: string
|
||||||
|
submitted_by: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
data_json?: Json
|
||||||
|
form_id: string
|
||||||
|
id?: string
|
||||||
|
student_id?: string | null
|
||||||
|
submitted_at?: string
|
||||||
|
submitted_by: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
data_json?: Json
|
||||||
|
form_id?: string
|
||||||
|
id?: string
|
||||||
|
student_id?: string | null
|
||||||
|
submitted_at?: string
|
||||||
|
submitted_by?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "form_responses_form_id_fkey"
|
||||||
|
columns: ["form_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "forms"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "form_responses_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
forms: {
|
||||||
|
Row: {
|
||||||
|
active: boolean
|
||||||
|
created_at: string
|
||||||
|
created_by: string | null
|
||||||
|
description: string | null
|
||||||
|
id: string
|
||||||
|
schema_json: Json
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
active?: boolean
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
description?: string | null
|
||||||
|
id?: string
|
||||||
|
schema_json?: Json
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
active?: boolean
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
description?: string | null
|
||||||
|
id?: string
|
||||||
|
schema_json?: Json
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
ledger_entries: {
|
||||||
|
Row: {
|
||||||
|
amount_cents: number
|
||||||
|
category: Database["public"]["Enums"]["ledger_category"]
|
||||||
|
created_at: string
|
||||||
|
created_by: string | null
|
||||||
|
date: string
|
||||||
|
id: string
|
||||||
|
kind: Database["public"]["Enums"]["ledger_kind"]
|
||||||
|
note: string | null
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
amount_cents: number
|
||||||
|
category?: Database["public"]["Enums"]["ledger_category"]
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
date?: string
|
||||||
|
id?: string
|
||||||
|
kind: Database["public"]["Enums"]["ledger_kind"]
|
||||||
|
note?: string | null
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
amount_cents?: number
|
||||||
|
category?: Database["public"]["Enums"]["ledger_category"]
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
date?: string
|
||||||
|
id?: string
|
||||||
|
kind?: Database["public"]["Enums"]["ledger_kind"]
|
||||||
|
note?: string | null
|
||||||
|
student_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "ledger_entries_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
message_threads: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
created_by: string
|
||||||
|
id: string
|
||||||
|
student_id: string | null
|
||||||
|
subject: string | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
created_by: string
|
||||||
|
id?: string
|
||||||
|
student_id?: string | null
|
||||||
|
subject?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string
|
||||||
|
id?: string
|
||||||
|
student_id?: string | null
|
||||||
|
subject?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "message_threads_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
messages: {
|
||||||
|
Row: {
|
||||||
|
body: string
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
sender_id: string
|
||||||
|
thread_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
body: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
sender_id: string
|
||||||
|
thread_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
body?: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
sender_id?: string
|
||||||
|
thread_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "messages_thread_id_fkey"
|
||||||
|
columns: ["thread_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "message_threads"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
parent_students: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
parent_id: string
|
||||||
|
relationship: string | null
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
parent_id: string
|
||||||
|
relationship?: string | null
|
||||||
|
student_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
parent_id?: string
|
||||||
|
relationship?: string | null
|
||||||
|
student_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "parent_students_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
profiles: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
email: string | null
|
||||||
|
full_name: string | null
|
||||||
|
id: string
|
||||||
|
phone: string | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
email?: string | null
|
||||||
|
full_name?: string | null
|
||||||
|
id: string
|
||||||
|
phone?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
email?: string | null
|
||||||
|
full_name?: string | null
|
||||||
|
id?: string
|
||||||
|
phone?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
students: {
|
||||||
|
Row: {
|
||||||
|
allergies: string | null
|
||||||
|
class_id: string | null
|
||||||
|
created_at: string
|
||||||
|
dob: string | null
|
||||||
|
first_name: string
|
||||||
|
id: string
|
||||||
|
last_name: string
|
||||||
|
notes: string | null
|
||||||
|
photo_release: boolean
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
allergies?: string | null
|
||||||
|
class_id?: string | null
|
||||||
|
created_at?: string
|
||||||
|
dob?: string | null
|
||||||
|
first_name: string
|
||||||
|
id?: string
|
||||||
|
last_name: string
|
||||||
|
notes?: string | null
|
||||||
|
photo_release?: boolean
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
allergies?: string | null
|
||||||
|
class_id?: string | null
|
||||||
|
created_at?: string
|
||||||
|
dob?: string | null
|
||||||
|
first_name?: string
|
||||||
|
id?: string
|
||||||
|
last_name?: string
|
||||||
|
notes?: string | null
|
||||||
|
photo_release?: boolean
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "students_class_id_fkey"
|
||||||
|
columns: ["class_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "classes"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
thread_participants: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
thread_id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
thread_id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
thread_id?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "thread_participants_thread_id_fkey"
|
||||||
|
columns: ["thread_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "message_threads"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
user_roles: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
role: Database["public"]["Enums"]["app_role"]
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
role: Database["public"]["Enums"]["app_role"]
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
role?: Database["public"]["Enums"]["app_role"]
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
current_user_has_role: {
|
||||||
|
Args: { _role: Database["public"]["Enums"]["app_role"] }
|
||||||
|
Returns: boolean
|
||||||
|
}
|
||||||
|
has_role: {
|
||||||
|
Args: {
|
||||||
|
_role: Database["public"]["Enums"]["app_role"]
|
||||||
|
_user_id: string
|
||||||
|
}
|
||||||
|
Returns: boolean
|
||||||
|
}
|
||||||
|
is_parent_of: { Args: { _student: string }; Returns: boolean }
|
||||||
|
is_thread_participant: { Args: { _thread: string }; Returns: boolean }
|
||||||
|
teaches_student: { Args: { _student: string }; Returns: boolean }
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
app_role: "admin" | "teacher" | "parent"
|
||||||
|
attendance_status: "present" | "absent" | "late" | "excused"
|
||||||
|
ledger_category: "tuition" | "late_pickup" | "activity" | "other"
|
||||||
|
ledger_kind: "charge" | "payment"
|
||||||
|
}
|
||||||
|
CompositeTypes: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
||||||
|
|
||||||
|
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
export type Tables<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])
|
||||||
|
? (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesInsert<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesUpdate<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Enums<
|
||||||
|
DefaultSchemaEnumNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Enums"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||||
|
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||||
|
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type CompositeTypes<
|
||||||
|
PublicCompositeTypeNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["CompositeTypes"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||||
|
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||||
|
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export const Constants = {
|
||||||
|
public: {
|
||||||
|
Enums: {
|
||||||
|
app_role: ["admin", "teacher", "parent"],
|
||||||
|
attendance_status: ["present", "absent", "late", "excused"],
|
||||||
|
ledger_category: ["tuition", "late_pickup", "activity", "other"],
|
||||||
|
ledger_kind: ["charge", "payment"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
+253
-13
@@ -9,38 +9,185 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as AuthRouteImport } from './routes/auth'
|
||||||
|
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students'
|
||||||
|
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
|
||||||
|
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
|
||||||
|
import { Route as AuthenticatedFormsRouteImport } from './routes/_authenticated/forms'
|
||||||
|
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
||||||
|
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 AuthenticatedStudentsIdRouteImport } from './routes/_authenticated/students.$id'
|
||||||
|
|
||||||
|
const AuthRoute = AuthRouteImport.update({
|
||||||
|
id: '/auth',
|
||||||
|
path: '/auth',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
|
||||||
|
id: '/_authenticated',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedStudentsRoute = AuthenticatedStudentsRouteImport.update({
|
||||||
|
id: '/students',
|
||||||
|
path: '/students',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedMessagesRoute = AuthenticatedMessagesRouteImport.update({
|
||||||
|
id: '/messages',
|
||||||
|
path: '/messages',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedLedgerRoute = AuthenticatedLedgerRouteImport.update({
|
||||||
|
id: '/ledger',
|
||||||
|
path: '/ledger',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedFormsRoute = AuthenticatedFormsRouteImport.update({
|
||||||
|
id: '/forms',
|
||||||
|
path: '/forms',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
|
||||||
|
id: '/dashboard',
|
||||||
|
path: '/dashboard',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedCalendarRoute = AuthenticatedCalendarRouteImport.update({
|
||||||
|
id: '/calendar',
|
||||||
|
path: '/calendar',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAttendanceRoute = AuthenticatedAttendanceRouteImport.update({
|
||||||
|
id: '/attendance',
|
||||||
|
path: '/attendance',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAdminRoute = AuthenticatedAdminRouteImport.update({
|
||||||
|
id: '/admin',
|
||||||
|
path: '/admin',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedStudentsIdRoute = AuthenticatedStudentsIdRouteImport.update({
|
||||||
|
id: '/$id',
|
||||||
|
path: '/$id',
|
||||||
|
getParentRoute: () => AuthenticatedStudentsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/auth': typeof AuthRoute
|
||||||
|
'/admin': typeof AuthenticatedAdminRoute
|
||||||
|
'/attendance': typeof AuthenticatedAttendanceRoute
|
||||||
|
'/calendar': typeof AuthenticatedCalendarRoute
|
||||||
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
|
'/forms': typeof AuthenticatedFormsRoute
|
||||||
|
'/ledger': typeof AuthenticatedLedgerRoute
|
||||||
|
'/messages': typeof AuthenticatedMessagesRoute
|
||||||
|
'/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||||
|
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/auth': typeof AuthRoute
|
||||||
|
'/admin': typeof AuthenticatedAdminRoute
|
||||||
|
'/attendance': typeof AuthenticatedAttendanceRoute
|
||||||
|
'/calendar': typeof AuthenticatedCalendarRoute
|
||||||
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
|
'/forms': typeof AuthenticatedFormsRoute
|
||||||
|
'/ledger': typeof AuthenticatedLedgerRoute
|
||||||
|
'/messages': typeof AuthenticatedMessagesRoute
|
||||||
|
'/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||||
|
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/_authenticated': typeof AuthenticatedRouteRouteWithChildren
|
||||||
|
'/auth': typeof AuthRoute
|
||||||
|
'/_authenticated/admin': typeof AuthenticatedAdminRoute
|
||||||
|
'/_authenticated/attendance': typeof AuthenticatedAttendanceRoute
|
||||||
|
'/_authenticated/calendar': typeof AuthenticatedCalendarRoute
|
||||||
|
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
|
'/_authenticated/forms': typeof AuthenticatedFormsRoute
|
||||||
|
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
|
||||||
|
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
|
||||||
|
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||||
|
'/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/auth'
|
||||||
|
| '/admin'
|
||||||
|
| '/attendance'
|
||||||
|
| '/calendar'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/forms'
|
||||||
|
| '/ledger'
|
||||||
|
| '/messages'
|
||||||
|
| '/students'
|
||||||
|
| '/students/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/'
|
to:
|
||||||
id: '__root__' | '/'
|
| '/'
|
||||||
|
| '/auth'
|
||||||
|
| '/admin'
|
||||||
|
| '/attendance'
|
||||||
|
| '/calendar'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/forms'
|
||||||
|
| '/ledger'
|
||||||
|
| '/messages'
|
||||||
|
| '/students'
|
||||||
|
| '/students/$id'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/_authenticated'
|
||||||
|
| '/auth'
|
||||||
|
| '/_authenticated/admin'
|
||||||
|
| '/_authenticated/attendance'
|
||||||
|
| '/_authenticated/calendar'
|
||||||
|
| '/_authenticated/dashboard'
|
||||||
|
| '/_authenticated/forms'
|
||||||
|
| '/_authenticated/ledger'
|
||||||
|
| '/_authenticated/messages'
|
||||||
|
| '/_authenticated/students'
|
||||||
|
| '/_authenticated/students/$id'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
|
||||||
|
AuthRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/auth': {
|
||||||
|
id: '/auth'
|
||||||
|
path: '/auth'
|
||||||
|
fullPath: '/auth'
|
||||||
|
preLoaderRoute: typeof AuthRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/_authenticated': {
|
||||||
|
id: '/_authenticated'
|
||||||
|
path: ''
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof AuthenticatedRouteRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -48,22 +195,115 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_authenticated/students': {
|
||||||
|
id: '/_authenticated/students'
|
||||||
|
path: '/students'
|
||||||
|
fullPath: '/students'
|
||||||
|
preLoaderRoute: typeof AuthenticatedStudentsRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/messages': {
|
||||||
|
id: '/_authenticated/messages'
|
||||||
|
path: '/messages'
|
||||||
|
fullPath: '/messages'
|
||||||
|
preLoaderRoute: typeof AuthenticatedMessagesRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/ledger': {
|
||||||
|
id: '/_authenticated/ledger'
|
||||||
|
path: '/ledger'
|
||||||
|
fullPath: '/ledger'
|
||||||
|
preLoaderRoute: typeof AuthenticatedLedgerRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/forms': {
|
||||||
|
id: '/_authenticated/forms'
|
||||||
|
path: '/forms'
|
||||||
|
fullPath: '/forms'
|
||||||
|
preLoaderRoute: typeof AuthenticatedFormsRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/dashboard': {
|
||||||
|
id: '/_authenticated/dashboard'
|
||||||
|
path: '/dashboard'
|
||||||
|
fullPath: '/dashboard'
|
||||||
|
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/calendar': {
|
||||||
|
id: '/_authenticated/calendar'
|
||||||
|
path: '/calendar'
|
||||||
|
fullPath: '/calendar'
|
||||||
|
preLoaderRoute: typeof AuthenticatedCalendarRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/attendance': {
|
||||||
|
id: '/_authenticated/attendance'
|
||||||
|
path: '/attendance'
|
||||||
|
fullPath: '/attendance'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAttendanceRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/admin': {
|
||||||
|
id: '/_authenticated/admin'
|
||||||
|
path: '/admin'
|
||||||
|
fullPath: '/admin'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAdminRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/students/$id': {
|
||||||
|
id: '/_authenticated/students/$id'
|
||||||
|
path: '/$id'
|
||||||
|
fullPath: '/students/$id'
|
||||||
|
preLoaderRoute: typeof AuthenticatedStudentsIdRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedStudentsRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuthenticatedStudentsRouteChildren {
|
||||||
|
AuthenticatedStudentsIdRoute: typeof AuthenticatedStudentsIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedStudentsRouteChildren: AuthenticatedStudentsRouteChildren = {
|
||||||
|
AuthenticatedStudentsIdRoute: AuthenticatedStudentsIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedStudentsRouteWithChildren =
|
||||||
|
AuthenticatedStudentsRoute._addFileChildren(
|
||||||
|
AuthenticatedStudentsRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AuthenticatedRouteRouteChildren {
|
||||||
|
AuthenticatedAdminRoute: typeof AuthenticatedAdminRoute
|
||||||
|
AuthenticatedAttendanceRoute: typeof AuthenticatedAttendanceRoute
|
||||||
|
AuthenticatedCalendarRoute: typeof AuthenticatedCalendarRoute
|
||||||
|
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
||||||
|
AuthenticatedFormsRoute: typeof AuthenticatedFormsRoute
|
||||||
|
AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute
|
||||||
|
AuthenticatedMessagesRoute: typeof AuthenticatedMessagesRoute
|
||||||
|
AuthenticatedStudentsRoute: typeof AuthenticatedStudentsRouteWithChildren
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||||
|
AuthenticatedAdminRoute: AuthenticatedAdminRoute,
|
||||||
|
AuthenticatedAttendanceRoute: AuthenticatedAttendanceRoute,
|
||||||
|
AuthenticatedCalendarRoute: AuthenticatedCalendarRoute,
|
||||||
|
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
||||||
|
AuthenticatedFormsRoute: AuthenticatedFormsRoute,
|
||||||
|
AuthenticatedLedgerRoute: AuthenticatedLedgerRoute,
|
||||||
|
AuthenticatedMessagesRoute: AuthenticatedMessagesRoute,
|
||||||
|
AuthenticatedStudentsRoute: AuthenticatedStudentsRouteWithChildren,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedRouteRouteWithChildren =
|
||||||
|
AuthenticatedRouteRoute._addFileChildren(AuthenticatedRouteRouteChildren)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
|
||||||
|
AuthRoute: AuthRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
._addFileTypes<FileRouteTypes>()
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
import type { getRouter } from './router.tsx'
|
|
||||||
import type { startInstance } from './start.ts'
|
|
||||||
declare module '@tanstack/react-start' {
|
|
||||||
interface Register {
|
|
||||||
ssr: true
|
|
||||||
router: Awaited<ReturnType<typeof getRouter>>
|
|
||||||
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useEffect, type ReactNode } from "react";
|
|||||||
|
|
||||||
import appCss from "../styles.css?url";
|
import appCss from "../styles.css?url";
|
||||||
import { reportLovableError } from "../lib/lovable-error-reporting";
|
import { reportLovableError } from "../lib/lovable-error-reporting";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
function NotFoundComponent() {
|
function NotFoundComponent() {
|
||||||
return (
|
return (
|
||||||
@@ -118,8 +119,8 @@ function RootComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { createFileRoute } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, Lock } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Role = "admin" | "teacher" | "parent";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/admin")({
|
||||||
|
head: () => ({ meta: [{ title: "Admin — School Portal" }] }),
|
||||||
|
component: AdminPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AdminPage() {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
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></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl">
|
||||||
|
<h1 className="text-2xl font-semibold mb-1">Admin</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-6">Manage users, roles, classes, and parent-student links.</p>
|
||||||
|
<Tabs defaultValue="users">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="users">Users & roles</TabsTrigger>
|
||||||
|
<TabsTrigger value="classes">Classes</TabsTrigger>
|
||||||
|
<TabsTrigger value="links">Parent ↔ student</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="users"><UsersTab /></TabsContent>
|
||||||
|
<TabsContent value="classes"><ClassesTab /></TabsContent>
|
||||||
|
<TabsContent value="links"><LinksTab /></TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersTab() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["admin-users"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: profiles } = await supabase.from("profiles").select("id, full_name, email");
|
||||||
|
const { data: roles } = await supabase.from("user_roles").select("user_id, role");
|
||||||
|
const rolesByUser: Record<string, Role[]> = {};
|
||||||
|
(roles ?? []).forEach((r) => { rolesByUser[r.user_id] = [...(rolesByUser[r.user_id] ?? []), r.role as Role]; });
|
||||||
|
return (profiles ?? []).map((p) => ({ ...p, roles: rolesByUser[p.id] ?? [] }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setRole = useMutation({
|
||||||
|
mutationFn: async ({ userId, role, on }: { userId: string; role: Role; on: boolean }) => {
|
||||||
|
if (on) {
|
||||||
|
const { error } = await supabase.from("user_roles").insert({ user_id: userId, role });
|
||||||
|
if (error && !error.message.includes("duplicate")) throw error;
|
||||||
|
} else {
|
||||||
|
const { error } = await supabase.from("user_roles").delete().eq("user_id", userId).eq("role", role);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg overflow-hidden mt-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted text-xs"><tr><th className="text-left p-2">Name</th><th className="text-left p-2">Email</th><th className="text-left p-2">Roles</th></tr></thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{(data ?? []).map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="p-2">{u.full_name || "—"}</td>
|
||||||
|
<td className="p-2 text-muted-foreground">{u.email}</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["admin", "teacher", "parent"] as Role[]).map((r) => {
|
||||||
|
const on = u.roles.includes(r);
|
||||||
|
return <Button key={r} size="sm" variant={on ? "default" : "outline"} onClick={() => setRole.mutate({ userId: u.id, role: r, on: !on })}>{r}</Button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClassesTab() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [teacherId, setTeacherId] = useState("");
|
||||||
|
|
||||||
|
const { data: classes } = useQuery({
|
||||||
|
queryKey: ["admin-classes"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("classes").select("*").order("name");
|
||||||
|
const tids = Array.from(new Set((data ?? []).map((c) => c.teacher_id).filter(Boolean))) as string[];
|
||||||
|
const { data: profs } = tids.length ? await supabase.from("profiles").select("id, full_name").in("id", tids) : { data: [] };
|
||||||
|
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name]));
|
||||||
|
return (data ?? []).map((c) => ({ ...c, teacher_name: c.teacher_id ? map[c.teacher_id] : null }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: teachers } = useQuery({
|
||||||
|
queryKey: ["teachers"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: r } = await supabase.from("user_roles").select("user_id").eq("role", "teacher");
|
||||||
|
const ids = (r ?? []).map((x) => x.user_id);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const { data } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("classes").insert({ name, teacher_id: teacherId || null });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setName(""); setTeacherId(""); qc.invalidateQueries({ queryKey: ["admin-classes"] }); toast.success("Class added"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => { const { error } = await supabase.from("classes").delete().eq("id", id); if (error) throw error; },
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-classes"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(classes ?? []).map((c) => (
|
||||||
|
<div key={c.id} className="p-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{c.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Teacher: {c.teacher_name ?? "unassigned"}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => del.mutate(c.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{classes?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No classes yet.</div>}
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">New class</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Input placeholder="Class name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Assign teacher" /></SelectTrigger>
|
||||||
|
<SelectContent>{(teachers ?? []).map((t) => <SelectItem key={t.id} value={t.id}>{t.full_name || t.email}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => add.mutate()} disabled={!name || add.isPending}><Plus className="h-4 w-4 mr-1" /> Add</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinksTab() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [parentId, setParentId] = useState("");
|
||||||
|
const [studentId, setStudentId] = useState("");
|
||||||
|
|
||||||
|
const { data: links } = useQuery({
|
||||||
|
queryKey: ["admin-links"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("parent_students").select("id, parent_id, students(first_name, last_name)");
|
||||||
|
const ids = (data ?? []).map((d) => d.parent_id);
|
||||||
|
const { data: profs } = ids.length ? await supabase.from("profiles").select("id, full_name, email").in("id", ids) : { data: [] };
|
||||||
|
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p]));
|
||||||
|
return (data ?? []).map((d) => ({ ...d, profile: map[d.parent_id] }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: parents } = useQuery({
|
||||||
|
queryKey: ["all-parents"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: r } = await supabase.from("user_roles").select("user_id").eq("role", "parent");
|
||||||
|
const ids = (r ?? []).map((x) => x.user_id);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const { data } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["all-students-link"],
|
||||||
|
queryFn: async () => (await supabase.from("students").select("id, first_name, last_name").order("last_name")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("parent_students").insert({ parent_id: parentId, student_id: studentId });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setParentId(""); setStudentId(""); qc.invalidateQueries({ queryKey: ["admin-links"] }); toast.success("Linked"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => { const { error } = await supabase.from("parent_students").delete().eq("id", id); if (error) throw error; },
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-links"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(links ?? []).map((l) => {
|
||||||
|
const p = l.profile as { full_name?: string; email?: string } | undefined;
|
||||||
|
const s = l.students as { first_name: string; last_name: string } | null;
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="p-3 flex justify-between items-center text-sm">
|
||||||
|
<span>{p?.full_name || p?.email} → {s?.first_name} {s?.last_name}</span>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => del.mutate(l.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{links?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No links yet.</div>}
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">Link parent to student</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Select value={parentId} onValueChange={setParentId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Parent" /></SelectTrigger>
|
||||||
|
<SelectContent>{(parents ?? []).map((p) => <SelectItem key={p.id} value={p.id}>{p.full_name || p.email}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={studentId} onValueChange={setStudentId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Student" /></SelectTrigger>
|
||||||
|
<SelectContent>{(students ?? []).map((s) => <SelectItem key={s.id} value={s.id}>{s.last_name}, {s.first_name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => add.mutate()} disabled={!parentId || !studentId || add.isPending}><Plus className="h-4 w-4 mr-1" /> Link</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createFileRoute } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/attendance")({
|
||||||
|
head: () => ({ meta: [{ title: "Attendance — School Portal" }] }),
|
||||||
|
component: AttendancePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AttendancePage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [classId, setClassId] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: classes } = useQuery({
|
||||||
|
queryKey: ["my-classes", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
let q = supabase.from("classes").select("id, name, teacher_id");
|
||||||
|
if (!isAdmin) q = q.eq("teacher_id", user!.id);
|
||||||
|
return (await q.order("name")).data ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["class-students", classId, date],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!classId) return [];
|
||||||
|
const { data } = await supabase.from("students").select("id, first_name, last_name, attendance(status, date, id)").eq("class_id", classId);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!classId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mark = useMutation({
|
||||||
|
mutationFn: async ({ studentId, status }: { studentId: string; status: string }) => {
|
||||||
|
const { error } = await supabase.from("attendance").upsert(
|
||||||
|
{ student_id: studentId, date, status: status as "present" | "absent" | "late" | "excused", recorded_by: user!.id },
|
||||||
|
{ onConflict: "student_id,date" }
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["class-students", classId, date] }),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatus = (s: { attendance: { date: string; status: string }[] }) =>
|
||||||
|
s.attendance.find((a) => a.date === date)?.status ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-semibold mb-1">Attendance</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-6">Mark today's attendance for your class.</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<div>
|
||||||
|
<Select value={classId} onValueChange={setClassId}>
|
||||||
|
<SelectTrigger className="w-60"><SelectValue placeholder="Choose class" /></SelectTrigger>
|
||||||
|
<SelectContent>{(classes ?? []).map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Input type="date" className="w-44" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{classId && (
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(students ?? []).map((s) => {
|
||||||
|
const status = getStatus(s as { attendance: { date: string; status: string }[] });
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="flex items-center justify-between p-3">
|
||||||
|
<div className="font-medium">{s.last_name}, {s.first_name}</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{["present", "absent", "late", "excused"].map((opt) => (
|
||||||
|
<Button key={opt} size="sm" variant={status === opt ? "default" : "outline"} onClick={() => mark.mutate({ studentId: s.id, status: opt })}>
|
||||||
|
{opt}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{students?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No students in this class.</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!classId && <p className="text-sm text-muted-foreground">Select a class to begin.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createFileRoute } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, Trash2, CalendarDays } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/calendar")({
|
||||||
|
head: () => ({ meta: [{ title: "Calendar — School Portal" }] }),
|
||||||
|
component: CalendarPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function CalendarPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({ title: "", date: "", end_date: "", description: "" });
|
||||||
|
|
||||||
|
const { data: events } = useQuery({
|
||||||
|
queryKey: ["calendar"],
|
||||||
|
queryFn: async () => (await supabase.from("calendar_events").select("*").order("date")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("calendar_events").insert({
|
||||||
|
title: form.title, date: form.date, end_date: form.end_date || null, description: form.description || null, created_by: user!.id,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setOpen(false); setForm({ title: "", date: "", end_date: "", description: "" }); qc.invalidateQueries({ queryKey: ["calendar"] }); toast.success("Event added"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => { const { error } = await supabase.from("calendar_events").delete().eq("id", id); if (error) throw error; },
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["calendar"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const upcoming = (events ?? []).filter((e) => e.date >= today);
|
||||||
|
const past = (events ?? []).filter((e) => e.date < today);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">School calendar</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Year at a glance.</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> Add event</Button></DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New calendar event</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div><Label>Title</Label><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>Date</Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
|
||||||
|
<div><Label>End date (optional)</Label><Input type="date" value={form.end_date} onChange={(e) => setForm({ ...form, end_date: e.target.value })} /></div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||||
|
<Button className="w-full" disabled={!form.title || !form.date || add.isPending} onClick={() => add.mutate()}>Add</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="font-medium text-sm text-muted-foreground mb-2">Upcoming</h2>
|
||||||
|
<div className="bg-card border rounded-lg divide-y mb-6">
|
||||||
|
{upcoming.map((e) => (
|
||||||
|
<div key={e.id} className="p-3 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{e.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(e.date).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
|
||||||
|
{e.end_date && ` – ${new Date(e.end_date).toLocaleDateString()}`}
|
||||||
|
</div>
|
||||||
|
{e.description && <div className="text-sm mt-1">{e.description}</div>}
|
||||||
|
</div>
|
||||||
|
{isAdmin && <Button size="icon" variant="ghost" onClick={() => del.mutate(e.id)}><Trash2 className="h-4 w-4" /></Button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{upcoming.length === 0 && <div className="p-6 text-sm text-muted-foreground text-center"><CalendarDays className="h-5 w-5 mx-auto mb-2" /> No upcoming events</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{past.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className="font-medium text-sm text-muted-foreground mb-2">Past</h2>
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{past.map((e) => (
|
||||||
|
<div key={e.id} className="p-3 text-sm text-muted-foreground flex justify-between">
|
||||||
|
<span>{e.title}</span><span>{new Date(e.date).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useAuth, highestRole } from "@/hooks/use-auth";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { Users, ClipboardCheck, Receipt, CalendarDays } from "lucide-react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/dashboard")({
|
||||||
|
head: () => ({ meta: [{ title: "Dashboard — School Portal" }] }),
|
||||||
|
component: Dashboard,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const role = highestRole(roles);
|
||||||
|
|
||||||
|
const { data: studentCount } = useQuery({
|
||||||
|
queryKey: ["students-count"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { count } = await supabase.from("students").select("*", { count: "exact", head: true });
|
||||||
|
return count ?? 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: todayAttendance } = useQuery({
|
||||||
|
queryKey: ["attendance-today"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const { count } = await supabase.from("attendance").select("*", { count: "exact", head: true }).eq("date", today);
|
||||||
|
return count ?? 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: upcoming } = useQuery({
|
||||||
|
queryKey: ["upcoming-events"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const { data } = await supabase.from("calendar_events").select("*").gte("date", today).order("date").limit(5);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl">
|
||||||
|
<h1 className="text-2xl font-semibold">Welcome back</h1>
|
||||||
|
<p className="text-muted-foreground">Signed in as <span className="capitalize">{role}</span></p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||||
|
<Stat icon={Users} label="Students" value={studentCount ?? "—"} />
|
||||||
|
<Stat icon={ClipboardCheck} label="Attendance recorded today" value={todayAttendance ?? "—"} />
|
||||||
|
<Stat icon={CalendarDays} label="Upcoming events" value={upcoming?.length ?? "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="font-semibold mb-3 flex items-center gap-2"><CalendarDays className="h-4 w-4" /> Upcoming on the calendar</h2>
|
||||||
|
{upcoming && upcoming.length > 0 ? (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{upcoming.map((e) => (
|
||||||
|
<li key={e.id} className="flex justify-between border-b last:border-0 py-2">
|
||||||
|
<span>{e.title}</span>
|
||||||
|
<span className="text-muted-foreground">{new Date(e.date).toLocaleDateString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No upcoming events.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: number | string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<Icon className="h-5 w-5 text-primary mb-2" />
|
||||||
|
<div className="text-3xl font-semibold">{value}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { createFileRoute } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, Trash2, FileText } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Field = { label: string; type: "text" | "textarea" | "yesno" | "date" };
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/forms")({
|
||||||
|
head: () => ({ meta: [{ title: "Forms — School Portal" }] }),
|
||||||
|
component: FormsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function FormsPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [activeForm, setActiveForm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: forms } = useQuery({
|
||||||
|
queryKey: ["forms"],
|
||||||
|
queryFn: async () => (await supabase.from("forms").select("*").eq("active", true).order("created_at", { ascending: false })).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Forms</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Push a form to families; they complete it in the portal.</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && <FormBuilder onCreated={() => qc.invalidateQueries({ queryKey: ["forms"] })} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{(forms ?? []).map((f) => (
|
||||||
|
<button key={f.id} onClick={() => setActiveForm(f.id)} className="text-left bg-card border rounded-lg p-4 hover:bg-muted/30">
|
||||||
|
<FileText className="h-5 w-5 text-primary mb-2" />
|
||||||
|
<div className="font-medium">{f.title}</div>
|
||||||
|
{f.description && <div className="text-sm text-muted-foreground mt-1">{f.description}</div>}
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">{(f.schema_json as Field[])?.length ?? 0} field(s)</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{forms?.length === 0 && <div className="text-sm text-muted-foreground">No active forms.</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeForm && <FormFillDialog formId={activeForm} onClose={() => setActiveForm(null)} isAdmin={isAdmin} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormBuilder({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [fields, setFields] = useState<Field[]>([{ label: "Full name", type: "text" }]);
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("forms").insert({ title, description, schema_json: fields, created_by: user!.id });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setOpen(false); setTitle(""); setDescription(""); setFields([{ label: "Full name", type: "text" }]); onCreated(); toast.success("Form created"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> New form</Button></DialogTrigger>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader><DialogTitle>Create form</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3 max-h-[70vh] overflow-auto">
|
||||||
|
<div><Label>Title</Label><Input value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||||
|
<div><Label>Description</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
||||||
|
<div>
|
||||||
|
<Label>Fields</Label>
|
||||||
|
<div className="space-y-2 mt-1">
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<Input className="flex-1" value={f.label} onChange={(e) => { const c = [...fields]; c[i] = { ...c[i], label: e.target.value }; setFields(c); }} placeholder="Field label" />
|
||||||
|
<select className="border rounded-md px-2 text-sm" value={f.type} onChange={(e) => { const c = [...fields]; c[i] = { ...c[i], type: e.target.value as Field["type"] }; setFields(c); }}>
|
||||||
|
<option value="text">Short text</option><option value="textarea">Long text</option><option value="yesno">Yes / No</option><option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setFields(fields.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setFields([...fields, { label: "", type: "text" }])}><Plus className="h-4 w-4 mr-1" /> Add field</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" disabled={!title || create.isPending} onClick={() => create.mutate()}>Create form</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose: () => void; isAdmin: boolean }) {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isParent = roles.includes("parent");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [studentId, setStudentId] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: form } = useQuery({
|
||||||
|
queryKey: ["form", formId],
|
||||||
|
queryFn: async () => (await supabase.from("forms").select("*").eq("id", formId).single()).data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: myStudents } = useQuery({
|
||||||
|
queryKey: ["my-students", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("parent_students").select("student_id, students(id, first_name, last_name)").eq("parent_id", user!.id);
|
||||||
|
return (data ?? []).map((d) => d.students as { id: string; first_name: string; last_name: string });
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: responses } = useQuery({
|
||||||
|
queryKey: ["form-responses", formId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("form_responses").select("*, students(first_name, last_name)").eq("form_id", formId).order("submitted_at", { ascending: false });
|
||||||
|
const ids = Array.from(new Set((data ?? []).map((r) => r.submitted_by)));
|
||||||
|
const { data: profs } = ids.length ? await supabase.from("profiles").select("id, full_name").in("id", ids) : { data: [] };
|
||||||
|
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name]));
|
||||||
|
return (data ?? []).map((r) => ({ ...r, submitter_name: map[r.submitted_by] ?? "—" }));
|
||||||
|
},
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("form_responses").insert({ form_id: formId, student_id: studentId || null, submitted_by: user!.id, data_json: values });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { toast.success("Submitted"); qc.invalidateQueries({ queryKey: ["form-responses", formId] }); onClose(); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = (form?.schema_json ?? []) as Field[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto">
|
||||||
|
<DialogHeader><DialogTitle>{form?.title}</DialogTitle></DialogHeader>
|
||||||
|
{form?.description && <p className="text-sm text-muted-foreground">{form.description}</p>}
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-2">
|
||||||
|
{isParent && myStudents && myStudents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>For student</Label>
|
||||||
|
<select className="w-full border rounded-md p-2 text-sm" value={studentId} onChange={(e) => setStudentId(e.target.value)}>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{myStudents.map((s) => <option key={s.id} value={s.id}>{s.first_name} {s.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<Label>{f.label}</Label>
|
||||||
|
{f.type === "textarea" ? <Textarea value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })} /> :
|
||||||
|
f.type === "yesno" ? (
|
||||||
|
<select className="w-full border rounded-md p-2 text-sm" value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}>
|
||||||
|
<option value="">—</option><option value="yes">Yes</option><option value="no">No</option>
|
||||||
|
</select>
|
||||||
|
) :
|
||||||
|
<Input type={f.type === "date" ? "date" : "text"} value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button className="w-full" onClick={() => submit.mutate()} disabled={submit.isPending}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-6 border-t pt-4">
|
||||||
|
<h3 className="font-medium text-sm mb-2">Responses ({responses?.length ?? 0})</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(responses ?? []).map((r) => (
|
||||||
|
<div key={r.id} className="border rounded-md p-3 text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{(r.students as { first_name: string; last_name: string } | null)?.first_name ?? "—"} {(r.students as { last_name?: string } | null)?.last_name ?? ""}
|
||||||
|
<span className="text-muted-foreground font-normal ml-2">by {r.submitter_name} · {new Date(r.submitted_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs space-y-0.5">
|
||||||
|
{Object.entries(r.data_json as Record<string, string>).map(([k, v]) => <div key={k}><span className="text-muted-foreground">{k}:</span> {v}</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/ledger")({
|
||||||
|
head: () => ({ meta: [{ title: "Tuition — School Portal" }] }),
|
||||||
|
component: LedgerListPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function LedgerListPage() {
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["students-with-balance"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: studs } = await supabase.from("students").select("id, first_name, last_name");
|
||||||
|
const { data: entries } = await supabase.from("ledger_entries").select("student_id, amount_cents, kind");
|
||||||
|
const balances: Record<string, number> = {};
|
||||||
|
(entries ?? []).forEach((e) => {
|
||||||
|
balances[e.student_id] = (balances[e.student_id] ?? 0) + (e.kind === "charge" ? e.amount_cents : -e.amount_cents);
|
||||||
|
});
|
||||||
|
return (studs ?? []).map((s) => ({ ...s, balance: balances[s.id] ?? 0 }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-semibold mb-1">Tuition ledger</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-6">Click a student to view and manage their ledger.</p>
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(students ?? []).map((s) => (
|
||||||
|
<Link key={s.id} to="/students/$id" params={{ id: s.id }} className="flex justify-between items-center p-3 hover:bg-muted/50">
|
||||||
|
<span className="font-medium">{s.last_name}, {s.first_name}</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className={`text-sm font-medium ${s.balance > 0 ? "text-destructive" : "text-green-700"}`}>${(s.balance / 100).toFixed(2)}</span>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{students?.length === 0 && <div className="p-6 text-center text-sm text-muted-foreground">No students.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { createFileRoute } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, MessageSquare } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/messages")({
|
||||||
|
head: () => ({ meta: [{ title: "Messages — School Portal" }] }),
|
||||||
|
component: MessagesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function MessagesPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isStaff = roles.includes("admin") || roles.includes("teacher");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [activeThread, setActiveThread] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: threads } = useQuery({
|
||||||
|
queryKey: ["threads", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: parts } = await supabase.from("thread_participants").select("thread_id").eq("user_id", user!.id);
|
||||||
|
const ids = (parts ?? []).map((p) => p.thread_id);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const { data } = await supabase.from("message_threads").select("*").in("id", ids).order("updated_at", { ascending: false });
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const ch = supabase.channel("messages-realtime").on("postgres_changes", { event: "*", schema: "public", table: "messages" }, () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["thread-messages"] });
|
||||||
|
}).subscribe();
|
||||||
|
return () => { supabase.removeChannel(ch); };
|
||||||
|
}, [user, qc]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Messages</h1>
|
||||||
|
<NewThreadDialog onCreated={(id) => setActiveThread(id)} isStaff={isStaff} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4 h-[70vh]">
|
||||||
|
<div className="bg-card border rounded-lg overflow-auto divide-y">
|
||||||
|
{(threads ?? []).map((t) => (
|
||||||
|
<button key={t.id} onClick={() => setActiveThread(t.id)} className={`w-full text-left p-3 hover:bg-muted/50 ${activeThread === t.id ? "bg-muted" : ""}`}>
|
||||||
|
<div className="font-medium text-sm">{t.subject ?? "(no subject)"}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{new Date(t.updated_at).toLocaleDateString()}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{threads?.length === 0 && <div className="p-6 text-sm text-muted-foreground text-center"><MessageSquare className="h-6 w-6 mx-auto mb-2" /> No threads yet</div>}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 bg-card border rounded-lg flex flex-col">
|
||||||
|
{activeThread ? <ThreadView threadId={activeThread} /> : <div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select a thread</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadView({ threadId }: { threadId: string }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: messages } = useQuery({
|
||||||
|
queryKey: ["thread-messages", threadId],
|
||||||
|
queryFn: async () => (await supabase.from("messages").select("*").eq("thread_id", threadId).order("created_at")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
|
||||||
|
|
||||||
|
const send = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
const { error } = await supabase.from("messages").insert({ thread_id: threadId, sender_id: user!.id, body: text.trim() });
|
||||||
|
if (error) throw error;
|
||||||
|
await supabase.from("message_threads").update({ updated_at: new Date().toISOString() }).eq("id", threadId);
|
||||||
|
},
|
||||||
|
onSuccess: () => { setText(""); qc.invalidateQueries({ queryKey: ["thread-messages", threadId] }); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-2">
|
||||||
|
{(messages ?? []).map((m) => (
|
||||||
|
<div key={m.id} className={`flex ${m.sender_id === user?.id ? "justify-end" : ""}`}>
|
||||||
|
<div className={`px-3 py-2 rounded-lg max-w-md text-sm ${m.sender_id === user?.id ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||||
|
{m.body}
|
||||||
|
<div className="text-[10px] opacity-70 mt-1">{new Date(m.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
<form className="border-t p-3 flex gap-2" onSubmit={(e) => { e.preventDefault(); send.mutate(); }}>
|
||||||
|
<Input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message…" />
|
||||||
|
<Button type="submit" disabled={!text.trim() || send.isPending}>Send</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewThreadDialog({ onCreated, isStaff }: { onCreated: (id: string) => void; isStaff: boolean }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [recipientId, setRecipientId] = useState("");
|
||||||
|
|
||||||
|
const { data: recipients } = useQuery({
|
||||||
|
queryKey: ["thread-recipients", isStaff],
|
||||||
|
queryFn: async () => (await supabase.from("profiles").select("id, full_name, email").neq("id", user!.id).order("full_name")).data ?? [],
|
||||||
|
enabled: open && !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!recipientId) throw new Error("Pick a recipient");
|
||||||
|
const { data: thread, error } = await supabase.from("message_threads").insert({ subject, created_by: user!.id }).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
const { error: pErr } = await supabase.from("thread_participants").insert([
|
||||||
|
{ thread_id: thread.id, user_id: user!.id },
|
||||||
|
{ thread_id: thread.id, user_id: recipientId },
|
||||||
|
]);
|
||||||
|
if (pErr) throw pErr;
|
||||||
|
if (body.trim()) {
|
||||||
|
await supabase.from("messages").insert({ thread_id: thread.id, sender_id: user!.id, body: body.trim() });
|
||||||
|
}
|
||||||
|
return thread.id;
|
||||||
|
},
|
||||||
|
onSuccess: (id) => { setOpen(false); setSubject(""); setBody(""); setRecipientId(""); onCreated(id); toast.success("Thread started"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> New message</Button></DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New conversation</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Recipient</Label>
|
||||||
|
<select className="w-full border rounded-md p-2 text-sm" value={recipientId} onChange={(e) => setRecipientId(e.target.value)}>
|
||||||
|
<option value="">Choose…</option>
|
||||||
|
{(recipients ?? []).map((r) => <option key={r.id} value={r.id}>{r.full_name || r.email}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><Label>Subject</Label><Input value={subject} onChange={(e) => setSubject(e.target.value)} /></div>
|
||||||
|
<div><Label>Message</Label><Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} /></div>
|
||||||
|
<Button className="w-full" onClick={() => create.mutate()} disabled={create.isPending}>Start conversation</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { useAuth, highestRole } from "@/hooks/use-auth";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
|
||||||
|
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated")({
|
||||||
|
ssr: false,
|
||||||
|
component: ProtectedLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ProtectedLayout() {
|
||||||
|
const { user, roles, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const path = useRouterState({ select: (s) => s.location.pathname });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) navigate({ to: "/auth" });
|
||||||
|
}, [loading, user, navigate]);
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
|
return <div className="min-h-screen flex items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = highestRole(roles);
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, show: true },
|
||||||
|
{ to: "/students", label: "Students", icon: Users, show: true },
|
||||||
|
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, show: role !== "parent" },
|
||||||
|
{ to: "/ledger", label: "Tuition", icon: Receipt, show: true },
|
||||||
|
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
|
||||||
|
{ to: "/forms", label: "Forms", icon: FileText, show: true },
|
||||||
|
{ to: "/calendar", label: "Calendar", icon: CalendarDays, show: true },
|
||||||
|
{ to: "/admin", label: "Admin", icon: Settings, show: isAdmin },
|
||||||
|
];
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
navigate({ to: "/auth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/20 flex">
|
||||||
|
<aside className="w-60 bg-card border-r flex flex-col">
|
||||||
|
<div className="h-16 border-b flex items-center gap-2 px-4 font-semibold">
|
||||||
|
<GraduationCap className="h-5 w-5 text-primary" /> School Portal
|
||||||
|
</div>
|
||||||
|
<nav className="p-3 flex-1 space-y-1">
|
||||||
|
{nav.filter((n) => n.show).map((n) => {
|
||||||
|
const active = path.startsWith(n.to);
|
||||||
|
return (
|
||||||
|
<Link key={n.to} to={n.to} className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${active ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}>
|
||||||
|
<n.icon className="h-4 w-4" /> {n.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="p-3 border-t">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2 px-1 truncate">{user.email}<br/><span className="capitalize">{role ?? "no role"}</span></div>
|
||||||
|
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={signOut}>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" /> Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-auto"><Outlet /></main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { createFileRoute, 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 { Switch } from "@/components/ui/switch";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { ArrowLeft, Upload, Trash2, Plus, FileText } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/students/$id")({
|
||||||
|
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
|
||||||
|
component: StudentDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
function StudentDetail() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: student } = useQuery({
|
||||||
|
queryKey: ["student", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await supabase.from("students").select("*, classes(name)").eq("id", id).single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl">
|
||||||
|
<Link to="/students" className="text-sm text-muted-foreground inline-flex items-center gap-1 mb-3"><ArrowLeft className="h-4 w-4" /> All students</Link>
|
||||||
|
<h1 className="text-2xl font-semibold">{student?.first_name} {student?.last_name}</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{(student?.classes as { name: string } | null)?.name ?? "No class"}</p>
|
||||||
|
|
||||||
|
<Tabs defaultValue="info" className="mt-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="info">Info</TabsTrigger>
|
||||||
|
<TabsTrigger value="pickups">Authorized pickup</TabsTrigger>
|
||||||
|
<TabsTrigger value="attendance">Attendance</TabsTrigger>
|
||||||
|
<TabsTrigger value="ledger">Tuition ledger</TabsTrigger>
|
||||||
|
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="info"><InfoTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
||||||
|
<TabsContent value="pickups"><PickupsTab studentId={id} /></TabsContent>
|
||||||
|
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
|
||||||
|
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
||||||
|
{isAdmin && <TabsContent value="contracts"><ContractsTab studentId={id} /></TabsContent>}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: s } = useQuery({
|
||||||
|
queryKey: ["student", studentId],
|
||||||
|
queryFn: async () => (await supabase.from("students").select("*").eq("id", studentId).single()).data,
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const current = form ?? (s as Record<string, unknown> | null);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!current) return;
|
||||||
|
const { error } = await supabase.from("students").update({
|
||||||
|
first_name: current.first_name as string,
|
||||||
|
last_name: current.last_name as string,
|
||||||
|
dob: (current.dob as string) || null,
|
||||||
|
allergies: (current.allergies as string) || null,
|
||||||
|
photo_release: !!current.photo_release,
|
||||||
|
notes: (current.notes as string) || null,
|
||||||
|
}).eq("id", studentId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { toast.success("Saved"); qc.invalidateQueries({ queryKey: ["student", studentId] }); setForm(null); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
const upd = (k: string, v: unknown) => setForm({ ...(current as Record<string, unknown>), [k]: v });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6 space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>First name</Label><Input disabled={!canEdit} value={current.first_name as string ?? ""} onChange={(e) => upd("first_name", e.target.value)} /></div>
|
||||||
|
<div><Label>Last name</Label><Input disabled={!canEdit} value={current.last_name as string ?? ""} onChange={(e) => upd("last_name", e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Date of birth</Label><Input disabled={!canEdit} type="date" value={(current.dob as string) ?? ""} onChange={(e) => upd("dob", e.target.value)} /></div>
|
||||||
|
<div><Label>Allergies</Label><Input disabled={!canEdit} value={(current.allergies as string) ?? ""} onChange={(e) => upd("allergies", e.target.value)} /></div>
|
||||||
|
<div className="flex items-center justify-between"><Label>Photo release granted</Label><Switch disabled={!canEdit} checked={!!current.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
|
||||||
|
<div><Label>Notes</Label><Textarea disabled={!canEdit} value={(current.notes as string) ?? ""} onChange={(e) => upd("notes", e.target.value)} /></div>
|
||||||
|
{canEdit && <Button onClick={() => save.mutate()} disabled={save.isPending}>Save changes</Button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickupsTab({ studentId }: { studentId: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["pickups", studentId],
|
||||||
|
queryFn: async () => (await supabase.from("authorized_pickups").select("*").eq("student_id", studentId).order("name")).data ?? [],
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState({ name: "", phone: "", relationship: "" });
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("authorized_pickups").insert({ student_id: studentId, ...form });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setForm({ name: "", phone: "", relationship: "" }); qc.invalidateQueries({ queryKey: ["pickups", studentId] }); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => { const { error } = await supabase.from("authorized_pickups").delete().eq("id", id); if (error) throw error; },
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["pickups", studentId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(data ?? []).map((p) => (
|
||||||
|
<div key={p.id} className="flex justify-between items-center p-3">
|
||||||
|
<div><div className="font-medium">{p.name}</div><div className="text-xs text-muted-foreground">{p.relationship} · {p.phone}</div></div>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => del.mutate(p.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No one added yet.</div>}
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">Add authorized pickup</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
<Input placeholder="Relationship" value={form.relationship} onChange={(e) => setForm({ ...form, relationship: e.target.value })} />
|
||||||
|
<Input placeholder="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => add.mutate()} disabled={!form.name || add.isPending}><Plus className="h-4 w-4 mr-1" /> Add</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttendanceTab({ studentId }: { studentId: string }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["attendance", studentId],
|
||||||
|
queryFn: async () => (await supabase.from("attendance").select("*").eq("student_id", studentId).order("date", { ascending: false }).limit(60)).data ?? [],
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg divide-y max-w-xl">
|
||||||
|
{(data ?? []).map((a) => (
|
||||||
|
<div key={a.id} className="flex justify-between p-3 text-sm">
|
||||||
|
<span>{new Date(a.date).toLocaleDateString()}</span>
|
||||||
|
<span className="capitalize">{a.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No attendance recorded.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LedgerTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["ledger", studentId],
|
||||||
|
queryFn: async () => (await supabase.from("ledger_entries").select("*").eq("student_id", studentId).order("date", { ascending: false })).data ?? [],
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState({ kind: "charge", category: "tuition", amount: "", note: "", date: new Date().toISOString().slice(0,10) });
|
||||||
|
|
||||||
|
const balance = (data ?? []).reduce((sum, e) => sum + (e.kind === "charge" ? e.amount_cents : -e.amount_cents), 0);
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const amt = Math.round(parseFloat(form.amount) * 100);
|
||||||
|
if (!amt) throw new Error("Enter a valid amount");
|
||||||
|
const { error } = await supabase.from("ledger_entries").insert({
|
||||||
|
student_id: studentId, kind: form.kind as "charge" | "payment", category: form.category as "tuition" | "late_pickup" | "activity" | "other",
|
||||||
|
amount_cents: amt, note: form.note || null, date: form.date,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setForm({ ...form, amount: "", note: "" }); qc.invalidateQueries({ queryKey: ["ledger", studentId] }); toast.success("Entry added"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<div className="bg-card border rounded-lg p-4 flex justify-between items-center">
|
||||||
|
<div className="text-sm text-muted-foreground">Current balance</div>
|
||||||
|
<div className={`text-2xl font-semibold ${balance > 0 ? "text-destructive" : ""}`}>${(balance / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted text-xs"><tr><th className="text-left p-2">Date</th><th className="text-left p-2">Type</th><th className="text-left p-2">Category</th><th className="text-left p-2">Note</th><th className="text-right p-2">Amount</th></tr></thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{(data ?? []).map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="p-2">{new Date(e.date).toLocaleDateString()}</td>
|
||||||
|
<td className="p-2 capitalize">{e.kind}</td>
|
||||||
|
<td className="p-2 capitalize">{e.category.replace("_", " ")}</td>
|
||||||
|
<td className="p-2 text-muted-foreground">{e.note}</td>
|
||||||
|
<td className={`p-2 text-right ${e.kind === "charge" ? "text-destructive" : "text-green-700"}`}>{e.kind === "payment" ? "−" : ""}${(e.amount_cents / 100).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data?.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-muted-foreground">No entries.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">Add entry</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
<select className="border rounded-md px-2 text-sm" value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
|
||||||
|
<option value="charge">Charge</option><option value="payment">Payment</option>
|
||||||
|
</select>
|
||||||
|
<select className="border rounded-md px-2 text-sm" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||||
|
<option value="tuition">Tuition</option><option value="late_pickup">Late pickup</option><option value="activity">Activity</option><option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||||
|
<Input placeholder="Amount ($)" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} />
|
||||||
|
<Input placeholder="Note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => add.mutate()} disabled={add.isPending}>Add entry</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContractsTab({ studentId }: { studentId: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["contracts", studentId],
|
||||||
|
queryFn: async () => (await supabase.from("contracts").select("*").eq("student_id", studentId).order("created_at", { ascending: false })).data ?? [],
|
||||||
|
});
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const onUpload = async (file: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const path = `${studentId}/${Date.now()}-${file.name}`;
|
||||||
|
const { error: upErr } = await supabase.storage.from("contracts").upload(path, file);
|
||||||
|
if (upErr) throw upErr;
|
||||||
|
const { data: u } = await supabase.auth.getUser();
|
||||||
|
const { error } = await supabase.from("contracts").insert({ student_id: studentId, file_path: path, title: title || file.name, uploaded_by: u.user?.id });
|
||||||
|
if (error) throw error;
|
||||||
|
setTitle("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["contracts", studentId] });
|
||||||
|
toast.success("Contract uploaded");
|
||||||
|
} catch (e) { toast.error((e as Error).message); } finally { setUploading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (path: string) => {
|
||||||
|
const { data, error } = await supabase.storage.from("contracts").createSignedUrl(path, 60);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
window.open(data.signedUrl, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">Upload signed contract (admin only)</div>
|
||||||
|
<Input placeholder="Title (optional)" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
<Input type="file" disabled={uploading} onChange={(e) => e.target.files && onUpload(e.target.files[0])} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(data ?? []).map((c) => (
|
||||||
|
<button key={c.id} onClick={() => download(c.file_path)} className="w-full flex justify-between items-center p-3 hover:bg-muted/50 text-left">
|
||||||
|
<span className="flex items-center gap-2 text-sm"><FileText className="h-4 w-4" /> {c.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{new Date(c.created_at).toLocaleDateString()}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No contracts uploaded.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { createFileRoute, 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 { 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" }] }),
|
||||||
|
component: StudentsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function StudentsPage() {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["students"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("students")
|
||||||
|
.select("id, first_name, last_name, dob, allergies, photo_release, class_id, classes(name)")
|
||||||
|
.order("last_name");
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Students</h1>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(students ?? []).map((s) => (
|
||||||
|
<Link to="/students/$id" params={{ id: s.id }} key={s.id} className="flex items-center justify-between px-4 py-3 hover:bg-muted/50">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{s.last_name}, {s.first_name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{(s.classes as { name: string } | null)?.name ?? "No class"} · {s.allergies ? `Allergies: ${s.allergies}` : "No allergies"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{students?.length === 0 && <div className="p-6 text-sm text-muted-foreground text-center">No students yet.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { lovable } from "@/integrations/lovable";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { GraduationCap } from "lucide-react";
|
||||||
|
|
||||||
|
const searchSchema = z.object({ mode: z.enum(["signin", "signup"]).optional() });
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/auth")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
|
head: () => ({ meta: [{ title: "Sign in — School Portal" }] }),
|
||||||
|
component: AuthPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AuthPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mode } = Route.useSearch();
|
||||||
|
const [tab, setTab] = useState<"signin" | "signup">(mode ?? "signin");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onSignIn = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
setLoading(false);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSignUp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: { emailRedirectTo: window.location.origin, data: { full_name: fullName } },
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
toast.success("Account created. You can sign in now.");
|
||||||
|
setTab("signin");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGoogle = async () => {
|
||||||
|
const res = await lovable.auth.signInWithOAuth("google", { redirect_uri: window.location.origin });
|
||||||
|
if (res.error) toast.error(res.error.message ?? "Google sign-in failed");
|
||||||
|
else if (!res.redirected) navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted/30 p-4">
|
||||||
|
<div className="w-full max-w-md bg-card border rounded-lg p-8 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<GraduationCap className="h-6 w-6 text-primary" />
|
||||||
|
<h1 className="font-semibold text-lg">School Portal</h1>
|
||||||
|
</div>
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as "signin" | "signup")}>
|
||||||
|
<TabsList className="grid grid-cols-2 w-full">
|
||||||
|
<TabsTrigger value="signin">Sign in</TabsTrigger>
|
||||||
|
<TabsTrigger value="signup">Create account</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="signin">
|
||||||
|
<form onSubmit={onSignIn} className="space-y-4 mt-4">
|
||||||
|
<div><Label>Email</Label><Input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||||
|
<div><Label>Password</Label><Input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} /></div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>Sign in</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="signup">
|
||||||
|
<form onSubmit={onSignUp} className="space-y-4 mt-4">
|
||||||
|
<div><Label>Full name</Label><Input required value={fullName} onChange={(e) => setFullName(e.target.value)} /></div>
|
||||||
|
<div><Label>Email</Label><Input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||||
|
<div><Label>Password</Label><Input type="password" required minLength={6} value={password} onChange={(e) => setPassword(e.target.value)} /></div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>Create account</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">The first account becomes admin. Other accounts default to parent — an admin can change your role.</p>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="my-4 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex-1 border-t" /> OR <div className="flex-1 border-t" />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full" onClick={onGoogle}>Continue with Google</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+53
-17
@@ -1,29 +1,65 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { GraduationCap, Users, CalendarDays, MessageSquare, FileText, Receipt } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Your App" },
|
{ title: "School Portal" },
|
||||||
{ name: "description", content: "Replace this with a one-sentence description of your app." },
|
{ name: "description", content: "Secure portal for staff and parents — attendance, tuition, messaging, and more." },
|
||||||
{ property: "og:title", content: "Your App" },
|
|
||||||
{ property: "og:description", content: "Replace this with a one-sentence description of your app." },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
component: Index,
|
component: Landing,
|
||||||
});
|
});
|
||||||
|
|
||||||
// IMPORTANT: Replace this placeholder. See ./README.md for routing conventions.
|
function Landing() {
|
||||||
function Index() {
|
const features = [
|
||||||
|
{ icon: Users, title: "Student records", desc: "Contacts, pickup list, allergies, photo release." },
|
||||||
|
{ icon: CalendarDays, title: "Attendance", desc: "Teachers mark their class. Parents see their child." },
|
||||||
|
{ icon: Receipt, title: "Tuition ledger", desc: "Monthly plan, late fees, activities — all in one place." },
|
||||||
|
{ icon: MessageSquare, title: "Messaging", desc: "Secure in-app inbox between staff and families." },
|
||||||
|
{ icon: FileText, title: "E-sign forms", desc: "Push a form, parents complete it in the portal." },
|
||||||
|
{ icon: CalendarDays, title: "School calendar", desc: "Year-at-a-glance for the whole school." },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen bg-background">
|
||||||
className="flex min-h-screen items-center justify-center"
|
<header className="border-b">
|
||||||
style={{ backgroundColor: "#fcfbf8" }}
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
>
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
<img
|
<GraduationCap className="h-6 w-6 text-primary" />
|
||||||
data-lovable-blank-page-placeholder="REMOVE_THIS"
|
<span>School Portal</span>
|
||||||
src="https://cdn.gpteng.co/blank-app-v1.svg"
|
</div>
|
||||||
alt="Your app will live here!"
|
<div className="flex gap-2">
|
||||||
/>
|
<Button asChild variant="ghost"><Link to="/auth">Sign in</Link></Button>
|
||||||
|
<Button asChild><Link to="/auth" search={{ mode: "signup" }}>Create account</Link></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="max-w-6xl mx-auto px-6 py-20 text-center">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">A private portal for your school community</h1>
|
||||||
|
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
One secure place for admins, teachers, and parents to manage students, attendance, tuition, and communication.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex gap-3 justify-center">
|
||||||
|
<Button asChild size="lg"><Link to="/auth">Sign in to portal</Link></Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="max-w-6xl mx-auto px-6 pb-20 grid md:grid-cols-3 gap-6">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div key={f.title} className="border rounded-lg p-6 bg-card">
|
||||||
|
<f.icon className="h-6 w-6 text-primary mb-3" />
|
||||||
|
<h3 className="font-semibold">{f.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="border-t py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Private parent portal · sign in required
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createStart, createMiddleware } from "@tanstack/react-start";
|
import { createStart, createMiddleware } from "@tanstack/react-start";
|
||||||
|
|
||||||
import { renderErrorPage } from "./lib/error-page";
|
import { renderErrorPage } from "./lib/error-page";
|
||||||
|
import { attachSupabaseAuth } from "@/integrations/supabase/auth-attacher";
|
||||||
|
|
||||||
const errorMiddleware = createMiddleware().server(async ({ next }) => {
|
const errorMiddleware = createMiddleware().server(async ({ next }) => {
|
||||||
try {
|
try {
|
||||||
@@ -18,5 +19,6 @@ const errorMiddleware = createMiddleware().server(async ({ next }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const startInstance = createStart(() => ({
|
export const startInstance = createStart(() => ({
|
||||||
|
functionMiddleware: [attachSupabaseAuth],
|
||||||
requestMiddleware: [errorMiddleware],
|
requestMiddleware: [errorMiddleware],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
project_id = "fvjpfhfgkhgtwtzrgcgx"
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
|
||||||
|
-- ============ ENUMS ============
|
||||||
|
CREATE TYPE public.app_role AS ENUM ('admin', 'teacher', 'parent');
|
||||||
|
CREATE TYPE public.attendance_status AS ENUM ('present', 'absent', 'late', 'excused');
|
||||||
|
CREATE TYPE public.ledger_kind AS ENUM ('charge', 'payment');
|
||||||
|
CREATE TYPE public.ledger_category AS ENUM ('tuition', 'late_pickup', 'activity', 'other');
|
||||||
|
|
||||||
|
-- ============ PROFILES ============
|
||||||
|
CREATE TABLE public.profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
full_name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON public.profiles TO authenticated;
|
||||||
|
GRANT ALL ON public.profiles TO service_role;
|
||||||
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ USER ROLES ============
|
||||||
|
CREATE TABLE public.user_roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
role app_role NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (user_id, role)
|
||||||
|
);
|
||||||
|
GRANT SELECT ON public.user_roles TO authenticated;
|
||||||
|
GRANT ALL ON public.user_roles TO service_role;
|
||||||
|
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.has_role(_user_id UUID, _role app_role)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = _role)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.current_user_has_role(_role app_role)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = auth.uid() AND role = _role)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============ CLASSES ============
|
||||||
|
CREATE TABLE public.classes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
teacher_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.classes TO authenticated;
|
||||||
|
GRANT ALL ON public.classes TO service_role;
|
||||||
|
ALTER TABLE public.classes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ STUDENTS ============
|
||||||
|
CREATE TABLE public.students (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
dob DATE,
|
||||||
|
class_id UUID REFERENCES public.classes(id) ON DELETE SET NULL,
|
||||||
|
allergies TEXT,
|
||||||
|
photo_release BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.students TO authenticated;
|
||||||
|
GRANT ALL ON public.students TO service_role;
|
||||||
|
ALTER TABLE public.students ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ PARENT-STUDENT LINKS ============
|
||||||
|
CREATE TABLE public.parent_students (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
parent_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||||
|
relationship TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (parent_id, student_id)
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.parent_students TO authenticated;
|
||||||
|
GRANT ALL ON public.parent_students TO service_role;
|
||||||
|
ALTER TABLE public.parent_students ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_parent_of(_student UUID)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.parent_students WHERE parent_id = auth.uid() AND student_id = _student)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.teaches_student(_student UUID)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM public.students s
|
||||||
|
JOIN public.classes c ON c.id = s.class_id
|
||||||
|
WHERE s.id = _student AND c.teacher_id = auth.uid()
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============ AUTHORIZED PICKUPS ============
|
||||||
|
CREATE TABLE public.authorized_pickups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
relationship TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.authorized_pickups TO authenticated;
|
||||||
|
GRANT ALL ON public.authorized_pickups TO service_role;
|
||||||
|
ALTER TABLE public.authorized_pickups ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ CONTRACTS (admin only) ============
|
||||||
|
CREATE TABLE public.contracts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
uploaded_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.contracts TO authenticated;
|
||||||
|
GRANT ALL ON public.contracts TO service_role;
|
||||||
|
ALTER TABLE public.contracts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ ATTENDANCE ============
|
||||||
|
CREATE TABLE public.attendance (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
status attendance_status NOT NULL DEFAULT 'present',
|
||||||
|
recorded_by UUID REFERENCES auth.users(id),
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (student_id, date)
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.attendance TO authenticated;
|
||||||
|
GRANT ALL ON public.attendance TO service_role;
|
||||||
|
ALTER TABLE public.attendance ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ LEDGER ============
|
||||||
|
CREATE TABLE public.ledger_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
kind ledger_kind NOT NULL,
|
||||||
|
category ledger_category NOT NULL DEFAULT 'tuition',
|
||||||
|
amount_cents INTEGER NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.ledger_entries TO authenticated;
|
||||||
|
GRANT ALL ON public.ledger_entries TO service_role;
|
||||||
|
ALTER TABLE public.ledger_entries ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ MESSAGING ============
|
||||||
|
CREATE TABLE public.message_threads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
subject TEXT,
|
||||||
|
student_id UUID REFERENCES public.students(id) ON DELETE SET NULL,
|
||||||
|
created_by UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.message_threads TO authenticated;
|
||||||
|
GRANT ALL ON public.message_threads TO service_role;
|
||||||
|
ALTER TABLE public.message_threads ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE TABLE public.thread_participants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
thread_id UUID NOT NULL REFERENCES public.message_threads(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (thread_id, user_id)
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.thread_participants TO authenticated;
|
||||||
|
GRANT ALL ON public.thread_participants TO service_role;
|
||||||
|
ALTER TABLE public.thread_participants ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_thread_participant(_thread UUID)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.thread_participants WHERE thread_id = _thread AND user_id = auth.uid())
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TABLE public.messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
thread_id UUID NOT NULL REFERENCES public.message_threads(id) ON DELETE CASCADE,
|
||||||
|
sender_id UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT ON public.messages TO authenticated;
|
||||||
|
GRANT ALL ON public.messages TO service_role;
|
||||||
|
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ FORMS ============
|
||||||
|
CREATE TABLE public.forms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
schema_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.forms TO authenticated;
|
||||||
|
GRANT ALL ON public.forms TO service_role;
|
||||||
|
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE TABLE public.form_responses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
form_id UUID NOT NULL REFERENCES public.forms(id) ON DELETE CASCADE,
|
||||||
|
student_id UUID REFERENCES public.students(id) ON DELETE SET NULL,
|
||||||
|
submitted_by UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
data_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT ON public.form_responses TO authenticated;
|
||||||
|
GRANT ALL ON public.form_responses TO service_role;
|
||||||
|
ALTER TABLE public.form_responses ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ CALENDAR ============
|
||||||
|
CREATE TABLE public.calendar_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.calendar_events TO authenticated;
|
||||||
|
GRANT ALL ON public.calendar_events TO service_role;
|
||||||
|
ALTER TABLE public.calendar_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ============ POLICIES ============
|
||||||
|
|
||||||
|
-- profiles: each user sees their own; admins see all
|
||||||
|
CREATE POLICY "own profile read" ON public.profiles FOR SELECT TO authenticated
|
||||||
|
USING (id = auth.uid() OR public.current_user_has_role('admin'));
|
||||||
|
CREATE POLICY "own profile update" ON public.profiles FOR UPDATE TO authenticated
|
||||||
|
USING (id = auth.uid() OR public.current_user_has_role('admin'));
|
||||||
|
CREATE POLICY "own profile insert" ON public.profiles FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (id = auth.uid());
|
||||||
|
|
||||||
|
-- user_roles: users see their own, admins manage all
|
||||||
|
CREATE POLICY "see own roles" ON public.user_roles FOR SELECT TO authenticated
|
||||||
|
USING (user_id = auth.uid() OR public.current_user_has_role('admin'));
|
||||||
|
CREATE POLICY "admins manage roles" ON public.user_roles FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- classes
|
||||||
|
CREATE POLICY "classes read" ON public.classes FOR SELECT TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin') OR teacher_id = auth.uid() OR public.current_user_has_role('parent'));
|
||||||
|
CREATE POLICY "admins manage classes" ON public.classes FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- students
|
||||||
|
CREATE POLICY "students read" ON public.students FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
public.current_user_has_role('admin')
|
||||||
|
OR public.is_parent_of(id)
|
||||||
|
OR (class_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.classes c WHERE c.id = class_id AND c.teacher_id = auth.uid()))
|
||||||
|
);
|
||||||
|
CREATE POLICY "admins manage students" ON public.students FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- parent_students
|
||||||
|
CREATE POLICY "parent_students read" ON public.parent_students FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
public.current_user_has_role('admin')
|
||||||
|
OR parent_id = auth.uid()
|
||||||
|
OR public.teaches_student(student_id)
|
||||||
|
);
|
||||||
|
CREATE POLICY "admins manage parent_students" ON public.parent_students FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- authorized_pickups
|
||||||
|
CREATE POLICY "pickups read" ON public.authorized_pickups 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 "pickups write parent or admin" ON public.authorized_pickups 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));
|
||||||
|
|
||||||
|
-- contracts: ADMIN ONLY
|
||||||
|
CREATE POLICY "contracts admin only" ON public.contracts FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- attendance
|
||||||
|
CREATE POLICY "attendance read" ON public.attendance 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 "attendance write teacher or admin" ON public.attendance FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin') OR public.teaches_student(student_id))
|
||||||
|
WITH CHECK (public.current_user_has_role('admin') OR public.teaches_student(student_id));
|
||||||
|
|
||||||
|
-- ledger
|
||||||
|
CREATE POLICY "ledger read" ON public.ledger_entries FOR SELECT TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin') OR public.is_parent_of(student_id));
|
||||||
|
CREATE POLICY "ledger admin manage" ON public.ledger_entries FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- threads
|
||||||
|
CREATE POLICY "threads participants read" ON public.message_threads FOR SELECT TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin') OR public.is_thread_participant(id));
|
||||||
|
CREATE POLICY "threads create" ON public.message_threads FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (created_by = auth.uid());
|
||||||
|
CREATE POLICY "threads admin manage" ON public.message_threads FOR UPDATE TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
CREATE POLICY "tp read" ON public.thread_participants FOR SELECT TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin') OR user_id = auth.uid() OR public.is_thread_participant(thread_id));
|
||||||
|
CREATE POLICY "tp insert" ON public.thread_participants FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
public.current_user_has_role('admin')
|
||||||
|
OR EXISTS (SELECT 1 FROM public.message_threads t WHERE t.id = thread_id AND t.created_by = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "msg read" ON public.messages FOR SELECT TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin') OR public.is_thread_participant(thread_id));
|
||||||
|
CREATE POLICY "msg send" ON public.messages FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (sender_id = auth.uid() AND (public.current_user_has_role('admin') OR public.is_thread_participant(thread_id)));
|
||||||
|
|
||||||
|
-- forms
|
||||||
|
CREATE POLICY "forms read all" ON public.forms FOR SELECT TO authenticated USING (TRUE);
|
||||||
|
CREATE POLICY "forms admin manage" ON public.forms FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
CREATE POLICY "form_responses read" ON public.form_responses FOR SELECT TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin') OR submitted_by = auth.uid());
|
||||||
|
CREATE POLICY "form_responses submit" ON public.form_responses FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (submitted_by = auth.uid());
|
||||||
|
|
||||||
|
-- calendar
|
||||||
|
CREATE POLICY "calendar read" ON public.calendar_events FOR SELECT TO authenticated USING (TRUE);
|
||||||
|
CREATE POLICY "calendar admin manage" ON public.calendar_events FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- ============ STORAGE POLICIES (contracts bucket: admin only) ============
|
||||||
|
CREATE POLICY "contracts bucket admin read" ON storage.objects FOR SELECT TO authenticated
|
||||||
|
USING (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
|
||||||
|
CREATE POLICY "contracts bucket admin write" ON storage.objects FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
|
||||||
|
CREATE POLICY "contracts bucket admin update" ON storage.objects FOR UPDATE TO authenticated
|
||||||
|
USING (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
|
||||||
|
CREATE POLICY "contracts bucket admin delete" ON storage.objects FOR DELETE TO authenticated
|
||||||
|
USING (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
|
||||||
|
|
||||||
|
-- ============ AUTO PROFILE + FIRST-USER-ADMIN TRIGGER ============
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.profiles (id, full_name, email)
|
||||||
|
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.email), NEW.email)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO user_count FROM public.user_roles;
|
||||||
|
IF user_count = 0 THEN
|
||||||
|
INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'admin');
|
||||||
|
ELSE
|
||||||
|
INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'parent')
|
||||||
|
ON CONFLICT (user_id, role) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
-- updated_at trigger function
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_updated_at()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_profiles_upd BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
CREATE TRIGGER trg_students_upd BEFORE UPDATE ON public.students FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
CREATE TRIGGER trg_threads_upd BEFORE UPDATE ON public.message_threads FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
Reference in New Issue
Block a user