Compare commits

..

3 Commits
main ... tldraw

Author SHA1 Message Date
5e61f40911 major major 2026-03-07 17:32:08 +00:00
ea95bf965f whiteboards 2026-03-02 07:08:55 +00:00
2b7f446c03 tldraw implementation 2026-03-01 22:42:10 +00:00
67 changed files with 60775 additions and 1887 deletions

7
.cursor/settings.json Normal file
View File

@ -0,0 +1,7 @@
{
"plugins": {
"supabase": {
"enabled": true
}
}
}

View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

3536
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,13 +14,12 @@
"@clerk/elements": "^0.14.6", "@clerk/elements": "^0.14.6",
"@clerk/nextjs": "^5.4.1", "@clerk/nextjs": "^5.4.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@prisma/client": "^5.19.1",
"@supabase/supabase-js": "^2.98.0", "@supabase/supabase-js": "^2.98.0",
"@types/react-big-calendar": "^1.8.9", "@types/react-big-calendar": "^1.8.9",
"lucide-react": "^0.575.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "14.2.5", "next": "^14.2.35",
"next-cloudinary": "^6.13.0", "next-cloudinary": "^6.13.0",
"prisma": "^5.19.1",
"react": "^18", "react": "^18",
"react-big-calendar": "^1.13.2", "react-big-calendar": "^1.13.2",
"react-calendar": "^5.0.0", "react-calendar": "^5.0.0",
@ -37,11 +36,17 @@
"@types/react-dom": "^18", "@types/react-dom": "^18",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.5", "eslint-config-next": "^14.2.35",
"phoenix": "^1.8.4",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
},
"overrides": {
"@clerk/types": "4.26.0",
"@clerk/shared": "2.9.2",
"@clerk/clerk-react": "5.12.0"
} }
} }

51901
school_data.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,99 +1,99 @@
{ {
"teacherMap": { "teacherMap": {
"1": "user_3AJAkSqshofbdPsW7lZh6q2eCe5", "1": "user_3AcluevCik3awerLuiRklEiYlJK",
"2": "user_3AJAkVye7wKBBK8seog6gljyc7L", "2": "user_3AclukmobwQyj1tnS2EpfGIEt6R",
"3": "user_3AJAkXA9lrOHxsGcJNoJGOXKNOr", "3": "user_3Aclul7evfG5xOADBZnREFuhYJf",
"4": "user_3AJAkc3f42kZzVPLvaRBFHor14J", "4": "user_3Aclum88YIVFMJy42nhED1n0sYr",
"5": "user_3AJAkbsXvHmZcGu3wFpRZm732LX", "5": "user_3AclusjcjWN31nZipvNmfMnxliI",
"6": "user_3AJAke4rOtbf26yRM9JoHhtEjyD", "6": "user_3Acluy4wczfAKj0LgYUpqXx1Dwo",
"7": "user_3AJAklVf8Pnm2pH6Ql1PnfQBIID", "7": "user_3AcluzotsLfMQe83PxIpQ5o1Ajq",
"8": "user_3AJAki8tuWw9rWvZZD9xIP6CxVp", "8": "user_3AcluuRXAW5Y0pfUI0bHKIl5PEp",
"9": "user_3AJAkn3ET3kFyUrx636ayvHrLds", "9": "user_3Aclv3yUI1stBwX8v1Y6BZw6xBB",
"10": "user_3AJAkuiTMWimGNBtw7ymHVUqwVW", "10": "user_3Aclv3EpmOTEXnSOCyXFuvkDGDP",
"11": "user_3AJAkvjGTDDsscx7g9dD998d6Gq", "11": "user_3AclvFBzxeP3PqdRjT9Wcuy5Wvw",
"12": "user_3AJAkr08HMjIKMG1Guzw3HJqB9L", "12": "user_3AclvEh6mSOExxYPUjP5iqE06C8",
"13": "user_3AJAl1xiX1ZtMg0ohV7QB2TpHm5", "13": "user_3AclvCPsCtWmKKQplr2KfKAlAna",
"14": "user_3AJAl1tyuI5t3DfmleToHAw6HgJ", "14": "user_3AclvFUjLy3SwIL3egEjVmlUzDx",
"15": "user_3AJAl3Rr5gq7K1W0fhl6AVNFXrY" "15": "user_3AclvGYnbjRTd8unbv6bj0LJlIy"
}, },
"parentMap": { "parentMap": {
"1": "user_3AJAl6KD5fVP5MbjDZLpyoZ7sDG", "1": "user_3AclvXcXbK4reTdS7oNXcwYOPLF",
"2": "user_3AJAl9gkapHVzn5Tik7RteIEJVZ", "2": "user_3AclvcMY65f4gujVmJwfTElOL4I",
"3": "user_3AJAl5BLWwfpho7phY16SrCleE8", "3": "user_3AclvW0MF1AqFkRcapoJ7lFBTyi",
"4": "user_3AJAl7BN77L27mpmHzjCp96OlFx", "4": "user_3AclvgyTfukFFSnuZ7Ka3XQsBTB",
"5": "user_3AJAlFko0fvXyleuRuyqmGkpgTV", "5": "user_3Aclvg9OfhvlTzwWluw2eSNNXRP",
"6": "user_3AJAlEyIdgY7vVA7yZQUbTwimOC", "6": "user_3AclvjgJNIhAzMelhWTzaIBU3q2",
"7": "user_3AJAlIlHOwSpcR92ENGiHrz0nCg", "7": "user_3AclvexY0qT0klmi2YEx9g0j2kn",
"8": "user_3AJAlKekpw2nrtGnnMBvN0xdh3c", "8": "user_3Aclvt7rWlBsErcFgMzY32PTtZk",
"9": "user_3AJAlLmCzuvKoqh9yh2ulnmz26h", "9": "user_3Aclvm2ako1pUz9zD0sxq0zcrtB",
"10": "user_3AJAlPbUuestp7PvH2BuX2GQK6R", "10": "user_3AclvpzxKyOlI6yhS83zam9B3Ty",
"11": "user_3AJAlWR17pNYP0S24hiNnUKI2R1", "11": "user_3AclvvlQgtOnlcymUW2FaM5Tk9I",
"12": "user_3AJAlWTNINh2uIOEueR8dPKXjlc", "12": "user_3Aclvv0Jn0UrL7KVTswpJLojIVL",
"13": "user_3AJAlUx3a2u65hTIoxWLjmDnDuj", "13": "user_3AclvuAT2eVlEp8qFQg3dW7v5Nw",
"14": "user_3AJAlac7l5vhi9lbnvuOIEZ2zoz", "14": "user_3Aclw79caQ2PJ7L6F0MzeyDKEqq",
"15": "user_3AJAldy0F23q4Jm8kz8olXdoao0", "15": "user_3Aclw72NNounCPLb27uNI1qJqY5",
"16": "user_3AJAlekANUdcMJeZ34EFcCjluAq", "16": "user_3Aclw7mjthXqbQsI2XKEsWJlUbp",
"17": "user_3AJAllI3cBBjs5JbcUimhrLEYvf", "17": "user_3AclwDc08JgsMxWBIjFXFuVkKId",
"18": "user_3AJAlp8YJPzQwvcutFoCsHAz3Sn", "18": "user_3AclwBWEplMgRAJ9eLzGqQVJSCI",
"19": "user_3AJAllVI1vR6vwSPlLPCyIb9oZB", "19": "user_3AclwBj5Qzo9d5yF1S4AT7OEwEN",
"20": "user_3AJAlwjmMMnvV9la8fFlH7TYuOS", "20": "user_3AclwKBjFVhqiC2KIvFGhqkdkFP",
"21": "user_3AJAlxGYIRJnKZmvkt2HB9ukrlC", "21": "user_3AclwIhCpEdcksHaZCzlFJIFQgD",
"22": "user_3AJAltSwlQoPrRGcsAZcJpH2sH9", "22": "user_3AclwHyw3NQ3hwP77tgqBw5S0Nq",
"23": "user_3AJAlvcnUE4GGbYKK0odLUjwxqu", "23": "user_3AclwTy1XzFeeR0dz9ToErN5f3t",
"24": "user_3AJAm16YficrMn5CeAOCAxV8iu1", "24": "user_3AclwQsdiL8JzC4g3vbogU2XcN6",
"25": "user_3AJAlxj9Z9gE4EIwQ5pljEywIV0" "25": "user_3AclwU8IiWYz0LKR2tO7jEHVdAF"
}, },
"studentMap": { "studentMap": {
"1": "user_3AJAm7o0RjL16Gt1jE9YLG0tib7", "1": "user_3AclwbE0uJkJGKuHp1Zpn8gMsmQ",
"2": "user_3AJAm9SoYdWl5sQfbf8slNI2Qn4", "2": "user_3AclwWTpwvqyBqUMWkDxtAY5c7J",
"3": "user_3AJAm8NIt39YG9DPjnAwq2G2V3G", "3": "user_3AclwYNQel4ZU9gIhTeStiF1Ze6",
"4": "user_3AJAm5KPpNLvz8DjwNU6MRCBO5e", "4": "user_3AclwYrAL1BHDdL3WX8IhZktQN4",
"5": "user_3AJAmGDojjfnVtn7LrmLVemoCiM", "5": "user_3Aclwk98pbn7UMCwJtUNJCvDluj",
"6": "user_3AJAmDK9jVHWfe3FjdR7emQ329K", "6": "user_3AclwgmspnQjXqsqSgxbM5Bibqj",
"7": "user_3AJAmMNLtdeplBIS9kwXYW0FC9Z", "7": "user_3AclwhuDRVreoGet0J0uuV1kC8E",
"8": "user_3AJAmNXiIZz0FYILKDOZxq1ET56", "8": "user_3AclwnpM8BXNEJPeqnutsmVWMho",
"9": "user_3AJAmMeKtN7oMMrLgcxIEWsm0EV", "9": "user_3Aclwp8KhfBigj2v1dBhnAcEZR6",
"10": "user_3AJAmQsp9Xg8LDpxCkkmccjDCuU", "10": "user_3AclwnecWiKC7tTa5d2lbqQtBI4",
"11": "user_3AJAmZtzvmAwwu7QJRvIpxZCNYF", "11": "user_3AclwvZ3xeaPpIee1GZSaF0ZcjZ",
"12": "user_3AJAmXM1fL4b0fONRdM5uIRIxBS", "12": "user_3Aclx07Hzs19WqTDY5XgkZOlA5Z",
"13": "user_3AJAmi4pxmDtV24YaIaNmmack33", "13": "user_3Aclx0waQhbfrqHIuIbBI7xh4sQ",
"14": "user_3AJAmf864NjSUdQ6ExiMR3zaDCn", "14": "user_3Aclx1W0bqTDhFm5aXFaWhT3Zpj",
"15": "user_3AJAmbZArRtgzFrBdW77qbd7P1z", "15": "user_3Aclx90O8i0uRp3QhFf4vdGRgJO",
"16": "user_3AJAmpWu9wzc4ve2kwTKPLveFST", "16": "user_3Aclx3Wudlyg6UrLuYVPpisc9Kw",
"17": "user_3AJAmoVVKEmMeE8GMd7A2fgn5nY", "17": "user_3Aclx1pknqQHygFrrfgBSbQM8P5",
"18": "user_3AJAmlYITikQW1km45TqvQ2biBm", "18": "user_3AclxA2diOV37Z9RkTkFFxVRJCI",
"19": "user_3AJAmuEU2s2CLT9391jnj6Uxb3e", "19": "user_3AclxEYCVHlfshKyPTciQqBStYt",
"20": "user_3AJAmwfQjhHcnd6Y2HGvXWTxTj8", "20": "user_3AclxACCapDzlCapyET2IOl3TMi",
"21": "user_3AJAmxavPXIvafOo84MWRqKPyRN", "21": "user_3AclxOhxWRx6Wxq2FUoRECA2Irr",
"22": "user_3AJAmxtksJ1U2UjHoP1wzXgk3JB", "22": "user_3AclxLC8W2o7R6MPkIbNVM4Qltw",
"23": "user_3AJAn3gfMCCyfaFrX0yY69XfQfQ", "23": "user_3AclxORMxzSDn0gFyODb5hEZZOM",
"24": "user_3AJAn1UL8HJe9pfcZboyDCqg4Zd", "24": "user_3AclxQSp6TOwDfRhMDfOGrYDnzc",
"25": "user_3AJAn4fAYAtcYCLKv1fBkBJSyxw", "25": "user_3AclxUWnaRkJilrFTa8KSadPIOQ",
"26": "user_3AJAnBlPYjZBRY7HrMaY95WRQ59", "26": "user_3AclxVh9iksSofGVSlHhZuzzP8p",
"27": "user_3AJAnD31sahtYeq9Zfzu867VgB8", "27": "user_3AclxXf5JQwlyBFRQa3l1J2Z8H7",
"28": "user_3AJAnK8qcNr259cZnIKpH4ZuoRH", "28": "user_3AclxXRWh9OG3aOGZ75fxmoIrTy",
"29": "user_3AJAnECM3SCOTVaHQvwaIkV1YnJ", "29": "user_3Aclxd1tcajIoLs0ss92G93plxc",
"30": "user_3AJAnH4sNG5lbNwuvYHmmbU9J3U", "30": "user_3AclxlTKEK90s8UxTh2t1kZ8ckq",
"31": "user_3AJAnRjFXzyoALI4egtIvZEUFuA", "31": "user_3AclxitgziRzcyQ6ZrCktA9F7SL",
"32": "user_3AJAnYhwtMvevCuQScoLSJePp79", "32": "user_3Aclxl3jUH7ZkLDT60bygOcSXnp",
"33": "user_3AJAnXQkG2mOAFxFm1lHaOV2aJQ", "33": "user_3Aclxn9ZCLy55ov2bwc3OmR61nV",
"34": "user_3AJAnYU4Frx0bX863nwBE9EJaGH", "34": "user_3Aclxqhy4nWJgAziVYw55XC9o3p",
"35": "user_3AJAncF3uP2xfkU6WixizrfSSrl", "35": "user_3Aclxs3SiQx0F6eAoeZkoieoIRb",
"36": "user_3AJAngPagGao0QCe5AcGeeGGpDS", "36": "user_3AclxvfvGobbMne72Yxhmu7Fffg",
"37": "user_3AJAnazAi3ERTCfqVQr7jDtTOK4", "37": "user_3Aclxu8hSwFVmjUgWVyXDuYDs6Z",
"38": "user_3AJAnoJnVGxwohyI3ot6cwnZ6iO", "38": "user_3Aclxu20dFV0lzMyydeshUZOEV5",
"39": "user_3AJAnoCbmMiBBlXE3PvUN1vg3CT", "39": "user_3Acly3gPFSpK7hQmMCJZSAuYeIF",
"40": "user_3AJAniemPzyyEwPSZZ2h7iXK9lV", "40": "user_3Acly45oxORkyRFuVfBLXIwNYGU",
"41": "user_3AJAnvqsElaS1dwqPtmhw4N7dCS", "41": "user_3Acly8AmTgp5Kwt7wVcJXU3GTYN",
"42": "user_3AJAnqp0r30RwkxKEJK3nrPJcwR", "42": "user_3AclyEuWVgcFXYDdX9od3suZOiT",
"43": "user_3AJAnsBcWHQQGYCpKxLbfWpSWdE", "43": "user_3AclyBfcG4U2GQwIFGf4g2Hrsml",
"44": "user_3AJAnrDUy5R1iFD1Vsb8FNlDO1I", "44": "user_3AclyGTRbrzucyRS67zME9Z97x5",
"45": "user_3AJAo1kdbtoIYYx3gvEcr3D9WT8", "45": "user_3AclyHWmt9JWT1n19gHmxb4E5pl",
"46": "user_3AJAo0CALCnqJle4D4JGt5VNqHJ", "46": "user_3AclyJah0uOBB8CB1QczkiMfq4L",
"47": "user_3AJAo1fZPiFTp8U0NYaprnET0Ls", "47": "user_3AclyO2ceOwUYl7f3u7e3kDJE2S",
"48": "user_3AJAoB35YG2oZHKLvT4UFBQVBqj", "48": "user_3AclyTZCkT3qO9afGXrYfap7Shu",
"49": "user_3AJAoDZ1nOyp3f3KO2Lefzgi6Ah", "49": "user_3AclyRDenLpSPEbgA3naueu3egB",
"50": "user_3AJAo6si4CGdeQ3teM04nPmrJe2" "50": "user_3AclyVSlp4TstXlvS1SMfp6Spl4"
}, },
"classes": [ "classes": [
{ {
@ -101,42 +101,53 @@
"name": "1A", "name": "1A",
"gradeId": 1, "gradeId": 1,
"capacity": 20, "capacity": 20,
"supervisorId": "user_3AJAkSqshofbdPsW7lZh6q2eCe5" "supervisorId": "user_3AcluevCik3awerLuiRklEiYlJK",
"schoolId": "default-school-1"
}, },
{ {
"id": 2, "id": 2,
"name": "2A", "name": "2A",
"gradeId": 2, "gradeId": 2,
"capacity": 20, "capacity": 20,
"supervisorId": "user_3AJAkVye7wKBBK8seog6gljyc7L" "supervisorId": "user_3AclukmobwQyj1tnS2EpfGIEt6R",
"schoolId": "default-school-1"
}, },
{ {
"id": 3, "id": 3,
"name": "3A", "name": "3A",
"gradeId": 3, "gradeId": 3,
"capacity": 20, "capacity": 20,
"supervisorId": "user_3AJAkXA9lrOHxsGcJNoJGOXKNOr" "supervisorId": "user_3Aclul7evfG5xOADBZnREFuhYJf",
"schoolId": "default-school-1"
}, },
{ {
"id": 4, "id": 4,
"name": "4A", "name": "4A",
"gradeId": 4, "gradeId": 4,
"capacity": 20, "capacity": 20,
"supervisorId": "user_3AJAkc3f42kZzVPLvaRBFHor14J" "supervisorId": "user_3Aclum88YIVFMJy42nhED1n0sYr",
"schoolId": "default-school-1"
}, },
{ {
"id": 5, "id": 5,
"name": "5A", "name": "5A",
"gradeId": 5, "gradeId": 5,
"capacity": 20, "capacity": 20,
"supervisorId": "user_3AJAkbsXvHmZcGu3wFpRZm732LX" "supervisorId": "user_3AclusjcjWN31nZipvNmfMnxliI",
"schoolId": "default-school-1"
}, },
{ {
"id": 6, "id": 6,
"name": "6A", "name": "6A",
"gradeId": 6, "gradeId": 6,
"capacity": 20, "capacity": 20,
"supervisorId": "user_3AJAkSqshofbdPsW7lZh6q2eCe5" "supervisorId": "user_3AcluevCik3awerLuiRklEiYlJK",
"schoolId": "default-school-1"
} }
] ],
"defaultSchoolId": "default-school-1",
"independentTeacherId": "user_3AclvGojyeTkQjCvNa11lqR5Xd8",
"independentSchoolId": "independent-school-1",
"agencyTeacherId": "user_3AclvKuqUVtfwyWyuhN8cTo6CuY",
"agencySchoolId": "agency-school-1"
} }

View File

