Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e61f40911 | |||
| ea95bf965f | |||
| 2b7f446c03 |
7
.cursor/settings.json
Normal file
7
.cursor/settings.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"supabase": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
8
full-stack-school.code-workspace
Normal file
8
full-stack-school.code-workspace
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
3536
package-lock.json
generated
3536
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -14,13 +14,12 @@
|
||||
"@clerk/elements": "^0.14.6",
|
||||
"@clerk/nextjs": "^5.4.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@prisma/client": "^5.19.1",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@types/react-big-calendar": "^1.8.9",
|
||||
"lucide-react": "^0.575.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "14.2.5",
|
||||
"next": "^14.2.35",
|
||||
"next-cloudinary": "^6.13.0",
|
||||
"prisma": "^5.19.1",
|
||||
"react": "^18",
|
||||
"react-big-calendar": "^1.13.2",
|
||||
"react-calendar": "^5.0.0",
|
||||
@ -37,11 +36,17 @@
|
||||
"@types/react-dom": "^18",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"eslint-config-next": "^14.2.35",
|
||||
"phoenix": "^1.8.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.21.0",
|
||||
"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
51901
school_data.csv
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,99 +1,99 @@
|
||||
{
|
||||
"teacherMap": {
|
||||
"1": "user_3AJAkSqshofbdPsW7lZh6q2eCe5",
|
||||
"2": "user_3AJAkVye7wKBBK8seog6gljyc7L",
|
||||
"3": "user_3AJAkXA9lrOHxsGcJNoJGOXKNOr",
|
||||
"4": "user_3AJAkc3f42kZzVPLvaRBFHor14J",
|
||||
"5": "user_3AJAkbsXvHmZcGu3wFpRZm732LX",
|
||||
"6": "user_3AJAke4rOtbf26yRM9JoHhtEjyD",
|
||||
"7": "user_3AJAklVf8Pnm2pH6Ql1PnfQBIID",
|
||||
"8": "user_3AJAki8tuWw9rWvZZD9xIP6CxVp",
|
||||
"9": "user_3AJAkn3ET3kFyUrx636ayvHrLds",
|
||||
"10": "user_3AJAkuiTMWimGNBtw7ymHVUqwVW",
|
||||
"11": "user_3AJAkvjGTDDsscx7g9dD998d6Gq",
|
||||
"12": "user_3AJAkr08HMjIKMG1Guzw3HJqB9L",
|
||||
"13": "user_3AJAl1xiX1ZtMg0ohV7QB2TpHm5",
|
||||
"14": "user_3AJAl1tyuI5t3DfmleToHAw6HgJ",
|
||||
"15": "user_3AJAl3Rr5gq7K1W0fhl6AVNFXrY"
|
||||
"1": "user_3AcluevCik3awerLuiRklEiYlJK",
|
||||
"2": "user_3AclukmobwQyj1tnS2EpfGIEt6R",
|
||||
"3": "user_3Aclul7evfG5xOADBZnREFuhYJf",
|
||||
"4": "user_3Aclum88YIVFMJy42nhED1n0sYr",
|
||||
"5": "user_3AclusjcjWN31nZipvNmfMnxliI",
|
||||
"6": "user_3Acluy4wczfAKj0LgYUpqXx1Dwo",
|
||||
"7": "user_3AcluzotsLfMQe83PxIpQ5o1Ajq",
|
||||
"8": "user_3AcluuRXAW5Y0pfUI0bHKIl5PEp",
|
||||
"9": "user_3Aclv3yUI1stBwX8v1Y6BZw6xBB",
|
||||
"10": "user_3Aclv3EpmOTEXnSOCyXFuvkDGDP",
|
||||
"11": "user_3AclvFBzxeP3PqdRjT9Wcuy5Wvw",
|
||||
"12": "user_3AclvEh6mSOExxYPUjP5iqE06C8",
|
||||
"13": "user_3AclvCPsCtWmKKQplr2KfKAlAna",
|
||||
"14": "user_3AclvFUjLy3SwIL3egEjVmlUzDx",
|
||||
"15": "user_3AclvGYnbjRTd8unbv6bj0LJlIy"
|
||||
},
|
||||
"parentMap": {
|
||||
"1": "user_3AJAl6KD5fVP5MbjDZLpyoZ7sDG",
|
||||
"2": "user_3AJAl9gkapHVzn5Tik7RteIEJVZ",
|
||||
"3": "user_3AJAl5BLWwfpho7phY16SrCleE8",
|
||||
"4": "user_3AJAl7BN77L27mpmHzjCp96OlFx",
|
||||
"5": "user_3AJAlFko0fvXyleuRuyqmGkpgTV",
|
||||
"6": "user_3AJAlEyIdgY7vVA7yZQUbTwimOC",
|
||||
"7": "user_3AJAlIlHOwSpcR92ENGiHrz0nCg",
|
||||
"8": "user_3AJAlKekpw2nrtGnnMBvN0xdh3c",
|
||||
"9": "user_3AJAlLmCzuvKoqh9yh2ulnmz26h",
|
||||
"10": "user_3AJAlPbUuestp7PvH2BuX2GQK6R",
|
||||
"11": "user_3AJAlWR17pNYP0S24hiNnUKI2R1",
|
||||
"12": "user_3AJAlWTNINh2uIOEueR8dPKXjlc",
|
||||
"13": "user_3AJAlUx3a2u65hTIoxWLjmDnDuj",
|
||||
"14": "user_3AJAlac7l5vhi9lbnvuOIEZ2zoz",
|
||||
"15": "user_3AJAldy0F23q4Jm8kz8olXdoao0",
|
||||
"16": "user_3AJAlekANUdcMJeZ34EFcCjluAq",
|
||||
"17": "user_3AJAllI3cBBjs5JbcUimhrLEYvf",
|
||||
"18": "user_3AJAlp8YJPzQwvcutFoCsHAz3Sn",
|
||||
"19": "user_3AJAllVI1vR6vwSPlLPCyIb9oZB",
|
||||
"20": "user_3AJAlwjmMMnvV9la8fFlH7TYuOS",
|
||||
"21": "user_3AJAlxGYIRJnKZmvkt2HB9ukrlC",
|
||||
"22": "user_3AJAltSwlQoPrRGcsAZcJpH2sH9",
|
||||
"23": "user_3AJAlvcnUE4GGbYKK0odLUjwxqu",
|
||||
"24": "user_3AJAm16YficrMn5CeAOCAxV8iu1",
|
||||
"25": "user_3AJAlxj9Z9gE4EIwQ5pljEywIV0"
|
||||
"1": "user_3AclvXcXbK4reTdS7oNXcwYOPLF",
|
||||
"2": "user_3AclvcMY65f4gujVmJwfTElOL4I",
|
||||
"3": "user_3AclvW0MF1AqFkRcapoJ7lFBTyi",
|
||||
"4": "user_3AclvgyTfukFFSnuZ7Ka3XQsBTB",
|
||||
"5": "user_3Aclvg9OfhvlTzwWluw2eSNNXRP",
|
||||
"6": "user_3AclvjgJNIhAzMelhWTzaIBU3q2",
|
||||
"7": "user_3AclvexY0qT0klmi2YEx9g0j2kn",
|
||||
"8": "user_3Aclvt7rWlBsErcFgMzY32PTtZk",
|
||||
"9": "user_3Aclvm2ako1pUz9zD0sxq0zcrtB",
|
||||
"10": "user_3AclvpzxKyOlI6yhS83zam9B3Ty",
|
||||
"11": "user_3AclvvlQgtOnlcymUW2FaM5Tk9I",
|
||||
"12": "user_3Aclvv0Jn0UrL7KVTswpJLojIVL",
|
||||
"13": "user_3AclvuAT2eVlEp8qFQg3dW7v5Nw",
|
||||
"14": "user_3Aclw79caQ2PJ7L6F0MzeyDKEqq",
|
||||
"15": "user_3Aclw72NNounCPLb27uNI1qJqY5",
|
||||
"16": "user_3Aclw7mjthXqbQsI2XKEsWJlUbp",
|
||||
"17": "user_3AclwDc08JgsMxWBIjFXFuVkKId",
|
||||
"18": "user_3AclwBWEplMgRAJ9eLzGqQVJSCI",
|
||||
"19": "user_3AclwBj5Qzo9d5yF1S4AT7OEwEN",
|
||||
"20": "user_3AclwKBjFVhqiC2KIvFGhqkdkFP",
|
||||
"21": "user_3AclwIhCpEdcksHaZCzlFJIFQgD",
|
||||
"22": "user_3AclwHyw3NQ3hwP77tgqBw5S0Nq",
|
||||
"23": "user_3AclwTy1XzFeeR0dz9ToErN5f3t",
|
||||
"24": "user_3AclwQsdiL8JzC4g3vbogU2XcN6",
|
||||
"25": "user_3AclwU8IiWYz0LKR2tO7jEHVdAF"
|
||||
},
|
||||
"studentMap": {
|
||||
"1": "user_3AJAm7o0RjL16Gt1jE9YLG0tib7",
|
||||
"2": "user_3AJAm9SoYdWl5sQfbf8slNI2Qn4",
|
||||
"3": "user_3AJAm8NIt39YG9DPjnAwq2G2V3G",
|
||||
"4": "user_3AJAm5KPpNLvz8DjwNU6MRCBO5e",
|
||||
"5": "user_3AJAmGDojjfnVtn7LrmLVemoCiM",
|
||||
"6": "user_3AJAmDK9jVHWfe3FjdR7emQ329K",
|
||||
"7": "user_3AJAmMNLtdeplBIS9kwXYW0FC9Z",
|
||||
"8": "user_3AJAmNXiIZz0FYILKDOZxq1ET56",
|
||||
"9": "user_3AJAmMeKtN7oMMrLgcxIEWsm0EV",
|
||||
"10": "user_3AJAmQsp9Xg8LDpxCkkmccjDCuU",
|
||||
"11": "user_3AJAmZtzvmAwwu7QJRvIpxZCNYF",
|
||||
"12": "user_3AJAmXM1fL4b0fONRdM5uIRIxBS",
|
||||
"13": "user_3AJAmi4pxmDtV24YaIaNmmack33",
|
||||
"14": "user_3AJAmf864NjSUdQ6ExiMR3zaDCn",
|
||||
"15": "user_3AJAmbZArRtgzFrBdW77qbd7P1z",
|
||||
"16": "user_3AJAmpWu9wzc4ve2kwTKPLveFST",
|
||||
"17": "user_3AJAmoVVKEmMeE8GMd7A2fgn5nY",
|
||||
"18": "user_3AJAmlYITikQW1km45TqvQ2biBm",
|
||||
"19": "user_3AJAmuEU2s2CLT9391jnj6Uxb3e",
|
||||
"20": "user_3AJAmwfQjhHcnd6Y2HGvXWTxTj8",
|
||||
"21": "user_3AJAmxavPXIvafOo84MWRqKPyRN",
|
||||
"22": "user_3AJAmxtksJ1U2UjHoP1wzXgk3JB",
|
||||
"23": "user_3AJAn3gfMCCyfaFrX0yY69XfQfQ",
|
||||
"24": "user_3AJAn1UL8HJe9pfcZboyDCqg4Zd",
|
||||
"25": "user_3AJAn4fAYAtcYCLKv1fBkBJSyxw",
|
||||
"26": "user_3AJAnBlPYjZBRY7HrMaY95WRQ59",
|
||||
"27": "user_3AJAnD31sahtYeq9Zfzu867VgB8",
|
||||
"28": "user_3AJAnK8qcNr259cZnIKpH4ZuoRH",
|
||||
"29": "user_3AJAnECM3SCOTVaHQvwaIkV1YnJ",
|
||||
"30": "user_3AJAnH4sNG5lbNwuvYHmmbU9J3U",
|
||||
"31": "user_3AJAnRjFXzyoALI4egtIvZEUFuA",
|
||||
"32": "user_3AJAnYhwtMvevCuQScoLSJePp79",
|
||||
"33": "user_3AJAnXQkG2mOAFxFm1lHaOV2aJQ",
|
||||
"34": "user_3AJAnYU4Frx0bX863nwBE9EJaGH",
|
||||
"35": "user_3AJAncF3uP2xfkU6WixizrfSSrl",
|
||||
"36": "user_3AJAngPagGao0QCe5AcGeeGGpDS",
|
||||
"37": "user_3AJAnazAi3ERTCfqVQr7jDtTOK4",
|
||||
"38": "user_3AJAnoJnVGxwohyI3ot6cwnZ6iO",
|
||||
"39": "user_3AJAnoCbmMiBBlXE3PvUN1vg3CT",
|
||||
"40": "user_3AJAniemPzyyEwPSZZ2h7iXK9lV",
|
||||
"41": "user_3AJAnvqsElaS1dwqPtmhw4N7dCS",
|
||||
"42": "user_3AJAnqp0r30RwkxKEJK3nrPJcwR",
|
||||
"43": "user_3AJAnsBcWHQQGYCpKxLbfWpSWdE",
|
||||
"44": "user_3AJAnrDUy5R1iFD1Vsb8FNlDO1I",
|
||||
"45": "user_3AJAo1kdbtoIYYx3gvEcr3D9WT8",
|
||||
"46": "user_3AJAo0CALCnqJle4D4JGt5VNqHJ",
|
||||
"47": "user_3AJAo1fZPiFTp8U0NYaprnET0Ls",
|
||||
"48": "user_3AJAoB35YG2oZHKLvT4UFBQVBqj",
|
||||
"49": "user_3AJAoDZ1nOyp3f3KO2Lefzgi6Ah",
|
||||
"50": "user_3AJAo6si4CGdeQ3teM04nPmrJe2"
|
||||
"1": "user_3AclwbE0uJkJGKuHp1Zpn8gMsmQ",
|
||||
"2": "user_3AclwWTpwvqyBqUMWkDxtAY5c7J",
|
||||
"3": "user_3AclwYNQel4ZU9gIhTeStiF1Ze6",
|
||||
"4": "user_3AclwYrAL1BHDdL3WX8IhZktQN4",
|
||||
"5": "user_3Aclwk98pbn7UMCwJtUNJCvDluj",
|
||||
"6": "user_3AclwgmspnQjXqsqSgxbM5Bibqj",
|
||||
"7": "user_3AclwhuDRVreoGet0J0uuV1kC8E",
|
||||
"8": "user_3AclwnpM8BXNEJPeqnutsmVWMho",
|
||||
"9": "user_3Aclwp8KhfBigj2v1dBhnAcEZR6",
|
||||
"10": "user_3AclwnecWiKC7tTa5d2lbqQtBI4",
|
||||
"11": "user_3AclwvZ3xeaPpIee1GZSaF0ZcjZ",
|
||||
"12": "user_3Aclx07Hzs19WqTDY5XgkZOlA5Z",
|
||||
"13": "user_3Aclx0waQhbfrqHIuIbBI7xh4sQ",
|
||||
"14": "user_3Aclx1W0bqTDhFm5aXFaWhT3Zpj",
|
||||
"15": "user_3Aclx90O8i0uRp3QhFf4vdGRgJO",
|
||||
"16": "user_3Aclx3Wudlyg6UrLuYVPpisc9Kw",
|
||||
"17": "user_3Aclx1pknqQHygFrrfgBSbQM8P5",
|
||||
"18": "user_3AclxA2diOV37Z9RkTkFFxVRJCI",
|
||||
"19": "user_3AclxEYCVHlfshKyPTciQqBStYt",
|
||||
"20": "user_3AclxACCapDzlCapyET2IOl3TMi",
|
||||
"21": "user_3AclxOhxWRx6Wxq2FUoRECA2Irr",
|
||||
"22": "user_3AclxLC8W2o7R6MPkIbNVM4Qltw",
|
||||
"23": "user_3AclxORMxzSDn0gFyODb5hEZZOM",
|
||||
"24": "user_3AclxQSp6TOwDfRhMDfOGrYDnzc",
|
||||
"25": "user_3AclxUWnaRkJilrFTa8KSadPIOQ",
|
||||
"26": "user_3AclxVh9iksSofGVSlHhZuzzP8p",
|
||||
"27": "user_3AclxXf5JQwlyBFRQa3l1J2Z8H7",
|
||||
"28": "user_3AclxXRWh9OG3aOGZ75fxmoIrTy",
|
||||
"29": "user_3Aclxd1tcajIoLs0ss92G93plxc",
|
||||
"30": "user_3AclxlTKEK90s8UxTh2t1kZ8ckq",
|
||||
"31": "user_3AclxitgziRzcyQ6ZrCktA9F7SL",
|
||||
"32": "user_3Aclxl3jUH7ZkLDT60bygOcSXnp",
|
||||
"33": "user_3Aclxn9ZCLy55ov2bwc3OmR61nV",
|
||||
"34": "user_3Aclxqhy4nWJgAziVYw55XC9o3p",
|
||||
"35": "user_3Aclxs3SiQx0F6eAoeZkoieoIRb",
|
||||
"36": "user_3AclxvfvGobbMne72Yxhmu7Fffg",
|
||||
"37": "user_3Aclxu8hSwFVmjUgWVyXDuYDs6Z",
|
||||
"38": "user_3Aclxu20dFV0lzMyydeshUZOEV5",
|
||||
"39": "user_3Acly3gPFSpK7hQmMCJZSAuYeIF",
|
||||
"40": "user_3Acly45oxORkyRFuVfBLXIwNYGU",
|
||||
"41": "user_3Acly8AmTgp5Kwt7wVcJXU3GTYN",
|
||||
"42": "user_3AclyEuWVgcFXYDdX9od3suZOiT",
|
||||
"43": "user_3AclyBfcG4U2GQwIFGf4g2Hrsml",
|
||||
"44": "user_3AclyGTRbrzucyRS67zME9Z97x5",
|
||||
"45": "user_3AclyHWmt9JWT1n19gHmxb4E5pl",
|
||||
"46": "user_3AclyJah0uOBB8CB1QczkiMfq4L",
|
||||
"47": "user_3AclyO2ceOwUYl7f3u7e3kDJE2S",
|
||||
"48": "user_3AclyTZCkT3qO9afGXrYfap7Shu",
|
||||
"49": "user_3AclyRDenLpSPEbgA3naueu3egB",
|
||||
"50": "user_3AclyVSlp4TstXlvS1SMfp6Spl4"
|
||||
},
|
||||
"classes": [
|
||||
{
|
||||
@ -101,42 +101,53 @@
|
||||
"name": "1A",
|
||||
"gradeId": 1,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkSqshofbdPsW7lZh6q2eCe5"
|
||||
"supervisorId": "user_3AcluevCik3awerLuiRklEiYlJK",
|
||||
"schoolId": "default-school-1"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "2A",
|
||||
"gradeId": 2,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkVye7wKBBK8seog6gljyc7L"
|
||||
"supervisorId": "user_3AclukmobwQyj1tnS2EpfGIEt6R",
|
||||
"schoolId": "default-school-1"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "3A",
|
||||
"gradeId": 3,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkXA9lrOHxsGcJNoJGOXKNOr"
|
||||
"supervisorId": "user_3Aclul7evfG5xOADBZnREFuhYJf",
|
||||
"schoolId": "default-school-1"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "4A",
|
||||
"gradeId": 4,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkc3f42kZzVPLvaRBFHor14J"
|
||||
"supervisorId": "user_3Aclum88YIVFMJy42nhED1n0sYr",
|
||||
"schoolId": "default-school-1"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "5A",
|
||||
"gradeId": 5,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkbsXvHmZcGu3wFpRZm732LX"
|
||||
"supervisorId": "user_3AclusjcjWN31nZipvNmfMnxliI",
|
||||
"schoolId": "default-school-1"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "6A",
|
||||
"gradeId": 6,
|
||||
"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"
|
||||
}
|
||||
630
scripts/seed.ts
630
scripts/seed.ts
@ -31,22 +31,335 @@ async function cleanSupabase() {
|
||||
console.log("Cleaning up Supabase tables...");
|
||||
const tables = [
|
||||
"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) {
|
||||
await supabase.from(table).delete().neq("id", "0" as any);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAdmin() {
|
||||
console.log("Syncing Admin...");
|
||||
type AdminSeedInfo = {
|
||||
adminId: string;
|
||||
defaultSchoolId: string;
|
||||
};
|
||||
|
||||
async function seedAdmin(): Promise<AdminSeedInfo | null> {
|
||||
console.log("Syncing Admin and Creating Default School...");
|
||||
const clerk = clerkClient();
|
||||
const users = await clerk.users.getUserList({ limit: 100 });
|
||||
const adminUser = users.data.find(u => u.username === "admin" || u.emailAddresses[0]?.emailAddress?.includes("admin"));
|
||||
|
||||
if (adminUser) {
|
||||
await supabase.from("Admin").upsert({ id: adminUser.id, username: adminUser.username || "admin" });
|
||||
console.log(`Synced Admin ID: ${adminUser.id}`);
|
||||
const defaultSchoolId = "default-school-1";
|
||||
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();
|
||||
await cleanClerk();
|
||||
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...");
|
||||
const grades = [1, 2, 3, 4, 5, 6].map((level) => ({ id: level, level }));
|
||||
await supabase.from("Grade").insert(grades);
|
||||
|
||||
const subjectsArray = [
|
||||
{ id: 1, name: "Mathematics" },
|
||||
{ id: 2, name: "Science" },
|
||||
{ id: 3, name: "English" },
|
||||
{ id: 4, name: "History" },
|
||||
{ id: 5, name: "Geography" },
|
||||
{ id: 6, name: "Physics" },
|
||||
{ id: 7, name: "Chemistry" },
|
||||
{ id: 8, name: "Biology" },
|
||||
{ id: 9, name: "Computer Science" },
|
||||
{ id: 10, name: "Art" },
|
||||
{ id: 1, name: "Mathematics", schoolId: defaultSchoolId },
|
||||
{ id: 2, name: "Science", schoolId: defaultSchoolId },
|
||||
{ id: 3, name: "English", schoolId: defaultSchoolId },
|
||||
{ id: 4, name: "History", schoolId: defaultSchoolId },
|
||||
{ id: 5, name: "Geography", schoolId: defaultSchoolId },
|
||||
{ id: 6, name: "Physics", schoolId: defaultSchoolId },
|
||||
{ id: 7, name: "Chemistry", schoolId: defaultSchoolId },
|
||||
{ id: 8, name: "Biology", schoolId: defaultSchoolId },
|
||||
{ id: 9, name: "Computer Science", schoolId: defaultSchoolId },
|
||||
{ id: 10, name: "Art", schoolId: defaultSchoolId },
|
||||
];
|
||||
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> = {};
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
const user = await clerk.users.createUser({
|
||||
@ -100,23 +423,264 @@ async function main() {
|
||||
birthday: "1996-02-27T00:26:35.280Z"
|
||||
});
|
||||
|
||||
await supabase.from("TeacherSchool").insert({
|
||||
teacherId: user.id,
|
||||
schoolId: defaultSchoolId,
|
||||
isManaged: true
|
||||
});
|
||||
|
||||
await supabase.from("TeacherSubject").insert([
|
||||
{ subjectId: (i % 10) + 1, teacherId: user.id, isPrimary: true },
|
||||
{ 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 = [
|
||||
{ id: 1, name: "1A", gradeId: 1, capacity: 20, supervisorId: teacherMap[1] },
|
||||
{ id: 2, name: "2A", gradeId: 2, capacity: 20, supervisorId: teacherMap[2] },
|
||||
{ id: 3, name: "3A", gradeId: 3, capacity: 20, supervisorId: teacherMap[3] },
|
||||
{ id: 4, name: "4A", gradeId: 4, capacity: 20, supervisorId: teacherMap[4] },
|
||||
{ id: 5, name: "5A", gradeId: 5, capacity: 20, supervisorId: teacherMap[5] },
|
||||
{ id: 6, name: "6A", gradeId: 6, 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], schoolId: defaultSchoolId },
|
||||
{ id: 3, name: "3A", gradeId: 3, capacity: 20, supervisorId: teacherMap[3], schoolId: defaultSchoolId },
|
||||
{ id: 4, name: "4A", gradeId: 4, capacity: 20, supervisorId: teacherMap[4], schoolId: defaultSchoolId },
|
||||
{ id: 5, name: "5A", gradeId: 5, capacity: 20, supervisorId: teacherMap[5], schoolId: defaultSchoolId },
|
||||
{ id: 6, name: "6A", gradeId: 6, capacity: 20, supervisorId: teacherMap[1], schoolId: defaultSchoolId },
|
||||
];
|
||||
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...");
|
||||
const parentMap: Record<number, string> = {};
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
@ -136,7 +700,8 @@ async function main() {
|
||||
surname: `PSurname${i}`,
|
||||
email: `parent${i}@example.com`,
|
||||
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",
|
||||
parentId: parentMap[(i % 25) + 1],
|
||||
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,
|
||||
parentMap,
|
||||
studentMap,
|
||||
classes: classesArray
|
||||
classes: classesArray,
|
||||
defaultSchoolId,
|
||||
independentTeacherId: independentUser.id,
|
||||
independentSchoolId,
|
||||
agencyTeacherId: agencyUser.id,
|
||||
agencySchoolId,
|
||||
};
|
||||
fs.writeFileSync(seedDataPath, JSON.stringify(seedData, null, 2));
|
||||
|
||||
|
||||
@ -64,10 +64,10 @@ async function main() {
|
||||
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 { teacherMap, studentMap, classes } = data;
|
||||
const { teacherMap, studentMap, classes, defaultSchoolId } = 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) {
|
||||
await supabase.from(table).delete().neq("id", "0" as any);
|
||||
}
|
||||
@ -86,6 +86,7 @@ async function main() {
|
||||
let attendanceIdCounter = 1;
|
||||
|
||||
const lessonsData = [];
|
||||
const whiteboardsData = [];
|
||||
const examsData = [];
|
||||
const assignmentsData = [];
|
||||
const resultsData = [];
|
||||
@ -141,12 +142,16 @@ async function main() {
|
||||
lessonsData.push({
|
||||
id: lessonIdCounter,
|
||||
name: `${classInfo.name} ${subjectKey} (${dayName})`,
|
||||
day: dayName,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
subjectId: subjectId,
|
||||
classId: classInfo.id,
|
||||
teacherId: teacherId
|
||||
teacherId: teacherId,
|
||||
schoolId: defaultSchoolId
|
||||
});
|
||||
|
||||
whiteboardsData.push({
|
||||
lessonId: lessonIdCounter
|
||||
});
|
||||
|
||||
// 2. Insert Exam / Results (Random Probability)
|
||||
@ -156,7 +161,8 @@ async function main() {
|
||||
title: `${subjectKey} Assessment`,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
lessonId: lessonIdCounter
|
||||
lessonId: lessonIdCounter,
|
||||
schoolId: defaultSchoolId
|
||||
});
|
||||
|
||||
// Only give results to a subset to prevent table explosions
|
||||
@ -165,7 +171,8 @@ async function main() {
|
||||
id: resultIdCounter++,
|
||||
score: 60 + Math.floor(Math.random() * 41), // 60-100
|
||||
studentId: sId,
|
||||
examId: examIdCounter
|
||||
examId: examIdCounter,
|
||||
schoolId: defaultSchoolId
|
||||
});
|
||||
}
|
||||
examIdCounter++;
|
||||
@ -178,7 +185,8 @@ async function main() {
|
||||
title: `${subjectKey} Practice`,
|
||||
startDate: startTime.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(),
|
||||
present: Math.random() < CONFIG.attendanceProbability,
|
||||
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
|
||||
await insertInChunks("Lesson", lessonsData);
|
||||
await insertInChunks("LessonWhiteboard", whiteboardsData);
|
||||
await insertInChunks("Exam", examsData);
|
||||
await insertInChunks("Assignment", assignmentsData);
|
||||
await insertInChunks("Result", resultsData);
|
||||
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(`- Lessons: ${lessonsData.length}`);
|
||||
console.log(`- Whiteboards: ${whiteboardsData.length}`);
|
||||
console.log(`- Exams: ${examsData.length}`);
|
||||
console.log(`- Assignments: ${assignmentsData.length}`);
|
||||
console.log(`- Results: ${resultsData.length}`);
|
||||
|
||||
@ -1,31 +1,70 @@
|
||||
import Menu from "@/components/Menu";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import DashboardShell from "@/components/DashboardShell";
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
|
||||
export default function DashboardLayout({
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
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 (
|
||||
<div className="h-screen flex">
|
||||
{/* LEFT */}
|
||||
<div className="w-[14%] md:w-[8%] lg:w-[16%] xl:w-[14%] p-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center lg:justify-start gap-2"
|
||||
>
|
||||
<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>
|
||||
<DashboardShell
|
||||
role={role}
|
||||
userMetadata={userMetadata}
|
||||
canManageSchool={canManageSchool}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import FormContainer from "@/components/FormContainer";
|
||||
import ListFilterSort from "@/components/ListFilterSort";
|
||||
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 { Suspense } from "react";
|
||||
|
||||
type ClassList = Tables<"Class"> & { supervisor: Tables<"Teacher"> | null };
|
||||
|
||||
@ -79,6 +80,14 @@ const ClassListPage = async ({
|
||||
const p = page ? parseInt(page) : 1;
|
||||
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
|
||||
let query = supabase
|
||||
.from("Class")
|
||||
@ -86,20 +95,26 @@ const ClassListPage = async ({
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "supervisorId":
|
||||
query = query.eq("supervisorId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (value === undefined) continue;
|
||||
switch (key) {
|
||||
case "supervisorId":
|
||||
query = query.eq("supervisorId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
case "sortBy": {
|
||||
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
|
||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||
@ -114,23 +129,14 @@ const ClassListPage = async ({
|
||||
|
||||
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">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>
|
||||
{/* 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} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import FormContainer from "@/components/FormContainer";
|
||||
import ListFilterSort from "@/components/ListFilterSort";
|
||||
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 { Suspense } from "react";
|
||||
|
||||
type EventList = Tables<"Event"> & { class: Tables<"Class"> | null };
|
||||
|
||||
@ -96,6 +96,14 @@ const EventListPage = async ({
|
||||
const p = page ? parseInt(page) : 1;
|
||||
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
|
||||
let query = supabase
|
||||
.from("Event")
|
||||
@ -103,22 +111,26 @@ const EventListPage = async ({
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "search":
|
||||
query = query.ilike("title", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (value === undefined) continue;
|
||||
switch (key) {
|
||||
case "classId":
|
||||
if (value) query = query.eq("classId", parseInt(value));
|
||||
break;
|
||||
case "search":
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (!queryParams.sortBy) query = query.order("startTime", { ascending: true });
|
||||
|
||||
// PAGINATION
|
||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||
@ -133,23 +145,14 @@ const EventListPage = async ({
|
||||
|
||||
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">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>
|
||||
{/* 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} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
|
||||
147
src/app/(dashboard)/list/holidays/page.tsx
Normal file
147
src/app/(dashboard)/list/holidays/page.tsx
Normal 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;
|
||||
@ -1,20 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import FormContainer from "@/components/FormContainer";
|
||||
import ListFilterSort from "@/components/ListFilterSort";
|
||||
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 { Suspense } from "react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type LessonList = Tables<"Lesson"> & {
|
||||
subject: Tables<"Subject">;
|
||||
class: Tables<"Class">;
|
||||
teacher: Tables<"Teacher">;
|
||||
subject: Tables<"Subject"> | null;
|
||||
class: Tables<"Class"> | null;
|
||||
teacher: Tables<"Teacher"> | null;
|
||||
};
|
||||
|
||||
|
||||
const LessonListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
@ -27,9 +30,23 @@ const LessonListPage = async ({
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Subject Name",
|
||||
header: "Lesson",
|
||||
accessor: "lessonName",
|
||||
},
|
||||
{
|
||||
header: "Subject",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
header: "Date",
|
||||
accessor: "date",
|
||||
className: "hidden sm:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
accessor: "time",
|
||||
className: "hidden sm:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Class",
|
||||
accessor: "class",
|
||||
@ -39,7 +56,7 @@ const LessonListPage = async ({
|
||||
accessor: "teacher",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
...(role === "admin"
|
||||
...(role === "admin" || role === "teacher" || role === "student"
|
||||
? [
|
||||
{
|
||||
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
|
||||
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.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 className="hidden md:table-cell">
|
||||
{item.teacher ? item.teacher.name + " " + item.teacher.surname : "-"}
|
||||
</td>
|
||||
<td>
|
||||
<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" && (
|
||||
<>
|
||||
<FormContainer table="lesson" type="update" data={item} />
|
||||
@ -71,67 +111,110 @@ const LessonListPage = async ({
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Lesson")
|
||||
.select("*, subject:Subject(*), class:Class(*), teacher:Teacher(*)", { count: "exact" });
|
||||
// Filter options for ListFilterSort
|
||||
const [teachersRes, classesRes] = await Promise.all([
|
||||
supabase.from("Teacher").select("id, name, surname").order("name"),
|
||||
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) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "classId":
|
||||
query = query.eq("classId", parseInt(value));
|
||||
break;
|
||||
case "teacherId":
|
||||
query = query.eq("teacherId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.or(`subject.name.ilike.%${value}%,teacher.name.ilike.%${value}%,class.name.ilike.%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (value === undefined) continue;
|
||||
switch (key) {
|
||||
case "classId":
|
||||
query = query.eq("classId", parseInt(value));
|
||||
break;
|
||||
case "teacherId":
|
||||
query = query.eq("teacherId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
case "sortBy": {
|
||||
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);
|
||||
|
||||
const { data: rawData, count, error } = await query;
|
||||
const { data: lessons, count, error } = await query;
|
||||
|
||||
if (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 (
|
||||
<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">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>
|
||||
{/* 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} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
|
||||
108
src/app/(dashboard)/list/my-schools/page.tsx
Normal file
108
src/app/(dashboard)/list/my-schools/page.tsx
Normal 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;
|
||||
|
||||
101
src/app/(dashboard)/list/school-timetables/page.tsx
Normal file
101
src/app/(dashboard)/list/school-timetables/page.tsx
Normal 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;
|
||||
@ -25,9 +25,11 @@ const SingleStudentPage = async ({
|
||||
.from("Student")
|
||||
.select(`
|
||||
*,
|
||||
class:Class(
|
||||
*,
|
||||
lessons:Lesson(count)
|
||||
studentClasses:StudentClass(
|
||||
class:Class(
|
||||
*,
|
||||
lessons:Lesson(count)
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq("id", id)
|
||||
@ -37,17 +39,17 @@ const SingleStudentPage = async ({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Extract count from the array returned by PostgREST inner select count
|
||||
const lessonsCount = Array.isArray(data.class?.lessons)
|
||||
? (data.class?.lessons as any)[0]?.count || 0
|
||||
: 0;
|
||||
const classes = (data.studentClasses ?? []).map((sc: any) => ({
|
||||
...sc.class,
|
||||
_count: {
|
||||
lessons: Array.isArray(sc.class?.lessons) ? (sc.class.lessons[0]?.count ?? 0) : 0,
|
||||
},
|
||||
})).filter((c: any) => c.id != null);
|
||||
|
||||
const student = {
|
||||
...data,
|
||||
class: {
|
||||
...data.class,
|
||||
_count: { lessons: lessonsCount }
|
||||
}
|
||||
classes,
|
||||
class: classes[0],
|
||||
} as any;
|
||||
|
||||
if (!student) {
|
||||
@ -131,7 +133,7 @@ const SingleStudentPage = async ({
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{student.class.name.charAt(0)}th
|
||||
{student.class?.name?.charAt(0) ?? "-"}th
|
||||
</h1>
|
||||
<span className="text-sm text-gray-400">Grade</span>
|
||||
</div>
|
||||
@ -147,7 +149,7 @@ const SingleStudentPage = async ({
|
||||
/>
|
||||
<div className="">
|
||||
<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>
|
||||
<span className="text-sm text-gray-400">Lessons</span>
|
||||
</div>
|
||||
@ -162,8 +164,10 @@ const SingleStudentPage = async ({
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">{student.class.name}</h1>
|
||||
<span className="text-sm text-gray-400">Class</span>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{student.classes?.map((c: any) => c.name).filter(Boolean).join(", ") || "-"}
|
||||
</h1>
|
||||
<span className="text-sm text-gray-400">Classes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -171,7 +175,7 @@ const SingleStudentPage = async ({
|
||||
{/* BOTTOM */}
|
||||
<div className="mt-4 bg-white rounded-md p-4 h-[800px]">
|
||||
<h1>Student's Schedule</h1>
|
||||
<BigCalendarContainer type="classId" id={student.class.id} />
|
||||
<BigCalendarContainer type="classId" id={student.classes?.map((c: any) => c.id) ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
@ -179,30 +183,37 @@ const SingleStudentPage = async ({
|
||||
<div className="bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">Shortcuts</h1>
|
||||
<div className="mt-4 flex gap-4 flex-wrap text-xs text-gray-500">
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/lessons?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Lessons
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaPurpleLight"
|
||||
href={`/list/teachers?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Teachers
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-pink-50"
|
||||
href={`/list/exams?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Exams
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/assignments?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Assignments
|
||||
</Link>
|
||||
{student.classes?.length ? (
|
||||
<>
|
||||
{student.classes.slice(0, 3).map((c: any) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/lessons?classId=${c.id}`}
|
||||
>
|
||||
Lessons ({c.name})
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaPurpleLight"
|
||||
href={`/list/teachers?classId=${student.classes[0]?.id}`}
|
||||
>
|
||||
Teachers
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-pink-50"
|
||||
href={`/list/exams?classId=${student.classes[0]?.id}`}
|
||||
>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/assignments?classId=${student.classes[0]?.id}`}
|
||||
>
|
||||
Assignments
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaYellowLight"
|
||||
href={`/list/results?studentId=${student.id}`}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import FormContainer from "@/components/FormContainer";
|
||||
import ListFilterSort from "@/components/ListFilterSort";
|
||||
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";
|
||||
import { Suspense } from "react";
|
||||
|
||||
type StudentList = Tables<"Student"> & { class: Tables<"Class"> };
|
||||
type StudentList = Tables<"Student"> & {
|
||||
studentClasses: { classId: number; class: Tables<"Class"> }[];
|
||||
};
|
||||
|
||||
const StudentListPage = async ({
|
||||
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
|
||||
key={item.id}
|
||||
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">
|
||||
<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>
|
||||
</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.address}</td>
|
||||
<td>
|
||||
@ -95,41 +99,69 @@ const StudentListPage = async ({
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
// Note: we need lessons if teacherId is provided
|
||||
const [classesRes, teachersRes] = await Promise.all([
|
||||
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
|
||||
.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) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "teacherId":
|
||||
// Filter by teacherId within the joined lessons.
|
||||
// Supabase postgREST filters on JSON: class.lessons.teacherId=eq...
|
||||
// It's tricky to filter the main rows based on a nested condition.
|
||||
// Instead we can use an inner join via class!inner(lessons!inner(*)).
|
||||
query = query.eq("class.lessons.teacherId", value);
|
||||
// It might require adjustments, but RLS generally restricts this.
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (value === undefined) continue;
|
||||
switch (key) {
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
case "sortBy": {
|
||||
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
|
||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||
|
||||
let { data: rawData, count, error } = await query;
|
||||
@ -138,44 +170,18 @@ const StudentListPage = async ({
|
||||
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[];
|
||||
|
||||
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">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>
|
||||
{/* 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} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import FormContainer from "@/components/FormContainer";
|
||||
import ListFilterSort from "@/components/ListFilterSort";
|
||||
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 { ITEM_PER_PAGE } from "@/lib/settings";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { Suspense } from "react";
|
||||
|
||||
type TeacherList = Tables<"Teacher"> & { subjects: Tables<"Subject">[] } & { classes: Tables<"Class">[] };
|
||||
|
||||
@ -107,29 +108,38 @@ const TeacherListPage = async ({
|
||||
const p = page ? parseInt(page) : 1;
|
||||
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
|
||||
// Note: we need lessons if classId is provided
|
||||
let query = supabase
|
||||
.from("Teacher")
|
||||
.select("*, TeacherSubject(Subject(*)), classes:Class(*), lessons:Lesson(*)", { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "classId":
|
||||
// Filter by classId within the joined lessons.
|
||||
query = query.eq("lessons.classId", parseInt(value));
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (value === undefined) continue;
|
||||
switch (key) {
|
||||
case "classId":
|
||||
query = query.eq("lessons.classId", parseInt(value));
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
case "sortBy": {
|
||||
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
|
||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||
@ -161,25 +171,14 @@ const TeacherListPage = async ({
|
||||
|
||||
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">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>
|
||||
{/* 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} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
|
||||
171
src/app/(dashboard)/list/templates/[id]/page.tsx
Normal file
171
src/app/(dashboard)/list/templates/[id]/page.tsx
Normal 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;
|
||||
159
src/app/(dashboard)/list/templates/page.tsx
Normal file
159
src/app/(dashboard)/list/templates/page.tsx
Normal 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;
|
||||
152
src/app/(dashboard)/list/terms/page.tsx
Normal file
152
src/app/(dashboard)/list/terms/page.tsx
Normal 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;
|
||||
196
src/app/(dashboard)/list/timeslots/page.tsx
Normal file
196
src/app/(dashboard)/list/timeslots/page.tsx
Normal 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;
|
||||
@ -2,7 +2,6 @@ import Announcements from "@/components/Announcements";
|
||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { Tables } from "@/types/supabase";
|
||||
|
||||
|
||||
const ParentPage = async () => {
|
||||
@ -12,30 +11,35 @@ const ParentPage = async () => {
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: students, error } = await supabase
|
||||
.from("Student")
|
||||
.select("*")
|
||||
// RLS policies should handle identifying parent id
|
||||
// .eq("parentId", currentUserId!)
|
||||
.select("*, studentClasses:StudentClass(classId)");
|
||||
|
||||
if (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 (
|
||||
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="">
|
||||
{studentsList.map((student) => (
|
||||
<div className="w-full xl:w-2/3" key={student.id}>
|
||||
<div className="h-full bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">
|
||||
Schedule ({student.name + " " + student.surname})
|
||||
</h1>
|
||||
<BigCalendarContainer type="classId" id={student.classId} />
|
||||
{studentsList.map((student) => {
|
||||
const classIds = student.studentClasses?.map((sc) => sc.classId) ?? [];
|
||||
return (
|
||||
<div className="w-full xl:w-2/3" key={student.id}>
|
||||
<div className="h-full bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">
|
||||
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>
|
||||
{/* RIGHT */}
|
||||
<div className="w-full xl:w-1/3 flex flex-col gap-8">
|
||||
|
||||
@ -3,45 +3,33 @@ import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import BigCalendar from "@/components/BigCalender";
|
||||
import EventCalendar from "@/components/EventCalendar";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import { Tables } from "@/types/supabase";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
const StudentPage = async () => {
|
||||
const { userId } = auth();
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: studentItem, error: studentError } = await supabase
|
||||
.from("Student")
|
||||
const { data: studentClasses, error: linkError } = await supabase
|
||||
.from("StudentClass")
|
||||
.select("classId")
|
||||
.eq("id", userId!)
|
||||
.single();
|
||||
.eq("studentId", userId!);
|
||||
|
||||
if (studentError) {
|
||||
console.error("Error fetching student details:", studentError);
|
||||
if (linkError) {
|
||||
console.error("Error fetching student classes:", linkError);
|
||||
}
|
||||
|
||||
const { data: classItems, error } = await supabase
|
||||
.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;
|
||||
const classIds = (studentClasses ?? []).map((r) => r.classId);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex gap-4 flex-col xl:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="w-full xl:w-2/3">
|
||||
<div className="h-full bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">Schedule (4A)</h1>
|
||||
{studentClassId ? (
|
||||
<BigCalendarContainer type="classId" id={studentClassId} />
|
||||
<h1 className="text-xl font-semibold">Schedule</h1>
|
||||
{classIds.length > 0 ? (
|
||||
<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>
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import Announcements from "@/components/Announcements";
|
||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import { ensureTeacherOnboarding } from "@/lib/actions";
|
||||
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();
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
||||
{/* LEFT */}
|
||||
|
||||
14
src/app/(dashboard)/whiteboard/page.tsx
Normal file
14
src/app/(dashboard)/whiteboard/page.tsx
Normal 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;
|
||||
14
src/app/(fullscreen)/board/page.tsx
Normal file
14
src/app/(fullscreen)/board/page.tsx
Normal 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;
|
||||
11
src/app/(fullscreen)/layout.tsx
Normal file
11
src/app/(fullscreen)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { useUser } from "@clerk/nextjs";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const LoginPage = () => {
|
||||
const { isLoaded, isSignedIn, user } = useUser();
|
||||
@ -61,6 +62,15 @@ const LoginPage = () => {
|
||||
>
|
||||
Sign In
|
||||
</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.Root>
|
||||
</div>
|
||||
|
||||
67
src/app/teacher-sign-up/page.tsx
Normal file
67
src/app/teacher-sign-up/page.tsx
Normal 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;
|
||||
|
||||
@ -1,20 +1,31 @@
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import BigCalendar from "./BigCalender";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
const BigCalendarContainer = async ({
|
||||
type,
|
||||
id,
|
||||
}: {
|
||||
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();
|
||||
|
||||
let query = supabase.from("Lesson").select("*");
|
||||
if (schoolId) {
|
||||
query = query.eq("schoolId", schoolId);
|
||||
}
|
||||
|
||||
if (type === "teacherId") {
|
||||
query = query.eq("teacherId", id as string);
|
||||
} 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;
|
||||
@ -25,6 +36,7 @@ const BigCalendarContainer = async ({
|
||||
const dataRes = rawData || [];
|
||||
|
||||
const data = dataRes.map((lesson) => ({
|
||||
id: lesson.id,
|
||||
title: lesson.name,
|
||||
start: new Date(lesson.startTime),
|
||||
end: new Date(lesson.endTime),
|
||||
|
||||
@ -4,13 +4,15 @@ import { Calendar, momentLocalizer, View, Views } from "react-big-calendar";
|
||||
import moment from "moment";
|
||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
const localizer = momentLocalizer(moment);
|
||||
|
||||
const BigCalendar = ({
|
||||
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 [date, setDate] = useState<Date>(new Date());
|
||||
@ -23,7 +25,22 @@ const BigCalendar = ({
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
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"
|
||||
endAccessor="end"
|
||||
views={["month", "work_week", "day"]}
|
||||
|
||||
76
src/components/DashboardShell.tsx
Normal file
76
src/components/DashboardShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -15,7 +15,12 @@ export type FormContainerProps = {
|
||||
| "result"
|
||||
| "attendance"
|
||||
| "event"
|
||||
| "announcement";
|
||||
| "announcement"
|
||||
| "term"
|
||||
| "holiday"
|
||||
| "schoolTimetableSlot"
|
||||
| "timetableTemplate"
|
||||
| "timetableEntry";
|
||||
type: "create" | "update" | "delete";
|
||||
data?: any;
|
||||
id?: number | string;
|
||||
@ -26,6 +31,7 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
|
||||
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
|
||||
const currentUserId = userId;
|
||||
|
||||
if (type !== "delete") {
|
||||
@ -49,12 +55,12 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
|
||||
}
|
||||
case "student": {
|
||||
const { data: studentGrades } = await supabase.from("Grade").select("id, level");
|
||||
const { data: studentClasses } = await supabase.from("Class").select("*, students:Student(count)");
|
||||
const classesWithCount = studentClasses?.map(c => ({
|
||||
const { data: studentClasses } = await supabase.from("Class").select("*, studentClasses:StudentClass(count)");
|
||||
const classesWithCount = studentClasses?.map((c: any) => ({
|
||||
...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;
|
||||
}
|
||||
case "lesson": {
|
||||
@ -96,6 +102,49 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
|
||||
relatedData = { classes: announcementClasses };
|
||||
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:
|
||||
break;
|
||||
}
|
||||
|
||||
@ -11,6 +11,11 @@ import {
|
||||
deleteResult,
|
||||
deleteEvent,
|
||||
deleteAnnouncement,
|
||||
deleteTerm,
|
||||
deleteHoliday,
|
||||
deleteSchoolTimetableSlot,
|
||||
deleteTimetableTemplate,
|
||||
deleteTimetableEntry,
|
||||
} from "@/lib/actions";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
@ -34,6 +39,11 @@ const deleteActionMap = {
|
||||
attendance: deleteSubject,
|
||||
event: deleteEvent,
|
||||
announcement: deleteAnnouncement,
|
||||
term: deleteTerm,
|
||||
holiday: deleteHoliday,
|
||||
schoolTimetableSlot: deleteSchoolTimetableSlot,
|
||||
timetableTemplate: deleteTimetableTemplate,
|
||||
timetableEntry: deleteTimetableEntry,
|
||||
};
|
||||
|
||||
// USE LAZY LOADING
|
||||
@ -71,6 +81,21 @@ const EventForm = dynamic(() => import("./forms/EventForm"), {
|
||||
const AnnouncementForm = dynamic(() => import("./forms/AnnouncementForm"), {
|
||||
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
|
||||
|
||||
const forms: {
|
||||
@ -160,7 +185,46 @@ const forms: {
|
||||
setOpen={setOpen}
|
||||
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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
135
src/components/ListFilterSort.tsx
Normal file
135
src/components/ListFilterSort.tsx
Normal 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;
|
||||
@ -1,4 +1,3 @@
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import Image from "next/image";
|
||||
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",
|
||||
items: [
|
||||
@ -117,32 +167,84 @@ const menuItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const Menu = async () => {
|
||||
const user = await currentUser();
|
||||
const role = user?.publicMetadata.role as string;
|
||||
const Menu = ({
|
||||
role,
|
||||
isCollapsed,
|
||||
canManageSchool,
|
||||
}: {
|
||||
role: string;
|
||||
isCollapsed: boolean;
|
||||
canManageSchool: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-4 text-sm">
|
||||
{menuItems.map((i) => (
|
||||
<div className="flex flex-col gap-2" key={i.title}>
|
||||
<span className="hidden lg:block text-gray-400 font-light my-4">
|
||||
{i.title}
|
||||
</span>
|
||||
{i.items.map((item) => {
|
||||
if (item.visible.includes(role)) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
key={item.label}
|
||||
className="flex items-center justify-center lg:justify-start gap-4 text-gray-500 py-2 md:px-2 rounded-md hover:bg-lamaSkyLight"
|
||||
<div className="mt-4 text-sm w-full">
|
||||
{menuItems.map((section) => {
|
||||
const filteredItems = section.items.filter((item) => {
|
||||
if (!item.visible.includes(role)) return false;
|
||||
if (
|
||||
section.title === "SCHOOL CONFIG" &&
|
||||
role === "teacher" &&
|
||||
!canManageSchool
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
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} />
|
||||
<span className="hidden lg:block">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
{/* 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">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,38 +1,101 @@
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
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 user = await currentUser();
|
||||
const Navbar = ({
|
||||
role,
|
||||
userMetadata,
|
||||
toggleSidebar,
|
||||
isCollapsed
|
||||
}: {
|
||||
role: string;
|
||||
userMetadata: any;
|
||||
toggleSidebar: () => void;
|
||||
isCollapsed: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{/* 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 className="flex items-center justify-between p-4 bg-white shadow-sm z-10 sticky top-0">
|
||||
{/* LEFT AREA: Toggle and Animated Logo */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Hamburger Toggle */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-md hover:bg-gray-100 transition-colors text-gray-600 focus:outline-none focus:ring-2 focus:ring-lamaSky"
|
||||
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>
|
||||
{/* ICONS AND USER */}
|
||||
<div className="flex items-center gap-6 justify-end w-full">
|
||||
<div className="bg-white rounded-full w-7 h-7 flex items-center justify-center cursor-pointer">
|
||||
|
||||
{/* RIGHT: ICONS AND USER */}
|
||||
<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} />
|
||||
</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} />
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{user?.publicMetadata?.role as string}
|
||||
{role}
|
||||
</span>
|
||||
</div>
|
||||
{/* <Image src="/avatar.png" alt="" width={36} height={36} className="rounded-full"/> */}
|
||||
<UserButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
257
src/components/Whiteboard/WhiteboardCore.tsx
Normal file
257
src/components/Whiteboard/WhiteboardCore.tsx
Normal 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;
|
||||
129
src/components/forms/GenerateLessonsForm.tsx
Normal file
129
src/components/forms/GenerateLessonsForm.tsx
Normal 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;
|
||||
46
src/components/forms/GenerateTimetableForm.tsx
Normal file
46
src/components/forms/GenerateTimetableForm.tsx
Normal 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;
|
||||
115
src/components/forms/HolidayForm.tsx
Normal file
115
src/components/forms/HolidayForm.tsx
Normal 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;
|
||||
@ -83,25 +83,7 @@ const LessonForm = ({
|
||||
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
|
||||
label="Start Time"
|
||||
name="startTime"
|
||||
|
||||
132
src/components/forms/SchoolTimetableSlotForm.tsx
Normal file
132
src/components/forms/SchoolTimetableSlotForm.tsx
Normal 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;
|
||||
@ -11,6 +11,7 @@ import {
|
||||
teacherSchema,
|
||||
TeacherSchema,
|
||||
} from "@/lib/formValidationSchemas";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { useFormState } from "react-dom";
|
||||
import {
|
||||
createStudent,
|
||||
@ -35,10 +36,14 @@ const StudentForm = ({
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<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>();
|
||||
@ -219,31 +224,44 @@ const StudentForm = ({
|
||||
</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(
|
||||
(classItem: {
|
||||
id: number;
|
||||
name: string;
|
||||
capacity: number;
|
||||
_count: { students: number };
|
||||
}) => (
|
||||
<option value={classItem.id} key={classItem.id}>
|
||||
({classItem.name} -{" "}
|
||||
{classItem._count.students + "/" + classItem.capacity}{" "}
|
||||
Capacity)
|
||||
</option>
|
||||
)
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="text-xs text-gray-500">Classes</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="classIds"
|
||||
render={({ field: { value = [], onChange } }) => (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{classes.map(
|
||||
(classItem: {
|
||||
id: number;
|
||||
name: string;
|
||||
capacity: number;
|
||||
_count: { students: number };
|
||||
}) => (
|
||||
<label key={classItem.id} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(classItem.id)}
|
||||
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">
|
||||
{errors.classId.message.toString()}
|
||||
{errors.classIds.message.toString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
115
src/components/forms/TermForm.tsx
Normal file
115
src/components/forms/TermForm.tsx
Normal 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;
|
||||
194
src/components/forms/TimetableEntryForm.tsx
Normal file
194
src/components/forms/TimetableEntryForm.tsx
Normal 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;
|
||||
123
src/components/forms/TimetableTemplateForm.tsx
Normal file
123
src/components/forms/TimetableTemplateForm.tsx
Normal 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;
|
||||
1059
src/lib/actions.ts
1059
src/lib/actions.ts
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ export const subjectSchema = z.object({
|
||||
id: z.coerce.number().optional(),
|
||||
name: z.string().min(1, { message: "Subject name is required!" }),
|
||||
teachers: z.array(z.string()), //teacher ids
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
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!" }),
|
||||
gradeId: z.coerce.number().min(1, { message: "Grade name is required!" }),
|
||||
supervisorId: z.coerce.string().optional(),
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
export type ClassSchema = z.infer<typeof classSchema>;
|
||||
@ -72,8 +74,9 @@ export const studentSchema = z.object({
|
||||
birthday: z.coerce.date({ message: "Birthday is required!" }),
|
||||
sex: z.enum(["MALE", "FEMALE"], { message: "Sex 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!" }),
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
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!" }),
|
||||
endTime: z.coerce.date({ message: "End time is required!" }),
|
||||
lessonId: z.coerce.number({ message: "Lesson is required!" }),
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
export type ExamSchema = z.infer<typeof examSchema>;
|
||||
@ -91,12 +95,12 @@ export type ExamSchema = z.infer<typeof examSchema>;
|
||||
export const lessonSchema = z.object({
|
||||
id: z.coerce.number().optional(),
|
||||
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!" }),
|
||||
endTime: z.coerce.date({ message: "End time is required!" }),
|
||||
subjectId: z.coerce.number({ message: "Subject is required!" }),
|
||||
classId: z.coerce.number({ message: "Class is required!" }),
|
||||
teacherId: z.string({ message: "Teacher is required!" }),
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
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!" }),
|
||||
dueDate: z.coerce.date({ message: "Due date is required!" }),
|
||||
lessonId: z.coerce.number({ message: "Lesson is required!" }),
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
export type AssignmentSchema = z.infer<typeof assignmentSchema>;
|
||||
@ -117,6 +122,7 @@ export const resultSchema = z.object({
|
||||
studentId: z.string({ message: "Student is required!" }),
|
||||
examId: z.coerce.number().optional(),
|
||||
assignmentId: z.coerce.number().optional(),
|
||||
schoolId: z.string(),
|
||||
}).refine(data => data.examId || data.assignmentId, {
|
||||
message: "Either Exam or Assignment is required",
|
||||
path: ["examId"],
|
||||
@ -131,6 +137,7 @@ export const eventSchema = z.object({
|
||||
startTime: z.coerce.date({ message: "Start time is required!" }),
|
||||
endTime: z.coerce.date({ message: "End time is required!" }),
|
||||
classId: z.coerce.number().optional(),
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
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!" }),
|
||||
date: z.coerce.date({ message: "Date is required!" }),
|
||||
classId: z.coerce.number().optional(),
|
||||
schoolId: z.string(),
|
||||
});
|
||||
|
||||
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>;
|
||||
|
||||
@ -7,6 +7,7 @@ type RouteAccessMap = {
|
||||
export const routeAccessMap: RouteAccessMap = {
|
||||
"/admin(.*)": ["admin"],
|
||||
"/student(.*)": ["student"],
|
||||
"/teacher-sign-up": [], // public: allow unauthenticated (middleware skips redirect when role is null)
|
||||
"/teacher(.*)": ["teacher"],
|
||||
"/parent(.*)": ["parent"],
|
||||
"/list/teachers": ["admin", "teacher"],
|
||||
|
||||
95
src/lib/whiteboardActions.ts
Normal file
95
src/lib/whiteboardActions.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
@ -17,7 +17,7 @@ export default clerkMiddleware((auth, req) => {
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,17 +37,28 @@ export type Database = {
|
||||
Admin: {
|
||||
Row: {
|
||||
id: string
|
||||
schoolId: string
|
||||
username: string
|
||||
}
|
||||
Insert: {
|
||||
id: string
|
||||
schoolId: string
|
||||
username: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
schoolId?: string
|
||||
username?: string
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "Admin_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
Announcement: {
|
||||
Row: {
|
||||
@ -55,6 +66,7 @@ export type Database = {
|
||||
date: string
|
||||
description: string
|
||||
id: number
|
||||
schoolId: string
|
||||
title: string
|
||||
}
|
||||
Insert: {
|
||||
@ -62,6 +74,7 @@ export type Database = {
|
||||
date: string
|
||||
description: string
|
||||
id?: number
|
||||
schoolId: string
|
||||
title: string
|
||||
}
|
||||
Update: {
|
||||
@ -69,6 +82,7 @@ export type Database = {
|
||||
date?: string
|
||||
description?: string
|
||||
id?: number
|
||||
schoolId?: string
|
||||
title?: string
|
||||
}
|
||||
Relationships: [
|
||||
@ -79,6 +93,13 @@ export type Database = {
|
||||
referencedRelation: "Class"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Announcement_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
Assignment: {
|
||||
@ -86,6 +107,7 @@ export type Database = {
|
||||
dueDate: string
|
||||
id: number
|
||||
lessonId: number
|
||||
schoolId: string
|
||||
startDate: string
|
||||
title: string
|
||||
}
|
||||
@ -93,6 +115,7 @@ export type Database = {
|
||||
dueDate: string
|
||||
id?: number
|
||||
lessonId: number
|
||||
schoolId: string
|
||||
startDate: string
|
||||
title: string
|
||||
}
|
||||
@ -100,6 +123,7 @@ export type Database = {
|
||||
dueDate?: string
|
||||
id?: number
|
||||
lessonId?: number
|
||||
schoolId?: string
|
||||
startDate?: string
|
||||
title?: string
|
||||
}
|
||||
@ -111,6 +135,13 @@ export type Database = {
|
||||
referencedRelation: "Lesson"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Assignment_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
Attendance: {
|
||||
@ -119,6 +150,7 @@ export type Database = {
|
||||
id: number
|
||||
lessonId: number
|
||||
present: boolean
|
||||
schoolId: string
|
||||
studentId: string
|
||||
}
|
||||
Insert: {
|
||||
@ -126,6 +158,7 @@ export type Database = {
|
||||
id?: number
|
||||
lessonId: number
|
||||
present: boolean
|
||||
schoolId: string
|
||||
studentId: string
|
||||
}
|
||||
Update: {
|
||||
@ -133,6 +166,7 @@ export type Database = {
|
||||
id?: number
|
||||
lessonId?: number
|
||||
present?: boolean
|
||||
schoolId?: string
|
||||
studentId?: string
|
||||
}
|
||||
Relationships: [
|
||||
@ -143,6 +177,13 @@ export type Database = {
|
||||
referencedRelation: "Lesson"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Attendance_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Attendance_studentId_fkey"
|
||||
columns: ["studentId"]
|
||||
@ -158,6 +199,7 @@ export type Database = {
|
||||
gradeId: number
|
||||
id: number
|
||||
name: string
|
||||
schoolId: string
|
||||
supervisorId: string | null
|
||||
}
|
||||
Insert: {
|
||||
@ -165,6 +207,7 @@ export type Database = {
|
||||
gradeId: number
|
||||
id?: number
|
||||
name: string
|
||||
schoolId: string
|
||||
supervisorId?: string | null
|
||||
}
|
||||
Update: {
|
||||
@ -172,6 +215,7 @@ export type Database = {
|
||||
gradeId?: number
|
||||
id?: number
|
||||
name?: string
|
||||
schoolId?: string
|
||||
supervisorId?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
@ -182,6 +226,13 @@ export type Database = {
|
||||
referencedRelation: "Grade"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Class_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Class_supervisorId_fkey"
|
||||
columns: ["supervisorId"]
|
||||
@ -197,6 +248,7 @@ export type Database = {
|
||||
description: string
|
||||
endTime: string
|
||||
id: number
|
||||
schoolId: string
|
||||
startTime: string
|
||||
title: string
|
||||
}
|
||||
@ -205,6 +257,7 @@ export type Database = {
|
||||
description: string
|
||||
endTime: string
|
||||
id?: number
|
||||
schoolId: string
|
||||
startTime: string
|
||||
title: string
|
||||
}
|
||||
@ -213,6 +266,7 @@ export type Database = {
|
||||
description?: string
|
||||
endTime?: string
|
||||
id?: number
|
||||
schoolId?: string
|
||||
startTime?: string
|
||||
title?: string
|
||||
}
|
||||
@ -224,6 +278,13 @@ export type Database = {
|
||||
referencedRelation: "Class"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Event_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
Exam: {
|
||||
@ -231,6 +292,7 @@ export type Database = {
|
||||
endTime: string
|
||||
id: number
|
||||
lessonId: number
|
||||
schoolId: string
|
||||
startTime: string
|
||||
title: string
|
||||
}
|
||||
@ -238,6 +300,7 @@ export type Database = {
|
||||
endTime: string
|
||||
id?: number
|
||||
lessonId: number
|
||||
schoolId: string
|
||||
startTime: string
|
||||
title: string
|
||||
}
|
||||
@ -245,6 +308,7 @@ export type Database = {
|
||||
endTime?: string
|
||||
id?: number
|
||||
lessonId?: number
|
||||
schoolId?: string
|
||||
startTime?: string
|
||||
title?: string
|
||||
}
|
||||
@ -256,6 +320,13 @@ export type Database = {
|
||||
referencedRelation: "Lesson"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Exam_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
Grade: {
|
||||
@ -273,33 +344,81 @@ export type Database = {
|
||||
}
|
||||
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: {
|
||||
Row: {
|
||||
classId: number
|
||||
day: Database["public"]["Enums"]["Day"]
|
||||
endTime: string
|
||||
id: number
|
||||
name: string
|
||||
schoolId: string
|
||||
startTime: string
|
||||
subjectId: number
|
||||
teacherId: string
|
||||
}
|
||||
Insert: {
|
||||
classId: number
|
||||
day: Database["public"]["Enums"]["Day"]
|
||||
endTime: string
|
||||
id?: number
|
||||
name: string
|
||||
schoolId: string
|
||||
startTime: string
|
||||
subjectId: number
|
||||
teacherId: string
|
||||
}
|
||||
Update: {
|
||||
classId?: number
|
||||
day?: Database["public"]["Enums"]["Day"]
|
||||
endTime?: string
|
||||
id?: number
|
||||
name?: string
|
||||
schoolId?: string
|
||||
startTime?: string
|
||||
subjectId?: number
|
||||
teacherId?: string
|
||||
@ -312,6 +431,13 @@ export type Database = {
|
||||
referencedRelation: "Class"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Lesson_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Lesson_subjectId_fkey"
|
||||
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: {
|
||||
Row: {
|
||||
address: string
|
||||
@ -336,6 +503,7 @@ export type Database = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
schoolId: string
|
||||
surname: string
|
||||
username: string
|
||||
}
|
||||
@ -346,6 +514,7 @@ export type Database = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
schoolId: string
|
||||
surname: string
|
||||
username: string
|
||||
}
|
||||
@ -356,16 +525,26 @@ export type Database = {
|
||||
id?: string
|
||||
name?: string
|
||||
phone?: string
|
||||
schoolId?: string
|
||||
surname?: string
|
||||
username?: string
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "Parent_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
Result: {
|
||||
Row: {
|
||||
assignmentId: number | null
|
||||
examId: number | null
|
||||
id: number
|
||||
schoolId: string
|
||||
score: number
|
||||
studentId: string
|
||||
}
|
||||
@ -373,6 +552,7 @@ export type Database = {
|
||||
assignmentId?: number | null
|
||||
examId?: number | null
|
||||
id?: number
|
||||
schoolId: string
|
||||
score: number
|
||||
studentId: string
|
||||
}
|
||||
@ -380,6 +560,7 @@ export type Database = {
|
||||
assignmentId?: number | null
|
||||
examId?: number | null
|
||||
id?: number
|
||||
schoolId?: string
|
||||
score?: number
|
||||
studentId?: string
|
||||
}
|
||||
@ -398,6 +579,13 @@ export type Database = {
|
||||
referencedRelation: "Exam"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Result_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Result_studentId_fkey"
|
||||
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: {
|
||||
Row: {
|
||||
address: string
|
||||
birthday: string
|
||||
bloodType: string
|
||||
classId: number
|
||||
createdAt: string
|
||||
email: string | null
|
||||
gradeId: number
|
||||
@ -421,6 +766,7 @@ export type Database = {
|
||||
name: string
|
||||
parentId: string
|
||||
phone: string | null
|
||||
schoolId: string
|
||||
sex: Database["public"]["Enums"]["UserSex"]
|
||||
surname: string
|
||||
username: string
|
||||
@ -429,7 +775,6 @@ export type Database = {
|
||||
address: string
|
||||
birthday: string
|
||||
bloodType: string
|
||||
classId: number
|
||||
createdAt?: string
|
||||
email?: string | null
|
||||
gradeId: number
|
||||
@ -438,6 +783,7 @@ export type Database = {
|
||||
name: string
|
||||
parentId: string
|
||||
phone?: string | null
|
||||
schoolId: string
|
||||
sex: Database["public"]["Enums"]["UserSex"]
|
||||
surname: string
|
||||
username: string
|
||||
@ -446,7 +792,6 @@ export type Database = {
|
||||
address?: string
|
||||
birthday?: string
|
||||
bloodType?: string
|
||||
classId?: number
|
||||
createdAt?: string
|
||||
email?: string | null
|
||||
gradeId?: number
|
||||
@ -455,18 +800,12 @@ export type Database = {
|
||||
name?: string
|
||||
parentId?: string
|
||||
phone?: string | null
|
||||
schoolId?: string
|
||||
sex?: Database["public"]["Enums"]["UserSex"]
|
||||
surname?: string
|
||||
username?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "Student_classId_fkey"
|
||||
columns: ["classId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "Class"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "Student_gradeId_fkey"
|
||||
columns: ["gradeId"]
|
||||
@ -481,22 +820,70 @@ export type Database = {
|
||||
referencedRelation: "Parent"
|
||||
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: {
|
||||
Row: {
|
||||
id: number
|
||||
name: string
|
||||
schoolId: string
|
||||
}
|
||||
Insert: {
|
||||
id?: number
|
||||
name: string
|
||||
schoolId: string
|
||||
}
|
||||
Update: {
|
||||
id?: number
|
||||
name?: string
|
||||
schoolId?: string
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "Subject_schoolId_fkey"
|
||||
columns: ["schoolId"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "School"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
Teacher: {
|
||||
Row: {
|
||||
@ -543,6 +930,42 @@ export type Database = {
|
||||
}
|
||||
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: {
|
||||
Row: {
|
||||
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: {
|
||||
[_ in never]: never
|
||||
@ -599,6 +1178,7 @@ export type Database = {
|
||||
}
|
||||
Enums: {
|
||||
Day: "MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY"
|
||||
SchoolType: "MANAGED" | "INDEPENDENT" | "AGENCY"
|
||||
UserSex: "MALE" | "FEMALE"
|
||||
}
|
||||
CompositeTypes: {
|
||||
@ -731,6 +1311,7 @@ export const Constants = {
|
||||
public: {
|
||||
Enums: {
|
||||
Day: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"],
|
||||
SchoolType: ["MANAGED", "INDEPENDENT", "AGENCY"],
|
||||
UserSex: ["MALE", "FEMALE"],
|
||||
},
|
||||
},
|
||||
|
||||
46
supabase/fullstack-school.erd
Normal file
46
supabase/fullstack-school.erd
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
15
supabase/migrations/20260301215434_lesson_whiteboard_rls.sql
Normal file
15
supabase/migrations/20260301215434_lesson_whiteboard_rls.sql
Normal 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')
|
||||
);
|
||||
119
supabase/migrations/20260305000000_schools_and_timetables.sql
Normal file
119
supabase/migrations/20260305000000_schools_and_timetables.sql
Normal 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;
|
||||
@ -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()
|
||||
);
|
||||
|
||||
126
supabase/migrations/20260305020000_timetable_rls_policies.sql
Normal file
126
supabase/migrations/20260305020000_timetable_rls_policies.sql
Normal 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")
|
||||
)
|
||||
);
|
||||
|
||||
@ -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")
|
||||
);
|
||||
|
||||
@ -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")
|
||||
)
|
||||
);
|
||||
22
supabase/migrations/20260305110000_sync_lesson_sequence.sql
Normal file
22
supabase/migrations/20260305110000_sync_lesson_sequence.sql
Normal 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;
|
||||
@ -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()
|
||||
);
|
||||
117
supabase/migrations/20260308000000_student_many_classes.sql
Normal file
117
supabase/migrations/20260308000000_student_many_classes.sql
Normal 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.
|
||||
@ -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
19
test_query.ts
Normal 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();
|
||||
Loading…
x
Reference in New Issue
Block a user