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:
gpt-engineer-app[bot]
2026-06-30 20:46:17 +00:00
co-authored by renee-png
28 changed files with 11594 additions and 31 deletions
+6
View File
@@ -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"
+20
View File
@@ -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=="],
+8328
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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",
+56
View File
@@ -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;
}
+38
View File
@@ -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);
},
});
+68
View File
@@ -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);
},
});
+670
View File
@@ -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
View File
@@ -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>>
}
}
+2 -1
View File
@@ -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>
); );
} }
+238
View File
@@ -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>
);
}
+96
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+201
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+284
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+96
View File
@@ -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
View File
@@ -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>
); );
} }
+2
View File
@@ -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],
})); }));
+1
View File
@@ -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();