@ -31,22 +31,335 @@ async function cleanSupabase() {
console.log("Cleaning up Supabase tables..."); console.log("Cleaning up Supabase tables...");
const tables = [ const tables = [
"Result", "Assignment", "Exam", "Attendance", "Event", "Announcement", "Result", "Assignment", "Exam", "Attendance", "Event", "Announcement",
"Lesson", "TeacherSubject", "Student", "Teacher", "Parent", "Class", "Subject", "Grade" "Lesson", "TeacherSchool", "TeacherSubject", "StudentClass", "Student", "Teacher", "Parent", "Class", "Subject", "Grade", "School", "Admin"
]; ];
for (const table of tables) { for (const table of tables) {
await supabase.from(table).delete().neq("id", "0" as any); await supabase.from(table).delete().neq("id", "0" as any);
} }
} }
async function seedAdmin() { type AdminSeedInfo = {
console.log("Syncing Admin..."); adminId: string;
defaultSchoolId: string;
};
async function seedAdmin(): Promise<AdminSeedInfo | null> {
console.log("Syncing Admin and Creating Default School...");
const clerk = clerkClient(); const clerk = clerkClient();
const users = await clerk.users.getUserList({ limit: 100 }); const users = await clerk.users.getUserList({ limit: 100 });
const adminUser = users.data.find(u => u.username === "admin" || u.emailAddresses[0]?.emailAddress?.includes("admin")); const adminUser = users.data.find(u => u.username === "admin" || u.emailAddresses[0]?.emailAddress?.includes("admin"));
if (adminUser) { if (adminUser) {
await supabase.from("Admin").upsert({ id: adminUser.id, username: adminUser.username || "admin" }); const defaultSchoolId = "default-school-1";
console.log(`Synced Admin ID: ${adminUser.id}`); await supabase.from("School").upsert({
id: defaultSchoolId,
name: "Lama Academy",
type: "MANAGED",
adminId: adminUser.id
});
await supabase.from("Admin").upsert({
id: adminUser.id,
username: adminUser.username || "admin",
schoolId: defaultSchoolId,
});
console.log(
`Synced Admin ID: ${adminUser.id} and School ID: ${defaultSchoolId}`,
);
return { adminId: adminUser.id, defaultSchoolId };
}
return null;
}
/** Parse a CSV line respecting double-quoted fields (handles commas inside quotes). */
function parseCsvLine(line: string): string[] {
const trimmed = line.replace(/\r$/, "").trim();
if (!trimmed) return [];
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < trimmed.length; i++) {
const c = trimmed[i];
if (c === '"') {
if (inQuotes && trimmed[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (c === "," && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += c;
}
}
result.push(current.trim());
return result;
}
async function seedSchoolDirectory(adminId: string) {
try {
console.log("Seeding School directory from school_data.csv...");
const csvPath = path.join(process.cwd(), "school_data.csv");
if (!fs.existsSync(csvPath)) {
console.log("school_data.csv not found. Skipping school directory seed.");
return;
}
const raw = fs.readFileSync(csvPath, "utf-8");
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length <= 1) {
console.log("school_data.csv has no data rows. Skipping.");
return;
}
const header = parseCsvLine(lines[0]);
const idxUrn = header.indexOf("URN");
const idxName = header.indexOf("EstablishmentName");
const idxStatus = header.indexOf("EstablishmentStatus (name)");
const idxTypeGroup = header.indexOf("EstablishmentTypeGroup (name)");
if (idxUrn === -1 || idxName === -1 || idxStatus === -1 || idxTypeGroup === -1) {
console.log("Expected columns not found in school_data.csv header. Skipping.");
return;
}
const schools: { id: string; name: string; type: string; adminId: string }[] = [];
const MAX_ROWS = 500; // limit for local dev to avoid huge inserts
const dataLines = lines.slice(1, 1 + MAX_ROWS);
for (const line of dataLines) {
const cols = parseCsvLine(line);
if (!cols.length) continue;
const status = cols[idxStatus];
if (status !== "Open") continue;
const urn = cols[idxUrn];
const name = cols[idxName];
const typeGroup = cols[idxTypeGroup] || "";
if (!urn || !name) continue;
const schoolType =
/independent/i.test(typeGroup) ? "INDEPENDENT" : "MANAGED";
schools.push({
id: urn.toString(),
name,
type: schoolType,
adminId,
});
}
if (schools.length === 0) {
console.log("No open schools found to seed from CSV.");
return;
}
const { error } = await supabase.from("School").upsert(schools);
if (error) {
console.error("Error seeding School directory from CSV:", error);
} else {
console.log(`Seeded/updated ${schools.length} schools from directory.`);
}
} catch (err) {
console.error("Failed to seed School directory from CSV:", err);
}
}
async function seedTestTimetableForSchool(
schoolId: string,
teacherId: string,
classId: number,
subjectIds: number[],
) {
try {
console.log(`Seeding basic timetable config for school ${schoolId}...`);
// 1. Ensure AcademicYear and SchoolTimetable exist for this school
const { data: existingAy } = await supabase
.from("AcademicYear")
.select("id")
.eq("schoolId", schoolId)
.limit(1)
.single();
let academicYearId: number;
if (existingAy?.id) {
academicYearId = existingAy.id;
} else {
const { data: newAy, error: ayError } = await supabase
.from("AcademicYear")
.insert({ schoolId, startYear: 2026, endYear: 2027, name: "2026-2027" })
.select("id")
.single();
if (ayError || !newAy) {
console.error("Error seeding AcademicYear for school", schoolId, ayError);
return;
}
academicYearId = newAy.id;
}
const { data: existingSt } = await supabase
.from("SchoolTimetable")
.select("id")
.eq("academicYearId", academicYearId)
.eq("schoolId", schoolId)
.limit(1)
.single();
let schoolTimetableId: number;
if (existingSt?.id) {
schoolTimetableId = existingSt.id;
} else {
const { data: newSt, error: stError } = await supabase
.from("SchoolTimetable")
.insert({ academicYearId, schoolId, name: "Standard Week" })
.select("id")
.single();
if (stError || !newSt) {
console.error("Error seeding SchoolTimetable for school", schoolId, stError);
return;
}
schoolTimetableId = newSt.id;
}
const termStart = new Date("2026-02-23T00:00:00.000Z");
const termEnd = new Date("2026-03-27T00:00:00.000Z");
const { data: term, error: termError } = await supabase
.from("Term")
.insert({
schoolId,
academicYearId,
name: "Spring Term 2026 (Seeded)",
startDate: termStart.toISOString(),
endDate: termEnd.toISOString(),
})
.select()
.single();
if (termError) {
console.error("Error seeding term for school", schoolId, termError);
return;
}
await supabase.from("Holiday").insert({
schoolId,
academicYearId,
name: "Half Term Break",
startDate: "2026-03-09T00:00:00.000Z",
endDate: "2026-03-13T23:59:59.000Z",
});
const { data: slots, error: slotError } = await supabase
.from("SchoolTimetableSlot")
.insert([
{
schoolId,
schoolTimetableId,
name: "Period 1",
startTime: "09:00",
endTime: "10:00",
isTeachingSlot: true,
position: 1,
},
{
schoolId,
schoolTimetableId,
name: "Period 2",
startTime: "10:15",
endTime: "11:15",
isTeachingSlot: true,
position: 2,
},
{
schoolId,
schoolTimetableId,
name: "Period 3",
startTime: "11:30",
endTime: "12:30",
isTeachingSlot: true,
position: 3,
},
])
.select();
if (slotError || !slots || slots.length === 0) {
console.error("Error seeding timetable slots for school", schoolId, slotError);
return;
}
const p1 = slots.find((s: any) => s.name === "Period 1");
const p2 = slots.find((s: any) => s.name === "Period 2");
const p3 = slots.find((s: any) => s.name === "Period 3");
if (!p1 || !p2 || !p3) {
console.error("Missing seeded slots for school", schoolId);
return;
}
const { data: templates, error: templateError } = await supabase
.from("TeacherTimetableTemplate")
.insert({
schoolId,
schoolTimetableId,
name: "Default Weekly Template (Seeded)",
teacherId,
})
.select();
if (templateError || !templates || templates.length === 0) {
console.error("Error seeding timetable template for school", schoolId, templateError);
return;
}
const templateId = templates[0].id as number;
const [subjectA, subjectB] = subjectIds;
const entries = [
{
teacherTimetableTemplateId: templateId,
schoolTimetableSlotId: p1.id,
classId,
subjectId: subjectA,
dayOfWeek: 1,
},
{
teacherTimetableTemplateId: templateId,
schoolTimetableSlotId: p2.id,
classId,
subjectId: subjectB,
dayOfWeek: 1,
},
{
teacherTimetableTemplateId: templateId,
schoolTimetableSlotId: p1.id,
classId,
subjectId: subjectB,
dayOfWeek: 3,
},
{
teacherTimetableTemplateId: templateId,
schoolTimetableSlotId: p3.id,
classId,
subjectId: subjectA,
dayOfWeek: 5,
},
];
const { error: entryError } = await supabase
.from("TeacherTimetableEntry")
.insert(entries);
if (entryError) {
console.error("Error seeding timetable entries for school", schoolId, entryError);
}
} catch (err) {
console.error("Failed to seed timetable config for school", schoolId, err);
} }
} }
@ -55,27 +368,37 @@ async function main() {
const clerk = clerkClient(); const clerk = clerkClient();
await cleanClerk(); await cleanClerk();
await cleanSupabase(); await cleanSupabase();
await seedAdmin(); const adminInfo = await seedAdmin();
if (!adminInfo) {
console.error("No Admin user found. Seed aborted.");
return;
}
const { adminId, defaultSchoolId } = adminInfo;
// Populate School directory from CSV (for My Schools / selection)
await seedSchoolDirectory(adminId);
console.log("Seeding Grades and Subjects..."); console.log("Seeding Grades and Subjects...");
const grades = [1, 2, 3, 4, 5, 6].map((level) => ({ id: level, level })); const grades = [1, 2, 3, 4, 5, 6].map((level) => ({ id: level, level }));
await supabase.from("Grade").insert(grades); await supabase.from("Grade").insert(grades);
const subjectsArray = [ const subjectsArray = [
{ id: 1, name: "Mathematics" }, { id: 1, name: "Mathematics", schoolId: defaultSchoolId },
{ id: 2, name: "Science" }, { id: 2, name: "Science", schoolId: defaultSchoolId },
{ id: 3, name: "English" }, { id: 3, name: "English", schoolId: defaultSchoolId },
{ id: 4, name: "History" }, { id: 4, name: "History", schoolId: defaultSchoolId },
{ id: 5, name: "Geography" }, { id: 5, name: "Geography", schoolId: defaultSchoolId },
{ id: 6, name: "Physics" }, { id: 6, name: "Physics", schoolId: defaultSchoolId },
{ id: 7, name: "Chemistry" }, { id: 7, name: "Chemistry", schoolId: defaultSchoolId },
{ id: 8, name: "Biology" }, { id: 8, name: "Biology", schoolId: defaultSchoolId },
{ id: 9, name: "Computer Science" }, { id: 9, name: "Computer Science", schoolId: defaultSchoolId },
{ id: 10, name: "Art" }, { id: 10, name: "Art", schoolId: defaultSchoolId },
]; ];
await supabase.from("Subject").insert(subjectsArray); await supabase.from("Subject").insert(subjectsArray);
console.log("Creating 15 Teachers..."); console.log("Creating 15 Managed Teachers in Default School...");
const teacherMap: Record<number, string> = {}; const teacherMap: Record<number, string> = {};
for (let i = 1; i <= 15; i++) { for (let i = 1; i <= 15; i++) {
const user = await clerk.users.createUser({ const user = await clerk.users.createUser({
@ -100,23 +423,264 @@ async function main() {
birthday: "1996-02-27T00:26:35.280Z" birthday: "1996-02-27T00:26:35.280Z"
}); });
await supabase.from("TeacherSchool").insert({
teacherId: user.id,
schoolId: defaultSchoolId,
isManaged: true
});
await supabase.from("TeacherSubject").insert([ await supabase.from("TeacherSubject").insert([
{ subjectId: (i % 10) + 1, teacherId: user.id, isPrimary: true }, { subjectId: (i % 10) + 1, teacherId: user.id, isPrimary: true },
{ subjectId: ((i + 1) % 10) + 1, teacherId: user.id } { subjectId: ((i + 1) % 10) + 1, teacherId: user.id }
]); ]);
} }
console.log("Creating 6 Classes..."); console.log("Creating Independent Teacher & Test School...");
const independentUser = await clerk.users.createUser({
username: "independent1",
password: PASSWORD,
firstName: "Indy",
lastName: "Teacher",
publicMetadata: { role: "teacher", teacherType: "INDEPENDENT" },
});
await supabase.from("Teacher").insert({
id: independentUser.id,
username: "independent1",
name: "Indy",
surname: "Teacher",
email: "independent1@example.com",
phone: "123-456-7890",
address: "Independent Street 1",
bloodType: "A+",
sex: "FEMALE",
birthday: "1990-01-01T00:00:00.000Z",
});
const independentSchoolId = "independent-school-1";
await supabase.from("School").upsert({
id: independentSchoolId,
name: "Independent School 1",
type: "INDEPENDENT",
adminId: independentUser.id,
});
await supabase.from("TeacherSchool").insert({
teacherId: independentUser.id,
schoolId: independentSchoolId,
isManaged: false,
});
// Optional: basic schedule indicating this teacher works at their independent school on weekdays
await supabase.from("TeacherSchoolSchedule").insert({
teacherId: independentUser.id,
schoolId: independentSchoolId,
startDate: "2026-01-01T00:00:00.000Z",
endDate: "2026-12-31T00:00:00.000Z",
daysOfWeek: [1, 2, 3, 4, 5],
});
const independentSubjects = [
{ id: 1001, name: "Indy Mathematics", schoolId: independentSchoolId },
{ id: 1002, name: "Indy English", schoolId: independentSchoolId },
];
await supabase.from("Subject").insert(independentSubjects);
await supabase.from("TeacherSubject").insert([
{ subjectId: 1001, teacherId: independentUser.id, isPrimary: true },
{ subjectId: 1002, teacherId: independentUser.id },
]);
console.log("Creating Agency Teacher & Test Agency School...");
const agencyUser = await clerk.users.createUser({
username: "agency1",
password: PASSWORD,
firstName: "Agen",
lastName: "Teacher",
publicMetadata: { role: "teacher", teacherType: "AGENCY" },
});
await supabase.from("Teacher").insert({
id: agencyUser.id,
username: "agency1",
name: "Agen",
surname: "Teacher",
email: "agency1@example.com",
phone: "987-654-3210",
address: "Agency Road 1",
bloodType: "B+",
sex: "MALE",
birthday: "1988-05-15T00:00:00.000Z",
});
const agencySchoolId = "agency-school-1";
await supabase.from("School").upsert({
id: agencySchoolId,
name: "Agency School 1",
type: "AGENCY",
adminId: agencyUser.id,
});
await supabase.from("TeacherSchool").insert({
teacherId: agencyUser.id,
schoolId: agencySchoolId,
isManaged: false,
});
await supabase.from("TeacherSchoolSchedule").insert({
teacherId: agencyUser.id,
schoolId: agencySchoolId,
startDate: "2026-01-01T00:00:00.000Z",
endDate: "2026-12-31T00:00:00.000Z",
daysOfWeek: [1, 3, 5], // Example: Mon, Wed, Fri
});
const agencySubjects = [
{ id: 1003, name: "Agency Mathematics", schoolId: agencySchoolId },
{ id: 1004, name: "Agency English", schoolId: agencySchoolId },
];
await supabase.from("Subject").insert(agencySubjects);
await supabase.from("TeacherSubject").insert([
{ subjectId: 1003, teacherId: agencyUser.id, isPrimary: true },
{ subjectId: 1004, teacherId: agencyUser.id },
]);
console.log("Creating additional Independent & Agency test teachers (blank setups)...");
const independentUser2 = await clerk.users.createUser({
username: "independent2",
password: PASSWORD,
firstName: "Indy",
lastName: "Teacher2",
publicMetadata: { role: "teacher", teacherType: "INDEPENDENT" },
});
await supabase.from("Teacher").insert({
id: independentUser2.id,
username: "independent2",
name: "Indy",
surname: "Teacher2",
email: "independent2@example.com",
phone: "123-456-7891",
address: "Independent Street 2",
bloodType: "A+",
sex: "MALE",
birthday: "1992-01-01T00:00:00.000Z",
});
const independentBlankSchoolId = "independent-school-2";
await supabase.from("School").upsert({
id: independentBlankSchoolId,
name: "Independent School 2 (Blank)",
type: "INDEPENDENT",
adminId: independentUser2.id,
});
await supabase.from("TeacherSchool").insert({
teacherId: independentUser2.id,
schoolId: independentBlankSchoolId,
isManaged: false,
});
await supabase.from("TeacherSchoolSchedule").insert({
teacherId: independentUser2.id,
schoolId: independentBlankSchoolId,
startDate: "2026-01-01T00:00:00.000Z",
endDate: "2026-12-31T00:00:00.000Z",
daysOfWeek: [1, 2, 3, 4, 5],
});
const agencyUser2 = await clerk.users.createUser({
username: "agency2",
password: PASSWORD,
firstName: "Agen",
lastName: "Teacher2",
publicMetadata: { role: "teacher", teacherType: "AGENCY" },
});
await supabase.from("Teacher").insert({
id: agencyUser2.id,
username: "agency2",
name: "Agen",
surname: "Teacher2",
email: "agency2@example.com",
phone: "987-654-3211",
address: "Agency Road 2",
bloodType: "B+",
sex: "FEMALE",
birthday: "1989-05-15T00:00:00.000Z",
});
const agencyBlankSchoolId = "agency-school-2";
await supabase.from("School").upsert({
id: agencyBlankSchoolId,
name: "Agency School 2 (Blank)",
type: "AGENCY",
adminId: agencyUser2.id,
});
await supabase.from("TeacherSchool").insert({
teacherId: agencyUser2.id,
schoolId: agencyBlankSchoolId,
isManaged: false,
});
await supabase.from("TeacherSchoolSchedule").insert({
teacherId: agencyUser2.id,
schoolId: agencyBlankSchoolId,
startDate: "2026-01-01T00:00:00.000Z",
endDate: "2026-12-31T00:00:00.000Z",
daysOfWeek: [2, 4], // Example: Tue, Thu
});
console.log("Creating 6 Classes for default school...");
const classesArray = [ const classesArray = [
{ id: 1, name: "1A", gradeId: 1, capacity: 20, supervisorId: teacherMap[1] }, { id: 1, name: "1A", gradeId: 1, capacity: 20, supervisorId: teacherMap[1], schoolId: defaultSchoolId },
{ id: 2, name: "2A", gradeId: 2, capacity: 20, supervisorId: teacherMap[2] }, { id: 2, name: "2A", gradeId: 2, capacity: 20, supervisorId: teacherMap[2], schoolId: defaultSchoolId },
{ id: 3, name: "3A", gradeId: 3, capacity: 20, supervisorId: teacherMap[3] }, { id: 3, name: "3A", gradeId: 3, capacity: 20, supervisorId: teacherMap[3], schoolId: defaultSchoolId },
{ id: 4, name: "4A", gradeId: 4, capacity: 20, supervisorId: teacherMap[4] }, { id: 4, name: "4A", gradeId: 4, capacity: 20, supervisorId: teacherMap[4], schoolId: defaultSchoolId },
{ id: 5, name: "5A", gradeId: 5, capacity: 20, supervisorId: teacherMap[5] }, { id: 5, name: "5A", gradeId: 5, capacity: 20, supervisorId: teacherMap[5], schoolId: defaultSchoolId },
{ id: 6, name: "6A", gradeId: 6, capacity: 20, supervisorId: teacherMap[1] }, { id: 6, name: "6A", gradeId: 6, capacity: 20, supervisorId: teacherMap[1], schoolId: defaultSchoolId },
]; ];
await supabase.from("Class").insert(classesArray); await supabase.from("Class").insert(classesArray);
console.log("Creating test classes for Independent and Agency schools...");
const independentClassId = 1001;
const agencyClassId = 2001;
await supabase.from("Class").insert([
{
id: independentClassId,
name: "Indy Class 1",
gradeId: 1,
capacity: 25,
supervisorId: independentUser.id,
schoolId: independentSchoolId,
},
{
id: agencyClassId,
name: "Agency Class 1",
gradeId: 1,
capacity: 25,
supervisorId: agencyUser.id,
schoolId: agencySchoolId,
},
]);
console.log("Seeding basic timetable config for Independent and Agency test schools...");
await seedTestTimetableForSchool(
independentSchoolId,
independentUser.id,
1001,
[1001, 1002],
);
await seedTestTimetableForSchool(
agencySchoolId,
agencyUser.id,
2001,
[1003, 1004],
);
console.log("Creating 25 Parents..."); console.log("Creating 25 Parents...");
const parentMap: Record<number, string> = {}; const parentMap: Record<number, string> = {};
for (let i = 1; i <= 25; i++) { for (let i = 1; i <= 25; i++) {
@ -136,7 +700,8 @@ async function main() {
surname: `PSurname${i}`, surname: `PSurname${i}`,
email: `parent${i}@example.com`, email: `parent${i}@example.com`,
phone: `123-456-789${i}`, phone: `123-456-789${i}`,
address: `Address${i}` address: `Address${i}`,
schoolId: defaultSchoolId
}); });
} }
@ -165,8 +730,12 @@ async function main() {
sex: i % 2 === 0 ? "MALE" : "FEMALE", sex: i % 2 === 0 ? "MALE" : "FEMALE",
parentId: parentMap[(i % 25) + 1], parentId: parentMap[(i % 25) + 1],
gradeId: classInfo.gradeId, gradeId: classInfo.gradeId,
classId: classInfo.id, birthday: "2016-02-27T00:26:35.281Z",
birthday: "2016-02-27T00:26:35.281Z" schoolId: defaultSchoolId
});
await supabase.from("StudentClass").insert({
studentId: user.id,
classId: classInfo.id
}); });
} }
@ -176,7 +745,12 @@ async function main() {
teacherMap, teacherMap,
parentMap, parentMap,
studentMap, studentMap,
classes: classesArray classes: classesArray,
defaultSchoolId,
independentTeacherId: independentUser.id,
independentSchoolId,
agencyTeacherId: agencyUser.id,
agencySchoolId,
}; };
fs.writeFileSync(seedDataPath, JSON.stringify(seedData, null, 2)); fs.writeFileSync(seedDataPath, JSON.stringify(seedData, null, 2));

View File

@ -64,10 +64,10 @@ async function main() {
throw new Error("seed-data.json not found! Please run `npm run seed:users` first."); throw new Error("seed-data.json not found! Please run `npm run seed:users` first.");
} }
const data = JSON.parse(fs.readFileSync(seedDataPath, "utf-8")); const data = JSON.parse(fs.readFileSync(seedDataPath, "utf-8"));
const { teacherMap, studentMap, classes } = data; const { teacherMap, studentMap, classes, defaultSchoolId } = data;
console.log("Cleaning old Schedule & Attendance data..."); console.log("Cleaning old Schedule & Attendance data...");
const tablesToClean = ["Attendance", "Result", "Assignment", "Exam", "Lesson"]; const tablesToClean = ["LessonWhiteboard", "Attendance", "Result", "Assignment", "Exam", "Lesson"];
for (const table of tablesToClean) { for (const table of tablesToClean) {
await supabase.from(table).delete().neq("id", "0" as any); await supabase.from(table).delete().neq("id", "0" as any);
} }
@ -86,6 +86,7 @@ async function main() {
let attendanceIdCounter = 1; let attendanceIdCounter = 1;
const lessonsData = []; const lessonsData = [];
const whiteboardsData = [];
const examsData = []; const examsData = [];
const assignmentsData = []; const assignmentsData = [];
const resultsData = []; const resultsData = [];
@ -141,12 +142,16 @@ async function main() {
lessonsData.push({ lessonsData.push({
id: lessonIdCounter, id: lessonIdCounter,
name: `${classInfo.name} ${subjectKey} (${dayName})`, name: `${classInfo.name} ${subjectKey} (${dayName})`,
day: dayName,
startTime: startTime.toISOString(), startTime: startTime.toISOString(),
endTime: endTime.toISOString(), endTime: endTime.toISOString(),
subjectId: subjectId, subjectId: subjectId,
classId: classInfo.id, classId: classInfo.id,
teacherId: teacherId teacherId: teacherId,
schoolId: defaultSchoolId
});
whiteboardsData.push({
lessonId: lessonIdCounter
}); });
// 2. Insert Exam / Results (Random Probability) // 2. Insert Exam / Results (Random Probability)
@ -156,7 +161,8 @@ async function main() {
title: `${subjectKey} Assessment`, title: `${subjectKey} Assessment`,
startTime: startTime.toISOString(), startTime: startTime.toISOString(),
endTime: endTime.toISOString(), endTime: endTime.toISOString(),
lessonId: lessonIdCounter lessonId: lessonIdCounter,
schoolId: defaultSchoolId
}); });
// Only give results to a subset to prevent table explosions // Only give results to a subset to prevent table explosions
@ -165,7 +171,8 @@ async function main() {
id: resultIdCounter++, id: resultIdCounter++,
score: 60 + Math.floor(Math.random() * 41), // 60-100 score: 60 + Math.floor(Math.random() * 41), // 60-100
studentId: sId, studentId: sId,
examId: examIdCounter examId: examIdCounter,
schoolId: defaultSchoolId
}); });
} }
examIdCounter++; examIdCounter++;
@ -178,7 +185,8 @@ async function main() {
title: `${subjectKey} Practice`, title: `${subjectKey} Practice`,
startDate: startTime.toISOString(), startDate: startTime.toISOString(),
dueDate: new Date(startTime.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), dueDate: new Date(startTime.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
lessonId: lessonIdCounter lessonId: lessonIdCounter,
schoolId: defaultSchoolId
}); });
} }
@ -190,7 +198,8 @@ async function main() {
date: startTime.toISOString(), date: startTime.toISOString(),
present: Math.random() < CONFIG.attendanceProbability, present: Math.random() < CONFIG.attendanceProbability,
studentId: sId, studentId: sId,
lessonId: lessonIdCounter lessonId: lessonIdCounter,
schoolId: defaultSchoolId
}); });
} }
} }
@ -206,13 +215,21 @@ async function main() {
// Send payload in bulk batches to Supabase to prevent network/memory bottlenecks // Send payload in bulk batches to Supabase to prevent network/memory bottlenecks
await insertInChunks("Lesson", lessonsData); await insertInChunks("Lesson", lessonsData);
await insertInChunks("LessonWhiteboard", whiteboardsData);
await insertInChunks("Exam", examsData); await insertInChunks("Exam", examsData);
await insertInChunks("Assignment", assignmentsData); await insertInChunks("Assignment", assignmentsData);
await insertInChunks("Result", resultsData); await insertInChunks("Result", resultsData);
await insertInChunks("Attendance", attendancesData); await insertInChunks("Attendance", attendancesData);
// Keep Lesson id sequence in sync so "Generate lessons" from templates does not hit duplicate key
const { error: syncErr } = await supabase.rpc("sync_lesson_id_sequence");
if (syncErr) {
console.warn("Lesson sequence sync skipped (run migrations if you use Generate lessons):", syncErr.message);
}
console.log(`\n✅ Timeline successfully generated!`); console.log(`\n✅ Timeline successfully generated!`);
console.log(`- Lessons: ${lessonsData.length}`); console.log(`- Lessons: ${lessonsData.length}`);
console.log(`- Whiteboards: ${whiteboardsData.length}`);
console.log(`- Exams: ${examsData.length}`); console.log(`- Exams: ${examsData.length}`);
console.log(`- Assignments: ${assignmentsData.length}`); console.log(`- Assignments: ${assignmentsData.length}`);
console.log(`- Results: ${resultsData.length}`); console.log(`- Results: ${resultsData.length}`);

View File

@ -1,31 +1,70 @@
import Menu from "@/components/Menu"; import DashboardShell from "@/components/DashboardShell";
import Navbar from "@/components/Navbar"; import { currentUser } from "@clerk/nextjs/server";
import Image from "next/image"; import { redirect } from "next/navigation";
import Link from "next/link"; import { getSupabaseClient } from "@/lib/supabase";
export default function DashboardLayout({ export default async function DashboardLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const user = await currentUser();
if (!user) {
redirect("/sign-in");
}
const role = user.publicMetadata.role as string;
const teacherType = user.publicMetadata.teacherType as string | undefined;
const activeSchoolId = user.publicMetadata.schoolId as string | undefined;
const supabase = await getSupabaseClient();
let schools: { id: string; name: string }[] = [];
if (role === "admin") {
const { data } = await supabase
.from("School")
.select("id, name")
.eq("adminId", user.id);
schools = data || [];
} else if (role === "teacher") {
const { data: mappings } = await supabase
.from("TeacherSchool")
.select("schoolId")
.eq("teacherId", user.id);
const schoolIds = (mappings || []).map((m: any) => m.schoolId);
if (schoolIds.length > 0) {
const { data } = await supabase
.from("School")
.select("id, name")
.in("id", schoolIds);
schools = data || [];
}
}
const canManageSchool =
role === "admin" ||
(role === "teacher" &&
(teacherType === "INDEPENDENT" || teacherType === "AGENCY"));
const userMetadata = {
firstName: user.firstName,
lastName: user.lastName,
role: role,
teacherType,
activeSchoolId,
schools,
};
return ( return (
<div className="h-screen flex"> <DashboardShell
{/* LEFT */} role={role}
<div className="w-[14%] md:w-[8%] lg:w-[16%] xl:w-[14%] p-4"> userMetadata={userMetadata}
<Link canManageSchool={canManageSchool}
href="/" >
className="flex items-center justify-center lg:justify-start gap-2" {children}
> </DashboardShell>
<Image src="/logo.png" alt="logo" width={32} height={32} />
<span className="hidden lg:block font-bold">SchooLama</span>
</Link>
<Menu />
</div>
{/* RIGHT */}
<div className="w-[86%] md:w-[92%] lg:w-[84%] xl:w-[86%] bg-[#F7F8FA] overflow-scroll flex flex-col">
<Navbar />
{children}
</div>
</div>
); );
} }

View File

@ -1,12 +1,13 @@
import FormContainer from "@/components/FormContainer"; import FormContainer from "@/components/FormContainer";
import ListFilterSort from "@/components/ListFilterSort";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import Table from "@/components/Table"; import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings"; import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase"; import { Tables } from "@/types/supabase";
import Image from "next/image"; import Image from "next/image";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { Suspense } from "react";
type ClassList = Tables<"Class"> & { supervisor: Tables<"Teacher"> | null }; type ClassList = Tables<"Class"> & { supervisor: Tables<"Teacher"> | null };
@ -79,6 +80,14 @@ const ClassListPage = async ({
const p = page ? parseInt(page) : 1; const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const teachersRes = await supabase.from("Teacher").select("id, name, surname").order("name");
const teacherOptions = (teachersRes.data ?? []).map((t) => ({
value: t.id,
label: `${t.name} ${t.surname}`.trim(),
}));
const sortOptions = [{ value: "name", label: "Name" }];
const filters = [{ key: "supervisorId", label: "Supervisor", options: teacherOptions }];
// URL PARAMS CONDITION // URL PARAMS CONDITION
let query = supabase let query = supabase
.from("Class") .from("Class")
@ -86,20 +95,26 @@ const ClassListPage = async ({
if (queryParams) { if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) { if (value === undefined) continue;
switch (key) { switch (key) {
case "supervisorId": case "supervisorId":
query = query.eq("supervisorId", value); query = query.eq("supervisorId", value);
break; break;
case "search": case "search":
query = query.ilike("name", `%${value}%`); query = query.ilike("name", `%${value}%`);
break; break;
default: case "sortBy": {
break; const col = value === "name" ? "name" : "name";
const asc = queryParams.sortOrder !== "desc";
query = query.order(col, { ascending: asc });
break;
} }
default:
break;
} }
} }
} }
if (!queryParams.sortBy) query = query.order("name", { ascending: true });
// PAGINATION // PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1); query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
@ -114,23 +129,14 @@ const ClassListPage = async ({
return ( return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0"> <div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">All Classes</h1> <h1 className="hidden md:block text-lg font-semibold">All Classes</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && <FormContainer table="class" type="create" />}
</div>
</div>
</div> </div>
{/* LIST */} <Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
{role === "admin" && <FormContainer table="class" type="create" />}
</ListFilterSort>
</Suspense>
<Table columns={columns} renderRow={renderRow} data={data} /> <Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */} {/* PAGINATION */}
<Pagination page={p} count={count || 0} /> <Pagination page={p} count={count || 0} />

View File

@ -1,12 +1,12 @@
import FormContainer from "@/components/FormContainer"; import FormContainer from "@/components/FormContainer";
import ListFilterSort from "@/components/ListFilterSort";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import Table from "@/components/Table"; import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings"; import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase"; import { Tables } from "@/types/supabase";
import Image from "next/image";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { Suspense } from "react";
type EventList = Tables<"Event"> & { class: Tables<"Class"> | null }; type EventList = Tables<"Event"> & { class: Tables<"Class"> | null };
@ -96,6 +96,14 @@ const EventListPage = async ({
const p = page ? parseInt(page) : 1; const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const classesRes = await supabase.from("Class").select("id, name").order("name");
const classOptions = (classesRes.data ?? []).map((c) => ({ value: String(c.id), label: c.name }));
const sortOptions = [
{ value: "startTime", label: "Date" },
{ value: "title", label: "Title" },
];
const filters = [{ key: "classId", label: "Class", options: classOptions }];
// URL PARAMS CONDITION // URL PARAMS CONDITION
let query = supabase let query = supabase
.from("Event") .from("Event")
@ -103,22 +111,26 @@ const EventListPage = async ({
if (queryParams) { if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) { if (value === undefined) continue;
switch (key) { switch (key) {
case "search": case "classId":
query = query.ilike("title", `%${value}%`); if (value) query = query.eq("classId", parseInt(value));
break; break;
default: case "search":
break; query = query.ilike("title", `%${value}%`);
break;
case "sortBy": {
const col = value === "title" || value === "startTime" ? value : "startTime";
const asc = queryParams.sortOrder !== "desc";
query = query.order(col, { ascending: asc });
break;
} }
default:
break;
} }
} }
} }
if (!queryParams.sortBy) query = query.order("startTime", { ascending: true });
// ROLE CONDITIONS
// Authorization is now handled by Supabase Postgres RLS policies.
// The initialized `supabase` client automatically passes the Clerk user JWT,
// so the database will only return the events this user is allowed to see.
// PAGINATION // PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1); query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
@ -133,23 +145,14 @@ const EventListPage = async ({
return ( return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0"> <div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">All Events</h1> <h1 className="hidden md:block text-lg font-semibold">All Events</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && <FormContainer table="event" type="create" />}
</div>
</div>
</div> </div>
{/* LIST */} <Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
{role === "admin" && <FormContainer table="event" type="create" />}
</ListFilterSort>
</Suspense>
<Table columns={columns} renderRow={renderRow} data={data} /> <Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */} {/* PAGINATION */}
<Pagination page={p} count={count || 0} /> <Pagination page={p} count={count || 0} />

View File

@ -0,0 +1,147 @@
import FormContainer from "@/components/FormContainer";
import Pagination from "@/components/Pagination";
import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase";
import Image from "next/image";
import { auth } from "@clerk/nextjs/server";
type HolidayList = Tables<"Holiday"> & { academicYear: Tables<"AcademicYear"> | null };
const HolidayListPage = async ({
searchParams,
}: {
searchParams: { [key: string]: string | undefined };
}) => {
const { sessionClaims } = auth();
const role = (sessionClaims?.metadata as { role?: string })?.role;
const teacherType = (sessionClaims?.metadata as { teacherType?: string })?.teacherType;
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const canManageSchool =
role === "admin" ||
(role === "teacher" &&
(teacherType === "INDEPENDENT" || teacherType === "AGENCY"));
const columns = [
{ header: "Holiday Name", accessor: "name" },
{
header: "Academic Year",
accessor: "academicYear",
className: "hidden md:table-cell",
},
{
header: "Start Date",
accessor: "startDate",
className: "hidden md:table-cell",
},
{
header: "End Date",
accessor: "endDate",
className: "hidden md:table-cell",
},
...(canManageSchool
? [
{
header: "Actions",
accessor: "action",
},
]
: []),
];
const renderRow = (item: HolidayList) => (
<tr
key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
>
<td className="flex items-center gap-4 p-4">{item.name}</td>
<td className="hidden md:table-cell text-gray-600">
{item.academicYear ? item.academicYear.name : "—"}
</td>
<td className="hidden md:table-cell">
{new Date(item.startDate).toLocaleDateString()}
</td>
<td className="hidden md:table-cell">
{new Date(item.endDate).toLocaleDateString()}
</td>
<td>
<div className="flex items-center gap-2">
{canManageSchool && (
<>
<FormContainer table="holiday" type="update" data={item} />
<FormContainer table="holiday" type="delete" id={item.id} />
</>
)}
</div>
</td>
</tr>
);
const { page, ...queryParams } = searchParams;
const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient();
let query = supabase
.from("Holiday")
.select("*, academicYear:AcademicYear(name)", { count: "exact" });
if (schoolId) {
query = query.eq("schoolId", schoolId);
}
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) {
switch (key) {
case "search":
query = query.ilike("name", `%${value}%`);
break;
default:
break;
}
}
}
}
// PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
const { data: rawData, count, error } = await query;
if (error) {
console.error("Error fetching holidays from Supabase:", error);
}
const data = (rawData || []) as unknown as HolidayList[];
return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">School Holidays</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && <FormContainer table="holiday" type="create" />}
</div>
</div>
</div>
{/* LIST */}
<Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */}
<Pagination page={p} count={count || 0} />
</div>
);
};
export default HolidayListPage;

View File

@ -1,20 +1,23 @@
import Link from "next/link";
import FormContainer from "@/components/FormContainer"; import FormContainer from "@/components/FormContainer";
import ListFilterSort from "@/components/ListFilterSort";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import Table from "@/components/Table"; import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings"; import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase"; import { Tables } from "@/types/supabase";
import Image from "next/image"; import Image from "next/image";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { Suspense } from "react";
export const dynamic = "force-dynamic";
type LessonList = Tables<"Lesson"> & { type LessonList = Tables<"Lesson"> & {
subject: Tables<"Subject">; subject: Tables<"Subject"> | null;
class: Tables<"Class">; class: Tables<"Class"> | null;
teacher: Tables<"Teacher">; teacher: Tables<"Teacher"> | null;
}; };
const LessonListPage = async ({ const LessonListPage = async ({
searchParams, searchParams,
}: { }: {
@ -27,9 +30,23 @@ const LessonListPage = async ({
const columns = [ const columns = [
{ {
header: "Subject Name", header: "Lesson",
accessor: "lessonName",
},
{
header: "Subject",
accessor: "name", accessor: "name",
}, },
{
header: "Date",
accessor: "date",
className: "hidden sm:table-cell",
},
{
header: "Time",
accessor: "time",
className: "hidden sm:table-cell",
},
{ {
header: "Class", header: "Class",
accessor: "class", accessor: "class",
@ -39,7 +56,7 @@ const LessonListPage = async ({
accessor: "teacher", accessor: "teacher",
className: "hidden md:table-cell", className: "hidden md:table-cell",
}, },
...(role === "admin" ...(role === "admin" || role === "teacher" || role === "student"
? [ ? [
{ {
header: "Actions", header: "Actions",
@ -49,18 +66,41 @@ const LessonListPage = async ({
: []), : []),
]; ];
const renderRow = (item: LessonList) => ( const formatTime = (iso: string | undefined) =>
iso ? new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) : null;
const formatDate = (iso: string | undefined) =>
iso ? new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : "-";
const renderRow = (item: LessonList) => {
const start = formatTime(item.startTime);
const end = formatTime(item.endTime);
const timeStr = start && end ? `${start} ${end}` : "-";
const dateStr = formatDate(item.startTime);
return (
<tr <tr
key={item.id} key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight" className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
> >
<td className="flex items-center gap-4 p-4">{item.subject?.name || "-"}</td> <td className="flex items-center gap-4 p-4">{item.name || "-"}</td>
<td>{item.subject?.name || "-"}</td>
<td className="hidden sm:table-cell">{dateStr}</td>
<td className="hidden sm:table-cell">{timeStr}</td>
<td>{item.class?.name || "-"}</td> <td>{item.class?.name || "-"}</td>
<td className="hidden md:table-cell"> <td className="hidden md:table-cell">
{item.teacher ? item.teacher.name + " " + item.teacher.surname : "-"} {item.teacher ? item.teacher.name + " " + item.teacher.surname : "-"}
</td> </td>
<td> <td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(role === "admin" || role === "teacher" || role === "student") && (
<Link href={`/whiteboard?lessonId=${item.id}`}>
<button
className="w-7 h-7 flex items-center justify-center rounded-full bg-lamaSky"
title="View Whiteboard"
>
<Image src="/student.png" alt="Board" width={14} height={14} />
</button>
</Link>
)}
{role === "admin" && ( {role === "admin" && (
<> <>
<FormContainer table="lesson" type="update" data={item} /> <FormContainer table="lesson" type="update" data={item} />
@ -71,67 +111,110 @@ const LessonListPage = async ({
</td> </td>
</tr> </tr>
); );
};
const { page, ...queryParams } = searchParams; const { page, ...queryParams } = searchParams;
const p = page ? parseInt(page) : 1; const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
// URL PARAMS CONDITION // Filter options for ListFilterSort
let query = supabase const [teachersRes, classesRes] = await Promise.all([
.from("Lesson") supabase.from("Teacher").select("id, name, surname").order("name"),
.select("*, subject:Subject(*), class:Class(*), teacher:Teacher(*)", { count: "exact" }); supabase.from("Class").select("id, name").order("name"),
]);
const teacherOptions = (teachersRes.data ?? []).map((t) => ({
value: t.id,
label: `${t.name} ${t.surname}`.trim(),
}));
const classOptions = (classesRes.data ?? []).map((c) => ({
value: String(c.id),
label: c.name,
}));
const sortOptions = [
{ value: "startTime", label: "Date" },
{ value: "name", label: "Lesson name" },
];
const filters = [
{ key: "teacherId", label: "Teacher", options: teacherOptions },
{ key: "classId", label: "Class", options: classOptions },
];
// Fetch lessons only (no embeds) so RLS on Subject/Class/Teacher cannot drop rows
let query = supabase.from("Lesson").select("*", { count: "exact" });
if (queryParams) { if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) { if (value === undefined) continue;
switch (key) { switch (key) {
case "classId": case "classId":
query = query.eq("classId", parseInt(value)); query = query.eq("classId", parseInt(value));
break; break;
case "teacherId": case "teacherId":
query = query.eq("teacherId", value); query = query.eq("teacherId", value);
break; break;
case "search": case "search":
query = query.or(`subject.name.ilike.%${value}%,teacher.name.ilike.%${value}%,class.name.ilike.%${value}%`); query = query.ilike("name", `%${value}%`);
break; break;
default: case "sortBy": {
break; const col = value === "name" || value === "startTime" ? value : "startTime";
const asc = queryParams.sortOrder !== "desc";
query = query.order(col, { ascending: asc });
break;
} }
default:
break;
} }
} }
} }
if (!queryParams.sortBy) {
query = query.order("startTime", { ascending: true });
}
// PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1); query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
const { data: rawData, count, error } = await query; const { data: lessons, count, error } = await query;
if (error) { if (error) {
console.error("Error fetching lessons from Supabase:", error); console.error("Error fetching lessons from Supabase:", error);
} }
const data = (rawData || []) as unknown as LessonList[]; const lessonRows = lessons ?? [];
// Load subject, class, teacher in separate queries (avoids embed RLS dropping lesson rows)
const subjectIds = Array.from(new Set(lessonRows.map((l) => l.subjectId).filter((id) => id != null)));
const classIds = Array.from(new Set(lessonRows.map((l) => l.classId).filter((id) => id != null)));
const teacherIds = Array.from(new Set(lessonRows.map((l) => l.teacherId).filter((id) => id != null)));
const [subjectsRes, classesRes2, teachersRes2] = await Promise.all([
subjectIds.length ? supabase.from("Subject").select("id, name").in("id", subjectIds) : { data: [] },
classIds.length ? supabase.from("Class").select("id, name").in("id", classIds) : { data: [] },
teacherIds.length ? supabase.from("Teacher").select("id, name, surname").in("id", teacherIds) : { data: [] },
]);
// Use Number() for map keys so string vs number from API doesn't break lookup
const subjectMap = new Map((subjectsRes.data ?? []).map((s) => [Number(s.id), s]));
const classMap = new Map((classesRes2.data ?? []).map((c) => [Number(c.id), c]));
const teacherMap = new Map((teachersRes2.data ?? []).map((t) => [t.id, t]));
const data = lessonRows.map((lesson) => ({
...lesson,
subject: subjectMap.get(Number(lesson.subjectId)) ?? null,
class: classMap.get(Number(lesson.classId)) ?? null,
teacher: teacherMap.get(lesson.teacherId) ?? null,
})) as LessonList[];
return ( return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0"> <div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">All Lessons</h1> <h1 className="hidden md:block text-lg font-semibold">All Lessons</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && <FormContainer table="lesson" type="create" />}
</div>
</div>
</div> </div>
{/* LIST */} <Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
{role === "admin" && <FormContainer table="lesson" type="create" />}
</ListFilterSort>
</Suspense>
<Table columns={columns} renderRow={renderRow} data={data} /> <Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */} {/* PAGINATION */}
<Pagination page={p} count={count || 0} /> <Pagination page={p} count={count || 0} />

View File

@ -0,0 +1,108 @@
import { auth } from "@clerk/nextjs/server";
import { getSupabaseClient } from "@/lib/supabase";
import { linkTeacherToSchool } from "@/lib/actions";
const MySchoolsPage = async () => {
const { sessionClaims } = auth();
const role = (sessionClaims?.metadata as { role?: string })?.role;
if (role !== "teacher") {
return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
<h1 className="text-lg font-semibold mb-2">My Schools</h1>
<p className="text-sm text-gray-500">
Only teachers can manage school assignments.
</p>
</div>
);
}
const supabase = await getSupabaseClient();
// Current teacher's schools
const { data: teacherMappings } = await supabase
.from("TeacherSchool")
.select("school:School(id, name)")
.order("schoolId");
const currentSchools =
(teacherMappings || [])
.map((m: any) => m.school)
.filter((s: any) => !!s) || [];
const currentSchoolIds = new Set(
currentSchools.map((s: any) => s.id as string)
);
// All available schools to link to (from pre-seeded directory)
const { data: allSchools } = await supabase
.from("School")
.select("id, name")
.order("name");
const availableSchools =
(allSchools || []).filter((s: any) => !currentSchoolIds.has(s.id)) || [];
return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
<h1 className="text-lg font-semibold mb-4">My Schools</h1>
{/* Current schools */}
<div className="mb-8">
<h2 className="text-md font-semibold mb-2">Schools you work at</h2>
{currentSchools.length === 0 ? (
<p className="text-sm text-gray-500">
You are not linked to any schools yet. Use the form below to add a
school you work at.
</p>
) : (
<ul className="list-disc list-inside text-sm text-gray-700">
{currentSchools.map((s: any) => (
<li key={s.id}>{s.name}</li>
))}
</ul>
)}
</div>
{/* Add a new school */}
<div>
<h2 className="text-md font-semibold mb-2">Add a school</h2>
{availableSchools.length === 0 ? (
<p className="text-sm text-gray-500">
There are no additional schools available to link.
</p>
) : (
<form action={linkTeacherToSchool} className="flex flex-col gap-3">
<select
name="schoolId"
required
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full md:w-1/2"
defaultValue=""
>
<option value="">
Select a school from the directory
</option>
{availableSchools.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
{/* Optional: future place for schedule fields (startDate, endDate, daysOfWeek) */}
<button
type="submit"
className="bg-blue-500 text-white px-3 py-2 text-sm rounded-md w-fit"
>
Add School
</button>
</form>
)}
</div>
</div>
);
};
export default MySchoolsPage;

View File

@ -0,0 +1,101 @@
import Pagination from "@/components/Pagination";
import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase";
import Image from "next/image";
import Link from "next/link";
import { auth } from "@clerk/nextjs/server";
type SchoolTimetableRow = Tables<"SchoolTimetable"> & {
academicYear: Tables<"AcademicYear"> | null;
};
const SchoolTimetablesListPage = async ({
searchParams,
}: {
searchParams: { [key: string]: string | undefined };
}) => {
const { sessionClaims } = auth();
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const columns = [
{ header: "Timetable Name", accessor: "name" },
{ header: "Academic Year", accessor: "academicYear", className: "hidden md:table-cell" },
{ header: "View Slots", accessor: "viewSlots" },
];
const renderRow = (item: SchoolTimetableRow) => (
<tr
key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
>
<td className="flex items-center gap-4 p-4 font-medium">{item.name}</td>
<td className="hidden md:table-cell text-gray-600">
{item.academicYear ? item.academicYear.name : "—"}
</td>
<td>
<Link
href={`/list/timeslots?schoolTimetableId=${item.id}`}
className="text-blue-500 hover:underline text-sm"
>
View slots
</Link>
</td>
</tr>
);
const { page, ...queryParams } = searchParams;
const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient();
let query = supabase
.from("SchoolTimetable")
.select("*, academicYear:AcademicYear(*)", { count: "exact" })
.order("id", { ascending: true });
if (schoolId) {
query = query.eq("schoolId", schoolId);
}
if (queryParams.search) {
query = query.or(`name.ilike.%${queryParams.search}%`);
}
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
const { data: rawData, count, error } = await query;
if (error) {
console.error("Error fetching school timetables from Supabase:", error);
}
const data = (rawData || []) as unknown as SchoolTimetableRow[];
return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
<div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">School Timetables</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
</div>
</div>
</div>
<p className="text-sm text-gray-500 mt-1 mb-2">
Each timetable defines the weekly slot layout for an academic year. Slots are managed under Timetable Slots.
</p>
<Table columns={columns} renderRow={renderRow} data={data} />
<Pagination page={p} count={count || 0} />
</div>
);
};
export default SchoolTimetablesListPage;

View File

@ -25,9 +25,11 @@ const SingleStudentPage = async ({
.from("Student") .from("Student")
.select(` .select(`
*, *,
class:Class( studentClasses:StudentClass(
*, class:Class(
lessons:Lesson(count) *,
lessons:Lesson(count)
)
) )
`) `)
.eq("id", id) .eq("id", id)
@ -37,17 +39,17 @@ const SingleStudentPage = async ({
return notFound(); return notFound();
} }
// Extract count from the array returned by PostgREST inner select count const classes = (data.studentClasses ?? []).map((sc: any) => ({
const lessonsCount = Array.isArray(data.class?.lessons) ...sc.class,
? (data.class?.lessons as any)[0]?.count || 0 _count: {
: 0; lessons: Array.isArray(sc.class?.lessons) ? (sc.class.lessons[0]?.count ?? 0) : 0,
},
})).filter((c: any) => c.id != null);
const student = { const student = {
...data, ...data,
class: { classes,
...data.class, class: classes[0],
_count: { lessons: lessonsCount }
}
} as any; } as any;
if (!student) { if (!student) {
@ -131,7 +133,7 @@ const SingleStudentPage = async ({
/> />
<div className=""> <div className="">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{student.class.name.charAt(0)}th {student.class?.name?.charAt(0) ?? "-"}th
</h1> </h1>
<span className="text-sm text-gray-400">Grade</span> <span className="text-sm text-gray-400">Grade</span>
</div> </div>
@ -147,7 +149,7 @@ const SingleStudentPage = async ({
/> />
<div className=""> <div className="">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{student.class._count.lessons} {student.classes?.reduce((n: number, c: any) => n + (c._count?.lessons ?? 0), 0) ?? 0}
</h1> </h1>
<span className="text-sm text-gray-400">Lessons</span> <span className="text-sm text-gray-400">Lessons</span>
</div> </div>
@ -162,8 +164,10 @@ const SingleStudentPage = async ({
className="w-6 h-6" className="w-6 h-6"
/> />
<div className=""> <div className="">
<h1 className="text-xl font-semibold">{student.class.name}</h1> <h1 className="text-xl font-semibold">
<span className="text-sm text-gray-400">Class</span> {student.classes?.map((c: any) => c.name).filter(Boolean).join(", ") || "-"}
</h1>
<span className="text-sm text-gray-400">Classes</span>
</div> </div>
</div> </div>
</div> </div>
@ -171,7 +175,7 @@ const SingleStudentPage = async ({
{/* BOTTOM */} {/* BOTTOM */}
<div className="mt-4 bg-white rounded-md p-4 h-[800px]"> <div className="mt-4 bg-white rounded-md p-4 h-[800px]">
<h1>Student&apos;s Schedule</h1> <h1>Student&apos;s Schedule</h1>
<BigCalendarContainer type="classId" id={student.class.id} /> <BigCalendarContainer type="classId" id={student.classes?.map((c: any) => c.id) ?? []} />
</div> </div>
</div> </div>
{/* RIGHT */} {/* RIGHT */}
@ -179,30 +183,37 @@ const SingleStudentPage = async ({
<div className="bg-white p-4 rounded-md"> <div className="bg-white p-4 rounded-md">
<h1 className="text-xl font-semibold">Shortcuts</h1> <h1 className="text-xl font-semibold">Shortcuts</h1>
<div className="mt-4 flex gap-4 flex-wrap text-xs text-gray-500"> <div className="mt-4 flex gap-4 flex-wrap text-xs text-gray-500">
<Link {student.classes?.length ? (
className="p-3 rounded-md bg-lamaSkyLight" <>
href={`/list/lessons?classId=${student.class.id}`} {student.classes.slice(0, 3).map((c: any) => (
> <Link
Student&apos;s Lessons key={c.id}
</Link> className="p-3 rounded-md bg-lamaSkyLight"
<Link href={`/list/lessons?classId=${c.id}`}
className="p-3 rounded-md bg-lamaPurpleLight" >
href={`/list/teachers?classId=${student.class.id}`} Lessons ({c.name})
> </Link>
Student&apos;s Teachers ))}
</Link> <Link
<Link className="p-3 rounded-md bg-lamaPurpleLight"
className="p-3 rounded-md bg-pink-50" href={`/list/teachers?classId=${student.classes[0]?.id}`}
href={`/list/exams?classId=${student.class.id}`} >
> Teachers
Student&apos;s Exams </Link>
</Link> <Link
<Link className="p-3 rounded-md bg-pink-50"
className="p-3 rounded-md bg-lamaSkyLight" href={`/list/exams?classId=${student.classes[0]?.id}`}
href={`/list/assignments?classId=${student.class.id}`} >
> Exams
Student&apos;s Assignments </Link>
</Link> <Link
className="p-3 rounded-md bg-lamaSkyLight"
href={`/list/assignments?classId=${student.classes[0]?.id}`}
>
Assignments
</Link>
</>
) : null}
<Link <Link
className="p-3 rounded-md bg-lamaYellowLight" className="p-3 rounded-md bg-lamaYellowLight"
href={`/list/results?studentId=${student.id}`} href={`/list/results?studentId=${student.id}`}

View File

@ -1,17 +1,18 @@
import FormContainer from "@/components/FormContainer"; import FormContainer from "@/components/FormContainer";
import ListFilterSort from "@/components/ListFilterSort";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import Table from "@/components/Table"; import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings"; import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase"; import { Tables } from "@/types/supabase";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { Suspense } from "react";
type StudentList = Tables<"Student"> & { class: Tables<"Class"> }; type StudentList = Tables<"Student"> & {
studentClasses: { classId: number; class: Tables<"Class"> }[];
};
const StudentListPage = async ({ const StudentListPage = async ({
searchParams, searchParams,
@ -56,7 +57,10 @@ const StudentListPage = async ({
: []), : []),
]; ];
const renderRow = (item: StudentList) => ( const renderRow = (item: StudentList) => {
const classNames = item.studentClasses?.map((sc) => sc.class?.name).filter(Boolean).join(", ") || "-";
const firstClassLetter = item.studentClasses?.[0]?.class?.name?.[0] ?? "-";
return (
<tr <tr
key={item.id} key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight" className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
@ -71,11 +75,11 @@ const StudentListPage = async ({
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<h3 className="font-semibold">{item.name}</h3> <h3 className="font-semibold">{item.name}</h3>
<p className="text-xs text-gray-500">{item.class.name}</p> <p className="text-xs text-gray-500">{classNames}</p>
</div> </div>
</td> </td>
<td className="hidden md:table-cell">{item.username}</td> <td className="hidden md:table-cell">{item.username}</td>
<td className="hidden md:table-cell">{item.class.name[0]}</td> <td className="hidden md:table-cell">{firstClassLetter}</td>
<td className="hidden md:table-cell">{item.phone}</td> <td className="hidden md:table-cell">{item.phone}</td>
<td className="hidden md:table-cell">{item.address}</td> <td className="hidden md:table-cell">{item.address}</td>
<td> <td>
@ -95,41 +99,69 @@ const StudentListPage = async ({
</td> </td>
</tr> </tr>
); );
};
const { page, ...queryParams } = searchParams; const { page, ...queryParams } = searchParams;
const p = page ? parseInt(page) : 1; const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
// URL PARAMS CONDITION const [classesRes, teachersRes] = await Promise.all([
// Note: we need lessons if teacherId is provided supabase.from("Class").select("id, name").order("name"),
supabase.from("Teacher").select("id, name, surname").order("name"),
]);
const classOptions = (classesRes.data ?? []).map((c) => ({ value: String(c.id), label: c.name }));
const teacherOptions = (teachersRes.data ?? []).map((t) => ({
value: t.id,
label: `${t.name} ${t.surname}`.trim(),
}));
const sortOptions = [{ value: "name", label: "Name" }];
const filters = [
{ key: "classId", label: "Class", options: classOptions },
{ key: "teacherId", label: "Teacher", options: teacherOptions },
];
let studentIdsFilter: string[] | null = null;
if (queryParams.classId) {
const { data: links } = await supabase.from("StudentClass").select("studentId").eq("classId", parseInt(queryParams.classId));
studentIdsFilter = (links ?? []).map((l) => l.studentId);
if (studentIdsFilter.length === 0) studentIdsFilter = [""];
} else if (queryParams.teacherId) {
const { data: lessonRows } = await supabase.from("Lesson").select("classId").eq("teacherId", queryParams.teacherId);
const classIds = Array.from(new Set((lessonRows ?? []).map((r) => r.classId)));
if (classIds.length > 0) {
const { data: scRows } = await supabase.from("StudentClass").select("studentId").in("classId", classIds);
studentIdsFilter = Array.from(new Set((scRows ?? []).map((r) => r.studentId)));
}
if (!studentIdsFilter || studentIdsFilter.length === 0) studentIdsFilter = [""];
}
let query = supabase let query = supabase
.from("Student") .from("Student")
.select("*, class:Class(*, lessons:Lesson(*))", { count: "exact" }); .select("*, studentClasses:StudentClass(class:Class(*))", { count: "exact" });
if (studentIdsFilter) query = query.in("id", studentIdsFilter);
if (queryParams) { if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) { if (value === undefined) continue;
switch (key) { switch (key) {
case "teacherId": case "search":
// Filter by teacherId within the joined lessons. query = query.ilike("name", `%${value}%`);
// Supabase postgREST filters on JSON: class.lessons.teacherId=eq... break;
// It's tricky to filter the main rows based on a nested condition. case "sortBy": {
// Instead we can use an inner join via class!inner(lessons!inner(*)). const col = value === "name" ? "name" : "name";
query = query.eq("class.lessons.teacherId", value); const asc = queryParams.sortOrder !== "desc";
// It might require adjustments, but RLS generally restricts this. query = query.order(col, { ascending: asc });
break; break;
case "search":
query = query.ilike("name", `%${value}%`);
break;
default:
break;
} }
default:
break;
} }
} }
} }
if (!queryParams.sortBy) query = query.order("name", { ascending: true });
// PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1); query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
let { data: rawData, count, error } = await query; let { data: rawData, count, error } = await query;
@ -138,44 +170,18 @@ const StudentListPage = async ({
console.error("Error fetching students from Supabase:", error); console.error("Error fetching students from Supabase:", error);
} }
// Workaround for `teacherId` query: Since Supabase inner joins
// on deep JSON relationships can sometimes fail or return empty arrays
// when `select` is used like "class(*)", let's filter after fetch if `teacherId` was passed
// (In a real scenario, an RPC is better for complex joins that filter parents by nested children)
if (queryParams.teacherId && rawData) {
rawData = rawData.filter(student =>
// @ts-ignore
student.class?.lessons?.some((lesson: any) => lesson.teacherId === queryParams.teacherId)
);
count = rawData.length;
}
const data = (rawData || []) as unknown as StudentList[]; const data = (rawData || []) as unknown as StudentList[];
return ( return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0"> <div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">All Students</h1> <h1 className="hidden md:block text-lg font-semibold">All Students</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && (
// <button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
// <Image src="/plus.png" alt="" width={14} height={14} />
// </button>
<FormContainer table="student" type="create" />
)}
</div>
</div>
</div> </div>
{/* LIST */} <Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
{role === "admin" && <FormContainer table="student" type="create" />}
</ListFilterSort>
</Suspense>
<Table columns={columns} renderRow={renderRow} data={data} /> <Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */} {/* PAGINATION */}
<Pagination page={p} count={count || 0} /> <Pagination page={p} count={count || 0} />

View File

@ -1,13 +1,14 @@
import FormContainer from "@/components/FormContainer"; import FormContainer from "@/components/FormContainer";
import ListFilterSort from "@/components/ListFilterSort";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import Table from "@/components/Table"; import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase"; import { Tables } from "@/types/supabase";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ITEM_PER_PAGE } from "@/lib/settings";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { Suspense } from "react";
type TeacherList = Tables<"Teacher"> & { subjects: Tables<"Subject">[] } & { classes: Tables<"Class">[] }; type TeacherList = Tables<"Teacher"> & { subjects: Tables<"Subject">[] } & { classes: Tables<"Class">[] };
@ -107,29 +108,38 @@ const TeacherListPage = async ({
const p = page ? parseInt(page) : 1; const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const classesRes = await supabase.from("Class").select("id, name").order("name");
const classOptions = (classesRes.data ?? []).map((c) => ({ value: String(c.id), label: c.name }));
const sortOptions = [{ value: "name", label: "Name" }];
const filters = [{ key: "classId", label: "Class", options: classOptions }];
// URL PARAMS CONDITION // URL PARAMS CONDITION
// Note: we need lessons if classId is provided
let query = supabase let query = supabase
.from("Teacher") .from("Teacher")
.select("*, TeacherSubject(Subject(*)), classes:Class(*), lessons:Lesson(*)", { count: "exact" }); .select("*, TeacherSubject(Subject(*)), classes:Class(*), lessons:Lesson(*)", { count: "exact" });
if (queryParams) { if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) { if (value === undefined) continue;
switch (key) { switch (key) {
case "classId": case "classId":
// Filter by classId within the joined lessons. query = query.eq("lessons.classId", parseInt(value));
query = query.eq("lessons.classId", parseInt(value)); break;
break; case "search":
case "search": query = query.ilike("name", `%${value}%`);
query = query.ilike("name", `%${value}%`); break;
break; case "sortBy": {
default: const col = value === "name" ? "name" : "name";
break; const asc = queryParams.sortOrder !== "desc";
query = query.order(col, { ascending: asc });
break;
} }
default:
break;
} }
} }
} }
if (!queryParams.sortBy) query = query.order("name", { ascending: true });
// PAGINATION // PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1); query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
@ -161,25 +171,14 @@ const TeacherListPage = async ({
return ( return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0"> <div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">All Teachers</h1> <h1 className="hidden md:block text-lg font-semibold">All Teachers</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && (
<FormContainer table="teacher" type="create" />
)}
</div>
</div>
</div> </div>
{/* LIST */} <Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
{role === "admin" && <FormContainer table="teacher" type="create" />}
</ListFilterSort>
</Suspense>
<Table columns={columns} renderRow={renderRow} data={data} /> <Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */} {/* PAGINATION */}
<Pagination page={p} count={count || 0} /> <Pagination page={p} count={count || 0} />

View File

@ -0,0 +1,171 @@
import FormContainer from "@/components/FormContainer";
import Table from "@/components/Table";
import { getSupabaseClient } from "@/lib/supabase";
import { Tables } from "@/types/supabase";
import { auth } from "@clerk/nextjs/server";
import { notFound } from "next/navigation";
import GenerateLessonsForm from "@/components/forms/GenerateLessonsForm";
type EntryList = Tables<"TeacherTimetableEntry"> & {
class: Tables<"Class"> | null;
subject: Tables<"Subject"> | null;
slot: Tables<"SchoolTimetableSlot"> | null;
};
const SingleTemplatePage = async ({
params: { id },
}: {
params: { id: string };
}) => {
const { sessionClaims, userId } = auth();
const role = (sessionClaims?.metadata as { role?: string })?.role;
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const teacherType = (sessionClaims?.metadata as { teacherType?: string })?.teacherType;
const canManageSchool =
role === "admin" ||
(role === "teacher" && (teacherType === "INDEPENDENT" || teacherType === "AGENCY"));
const isTemplateOwner = (tid: string) => tid === userId;
const supabase = await getSupabaseClient();
// Fetch the template
const { data: template, error: templateError } = await supabase
.from("TeacherTimetableTemplate")
.select("*, teacher:Teacher(*)")
.eq("id", parseInt(id))
.single();
if (templateError || !template) {
return notFound();
}
// Ensure it belongs to the current school
if (schoolId && template.schoolId !== schoolId) {
return notFound();
}
// Fetch the entries
const { data: rawEntries } = await supabase
.from("TeacherTimetableEntry")
.select("*, class:Class(*), subject:Subject(*), slot:SchoolTimetableSlot(*)")
.eq("teacherTimetableTemplateId", template.id)
.order("dayOfWeek", { ascending: true });
const entries = (rawEntries || []) as unknown as EntryList[];
// Sort entries by day then by slot position
entries.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
const posA = (a.slot as { position?: number })?.position ?? 0;
const posB = (b.slot as { position?: number })?.position ?? 0;
return posA - posB;
});
const schoolForTerms = schoolId || template.schoolId;
const terms = schoolForTerms
? (await supabase
.from("Term")
.select("id, name, startDate, endDate")
.eq("schoolId", schoolForTerms)
.order("startDate", { ascending: true })).data ?? []
: [];
const columns = [
{ header: "Day", accessor: "dayOfWeek" },
{ header: "Time Slot", accessor: "slot" },
{ header: "Class", accessor: "class", className: "hidden md:table-cell" },
{ header: "Subject", accessor: "subject", className: "hidden md:table-cell" },
...(role === "admin"
? [{ header: "Actions", accessor: "action" }]
: []),
];
const getDayName = (day: number) => {
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
// JS days: 0 = Sunday, 1 = Monday. We'll map assuming 1 = Monday like our form (1-7).
if (day >= 1 && day <= 7) {
// Just map standard ISO 1=Monday... 7=Sunday
const isoDays = ["", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
return isoDays[day];
}
return "Unknown";
}
const renderRow = (item: EntryList) => (
<tr
key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
>
<td className="p-4">{getDayName(item.dayOfWeek)}</td>
<td>
{item.slot ? `${item.slot.name} (${item.slot.startTime}-${item.slot.endTime})` : "-"}
</td>
<td className="hidden md:table-cell">{item.class?.name || "-"}</td>
<td className="hidden md:table-cell">{item.subject?.name || "-"}</td>
<td>
<div className="flex items-center gap-2">
{role === "admin" && (
<>
<FormContainer
table="timetableEntry"
type="update"
data={item}
/>
<FormContainer
table="timetableEntry"
type="delete"
id={item.id}
/>
</>
)}
</div>
</td>
</tr>
);
return (
<div className="flex-1 p-4 flex flex-col gap-4 xl:flex-row">
{/* LEFT */}
<div className="w-full xl:w-2/3 flex flex-col gap-8">
{/* TOP INFO */}
<div className="bg-lamaSky rounded-md p-4 flex gap-4">
<div className="w-2/3 flex flex-col justify-between gap-4">
<h1 className="text-xl font-semibold">{template.name}</h1>
<p className="text-sm text-gray-500">
Teacher Owner: {template.teacher ? `${template.teacher.name} ${template.teacher.surname}` : "Unknown"}
</p>
</div>
</div>
{/* GENERATE LESSONS: template owner or admin; use template.schoolId when session school not set (e.g. admin) */}
{canManageSchool && (role === "admin" || isTemplateOwner(template.teacherId)) && (schoolId || template.schoolId) && (
<GenerateLessonsForm
templateId={template.id}
schoolId={schoolId || template.schoolId}
terms={terms}
/>
)}
{/* ENTRIES LIST */}
<div className="bg-white rounded-md p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Timetable Entries</h2>
{role === "admin" && (
<FormContainer
table="timetableEntry"
type="create"
// Pre-fill the template ID
data={{ timetableTemplateId: template.id, teacherTimetableTemplateId: template.id }}
/>
)}
</div>
<Table columns={columns} renderRow={renderRow} data={entries} />
</div>
</div>
</div>
);
};
export default SingleTemplatePage;

View File

@ -0,0 +1,159 @@
import FormContainer from "@/components/FormContainer";
import Pagination from "@/components/Pagination";
import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase";
import Image from "next/image";
import { auth } from "@clerk/nextjs/server";
import Link from "next/link";
type TemplateList = Tables<"TeacherTimetableTemplate"> & {
teacher: Tables<"Teacher">;
schoolTimetable: (Tables<"SchoolTimetable"> & { academicYear: Tables<"AcademicYear"> | null }) | null;
};
const TimetableTemplateListPage = async ({
searchParams,
}: {
searchParams: { [key: string]: string | undefined };
}) => {
const { sessionClaims } = auth();
const role = (sessionClaims?.metadata as { role?: string })?.role;
const teacherType = (sessionClaims?.metadata as { teacherType?: string })?.teacherType;
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const canManageSchool =
role === "admin" ||
(role === "teacher" &&
(teacherType === "INDEPENDENT" || teacherType === "AGENCY"));
const columns = [
{ header: "Template Name", accessor: "name" },
{
header: "School Timetable",
accessor: "schoolTimetable",
className: "hidden md:table-cell",
},
{
header: "Owner",
accessor: "teacher",
className: "hidden md:table-cell",
},
...(canManageSchool
? [
{
header: "Actions",
accessor: "action",
},
]
: []),
];
const renderRow = (item: TemplateList) => (
<tr
key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
>
<td className="flex items-center gap-4 p-4">
<Link href={`/list/templates/${item.id}`} className="text-blue-500 hover:underline">
{item.name}
</Link>
</td>
<td className="hidden md:table-cell text-gray-600">
{item.schoolTimetable
? `${item.schoolTimetable.name}${item.schoolTimetable.academicYear ? ` (${item.schoolTimetable.academicYear.name})` : ""}`
: "—"}
</td>
<td className="hidden md:table-cell">
{item.teacher ? `${item.teacher.name} ${item.teacher.surname}` : "Unknown"}
</td>
<td>
<div className="flex items-center gap-2">
{canManageSchool && (
<>
<FormContainer
table="timetableTemplate"
type="update"
data={item}
/>
<FormContainer
table="timetableTemplate"
type="delete"
id={item.id}
/>
</>
)}
</div>
</td>
</tr>
);
const { page, ...queryParams } = searchParams;
const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient();
let query = supabase
.from("TeacherTimetableTemplate")
.select("*, teacher:Teacher(*), schoolTimetable:SchoolTimetable(id, name, academicYear:AcademicYear(name))", { count: "exact" });
if (schoolId) {
query = query.eq("schoolId", schoolId);
}
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) {
switch (key) {
case "search":
query = query.ilike("name", `%${value}%`);
break;
default:
break;
}
}
}
}
// PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
const { data: rawData, count, error } = await query;
if (error) {
console.error("Error fetching timetable templates from Supabase:", error);
}
const data = (rawData || []) as unknown as TemplateList[];
return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">Timetable Templates</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && (
<FormContainer table="timetableTemplate" type="create" />
)}
</div>
</div>
</div>
{/* LIST */}
<Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */}
<Pagination page={p} count={count || 0} />
</div>
);
};
export default TimetableTemplateListPage;

View File

@ -0,0 +1,152 @@
import FormContainer from "@/components/FormContainer";
import Pagination from "@/components/Pagination";
import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase";
import Image from "next/image";
import { auth } from "@clerk/nextjs/server";
import GenerateTimetableForm from "@/components/forms/GenerateTimetableForm";
type TermList = Tables<"Term"> & { academicYear: Tables<"AcademicYear"> | null };
const TermListPage = async ({
searchParams,
}: {
searchParams: { [key: string]: string | undefined };
}) => {
const { sessionClaims } = auth();
const role = (sessionClaims?.metadata as { role?: string })?.role;
const teacherType = (sessionClaims?.metadata as { teacherType?: string })?.teacherType;
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const canManageSchool =
role === "admin" ||
(role === "teacher" &&
(teacherType === "INDEPENDENT" || teacherType === "AGENCY"));
const columns = [
{ header: "Term Name", accessor: "name" },
{
header: "Academic Year",
accessor: "academicYear",
className: "hidden md:table-cell",
},
{
header: "Start Date",
accessor: "startDate",
className: "hidden md:table-cell",
},
{
header: "End Date",
accessor: "endDate",
className: "hidden md:table-cell",
},
...(canManageSchool
? [
{
header: "Actions",
accessor: "action",
},
]
: []),
];
const renderRow = (item: TermList) => (
<tr
key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
>
<td className="flex items-center gap-4 p-4">{item.name}</td>
<td className="hidden md:table-cell text-gray-600">
{item.academicYear ? item.academicYear.name : "—"}
</td>
<td className="hidden md:table-cell">
{new Date(item.startDate).toLocaleDateString()}
</td>
<td className="hidden md:table-cell">
{new Date(item.endDate).toLocaleDateString()}
</td>
<td>
<div className="flex items-center gap-2">
{canManageSchool && schoolId && (
<GenerateTimetableForm termId={item.id} schoolId={schoolId} />
)}
{canManageSchool && (
<>
<FormContainer table="term" type="update" data={item} />
<FormContainer table="term" type="delete" id={item.id} />
</>
)}
</div>
</td>
</tr>
);
const { page, ...queryParams } = searchParams;
const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient();
let query = supabase
.from("Term")
.select("*, academicYear:AcademicYear(name)", { count: "exact" });
// ONLY show terms for the current school
if (schoolId) {
query = query.eq("schoolId", schoolId);
}
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) {
switch (key) {
case "search":
query = query.ilike("name", `%${value}%`);
break;
default:
break;
}
}
}
}
// PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
const { data: rawData, count, error } = await query;
if (error) {
console.error("Error fetching terms from Supabase:", error);
}
const data = (rawData || []) as unknown as TermList[];
return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
<div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">School Terms</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && <FormContainer table="term" type="create" />}
</div>
</div>
</div>
{/* LIST */}
<Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */}
<Pagination page={p} count={count || 0} />
</div>
);
};
export default TermListPage;

View File

@ -0,0 +1,196 @@
import FormContainer from "@/components/FormContainer";
import Pagination from "@/components/Pagination";
import Table from "@/components/Table";
import TableSearch from "@/components/TableSearch";
import { getSupabaseClient } from "@/lib/supabase";
import { ITEM_PER_PAGE } from "@/lib/settings";
import { Tables } from "@/types/supabase";
import Image from "next/image";
import Link from "next/link";
import { auth } from "@clerk/nextjs/server";
type SlotList = Tables<"SchoolTimetableSlot"> & {
schoolTimetable: (Tables<"SchoolTimetable"> & { academicYear: Tables<"AcademicYear"> | null }) | null;
};
const TimetableSlotListPage = async ({
searchParams,
}: {
searchParams: { [key: string]: string | undefined };
}) => {
const { sessionClaims } = auth();
const role = (sessionClaims?.metadata as { role?: string })?.role;
const teacherType = (sessionClaims?.metadata as { teacherType?: string })?.teacherType;
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const canManageSchool =
role === "admin" ||
(role === "teacher" &&
(teacherType === "INDEPENDENT" || teacherType === "AGENCY"));
const columns = [
{
header: "Slot Name",
accessor: "name",
},
{
header: "Timetable",
accessor: "schoolTimetable",
className: "hidden md:table-cell",
},
{
header: "Start Time",
accessor: "startTime",
className: "hidden md:table-cell",
},
{
header: "End Time",
accessor: "endTime",
className: "hidden md:table-cell",
},
{
header: "Teaching Slot",
accessor: "isTeachingSlot",
className: "hidden md:table-cell",
},
...(canManageSchool
? [
{
header: "Actions",
accessor: "action",
},
]
: []),
];
const renderRow = (item: SlotList) => (
<tr
key={item.id}
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
>
<td className="flex items-center gap-4 p-4">{item.name}</td>
<td className="hidden md:table-cell text-gray-600">
{item.schoolTimetable
? `${item.schoolTimetable.name}${item.schoolTimetable.academicYear ? ` (${item.schoolTimetable.academicYear.name})` : ""}`
: "—"}
</td>
<td className="hidden md:table-cell">{item.startTime}</td>
<td className="hidden md:table-cell">{item.endTime}</td>
<td className="hidden md:table-cell">
{item.isTeachingSlot ? "Yes" : "No"}
</td>
<td>
<div className="flex items-center gap-2">
{canManageSchool && (
<>
<FormContainer
table="schoolTimetableSlot"
type="update"
data={item}
/>
<FormContainer
table="schoolTimetableSlot"
type="delete"
id={item.id}
/>
</>
)}
</div>
</td>
</tr>
);
const { page, ...queryParams } = searchParams;
const p = page ? parseInt(page) : 1;
const supabase = await getSupabaseClient();
const schoolTimetableId = queryParams.schoolTimetableId
? parseInt(queryParams.schoolTimetableId)
: undefined;
let query = supabase
.from("SchoolTimetableSlot")
.select("*, schoolTimetable:SchoolTimetable(id, name, academicYear:AcademicYear(name))", { count: "exact" })
.order("position", { ascending: true });
if (schoolId) {
query = query.eq("schoolId", schoolId);
}
if (schoolTimetableId && !Number.isNaN(schoolTimetableId)) {
query = query.eq("schoolTimetableId", schoolTimetableId);
}
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) {
switch (key) {
case "search":
query = query.ilike("name", `%${value}%`);
break;
default:
break;
}
}
}
}
// PAGINATION
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
const { data: rawData, count, error } = await query;
if (error) {
console.error("Error fetching timetable slots from Supabase:", error);
}
const data = (rawData || []) as unknown as SlotList[];
let filterTimetableName: string | null = null;
if (schoolTimetableId && !Number.isNaN(schoolTimetableId)) {
const { data: st } = await supabase
.from("SchoolTimetable")
.select("name, academicYear:AcademicYear(name)")
.eq("id", schoolTimetableId)
.single();
if (st) {
const ay = (st as { academicYear?: { name: string } }).academicYear;
filterTimetableName = ay ? `${st.name} (${ay.name})` : st.name;
}
}
return (
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
{/* TOP */}
{filterTimetableName && (
<div className="mb-2 text-sm text-gray-600 flex items-center gap-2 flex-wrap">
<span>Showing slots for: <strong>{filterTimetableName}</strong></span>
<Link href="/list/timeslots" className="text-blue-500 hover:underline">Show all slots</Link>
</div>
)}
<div className="flex items-center justify-between">
<h1 className="hidden md:block text-lg font-semibold">Timetable Slots</h1>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<TableSearch />
<div className="flex items-center gap-4 self-end">
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/filter.png" alt="" width={14} height={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
<Image src="/sort.png" alt="" width={14} height={14} />
</button>
{role === "admin" && (
<FormContainer table="schoolTimetableSlot" type="create" />
)}
</div>
</div>
</div>
{/* LIST */}
<Table columns={columns} renderRow={renderRow} data={data} />
{/* PAGINATION */}
<Pagination page={p} count={count || 0} />
</div>
);
};
export default TimetableSlotListPage;

View File

@ -2,7 +2,6 @@ import Announcements from "@/components/Announcements";
import BigCalendarContainer from "@/components/BigCalendarContainer"; import BigCalendarContainer from "@/components/BigCalendarContainer";
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { Tables } from "@/types/supabase";
const ParentPage = async () => { const ParentPage = async () => {
@ -12,30 +11,35 @@ const ParentPage = async () => {
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const { data: students, error } = await supabase const { data: students, error } = await supabase
.from("Student") .from("Student")
.select("*") .select("*, studentClasses:StudentClass(classId)");
// RLS policies should handle identifying parent id
// .eq("parentId", currentUserId!)
if (error) { if (error) {
console.error("Error fetching parent students:", error); console.error("Error fetching parent students:", error);
} }
const studentsList = (students || []) as Tables<"Student">[]; const studentsList = (students || []) as { id: string; name: string; surname: string; studentClasses: { classId: number }[] }[];
return ( return (
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row"> <div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
{/* LEFT */} {/* LEFT */}
<div className=""> <div className="">
{studentsList.map((student) => ( {studentsList.map((student) => {
<div className="w-full xl:w-2/3" key={student.id}> const classIds = student.studentClasses?.map((sc) => sc.classId) ?? [];
<div className="h-full bg-white p-4 rounded-md"> return (
<h1 className="text-xl font-semibold"> <div className="w-full xl:w-2/3" key={student.id}>
Schedule ({student.name + " " + student.surname}) <div className="h-full bg-white p-4 rounded-md">
</h1> <h1 className="text-xl font-semibold">
<BigCalendarContainer type="classId" id={student.classId} /> Schedule ({student.name} {student.surname})
</h1>
{classIds.length > 0 ? (
<BigCalendarContainer type="classId" id={classIds} />
) : (
<div className="text-gray-500 mt-4">No classes assigned.</div>
)}
</div>
</div> </div>
</div> );
))} })}
</div> </div>
{/* RIGHT */} {/* RIGHT */}
<div className="w-full xl:w-1/3 flex flex-col gap-8"> <div className="w-full xl:w-1/3 flex flex-col gap-8">

View File

@ -3,45 +3,33 @@ import BigCalendarContainer from "@/components/BigCalendarContainer";
import BigCalendar from "@/components/BigCalender"; import BigCalendar from "@/components/BigCalender";
import EventCalendar from "@/components/EventCalendar"; import EventCalendar from "@/components/EventCalendar";
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import { Tables } from "@/types/supabase";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
const StudentPage = async () => { const StudentPage = async () => {
const { userId } = auth(); const { userId } = auth();
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const { data: studentItem, error: studentError } = await supabase const { data: studentClasses, error: linkError } = await supabase
.from("Student") .from("StudentClass")
.select("classId") .select("classId")
.eq("id", userId!) .eq("studentId", userId!);
.single();
if (studentError) { if (linkError) {
console.error("Error fetching student details:", studentError); console.error("Error fetching student classes:", linkError);
} }
const { data: classItems, error } = await supabase const classIds = (studentClasses ?? []).map((r) => r.classId);
.from("Class")
.select("*")
.eq("id", studentItem?.classId || 0);
if (error) {
console.error("Error fetching student class:", error);
}
const classItem = (classItems || []) as Tables<"Class">[];
const studentClassId = classItem.length > 0 ? classItem[0].id : null;
return ( return (
<div className="p-4 flex gap-4 flex-col xl:flex-row"> <div className="p-4 flex gap-4 flex-col xl:flex-row">
{/* LEFT */} {/* LEFT */}
<div className="w-full xl:w-2/3"> <div className="w-full xl:w-2/3">
<div className="h-full bg-white p-4 rounded-md"> <div className="h-full bg-white p-4 rounded-md">
<h1 className="text-xl font-semibold">Schedule (4A)</h1> <h1 className="text-xl font-semibold">Schedule</h1>
{studentClassId ? ( {classIds.length > 0 ? (
<BigCalendarContainer type="classId" id={studentClassId} /> <BigCalendarContainer type="classId" id={classIds} />
) : ( ) : (
<div className="text-gray-500 mt-4">No schedule found for your assigned class.</div> <div className="text-gray-500 mt-4">No classes assigned. Your schedule will appear here.</div>
)} )}
</div> </div>
</div> </div>

View File

@ -1,9 +1,15 @@
import Announcements from "@/components/Announcements"; import Announcements from "@/components/Announcements";
import BigCalendarContainer from "@/components/BigCalendarContainer"; import BigCalendarContainer from "@/components/BigCalendarContainer";
import { ensureTeacherOnboarding } from "@/lib/actions";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
const TeacherPage = () => { // This page uses auth() and ensureTeacherOnboarding() (which use headers), so it must be dynamic.
export const dynamic = "force-dynamic";
const TeacherPage = async () => {
await ensureTeacherOnboarding();
const { userId } = auth(); const { userId } = auth();
return ( return (
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row"> <div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
{/* LEFT */} {/* LEFT */}

View File

@ -0,0 +1,14 @@
"use client";
import { useSearchParams } from "next/navigation";
import WhiteboardCore from "@/components/Whiteboard/WhiteboardCore";
const DashboardWhiteboardPage = () => {
const searchParams = useSearchParams();
const lessonIdParam = searchParams.get("lessonId");
const lessonId = lessonIdParam ? parseInt(lessonIdParam) : null;
return <WhiteboardCore lessonId={lessonId} isFullscreen={false} />;
};
export default DashboardWhiteboardPage;

View File

@ -0,0 +1,14 @@
"use client";
import { useSearchParams } from "next/navigation";
import WhiteboardCore from "@/components/Whiteboard/WhiteboardCore";
const FullscreenWhiteboardPage = () => {
const searchParams = useSearchParams();
const lessonIdParam = searchParams.get("lessonId");
const lessonId = lessonIdParam ? parseInt(lessonIdParam) : null;
return <WhiteboardCore lessonId={lessonId} isFullscreen={true} />;
};
export default FullscreenWhiteboardPage;

View File

@ -0,0 +1,11 @@
export default function FullscreenLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="w-screen h-screen overflow-hidden bg-white">
{children}
</div>
);
}

View File

@ -6,6 +6,7 @@ import { useUser } from "@clerk/nextjs";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import Link from "next/link";
const LoginPage = () => { const LoginPage = () => {
const { isLoaded, isSignedIn, user } = useUser(); const { isLoaded, isSignedIn, user } = useUser();
@ -61,6 +62,15 @@ const LoginPage = () => {
> >
Sign In Sign In
</SignIn.Action> </SignIn.Action>
<p className="text-xs text-gray-500 mt-2 text-center">
Independent or agency teacher?{" "}
<Link
href="/teacher-sign-up"
className="text-blue-500 underline"
>
Create an account
</Link>
</p>
</SignIn.Step> </SignIn.Step>
</SignIn.Root> </SignIn.Root>
</div> </div>

View File

@ -0,0 +1,67 @@
"use client";
import * as Clerk from "@clerk/elements/common";
import * as SignUp from "@clerk/elements/sign-up";
import Image from "next/image";
import Link from "next/link";
const TeacherSignUpPage = () => {
return (
<div className="h-screen flex items-center justify-center bg-lamaSkyLight">
<SignUp.Root>
<SignUp.Step
name="start"
className="bg-white p-12 rounded-md shadow-2xl flex flex-col gap-2"
>
<h1 className="text-xl font-bold flex items-center gap-2">
<Image src="/logo.png" alt="" width={24} height={24} />
SchooLama
</h1>
<h2 className="text-gray-400">
Independent or agency teacher sign up
</h2>
<Clerk.GlobalError className="text-sm text-red-400" />
<Clerk.Field name="identifier" className="flex flex-col gap-2">
<Clerk.Label className="text-xs text-gray-500">
Username or Email
</Clerk.Label>
<Clerk.Input
type="text"
required
className="p-2 rounded-md ring-1 ring-gray-300"
/>
<Clerk.FieldError className="text-xs text-red-400" />
</Clerk.Field>
<Clerk.Field name="password" className="flex flex-col gap-2">
<Clerk.Label className="text-xs text-gray-500">
Password
</Clerk.Label>
<Clerk.Input
type="password"
required
className="p-2 rounded-md ring-1 ring-gray-300"
/>
<Clerk.FieldError className="text-xs text-red-400" />
</Clerk.Field>
{/* You can map additional custom fields here (e.g. teacherType)
to Clerk public metadata in your JWT template if needed. */}
<SignUp.Action
submit
className="bg-blue-500 text-white my-1 rounded-md text-sm p-[10px]"
>
Sign Up
</SignUp.Action>
<p className="text-xs text-gray-500 mt-2 text-center">
Already have an account?{" "}
<Link href="/sign-in" className="text-blue-500 underline">
Sign in
</Link>
</p>
</SignUp.Step>
</SignUp.Root>
</div>
);
};
export default TeacherSignUpPage;

View File

@ -1,20 +1,31 @@
import { getSupabaseClient } from "@/lib/supabase"; import { getSupabaseClient } from "@/lib/supabase";
import BigCalendar from "./BigCalender"; import BigCalendar from "./BigCalender";
import { auth } from "@clerk/nextjs/server";
const BigCalendarContainer = async ({ const BigCalendarContainer = async ({
type, type,
id, id,
}: { }: {
type: "teacherId" | "classId"; type: "teacherId" | "classId";
id: string | number; id: string | number | number[];
}) => { }) => {
const { sessionClaims } = auth();
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
let query = supabase.from("Lesson").select("*"); let query = supabase.from("Lesson").select("*");
if (schoolId) {
query = query.eq("schoolId", schoolId);
}
if (type === "teacherId") { if (type === "teacherId") {
query = query.eq("teacherId", id as string); query = query.eq("teacherId", id as string);
} else { } else {
query = query.eq("classId", id as number); const classIds = Array.isArray(id) ? id : [id];
if (classIds.length > 0) {
query = query.in("classId", classIds as number[]);
}
} }
const { data: rawData, error } = await query; const { data: rawData, error } = await query;
@ -25,6 +36,7 @@ const BigCalendarContainer = async ({
const dataRes = rawData || []; const dataRes = rawData || [];
const data = dataRes.map((lesson) => ({ const data = dataRes.map((lesson) => ({
id: lesson.id,
title: lesson.name, title: lesson.name,
start: new Date(lesson.startTime), start: new Date(lesson.startTime),
end: new Date(lesson.endTime), end: new Date(lesson.endTime),

View File

@ -4,13 +4,15 @@ import { Calendar, momentLocalizer, View, Views } from "react-big-calendar";
import moment from "moment"; import moment from "moment";
import "react-big-calendar/lib/css/react-big-calendar.css"; import "react-big-calendar/lib/css/react-big-calendar.css";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
const localizer = momentLocalizer(moment); const localizer = momentLocalizer(moment);
const BigCalendar = ({ const BigCalendar = ({
data, data,
}: { }: {
data: { title: string; start: Date; end: Date }[]; data: { id?: number; title: string; start: Date; end: Date }[];
}) => { }) => {
const [view, setView] = useState<View>(Views.WORK_WEEK); const [view, setView] = useState<View>(Views.WORK_WEEK);
const [date, setDate] = useState<Date>(new Date()); const [date, setDate] = useState<Date>(new Date());
@ -23,7 +25,22 @@ const BigCalendar = ({
<Calendar <Calendar
localizer={localizer} localizer={localizer}
events={data} events={data}
components={{}} components={{
event: (props) => (
<div className="flex flex-col h-full justify-between p-1">
<span className="text-xs font-semibold truncate leading-tight" title={props.title}>{props.title}</span>
{props.event.id && (
<Link
href={`/lesson?lessonId=${props.event.id}`}
className="mt-1 flex items-center gap-1 w-max rounded text-xs bg-white bg-opacity-30 hover:bg-opacity-50 transition-all px-1 py-0.5"
>
<Image src="/student.png" alt="Board" width={10} height={10} />
<span>Board</span>
</Link>
)}
</div>
)
}}
startAccessor="start" startAccessor="start"
endAccessor="end" endAccessor="end"
views={["month", "work_week", "day"]} views={["month", "work_week", "day"]}

View File

@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import Menu from "@/components/Menu";
import Navbar from "@/components/Navbar";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function DashboardShell({
children,
role,
userMetadata,
canManageSchool,
}: {
children: React.ReactNode;
role: string;
userMetadata: any;
canManageSchool: boolean;
}) {
const [isCollapsed, setIsCollapsed] = useState(false);
const toggleSidebar = () => {
setIsCollapsed((prev) => !prev);
};
return (
<div className="h-screen flex overflow-hidden">
{/* LEFT SIDEBAR */}
<div
className={`${isCollapsed ? "w-[80px]" : "w-[14%] md:w-[8%] lg:w-[16%] xl:w-[14%]"
} p-4 transition-all duration-300 ease-in-out border-r bg-white z-20 overflow-y-auto no-scrollbar`}
>
{/* Logo Area (Hidden when collapsed, moves to Navbar) */}
<div
className={`flex items-center justify-center lg:justify-start gap-2 mb-4 transition-all duration-300 ${isCollapsed ? "opacity-0 h-0 hidden" : "opacity-100 h-8"
}`}
>
<Link href="/" className="flex items-center gap-2">
<Image src="/logo.png" alt="logo" width={32} height={32} />
<span className="hidden lg:block font-bold">SchooLama</span>
</Link>
</div>
{/* Collapsed Logo (Shows only when collapsed) */}
<div
className={`flex items-center justify-center gap-2 mb-4 transition-all duration-300 ${!isCollapsed ? "opacity-0 h-0 hidden" : "opacity-100 h-8"
}`}
>
<Link href="/" className="flex items-center justify-center w-full">
<Image src="/logo.png" alt="logo" width={32} height={32} />
</Link>
</div>
<Menu
role={role}
isCollapsed={isCollapsed}
canManageSchool={canManageSchool}
/>
</div>
{/* RIGHT MAIN CONTENT */}
<div className="flex-1 bg-[#F7F8FA] overflow-y-auto flex flex-col relative w-full">
<Navbar
role={role}
userMetadata={userMetadata}
toggleSidebar={toggleSidebar}
isCollapsed={isCollapsed}
/>
<main className="flex-1 relative">
{children}
</main>
</div>
</div>
);
}

View File

@ -15,7 +15,12 @@ export type FormContainerProps = {
| "result" | "result"
| "attendance" | "attendance"
| "event" | "event"
| "announcement"; | "announcement"
| "term"
| "holiday"
| "schoolTimetableSlot"
| "timetableTemplate"
| "timetableEntry";
type: "create" | "update" | "delete"; type: "create" | "update" | "delete";
data?: any; data?: any;
id?: number | string; id?: number | string;
@ -26,6 +31,7 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
const { userId, sessionClaims } = auth(); const { userId, sessionClaims } = auth();
const role = (sessionClaims?.metadata as { role?: string })?.role; const role = (sessionClaims?.metadata as { role?: string })?.role;
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
const currentUserId = userId; const currentUserId = userId;
if (type !== "delete") { if (type !== "delete") {
@ -49,12 +55,12 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
} }
case "student": { case "student": {
const { data: studentGrades } = await supabase.from("Grade").select("id, level"); const { data: studentGrades } = await supabase.from("Grade").select("id, level");
const { data: studentClasses } = await supabase.from("Class").select("*, students:Student(count)"); const { data: studentClasses } = await supabase.from("Class").select("*, studentClasses:StudentClass(count)");
const classesWithCount = studentClasses?.map(c => ({ const classesWithCount = studentClasses?.map((c: any) => ({
...c, ...c,
_count: { students: Array.isArray(c.students) ? (c.students as any)[0]?.count || 0 : 0 } _count: { students: Array.isArray(c.studentClasses) ? (c.studentClasses[0]?.count ?? 0) : 0 },
})); }));
relatedData = { classes: classesWithCount, grades: studentGrades }; relatedData = { classes: classesWithCount ?? [], grades: studentGrades };
break; break;
} }
case "lesson": { case "lesson": {
@ -96,6 +102,49 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
relatedData = { classes: announcementClasses }; relatedData = { classes: announcementClasses };
break; break;
} }
case "timetableTemplate": {
let teacherIds: string[] | null = null;
if (schoolId) {
const { data: mappings, error } = await supabase
.from("TeacherSchool")
.select("teacherId")
.eq("schoolId", schoolId);
if (!error && mappings) {
teacherIds = mappings.map((m: any) => m.teacherId as string);
}
}
let query = supabase.from("Teacher").select("id, name, surname");
if (teacherIds && teacherIds.length > 0) {
query = query.in("id", teacherIds);
}
const { data: templateTeachers } = await query;
relatedData = { teachers: templateTeachers || [] };
break;
}
case "timetableEntry": {
let classQuery = supabase.from("Class").select("id, name");
let subjectQuery = supabase.from("Subject").select("id, name");
let slotQuery = supabase
.from("SchoolTimetableSlot")
.select("id, name, startTime, endTime")
.order("position", { ascending: true });
if (schoolId) {
classQuery = classQuery.eq("schoolId", schoolId);
subjectQuery = subjectQuery.eq("schoolId", schoolId);
slotQuery = slotQuery.eq("schoolId", schoolId);
}
const [{ data: entryClasses }, { data: entrySubjects }, { data: entrySlots }] = await Promise.all([
classQuery,
subjectQuery,
slotQuery,
]);
relatedData = { classes: entryClasses, subjects: entrySubjects, slots: entrySlots };
break;
}
default: default:
break; break;
} }

View File

@ -11,6 +11,11 @@ import {
deleteResult, deleteResult,
deleteEvent, deleteEvent,
deleteAnnouncement, deleteAnnouncement,
deleteTerm,
deleteHoliday,
deleteSchoolTimetableSlot,
deleteTimetableTemplate,
deleteTimetableEntry,
} from "@/lib/actions"; } from "@/lib/actions";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Image from "next/image"; import Image from "next/image";
@ -34,6 +39,11 @@ const deleteActionMap = {
attendance: deleteSubject, attendance: deleteSubject,
event: deleteEvent, event: deleteEvent,
announcement: deleteAnnouncement, announcement: deleteAnnouncement,
term: deleteTerm,
holiday: deleteHoliday,
schoolTimetableSlot: deleteSchoolTimetableSlot,
timetableTemplate: deleteTimetableTemplate,
timetableEntry: deleteTimetableEntry,
}; };
// USE LAZY LOADING // USE LAZY LOADING
@ -71,6 +81,21 @@ const EventForm = dynamic(() => import("./forms/EventForm"), {
const AnnouncementForm = dynamic(() => import("./forms/AnnouncementForm"), { const AnnouncementForm = dynamic(() => import("./forms/AnnouncementForm"), {
loading: () => <h1>Loading...</h1>, loading: () => <h1>Loading...</h1>,
}); });
const TermForm = dynamic(() => import("./forms/TermForm"), {
loading: () => <h1>Loading...</h1>,
});
const HolidayForm = dynamic(() => import("./forms/HolidayForm"), {
loading: () => <h1>Loading...</h1>,
});
const SchoolTimetableSlotForm = dynamic(() => import("./forms/SchoolTimetableSlotForm"), {
loading: () => <h1>Loading...</h1>,
});
const TimetableTemplateForm = dynamic(() => import("./forms/TimetableTemplateForm"), {
loading: () => <h1>Loading...</h1>,
});
const TimetableEntryForm = dynamic(() => import("./forms/TimetableEntryForm"), {
loading: () => <h1>Loading...</h1>,
});
// TODO: OTHER FORMS // TODO: OTHER FORMS
const forms: { const forms: {
@ -160,7 +185,46 @@ const forms: {
setOpen={setOpen} setOpen={setOpen}
relatedData={relatedData} relatedData={relatedData}
/> />
// TODO OTHER LIST ITEMS ),
term: (setOpen, type, data, relatedData) => (
<TermForm
type={type}
data={data}
setOpen={setOpen}
relatedData={relatedData}
/>
),
holiday: (setOpen, type, data, relatedData) => (
<HolidayForm
type={type}
data={data}
setOpen={setOpen}
relatedData={relatedData}
/>
),
schoolTimetableSlot: (setOpen, type, data, relatedData) => (
<SchoolTimetableSlotForm
type={type}
data={data}
setOpen={setOpen}
relatedData={relatedData}
/>
),
timetableTemplate: (setOpen, type, data, relatedData) => (
<TimetableTemplateForm
type={type}
data={data}
setOpen={setOpen}
relatedData={relatedData}
/>
),
timetableEntry: (setOpen, type, data, relatedData) => (
<TimetableEntryForm
type={type}
data={data}
setOpen={setOpen}
relatedData={relatedData}
/>
), ),
}; };

View File

@ -0,0 +1,135 @@
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
export type SortOption = { value: string; label: string };
export type FilterOptionItem = { value: string; label: string };
export type FilterOption = { key: string; label: string; options: FilterOptionItem[] };
type ListFilterSortProps = {
/** Sort field options (value = URL param & typically DB column name) */
sortOptions: SortOption[];
/** Filter dropdowns: key = URL param name, options = list for dropdown */
filters?: FilterOption[];
/** Show the search input in this segment (default true) */
showSearch?: boolean;
/** Optional extra content (e.g. Create button) to show on the right */
children?: React.ReactNode;
};
const ListFilterSort = ({
sortOptions,
filters = [],
showSearch = true,
children,
}: ListFilterSortProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === "") params.delete(key);
else params.set(key, value);
}
params.delete("page"); // reset to first page when filters/sort change
router.push(`${window.location.pathname}?${params}`);
},
[router, searchParams]
);
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const value = (e.currentTarget.elements.namedItem("search") as HTMLInputElement)?.value?.trim() ?? "";
updateParams({ search: value || null });
};
const sortBy = searchParams.get("sortBy") ?? (sortOptions[0]?.value ?? "");
const sortOrder = searchParams.get("sortOrder") ?? "asc";
const hasActiveFilters = filters.some((f) => searchParams.get(f.key));
const clearFilters = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("page");
filters.forEach((f) => params.delete(f.key));
router.push(`${window.location.pathname}?${params}`);
};
return (
<div className="flex flex-wrap items-center gap-3 py-3 border-b border-gray-200 mb-2">
{showSearch && (
<form onSubmit={handleSearch} className="flex items-center gap-2 text-xs rounded-full ring-[1.5px] ring-gray-300 px-2">
<Image src="/search.png" alt="" width={14} height={14} />
<input
name="search"
type="text"
placeholder="Search..."
defaultValue={searchParams.get("search") ?? ""}
className="w-[180px] sm:w-[200px] p-2 bg-transparent outline-none"
/>
</form>
)}
{sortOptions.length > 0 && (
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 whitespace-nowrap">Sort by</label>
<select
value={sortBy}
onChange={(e) => updateParams({ sortBy: e.target.value })}
className="text-sm rounded-md border border-gray-300 px-2 py-1.5 bg-white min-w-[120px]"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select
value={sortOrder}
onChange={(e) => updateParams({ sortOrder: e.target.value })}
className="text-sm rounded-md border border-gray-300 px-2 py-1.5 bg-white"
aria-label="Sort order"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
)}
{filters.map((filter) => (
<div key={filter.key} className="flex items-center gap-2">
<label className="text-xs text-gray-500 whitespace-nowrap">{filter.label}</label>
<select
value={searchParams.get(filter.key) ?? ""}
onChange={(e) => updateParams({ [filter.key]: e.target.value || null })}
className="text-sm rounded-md border border-gray-300 px-2 py-1.5 bg-white min-w-[120px]"
>
<option value="">All</option>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
{hasActiveFilters && (
<button
type="button"
onClick={clearFilters}
className="text-xs text-gray-600 hover:text-gray-900 underline"
>
Clear filters
</button>
)}
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
};
export default ListFilterSort;

View File

@ -1,4 +1,3 @@
import { currentUser } from "@clerk/nextjs/server";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@ -92,6 +91,57 @@ const menuItems = [
}, },
], ],
}, },
{
title: "TEACHER CONFIG",
items: [
{
icon: "/home.png",
label: "My Schools",
href: "/list/my-schools",
visible: ["teacher"],
},
],
},
{
title: "SCHOOL CONFIG",
items: [
{
icon: "/calendar.png",
label: "Terms",
href: "/list/terms",
visible: ["admin", "teacher"],
},
{
icon: "/calendar.png",
label: "Holidays",
href: "/list/holidays",
visible: ["admin", "teacher"],
},
{
icon: "/calendar.png",
label: "School Timetables",
href: "/list/school-timetables",
visible: ["admin", "teacher"],
},
{
icon: "/lesson.png",
label: "Timetable Slots",
href: "/list/timeslots",
visible: ["admin", "teacher"],
},
],
},
{
title: "TEACHER TIMETABLE CONFIG",
items: [
{
icon: "/calendar.png",
label: "Timetable Templates",
href: "/list/templates",
visible: ["admin", "teacher"],
},
],
},
{ {
title: "OTHER", title: "OTHER",
items: [ items: [
@ -117,32 +167,84 @@ const menuItems = [
}, },
]; ];
const Menu = async () => { const Menu = ({
const user = await currentUser(); role,
const role = user?.publicMetadata.role as string; isCollapsed,
canManageSchool,
}: {
role: string;
isCollapsed: boolean;
canManageSchool: boolean;
}) => {
return ( return (
<div className="mt-4 text-sm"> <div className="mt-4 text-sm w-full">
{menuItems.map((i) => ( {menuItems.map((section) => {
<div className="flex flex-col gap-2" key={i.title}> const filteredItems = section.items.filter((item) => {
<span className="hidden lg:block text-gray-400 font-light my-4"> if (!item.visible.includes(role)) return false;
{i.title} if (
</span> section.title === "SCHOOL CONFIG" &&
{i.items.map((item) => { role === "teacher" &&
if (item.visible.includes(role)) { !canManageSchool
return ( ) {
<Link return false;
href={item.href} }
key={item.label} return true;
className="flex items-center justify-center lg:justify-start gap-4 text-gray-500 py-2 md:px-2 rounded-md hover:bg-lamaSkyLight" });
if (filteredItems.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-2 relative w-full" key={section.title}>
<span
className={`hidden lg:block text-gray-400 font-light my-4 transition-all duration-300 ease-in-out whitespace-nowrap overflow-hidden ${
isCollapsed
? "opacity-0 h-0 my-0 mt-4 text-[0px]"
: "opacity-100 h-4 my-4 text-xs"
}`}
>
{section.title}
</span>
<div
className={`block lg:hidden w-full h-4 ${
isCollapsed ? "" : "my-4"
}`}
/>
{filteredItems.map((item) => (
<Link
href={item.href}
key={item.label}
className={`flex items-center text-gray-500 py-2 rounded-md hover:bg-lamaSkyLight transition-all relative group
${isCollapsed ? "justify-center px-0 w-10 h-10 mx-auto" : "justify-center lg:justify-start md:px-2"}
`}
>
<Image
src={item.icon}
alt=""
width={20}
height={20}
className="min-w-[20px]"
/>
<span
className={`hidden lg:block whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out origin-left
${isCollapsed ? "opacity-0 w-0 max-w-0" : "opacity-100 w-auto ml-4 max-w-[200px]"}
`}
> >
<Image src={item.icon} alt="" width={20} height={20} /> {item.label}
<span className="hidden lg:block">{item.label}</span> </span>
</Link>
); {/* Tooltip on hover when collapsed */}
} {isCollapsed && (
})} <div className="absolute left-14 bg-gray-800 text-white text-xs py-1 px-2 rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 pointer-events-none">
</div> {item.label}
))} </div>
)}
</Link>
))}
</div>
);
})}
</div> </div>
); );
}; };

View File

@ -1,38 +1,101 @@
import { UserButton } from "@clerk/nextjs"; import { UserButton } from "@clerk/nextjs";
import { currentUser } from "@clerk/nextjs/server";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { Menu as MenuIcon } from "lucide-react";
import { setActiveSchool } from "@/lib/actions";
const Navbar = async () => { const Navbar = ({
const user = await currentUser(); role,
userMetadata,
toggleSidebar,
isCollapsed
}: {
role: string;
userMetadata: any;
toggleSidebar: () => void;
isCollapsed: boolean;
}) => {
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4 bg-white shadow-sm z-10 sticky top-0">
{/* SEARCH BAR */} {/* LEFT AREA: Toggle and Animated Logo */}
<div className="hidden md:flex items-center gap-2 text-xs rounded-full ring-[1.5px] ring-gray-300 px-2"> <div className="flex items-center gap-4">
<Image src="/search.png" alt="" width={14} height={14} /> {/* Hamburger Toggle */}
<input <button
type="text" onClick={toggleSidebar}
placeholder="Search..." className="p-2 rounded-md hover:bg-gray-100 transition-colors text-gray-600 focus:outline-none focus:ring-2 focus:ring-lamaSky"
className="w-[200px] p-2 bg-transparent outline-none" aria-label="Toggle Sidebar"
/> >
<MenuIcon size={20} />
</button>
{/* Logo (Appears here when sidebar is collapsed) */}
<div
className={`flex items-center gap-2 overflow-hidden transition-all duration-300 ease-in-out origin-left
${isCollapsed ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0"}`}
>
<Link href="/" className="flex items-center gap-2">
<Image src="/logo.png" alt="logo" width={24} height={24} />
<span className="font-bold text-lg hidden sm:block">SchooLama</span>
</Link>
</div>
</div> </div>
{/* ICONS AND USER */}
<div className="flex items-center gap-6 justify-end w-full"> {/* RIGHT: ICONS AND USER */}
<div className="bg-white rounded-full w-7 h-7 flex items-center justify-center cursor-pointer"> <div className="flex items-center gap-6 justify-end">
{/* Active school selector (for admins / teachers) */}
{Array.isArray(userMetadata?.schools) &&
userMetadata.schools.length > 0 && (
<form action={setActiveSchool}>
<select
name="schoolId"
defaultValue={userMetadata.activeSchoolId || ""}
className="text-xs ring-[1.5px] ring-gray-300 rounded-md px-2 py-1 bg-white"
onChange={(e) => {
// Auto-submit the form when the selection changes
e.currentTarget.form?.requestSubmit();
}}
>
{!userMetadata.activeSchoolId && (
<option value="">Select school</option>
)}
{userMetadata.schools.map(
(s: { id: string; name: string }) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
)
)}
</select>
</form>
)}
{/* SEARCH BAR */}
<div className="hidden md:flex items-center gap-2 text-xs rounded-full ring-[1.5px] ring-gray-300 px-2">
<Image src="/search.png" alt="" width={14} height={14} />
<input
type="text"
placeholder="Search..."
className="w-[200px] p-2 bg-transparent outline-none"
/>
</div>
<div className="bg-lamaSkyLight rounded-full w-7 h-7 flex items-center justify-center cursor-pointer">
<Image src="/message.png" alt="" width={20} height={20} /> <Image src="/message.png" alt="" width={20} height={20} />
</div> </div>
<div className="bg-white rounded-full w-7 h-7 flex items-center justify-center cursor-pointer relative"> <div className="bg-lamaSkyLight rounded-full w-7 h-7 flex items-center justify-center cursor-pointer relative">
<Image src="/announcement.png" alt="" width={20} height={20} /> <Image src="/announcement.png" alt="" width={20} height={20} />
<div className="absolute -top-3 -right-3 w-5 h-5 flex items-center justify-center bg-purple-500 text-white rounded-full text-xs"> <div className="absolute -top-3 -right-3 w-5 h-5 flex items-center justify-center bg-purple-500 text-white rounded-full text-xs">
1 1
</div> </div>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs leading-3 font-medium">John Doe</span> <span className="text-xs leading-3 font-medium">
{userMetadata?.firstName} {userMetadata?.lastName}
</span>
<span className="text-[10px] text-gray-500 text-right"> <span className="text-[10px] text-gray-500 text-right">
{user?.publicMetadata?.role as string} {role}
</span> </span>
</div> </div>
{/* <Image src="/avatar.png" alt="" width={36} height={36} className="rounded-full"/> */}
<UserButton /> <UserButton />
</div> </div>
</div> </div>

View File

@ -0,0 +1,257 @@
"use client";
import { useEffect, useState, useRef, createContext, useContext } from "react";
import {
Tldraw,
createTLStore,
TLStore,
Editor,
getSnapshot,
loadSnapshot,
useDialogs,
TldrawUiButton,
TldrawUiButtonLabel,
TldrawUiButtonIcon,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
TldrawUiDialogCloseButton,
TldrawUiDialogBody,
TldrawUiIcon
} from "tldraw";
import "tldraw/tldraw.css";
import { getLessonWhiteboard, saveLessonSnapshot, SnapshotType } from "@/lib/whiteboardActions";
import { toast } from "react-toastify";
import { useUser } from "@clerk/nextjs";
type WhiteboardContextType = {
activeState: SnapshotType;
isTeacherOrAdmin: boolean;
handleSave: (type: SnapshotType) => void;
handleLoadData: (type: SnapshotType) => void;
lessonId: number | null;
whiteboardRecord: any;
isFullscreen: boolean;
};
const WhiteboardContext = createContext<WhiteboardContextType | null>(null);
const useWhiteboardContext = () => {
const ctx = useContext(WhiteboardContext);
if (!ctx) throw new Error("Missing WhiteboardContext.Provider");
return ctx;
};
function NativeFileManagerDialog({ onClose }: { onClose: () => void }) {
const { activeState, isTeacherOrAdmin, handleSave, handleLoadData, whiteboardRecord } = useWhiteboardContext();
const renderFileRow = (type: SnapshotType, title: string) => {
const hasData = whiteboardRecord && whiteboardRecord[`${type}SnapshotData`];
const isActive = activeState === type;
return (
<div className="flex items-center justify-between p-3 border rounded-md mb-2 bg-gray-50" key={type}>
<div className="flex flex-col">
<span className="font-semibold text-gray-800 flex items-center gap-2">
{title}
{isActive && <span className="text-[10px] bg-lamaPurple text-white px-2 py-0.5 rounded-full mt-0.5">ACTIVE</span>}
</span>
<span className="text-xs text-gray-500">
{hasData ? "Snapshot available" : "No snapshot saved"}
</span>
</div>
<div className="flex gap-2">
{isTeacherOrAdmin && (
<TldrawUiButton type="normal" onClick={() => { handleSave(type); }}>
<TldrawUiButtonLabel>Save Here</TldrawUiButtonLabel>
</TldrawUiButton>
)}
<TldrawUiButton type="primary" disabled={!hasData} onClick={() => { handleLoadData(type); onClose(); }}>
<TldrawUiButtonLabel>Load</TldrawUiButtonLabel>
</TldrawUiButton>
</div>
</div>
);
};
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>File Manager</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 450 }}>
<div className="mb-4 text-sm text-gray-600">
Manage your lesson whiteboard snapshots below.
{!isTeacherOrAdmin && " As a student, you can only load available snapshots."}
</div>
{renderFileRow("planned", "Planned")}
{renderFileRow("live", "Live")}
{renderFileRow("final", "Final")}
</TldrawUiDialogBody>
</>
)
}
const CustomSharePanel = () => {
const { addDialog } = useDialogs();
const { isFullscreen, lessonId } = useWhiteboardContext();
return (
<div style={{ pointerEvents: 'all', display: 'flex', gap: '8px' }}>
{!isFullscreen && lessonId && (
<TldrawUiButton
type="icon"
title="Open Fullscreen in New Window"
onClick={() => {
window.open(`/board?lessonId=${lessonId}`, '_blank');
}}
>
<TldrawUiButtonIcon icon="external-link" />
</TldrawUiButton>
)}
<TldrawUiButton
type="icon"
title="File Manager"
onClick={() => addDialog({ component: NativeFileManagerDialog })}
>
<TldrawUiButtonIcon icon="menu" />
</TldrawUiButton>
</div>
);
};
const WhiteboardCore = ({ lessonId, isFullscreen = false }: { lessonId: number | null, isFullscreen?: boolean }) => {
const { user } = useUser();
const role = user?.publicMetadata?.role as string | undefined;
const isTeacherOrAdmin = role === "teacher" || role === "admin";
const [store, setStore] = useState<TLStore | null>(null);
const [loading, setLoading] = useState(true);
const [activeState, setActiveState] = useState<SnapshotType>("planned");
const [whiteboardRecord, setWhiteboardRecord] = useState<any>(null);
const editorRef = useRef<Editor | null>(null);
useEffect(() => {
const fetchWhiteboard = async () => {
if (!lessonId) {
setLoading(false);
return;
}
const { success, data } = await getLessonWhiteboard(lessonId);
if (success && data) {
setWhiteboardRecord(data);
// Initialize store based on role
const newStore = createTLStore();
let defaultSnapshot = data.plannedSnapshotData;
let defaultState: SnapshotType = "planned";
// For students, default to live, or final if live doesn't exist. If neither, show empty.
if (!isTeacherOrAdmin) {
if (data.liveSnapshotData) {
defaultSnapshot = data.liveSnapshotData;
defaultState = "live";
} else if (data.finalSnapshotData) {
defaultSnapshot = data.finalSnapshotData;
defaultState = "final";
} else {
defaultSnapshot = null;
defaultState = "live"; // Wait for live
}
}
if (defaultSnapshot) {
try {
loadSnapshot(newStore, defaultSnapshot as any);
} catch (e) {
console.error("Failed to load snapshot", e);
}
}
setStore(newStore);
setActiveState(defaultState);
}
setLoading(false);
};
if (role !== undefined) {
fetchWhiteboard();
}
}, [isTeacherOrAdmin, lessonId, role]);
const handleSave = async (type: SnapshotType) => {
if (!lessonId || !editorRef.current || !isTeacherOrAdmin) return;
const snapshot = getSnapshot(editorRef.current.store);
const { success } = await saveLessonSnapshot(lessonId, type, snapshot);
if (success) {
toast.success(`Successfully saved ${type} snapshot!`);
setActiveState(type);
setWhiteboardRecord((prev: any) => ({
...prev,
[`${type}SnapshotData`]: snapshot
}));
} else {
toast.error("Failed to save snapshot.");
}
};
const handleLoadData = (type: SnapshotType) => {
if (!whiteboardRecord || !editorRef.current) return;
let snapshotData = null;
if (type === "planned") snapshotData = whiteboardRecord.plannedSnapshotData;
if (type === "live") snapshotData = whiteboardRecord.liveSnapshotData;
if (type === "final") snapshotData = whiteboardRecord.finalSnapshotData;
if (snapshotData) {
try {
loadSnapshot(editorRef.current.store, snapshotData as any);
toast.success(`Loaded ${type} snapshot.`);
setActiveState(type);
} catch (e) {
console.error("Failed to load snapshot", e);
toast.error("Snapshot data is invalid or corrupted.");
}
} else {
toast.info(`No data found for ${type} snapshot.`);
}
};
return (
<div className={`flex-1 w-full relative bg-white overflow-hidden ${isFullscreen ? 'h-screen' : 'h-[calc(100vh-80px)]'}`}>
{loading || role === undefined ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
Loading whiteboard...
</div>
) : !lessonId ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
Please provide a ?lessonId URL parameter.
</div>
) : store ? (
<WhiteboardContext.Provider value={{ activeState, isTeacherOrAdmin, handleSave, handleLoadData, lessonId, whiteboardRecord, isFullscreen }}>
<div className="absolute inset-0">
<Tldraw
store={store}
components={{ SharePanel: CustomSharePanel }}
onMount={(editor) => {
editorRef.current = editor;
if (!isTeacherOrAdmin) {
editor.updateInstanceState({ isReadonly: true });
}
}}
/>
</div>
</WhiteboardContext.Provider>
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
Failed to initialize whiteboard.
</div>
)}
</div>
);
};
export default WhiteboardCore;

View File

@ -0,0 +1,129 @@
"use client";
import { useFormState } from "react-dom";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { generateLessonsFromTemplate } from "@/lib/actions";
import { useRouter } from "next/navigation";
type TermOption = { id: number; name: string; startDate: string; endDate: string };
const GenerateLessonsForm = ({
templateId,
schoolId,
terms,
}: {
templateId: number;
schoolId: string;
terms: TermOption[];
}) => {
const [state, formAction] = useFormState(generateLessonsFromTemplate, {
success: false,
error: false,
message: "",
});
const [isGenerating, setIsGenerating] = useState(false);
const [useTerm, setUseTerm] = useState(true);
const router = useRouter();
useEffect(() => {
if (state.success) {
toast.success(state.message || "Lessons generated.");
setIsGenerating(false);
router.refresh();
} else if (state.error) {
toast.error(state.message || "Failed to generate lessons.");
setIsGenerating(false);
}
}, [state, router]);
return (
<form
action={(fd) => {
setIsGenerating(true);
formAction(fd);
}}
className="flex flex-col gap-3 p-4 bg-slate-50 rounded-lg border border-slate-200"
>
<h3 className="font-semibold text-sm text-gray-700">Generate lessons for your calendar</h3>
<p className="text-xs text-gray-500">
This will create lessons from this template for the chosen period. Any existing lessons in that range for this teacher will be replaced.
</p>
<input type="hidden" name="templateId" value={templateId} />
<input type="hidden" name="schoolId" value={schoolId} />
<div className="flex flex-wrap gap-4 items-end">
<label className="flex items-center gap-2">
<input
type="radio"
checked={useTerm}
onChange={() => setUseTerm(true)}
className="rounded"
/>
<span className="text-sm">Use a term</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={!useTerm}
onChange={() => setUseTerm(false)}
className="rounded"
/>
<span className="text-sm">Custom date range</span>
</label>
</div>
{useTerm ? (
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Term</label>
<select
name="termId"
className="ring-1 ring-gray-300 rounded px-2 py-1.5 text-sm w-full max-w-xs"
required={useTerm}
>
<option value="">Select term</option>
{terms.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({new Date(t.startDate).toLocaleDateString()} {new Date(t.endDate).toLocaleDateString()})
</option>
))}
</select>
<input type="hidden" name="startDate" value="" />
<input type="hidden" name="endDate" value="" />
</div>
) : (
<div className="flex flex-wrap gap-3 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Start date</label>
<input
type="date"
name="startDate"
className="ring-1 ring-gray-300 rounded px-2 py-1.5 text-sm"
required={!useTerm}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">End date</label>
<input
type="date"
name="endDate"
className="ring-1 ring-gray-300 rounded px-2 py-1.5 text-sm"
required={!useTerm}
/>
</div>
<input type="hidden" name="termId" value="" />
</div>
)}
<button
type="submit"
disabled={isGenerating}
className="bg-lamaSky text-white px-4 py-2 text-sm rounded-md disabled:opacity-50 hover:bg-lamaSkyLight w-fit"
>
{isGenerating ? "Generating…" : "Generate lessons"}
</button>
</form>
);
};
export default GenerateLessonsForm;

View File

@ -0,0 +1,46 @@
"use client";
import { useFormState } from "react-dom";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { generateTimetableLessons } from "@/lib/actions";
const GenerateTimetableForm = ({ termId, schoolId }: { termId: number; schoolId: string }) => {
const [state, formAction] = useFormState(generateTimetableLessons, {
success: false,
error: false,
message: "",
});
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
if (state.success) {
toast.success(state.message || "Timetable generated successfully!");
setIsGenerating(false);
} else if (state.error) {
toast.error(state.message || "Failed to generate timetable.");
setIsGenerating(false);
}
}, [state]);
const handleSubmit = (formData: FormData) => {
setIsGenerating(true);
formAction(formData);
};
return (
<form action={handleSubmit}>
<input type="hidden" name="termId" value={termId} />
<input type="hidden" name="schoolId" value={schoolId} />
<button
disabled={isGenerating}
className="bg-lamaSky text-white px-3 py-1 text-sm rounded-md disabled:opacity-50 hover:bg-lamaSkyLight"
>
{isGenerating ? "Generating..." : "Generate Timetable"}
</button>
</form>
);
};
export default GenerateTimetableForm;

View File

@ -0,0 +1,115 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import InputField from "../InputField";
import { holidaySchema, HolidaySchema } from "@/lib/formValidationSchemas";
import { createHoliday, updateHoliday } from "@/lib/actions";
import { useFormState } from "react-dom";
import { Dispatch, SetStateAction, useEffect } from "react";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
import { useUser } from "@clerk/nextjs";
const HolidayForm = ({
type,
data,
setOpen,
relatedData,
}: {
type: "create" | "update";
data?: any;
setOpen: Dispatch<SetStateAction<boolean>>;
relatedData?: any;
}) => {
const { user } = useUser();
const schoolId = user?.publicMetadata?.schoolId as string;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<HolidaySchema>({
resolver: zodResolver(holidaySchema),
});
const [state, formAction] = useFormState(
type === "create" ? createHoliday : updateHoliday,
{
success: false,
error: false,
}
);
const onSubmit = handleSubmit((formData) => {
formAction({ ...formData, schoolId });
});
const router = useRouter();
useEffect(() => {
if (state.success) {
toast(`Holiday has been ${type === "create" ? "created" : "updated"}!`);
setOpen(false);
router.refresh();
}
}, [state, router, type, setOpen]);
return (
<form className="flex flex-col gap-8" onSubmit={onSubmit}>
<h1 className="text-xl font-semibold">
{type === "create" ? "Create a new holiday" : "Update the holiday"}
</h1>
<div className="flex justify-between flex-wrap gap-4">
<InputField
label="Holiday name"
name="name"
defaultValue={data?.name}
register={register}
error={errors?.name}
/>
<InputField
label="Start Date"
name="startDate"
type="date"
defaultValue={
data?.startDate ? new Date(data.startDate).toISOString().split("T")[0] : ""
}
register={register}
error={errors?.startDate}
/>
<InputField
label="End Date"
name="endDate"
type="date"
defaultValue={
data?.endDate ? new Date(data.endDate).toISOString().split("T")[0] : ""
}
register={register}
error={errors?.endDate}
/>
{data && (
<InputField
label="Id"
name="id"
defaultValue={data?.id}
register={register}
error={errors?.id}
hidden
/>
)}
<input type="hidden" value={schoolId || ""} {...register("schoolId")} />
</div>
{state.error && (
<span className="text-red-500">Something went wrong!</span>
)}
<button className="bg-blue-400 text-white p-2 rounded-md">
{type === "create" ? "Create" : "Update"}
</button>
</form>
);
};
export default HolidayForm;

View File

@ -83,25 +83,7 @@ const LessonForm = ({
hidden hidden
/> />
)} )}
<div className="flex flex-col gap-2 w-full md:w-1/4">
<label className="text-xs text-gray-500">Day</label>
<select
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
{...register("day")}
defaultValue={data?.day}
>
<option value="MONDAY">Monday</option>
<option value="TUESDAY">Tuesday</option>
<option value="WEDNESDAY">Wednesday</option>
<option value="THURSDAY">Thursday</option>
<option value="FRIDAY">Friday</option>
</select>
{errors.day?.message && (
<p className="text-xs text-red-400">
{errors.day.message.toString()}
</p>
)}
</div>
<InputField <InputField
label="Start Time" label="Start Time"
name="startTime" name="startTime"

View File

@ -0,0 +1,132 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import InputField from "../InputField";
import { schoolTimetableSlotSchema, SchoolTimetableSlotSchema } from "@/lib/formValidationSchemas";
import { createSchoolTimetableSlot, updateSchoolTimetableSlot } from "@/lib/actions";
import { useFormState } from "react-dom";
import { Dispatch, SetStateAction, useEffect } from "react";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
import { useUser } from "@clerk/nextjs";
const SchoolTimetableSlotForm = ({
type,
data,
setOpen,
relatedData,
}: {
type: "create" | "update";
data?: any;
setOpen: Dispatch<SetStateAction<boolean>>;
relatedData?: any;
}) => {
const { user } = useUser();
const schoolId = user?.publicMetadata?.schoolId as string;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SchoolTimetableSlotSchema>({
resolver: zodResolver(schoolTimetableSlotSchema),
});
const [state, formAction] = useFormState(
type === "create" ? createSchoolTimetableSlot : updateSchoolTimetableSlot,
{
success: false,
error: false,
}
);
const onSubmit = handleSubmit((formData) => {
formAction({ ...formData, schoolId });
});
const router = useRouter();
useEffect(() => {
if (state.success) {
toast(`Timetable Slot has been ${type === "create" ? "created" : "updated"}!`);
setOpen(false);
router.refresh();
}
}, [state, router, type, setOpen]);
return (
<form className="flex flex-col gap-8" onSubmit={onSubmit}>
<h1 className="text-xl font-semibold">
{type === "create" ? "Create a new timetable slot" : "Update the timetable slot"}
</h1>
<div className="flex justify-between flex-wrap gap-4">
<InputField
label="Slot Name (e.g., Period 1, Registration)"
name="name"
defaultValue={data?.name}
register={register}
error={errors?.name}
/>
<InputField
label="Start Time (e.g., 08:00)"
name="startTime"
type="time"
defaultValue={data?.startTime}
register={register}
error={errors?.startTime}
/>
<InputField
label="End Time (e.g., 09:00)"
name="endTime"
type="time"
defaultValue={data?.endTime}
register={register}
error={errors?.endTime}
/>
<div className="flex flex-col gap-2 w-full md:w-1/4 justify-center">
<div className="flex items-center gap-2 mt-4">
<input
type="checkbox"
id="isTeachingSlot"
{...register("isTeachingSlot")}
defaultChecked={data?.isTeachingSlot ?? true}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="isTeachingSlot" className="text-sm text-gray-700">
Is a Teaching Slot?
</label>
</div>
{errors.isTeachingSlot?.message && (
<p className="text-xs text-red-400">
{errors.isTeachingSlot.message.toString()}
</p>
)}
</div>
{data && (
<InputField
label="Id"
name="id"
defaultValue={data?.id}
register={register}
error={errors?.id}
hidden
/>
)}
<input type="hidden" value={schoolId || ""} {...register("schoolId")} />
</div>
{state.error && (
<span className="text-red-500">Something went wrong!</span>
)}
<button className="bg-blue-400 text-white p-2 rounded-md">
{type === "create" ? "Create" : "Update"}
</button>
</form>
);
};
export default SchoolTimetableSlotForm;

View File

@ -11,6 +11,7 @@ import {
teacherSchema, teacherSchema,
TeacherSchema, TeacherSchema,
} from "@/lib/formValidationSchemas"; } from "@/lib/formValidationSchemas";
import { Controller } from "react-hook-form";
import { useFormState } from "react-dom"; import { useFormState } from "react-dom";
import { import {
createStudent, createStudent,
@ -35,10 +36,14 @@ const StudentForm = ({
}) => { }) => {
const { const {
register, register,
control,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<StudentSchema>({ } = useForm<StudentSchema>({
resolver: zodResolver(studentSchema), resolver: zodResolver(studentSchema),
defaultValues: {
classIds: data?.classes?.map((c: { classId: number }) => c.classId) ?? data?.studentClasses?.map((sc: { classId: number }) => sc.classId) ?? [],
},
}); });
const [img, setImg] = useState<any>(); const [img, setImg] = useState<any>();
@ -219,31 +224,44 @@ const StudentForm = ({
</p> </p>
)} )}
</div> </div>
<div className="flex flex-col gap-2 w-full md:w-1/4"> <div className="flex flex-col gap-2 w-full">
<label className="text-xs text-gray-500">Class</label> <label className="text-xs text-gray-500">Classes</label>
<select <Controller
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full" control={control}
{...register("classId")} name="classIds"
defaultValue={data?.classId} render={({ field: { value = [], onChange } }) => (
> <div className="flex flex-wrap gap-3">
{classes.map( {classes.map(
(classItem: { (classItem: {
id: number; id: number;
name: string; name: string;
capacity: number; capacity: number;
_count: { students: number }; _count: { students: number };
}) => ( }) => (
<option value={classItem.id} key={classItem.id}> <label key={classItem.id} className="flex items-center gap-2 text-sm cursor-pointer">
({classItem.name} -{" "} <input
{classItem._count.students + "/" + classItem.capacity}{" "} type="checkbox"
Capacity) checked={value.includes(classItem.id)}
</option> onChange={(e) => {
) if (e.target.checked) {
onChange([...value, classItem.id]);
} else {
onChange(value.filter((id) => id !== classItem.id));
}
}}
/>
<span>
{classItem.name} ({classItem._count.students}/{classItem.capacity})
</span>
</label>
)
)}
</div>
)} )}
</select> />
{errors.classId?.message && ( {errors.classIds?.message && (
<p className="text-xs text-red-400"> <p className="text-xs text-red-400">
{errors.classId.message.toString()} {errors.classIds.message.toString()}
</p> </p>
)} )}
</div> </div>

View File

@ -0,0 +1,115 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import InputField from "../InputField";
import { termSchema, TermSchema } from "@/lib/formValidationSchemas";
import { createTerm, updateTerm } from "@/lib/actions";
import { useFormState } from "react-dom";
import { Dispatch, SetStateAction, useEffect } from "react";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
import { useUser } from "@clerk/nextjs";
const TermForm = ({
type,
data,
setOpen,
relatedData,
}: {
type: "create" | "update";
data?: any;
setOpen: Dispatch<SetStateAction<boolean>>;
relatedData?: any;
}) => {
const { user } = useUser();
const schoolId = user?.publicMetadata?.schoolId as string;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TermSchema>({
resolver: zodResolver(termSchema),
});
const [state, formAction] = useFormState(
type === "create" ? createTerm : updateTerm,
{
success: false,
error: false,
}
);
const onSubmit = handleSubmit((formData) => {
formAction({ ...formData, schoolId });
});
const router = useRouter();
useEffect(() => {
if (state.success) {
toast(`Term has been ${type === "create" ? "created" : "updated"}!`);
setOpen(false);
router.refresh();
}
}, [state, router, type, setOpen]);
return (
<form className="flex flex-col gap-8" onSubmit={onSubmit}>
<h1 className="text-xl font-semibold">
{type === "create" ? "Create a new term" : "Update the term"}
</h1>
<div className="flex justify-between flex-wrap gap-4">
<InputField
label="Term name"
name="name"
defaultValue={data?.name}
register={register}
error={errors?.name}
/>
<InputField
label="Start Date"
name="startDate"
type="date"
defaultValue={
data?.startDate ? new Date(data.startDate).toISOString().split("T")[0] : ""
}
register={register}
error={errors?.startDate}
/>
<InputField
label="End Date"
name="endDate"
type="date"
defaultValue={
data?.endDate ? new Date(data.endDate).toISOString().split("T")[0] : ""
}
register={register}
error={errors?.endDate}
/>
{data && (
<InputField
label="Id"
name="id"
defaultValue={data?.id}
register={register}
error={errors?.id}
hidden
/>
)}
<input type="hidden" value={schoolId || ""} {...register("schoolId")} />
</div>
{state.error && (
<span className="text-red-500">Something went wrong!</span>
)}
<button className="bg-blue-400 text-white p-2 rounded-md">
{type === "create" ? "Create" : "Update"}
</button>
</form>
);
};
export default TermForm;

View File

@ -0,0 +1,194 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import InputField from "../InputField";
import { timetableEntrySchema, TimetableEntrySchema } from "@/lib/formValidationSchemas";
import { createTimetableEntry, updateTimetableEntry } from "@/lib/actions";
import { useFormState } from "react-dom";
import { Dispatch, SetStateAction, useEffect } from "react";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
import { useUser } from "@clerk/nextjs";
const TimetableEntryForm = ({
type,
data,
setOpen,
relatedData,
}: {
type: "create" | "update";
data?: any;
setOpen: Dispatch<SetStateAction<boolean>>;
relatedData?: any;
}) => {
const { user } = useUser();
const schoolId = user?.publicMetadata?.schoolId as string;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TimetableEntrySchema>({
resolver: zodResolver(timetableEntrySchema),
});
const [state, formAction] = useFormState(
type === "create" ? createTimetableEntry : updateTimetableEntry,
{
success: false,
error: false,
}
);
const onSubmit = handleSubmit((formData) => {
formAction(formData);
});
const router = useRouter();
useEffect(() => {
if (state.success) {
toast(`Timetable Entry has been ${type === "create" ? "created" : "updated"}!`);
setOpen(false);
router.refresh();
}
}, [state, router, type, setOpen]);
// relatedData structure: { classes, subjects, slots }
const { classes, subjects, slots } = relatedData || { classes: [], subjects: [], slots: [] };
return (
<form className="flex flex-col gap-8" onSubmit={onSubmit}>
<h1 className="text-xl font-semibold">
{type === "create" ? "Create a new entry" : "Update the entry"}
</h1>
<div className="flex justify-between flex-wrap gap-4">
{/* Template ID passed by the Template UI, either via prop or query string.
If data has timetableTemplateId, we default it and hide/disable it if needed,
or let the user select it if they are managing globally. For this view,
it's expected we'll inject it via data.
*/}
<div className="flex flex-col gap-2 w-full md:w-1/4">
<label className="text-xs text-gray-500">Template ID</label>
<input
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
{...register("timetableTemplateId")}
defaultValue={data?.teacherTimetableTemplateId ?? data?.timetableTemplateId}
readOnly={!!data?.timetableTemplateId}
/>
{errors.timetableTemplateId?.message && (
<p className="text-xs text-red-400">
{errors.timetableTemplateId.message.toString()}
</p>
)}
</div>
<div className="flex flex-col gap-2 w-full md:w-1/4">
<label className="text-xs text-gray-500">Day of Week</label>
<select
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
{...register("dayOfWeek")}
defaultValue={data?.dayOfWeek}
>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
<option value="7">Sunday</option>
</select>
{errors.dayOfWeek?.message && (
<p className="text-xs text-red-400">
{errors.dayOfWeek.message.toString()}
</p>
)}
</div>
<div className="flex flex-col gap-2 w-full md:w-1/4">
<label className="text-xs text-gray-500">Time Slot</label>
<select
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
{...register("schoolTimetableSlotId")}
defaultValue={data?.schoolTimetableSlotId}
>
{slots.map((slot: any) => (
<option value={slot.id} key={slot.id}>
{slot.name} ({slot.startTime} - {slot.endTime})
</option>
))}
</select>
{errors.schoolTimetableSlotId?.message && (
<p className="text-xs text-red-400">
{errors.schoolTimetableSlotId.message.toString()}
</p>
)}
</div>
<div className="flex flex-col gap-2 w-full md:w-1/4">
<label className="text-xs text-gray-500">Class</label>
<select
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
{...register("classId")}
defaultValue={data?.classId}
>
{classes.map((c: any) => (
<option value={c.id} key={c.id}>
{c.name}
</option>
))}
</select>
{errors.classId?.message && (
<p className="text-xs text-red-400">
{errors.classId.message.toString()}
</p>
)}
</div>
<div className="flex flex-col gap-2 w-full md:w-1/4">
<label className="text-xs text-gray-500">Subject</label>
<select
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
{...register("subjectId")}
defaultValue={data?.subjectId}
>
{subjects.map((s: any) => (
<option value={s.id} key={s.id}>
{s.name}
</option>
))}
</select>
{errors.subjectId?.message && (
<p className="text-xs text-red-400">
{errors.subjectId.message.toString()}
</p>
)}
</div>
{data?.id && (
<InputField
label="Id"
name="id"
defaultValue={data.id}
register={register}
error={errors?.id}
hidden
/>
)}
</div>
{state.error && (
<span className="text-red-500">Something went wrong!</span>
)}
<button className="bg-blue-400 text-white p-2 rounded-md">
{type === "create" ? "Create" : "Update"}
</button>
</form>
);
};
export default TimetableEntryForm;

View File

@ -0,0 +1,123 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import InputField from "../InputField";
import { timetableTemplateSchema, TimetableTemplateSchema } from "@/lib/formValidationSchemas";
import { createTimetableTemplate, updateTimetableTemplate } from "@/lib/actions";
import { useFormState } from "react-dom";
import { Dispatch, SetStateAction, useEffect } from "react";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
import { useUser } from "@clerk/nextjs";
const TimetableTemplateForm = ({
type,
data,
setOpen,
relatedData,
}: {
type: "create" | "update";
data?: any;
setOpen: Dispatch<SetStateAction<boolean>>;
relatedData?: any;
}) => {
const { user } = useUser();
const schoolId = user?.publicMetadata?.schoolId as string;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TimetableTemplateSchema>({
resolver: zodResolver(timetableTemplateSchema),
});
const [state, formAction] = useFormState(
type === "create" ? createTimetableTemplate : updateTimetableTemplate,
{
success: false,
error: false,
}
);
const onSubmit = handleSubmit((formData) => {
formAction({ ...formData, schoolId });
});
const router = useRouter();
useEffect(() => {
if (state.success) {
toast(`Template has been ${type === "create" ? "created" : "updated"}!`);
setOpen(false);
router.refresh();
}
}, [state, router, type, setOpen]);
const teachers: { id: string; name: string; surname: string }[] =
Array.isArray(relatedData?.teachers) ? relatedData.teachers : [];
return (
<form className="flex flex-col gap-8" onSubmit={onSubmit}>
<h1 className="text-xl font-semibold">
{type === "create" ? "Create a new template" : "Update the template"}
</h1>
<div className="flex justify-between flex-wrap gap-4">
<InputField
label="Template Name"
name="name"
defaultValue={data?.name}
register={register}
error={errors?.name}
/>
<div className="flex flex-col gap-2 w-full md:w-1/4">
<label className="text-xs text-gray-500">Teacher (Owner)</label>
<select
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
{...register("teacherId")}
defaultValue={data?.teacherId || (teachers[0]?.id ?? "")}
>
{teachers.length === 0 ? (
<option value="">No teachers available</option>
) : (
teachers.map((teacher) => (
<option value={teacher.id} key={teacher.id}>
{teacher.name + " " + teacher.surname}
</option>
))
)}
</select>
{errors.teacherId?.message && (
<p className="text-xs text-red-400">
{errors.teacherId.message.toString()}
</p>
)}
</div>
{data && (
<InputField
label="Id"
name="id"
defaultValue={data?.id}
register={register}
error={errors?.id}
hidden
/>
)}
<input type="hidden" value={schoolId || ""} {...register("schoolId")} />
</div>
{state.error && (
<span className="text-red-500">Something went wrong!</span>
)}
<button className="bg-blue-400 text-white p-2 rounded-md">
{type === "create" ? "Create" : "Update"}
</button>
</form>
);
};
export default TimetableTemplateForm;

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ export const subjectSchema = z.object({
id: z.coerce.number().optional(), id: z.coerce.number().optional(),
name: z.string().min(1, { message: "Subject name is required!" }), name: z.string().min(1, { message: "Subject name is required!" }),
teachers: z.array(z.string()), //teacher ids teachers: z.array(z.string()), //teacher ids
schoolId: z.string(),
}); });
export type SubjectSchema = z.infer<typeof subjectSchema>; export type SubjectSchema = z.infer<typeof subjectSchema>;
@ -14,6 +15,7 @@ export const classSchema = z.object({
capacity: z.coerce.number().min(1, { message: "Capacity name is required!" }), capacity: z.coerce.number().min(1, { message: "Capacity name is required!" }),
gradeId: z.coerce.number().min(1, { message: "Grade name is required!" }), gradeId: z.coerce.number().min(1, { message: "Grade name is required!" }),
supervisorId: z.coerce.string().optional(), supervisorId: z.coerce.string().optional(),
schoolId: z.string(),
}); });
export type ClassSchema = z.infer<typeof classSchema>; export type ClassSchema = z.infer<typeof classSchema>;
@ -72,8 +74,9 @@ export const studentSchema = z.object({
birthday: z.coerce.date({ message: "Birthday is required!" }), birthday: z.coerce.date({ message: "Birthday is required!" }),
sex: z.enum(["MALE", "FEMALE"], { message: "Sex is required!" }), sex: z.enum(["MALE", "FEMALE"], { message: "Sex is required!" }),
gradeId: z.coerce.number().min(1, { message: "Grade is required!" }), gradeId: z.coerce.number().min(1, { message: "Grade is required!" }),
classId: z.coerce.number().min(1, { message: "Class is required!" }), classIds: z.array(z.coerce.number()).min(1, { message: "At least one class is required!" }),
parentId: z.string().min(1, { message: "Parent Id is required!" }), parentId: z.string().min(1, { message: "Parent Id is required!" }),
schoolId: z.string(),
}); });
export type StudentSchema = z.infer<typeof studentSchema>; export type StudentSchema = z.infer<typeof studentSchema>;
@ -84,6 +87,7 @@ export const examSchema = z.object({
startTime: z.coerce.date({ message: "Start time is required!" }), startTime: z.coerce.date({ message: "Start time is required!" }),
endTime: z.coerce.date({ message: "End time is required!" }), endTime: z.coerce.date({ message: "End time is required!" }),
lessonId: z.coerce.number({ message: "Lesson is required!" }), lessonId: z.coerce.number({ message: "Lesson is required!" }),
schoolId: z.string(),
}); });
export type ExamSchema = z.infer<typeof examSchema>; export type ExamSchema = z.infer<typeof examSchema>;
@ -91,12 +95,12 @@ export type ExamSchema = z.infer<typeof examSchema>;
export const lessonSchema = z.object({ export const lessonSchema = z.object({
id: z.coerce.number().optional(), id: z.coerce.number().optional(),
name: z.string().min(1, { message: "Lesson name is required!" }), name: z.string().min(1, { message: "Lesson name is required!" }),
day: z.enum(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"], { message: "Day is required!" }),
startTime: z.coerce.date({ message: "Start time is required!" }), startTime: z.coerce.date({ message: "Start time is required!" }),
endTime: z.coerce.date({ message: "End time is required!" }), endTime: z.coerce.date({ message: "End time is required!" }),
subjectId: z.coerce.number({ message: "Subject is required!" }), subjectId: z.coerce.number({ message: "Subject is required!" }),
classId: z.coerce.number({ message: "Class is required!" }), classId: z.coerce.number({ message: "Class is required!" }),
teacherId: z.string({ message: "Teacher is required!" }), teacherId: z.string({ message: "Teacher is required!" }),
schoolId: z.string(),
}); });
export type LessonSchema = z.infer<typeof lessonSchema>; export type LessonSchema = z.infer<typeof lessonSchema>;
@ -107,6 +111,7 @@ export const assignmentSchema = z.object({
startDate: z.coerce.date({ message: "Start date is required!" }), startDate: z.coerce.date({ message: "Start date is required!" }),
dueDate: z.coerce.date({ message: "Due date is required!" }), dueDate: z.coerce.date({ message: "Due date is required!" }),
lessonId: z.coerce.number({ message: "Lesson is required!" }), lessonId: z.coerce.number({ message: "Lesson is required!" }),
schoolId: z.string(),
}); });
export type AssignmentSchema = z.infer<typeof assignmentSchema>; export type AssignmentSchema = z.infer<typeof assignmentSchema>;
@ -117,6 +122,7 @@ export const resultSchema = z.object({
studentId: z.string({ message: "Student is required!" }), studentId: z.string({ message: "Student is required!" }),
examId: z.coerce.number().optional(), examId: z.coerce.number().optional(),
assignmentId: z.coerce.number().optional(), assignmentId: z.coerce.number().optional(),
schoolId: z.string(),
}).refine(data => data.examId || data.assignmentId, { }).refine(data => data.examId || data.assignmentId, {
message: "Either Exam or Assignment is required", message: "Either Exam or Assignment is required",
path: ["examId"], path: ["examId"],
@ -131,6 +137,7 @@ export const eventSchema = z.object({
startTime: z.coerce.date({ message: "Start time is required!" }), startTime: z.coerce.date({ message: "Start time is required!" }),
endTime: z.coerce.date({ message: "End time is required!" }), endTime: z.coerce.date({ message: "End time is required!" }),
classId: z.coerce.number().optional(), classId: z.coerce.number().optional(),
schoolId: z.string(),
}); });
export type EventSchema = z.infer<typeof eventSchema>; export type EventSchema = z.infer<typeof eventSchema>;
@ -141,6 +148,64 @@ export const announcementSchema = z.object({
description: z.string().min(1, { message: "Description is required!" }), description: z.string().min(1, { message: "Description is required!" }),
date: z.coerce.date({ message: "Date is required!" }), date: z.coerce.date({ message: "Date is required!" }),
classId: z.coerce.number().optional(), classId: z.coerce.number().optional(),
schoolId: z.string(),
}); });
export type AnnouncementSchema = z.infer<typeof announcementSchema>; export type AnnouncementSchema = z.infer<typeof announcementSchema>;
export const termSchema = z.object({
id: z.coerce.number().optional(),
name: z.string().min(1, { message: "Term name is required!" }),
startDate: z.coerce.date({ message: "Start date is required!" }),
endDate: z.coerce.date({ message: "End date is required!" }),
schoolId: z.string(),
academicYearId: z.coerce.number().optional(),
});
export type TermSchema = z.infer<typeof termSchema>;
export const holidaySchema = z.object({
id: z.coerce.number().optional(),
name: z.string().min(1, { message: "Holiday name is required!" }),
startDate: z.coerce.date({ message: "Start date is required!" }),
endDate: z.coerce.date({ message: "End date is required!" }),
schoolId: z.string(),
academicYearId: z.coerce.number().optional(),
});
export type HolidaySchema = z.infer<typeof holidaySchema>;
export const schoolTimetableSlotSchema = z.object({
id: z.coerce.number().optional(),
name: z.string().min(1, { message: "Slot name is required!" }),
startTime: z.string().min(1, { message: "Start time is required!" }),
endTime: z.string().min(1, { message: "End time is required!" }),
isTeachingSlot: z.boolean().default(true),
schoolId: z.string(),
schoolTimetableId: z.coerce.number().optional(),
position: z.coerce.number().optional(),
});
export type SchoolTimetableSlotSchema = z.infer<typeof schoolTimetableSlotSchema>;
export const timetableTemplateSchema = z.object({
id: z.coerce.number().optional(),
name: z.string().min(1, { message: "Template name is required!" }),
teacherId: z.string().min(1, { message: "Teacher is required!" }),
schoolId: z.string(),
schoolTimetableId: z.coerce.number().optional(),
});
export type TimetableTemplateSchema = z.infer<typeof timetableTemplateSchema>;
export const timetableEntrySchema = z.object({
id: z.coerce.number().optional(),
timetableTemplateId: z.coerce.number({ message: "Template ID is required!" }),
teacherTimetableTemplateId: z.coerce.number().optional(),
schoolTimetableSlotId: z.coerce.number({ message: "Time Slot is required!" }),
classId: z.coerce.number({ message: "Class is required!" }),
subjectId: z.coerce.number({ message: "Subject is required!" }),
dayOfWeek: z.coerce.number().min(1).max(7, { message: "Invalid day of week!" }),
});
export type TimetableEntrySchema = z.infer<typeof timetableEntrySchema>;

View File

@ -7,6 +7,7 @@ type RouteAccessMap = {
export const routeAccessMap: RouteAccessMap = { export const routeAccessMap: RouteAccessMap = {
"/admin(.*)": ["admin"], "/admin(.*)": ["admin"],
"/student(.*)": ["student"], "/student(.*)": ["student"],
"/teacher-sign-up": [], // public: allow unauthenticated (middleware skips redirect when role is null)
"/teacher(.*)": ["teacher"], "/teacher(.*)": ["teacher"],
"/parent(.*)": ["parent"], "/parent(.*)": ["parent"],
"/list/teachers": ["admin", "teacher"], "/list/teachers": ["admin", "teacher"],

View File

@ -0,0 +1,95 @@
"use server";
import { getSupabaseClient } from "./supabase";
export type SnapshotType = "planned" | "live" | "final";
// Get the whiteboard record for a lesson
export const getLessonWhiteboard = async (lessonId: number) => {
try {
const supabase = await getSupabaseClient();
const { data, error } = await supabase
.from("LessonWhiteboard")
.select("*")
.eq("lessonId", lessonId)
.single();
// TODO: This doesn't feel right...
if (error) {
if (error.code === "PGRST116") {
// No whiteboard exists yet for this lesson. Try to create one lazily.
const { data: created, error: createError } = await supabase
.from("LessonWhiteboard")
.insert({ lessonId })
.select()
.single();
if (createError) {
console.error("Failed to create LessonWhiteboard record:", createError);
return { success: false, data: null };
}
return { success: true, data: created };
}
throw error;
}
return { success: true, data };
} catch (err) {
console.error("Failed to fetch whiteboard:", err);
return { success: false, data: null };
}
};
// Save a specific snapshot state (planned, live, or final)
export const saveLessonSnapshot = async (
lessonId: number,
snapshotType: SnapshotType,
snapshotData: any
) => {
try {
const supabase = await getSupabaseClient();
const updatePayload: Record<string, any> = {};
if (snapshotType === "planned") updatePayload.plannedSnapshotData = snapshotData;
if (snapshotType === "live") updatePayload.liveSnapshotData = snapshotData;
if (snapshotType === "final") updatePayload.finalSnapshotData = snapshotData;
const { error } = await supabase
.from("LessonWhiteboard")
.update(updatePayload)
.eq("lessonId", lessonId);
if (error) throw error;
return { success: true };
} catch (err) {
console.error("Failed to save snapshot:", err);
return { success: false };
}
};
// Duplicate a planned snapshot to a new lesson
export const duplicateLessonPlan = async (sourceLessonId: number, targetLessonId: number) => {
try {
const supabase = await getSupabaseClient();
const { data: sourceBoard, error: fetchError } = await supabase
.from("LessonWhiteboard")
.select("plannedSnapshotData")
.eq("lessonId", sourceLessonId)
.single();
if (fetchError) throw fetchError;
if (!sourceBoard?.plannedSnapshotData) throw new Error("No planned snapshot found to copy.");
const { error: updateError } = await supabase
.from("LessonWhiteboard")
.update({ plannedSnapshotData: sourceBoard.plannedSnapshotData })
.eq("lessonId", targetLessonId);
if (updateError) throw updateError;
return { success: true };
} catch (err) {
console.error("Failed to duplicate lesson plan:", err);
return { success: false };
}
};

View File

@ -17,7 +17,7 @@ export default clerkMiddleware((auth, req) => {
const role = (sessionClaims?.metadata as { role?: string })?.role; const role = (sessionClaims?.metadata as { role?: string })?.role;
for (const { matcher, allowedRoles } of matchers) { for (const { matcher, allowedRoles } of matchers) {
if (matcher(req) && !allowedRoles.includes(role!)) { if (matcher(req) && role != null && !allowedRoles.includes(role)) {
return NextResponse.redirect(new URL(`/${role}`, req.url)); return NextResponse.redirect(new URL(`/${role}`, req.url));
} }
} }

View File

@ -37,17 +37,28 @@ export type Database = {
Admin: { Admin: {
Row: { Row: {
id: string id: string
schoolId: string
username: string username: string
} }
Insert: { Insert: {
id: string id: string
schoolId: string
username: string username: string
} }
Update: { Update: {
id?: string id?: string
schoolId?: string
username?: string username?: string
} }
Relationships: [] Relationships: [
{
foreignKeyName: "Admin_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
]
} }
Announcement: { Announcement: {
Row: { Row: {
@ -55,6 +66,7 @@ export type Database = {
date: string date: string
description: string description: string
id: number id: number
schoolId: string
title: string title: string
} }
Insert: { Insert: {
@ -62,6 +74,7 @@ export type Database = {
date: string date: string
description: string description: string
id?: number id?: number
schoolId: string
title: string title: string
} }
Update: { Update: {
@ -69,6 +82,7 @@ export type Database = {
date?: string date?: string
description?: string description?: string
id?: number id?: number
schoolId?: string
title?: string title?: string
} }
Relationships: [ Relationships: [
@ -79,6 +93,13 @@ export type Database = {
referencedRelation: "Class" referencedRelation: "Class"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Announcement_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
] ]
} }
Assignment: { Assignment: {
@ -86,6 +107,7 @@ export type Database = {
dueDate: string dueDate: string
id: number id: number
lessonId: number lessonId: number
schoolId: string
startDate: string startDate: string
title: string title: string
} }
@ -93,6 +115,7 @@ export type Database = {
dueDate: string dueDate: string
id?: number id?: number
lessonId: number lessonId: number
schoolId: string
startDate: string startDate: string
title: string title: string
} }
@ -100,6 +123,7 @@ export type Database = {
dueDate?: string dueDate?: string
id?: number id?: number
lessonId?: number lessonId?: number
schoolId?: string
startDate?: string startDate?: string
title?: string title?: string
} }
@ -111,6 +135,13 @@ export type Database = {
referencedRelation: "Lesson" referencedRelation: "Lesson"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Assignment_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
] ]
} }
Attendance: { Attendance: {
@ -119,6 +150,7 @@ export type Database = {
id: number id: number
lessonId: number lessonId: number
present: boolean present: boolean
schoolId: string
studentId: string studentId: string
} }
Insert: { Insert: {
@ -126,6 +158,7 @@ export type Database = {
id?: number id?: number
lessonId: number lessonId: number
present: boolean present: boolean
schoolId: string
studentId: string studentId: string
} }
Update: { Update: {
@ -133,6 +166,7 @@ export type Database = {
id?: number id?: number
lessonId?: number lessonId?: number
present?: boolean present?: boolean
schoolId?: string
studentId?: string studentId?: string
} }
Relationships: [ Relationships: [
@ -143,6 +177,13 @@ export type Database = {
referencedRelation: "Lesson" referencedRelation: "Lesson"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Attendance_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{ {
foreignKeyName: "Attendance_studentId_fkey" foreignKeyName: "Attendance_studentId_fkey"
columns: ["studentId"] columns: ["studentId"]
@ -158,6 +199,7 @@ export type Database = {
gradeId: number gradeId: number
id: number id: number
name: string name: string
schoolId: string
supervisorId: string | null supervisorId: string | null
} }
Insert: { Insert: {
@ -165,6 +207,7 @@ export type Database = {
gradeId: number gradeId: number
id?: number id?: number
name: string name: string
schoolId: string
supervisorId?: string | null supervisorId?: string | null
} }
Update: { Update: {
@ -172,6 +215,7 @@ export type Database = {
gradeId?: number gradeId?: number
id?: number id?: number
name?: string name?: string
schoolId?: string
supervisorId?: string | null supervisorId?: string | null
} }
Relationships: [ Relationships: [
@ -182,6 +226,13 @@ export type Database = {
referencedRelation: "Grade" referencedRelation: "Grade"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Class_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{ {
foreignKeyName: "Class_supervisorId_fkey" foreignKeyName: "Class_supervisorId_fkey"
columns: ["supervisorId"] columns: ["supervisorId"]
@ -197,6 +248,7 @@ export type Database = {
description: string description: string
endTime: string endTime: string
id: number id: number
schoolId: string
startTime: string startTime: string
title: string title: string
} }
@ -205,6 +257,7 @@ export type Database = {
description: string description: string
endTime: string endTime: string
id?: number id?: number
schoolId: string
startTime: string startTime: string
title: string title: string
} }
@ -213,6 +266,7 @@ export type Database = {
description?: string description?: string
endTime?: string endTime?: string
id?: number id?: number
schoolId?: string
startTime?: string startTime?: string
title?: string title?: string
} }
@ -224,6 +278,13 @@ export type Database = {
referencedRelation: "Class" referencedRelation: "Class"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Event_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
] ]
} }
Exam: { Exam: {
@ -231,6 +292,7 @@ export type Database = {
endTime: string endTime: string
id: number id: number
lessonId: number lessonId: number
schoolId: string
startTime: string startTime: string
title: string title: string
} }
@ -238,6 +300,7 @@ export type Database = {
endTime: string endTime: string
id?: number id?: number
lessonId: number lessonId: number
schoolId: string
startTime: string startTime: string
title: string title: string
} }
@ -245,6 +308,7 @@ export type Database = {
endTime?: string endTime?: string
id?: number id?: number
lessonId?: number lessonId?: number
schoolId?: string
startTime?: string startTime?: string
title?: string title?: string
} }
@ -256,6 +320,13 @@ export type Database = {
referencedRelation: "Lesson" referencedRelation: "Lesson"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Exam_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
] ]
} }
Grade: { Grade: {
@ -273,33 +344,81 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
Holiday: {
Row: {
id: number
schoolId: string
name: string
startDate: string
endDate: string
academicYearId: number | null
createdById: string | null
createdAt: string
}
Insert: {
id?: number
schoolId: string
name: string
startDate: string
endDate: string
academicYearId?: number | null
createdById?: string | null
createdAt?: string
}
Update: {
id?: number
schoolId?: string
name?: string
startDate?: string
endDate?: string
academicYearId?: number | null
createdById?: string | null
createdAt?: string
}
Relationships: [
{
foreignKeyName: "Holiday_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{
foreignKeyName: "Holiday_academicYearId_fkey"
columns: ["academicYearId"]
isOneToOne: false
referencedRelation: "AcademicYear"
referencedColumns: ["id"]
},
]
}
Lesson: { Lesson: {
Row: { Row: {
classId: number classId: number
day: Database["public"]["Enums"]["Day"]
endTime: string endTime: string
id: number id: number
name: string name: string
schoolId: string
startTime: string startTime: string
subjectId: number subjectId: number
teacherId: string teacherId: string
} }
Insert: { Insert: {
classId: number classId: number
day: Database["public"]["Enums"]["Day"]
endTime: string endTime: string
id?: number id?: number
name: string name: string
schoolId: string
startTime: string startTime: string
subjectId: number subjectId: number
teacherId: string teacherId: string
} }
Update: { Update: {
classId?: number classId?: number
day?: Database["public"]["Enums"]["Day"]
endTime?: string endTime?: string
id?: number id?: number
name?: string name?: string
schoolId?: string
startTime?: string startTime?: string
subjectId?: number subjectId?: number
teacherId?: string teacherId?: string
@ -312,6 +431,13 @@ export type Database = {
referencedRelation: "Class" referencedRelation: "Class"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Lesson_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{ {
foreignKeyName: "Lesson_subjectId_fkey" foreignKeyName: "Lesson_subjectId_fkey"
columns: ["subjectId"] columns: ["subjectId"]
@ -328,6 +454,47 @@ export type Database = {
}, },
] ]
} }
LessonWhiteboard: {
Row: {
createdAt: string
finalSnapshotData: Json | null
id: number
lessonId: number
liveSnapshotData: Json | null
plannedSnapshotData: Json | null
title: string | null
updatedAt: string
}
Insert: {
createdAt?: string
finalSnapshotData?: Json | null
id?: number
lessonId: number
liveSnapshotData?: Json | null
plannedSnapshotData?: Json | null
title?: string | null
updatedAt?: string
}
Update: {
createdAt?: string
finalSnapshotData?: Json | null
id?: number
lessonId?: number
liveSnapshotData?: Json | null
plannedSnapshotData?: Json | null
title?: string | null
updatedAt?: string
}
Relationships: [
{
foreignKeyName: "LessonWhiteboard_lessonId_fkey"
columns: ["lessonId"]
isOneToOne: false
referencedRelation: "Lesson"
referencedColumns: ["id"]
},
]
}
Parent: { Parent: {
Row: { Row: {
address: string address: string
@ -336,6 +503,7 @@ export type Database = {
id: string id: string
name: string name: string
phone: string phone: string
schoolId: string
surname: string surname: string
username: string username: string
} }
@ -346,6 +514,7 @@ export type Database = {
id: string id: string
name: string name: string
phone: string phone: string
schoolId: string
surname: string surname: string
username: string username: string
} }
@ -356,16 +525,26 @@ export type Database = {
id?: string id?: string
name?: string name?: string
phone?: string phone?: string
schoolId?: string
surname?: string surname?: string
username?: string username?: string
} }
Relationships: [] Relationships: [
{
foreignKeyName: "Parent_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
]
} }
Result: { Result: {
Row: { Row: {
assignmentId: number | null assignmentId: number | null
examId: number | null examId: number | null
id: number id: number
schoolId: string
score: number score: number
studentId: string studentId: string
} }
@ -373,6 +552,7 @@ export type Database = {
assignmentId?: number | null assignmentId?: number | null
examId?: number | null examId?: number | null
id?: number id?: number
schoolId: string
score: number score: number
studentId: string studentId: string
} }
@ -380,6 +560,7 @@ export type Database = {
assignmentId?: number | null assignmentId?: number | null
examId?: number | null examId?: number | null
id?: number id?: number
schoolId?: string
score?: number score?: number
studentId?: string studentId?: string
} }
@ -398,6 +579,13 @@ export type Database = {
referencedRelation: "Exam" referencedRelation: "Exam"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Result_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{ {
foreignKeyName: "Result_studentId_fkey" foreignKeyName: "Result_studentId_fkey"
columns: ["studentId"] columns: ["studentId"]
@ -407,12 +595,169 @@ export type Database = {
}, },
] ]
} }
School: {
Row: {
adminId: string
createdAt: string
id: string
name: string
type: Database["public"]["Enums"]["SchoolType"]
}
Insert: {
adminId: string
createdAt?: string
id: string
name: string
type?: Database["public"]["Enums"]["SchoolType"]
}
Update: {
adminId?: string
createdAt?: string
id?: string
name?: string
type?: Database["public"]["Enums"]["SchoolType"]
}
Relationships: []
}
AcademicYear: {
Row: {
id: number
schoolId: string
startYear: number
endYear: number
name: string
createdById: string | null
createdAt: string
}
Insert: {
id?: number
schoolId: string
startYear: number
endYear: number
name: string
createdById?: string | null
createdAt?: string
}
Update: {
id?: number
schoolId?: string
startYear?: number
endYear?: number
name?: string
createdById?: string | null
createdAt?: string
}
Relationships: [
{
foreignKeyName: "AcademicYear_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
]
}
SchoolTimetable: {
Row: {
id: number
academicYearId: number
schoolId: string
name: string
createdById: string | null
createdAt: string
}
Insert: {
id?: number
academicYearId: number
schoolId: string
name: string
createdById?: string | null
createdAt?: string
}
Update: {
id?: number
academicYearId?: number
schoolId?: string
name?: string
createdById?: string | null
createdAt?: string
}
Relationships: [
{
foreignKeyName: "SchoolTimetable_academicYearId_fkey"
columns: ["academicYearId"]
isOneToOne: false
referencedRelation: "AcademicYear"
referencedColumns: ["id"]
},
{
foreignKeyName: "SchoolTimetable_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
]
}
SchoolTimetableSlot: {
Row: {
id: number
schoolId: string
schoolTimetableId: number
name: string
startTime: string
endTime: string
isTeachingSlot: boolean
position: number
createdById: string | null
createdAt: string
}
Insert: {
id?: number
schoolId: string
schoolTimetableId: number
name: string
startTime: string
endTime: string
isTeachingSlot?: boolean
position: number
createdById?: string | null
createdAt?: string
}
Update: {
id?: number
schoolId?: string
schoolTimetableId?: number
name?: string
startTime?: string
endTime?: string
isTeachingSlot?: boolean
position?: number
createdById?: string | null
createdAt?: string
}
Relationships: [
{
foreignKeyName: "SchoolTimetableSlot_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{
foreignKeyName: "SchoolTimetableSlot_schoolTimetableId_fkey"
columns: ["schoolTimetableId"]
isOneToOne: false
referencedRelation: "SchoolTimetable"
referencedColumns: ["id"]
},
]
}
Student: { Student: {
Row: { Row: {
address: string address: string
birthday: string birthday: string
bloodType: string bloodType: string
classId: number
createdAt: string createdAt: string
email: string | null email: string | null
gradeId: number gradeId: number
@ -421,6 +766,7 @@ export type Database = {
name: string name: string
parentId: string parentId: string
phone: string | null phone: string | null
schoolId: string
sex: Database["public"]["Enums"]["UserSex"] sex: Database["public"]["Enums"]["UserSex"]
surname: string surname: string
username: string username: string
@ -429,7 +775,6 @@ export type Database = {
address: string address: string
birthday: string birthday: string
bloodType: string bloodType: string
classId: number
createdAt?: string createdAt?: string
email?: string | null email?: string | null
gradeId: number gradeId: number
@ -438,6 +783,7 @@ export type Database = {
name: string name: string
parentId: string parentId: string
phone?: string | null phone?: string | null
schoolId: string
sex: Database["public"]["Enums"]["UserSex"] sex: Database["public"]["Enums"]["UserSex"]
surname: string surname: string
username: string username: string
@ -446,7 +792,6 @@ export type Database = {
address?: string address?: string
birthday?: string birthday?: string
bloodType?: string bloodType?: string
classId?: number
createdAt?: string createdAt?: string
email?: string | null email?: string | null
gradeId?: number gradeId?: number
@ -455,18 +800,12 @@ export type Database = {
name?: string name?: string
parentId?: string parentId?: string
phone?: string | null phone?: string | null
schoolId?: string
sex?: Database["public"]["Enums"]["UserSex"] sex?: Database["public"]["Enums"]["UserSex"]
surname?: string surname?: string
username?: string username?: string
} }
Relationships: [ Relationships: [
{
foreignKeyName: "Student_classId_fkey"
columns: ["classId"]
isOneToOne: false
referencedRelation: "Class"
referencedColumns: ["id"]
},
{ {
foreignKeyName: "Student_gradeId_fkey" foreignKeyName: "Student_gradeId_fkey"
columns: ["gradeId"] columns: ["gradeId"]
@ -481,22 +820,70 @@ export type Database = {
referencedRelation: "Parent" referencedRelation: "Parent"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "Student_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
]
}
StudentClass: {
Row: {
studentId: string
classId: number
}
Insert: {
studentId: string
classId: number
}
Update: {
studentId?: string
classId?: number
}
Relationships: [
{
foreignKeyName: "StudentClass_studentId_fkey"
columns: ["studentId"]
isOneToOne: false
referencedRelation: "Student"
referencedColumns: ["id"]
},
{
foreignKeyName: "StudentClass_classId_fkey"
columns: ["classId"]
isOneToOne: false
referencedRelation: "Class"
referencedColumns: ["id"]
},
] ]
} }
Subject: { Subject: {
Row: { Row: {
id: number id: number
name: string name: string
schoolId: string
} }
Insert: { Insert: {
id?: number id?: number
name: string name: string
schoolId: string
} }
Update: { Update: {
id?: number id?: number
name?: string name?: string
schoolId?: string
} }
Relationships: [] Relationships: [
{
foreignKeyName: "Subject_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
]
} }
Teacher: { Teacher: {
Row: { Row: {
@ -543,6 +930,42 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
TeacherSchool: {
Row: {
id: number
isManaged: boolean
schoolId: string
teacherId: string
}
Insert: {
id?: number
isManaged?: boolean
schoolId: string
teacherId: string
}
Update: {
id?: number
isManaged?: boolean
schoolId?: string
teacherId?: string
}
Relationships: [
{
foreignKeyName: "TeacherSchool_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{
foreignKeyName: "TeacherSchool_teacherId_fkey"
columns: ["teacherId"]
isOneToOne: false
referencedRelation: "Teacher"
referencedColumns: ["id"]
},
]
}
TeacherSubject: { TeacherSubject: {
Row: { Row: {
assignedAt: string | null assignedAt: string | null
@ -582,6 +1005,162 @@ export type Database = {
}, },
] ]
} }
Term: {
Row: {
id: number
schoolId: string
academicYearId: number
name: string
startDate: string
endDate: string
createdById: string | null
createdAt: string
}
Insert: {
id?: number
schoolId: string
academicYearId: number
name: string
startDate: string
endDate: string
createdById?: string | null
createdAt?: string
}
Update: {
id?: number
schoolId?: string
academicYearId?: number
name?: string
startDate?: string
endDate?: string
createdById?: string | null
createdAt?: string
}
Relationships: [
{
foreignKeyName: "Term_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{
foreignKeyName: "Term_academicYearId_fkey"
columns: ["academicYearId"]
isOneToOne: false
referencedRelation: "AcademicYear"
referencedColumns: ["id"]
},
]
}
TeacherTimetableEntry: {
Row: {
id: number
teacherTimetableTemplateId: number
schoolTimetableSlotId: number
classId: number
subjectId: number
dayOfWeek: number
}
Insert: {
id?: number
teacherTimetableTemplateId: number
schoolTimetableSlotId: number
classId: number
subjectId: number
dayOfWeek: number
}
Update: {
id?: number
teacherTimetableTemplateId?: number
schoolTimetableSlotId?: number
classId?: number
subjectId?: number
dayOfWeek?: number
}
Relationships: [
{
foreignKeyName: "TeacherTimetableEntry_classId_fkey"
columns: ["classId"]
isOneToOne: false
referencedRelation: "Class"
referencedColumns: ["id"]
},
{
foreignKeyName: "TeacherTimetableEntry_schoolTimetableSlotId_fkey"
columns: ["schoolTimetableSlotId"]
isOneToOne: false
referencedRelation: "SchoolTimetableSlot"
referencedColumns: ["id"]
},
{
foreignKeyName: "TeacherTimetableEntry_subjectId_fkey"
columns: ["subjectId"]
isOneToOne: false
referencedRelation: "Subject"
referencedColumns: ["id"]
},
{
foreignKeyName: "TeacherTimetableEntry_teacherTimetableTemplateId_fkey"
columns: ["teacherTimetableTemplateId"]
isOneToOne: false
referencedRelation: "TeacherTimetableTemplate"
referencedColumns: ["id"]
},
]
}
TeacherTimetableTemplate: {
Row: {
id: number
schoolId: string
schoolTimetableId: number
name: string
teacherId: string
createdById: string | null
createdAt: string
}
Insert: {
id?: number
schoolId: string
schoolTimetableId: number
name: string
teacherId: string
createdById?: string | null
createdAt?: string
}
Update: {
id?: number
schoolId?: string
schoolTimetableId?: number
name?: string
teacherId?: string
createdById?: string | null
createdAt?: string
}
Relationships: [
{
foreignKeyName: "TeacherTimetableTemplate_schoolId_fkey"
columns: ["schoolId"]
isOneToOne: false
referencedRelation: "School"
referencedColumns: ["id"]
},
{
foreignKeyName: "TeacherTimetableTemplate_schoolTimetableId_fkey"
columns: ["schoolTimetableId"]
isOneToOne: false
referencedRelation: "SchoolTimetable"
referencedColumns: ["id"]
},
{
foreignKeyName: "TeacherTimetableTemplate_teacherId_fkey"
columns: ["teacherId"]
isOneToOne: false
referencedRelation: "Teacher"
referencedColumns: ["id"]
},
]
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
@ -599,6 +1178,7 @@ export type Database = {
} }
Enums: { Enums: {
Day: "MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY" Day: "MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY"
SchoolType: "MANAGED" | "INDEPENDENT" | "AGENCY"
UserSex: "MALE" | "FEMALE" UserSex: "MALE" | "FEMALE"
} }
CompositeTypes: { CompositeTypes: {
@ -731,6 +1311,7 @@ export const Constants = {
public: { public: {
Enums: { Enums: {
Day: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"], Day: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"],
SchoolType: ["MANAGED", "INDEPENDENT", "AGENCY"],
UserSex: ["MALE", "FEMALE"], UserSex: ["MALE", "FEMALE"],
}, },
}, },

View File

@ -0,0 +1,46 @@
{
"$schema": "https://raw.githubusercontent.com/dineug/erd-editor/main/json-schema/schema.json",
"version": "3.0.0",
"settings": {
"width": 2000,
"height": 2000,
"scrollTop": 0,
"scrollLeft": 0,
"zoomLevel": 1,
"show": 431,
"database": 4,
"databaseName": "test",
"canvasType": "settings",
"language": 1,
"tableNameCase": 4,
"columnNameCase": 2,
"bracketType": 1,
"relationshipDataTypeSync": true,
"relationshipOptimization": false,
"columnOrder": [
1,
2,
4,
8,
16,
32,
64
],
"maxWidthComment": -1,
"ignoreSaveSettings": 0
},
"doc": {
"tableIds": [],
"relationshipIds": [],
"indexIds": [],
"memoIds": []
},
"collections": {
"tableEntities": {},
"tableColumnEntities": {},
"relationshipEntities": {},
"indexEntities": {},
"indexColumnEntities": {},
"memoEntities": {}
}
}

View File

@ -0,0 +1,19 @@
-- CreateTable LessonWhiteboard
CREATE TABLE "LessonWhiteboard" (
"id" SERIAL NOT NULL,
"lessonId" INTEGER NOT NULL,
"title" TEXT,
"plannedSnapshotData" JSONB,
"liveSnapshotData" JSONB,
"finalSnapshotData" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LessonWhiteboard_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "LessonWhiteboard" ADD CONSTRAINT "LessonWhiteboard_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE UNIQUE INDEX "LessonWhiteboard_lessonId_key" ON "LessonWhiteboard"("lessonId");

View File

@ -0,0 +1,15 @@
ALTER TABLE "LessonWhiteboard" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins have full access" ON "LessonWhiteboard" FOR ALL USING (is_admin());
CREATE POLICY "Users can view permitted lesson whiteboards" ON "LessonWhiteboard" FOR SELECT USING (
is_admin() OR "lessonId" IN (SELECT auth_user_lessons())
);
CREATE POLICY "Teachers can update lesson whiteboards" ON "LessonWhiteboard" FOR UPDATE USING (
is_admin() OR ("lessonId" IN (SELECT auth_user_lessons()) AND requesting_user_role() = 'teacher')
);
CREATE POLICY "Teachers can insert lesson whiteboards" ON "LessonWhiteboard" FOR INSERT WITH CHECK (
is_admin() OR ("lessonId" IN (SELECT auth_user_lessons()) AND requesting_user_role() = 'teacher')
);

View File

@ -0,0 +1,119 @@
-- Migration: Schools, Agencies, and Timetables
-- 1. Create SchoolType Enum
CREATE TYPE "SchoolType" AS ENUM ('MANAGED', 'INDEPENDENT', 'AGENCY');
-- 2. Create School Table
CREATE TABLE "School" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "SchoolType" NOT NULL DEFAULT 'MANAGED',
"adminId" TEXT NOT NULL, -- The user who created this school
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "School_pkey" PRIMARY KEY ("id")
);
-- 3. Create TeacherSchool (Mapping Table)
CREATE TABLE "TeacherSchool" (
"id" SERIAL NOT NULL,
"teacherId" TEXT NOT NULL,
"schoolId" TEXT NOT NULL,
"isManaged" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "TeacherSchool_pkey" PRIMARY KEY ("id"),
CONSTRAINT "TeacherSchool_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TeacherSchool_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "TeacherSchool_teacherId_schoolId_key" ON "TeacherSchool"("teacherId", "schoolId");
-- 4. Create Term Table
CREATE TABLE "Term" (
"id" SERIAL NOT NULL,
"schoolId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Term_pkey" PRIMARY KEY ("id"),
CONSTRAINT "Term_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- 5. Create Holiday Table
CREATE TABLE "Holiday" (
"id" SERIAL NOT NULL,
"schoolId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Holiday_pkey" PRIMARY KEY ("id"),
CONSTRAINT "Holiday_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- 6. Create SchoolTimetableSlot Table
CREATE TABLE "SchoolTimetableSlot" (
"id" SERIAL NOT NULL,
"schoolId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"startTime" TEXT NOT NULL, -- e.g. "09:00"
"endTime" TEXT NOT NULL, -- e.g. "10:00"
"isTeachingSlot" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "SchoolTimetableSlot_pkey" PRIMARY KEY ("id"),
CONSTRAINT "SchoolTimetableSlot_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- 7. Create TimetableTemplate and TimetableEntry
CREATE TABLE "TimetableTemplate" (
"id" SERIAL NOT NULL,
"schoolId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
CONSTRAINT "TimetableTemplate_pkey" PRIMARY KEY ("id"),
CONSTRAINT "TimetableTemplate_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TimetableTemplate_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "TimetableEntry" (
"id" SERIAL NOT NULL,
"timetableTemplateId" INTEGER NOT NULL,
"schoolTimetableSlotId" INTEGER NOT NULL,
"classId" INTEGER NOT NULL,
"subjectId" INTEGER NOT NULL,
"dayOfWeek" INTEGER NOT NULL, -- 1=Monday, 7=Sunday
CONSTRAINT "TimetableEntry_pkey" PRIMARY KEY ("id"),
CONSTRAINT "TimetableEntry_timetableTemplateId_fkey" FOREIGN KEY ("timetableTemplateId") REFERENCES "TimetableTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TimetableEntry_schoolTimetableSlotId_fkey" FOREIGN KEY ("schoolTimetableSlotId") REFERENCES "SchoolTimetableSlot"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TimetableEntry_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TimetableEntry_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- 8. Alter Existing Tables to add schoolId (Since DB is reset, we assume tables are empty and can add NOT NULL safely)
-- For schemas heavily dependent on multi-tenancy:
ALTER TABLE "Admin" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Student" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Parent" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Class" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Subject" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Lesson" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Exam" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Assignment" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Result" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Attendance" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Event" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
ALTER TABLE "Announcement" ADD COLUMN "schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE;
-- 9. Cleanup Lesson table constraints and deprecated fields
ALTER TABLE "Lesson" DROP COLUMN "day";
-- Enable RLS on new tables
ALTER TABLE "School" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TeacherSchool" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Term" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Holiday" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "SchoolTimetableSlot" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TimetableTemplate" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TimetableEntry" ENABLE ROW LEVEL SECURITY;

View File

@ -0,0 +1,87 @@
-- TeacherSchoolSchedule table to model when teachers work at specific schools
CREATE TABLE "TeacherSchoolSchedule" (
"id" SERIAL PRIMARY KEY,
"teacherId" TEXT NOT NULL REFERENCES "Teacher"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"schoolId" TEXT NOT NULL REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
-- 1=Monday ... 7=Sunday, aligned with TimetableEntry.dayOfWeek
"daysOfWeek" INTEGER[] NOT NULL
);
ALTER TABLE "TeacherSchoolSchedule" ENABLE ROW LEVEL SECURITY;
-- RLS policies for School, TeacherSchool, and TeacherSchoolSchedule
-- Allow admins full control over School
CREATE POLICY "Admins have full access to School"
ON "School"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
-- Teachers can read schools they are mapped to via TeacherSchool
CREATE POLICY "Teachers can view their schools"
ON "School"
FOR SELECT
TO authenticated
USING (
is_admin()
OR id IN (
SELECT "schoolId" FROM "TeacherSchool" WHERE "teacherId" = requesting_user_id()
)
);
-- Optionally allow any authenticated user to browse the school directory
CREATE POLICY "Anyone can view schools"
ON "School"
FOR SELECT
TO authenticated
USING (true);
-- Teachers can manage schools where they are the adminId (independent/agency owners)
CREATE POLICY "Teachers manage their own schools"
ON "School"
FOR ALL
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND "adminId" = requesting_user_id()
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND "adminId" = requesting_user_id()
);
-- TeacherSchool policies
CREATE POLICY "Admins have full access to TeacherSchool"
ON "TeacherSchool"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Teachers can view their TeacherSchool mappings"
ON "TeacherSchool"
FOR SELECT
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND "teacherId" = requesting_user_id()
);
-- TeacherSchoolSchedule policies
CREATE POLICY "Admins have full access to TeacherSchoolSchedule"
ON "TeacherSchoolSchedule"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Teachers can view their schedules"
ON "TeacherSchoolSchedule"
FOR SELECT
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND "teacherId" = requesting_user_id()
);

View File

@ -0,0 +1,126 @@
-- RLS policies for timetable-related tables (Term, Holiday, SchoolTimetableSlot, TimetableTemplate, TimetableEntry)
-- Helper condition: teacher can manage schools where they are linked and not managed (independent/agency)
CREATE OR REPLACE FUNCTION teacher_can_manage_school(_school_id text)
RETURNS boolean
LANGUAGE sql STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM "TeacherSchool"
WHERE "teacherId" = requesting_user_id()
AND "schoolId" = _school_id
AND "isManaged" = false
);
$$;
-- TERM
CREATE POLICY "Admins have full access on Term"
ON "Term"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Teachers manage terms for their schools"
ON "Term"
FOR ALL
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
);
-- HOLIDAY
CREATE POLICY "Admins have full access on Holiday"
ON "Holiday"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Teachers manage holidays for their schools"
ON "Holiday"
FOR ALL
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
);
-- SCHOOL TIMETABLE SLOT
CREATE POLICY "Admins have full access on SchoolTimetableSlot"
ON "SchoolTimetableSlot"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Teachers manage slots for their schools"
ON "SchoolTimetableSlot"
FOR ALL
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
);
-- TIMETABLE TEMPLATE
CREATE POLICY "Admins have full access on TimetableTemplate"
ON "TimetableTemplate"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Teachers manage templates for their schools"
ON "TimetableTemplate"
FOR ALL
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND teacher_can_manage_school("schoolId")
);
-- TIMETABLE ENTRY
CREATE POLICY "Admins have full access on TimetableEntry"
ON "TimetableEntry"
FOR ALL
USING (is_admin())
WITH CHECK (is_admin());
CREATE POLICY "Teachers manage entries for their schools"
ON "TimetableEntry"
FOR ALL
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND EXISTS (
SELECT 1
FROM "TimetableTemplate" tt
WHERE tt.id = "timetableTemplateId"
AND teacher_can_manage_school(tt."schoolId")
)
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND EXISTS (
SELECT 1
FROM "TimetableTemplate" tt
WHERE tt.id = "timetableTemplateId"
AND teacher_can_manage_school(tt."schoolId")
)
);

View File

@ -0,0 +1,40 @@
-- Allow teachers to create and manage their own lessons for schools they manage
-- Teachers can insert lessons for their managed schools
CREATE POLICY "Teachers can insert lessons for their schools"
ON "Lesson"
FOR INSERT
TO authenticated
WITH CHECK (
requesting_user_role() = 'teacher'
AND "teacherId" = requesting_user_id()
AND teacher_can_manage_school("schoolId")
);
-- Teachers can update their own lessons
CREATE POLICY "Teachers can update their lessons"
ON "Lesson"
FOR UPDATE
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND "teacherId" = requesting_user_id()
AND teacher_can_manage_school("schoolId")
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND "teacherId" = requesting_user_id()
AND teacher_can_manage_school("schoolId")
);
-- Teachers can delete their own lessons
CREATE POLICY "Teachers can delete their lessons"
ON "Lesson"
FOR DELETE
TO authenticated
USING (
requesting_user_role() = 'teacher'
AND "teacherId" = requesting_user_id()
AND teacher_can_manage_school("schoolId")
);

View File

@ -0,0 +1,172 @@
-- Academic years and School Timetable (slot layout) with slot ordering
-- Adds AcademicYear, SchoolTimetable; links Term/Holiday/Slots to them; renames Template/Entry and adds schoolTimetableId + audit fields.
-- 1. AcademicYear
CREATE TABLE "AcademicYear" (
"id" SERIAL NOT NULL,
"schoolId" TEXT NOT NULL,
"startYear" INTEGER NOT NULL,
"endYear" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AcademicYear_pkey" PRIMARY KEY ("id"),
CONSTRAINT "AcademicYear_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "AcademicYear_endYear_check" CHECK ("endYear" = "startYear" + 1)
);
CREATE INDEX "AcademicYear_schoolId_idx" ON "AcademicYear"("schoolId");
-- 2. SchoolTimetable (slot layout for an academic year)
CREATE TABLE "SchoolTimetable" (
"id" SERIAL NOT NULL,
"academicYearId" INTEGER NOT NULL,
"schoolId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SchoolTimetable_pkey" PRIMARY KEY ("id"),
CONSTRAINT "SchoolTimetable_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SchoolTimetable_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "SchoolTimetable_academicYearId_idx" ON "SchoolTimetable"("academicYearId");
CREATE INDEX "SchoolTimetable_schoolId_idx" ON "SchoolTimetable"("schoolId");
-- 3. Term: add academicYearId, createdById, createdAt
ALTER TABLE "Term"
ADD COLUMN "academicYearId" INTEGER,
ADD COLUMN "createdById" TEXT,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- 4. Holiday: add academicYearId, createdById, createdAt
ALTER TABLE "Holiday"
ADD COLUMN "academicYearId" INTEGER,
ADD COLUMN "createdById" TEXT,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- 5. SchoolTimetableSlot: add schoolTimetableId, position, createdById (keep schoolId for denormalization)
ALTER TABLE "SchoolTimetableSlot"
ADD COLUMN "schoolTimetableId" INTEGER,
ADD COLUMN "position" INTEGER,
ADD COLUMN "createdById" TEXT,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- 6. Backfill: one AcademicYear (2026-2027) and one SchoolTimetable per school
INSERT INTO "AcademicYear" ("schoolId", "startYear", "endYear", "name")
SELECT "id", 2026, 2027, '2026-2027' FROM "School";
INSERT INTO "SchoolTimetable" ("academicYearId", "schoolId", "name")
SELECT ay."id", ay."schoolId", 'Standard Week'
FROM "AcademicYear" ay;
UPDATE "Term" t
SET "academicYearId" = ay."id"
FROM "AcademicYear" ay
WHERE ay."schoolId" = t."schoolId";
UPDATE "Holiday" h
SET "academicYearId" = ay."id"
FROM "AcademicYear" ay
WHERE ay."schoolId" = h."schoolId";
UPDATE "SchoolTimetableSlot" s
SET "schoolTimetableId" = st."id", "position" = sub.rn
FROM "SchoolTimetable" st,
(SELECT "id", ROW_NUMBER() OVER (PARTITION BY "schoolId" ORDER BY "id") AS rn FROM "SchoolTimetableSlot") sub
WHERE st."schoolId" = s."schoolId" AND sub."id" = s."id";
-- 7. Enforce NOT NULL and FKs for Term and SchoolTimetableSlot; optional FK for Holiday
ALTER TABLE "Holiday"
ADD CONSTRAINT "Holiday_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Term and SchoolTimetableSlot
ALTER TABLE "Term"
ADD CONSTRAINT "Term_academicYearId_fkey" FOREIGN KEY ("academicYearId") REFERENCES "AcademicYear"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Term" ALTER COLUMN "academicYearId" SET NOT NULL;
ALTER TABLE "SchoolTimetableSlot"
ADD CONSTRAINT "SchoolTimetableSlot_schoolTimetableId_fkey" FOREIGN KEY ("schoolTimetableId") REFERENCES "SchoolTimetable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SchoolTimetableSlot" ALTER COLUMN "schoolTimetableId" SET NOT NULL;
ALTER TABLE "SchoolTimetableSlot" ALTER COLUMN "position" SET NOT NULL;
CREATE INDEX "Term_academicYearId_idx" ON "Term"("academicYearId");
CREATE INDEX "SchoolTimetableSlot_schoolTimetableId_idx" ON "SchoolTimetableSlot"("schoolTimetableId");
-- 8. Rename TimetableTemplate -> TeacherTimetableTemplate; add schoolTimetableId, createdById, createdAt
ALTER TABLE "TimetableTemplate"
ADD COLUMN "schoolTimetableId" INTEGER,
ADD COLUMN "createdById" TEXT,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
UPDATE "TimetableTemplate" tt
SET "schoolTimetableId" = (
SELECT st."id" FROM "SchoolTimetable" st WHERE st."schoolId" = tt."schoolId" ORDER BY st."id" LIMIT 1
);
ALTER TABLE "TimetableTemplate"
ADD CONSTRAINT "TimetableTemplate_schoolTimetableId_fkey" FOREIGN KEY ("schoolTimetableId") REFERENCES "SchoolTimetable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TimetableTemplate" ALTER COLUMN "schoolTimetableId" SET NOT NULL;
ALTER TABLE "TimetableTemplate" RENAME TO "TeacherTimetableTemplate";
CREATE INDEX "TeacherTimetableTemplate_schoolTimetableId_idx" ON "TeacherTimetableTemplate"("schoolTimetableId");
-- 9. Rename TimetableEntry -> TeacherTimetableEntry; column timetableTemplateId -> teacherTimetableTemplateId
ALTER TABLE "TimetableEntry" RENAME TO "TeacherTimetableEntry";
ALTER TABLE "TeacherTimetableEntry" RENAME COLUMN "timetableTemplateId" TO "teacherTimetableTemplateId";
-- FK constraint name still points to old table name; drop and re-add
ALTER TABLE "TeacherTimetableEntry" DROP CONSTRAINT "TimetableEntry_timetableTemplateId_fkey";
ALTER TABLE "TeacherTimetableEntry"
ADD CONSTRAINT "TeacherTimetableEntry_teacherTimetableTemplateId_fkey"
FOREIGN KEY ("teacherTimetableTemplateId") REFERENCES "TeacherTimetableTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX "TeacherTimetableEntry_teacherTimetableTemplateId_idx" ON "TeacherTimetableEntry"("teacherTimetableTemplateId");
-- 10. Enable RLS on new tables
ALTER TABLE "AcademicYear" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "SchoolTimetable" ENABLE ROW LEVEL SECURITY;
-- 11. RLS policies for AcademicYear and SchoolTimetable (teacher_can_manage_school from 20260305020000)
CREATE POLICY "Admins have full access on AcademicYear"
ON "AcademicYear" FOR ALL USING (is_admin()) WITH CHECK (is_admin());
CREATE POLICY "Teachers manage academic years for their schools"
ON "AcademicYear" FOR ALL TO authenticated
USING (requesting_user_role() = 'teacher' AND teacher_can_manage_school("schoolId"))
WITH CHECK (requesting_user_role() = 'teacher' AND teacher_can_manage_school("schoolId"));
CREATE POLICY "Admins have full access on SchoolTimetable"
ON "SchoolTimetable" FOR ALL USING (is_admin()) WITH CHECK (is_admin());
CREATE POLICY "Teachers manage school timetables for their schools"
ON "SchoolTimetable" FOR ALL TO authenticated
USING (requesting_user_role() = 'teacher' AND teacher_can_manage_school("schoolId"))
WITH CHECK (requesting_user_role() = 'teacher' AND teacher_can_manage_school("schoolId"));
-- 12. Update TeacherTimetableEntry policies to use renamed table/column (drop old, create new)
DROP POLICY IF EXISTS "Admins have full access on TimetableEntry" ON "TeacherTimetableEntry";
DROP POLICY IF EXISTS "Teachers manage entries for their schools" ON "TeacherTimetableEntry";
CREATE POLICY "Admins have full access on TeacherTimetableEntry"
ON "TeacherTimetableEntry" FOR ALL USING (is_admin()) WITH CHECK (is_admin());
CREATE POLICY "Teachers manage entries for their schools"
ON "TeacherTimetableEntry" FOR ALL TO authenticated
USING (
requesting_user_role() = 'teacher'
AND EXISTS (
SELECT 1 FROM "TeacherTimetableTemplate" tt
WHERE tt."id" = "teacherTimetableTemplateId" AND teacher_can_manage_school(tt."schoolId")
)
)
WITH CHECK (
requesting_user_role() = 'teacher'
AND EXISTS (
SELECT 1 FROM "TeacherTimetableTemplate" tt
WHERE tt."id" = "teacherTimetableTemplateId" AND teacher_can_manage_school(tt."schoolId")
)
);

View File

@ -0,0 +1,22 @@
-- Fix Lesson id sequence when it gets out of sync (e.g. after seed_schedule inserts with explicit ids).
-- Call this before bulk-inserting lessons so new rows get unique ids.
CREATE OR REPLACE FUNCTION sync_lesson_id_sequence()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
PERFORM setval(
pg_get_serial_sequence('"Lesson"', 'id'),
COALESCE((SELECT MAX(id) FROM "Lesson"), 1)
);
END;
$$;
COMMENT ON FUNCTION sync_lesson_id_sequence() IS 'Sets Lesson id sequence to max(id)+1 so next inserts do not violate Lesson_pkey.';
-- Allow authenticated users to call (needed for generate-lessons actions)
GRANT EXECUTE ON FUNCTION sync_lesson_id_sequence() TO authenticated;
GRANT EXECUTE ON FUNCTION sync_lesson_id_sequence() TO service_role;

View File

@ -0,0 +1,9 @@
-- Allow teachers to add themselves to a school (link from directory on My Schools page)
CREATE POLICY "Teachers can insert their own TeacherSchool mapping"
ON "TeacherSchool"
FOR INSERT
TO authenticated
WITH CHECK (
requesting_user_role() = 'teacher'
AND "teacherId" = requesting_user_id()
);

View File

@ -0,0 +1,117 @@
-- Students can belong to multiple classes: add StudentClass junction table and remove Student.classId
-- 1. Create junction table
CREATE TABLE "StudentClass" (
"studentId" TEXT NOT NULL REFERENCES "Student"("id") ON DELETE CASCADE,
"classId" INTEGER NOT NULL REFERENCES "Class"("id") ON DELETE CASCADE,
CONSTRAINT "StudentClass_pkey" PRIMARY KEY ("studentId", "classId")
);
CREATE INDEX "StudentClass_classId_idx" ON "StudentClass"("classId");
CREATE INDEX "StudentClass_studentId_idx" ON "StudentClass"("studentId");
-- 2. Backfill from existing Student.classId
INSERT INTO "StudentClass" ("studentId", "classId")
SELECT id, "classId" FROM "Student" WHERE "classId" IS NOT NULL;
-- 3. Drop FK and column on Student
ALTER TABLE "Student" DROP CONSTRAINT IF EXISTS "Student_classId_fkey";
ALTER TABLE "Student" DROP COLUMN IF EXISTS "classId";
-- 4. RLS for StudentClass
ALTER TABLE "StudentClass" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins have full access" ON "StudentClass" FOR ALL USING (
EXISTS (SELECT 1 FROM "Admin" WHERE id = requesting_user_id())
);
CREATE POLICY "Users can view permitted student-class links" ON "StudentClass" FOR SELECT USING (
is_admin()
OR ("studentId" = requesting_user_id() AND requesting_user_role() = 'student')
OR ("studentId" IN (SELECT id FROM "Student" WHERE "parentId" = requesting_user_id()) AND requesting_user_role() = 'parent')
OR ("classId" IN (SELECT "classId" FROM "Lesson" WHERE "teacherId" = requesting_user_id()) AND requesting_user_role() = 'teacher')
OR ("classId" IN (SELECT id FROM "Class" WHERE "supervisorId" = requesting_user_id()) AND requesting_user_role() = 'teacher')
);
CREATE POLICY "Admins can insert student-class" ON "StudentClass" FOR INSERT WITH CHECK (is_admin());
CREATE POLICY "Admins can update student-class" ON "StudentClass" FOR UPDATE USING (is_admin());
CREATE POLICY "Admins can delete student-class" ON "StudentClass" FOR DELETE USING (is_admin());
-- 5. Update auth helper: classes the user can see (student/parent via StudentClass, teacher via Lesson/Class)
CREATE OR REPLACE FUNCTION auth_user_classes() RETURNS SETOF integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = public STABLE AS $$
BEGIN
RETURN QUERY
SELECT "classId" FROM "StudentClass" WHERE "studentId" = requesting_user_id() AND requesting_user_role() = 'student'
UNION
SELECT "classId" FROM "StudentClass" WHERE "studentId" IN (SELECT id FROM "Student" WHERE "parentId" = requesting_user_id()) AND requesting_user_role() = 'parent'
UNION
SELECT "classId" FROM "Lesson" WHERE "teacherId" = requesting_user_id() AND requesting_user_role() = 'teacher'
UNION
SELECT id FROM "Class" WHERE "supervisorId" = requesting_user_id() AND requesting_user_role() = 'teacher';
END;
$$;
-- 6. Update auth_user_students: teacher sees students in their classes via StudentClass
CREATE OR REPLACE FUNCTION auth_user_students() RETURNS SETOF text LANGUAGE plpgsql SECURITY DEFINER SET search_path = public STABLE AS $$
BEGIN
RETURN QUERY
SELECT id FROM "Student" WHERE id = requesting_user_id() AND requesting_user_role() = 'student'
UNION
SELECT id FROM "Student" WHERE "parentId" = requesting_user_id() AND requesting_user_role() = 'parent'
UNION
SELECT "studentId" FROM "StudentClass" WHERE "classId" IN (
SELECT "classId" FROM "Lesson" WHERE "teacherId" = requesting_user_id()
) AND requesting_user_role() = 'teacher';
END;
$$;
-- 7. Update auth_user_lessons: student/parent see lessons for any of their classes (via StudentClass)
CREATE OR REPLACE FUNCTION auth_user_lessons() RETURNS SETOF integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = public STABLE AS $$
BEGIN
RETURN QUERY
SELECT id FROM "Lesson" WHERE "teacherId" = requesting_user_id() AND requesting_user_role() = 'teacher'
UNION
SELECT id FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" = requesting_user_id() AND requesting_user_role() = 'student'
)
UNION
SELECT id FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" IN (SELECT id FROM "Student" WHERE "parentId" = requesting_user_id()) AND requesting_user_role() = 'parent'
);
END;
$$;
-- 8. Update auth_user_subjects
CREATE OR REPLACE FUNCTION auth_user_subjects() RETURNS SETOF integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = public STABLE AS $$
BEGIN
RETURN QUERY
SELECT "A" FROM "_SubjectToTeacher" WHERE "B" = requesting_user_id() AND requesting_user_role() = 'teacher'
UNION
SELECT "subjectId" FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" = requesting_user_id() AND requesting_user_role() = 'student'
)
UNION
SELECT "subjectId" FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" IN (SELECT id FROM "Student" WHERE "parentId" = requesting_user_id()) AND requesting_user_role() = 'parent'
);
END;
$$;
-- 9. Update auth_user_teachers
CREATE OR REPLACE FUNCTION auth_user_teachers() RETURNS SETOF text LANGUAGE plpgsql SECURITY DEFINER SET search_path = public STABLE AS $$
BEGIN
RETURN QUERY
SELECT id FROM "Teacher" WHERE id = requesting_user_id() AND requesting_user_role() = 'teacher'
UNION
SELECT "teacherId" FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" = requesting_user_id() AND requesting_user_role() = 'student'
)
UNION
SELECT "teacherId" FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" IN (SELECT id FROM "Student" WHERE "parentId" = requesting_user_id()) AND requesting_user_role() = 'parent'
);
END;
$$;
-- 10. Class policy referenced classId on Student; Student no longer has classId. Policy uses auth_user_classes() which we updated.
-- Event/Announcement policies use auth_user_classes() - no change needed.

View File

@ -0,0 +1,17 @@
-- Fix auth_user_subjects(): use TeacherSubject (not _SubjectToTeacher) and include teacher's lesson subjects
CREATE OR REPLACE FUNCTION auth_user_subjects() RETURNS SETOF integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = public STABLE AS $$
BEGIN
RETURN QUERY
SELECT "subjectId" FROM "TeacherSubject" WHERE "teacherId" = requesting_user_id() AND requesting_user_role() = 'teacher'
UNION
SELECT "subjectId" FROM "Lesson" WHERE "teacherId" = requesting_user_id() AND requesting_user_role() = 'teacher'
UNION
SELECT "subjectId" FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" = requesting_user_id() AND requesting_user_role() = 'student'
)
UNION
SELECT "subjectId" FROM "Lesson" WHERE "classId" IN (
SELECT "classId" FROM "StudentClass" WHERE "studentId" IN (SELECT id FROM "Student" WHERE "parentId" = requesting_user_id()) AND requesting_user_role() = 'parent'
);
END;
$$;

19
test_query.ts Normal file
View File

@ -0,0 +1,19 @@
import "dotenv/config";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
"sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz",
{ auth: { persistSession: false } }
);
async function main() {
let query = supabase
.from("Teacher")
.select("*, TeacherSubject(Subject(*)), classes:Class(*), lessons:Lesson(*)", { count: "exact" }).limit(1);
let { data, error } = await query;
console.log("Error:", error);
console.log("Data:", JSON.stringify(data, null, 2));
}
main();