major major
This commit is contained in:
parent
ea95bf965f
commit
5e61f40911
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": {}
|
||||||
|
}
|
||||||
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": {
|
"teacherMap": {
|
||||||
"1": "user_3AMTkSb5aZsaWlYnCd2x5iImprs",
|
"1": "user_3AcluevCik3awerLuiRklEiYlJK",
|
||||||
"2": "user_3AMTkUkKWrV9QWt9dvD2qOjoxiE",
|
"2": "user_3AclukmobwQyj1tnS2EpfGIEt6R",
|
||||||
"3": "user_3AMTkRjafcujtrgbyS4SNoWFgjh",
|
"3": "user_3Aclul7evfG5xOADBZnREFuhYJf",
|
||||||
"4": "user_3AMTkfb5Y9O7a55cye9iQAHxLAB",
|
"4": "user_3Aclum88YIVFMJy42nhED1n0sYr",
|
||||||
"5": "user_3AMTkaTAmH3VOUCcxgeCfSTaaIX",
|
"5": "user_3AclusjcjWN31nZipvNmfMnxliI",
|
||||||
"6": "user_3AMTkZQ1Jea1Mdc4gT9qqwhX9zb",
|
"6": "user_3Acluy4wczfAKj0LgYUpqXx1Dwo",
|
||||||
"7": "user_3AMTkio1bTs7CEgKbXJQuJkcDYu",
|
"7": "user_3AcluzotsLfMQe83PxIpQ5o1Ajq",
|
||||||
"8": "user_3AMTkkYnJUOui3Z73yZlx7KNg21",
|
"8": "user_3AcluuRXAW5Y0pfUI0bHKIl5PEp",
|
||||||
"9": "user_3AMTkmAn0DTuaxFdyR4ii1oPQr7",
|
"9": "user_3Aclv3yUI1stBwX8v1Y6BZw6xBB",
|
||||||
"10": "user_3AMTkvIYftbqLCpyuhSnCp0Xdcv",
|
"10": "user_3Aclv3EpmOTEXnSOCyXFuvkDGDP",
|
||||||
"11": "user_3AMTksKS8GKlkstBeBndBvDSMi8",
|
"11": "user_3AclvFBzxeP3PqdRjT9Wcuy5Wvw",
|
||||||
"12": "user_3AMTkqRKtBPzjilxeONx344GQJS",
|
"12": "user_3AclvEh6mSOExxYPUjP5iqE06C8",
|
||||||
"13": "user_3AMTkx8ZtB15yOcnEDgoAic3Z60",
|
"13": "user_3AclvCPsCtWmKKQplr2KfKAlAna",
|
||||||
"14": "user_3AMTkz65CADkvCgyC5fh2qRxRQV",
|
"14": "user_3AclvFUjLy3SwIL3egEjVmlUzDx",
|
||||||
"15": "user_3AMTkzAvbz587Hc36Kvk4MXIBsL"
|
"15": "user_3AclvGYnbjRTd8unbv6bj0LJlIy"
|
||||||
},
|
},
|
||||||
"parentMap": {
|
"parentMap": {
|
||||||
"1": "user_3AMTl8n08l1L5ierzRsTYWMdFZP",
|
"1": "user_3AclvXcXbK4reTdS7oNXcwYOPLF",
|
||||||
"2": "user_3AMTl8u9bFoUz7mFK14Zhh85K6X",
|
"2": "user_3AclvcMY65f4gujVmJwfTElOL4I",
|
||||||
"3": "user_3AMTlCd06BO0Y9WoBWXy2ictYYz",
|
"3": "user_3AclvW0MF1AqFkRcapoJ7lFBTyi",
|
||||||
"4": "user_3AMTlCMBaMKRQv5BCQPX46Iv5xK",
|
"4": "user_3AclvgyTfukFFSnuZ7Ka3XQsBTB",
|
||||||
"5": "user_3AMTlGoIV7fNrrYhuOjoyTs1h1a",
|
"5": "user_3Aclvg9OfhvlTzwWluw2eSNNXRP",
|
||||||
"6": "user_3AMTlGZpGBcdicxrK5pjYa901Hv",
|
"6": "user_3AclvjgJNIhAzMelhWTzaIBU3q2",
|
||||||
"7": "user_3AMTlQ4wTNdagORX0RmbdfDPdFT",
|
"7": "user_3AclvexY0qT0klmi2YEx9g0j2kn",
|
||||||
"8": "user_3AMTlLQtI62V185SDKt3mge25gk",
|
"8": "user_3Aclvt7rWlBsErcFgMzY32PTtZk",
|
||||||
"9": "user_3AMTlMjordV5M4W13cg8XiQGMaP",
|
"9": "user_3Aclvm2ako1pUz9zD0sxq0zcrtB",
|
||||||
"10": "user_3AMTlZKLWrUlRjeCFCiGBOmqI5l",
|
"10": "user_3AclvpzxKyOlI6yhS83zam9B3Ty",
|
||||||
"11": "user_3AMTlWlRgP2UBskbHenAXzqzNjn",
|
"11": "user_3AclvvlQgtOnlcymUW2FaM5Tk9I",
|
||||||
"12": "user_3AMTlbgjdcsv8HKKnlpWoA42jiW",
|
"12": "user_3Aclvv0Jn0UrL7KVTswpJLojIVL",
|
||||||
"13": "user_3AMTlauWbEOSp4xb9NzBTu7uNeI",
|
"13": "user_3AclvuAT2eVlEp8qFQg3dW7v5Nw",
|
||||||
"14": "user_3AMTlfzhy2jrbLrhCBaUSB6LwfW",
|
"14": "user_3Aclw79caQ2PJ7L6F0MzeyDKEqq",
|
||||||
"15": "user_3AMTll6wXmJI0KjTUvbXZZvSlGq",
|
"15": "user_3Aclw72NNounCPLb27uNI1qJqY5",
|
||||||
"16": "user_3AMTlosVB9G5Nr8ZSY4JkVtqGqT",
|
"16": "user_3Aclw7mjthXqbQsI2XKEsWJlUbp",
|
||||||
"17": "user_3AMTlkMhTzRgPBEHDfu47qtwJ6i",
|
"17": "user_3AclwDc08JgsMxWBIjFXFuVkKId",
|
||||||
"18": "user_3AMTlrbxKFS3HnwNQVKJaLNF3X7",
|
"18": "user_3AclwBWEplMgRAJ9eLzGqQVJSCI",
|
||||||
"19": "user_3AMTlqQnvCBDhiH4qrUChkNxbE5",
|
"19": "user_3AclwBj5Qzo9d5yF1S4AT7OEwEN",
|
||||||
"20": "user_3AMTm3oZYPIAnNdzN63xfpV4QPI",
|
"20": "user_3AclwKBjFVhqiC2KIvFGhqkdkFP",
|
||||||
"21": "user_3AMTm5OrIhMbQ325RYoCa0HAZlh",
|
"21": "user_3AclwIhCpEdcksHaZCzlFJIFQgD",
|
||||||
"22": "user_3AMTmB28qoyEFvcUlhXzaHVDJL9",
|
"22": "user_3AclwHyw3NQ3hwP77tgqBw5S0Nq",
|
||||||
"23": "user_3AMTmAPi7FmXoUirnikOnGfKIlO",
|
"23": "user_3AclwTy1XzFeeR0dz9ToErN5f3t",
|
||||||
"24": "user_3AMTmCaqV2OPnTRJk3Z902P9nos",
|
"24": "user_3AclwQsdiL8JzC4g3vbogU2XcN6",
|
||||||
"25": "user_3AMTmCnG8IyhdHGTskXDIWfVgKK"
|
"25": "user_3AclwU8IiWYz0LKR2tO7jEHVdAF"
|
||||||
},
|
},
|
||||||
"studentMap": {
|
"studentMap": {
|
||||||
"1": "user_3AMTmFbsTvd1bTYxbuaiJEWEib6",
|
"1": "user_3AclwbE0uJkJGKuHp1Zpn8gMsmQ",
|
||||||
"2": "user_3AMTmLp1BkbpUdSDn9ynA4Y6JF2",
|
"2": "user_3AclwWTpwvqyBqUMWkDxtAY5c7J",
|
||||||
"3": "user_3AMTmNTKwZ70qJn1VqqOgsMsn6B",
|
"3": "user_3AclwYNQel4ZU9gIhTeStiF1Ze6",
|
||||||
"4": "user_3AMTmOWj8vhMDBh9wsO2rWFEfO3",
|
"4": "user_3AclwYrAL1BHDdL3WX8IhZktQN4",
|
||||||
"5": "user_3AMTmVJMwFUQ5xphwLHFFoCJ5hl",
|
"5": "user_3Aclwk98pbn7UMCwJtUNJCvDluj",
|
||||||
"6": "user_3AMTmVAfVEpQldG5aEZYNoObmqE",
|
"6": "user_3AclwgmspnQjXqsqSgxbM5Bibqj",
|
||||||
"7": "user_3AMTmXvXbUJ0hbMSGy2GxR4XBq3",
|
"7": "user_3AclwhuDRVreoGet0J0uuV1kC8E",
|
||||||
"8": "user_3AMTmTZJJUaHpC6vPrRnlNQjzV1",
|
"8": "user_3AclwnpM8BXNEJPeqnutsmVWMho",
|
||||||
"9": "user_3AMTmenWIejkaHVpU9ECpu2ym0b",
|
"9": "user_3Aclwp8KhfBigj2v1dBhnAcEZR6",
|
||||||
"10": "user_3AMTmb2na93TGqMYW6dAO7bRhzZ",
|
"10": "user_3AclwnecWiKC7tTa5d2lbqQtBI4",
|
||||||
"11": "user_3AMTmmAhGdPyG73SqbZy65xo4zR",
|
"11": "user_3AclwvZ3xeaPpIee1GZSaF0ZcjZ",
|
||||||
"12": "user_3AMTmiNz7s34137Vm7R40tldWFj",
|
"12": "user_3Aclx07Hzs19WqTDY5XgkZOlA5Z",
|
||||||
"13": "user_3AMTmhhd6xR4jQhqlytwF4JVc0N",
|
"13": "user_3Aclx0waQhbfrqHIuIbBI7xh4sQ",
|
||||||
"14": "user_3AMTmud3UuGWrvcCstxciqaRhJP",
|
"14": "user_3Aclx1W0bqTDhFm5aXFaWhT3Zpj",
|
||||||
"15": "user_3AMTmwPzZcSKvVE1vCYagb4IrQO",
|
"15": "user_3Aclx90O8i0uRp3QhFf4vdGRgJO",
|
||||||
"16": "user_3AMTmvzkaBKZs4f0qwc4Q2L0GNe",
|
"16": "user_3Aclx3Wudlyg6UrLuYVPpisc9Kw",
|
||||||
"17": "user_3AMTn3lCnHDzMShKlenMMPkDtoA",
|
"17": "user_3Aclx1pknqQHygFrrfgBSbQM8P5",
|
||||||
"18": "user_3AMTn3bE5un9OCnYr0iwftXrPPy",
|
"18": "user_3AclxA2diOV37Z9RkTkFFxVRJCI",
|
||||||
"19": "user_3AMTn4QZQqYQIps95xfehqdp4dt",
|
"19": "user_3AclxEYCVHlfshKyPTciQqBStYt",
|
||||||
"20": "user_3AMTn7sakhjRDyoKY7LmjcudCMn",
|
"20": "user_3AclxACCapDzlCapyET2IOl3TMi",
|
||||||
"21": "user_3AMTnIgyMm8qJDxqYczGISUl4SU",
|
"21": "user_3AclxOhxWRx6Wxq2FUoRECA2Irr",
|
||||||
"22": "user_3AMTnJj4Nd0oVkAZRssvRMAVbk7",
|
"22": "user_3AclxLC8W2o7R6MPkIbNVM4Qltw",
|
||||||
"23": "user_3AMTnN15QzCO98nkpYo5eD34R5S",
|
"23": "user_3AclxORMxzSDn0gFyODb5hEZZOM",
|
||||||
"24": "user_3AMTnM51Kdx7VxvbO7pIxIKCHcs",
|
"24": "user_3AclxQSp6TOwDfRhMDfOGrYDnzc",
|
||||||
"25": "user_3AMTnPCxefgX1zECfff75vIMQEG",
|
"25": "user_3AclxUWnaRkJilrFTa8KSadPIOQ",
|
||||||
"26": "user_3AMTnXwI5GcroWoCvDREunhXuLA",
|
"26": "user_3AclxVh9iksSofGVSlHhZuzzP8p",
|
||||||
"27": "user_3AMTnVoqmZanh2rqRKylP2KFPU2",
|
"27": "user_3AclxXf5JQwlyBFRQa3l1J2Z8H7",
|
||||||
"28": "user_3AMTnUCYRW9lfzGCj2X1agCKBR3",
|
"28": "user_3AclxXRWh9OG3aOGZ75fxmoIrTy",
|
||||||
"29": "user_3AMTnV2uWRevLmHMpAgcLXpiM3k",
|
"29": "user_3Aclxd1tcajIoLs0ss92G93plxc",
|
||||||
"30": "user_3AMTnaDOgQgbZmJAcIJTX3exNeq",
|
"30": "user_3AclxlTKEK90s8UxTh2t1kZ8ckq",
|
||||||
"31": "user_3AMTnaVlrBUic7iwIUBXZlrBNYp",
|
"31": "user_3AclxitgziRzcyQ6ZrCktA9F7SL",
|
||||||
"32": "user_3AMTng8nP5U5uzriwfkvmX1LbVo",
|
"32": "user_3Aclxl3jUH7ZkLDT60bygOcSXnp",
|
||||||
"33": "user_3AMTnje4x1ektrJKGRUulxI2kJz",
|
"33": "user_3Aclxn9ZCLy55ov2bwc3OmR61nV",
|
||||||
"34": "user_3AMTnnDJTdBhWYZZXl2mrlGHpgc",
|
"34": "user_3Aclxqhy4nWJgAziVYw55XC9o3p",
|
||||||
"35": "user_3AMTnoayLsbHr2ZmuUcuP40RImH",
|
"35": "user_3Aclxs3SiQx0F6eAoeZkoieoIRb",
|
||||||
"36": "user_3AMTnupHywRyiHEgfOYls192hUs",
|
"36": "user_3AclxvfvGobbMne72Yxhmu7Fffg",
|
||||||
"37": "user_3AMTnvI2VJoPiARcUuO7hP00C0m",
|
"37": "user_3Aclxu8hSwFVmjUgWVyXDuYDs6Z",
|
||||||
"38": "user_3AMTnwh5t3ngn4U98Il3Ba9zn3b",
|
"38": "user_3Aclxu20dFV0lzMyydeshUZOEV5",
|
||||||
"39": "user_3AMTnyC41WG5cG9ryBTXXazSXp2",
|
"39": "user_3Acly3gPFSpK7hQmMCJZSAuYeIF",
|
||||||
"40": "user_3AMTo4h5975zlCH2h1GsSj4lmab",
|
"40": "user_3Acly45oxORkyRFuVfBLXIwNYGU",
|
||||||
"41": "user_3AMTo3S5SZW96jfUZhZ1qr80BSe",
|
"41": "user_3Acly8AmTgp5Kwt7wVcJXU3GTYN",
|
||||||
"42": "user_3AMTo7ubYy6cgkvVbIClDnxR9UW",
|
"42": "user_3AclyEuWVgcFXYDdX9od3suZOiT",
|
||||||
"43": "user_3AMToANk7wfWM411FkpwodGtz6J",
|
"43": "user_3AclyBfcG4U2GQwIFGf4g2Hrsml",
|
||||||
"44": "user_3AMTo7Jb4xAnwiaUWdUqVPns950",
|
"44": "user_3AclyGTRbrzucyRS67zME9Z97x5",
|
||||||
"45": "user_3AMToDel0WihAMkoD28W7pgSoFf",
|
"45": "user_3AclyHWmt9JWT1n19gHmxb4E5pl",
|
||||||
"46": "user_3AMToGI2FXT9ZRi8WRoADjvu03i",
|
"46": "user_3AclyJah0uOBB8CB1QczkiMfq4L",
|
||||||
"47": "user_3AMToDID2tATAjoVnaaxToKon3R",
|
"47": "user_3AclyO2ceOwUYl7f3u7e3kDJE2S",
|
||||||
"48": "user_3AMToLwHwVZYvznCjPa58b29eQE",
|
"48": "user_3AclyTZCkT3qO9afGXrYfap7Shu",
|
||||||
"49": "user_3AMToRmg19Bu1CwZ8rGz4ODkxjS",
|
"49": "user_3AclyRDenLpSPEbgA3naueu3egB",
|
||||||
"50": "user_3AMToPjQulU7sRNtDe83TAnxnY1"
|
"50": "user_3AclyVSlp4TstXlvS1SMfp6Spl4"
|
||||||
},
|
},
|
||||||
"classes": [
|
"classes": [
|
||||||
{
|
{
|
||||||
@ -101,42 +101,53 @@
|
|||||||
"name": "1A",
|
"name": "1A",
|
||||||
"gradeId": 1,
|
"gradeId": 1,
|
||||||
"capacity": 20,
|
"capacity": 20,
|
||||||
"supervisorId": "user_3AMTkSb5aZsaWlYnCd2x5iImprs"
|
"supervisorId": "user_3AcluevCik3awerLuiRklEiYlJK",
|
||||||
|
"schoolId": "default-school-1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"name": "2A",
|
"name": "2A",
|
||||||
"gradeId": 2,
|
"gradeId": 2,
|
||||||
"capacity": 20,
|
"capacity": 20,
|
||||||
"supervisorId": "user_3AMTkUkKWrV9QWt9dvD2qOjoxiE"
|
"supervisorId": "user_3AclukmobwQyj1tnS2EpfGIEt6R",
|
||||||
|
"schoolId": "default-school-1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"name": "3A",
|
"name": "3A",
|
||||||
"gradeId": 3,
|
"gradeId": 3,
|
||||||
"capacity": 20,
|
"capacity": 20,
|
||||||
"supervisorId": "user_3AMTkRjafcujtrgbyS4SNoWFgjh"
|
"supervisorId": "user_3Aclul7evfG5xOADBZnREFuhYJf",
|
||||||
|
"schoolId": "default-school-1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"name": "4A",
|
"name": "4A",
|
||||||
"gradeId": 4,
|
"gradeId": 4,
|
||||||
"capacity": 20,
|
"capacity": 20,
|
||||||
"supervisorId": "user_3AMTkfb5Y9O7a55cye9iQAHxLAB"
|
"supervisorId": "user_3Aclum88YIVFMJy42nhED1n0sYr",
|
||||||
|
"schoolId": "default-school-1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"name": "5A",
|
"name": "5A",
|
||||||
"gradeId": 5,
|
"gradeId": 5,
|
||||||
"capacity": 20,
|
"capacity": 20,
|
||||||
"supervisorId": "user_3AMTkaTAmH3VOUCcxgeCfSTaaIX"
|
"supervisorId": "user_3AclusjcjWN31nZipvNmfMnxliI",
|
||||||
|
"schoolId": "default-school-1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"name": "6A",
|
"name": "6A",
|
||||||
"gradeId": 6,
|
"gradeId": 6,
|
||||||
"capacity": 20,
|
"capacity": 20,
|
||||||
"supervisorId": "user_3AMTkSb5aZsaWlYnCd2x5iImprs"
|
"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...");
|
console.log("Cleaning up Supabase tables...");
|
||||||
const tables = [
|
const tables = [
|
||||||
"Result", "Assignment", "Exam", "Attendance", "Event", "Announcement",
|
"Result", "Assignment", "Exam", "Attendance", "Event", "Announcement",
|
||||||
"Lesson", "TeacherSubject", "Student", "Teacher", "Parent", "Class", "Subject", "Grade"
|
"Lesson", "TeacherSchool", "TeacherSubject", "StudentClass", "Student", "Teacher", "Parent", "Class", "Subject", "Grade", "School", "Admin"
|
||||||
];
|
];
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
await supabase.from(table).delete().neq("id", "0" as any);
|
await supabase.from(table).delete().neq("id", "0" as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedAdmin() {
|
type AdminSeedInfo = {
|
||||||
console.log("Syncing Admin...");
|
adminId: string;
|
||||||
|
defaultSchoolId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seedAdmin(): Promise<AdminSeedInfo | null> {
|
||||||
|
console.log("Syncing Admin and Creating Default School...");
|
||||||
const clerk = clerkClient();
|
const clerk = clerkClient();
|
||||||
const users = await clerk.users.getUserList({ limit: 100 });
|
const users = await clerk.users.getUserList({ limit: 100 });
|
||||||
const adminUser = users.data.find(u => u.username === "admin" || u.emailAddresses[0]?.emailAddress?.includes("admin"));
|
const adminUser = users.data.find(u => u.username === "admin" || u.emailAddresses[0]?.emailAddress?.includes("admin"));
|
||||||
|
|
||||||
if (adminUser) {
|
if (adminUser) {
|
||||||
await supabase.from("Admin").upsert({ id: adminUser.id, username: adminUser.username || "admin" });
|
const defaultSchoolId = "default-school-1";
|
||||||
console.log(`Synced Admin ID: ${adminUser.id}`);
|
await supabase.from("School").upsert({
|
||||||
|
id: defaultSchoolId,
|
||||||
|
name: "Lama Academy",
|
||||||
|
type: "MANAGED",
|
||||||
|
adminId: adminUser.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("Admin").upsert({
|
||||||
|
id: adminUser.id,
|
||||||
|
username: adminUser.username || "admin",
|
||||||
|
schoolId: defaultSchoolId,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Synced Admin ID: ${adminUser.id} and School ID: ${defaultSchoolId}`,
|
||||||
|
);
|
||||||
|
return { adminId: adminUser.id, defaultSchoolId };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a CSV line respecting double-quoted fields (handles commas inside quotes). */
|
||||||
|
function parseCsvLine(line: string): string[] {
|
||||||
|
const trimmed = line.replace(/\r$/, "").trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < trimmed.length; i++) {
|
||||||
|
const c = trimmed[i];
|
||||||
|
if (c === '"') {
|
||||||
|
if (inQuotes && trimmed[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (c === "," && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
} else {
|
||||||
|
current += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSchoolDirectory(adminId: string) {
|
||||||
|
try {
|
||||||
|
console.log("Seeding School directory from school_data.csv...");
|
||||||
|
const csvPath = path.join(process.cwd(), "school_data.csv");
|
||||||
|
if (!fs.existsSync(csvPath)) {
|
||||||
|
console.log("school_data.csv not found. Skipping school directory seed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(csvPath, "utf-8");
|
||||||
|
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||||
|
if (lines.length <= 1) {
|
||||||
|
console.log("school_data.csv has no data rows. Skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = parseCsvLine(lines[0]);
|
||||||
|
const idxUrn = header.indexOf("URN");
|
||||||
|
const idxName = header.indexOf("EstablishmentName");
|
||||||
|
const idxStatus = header.indexOf("EstablishmentStatus (name)");
|
||||||
|
const idxTypeGroup = header.indexOf("EstablishmentTypeGroup (name)");
|
||||||
|
|
||||||
|
if (idxUrn === -1 || idxName === -1 || idxStatus === -1 || idxTypeGroup === -1) {
|
||||||
|
console.log("Expected columns not found in school_data.csv header. Skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schools: { id: string; name: string; type: string; adminId: string }[] = [];
|
||||||
|
|
||||||
|
const MAX_ROWS = 500; // limit for local dev to avoid huge inserts
|
||||||
|
const dataLines = lines.slice(1, 1 + MAX_ROWS);
|
||||||
|
|
||||||
|
for (const line of dataLines) {
|
||||||
|
const cols = parseCsvLine(line);
|
||||||
|
if (!cols.length) continue;
|
||||||
|
|
||||||
|
const status = cols[idxStatus];
|
||||||
|
if (status !== "Open") continue;
|
||||||
|
|
||||||
|
const urn = cols[idxUrn];
|
||||||
|
const name = cols[idxName];
|
||||||
|
const typeGroup = cols[idxTypeGroup] || "";
|
||||||
|
|
||||||
|
if (!urn || !name) continue;
|
||||||
|
|
||||||
|
const schoolType =
|
||||||
|
/independent/i.test(typeGroup) ? "INDEPENDENT" : "MANAGED";
|
||||||
|
|
||||||
|
schools.push({
|
||||||
|
id: urn.toString(),
|
||||||
|
name,
|
||||||
|
type: schoolType,
|
||||||
|
adminId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schools.length === 0) {
|
||||||
|
console.log("No open schools found to seed from CSV.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from("School").upsert(schools);
|
||||||
|
if (error) {
|
||||||
|
console.error("Error seeding School directory from CSV:", error);
|
||||||
|
} else {
|
||||||
|
console.log(`Seeded/updated ${schools.length} schools from directory.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to seed School directory from CSV:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTestTimetableForSchool(
|
||||||
|
schoolId: string,
|
||||||
|
teacherId: string,
|
||||||
|
classId: number,
|
||||||
|
subjectIds: number[],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
console.log(`Seeding basic timetable config for school ${schoolId}...`);
|
||||||
|
|
||||||
|
// 1. Ensure AcademicYear and SchoolTimetable exist for this school
|
||||||
|
const { data: existingAy } = await supabase
|
||||||
|
.from("AcademicYear")
|
||||||
|
.select("id")
|
||||||
|
.eq("schoolId", schoolId)
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
let academicYearId: number;
|
||||||
|
if (existingAy?.id) {
|
||||||
|
academicYearId = existingAy.id;
|
||||||
|
} else {
|
||||||
|
const { data: newAy, error: ayError } = await supabase
|
||||||
|
.from("AcademicYear")
|
||||||
|
.insert({ schoolId, startYear: 2026, endYear: 2027, name: "2026-2027" })
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (ayError || !newAy) {
|
||||||
|
console.error("Error seeding AcademicYear for school", schoolId, ayError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
academicYearId = newAy.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: existingSt } = await supabase
|
||||||
|
.from("SchoolTimetable")
|
||||||
|
.select("id")
|
||||||
|
.eq("academicYearId", academicYearId)
|
||||||
|
.eq("schoolId", schoolId)
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
let schoolTimetableId: number;
|
||||||
|
if (existingSt?.id) {
|
||||||
|
schoolTimetableId = existingSt.id;
|
||||||
|
} else {
|
||||||
|
const { data: newSt, error: stError } = await supabase
|
||||||
|
.from("SchoolTimetable")
|
||||||
|
.insert({ academicYearId, schoolId, name: "Standard Week" })
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (stError || !newSt) {
|
||||||
|
console.error("Error seeding SchoolTimetable for school", schoolId, stError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schoolTimetableId = newSt.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const termStart = new Date("2026-02-23T00:00:00.000Z");
|
||||||
|
const termEnd = new Date("2026-03-27T00:00:00.000Z");
|
||||||
|
|
||||||
|
const { data: term, error: termError } = await supabase
|
||||||
|
.from("Term")
|
||||||
|
.insert({
|
||||||
|
schoolId,
|
||||||
|
academicYearId,
|
||||||
|
name: "Spring Term 2026 (Seeded)",
|
||||||
|
startDate: termStart.toISOString(),
|
||||||
|
endDate: termEnd.toISOString(),
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (termError) {
|
||||||
|
console.error("Error seeding term for school", schoolId, termError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.from("Holiday").insert({
|
||||||
|
schoolId,
|
||||||
|
academicYearId,
|
||||||
|
name: "Half Term Break",
|
||||||
|
startDate: "2026-03-09T00:00:00.000Z",
|
||||||
|
endDate: "2026-03-13T23:59:59.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: slots, error: slotError } = await supabase
|
||||||
|
.from("SchoolTimetableSlot")
|
||||||
|
.insert([
|
||||||
|
{
|
||||||
|
schoolId,
|
||||||
|
schoolTimetableId,
|
||||||
|
name: "Period 1",
|
||||||
|
startTime: "09:00",
|
||||||
|
endTime: "10:00",
|
||||||
|
isTeachingSlot: true,
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schoolId,
|
||||||
|
schoolTimetableId,
|
||||||
|
name: "Period 2",
|
||||||
|
startTime: "10:15",
|
||||||
|
endTime: "11:15",
|
||||||
|
isTeachingSlot: true,
|
||||||
|
position: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schoolId,
|
||||||
|
schoolTimetableId,
|
||||||
|
name: "Period 3",
|
||||||
|
startTime: "11:30",
|
||||||
|
endTime: "12:30",
|
||||||
|
isTeachingSlot: true,
|
||||||
|
position: 3,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.select();
|
||||||
|
|
||||||
|
if (slotError || !slots || slots.length === 0) {
|
||||||
|
console.error("Error seeding timetable slots for school", schoolId, slotError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p1 = slots.find((s: any) => s.name === "Period 1");
|
||||||
|
const p2 = slots.find((s: any) => s.name === "Period 2");
|
||||||
|
const p3 = slots.find((s: any) => s.name === "Period 3");
|
||||||
|
|
||||||
|
if (!p1 || !p2 || !p3) {
|
||||||
|
console.error("Missing seeded slots for school", schoolId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: templates, error: templateError } = await supabase
|
||||||
|
.from("TeacherTimetableTemplate")
|
||||||
|
.insert({
|
||||||
|
schoolId,
|
||||||
|
schoolTimetableId,
|
||||||
|
name: "Default Weekly Template (Seeded)",
|
||||||
|
teacherId,
|
||||||
|
})
|
||||||
|
.select();
|
||||||
|
|
||||||
|
if (templateError || !templates || templates.length === 0) {
|
||||||
|
console.error("Error seeding timetable template for school", schoolId, templateError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = templates[0].id as number;
|
||||||
|
const [subjectA, subjectB] = subjectIds;
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
teacherTimetableTemplateId: templateId,
|
||||||
|
schoolTimetableSlotId: p1.id,
|
||||||
|
classId,
|
||||||
|
subjectId: subjectA,
|
||||||
|
dayOfWeek: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teacherTimetableTemplateId: templateId,
|
||||||
|
schoolTimetableSlotId: p2.id,
|
||||||
|
classId,
|
||||||
|
subjectId: subjectB,
|
||||||
|
dayOfWeek: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teacherTimetableTemplateId: templateId,
|
||||||
|
schoolTimetableSlotId: p1.id,
|
||||||
|
classId,
|
||||||
|
subjectId: subjectB,
|
||||||
|
dayOfWeek: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teacherTimetableTemplateId: templateId,
|
||||||
|
schoolTimetableSlotId: p3.id,
|
||||||
|
classId,
|
||||||
|
subjectId: subjectA,
|
||||||
|
dayOfWeek: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { error: entryError } = await supabase
|
||||||
|
.from("TeacherTimetableEntry")
|
||||||
|
.insert(entries);
|
||||||
|
|
||||||
|
if (entryError) {
|
||||||
|
console.error("Error seeding timetable entries for school", schoolId, entryError);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to seed timetable config for school", schoolId, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,27 +368,37 @@ async function main() {
|
|||||||
const clerk = clerkClient();
|
const clerk = clerkClient();
|
||||||
await cleanClerk();
|
await cleanClerk();
|
||||||
await cleanSupabase();
|
await cleanSupabase();
|
||||||
await seedAdmin();
|
const adminInfo = await seedAdmin();
|
||||||
|
|
||||||
|
if (!adminInfo) {
|
||||||
|
console.error("No Admin user found. Seed aborted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { adminId, defaultSchoolId } = adminInfo;
|
||||||
|
|
||||||
|
// Populate School directory from CSV (for My Schools / selection)
|
||||||
|
await seedSchoolDirectory(adminId);
|
||||||
|
|
||||||
console.log("Seeding Grades and Subjects...");
|
console.log("Seeding Grades and Subjects...");
|
||||||
const grades = [1, 2, 3, 4, 5, 6].map((level) => ({ id: level, level }));
|
const grades = [1, 2, 3, 4, 5, 6].map((level) => ({ id: level, level }));
|
||||||
await supabase.from("Grade").insert(grades);
|
await supabase.from("Grade").insert(grades);
|
||||||
|
|
||||||
const subjectsArray = [
|
const subjectsArray = [
|
||||||
{ id: 1, name: "Mathematics" },
|
{ id: 1, name: "Mathematics", schoolId: defaultSchoolId },
|
||||||
{ id: 2, name: "Science" },
|
{ id: 2, name: "Science", schoolId: defaultSchoolId },
|
||||||
{ id: 3, name: "English" },
|
{ id: 3, name: "English", schoolId: defaultSchoolId },
|
||||||
{ id: 4, name: "History" },
|
{ id: 4, name: "History", schoolId: defaultSchoolId },
|
||||||
{ id: 5, name: "Geography" },
|
{ id: 5, name: "Geography", schoolId: defaultSchoolId },
|
||||||
{ id: 6, name: "Physics" },
|
{ id: 6, name: "Physics", schoolId: defaultSchoolId },
|
||||||
{ id: 7, name: "Chemistry" },
|
{ id: 7, name: "Chemistry", schoolId: defaultSchoolId },
|
||||||
{ id: 8, name: "Biology" },
|
{ id: 8, name: "Biology", schoolId: defaultSchoolId },
|
||||||
{ id: 9, name: "Computer Science" },
|
{ id: 9, name: "Computer Science", schoolId: defaultSchoolId },
|
||||||
{ id: 10, name: "Art" },
|
{ id: 10, name: "Art", schoolId: defaultSchoolId },
|
||||||
];
|
];
|
||||||
await supabase.from("Subject").insert(subjectsArray);
|
await supabase.from("Subject").insert(subjectsArray);
|
||||||
|
|
||||||
console.log("Creating 15 Teachers...");
|
console.log("Creating 15 Managed Teachers in Default School...");
|
||||||
const teacherMap: Record<number, string> = {};
|
const teacherMap: Record<number, string> = {};
|
||||||
for (let i = 1; i <= 15; i++) {
|
for (let i = 1; i <= 15; i++) {
|
||||||
const user = await clerk.users.createUser({
|
const user = await clerk.users.createUser({
|
||||||
@ -100,23 +423,264 @@ async function main() {
|
|||||||
birthday: "1996-02-27T00:26:35.280Z"
|
birthday: "1996-02-27T00:26:35.280Z"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchool").insert({
|
||||||
|
teacherId: user.id,
|
||||||
|
schoolId: defaultSchoolId,
|
||||||
|
isManaged: true
|
||||||
|
});
|
||||||
|
|
||||||
await supabase.from("TeacherSubject").insert([
|
await supabase.from("TeacherSubject").insert([
|
||||||
{ subjectId: (i % 10) + 1, teacherId: user.id, isPrimary: true },
|
{ subjectId: (i % 10) + 1, teacherId: user.id, isPrimary: true },
|
||||||
{ subjectId: ((i + 1) % 10) + 1, teacherId: user.id }
|
{ subjectId: ((i + 1) % 10) + 1, teacherId: user.id }
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Creating 6 Classes...");
|
console.log("Creating Independent Teacher & Test School...");
|
||||||
|
const independentUser = await clerk.users.createUser({
|
||||||
|
username: "independent1",
|
||||||
|
password: PASSWORD,
|
||||||
|
firstName: "Indy",
|
||||||
|
lastName: "Teacher",
|
||||||
|
publicMetadata: { role: "teacher", teacherType: "INDEPENDENT" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("Teacher").insert({
|
||||||
|
id: independentUser.id,
|
||||||
|
username: "independent1",
|
||||||
|
name: "Indy",
|
||||||
|
surname: "Teacher",
|
||||||
|
email: "independent1@example.com",
|
||||||
|
phone: "123-456-7890",
|
||||||
|
address: "Independent Street 1",
|
||||||
|
bloodType: "A+",
|
||||||
|
sex: "FEMALE",
|
||||||
|
birthday: "1990-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const independentSchoolId = "independent-school-1";
|
||||||
|
await supabase.from("School").upsert({
|
||||||
|
id: independentSchoolId,
|
||||||
|
name: "Independent School 1",
|
||||||
|
type: "INDEPENDENT",
|
||||||
|
adminId: independentUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchool").insert({
|
||||||
|
teacherId: independentUser.id,
|
||||||
|
schoolId: independentSchoolId,
|
||||||
|
isManaged: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: basic schedule indicating this teacher works at their independent school on weekdays
|
||||||
|
await supabase.from("TeacherSchoolSchedule").insert({
|
||||||
|
teacherId: independentUser.id,
|
||||||
|
schoolId: independentSchoolId,
|
||||||
|
startDate: "2026-01-01T00:00:00.000Z",
|
||||||
|
endDate: "2026-12-31T00:00:00.000Z",
|
||||||
|
daysOfWeek: [1, 2, 3, 4, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
const independentSubjects = [
|
||||||
|
{ id: 1001, name: "Indy Mathematics", schoolId: independentSchoolId },
|
||||||
|
{ id: 1002, name: "Indy English", schoolId: independentSchoolId },
|
||||||
|
];
|
||||||
|
await supabase.from("Subject").insert(independentSubjects);
|
||||||
|
|
||||||
|
await supabase.from("TeacherSubject").insert([
|
||||||
|
{ subjectId: 1001, teacherId: independentUser.id, isPrimary: true },
|
||||||
|
{ subjectId: 1002, teacherId: independentUser.id },
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Creating Agency Teacher & Test Agency School...");
|
||||||
|
const agencyUser = await clerk.users.createUser({
|
||||||
|
username: "agency1",
|
||||||
|
password: PASSWORD,
|
||||||
|
firstName: "Agen",
|
||||||
|
lastName: "Teacher",
|
||||||
|
publicMetadata: { role: "teacher", teacherType: "AGENCY" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("Teacher").insert({
|
||||||
|
id: agencyUser.id,
|
||||||
|
username: "agency1",
|
||||||
|
name: "Agen",
|
||||||
|
surname: "Teacher",
|
||||||
|
email: "agency1@example.com",
|
||||||
|
phone: "987-654-3210",
|
||||||
|
address: "Agency Road 1",
|
||||||
|
bloodType: "B+",
|
||||||
|
sex: "MALE",
|
||||||
|
birthday: "1988-05-15T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const agencySchoolId = "agency-school-1";
|
||||||
|
await supabase.from("School").upsert({
|
||||||
|
id: agencySchoolId,
|
||||||
|
name: "Agency School 1",
|
||||||
|
type: "AGENCY",
|
||||||
|
adminId: agencyUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchool").insert({
|
||||||
|
teacherId: agencyUser.id,
|
||||||
|
schoolId: agencySchoolId,
|
||||||
|
isManaged: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchoolSchedule").insert({
|
||||||
|
teacherId: agencyUser.id,
|
||||||
|
schoolId: agencySchoolId,
|
||||||
|
startDate: "2026-01-01T00:00:00.000Z",
|
||||||
|
endDate: "2026-12-31T00:00:00.000Z",
|
||||||
|
daysOfWeek: [1, 3, 5], // Example: Mon, Wed, Fri
|
||||||
|
});
|
||||||
|
|
||||||
|
const agencySubjects = [
|
||||||
|
{ id: 1003, name: "Agency Mathematics", schoolId: agencySchoolId },
|
||||||
|
{ id: 1004, name: "Agency English", schoolId: agencySchoolId },
|
||||||
|
];
|
||||||
|
await supabase.from("Subject").insert(agencySubjects);
|
||||||
|
|
||||||
|
await supabase.from("TeacherSubject").insert([
|
||||||
|
{ subjectId: 1003, teacherId: agencyUser.id, isPrimary: true },
|
||||||
|
{ subjectId: 1004, teacherId: agencyUser.id },
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Creating additional Independent & Agency test teachers (blank setups)...");
|
||||||
|
const independentUser2 = await clerk.users.createUser({
|
||||||
|
username: "independent2",
|
||||||
|
password: PASSWORD,
|
||||||
|
firstName: "Indy",
|
||||||
|
lastName: "Teacher2",
|
||||||
|
publicMetadata: { role: "teacher", teacherType: "INDEPENDENT" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("Teacher").insert({
|
||||||
|
id: independentUser2.id,
|
||||||
|
username: "independent2",
|
||||||
|
name: "Indy",
|
||||||
|
surname: "Teacher2",
|
||||||
|
email: "independent2@example.com",
|
||||||
|
phone: "123-456-7891",
|
||||||
|
address: "Independent Street 2",
|
||||||
|
bloodType: "A+",
|
||||||
|
sex: "MALE",
|
||||||
|
birthday: "1992-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const independentBlankSchoolId = "independent-school-2";
|
||||||
|
await supabase.from("School").upsert({
|
||||||
|
id: independentBlankSchoolId,
|
||||||
|
name: "Independent School 2 (Blank)",
|
||||||
|
type: "INDEPENDENT",
|
||||||
|
adminId: independentUser2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchool").insert({
|
||||||
|
teacherId: independentUser2.id,
|
||||||
|
schoolId: independentBlankSchoolId,
|
||||||
|
isManaged: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchoolSchedule").insert({
|
||||||
|
teacherId: independentUser2.id,
|
||||||
|
schoolId: independentBlankSchoolId,
|
||||||
|
startDate: "2026-01-01T00:00:00.000Z",
|
||||||
|
endDate: "2026-12-31T00:00:00.000Z",
|
||||||
|
daysOfWeek: [1, 2, 3, 4, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
const agencyUser2 = await clerk.users.createUser({
|
||||||
|
username: "agency2",
|
||||||
|
password: PASSWORD,
|
||||||
|
firstName: "Agen",
|
||||||
|
lastName: "Teacher2",
|
||||||
|
publicMetadata: { role: "teacher", teacherType: "AGENCY" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("Teacher").insert({
|
||||||
|
id: agencyUser2.id,
|
||||||
|
username: "agency2",
|
||||||
|
name: "Agen",
|
||||||
|
surname: "Teacher2",
|
||||||
|
email: "agency2@example.com",
|
||||||
|
phone: "987-654-3211",
|
||||||
|
address: "Agency Road 2",
|
||||||
|
bloodType: "B+",
|
||||||
|
sex: "FEMALE",
|
||||||
|
birthday: "1989-05-15T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const agencyBlankSchoolId = "agency-school-2";
|
||||||
|
await supabase.from("School").upsert({
|
||||||
|
id: agencyBlankSchoolId,
|
||||||
|
name: "Agency School 2 (Blank)",
|
||||||
|
type: "AGENCY",
|
||||||
|
adminId: agencyUser2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchool").insert({
|
||||||
|
teacherId: agencyUser2.id,
|
||||||
|
schoolId: agencyBlankSchoolId,
|
||||||
|
isManaged: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase.from("TeacherSchoolSchedule").insert({
|
||||||
|
teacherId: agencyUser2.id,
|
||||||
|
schoolId: agencyBlankSchoolId,
|
||||||
|
startDate: "2026-01-01T00:00:00.000Z",
|
||||||
|
endDate: "2026-12-31T00:00:00.000Z",
|
||||||
|
daysOfWeek: [2, 4], // Example: Tue, Thu
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Creating 6 Classes for default school...");
|
||||||
const classesArray = [
|
const classesArray = [
|
||||||
{ id: 1, name: "1A", gradeId: 1, capacity: 20, supervisorId: teacherMap[1] },
|
{ id: 1, name: "1A", gradeId: 1, capacity: 20, supervisorId: teacherMap[1], schoolId: defaultSchoolId },
|
||||||
{ id: 2, name: "2A", gradeId: 2, capacity: 20, supervisorId: teacherMap[2] },
|
{ id: 2, name: "2A", gradeId: 2, capacity: 20, supervisorId: teacherMap[2], schoolId: defaultSchoolId },
|
||||||
{ id: 3, name: "3A", gradeId: 3, capacity: 20, supervisorId: teacherMap[3] },
|
{ id: 3, name: "3A", gradeId: 3, capacity: 20, supervisorId: teacherMap[3], schoolId: defaultSchoolId },
|
||||||
{ id: 4, name: "4A", gradeId: 4, capacity: 20, supervisorId: teacherMap[4] },
|
{ id: 4, name: "4A", gradeId: 4, capacity: 20, supervisorId: teacherMap[4], schoolId: defaultSchoolId },
|
||||||
{ id: 5, name: "5A", gradeId: 5, capacity: 20, supervisorId: teacherMap[5] },
|
{ id: 5, name: "5A", gradeId: 5, capacity: 20, supervisorId: teacherMap[5], schoolId: defaultSchoolId },
|
||||||
{ id: 6, name: "6A", gradeId: 6, capacity: 20, supervisorId: teacherMap[1] },
|
{ id: 6, name: "6A", gradeId: 6, capacity: 20, supervisorId: teacherMap[1], schoolId: defaultSchoolId },
|
||||||
];
|
];
|
||||||
await supabase.from("Class").insert(classesArray);
|
await supabase.from("Class").insert(classesArray);
|
||||||
|
|
||||||
|
console.log("Creating test classes for Independent and Agency schools...");
|
||||||
|
const independentClassId = 1001;
|
||||||
|
const agencyClassId = 2001;
|
||||||
|
|
||||||
|
await supabase.from("Class").insert([
|
||||||
|
{
|
||||||
|
id: independentClassId,
|
||||||
|
name: "Indy Class 1",
|
||||||
|
gradeId: 1,
|
||||||
|
capacity: 25,
|
||||||
|
supervisorId: independentUser.id,
|
||||||
|
schoolId: independentSchoolId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: agencyClassId,
|
||||||
|
name: "Agency Class 1",
|
||||||
|
gradeId: 1,
|
||||||
|
capacity: 25,
|
||||||
|
supervisorId: agencyUser.id,
|
||||||
|
schoolId: agencySchoolId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Seeding basic timetable config for Independent and Agency test schools...");
|
||||||
|
await seedTestTimetableForSchool(
|
||||||
|
independentSchoolId,
|
||||||
|
independentUser.id,
|
||||||
|
1001,
|
||||||
|
[1001, 1002],
|
||||||
|
);
|
||||||
|
await seedTestTimetableForSchool(
|
||||||
|
agencySchoolId,
|
||||||
|
agencyUser.id,
|
||||||
|
2001,
|
||||||
|
[1003, 1004],
|
||||||
|
);
|
||||||
|
|
||||||
console.log("Creating 25 Parents...");
|
console.log("Creating 25 Parents...");
|
||||||
const parentMap: Record<number, string> = {};
|
const parentMap: Record<number, string> = {};
|
||||||
for (let i = 1; i <= 25; i++) {
|
for (let i = 1; i <= 25; i++) {
|
||||||
@ -136,7 +700,8 @@ async function main() {
|
|||||||
surname: `PSurname${i}`,
|
surname: `PSurname${i}`,
|
||||||
email: `parent${i}@example.com`,
|
email: `parent${i}@example.com`,
|
||||||
phone: `123-456-789${i}`,
|
phone: `123-456-789${i}`,
|
||||||
address: `Address${i}`
|
address: `Address${i}`,
|
||||||
|
schoolId: defaultSchoolId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,8 +730,12 @@ async function main() {
|
|||||||
sex: i % 2 === 0 ? "MALE" : "FEMALE",
|
sex: i % 2 === 0 ? "MALE" : "FEMALE",
|
||||||
parentId: parentMap[(i % 25) + 1],
|
parentId: parentMap[(i % 25) + 1],
|
||||||
gradeId: classInfo.gradeId,
|
gradeId: classInfo.gradeId,
|
||||||
classId: classInfo.id,
|
birthday: "2016-02-27T00:26:35.281Z",
|
||||||
birthday: "2016-02-27T00:26:35.281Z"
|
schoolId: defaultSchoolId
|
||||||
|
});
|
||||||
|
await supabase.from("StudentClass").insert({
|
||||||
|
studentId: user.id,
|
||||||
|
classId: classInfo.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +745,12 @@ async function main() {
|
|||||||
teacherMap,
|
teacherMap,
|
||||||
parentMap,
|
parentMap,
|
||||||
studentMap,
|
studentMap,
|
||||||
classes: classesArray
|
classes: classesArray,
|
||||||
|
defaultSchoolId,
|
||||||
|
independentTeacherId: independentUser.id,
|
||||||
|
independentSchoolId,
|
||||||
|
agencyTeacherId: agencyUser.id,
|
||||||
|
agencySchoolId,
|
||||||
};
|
};
|
||||||
fs.writeFileSync(seedDataPath, JSON.stringify(seedData, null, 2));
|
fs.writeFileSync(seedDataPath, JSON.stringify(seedData, null, 2));
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,7 @@ async function main() {
|
|||||||
throw new Error("seed-data.json not found! Please run `npm run seed:users` first.");
|
throw new Error("seed-data.json not found! Please run `npm run seed:users` first.");
|
||||||
}
|
}
|
||||||
const data = JSON.parse(fs.readFileSync(seedDataPath, "utf-8"));
|
const data = JSON.parse(fs.readFileSync(seedDataPath, "utf-8"));
|
||||||
const { teacherMap, studentMap, classes } = data;
|
const { teacherMap, studentMap, classes, defaultSchoolId } = data;
|
||||||
|
|
||||||
console.log("Cleaning old Schedule & Attendance data...");
|
console.log("Cleaning old Schedule & Attendance data...");
|
||||||
const tablesToClean = ["LessonWhiteboard", "Attendance", "Result", "Assignment", "Exam", "Lesson"];
|
const tablesToClean = ["LessonWhiteboard", "Attendance", "Result", "Assignment", "Exam", "Lesson"];
|
||||||
@ -142,12 +142,12 @@ async function main() {
|
|||||||
lessonsData.push({
|
lessonsData.push({
|
||||||
id: lessonIdCounter,
|
id: lessonIdCounter,
|
||||||
name: `${classInfo.name} ${subjectKey} (${dayName})`,
|
name: `${classInfo.name} ${subjectKey} (${dayName})`,
|
||||||
day: dayName,
|
|
||||||
startTime: startTime.toISOString(),
|
startTime: startTime.toISOString(),
|
||||||
endTime: endTime.toISOString(),
|
endTime: endTime.toISOString(),
|
||||||
subjectId: subjectId,
|
subjectId: subjectId,
|
||||||
classId: classInfo.id,
|
classId: classInfo.id,
|
||||||
teacherId: teacherId
|
teacherId: teacherId,
|
||||||
|
schoolId: defaultSchoolId
|
||||||
});
|
});
|
||||||
|
|
||||||
whiteboardsData.push({
|
whiteboardsData.push({
|
||||||
@ -161,7 +161,8 @@ async function main() {
|
|||||||
title: `${subjectKey} Assessment`,
|
title: `${subjectKey} Assessment`,
|
||||||
startTime: startTime.toISOString(),
|
startTime: startTime.toISOString(),
|
||||||
endTime: endTime.toISOString(),
|
endTime: endTime.toISOString(),
|
||||||
lessonId: lessonIdCounter
|
lessonId: lessonIdCounter,
|
||||||
|
schoolId: defaultSchoolId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only give results to a subset to prevent table explosions
|
// Only give results to a subset to prevent table explosions
|
||||||
@ -170,7 +171,8 @@ async function main() {
|
|||||||
id: resultIdCounter++,
|
id: resultIdCounter++,
|
||||||
score: 60 + Math.floor(Math.random() * 41), // 60-100
|
score: 60 + Math.floor(Math.random() * 41), // 60-100
|
||||||
studentId: sId,
|
studentId: sId,
|
||||||
examId: examIdCounter
|
examId: examIdCounter,
|
||||||
|
schoolId: defaultSchoolId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
examIdCounter++;
|
examIdCounter++;
|
||||||
@ -183,7 +185,8 @@ async function main() {
|
|||||||
title: `${subjectKey} Practice`,
|
title: `${subjectKey} Practice`,
|
||||||
startDate: startTime.toISOString(),
|
startDate: startTime.toISOString(),
|
||||||
dueDate: new Date(startTime.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
dueDate: new Date(startTime.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
lessonId: lessonIdCounter
|
lessonId: lessonIdCounter,
|
||||||
|
schoolId: defaultSchoolId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,7 +198,8 @@ async function main() {
|
|||||||
date: startTime.toISOString(),
|
date: startTime.toISOString(),
|
||||||
present: Math.random() < CONFIG.attendanceProbability,
|
present: Math.random() < CONFIG.attendanceProbability,
|
||||||
studentId: sId,
|
studentId: sId,
|
||||||
lessonId: lessonIdCounter
|
lessonId: lessonIdCounter,
|
||||||
|
schoolId: defaultSchoolId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,6 +221,12 @@ async function main() {
|
|||||||
await insertInChunks("Result", resultsData);
|
await insertInChunks("Result", resultsData);
|
||||||
await insertInChunks("Attendance", attendancesData);
|
await insertInChunks("Attendance", attendancesData);
|
||||||
|
|
||||||
|
// Keep Lesson id sequence in sync so "Generate lessons" from templates does not hit duplicate key
|
||||||
|
const { error: syncErr } = await supabase.rpc("sync_lesson_id_sequence");
|
||||||
|
if (syncErr) {
|
||||||
|
console.warn("Lesson sequence sync skipped (run migrations if you use Generate lessons):", syncErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n✅ Timeline successfully generated!`);
|
console.log(`\n✅ Timeline successfully generated!`);
|
||||||
console.log(`- Lessons: ${lessonsData.length}`);
|
console.log(`- Lessons: ${lessonsData.length}`);
|
||||||
console.log(`- Whiteboards: ${whiteboardsData.length}`);
|
console.log(`- Whiteboards: ${whiteboardsData.length}`);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import DashboardShell from "@/components/DashboardShell";
|
import DashboardShell from "@/components/DashboardShell";
|
||||||
import { currentUser } from "@clerk/nextjs/server";
|
import { currentUser } from "@clerk/nextjs/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@ -14,14 +15,55 @@ export default async function DashboardLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const role = user.publicMetadata.role as string;
|
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 = {
|
const userMetadata = {
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
role: role
|
role: role,
|
||||||
|
teacherType,
|
||||||
|
activeSchoolId,
|
||||||
|
schools,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell role={role} userMetadata={userMetadata}>
|
<DashboardShell
|
||||||
|
role={role}
|
||||||
|
userMetadata={userMetadata}
|
||||||
|
canManageSchool={canManageSchool}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import FormContainer from "@/components/FormContainer";
|
import FormContainer from "@/components/FormContainer";
|
||||||
|
import ListFilterSort from "@/components/ListFilterSort";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import Table from "@/components/Table";
|
import Table from "@/components/Table";
|
||||||
import TableSearch from "@/components/TableSearch";
|
|
||||||
import { getSupabaseClient } from "@/lib/supabase";
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
import { ITEM_PER_PAGE } from "@/lib/settings";
|
import { ITEM_PER_PAGE } from "@/lib/settings";
|
||||||
import { Tables } from "@/types/supabase";
|
import { Tables } from "@/types/supabase";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
type ClassList = Tables<"Class"> & { supervisor: Tables<"Teacher"> | null };
|
type ClassList = Tables<"Class"> & { supervisor: Tables<"Teacher"> | null };
|
||||||
|
|
||||||
@ -79,6 +80,14 @@ const ClassListPage = async ({
|
|||||||
const p = page ? parseInt(page) : 1;
|
const p = page ? parseInt(page) : 1;
|
||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
|
|
||||||
|
const teachersRes = await supabase.from("Teacher").select("id, name, surname").order("name");
|
||||||
|
const teacherOptions = (teachersRes.data ?? []).map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.name} ${t.surname}`.trim(),
|
||||||
|
}));
|
||||||
|
const sortOptions = [{ value: "name", label: "Name" }];
|
||||||
|
const filters = [{ key: "supervisorId", label: "Supervisor", options: teacherOptions }];
|
||||||
|
|
||||||
// URL PARAMS CONDITION
|
// URL PARAMS CONDITION
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from("Class")
|
.from("Class")
|
||||||
@ -86,20 +95,26 @@ const ClassListPage = async ({
|
|||||||
|
|
||||||
if (queryParams) {
|
if (queryParams) {
|
||||||
for (const [key, value] of Object.entries(queryParams)) {
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
if (value !== undefined) {
|
if (value === undefined) continue;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "supervisorId":
|
case "supervisorId":
|
||||||
query = query.eq("supervisorId", value);
|
query = query.eq("supervisorId", value);
|
||||||
break;
|
break;
|
||||||
case "search":
|
case "search":
|
||||||
query = query.ilike("name", `%${value}%`);
|
query = query.ilike("name", `%${value}%`);
|
||||||
break;
|
break;
|
||||||
default:
|
case "sortBy": {
|
||||||
break;
|
const col = value === "name" ? "name" : "name";
|
||||||
|
const asc = queryParams.sortOrder !== "desc";
|
||||||
|
query = query.order(col, { ascending: asc });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!queryParams.sortBy) query = query.order("name", { ascending: true });
|
||||||
|
|
||||||
// PAGINATION
|
// PAGINATION
|
||||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||||
@ -114,23 +129,14 @@ const ClassListPage = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||||
{/* TOP */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="hidden md:block text-lg font-semibold">All Classes</h1>
|
<h1 className="hidden md:block text-lg font-semibold">All Classes</h1>
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
|
||||||
<TableSearch />
|
|
||||||
<div className="flex items-center gap-4 self-end">
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
{role === "admin" && <FormContainer table="class" type="create" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* LIST */}
|
<Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
|
||||||
|
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
|
||||||
|
{role === "admin" && <FormContainer table="class" type="create" />}
|
||||||
|
</ListFilterSort>
|
||||||
|
</Suspense>
|
||||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||||
{/* PAGINATION */}
|
{/* PAGINATION */}
|
||||||
<Pagination page={p} count={count || 0} />
|
<Pagination page={p} count={count || 0} />
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import FormContainer from "@/components/FormContainer";
|
import FormContainer from "@/components/FormContainer";
|
||||||
|
import ListFilterSort from "@/components/ListFilterSort";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import Table from "@/components/Table";
|
import Table from "@/components/Table";
|
||||||
import TableSearch from "@/components/TableSearch";
|
|
||||||
import { getSupabaseClient } from "@/lib/supabase";
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
import { ITEM_PER_PAGE } from "@/lib/settings";
|
import { ITEM_PER_PAGE } from "@/lib/settings";
|
||||||
import { Tables } from "@/types/supabase";
|
import { Tables } from "@/types/supabase";
|
||||||
import Image from "next/image";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
type EventList = Tables<"Event"> & { class: Tables<"Class"> | null };
|
type EventList = Tables<"Event"> & { class: Tables<"Class"> | null };
|
||||||
|
|
||||||
@ -96,6 +96,14 @@ const EventListPage = async ({
|
|||||||
const p = page ? parseInt(page) : 1;
|
const p = page ? parseInt(page) : 1;
|
||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
|
|
||||||
|
const classesRes = await supabase.from("Class").select("id, name").order("name");
|
||||||
|
const classOptions = (classesRes.data ?? []).map((c) => ({ value: String(c.id), label: c.name }));
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: "startTime", label: "Date" },
|
||||||
|
{ value: "title", label: "Title" },
|
||||||
|
];
|
||||||
|
const filters = [{ key: "classId", label: "Class", options: classOptions }];
|
||||||
|
|
||||||
// URL PARAMS CONDITION
|
// URL PARAMS CONDITION
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from("Event")
|
.from("Event")
|
||||||
@ -103,22 +111,26 @@ const EventListPage = async ({
|
|||||||
|
|
||||||
if (queryParams) {
|
if (queryParams) {
|
||||||
for (const [key, value] of Object.entries(queryParams)) {
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
if (value !== undefined) {
|
if (value === undefined) continue;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "search":
|
case "classId":
|
||||||
query = query.ilike("title", `%${value}%`);
|
if (value) query = query.eq("classId", parseInt(value));
|
||||||
break;
|
break;
|
||||||
default:
|
case "search":
|
||||||
break;
|
query = query.ilike("title", `%${value}%`);
|
||||||
|
break;
|
||||||
|
case "sortBy": {
|
||||||
|
const col = value === "title" || value === "startTime" ? value : "startTime";
|
||||||
|
const asc = queryParams.sortOrder !== "desc";
|
||||||
|
query = query.order(col, { ascending: asc });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!queryParams.sortBy) query = query.order("startTime", { ascending: true });
|
||||||
// ROLE CONDITIONS
|
|
||||||
// Authorization is now handled by Supabase Postgres RLS policies.
|
|
||||||
// The initialized `supabase` client automatically passes the Clerk user JWT,
|
|
||||||
// so the database will only return the events this user is allowed to see.
|
|
||||||
|
|
||||||
// PAGINATION
|
// PAGINATION
|
||||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||||
@ -133,23 +145,14 @@ const EventListPage = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||||
{/* TOP */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="hidden md:block text-lg font-semibold">All Events</h1>
|
<h1 className="hidden md:block text-lg font-semibold">All Events</h1>
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
|
||||||
<TableSearch />
|
|
||||||
<div className="flex items-center gap-4 self-end">
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
{role === "admin" && <FormContainer table="event" type="create" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* LIST */}
|
<Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
|
||||||
|
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
|
||||||
|
{role === "admin" && <FormContainer table="event" type="create" />}
|
||||||
|
</ListFilterSort>
|
||||||
|
</Suspense>
|
||||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||||
{/* PAGINATION */}
|
{/* PAGINATION */}
|
||||||
<Pagination page={p} count={count || 0} />
|
<Pagination page={p} count={count || 0} />
|
||||||
|
|||||||
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,21 +1,23 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import FormContainer from "@/components/FormContainer";
|
import FormContainer from "@/components/FormContainer";
|
||||||
|
import ListFilterSort from "@/components/ListFilterSort";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import Table from "@/components/Table";
|
import Table from "@/components/Table";
|
||||||
import TableSearch from "@/components/TableSearch";
|
|
||||||
import { getSupabaseClient } from "@/lib/supabase";
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
import { ITEM_PER_PAGE } from "@/lib/settings";
|
import { ITEM_PER_PAGE } from "@/lib/settings";
|
||||||
import { Tables } from "@/types/supabase";
|
import { Tables } from "@/types/supabase";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type LessonList = Tables<"Lesson"> & {
|
type LessonList = Tables<"Lesson"> & {
|
||||||
subject: Tables<"Subject">;
|
subject: Tables<"Subject"> | null;
|
||||||
class: Tables<"Class">;
|
class: Tables<"Class"> | null;
|
||||||
teacher: Tables<"Teacher">;
|
teacher: Tables<"Teacher"> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const LessonListPage = async ({
|
const LessonListPage = async ({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@ -28,9 +30,23 @@ const LessonListPage = async ({
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
header: "Subject Name",
|
header: "Lesson",
|
||||||
|
accessor: "lessonName",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Subject",
|
||||||
accessor: "name",
|
accessor: "name",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: "Date",
|
||||||
|
accessor: "date",
|
||||||
|
className: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Time",
|
||||||
|
accessor: "time",
|
||||||
|
className: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: "Class",
|
header: "Class",
|
||||||
accessor: "class",
|
accessor: "class",
|
||||||
@ -50,12 +66,25 @@ const LessonListPage = async ({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderRow = (item: LessonList) => (
|
const formatTime = (iso: string | undefined) =>
|
||||||
|
iso ? new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) : null;
|
||||||
|
const formatDate = (iso: string | undefined) =>
|
||||||
|
iso ? new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : "-";
|
||||||
|
|
||||||
|
const renderRow = (item: LessonList) => {
|
||||||
|
const start = formatTime(item.startTime);
|
||||||
|
const end = formatTime(item.endTime);
|
||||||
|
const timeStr = start && end ? `${start} – ${end}` : "-";
|
||||||
|
const dateStr = formatDate(item.startTime);
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
|
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
|
||||||
>
|
>
|
||||||
<td className="flex items-center gap-4 p-4">{item.subject?.name || "-"}</td>
|
<td className="flex items-center gap-4 p-4">{item.name || "-"}</td>
|
||||||
|
<td>{item.subject?.name || "-"}</td>
|
||||||
|
<td className="hidden sm:table-cell">{dateStr}</td>
|
||||||
|
<td className="hidden sm:table-cell">{timeStr}</td>
|
||||||
<td>{item.class?.name || "-"}</td>
|
<td>{item.class?.name || "-"}</td>
|
||||||
<td className="hidden md:table-cell">
|
<td className="hidden md:table-cell">
|
||||||
{item.teacher ? item.teacher.name + " " + item.teacher.surname : "-"}
|
{item.teacher ? item.teacher.name + " " + item.teacher.surname : "-"}
|
||||||
@ -63,7 +92,7 @@ const LessonListPage = async ({
|
|||||||
<td>
|
<td>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(role === "admin" || role === "teacher" || role === "student") && (
|
{(role === "admin" || role === "teacher" || role === "student") && (
|
||||||
<Link href={`/lesson?lessonId=${item.id}`}>
|
<Link href={`/whiteboard?lessonId=${item.id}`}>
|
||||||
<button
|
<button
|
||||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-lamaSky"
|
className="w-7 h-7 flex items-center justify-center rounded-full bg-lamaSky"
|
||||||
title="View Whiteboard"
|
title="View Whiteboard"
|
||||||
@ -82,67 +111,110 @@ const LessonListPage = async ({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const { page, ...queryParams } = searchParams;
|
const { page, ...queryParams } = searchParams;
|
||||||
|
|
||||||
const p = page ? parseInt(page) : 1;
|
const p = page ? parseInt(page) : 1;
|
||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
|
|
||||||
// URL PARAMS CONDITION
|
// Filter options for ListFilterSort
|
||||||
let query = supabase
|
const [teachersRes, classesRes] = await Promise.all([
|
||||||
.from("Lesson")
|
supabase.from("Teacher").select("id, name, surname").order("name"),
|
||||||
.select("*, subject:Subject(*), class:Class(*), teacher:Teacher(*)", { count: "exact" });
|
supabase.from("Class").select("id, name").order("name"),
|
||||||
|
]);
|
||||||
|
const teacherOptions = (teachersRes.data ?? []).map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.name} ${t.surname}`.trim(),
|
||||||
|
}));
|
||||||
|
const classOptions = (classesRes.data ?? []).map((c) => ({
|
||||||
|
value: String(c.id),
|
||||||
|
label: c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: "startTime", label: "Date" },
|
||||||
|
{ value: "name", label: "Lesson name" },
|
||||||
|
];
|
||||||
|
const filters = [
|
||||||
|
{ key: "teacherId", label: "Teacher", options: teacherOptions },
|
||||||
|
{ key: "classId", label: "Class", options: classOptions },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch lessons only (no embeds) so RLS on Subject/Class/Teacher cannot drop rows
|
||||||
|
let query = supabase.from("Lesson").select("*", { count: "exact" });
|
||||||
|
|
||||||
if (queryParams) {
|
if (queryParams) {
|
||||||
for (const [key, value] of Object.entries(queryParams)) {
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
if (value !== undefined) {
|
if (value === undefined) continue;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "classId":
|
case "classId":
|
||||||
query = query.eq("classId", parseInt(value));
|
query = query.eq("classId", parseInt(value));
|
||||||
break;
|
break;
|
||||||
case "teacherId":
|
case "teacherId":
|
||||||
query = query.eq("teacherId", value);
|
query = query.eq("teacherId", value);
|
||||||
break;
|
break;
|
||||||
case "search":
|
case "search":
|
||||||
query = query.or(`subject.name.ilike.%${value}%,teacher.name.ilike.%${value}%,class.name.ilike.%${value}%`);
|
query = query.ilike("name", `%${value}%`);
|
||||||
break;
|
break;
|
||||||
default:
|
case "sortBy": {
|
||||||
break;
|
const col = value === "name" || value === "startTime" ? value : "startTime";
|
||||||
|
const asc = queryParams.sortOrder !== "desc";
|
||||||
|
query = query.order(col, { ascending: asc });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!queryParams.sortBy) {
|
||||||
|
query = query.order("startTime", { ascending: true });
|
||||||
|
}
|
||||||
|
|
||||||
// PAGINATION
|
|
||||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||||
|
|
||||||
const { data: rawData, count, error } = await query;
|
const { data: lessons, count, error } = await query;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error fetching lessons from Supabase:", error);
|
console.error("Error fetching lessons from Supabase:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (rawData || []) as unknown as LessonList[];
|
const lessonRows = lessons ?? [];
|
||||||
|
|
||||||
|
// Load subject, class, teacher in separate queries (avoids embed RLS dropping lesson rows)
|
||||||
|
const subjectIds = Array.from(new Set(lessonRows.map((l) => l.subjectId).filter((id) => id != null)));
|
||||||
|
const classIds = Array.from(new Set(lessonRows.map((l) => l.classId).filter((id) => id != null)));
|
||||||
|
const teacherIds = Array.from(new Set(lessonRows.map((l) => l.teacherId).filter((id) => id != null)));
|
||||||
|
|
||||||
|
const [subjectsRes, classesRes2, teachersRes2] = await Promise.all([
|
||||||
|
subjectIds.length ? supabase.from("Subject").select("id, name").in("id", subjectIds) : { data: [] },
|
||||||
|
classIds.length ? supabase.from("Class").select("id, name").in("id", classIds) : { data: [] },
|
||||||
|
teacherIds.length ? supabase.from("Teacher").select("id, name, surname").in("id", teacherIds) : { data: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Use Number() for map keys so string vs number from API doesn't break lookup
|
||||||
|
const subjectMap = new Map((subjectsRes.data ?? []).map((s) => [Number(s.id), s]));
|
||||||
|
const classMap = new Map((classesRes2.data ?? []).map((c) => [Number(c.id), c]));
|
||||||
|
const teacherMap = new Map((teachersRes2.data ?? []).map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
const data = lessonRows.map((lesson) => ({
|
||||||
|
...lesson,
|
||||||
|
subject: subjectMap.get(Number(lesson.subjectId)) ?? null,
|
||||||
|
class: classMap.get(Number(lesson.classId)) ?? null,
|
||||||
|
teacher: teacherMap.get(lesson.teacherId) ?? null,
|
||||||
|
})) as LessonList[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||||
{/* TOP */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="hidden md:block text-lg font-semibold">All Lessons</h1>
|
<h1 className="hidden md:block text-lg font-semibold">All Lessons</h1>
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
|
||||||
<TableSearch />
|
|
||||||
<div className="flex items-center gap-4 self-end">
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
{role === "admin" && <FormContainer table="lesson" type="create" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* LIST */}
|
<Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
|
||||||
|
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
|
||||||
|
{role === "admin" && <FormContainer table="lesson" type="create" />}
|
||||||
|
</ListFilterSort>
|
||||||
|
</Suspense>
|
||||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||||
{/* PAGINATION */}
|
{/* PAGINATION */}
|
||||||
<Pagination page={p} count={count || 0} />
|
<Pagination page={p} count={count || 0} />
|
||||||
|
|||||||
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")
|
.from("Student")
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
class:Class(
|
studentClasses:StudentClass(
|
||||||
*,
|
class:Class(
|
||||||
lessons:Lesson(count)
|
*,
|
||||||
|
lessons:Lesson(count)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq("id", id)
|
.eq("id", id)
|
||||||
@ -37,17 +39,17 @@ const SingleStudentPage = async ({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract count from the array returned by PostgREST inner select count
|
const classes = (data.studentClasses ?? []).map((sc: any) => ({
|
||||||
const lessonsCount = Array.isArray(data.class?.lessons)
|
...sc.class,
|
||||||
? (data.class?.lessons as any)[0]?.count || 0
|
_count: {
|
||||||
: 0;
|
lessons: Array.isArray(sc.class?.lessons) ? (sc.class.lessons[0]?.count ?? 0) : 0,
|
||||||
|
},
|
||||||
|
})).filter((c: any) => c.id != null);
|
||||||
|
|
||||||
const student = {
|
const student = {
|
||||||
...data,
|
...data,
|
||||||
class: {
|
classes,
|
||||||
...data.class,
|
class: classes[0],
|
||||||
_count: { lessons: lessonsCount }
|
|
||||||
}
|
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
if (!student) {
|
if (!student) {
|
||||||
@ -131,7 +133,7 @@ const SingleStudentPage = async ({
|
|||||||
/>
|
/>
|
||||||
<div className="">
|
<div className="">
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
{student.class.name.charAt(0)}th
|
{student.class?.name?.charAt(0) ?? "-"}th
|
||||||
</h1>
|
</h1>
|
||||||
<span className="text-sm text-gray-400">Grade</span>
|
<span className="text-sm text-gray-400">Grade</span>
|
||||||
</div>
|
</div>
|
||||||
@ -147,7 +149,7 @@ const SingleStudentPage = async ({
|
|||||||
/>
|
/>
|
||||||
<div className="">
|
<div className="">
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
{student.class._count.lessons}
|
{student.classes?.reduce((n: number, c: any) => n + (c._count?.lessons ?? 0), 0) ?? 0}
|
||||||
</h1>
|
</h1>
|
||||||
<span className="text-sm text-gray-400">Lessons</span>
|
<span className="text-sm text-gray-400">Lessons</span>
|
||||||
</div>
|
</div>
|
||||||
@ -162,8 +164,10 @@ const SingleStudentPage = async ({
|
|||||||
className="w-6 h-6"
|
className="w-6 h-6"
|
||||||
/>
|
/>
|
||||||
<div className="">
|
<div className="">
|
||||||
<h1 className="text-xl font-semibold">{student.class.name}</h1>
|
<h1 className="text-xl font-semibold">
|
||||||
<span className="text-sm text-gray-400">Class</span>
|
{student.classes?.map((c: any) => c.name).filter(Boolean).join(", ") || "-"}
|
||||||
|
</h1>
|
||||||
|
<span className="text-sm text-gray-400">Classes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -171,7 +175,7 @@ const SingleStudentPage = async ({
|
|||||||
{/* BOTTOM */}
|
{/* BOTTOM */}
|
||||||
<div className="mt-4 bg-white rounded-md p-4 h-[800px]">
|
<div className="mt-4 bg-white rounded-md p-4 h-[800px]">
|
||||||
<h1>Student's Schedule</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/* RIGHT */}
|
{/* RIGHT */}
|
||||||
@ -179,30 +183,37 @@ const SingleStudentPage = async ({
|
|||||||
<div className="bg-white p-4 rounded-md">
|
<div className="bg-white p-4 rounded-md">
|
||||||
<h1 className="text-xl font-semibold">Shortcuts</h1>
|
<h1 className="text-xl font-semibold">Shortcuts</h1>
|
||||||
<div className="mt-4 flex gap-4 flex-wrap text-xs text-gray-500">
|
<div className="mt-4 flex gap-4 flex-wrap text-xs text-gray-500">
|
||||||
<Link
|
{student.classes?.length ? (
|
||||||
className="p-3 rounded-md bg-lamaSkyLight"
|
<>
|
||||||
href={`/list/lessons?classId=${student.class.id}`}
|
{student.classes.slice(0, 3).map((c: any) => (
|
||||||
>
|
<Link
|
||||||
Student's Lessons
|
key={c.id}
|
||||||
</Link>
|
className="p-3 rounded-md bg-lamaSkyLight"
|
||||||
<Link
|
href={`/list/lessons?classId=${c.id}`}
|
||||||
className="p-3 rounded-md bg-lamaPurpleLight"
|
>
|
||||||
href={`/list/teachers?classId=${student.class.id}`}
|
Lessons ({c.name})
|
||||||
>
|
</Link>
|
||||||
Student's Teachers
|
))}
|
||||||
</Link>
|
<Link
|
||||||
<Link
|
className="p-3 rounded-md bg-lamaPurpleLight"
|
||||||
className="p-3 rounded-md bg-pink-50"
|
href={`/list/teachers?classId=${student.classes[0]?.id}`}
|
||||||
href={`/list/exams?classId=${student.class.id}`}
|
>
|
||||||
>
|
Teachers
|
||||||
Student's Exams
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
<Link
|
className="p-3 rounded-md bg-pink-50"
|
||||||
className="p-3 rounded-md bg-lamaSkyLight"
|
href={`/list/exams?classId=${student.classes[0]?.id}`}
|
||||||
href={`/list/assignments?classId=${student.class.id}`}
|
>
|
||||||
>
|
Exams
|
||||||
Student's Assignments
|
</Link>
|
||||||
</Link>
|
<Link
|
||||||
|
className="p-3 rounded-md bg-lamaSkyLight"
|
||||||
|
href={`/list/assignments?classId=${student.classes[0]?.id}`}
|
||||||
|
>
|
||||||
|
Assignments
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
className="p-3 rounded-md bg-lamaYellowLight"
|
className="p-3 rounded-md bg-lamaYellowLight"
|
||||||
href={`/list/results?studentId=${student.id}`}
|
href={`/list/results?studentId=${student.id}`}
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
import FormContainer from "@/components/FormContainer";
|
import FormContainer from "@/components/FormContainer";
|
||||||
|
import ListFilterSort from "@/components/ListFilterSort";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import Table from "@/components/Table";
|
import Table from "@/components/Table";
|
||||||
import TableSearch from "@/components/TableSearch";
|
|
||||||
|
|
||||||
import { getSupabaseClient } from "@/lib/supabase";
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
import { ITEM_PER_PAGE } from "@/lib/settings";
|
import { ITEM_PER_PAGE } from "@/lib/settings";
|
||||||
import { Tables } from "@/types/supabase";
|
import { Tables } from "@/types/supabase";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
type StudentList = Tables<"Student"> & { class: Tables<"Class"> };
|
type StudentList = Tables<"Student"> & {
|
||||||
|
studentClasses: { classId: number; class: Tables<"Class"> }[];
|
||||||
|
};
|
||||||
|
|
||||||
const StudentListPage = async ({
|
const StudentListPage = async ({
|
||||||
searchParams,
|
searchParams,
|
||||||
@ -56,7 +57,10 @@ const StudentListPage = async ({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderRow = (item: StudentList) => (
|
const renderRow = (item: StudentList) => {
|
||||||
|
const classNames = item.studentClasses?.map((sc) => sc.class?.name).filter(Boolean).join(", ") || "-";
|
||||||
|
const firstClassLetter = item.studentClasses?.[0]?.class?.name?.[0] ?? "-";
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
|
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
|
||||||
@ -71,11 +75,11 @@ const StudentListPage = async ({
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h3 className="font-semibold">{item.name}</h3>
|
<h3 className="font-semibold">{item.name}</h3>
|
||||||
<p className="text-xs text-gray-500">{item.class.name}</p>
|
<p className="text-xs text-gray-500">{classNames}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden md:table-cell">{item.username}</td>
|
<td className="hidden md:table-cell">{item.username}</td>
|
||||||
<td className="hidden md:table-cell">{item.class.name[0]}</td>
|
<td className="hidden md:table-cell">{firstClassLetter}</td>
|
||||||
<td className="hidden md:table-cell">{item.phone}</td>
|
<td className="hidden md:table-cell">{item.phone}</td>
|
||||||
<td className="hidden md:table-cell">{item.address}</td>
|
<td className="hidden md:table-cell">{item.address}</td>
|
||||||
<td>
|
<td>
|
||||||
@ -95,41 +99,69 @@ const StudentListPage = async ({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const { page, ...queryParams } = searchParams;
|
const { page, ...queryParams } = searchParams;
|
||||||
|
|
||||||
const p = page ? parseInt(page) : 1;
|
const p = page ? parseInt(page) : 1;
|
||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
|
|
||||||
// URL PARAMS CONDITION
|
const [classesRes, teachersRes] = await Promise.all([
|
||||||
// Note: we need lessons if teacherId is provided
|
supabase.from("Class").select("id, name").order("name"),
|
||||||
|
supabase.from("Teacher").select("id, name, surname").order("name"),
|
||||||
|
]);
|
||||||
|
const classOptions = (classesRes.data ?? []).map((c) => ({ value: String(c.id), label: c.name }));
|
||||||
|
const teacherOptions = (teachersRes.data ?? []).map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.name} ${t.surname}`.trim(),
|
||||||
|
}));
|
||||||
|
const sortOptions = [{ value: "name", label: "Name" }];
|
||||||
|
const filters = [
|
||||||
|
{ key: "classId", label: "Class", options: classOptions },
|
||||||
|
{ key: "teacherId", label: "Teacher", options: teacherOptions },
|
||||||
|
];
|
||||||
|
|
||||||
|
let studentIdsFilter: string[] | null = null;
|
||||||
|
if (queryParams.classId) {
|
||||||
|
const { data: links } = await supabase.from("StudentClass").select("studentId").eq("classId", parseInt(queryParams.classId));
|
||||||
|
studentIdsFilter = (links ?? []).map((l) => l.studentId);
|
||||||
|
if (studentIdsFilter.length === 0) studentIdsFilter = [""];
|
||||||
|
} else if (queryParams.teacherId) {
|
||||||
|
const { data: lessonRows } = await supabase.from("Lesson").select("classId").eq("teacherId", queryParams.teacherId);
|
||||||
|
const classIds = Array.from(new Set((lessonRows ?? []).map((r) => r.classId)));
|
||||||
|
if (classIds.length > 0) {
|
||||||
|
const { data: scRows } = await supabase.from("StudentClass").select("studentId").in("classId", classIds);
|
||||||
|
studentIdsFilter = Array.from(new Set((scRows ?? []).map((r) => r.studentId)));
|
||||||
|
}
|
||||||
|
if (!studentIdsFilter || studentIdsFilter.length === 0) studentIdsFilter = [""];
|
||||||
|
}
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from("Student")
|
.from("Student")
|
||||||
.select("*, class:Class(*, lessons:Lesson(*))", { count: "exact" });
|
.select("*, studentClasses:StudentClass(class:Class(*))", { count: "exact" });
|
||||||
|
|
||||||
|
if (studentIdsFilter) query = query.in("id", studentIdsFilter);
|
||||||
|
|
||||||
if (queryParams) {
|
if (queryParams) {
|
||||||
for (const [key, value] of Object.entries(queryParams)) {
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
if (value !== undefined) {
|
if (value === undefined) continue;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "teacherId":
|
case "search":
|
||||||
// Filter by teacherId within the joined lessons.
|
query = query.ilike("name", `%${value}%`);
|
||||||
// Supabase postgREST filters on JSON: class.lessons.teacherId=eq...
|
break;
|
||||||
// It's tricky to filter the main rows based on a nested condition.
|
case "sortBy": {
|
||||||
// Instead we can use an inner join via class!inner(lessons!inner(*)).
|
const col = value === "name" ? "name" : "name";
|
||||||
query = query.eq("class.lessons.teacherId", value);
|
const asc = queryParams.sortOrder !== "desc";
|
||||||
// It might require adjustments, but RLS generally restricts this.
|
query = query.order(col, { ascending: asc });
|
||||||
break;
|
break;
|
||||||
case "search":
|
|
||||||
query = query.ilike("name", `%${value}%`);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!queryParams.sortBy) query = query.order("name", { ascending: true });
|
||||||
|
|
||||||
// PAGINATION
|
|
||||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||||
|
|
||||||
let { data: rawData, count, error } = await query;
|
let { data: rawData, count, error } = await query;
|
||||||
@ -138,44 +170,18 @@ const StudentListPage = async ({
|
|||||||
console.error("Error fetching students from Supabase:", error);
|
console.error("Error fetching students from Supabase:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workaround for `teacherId` query: Since Supabase inner joins
|
|
||||||
// on deep JSON relationships can sometimes fail or return empty arrays
|
|
||||||
// when `select` is used like "class(*)", let's filter after fetch if `teacherId` was passed
|
|
||||||
// (In a real scenario, an RPC is better for complex joins that filter parents by nested children)
|
|
||||||
if (queryParams.teacherId && rawData) {
|
|
||||||
rawData = rawData.filter(student =>
|
|
||||||
// @ts-ignore
|
|
||||||
student.class?.lessons?.some((lesson: any) => lesson.teacherId === queryParams.teacherId)
|
|
||||||
);
|
|
||||||
count = rawData.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (rawData || []) as unknown as StudentList[];
|
const data = (rawData || []) as unknown as StudentList[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||||
{/* TOP */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="hidden md:block text-lg font-semibold">All Students</h1>
|
<h1 className="hidden md:block text-lg font-semibold">All Students</h1>
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
|
||||||
<TableSearch />
|
|
||||||
<div className="flex items-center gap-4 self-end">
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
{role === "admin" && (
|
|
||||||
// <button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
// <Image src="/plus.png" alt="" width={14} height={14} />
|
|
||||||
// </button>
|
|
||||||
<FormContainer table="student" type="create" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* LIST */}
|
<Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
|
||||||
|
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
|
||||||
|
{role === "admin" && <FormContainer table="student" type="create" />}
|
||||||
|
</ListFilterSort>
|
||||||
|
</Suspense>
|
||||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||||
{/* PAGINATION */}
|
{/* PAGINATION */}
|
||||||
<Pagination page={p} count={count || 0} />
|
<Pagination page={p} count={count || 0} />
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import FormContainer from "@/components/FormContainer";
|
import FormContainer from "@/components/FormContainer";
|
||||||
|
import ListFilterSort from "@/components/ListFilterSort";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import Table from "@/components/Table";
|
import Table from "@/components/Table";
|
||||||
import TableSearch from "@/components/TableSearch";
|
|
||||||
import { getSupabaseClient } from "@/lib/supabase";
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
|
import { ITEM_PER_PAGE } from "@/lib/settings";
|
||||||
import { Tables } from "@/types/supabase";
|
import { Tables } from "@/types/supabase";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ITEM_PER_PAGE } from "@/lib/settings";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
type TeacherList = Tables<"Teacher"> & { subjects: Tables<"Subject">[] } & { classes: Tables<"Class">[] };
|
type TeacherList = Tables<"Teacher"> & { subjects: Tables<"Subject">[] } & { classes: Tables<"Class">[] };
|
||||||
|
|
||||||
@ -107,29 +108,38 @@ const TeacherListPage = async ({
|
|||||||
const p = page ? parseInt(page) : 1;
|
const p = page ? parseInt(page) : 1;
|
||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
|
|
||||||
|
const classesRes = await supabase.from("Class").select("id, name").order("name");
|
||||||
|
const classOptions = (classesRes.data ?? []).map((c) => ({ value: String(c.id), label: c.name }));
|
||||||
|
const sortOptions = [{ value: "name", label: "Name" }];
|
||||||
|
const filters = [{ key: "classId", label: "Class", options: classOptions }];
|
||||||
|
|
||||||
// URL PARAMS CONDITION
|
// URL PARAMS CONDITION
|
||||||
// Note: we need lessons if classId is provided
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from("Teacher")
|
.from("Teacher")
|
||||||
.select("*, TeacherSubject(Subject(*)), classes:Class(*), lessons:Lesson(*)", { count: "exact" });
|
.select("*, TeacherSubject(Subject(*)), classes:Class(*), lessons:Lesson(*)", { count: "exact" });
|
||||||
|
|
||||||
if (queryParams) {
|
if (queryParams) {
|
||||||
for (const [key, value] of Object.entries(queryParams)) {
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
if (value !== undefined) {
|
if (value === undefined) continue;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "classId":
|
case "classId":
|
||||||
// Filter by classId within the joined lessons.
|
query = query.eq("lessons.classId", parseInt(value));
|
||||||
query = query.eq("lessons.classId", parseInt(value));
|
break;
|
||||||
break;
|
case "search":
|
||||||
case "search":
|
query = query.ilike("name", `%${value}%`);
|
||||||
query = query.ilike("name", `%${value}%`);
|
break;
|
||||||
break;
|
case "sortBy": {
|
||||||
default:
|
const col = value === "name" ? "name" : "name";
|
||||||
break;
|
const asc = queryParams.sortOrder !== "desc";
|
||||||
|
query = query.order(col, { ascending: asc });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!queryParams.sortBy) query = query.order("name", { ascending: true });
|
||||||
|
|
||||||
// PAGINATION
|
// PAGINATION
|
||||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||||
@ -161,25 +171,14 @@ const TeacherListPage = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||||
{/* TOP */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="hidden md:block text-lg font-semibold">All Teachers</h1>
|
<h1 className="hidden md:block text-lg font-semibold">All Teachers</h1>
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
|
||||||
<TableSearch />
|
|
||||||
<div className="flex items-center gap-4 self-end">
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
|
||||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
|
||||||
</button>
|
|
||||||
{role === "admin" && (
|
|
||||||
<FormContainer table="teacher" type="create" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* LIST */}
|
<Suspense fallback={<div className="py-3 border-b border-gray-200 animate-pulse" />}>
|
||||||
|
<ListFilterSort sortOptions={sortOptions} filters={filters} showSearch>
|
||||||
|
{role === "admin" && <FormContainer table="teacher" type="create" />}
|
||||||
|
</ListFilterSort>
|
||||||
|
</Suspense>
|
||||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||||
{/* PAGINATION */}
|
{/* PAGINATION */}
|
||||||
<Pagination page={p} count={count || 0} />
|
<Pagination page={p} count={count || 0} />
|
||||||
|
|||||||
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 BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||||
import { getSupabaseClient } from "@/lib/supabase";
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
import { Tables } from "@/types/supabase";
|
|
||||||
|
|
||||||
|
|
||||||
const ParentPage = async () => {
|
const ParentPage = async () => {
|
||||||
@ -12,30 +11,35 @@ const ParentPage = async () => {
|
|||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
const { data: students, error } = await supabase
|
const { data: students, error } = await supabase
|
||||||
.from("Student")
|
.from("Student")
|
||||||
.select("*")
|
.select("*, studentClasses:StudentClass(classId)");
|
||||||
// RLS policies should handle identifying parent id
|
|
||||||
// .eq("parentId", currentUserId!)
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error fetching parent students:", error);
|
console.error("Error fetching parent students:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const studentsList = (students || []) as Tables<"Student">[];
|
const studentsList = (students || []) as { id: string; name: string; surname: string; studentClasses: { classId: number }[] }[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
||||||
{/* LEFT */}
|
{/* LEFT */}
|
||||||
<div className="">
|
<div className="">
|
||||||
{studentsList.map((student) => (
|
{studentsList.map((student) => {
|
||||||
<div className="w-full xl:w-2/3" key={student.id}>
|
const classIds = student.studentClasses?.map((sc) => sc.classId) ?? [];
|
||||||
<div className="h-full bg-white p-4 rounded-md">
|
return (
|
||||||
<h1 className="text-xl font-semibold">
|
<div className="w-full xl:w-2/3" key={student.id}>
|
||||||
Schedule ({student.name + " " + student.surname})
|
<div className="h-full bg-white p-4 rounded-md">
|
||||||
</h1>
|
<h1 className="text-xl font-semibold">
|
||||||
<BigCalendarContainer type="classId" id={student.classId} />
|
Schedule ({student.name} {student.surname})
|
||||||
|
</h1>
|
||||||
|
{classIds.length > 0 ? (
|
||||||
|
<BigCalendarContainer type="classId" id={classIds} />
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500 mt-4">No classes assigned.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/* RIGHT */}
|
{/* RIGHT */}
|
||||||
<div className="w-full xl:w-1/3 flex flex-col gap-8">
|
<div className="w-full xl:w-1/3 flex flex-col gap-8">
|
||||||
|
|||||||
@ -3,45 +3,33 @@ import BigCalendarContainer from "@/components/BigCalendarContainer";
|
|||||||
import BigCalendar from "@/components/BigCalender";
|
import BigCalendar from "@/components/BigCalender";
|
||||||
import EventCalendar from "@/components/EventCalendar";
|
import EventCalendar from "@/components/EventCalendar";
|
||||||
import { getSupabaseClient } from "@/lib/supabase";
|
import { getSupabaseClient } from "@/lib/supabase";
|
||||||
import { Tables } from "@/types/supabase";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
const StudentPage = async () => {
|
const StudentPage = async () => {
|
||||||
const { userId } = auth();
|
const { userId } = auth();
|
||||||
|
|
||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
const { data: studentItem, error: studentError } = await supabase
|
const { data: studentClasses, error: linkError } = await supabase
|
||||||
.from("Student")
|
.from("StudentClass")
|
||||||
.select("classId")
|
.select("classId")
|
||||||
.eq("id", userId!)
|
.eq("studentId", userId!);
|
||||||
.single();
|
|
||||||
|
|
||||||
if (studentError) {
|
if (linkError) {
|
||||||
console.error("Error fetching student details:", studentError);
|
console.error("Error fetching student classes:", linkError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: classItems, error } = await supabase
|
const classIds = (studentClasses ?? []).map((r) => r.classId);
|
||||||
.from("Class")
|
|
||||||
.select("*")
|
|
||||||
.eq("id", studentItem?.classId || 0);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching student class:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const classItem = (classItems || []) as Tables<"Class">[];
|
|
||||||
const studentClassId = classItem.length > 0 ? classItem[0].id : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 flex gap-4 flex-col xl:flex-row">
|
<div className="p-4 flex gap-4 flex-col xl:flex-row">
|
||||||
{/* LEFT */}
|
{/* LEFT */}
|
||||||
<div className="w-full xl:w-2/3">
|
<div className="w-full xl:w-2/3">
|
||||||
<div className="h-full bg-white p-4 rounded-md">
|
<div className="h-full bg-white p-4 rounded-md">
|
||||||
<h1 className="text-xl font-semibold">Schedule (4A)</h1>
|
<h1 className="text-xl font-semibold">Schedule</h1>
|
||||||
{studentClassId ? (
|
{classIds.length > 0 ? (
|
||||||
<BigCalendarContainer type="classId" id={studentClassId} />
|
<BigCalendarContainer type="classId" id={classIds} />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-gray-500 mt-4">No schedule found for your assigned class.</div>
|
<div className="text-gray-500 mt-4">No classes assigned. Your schedule will appear here.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import Announcements from "@/components/Announcements";
|
import Announcements from "@/components/Announcements";
|
||||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||||
|
import { ensureTeacherOnboarding } from "@/lib/actions";
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
const TeacherPage = () => {
|
// This page uses auth() and ensureTeacherOnboarding() (which use headers), so it must be dynamic.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const TeacherPage = async () => {
|
||||||
|
await ensureTeacherOnboarding();
|
||||||
const { userId } = auth();
|
const { userId } = auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
||||||
{/* LEFT */}
|
{/* LEFT */}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useUser } from "@clerk/nextjs";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const { isLoaded, isSignedIn, user } = useUser();
|
const { isLoaded, isSignedIn, user } = useUser();
|
||||||
@ -61,6 +62,15 @@ const LoginPage = () => {
|
|||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</SignIn.Action>
|
</SignIn.Action>
|
||||||
|
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||||
|
Independent or agency teacher?{" "}
|
||||||
|
<Link
|
||||||
|
href="/teacher-sign-up"
|
||||||
|
className="text-blue-500 underline"
|
||||||
|
>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</SignIn.Step>
|
</SignIn.Step>
|
||||||
</SignIn.Root>
|
</SignIn.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { getSupabaseClient } from "@/lib/supabase";
|
||||||
import BigCalendar from "./BigCalender";
|
import BigCalendar from "./BigCalender";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
const BigCalendarContainer = async ({
|
const BigCalendarContainer = async ({
|
||||||
type,
|
type,
|
||||||
id,
|
id,
|
||||||
}: {
|
}: {
|
||||||
type: "teacherId" | "classId";
|
type: "teacherId" | "classId";
|
||||||
id: string | number;
|
id: string | number | number[];
|
||||||
}) => {
|
}) => {
|
||||||
|
const { sessionClaims } = auth();
|
||||||
|
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
|
||||||
|
|
||||||
const supabase = await getSupabaseClient();
|
const supabase = await getSupabaseClient();
|
||||||
|
|
||||||
let query = supabase.from("Lesson").select("*");
|
let query = supabase.from("Lesson").select("*");
|
||||||
|
if (schoolId) {
|
||||||
|
query = query.eq("schoolId", schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "teacherId") {
|
if (type === "teacherId") {
|
||||||
query = query.eq("teacherId", id as string);
|
query = query.eq("teacherId", id as string);
|
||||||
} else {
|
} else {
|
||||||
query = query.eq("classId", id as number);
|
const classIds = Array.isArray(id) ? id : [id];
|
||||||
|
if (classIds.length > 0) {
|
||||||
|
query = query.in("classId", classIds as number[]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: rawData, error } = await query;
|
const { data: rawData, error } = await query;
|
||||||
|
|||||||
@ -10,11 +10,13 @@ import { usePathname } from "next/navigation";
|
|||||||
export default function DashboardShell({
|
export default function DashboardShell({
|
||||||
children,
|
children,
|
||||||
role,
|
role,
|
||||||
userMetadata
|
userMetadata,
|
||||||
|
canManageSchool,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
role: string;
|
role: string;
|
||||||
userMetadata: any;
|
userMetadata: any;
|
||||||
|
canManageSchool: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
@ -50,7 +52,11 @@ export default function DashboardShell({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Menu role={role} isCollapsed={isCollapsed} />
|
<Menu
|
||||||
|
role={role}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
canManageSchool={canManageSchool}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT MAIN CONTENT */}
|
{/* RIGHT MAIN CONTENT */}
|
||||||
|
|||||||
@ -15,7 +15,12 @@ export type FormContainerProps = {
|
|||||||
| "result"
|
| "result"
|
||||||
| "attendance"
|
| "attendance"
|
||||||
| "event"
|
| "event"
|
||||||
| "announcement";
|
| "announcement"
|
||||||
|
| "term"
|
||||||
|
| "holiday"
|
||||||
|
| "schoolTimetableSlot"
|
||||||
|
| "timetableTemplate"
|
||||||
|
| "timetableEntry";
|
||||||
type: "create" | "update" | "delete";
|
type: "create" | "update" | "delete";
|
||||||
data?: any;
|
data?: any;
|
||||||
id?: number | string;
|
id?: number | string;
|
||||||
@ -26,6 +31,7 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
|
|||||||
|
|
||||||
const { userId, sessionClaims } = auth();
|
const { userId, sessionClaims } = auth();
|
||||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||||
|
const schoolId = (sessionClaims?.metadata as { schoolId?: string })?.schoolId;
|
||||||
const currentUserId = userId;
|
const currentUserId = userId;
|
||||||
|
|
||||||
if (type !== "delete") {
|
if (type !== "delete") {
|
||||||
@ -49,12 +55,12 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
|
|||||||
}
|
}
|
||||||
case "student": {
|
case "student": {
|
||||||
const { data: studentGrades } = await supabase.from("Grade").select("id, level");
|
const { data: studentGrades } = await supabase.from("Grade").select("id, level");
|
||||||
const { data: studentClasses } = await supabase.from("Class").select("*, students:Student(count)");
|
const { data: studentClasses } = await supabase.from("Class").select("*, studentClasses:StudentClass(count)");
|
||||||
const classesWithCount = studentClasses?.map(c => ({
|
const classesWithCount = studentClasses?.map((c: any) => ({
|
||||||
...c,
|
...c,
|
||||||
_count: { students: Array.isArray(c.students) ? (c.students as any)[0]?.count || 0 : 0 }
|
_count: { students: Array.isArray(c.studentClasses) ? (c.studentClasses[0]?.count ?? 0) : 0 },
|
||||||
}));
|
}));
|
||||||
relatedData = { classes: classesWithCount, grades: studentGrades };
|
relatedData = { classes: classesWithCount ?? [], grades: studentGrades };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "lesson": {
|
case "lesson": {
|
||||||
@ -96,6 +102,49 @@ const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
|
|||||||
relatedData = { classes: announcementClasses };
|
relatedData = { classes: announcementClasses };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "timetableTemplate": {
|
||||||
|
let teacherIds: string[] | null = null;
|
||||||
|
|
||||||
|
if (schoolId) {
|
||||||
|
const { data: mappings, error } = await supabase
|
||||||
|
.from("TeacherSchool")
|
||||||
|
.select("teacherId")
|
||||||
|
.eq("schoolId", schoolId);
|
||||||
|
|
||||||
|
if (!error && mappings) {
|
||||||
|
teacherIds = mappings.map((m: any) => m.teacherId as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = supabase.from("Teacher").select("id, name, surname");
|
||||||
|
if (teacherIds && teacherIds.length > 0) {
|
||||||
|
query = query.in("id", teacherIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: templateTeachers } = await query;
|
||||||
|
relatedData = { teachers: templateTeachers || [] };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "timetableEntry": {
|
||||||
|
let classQuery = supabase.from("Class").select("id, name");
|
||||||
|
let subjectQuery = supabase.from("Subject").select("id, name");
|
||||||
|
let slotQuery = supabase
|
||||||
|
.from("SchoolTimetableSlot")
|
||||||
|
.select("id, name, startTime, endTime")
|
||||||
|
.order("position", { ascending: true });
|
||||||
|
if (schoolId) {
|
||||||
|
classQuery = classQuery.eq("schoolId", schoolId);
|
||||||
|
subjectQuery = subjectQuery.eq("schoolId", schoolId);
|
||||||
|
slotQuery = slotQuery.eq("schoolId", schoolId);
|
||||||
|
}
|
||||||
|
const [{ data: entryClasses }, { data: entrySubjects }, { data: entrySlots }] = await Promise.all([
|
||||||
|
classQuery,
|
||||||
|
subjectQuery,
|
||||||
|
slotQuery,
|
||||||
|
]);
|
||||||
|
relatedData = { classes: entryClasses, subjects: entrySubjects, slots: entrySlots };
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,11 @@ import {
|
|||||||
deleteResult,
|
deleteResult,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
deleteAnnouncement,
|
deleteAnnouncement,
|
||||||
|
deleteTerm,
|
||||||
|
deleteHoliday,
|
||||||
|
deleteSchoolTimetableSlot,
|
||||||
|
deleteTimetableTemplate,
|
||||||
|
deleteTimetableEntry,
|
||||||
} from "@/lib/actions";
|
} from "@/lib/actions";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@ -34,6 +39,11 @@ const deleteActionMap = {
|
|||||||
attendance: deleteSubject,
|
attendance: deleteSubject,
|
||||||
event: deleteEvent,
|
event: deleteEvent,
|
||||||
announcement: deleteAnnouncement,
|
announcement: deleteAnnouncement,
|
||||||
|
term: deleteTerm,
|
||||||
|
holiday: deleteHoliday,
|
||||||
|
schoolTimetableSlot: deleteSchoolTimetableSlot,
|
||||||
|
timetableTemplate: deleteTimetableTemplate,
|
||||||
|
timetableEntry: deleteTimetableEntry,
|
||||||
};
|
};
|
||||||
|
|
||||||
// USE LAZY LOADING
|
// USE LAZY LOADING
|
||||||
@ -71,6 +81,21 @@ const EventForm = dynamic(() => import("./forms/EventForm"), {
|
|||||||
const AnnouncementForm = dynamic(() => import("./forms/AnnouncementForm"), {
|
const AnnouncementForm = dynamic(() => import("./forms/AnnouncementForm"), {
|
||||||
loading: () => <h1>Loading...</h1>,
|
loading: () => <h1>Loading...</h1>,
|
||||||
});
|
});
|
||||||
|
const TermForm = dynamic(() => import("./forms/TermForm"), {
|
||||||
|
loading: () => <h1>Loading...</h1>,
|
||||||
|
});
|
||||||
|
const HolidayForm = dynamic(() => import("./forms/HolidayForm"), {
|
||||||
|
loading: () => <h1>Loading...</h1>,
|
||||||
|
});
|
||||||
|
const SchoolTimetableSlotForm = dynamic(() => import("./forms/SchoolTimetableSlotForm"), {
|
||||||
|
loading: () => <h1>Loading...</h1>,
|
||||||
|
});
|
||||||
|
const TimetableTemplateForm = dynamic(() => import("./forms/TimetableTemplateForm"), {
|
||||||
|
loading: () => <h1>Loading...</h1>,
|
||||||
|
});
|
||||||
|
const TimetableEntryForm = dynamic(() => import("./forms/TimetableEntryForm"), {
|
||||||
|
loading: () => <h1>Loading...</h1>,
|
||||||
|
});
|
||||||
// TODO: OTHER FORMS
|
// TODO: OTHER FORMS
|
||||||
|
|
||||||
const forms: {
|
const forms: {
|
||||||
@ -160,7 +185,46 @@ const forms: {
|
|||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
relatedData={relatedData}
|
relatedData={relatedData}
|
||||||
/>
|
/>
|
||||||
// TODO OTHER LIST ITEMS
|
),
|
||||||
|
term: (setOpen, type, data, relatedData) => (
|
||||||
|
<TermForm
|
||||||
|
type={type}
|
||||||
|
data={data}
|
||||||
|
setOpen={setOpen}
|
||||||
|
relatedData={relatedData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
holiday: (setOpen, type, data, relatedData) => (
|
||||||
|
<HolidayForm
|
||||||
|
type={type}
|
||||||
|
data={data}
|
||||||
|
setOpen={setOpen}
|
||||||
|
relatedData={relatedData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
schoolTimetableSlot: (setOpen, type, data, relatedData) => (
|
||||||
|
<SchoolTimetableSlotForm
|
||||||
|
type={type}
|
||||||
|
data={data}
|
||||||
|
setOpen={setOpen}
|
||||||
|
relatedData={relatedData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
timetableTemplate: (setOpen, type, data, relatedData) => (
|
||||||
|
<TimetableTemplateForm
|
||||||
|
type={type}
|
||||||
|
data={data}
|
||||||
|
setOpen={setOpen}
|
||||||
|
relatedData={relatedData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
timetableEntry: (setOpen, type, data, relatedData) => (
|
||||||
|
<TimetableEntryForm
|
||||||
|
type={type}
|
||||||
|
data={data}
|
||||||
|
setOpen={setOpen}
|
||||||
|
relatedData={relatedData}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
@ -91,6 +91,57 @@ const menuItems = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "TEACHER CONFIG",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "/home.png",
|
||||||
|
label: "My Schools",
|
||||||
|
href: "/list/my-schools",
|
||||||
|
visible: ["teacher"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "SCHOOL CONFIG",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "/calendar.png",
|
||||||
|
label: "Terms",
|
||||||
|
href: "/list/terms",
|
||||||
|
visible: ["admin", "teacher"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/calendar.png",
|
||||||
|
label: "Holidays",
|
||||||
|
href: "/list/holidays",
|
||||||
|
visible: ["admin", "teacher"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/calendar.png",
|
||||||
|
label: "School Timetables",
|
||||||
|
href: "/list/school-timetables",
|
||||||
|
visible: ["admin", "teacher"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/lesson.png",
|
||||||
|
label: "Timetable Slots",
|
||||||
|
href: "/list/timeslots",
|
||||||
|
visible: ["admin", "teacher"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "TEACHER TIMETABLE CONFIG",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "/calendar.png",
|
||||||
|
label: "Timetable Templates",
|
||||||
|
href: "/list/templates",
|
||||||
|
visible: ["admin", "teacher"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "OTHER",
|
title: "OTHER",
|
||||||
items: [
|
items: [
|
||||||
@ -116,49 +167,84 @@ const menuItems = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const Menu = ({ role, isCollapsed }: { role: string; isCollapsed: boolean }) => {
|
const Menu = ({
|
||||||
|
role,
|
||||||
|
isCollapsed,
|
||||||
|
canManageSchool,
|
||||||
|
}: {
|
||||||
|
role: string;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
canManageSchool: boolean;
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 text-sm w-full">
|
<div className="mt-4 text-sm w-full">
|
||||||
{menuItems.map((i) => (
|
{menuItems.map((section) => {
|
||||||
<div className="flex flex-col gap-2 relative w-full" key={i.title}>
|
const filteredItems = section.items.filter((item) => {
|
||||||
<span
|
if (!item.visible.includes(role)) return false;
|
||||||
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"
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{i.title}
|
{section.title}
|
||||||
</span>
|
</span>
|
||||||
<div className={`block lg:hidden w-full h-4 ${isCollapsed ? '' : 'my-4'}`} />
|
<div
|
||||||
{i.items.map((item) => {
|
className={`block lg:hidden w-full h-4 ${
|
||||||
if (item.visible.includes(role)) {
|
isCollapsed ? "" : "my-4"
|
||||||
return (
|
}`}
|
||||||
<Link
|
/>
|
||||||
href={item.href}
|
{filteredItems.map((item) => (
|
||||||
key={item.label}
|
<Link
|
||||||
className={`flex items-center text-gray-500 py-2 rounded-md hover:bg-lamaSkyLight transition-all relative group
|
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"}
|
${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]" />
|
<Image
|
||||||
<span
|
src={item.icon}
|
||||||
className={`hidden lg:block whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out origin-left
|
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]"}
|
${isCollapsed ? "opacity-0 w-0 max-w-0" : "opacity-100 w-auto ml-4 max-w-[200px]"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Tooltip on hover when collapsed */}
|
{/* Tooltip on hover when collapsed */}
|
||||||
{isCollapsed && (
|
{isCollapsed && (
|
||||||
<div className="absolute left-14 bg-gray-800 text-white text-xs py-1 px-2 rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 pointer-events-none">
|
<div 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}
|
{item.label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
))}
|
||||||
}
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { UserButton } from "@clerk/nextjs";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Menu as MenuIcon } from "lucide-react";
|
import { Menu as MenuIcon } from "lucide-react";
|
||||||
|
import { setActiveSchool } from "@/lib/actions";
|
||||||
|
|
||||||
const Navbar = ({
|
const Navbar = ({
|
||||||
role,
|
role,
|
||||||
@ -41,6 +42,33 @@ const Navbar = ({
|
|||||||
|
|
||||||
{/* RIGHT: ICONS AND USER */}
|
{/* RIGHT: ICONS AND USER */}
|
||||||
<div className="flex items-center gap-6 justify-end">
|
<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 */}
|
{/* SEARCH BAR */}
|
||||||
<div className="hidden md:flex items-center gap-2 text-xs rounded-full ring-[1.5px] ring-gray-300 px-2">
|
<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} />
|
<Image src="/search.png" alt="" width={14} height={14} />
|
||||||
|
|||||||
@ -178,7 +178,7 @@ const WhiteboardCore = ({ lessonId, isFullscreen = false }: { lessonId: number |
|
|||||||
if (role !== undefined) {
|
if (role !== undefined) {
|
||||||
fetchWhiteboard();
|
fetchWhiteboard();
|
||||||
}
|
}
|
||||||
}, [lessonId, role]);
|
}, [isTeacherOrAdmin, lessonId, role]);
|
||||||
|
|
||||||
const handleSave = async (type: SnapshotType) => {
|
const handleSave = async (type: SnapshotType) => {
|
||||||
if (!lessonId || !editorRef.current || !isTeacherOrAdmin) return;
|
if (!lessonId || !editorRef.current || !isTeacherOrAdmin) return;
|
||||||
|
|||||||
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
|
hidden
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2 w-full md:w-1/4">
|
|
||||||
<label className="text-xs text-gray-500">Day</label>
|
|
||||||
<select
|
|
||||||
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
|
|
||||||
{...register("day")}
|
|
||||||
defaultValue={data?.day}
|
|
||||||
>
|
|
||||||
<option value="MONDAY">Monday</option>
|
|
||||||
<option value="TUESDAY">Tuesday</option>
|
|
||||||
<option value="WEDNESDAY">Wednesday</option>
|
|
||||||
<option value="THURSDAY">Thursday</option>
|
|
||||||
<option value="FRIDAY">Friday</option>
|
|
||||||
</select>
|
|
||||||
{errors.day?.message && (
|
|
||||||
<p className="text-xs text-red-400">
|
|
||||||
{errors.day.message.toString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<InputField
|
<InputField
|
||||||
label="Start Time"
|
label="Start Time"
|
||||||
name="startTime"
|
name="startTime"
|
||||||
|
|||||||
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,
|
||||||
TeacherSchema,
|
TeacherSchema,
|
||||||
} from "@/lib/formValidationSchemas";
|
} from "@/lib/formValidationSchemas";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
import { useFormState } from "react-dom";
|
import { useFormState } from "react-dom";
|
||||||
import {
|
import {
|
||||||
createStudent,
|
createStudent,
|
||||||
@ -35,10 +36,14 @@ const StudentForm = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<StudentSchema>({
|
} = useForm<StudentSchema>({
|
||||||
resolver: zodResolver(studentSchema),
|
resolver: zodResolver(studentSchema),
|
||||||
|
defaultValues: {
|
||||||
|
classIds: data?.classes?.map((c: { classId: number }) => c.classId) ?? data?.studentClasses?.map((sc: { classId: number }) => sc.classId) ?? [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [img, setImg] = useState<any>();
|
const [img, setImg] = useState<any>();
|
||||||
@ -219,31 +224,44 @@ const StudentForm = ({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 w-full md:w-1/4">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<label className="text-xs text-gray-500">Class</label>
|
<label className="text-xs text-gray-500">Classes</label>
|
||||||
<select
|
<Controller
|
||||||
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
|
control={control}
|
||||||
{...register("classId")}
|
name="classIds"
|
||||||
defaultValue={data?.classId}
|
render={({ field: { value = [], onChange } }) => (
|
||||||
>
|
<div className="flex flex-wrap gap-3">
|
||||||
{classes.map(
|
{classes.map(
|
||||||
(classItem: {
|
(classItem: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
_count: { students: number };
|
_count: { students: number };
|
||||||
}) => (
|
}) => (
|
||||||
<option value={classItem.id} key={classItem.id}>
|
<label key={classItem.id} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
({classItem.name} -{" "}
|
<input
|
||||||
{classItem._count.students + "/" + classItem.capacity}{" "}
|
type="checkbox"
|
||||||
Capacity)
|
checked={value.includes(classItem.id)}
|
||||||
</option>
|
onChange={(e) => {
|
||||||
)
|
if (e.target.checked) {
|
||||||
|
onChange([...value, classItem.id]);
|
||||||
|
} else {
|
||||||
|
onChange(value.filter((id) => id !== classItem.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{classItem.name} ({classItem._count.students}/{classItem.capacity})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</select>
|
/>
|
||||||
{errors.classId?.message && (
|
{errors.classIds?.message && (
|
||||||
<p className="text-xs text-red-400">
|
<p className="text-xs text-red-400">
|
||||||
{errors.classId.message.toString()}
|
{errors.classIds.message.toString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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;
|
||||||
1042
src/lib/actions.ts
1042
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(),
|
id: z.coerce.number().optional(),
|
||||||
name: z.string().min(1, { message: "Subject name is required!" }),
|
name: z.string().min(1, { message: "Subject name is required!" }),
|
||||||
teachers: z.array(z.string()), //teacher ids
|
teachers: z.array(z.string()), //teacher ids
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SubjectSchema = z.infer<typeof subjectSchema>;
|
export type SubjectSchema = z.infer<typeof subjectSchema>;
|
||||||
@ -14,6 +15,7 @@ export const classSchema = z.object({
|
|||||||
capacity: z.coerce.number().min(1, { message: "Capacity name is required!" }),
|
capacity: z.coerce.number().min(1, { message: "Capacity name is required!" }),
|
||||||
gradeId: z.coerce.number().min(1, { message: "Grade name is required!" }),
|
gradeId: z.coerce.number().min(1, { message: "Grade name is required!" }),
|
||||||
supervisorId: z.coerce.string().optional(),
|
supervisorId: z.coerce.string().optional(),
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ClassSchema = z.infer<typeof classSchema>;
|
export type ClassSchema = z.infer<typeof classSchema>;
|
||||||
@ -72,8 +74,9 @@ export const studentSchema = z.object({
|
|||||||
birthday: z.coerce.date({ message: "Birthday is required!" }),
|
birthday: z.coerce.date({ message: "Birthday is required!" }),
|
||||||
sex: z.enum(["MALE", "FEMALE"], { message: "Sex is required!" }),
|
sex: z.enum(["MALE", "FEMALE"], { message: "Sex is required!" }),
|
||||||
gradeId: z.coerce.number().min(1, { message: "Grade is required!" }),
|
gradeId: z.coerce.number().min(1, { message: "Grade is required!" }),
|
||||||
classId: z.coerce.number().min(1, { message: "Class is required!" }),
|
classIds: z.array(z.coerce.number()).min(1, { message: "At least one class is required!" }),
|
||||||
parentId: z.string().min(1, { message: "Parent Id is required!" }),
|
parentId: z.string().min(1, { message: "Parent Id is required!" }),
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StudentSchema = z.infer<typeof studentSchema>;
|
export type StudentSchema = z.infer<typeof studentSchema>;
|
||||||
@ -84,6 +87,7 @@ export const examSchema = z.object({
|
|||||||
startTime: z.coerce.date({ message: "Start time is required!" }),
|
startTime: z.coerce.date({ message: "Start time is required!" }),
|
||||||
endTime: z.coerce.date({ message: "End time is required!" }),
|
endTime: z.coerce.date({ message: "End time is required!" }),
|
||||||
lessonId: z.coerce.number({ message: "Lesson is required!" }),
|
lessonId: z.coerce.number({ message: "Lesson is required!" }),
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ExamSchema = z.infer<typeof examSchema>;
|
export type ExamSchema = z.infer<typeof examSchema>;
|
||||||
@ -91,12 +95,12 @@ export type ExamSchema = z.infer<typeof examSchema>;
|
|||||||
export const lessonSchema = z.object({
|
export const lessonSchema = z.object({
|
||||||
id: z.coerce.number().optional(),
|
id: z.coerce.number().optional(),
|
||||||
name: z.string().min(1, { message: "Lesson name is required!" }),
|
name: z.string().min(1, { message: "Lesson name is required!" }),
|
||||||
day: z.enum(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"], { message: "Day is required!" }),
|
|
||||||
startTime: z.coerce.date({ message: "Start time is required!" }),
|
startTime: z.coerce.date({ message: "Start time is required!" }),
|
||||||
endTime: z.coerce.date({ message: "End time is required!" }),
|
endTime: z.coerce.date({ message: "End time is required!" }),
|
||||||
subjectId: z.coerce.number({ message: "Subject is required!" }),
|
subjectId: z.coerce.number({ message: "Subject is required!" }),
|
||||||
classId: z.coerce.number({ message: "Class is required!" }),
|
classId: z.coerce.number({ message: "Class is required!" }),
|
||||||
teacherId: z.string({ message: "Teacher is required!" }),
|
teacherId: z.string({ message: "Teacher is required!" }),
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LessonSchema = z.infer<typeof lessonSchema>;
|
export type LessonSchema = z.infer<typeof lessonSchema>;
|
||||||
@ -107,6 +111,7 @@ export const assignmentSchema = z.object({
|
|||||||
startDate: z.coerce.date({ message: "Start date is required!" }),
|
startDate: z.coerce.date({ message: "Start date is required!" }),
|
||||||
dueDate: z.coerce.date({ message: "Due date is required!" }),
|
dueDate: z.coerce.date({ message: "Due date is required!" }),
|
||||||
lessonId: z.coerce.number({ message: "Lesson is required!" }),
|
lessonId: z.coerce.number({ message: "Lesson is required!" }),
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AssignmentSchema = z.infer<typeof assignmentSchema>;
|
export type AssignmentSchema = z.infer<typeof assignmentSchema>;
|
||||||
@ -117,6 +122,7 @@ export const resultSchema = z.object({
|
|||||||
studentId: z.string({ message: "Student is required!" }),
|
studentId: z.string({ message: "Student is required!" }),
|
||||||
examId: z.coerce.number().optional(),
|
examId: z.coerce.number().optional(),
|
||||||
assignmentId: z.coerce.number().optional(),
|
assignmentId: z.coerce.number().optional(),
|
||||||
|
schoolId: z.string(),
|
||||||
}).refine(data => data.examId || data.assignmentId, {
|
}).refine(data => data.examId || data.assignmentId, {
|
||||||
message: "Either Exam or Assignment is required",
|
message: "Either Exam or Assignment is required",
|
||||||
path: ["examId"],
|
path: ["examId"],
|
||||||
@ -131,6 +137,7 @@ export const eventSchema = z.object({
|
|||||||
startTime: z.coerce.date({ message: "Start time is required!" }),
|
startTime: z.coerce.date({ message: "Start time is required!" }),
|
||||||
endTime: z.coerce.date({ message: "End time is required!" }),
|
endTime: z.coerce.date({ message: "End time is required!" }),
|
||||||
classId: z.coerce.number().optional(),
|
classId: z.coerce.number().optional(),
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EventSchema = z.infer<typeof eventSchema>;
|
export type EventSchema = z.infer<typeof eventSchema>;
|
||||||
@ -141,6 +148,64 @@ export const announcementSchema = z.object({
|
|||||||
description: z.string().min(1, { message: "Description is required!" }),
|
description: z.string().min(1, { message: "Description is required!" }),
|
||||||
date: z.coerce.date({ message: "Date is required!" }),
|
date: z.coerce.date({ message: "Date is required!" }),
|
||||||
classId: z.coerce.number().optional(),
|
classId: z.coerce.number().optional(),
|
||||||
|
schoolId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AnnouncementSchema = z.infer<typeof announcementSchema>;
|
export type AnnouncementSchema = z.infer<typeof announcementSchema>;
|
||||||
|
|
||||||
|
export const termSchema = z.object({
|
||||||
|
id: z.coerce.number().optional(),
|
||||||
|
name: z.string().min(1, { message: "Term name is required!" }),
|
||||||
|
startDate: z.coerce.date({ message: "Start date is required!" }),
|
||||||
|
endDate: z.coerce.date({ message: "End date is required!" }),
|
||||||
|
schoolId: z.string(),
|
||||||
|
academicYearId: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TermSchema = z.infer<typeof termSchema>;
|
||||||
|
|
||||||
|
export const holidaySchema = z.object({
|
||||||
|
id: z.coerce.number().optional(),
|
||||||
|
name: z.string().min(1, { message: "Holiday name is required!" }),
|
||||||
|
startDate: z.coerce.date({ message: "Start date is required!" }),
|
||||||
|
endDate: z.coerce.date({ message: "End date is required!" }),
|
||||||
|
schoolId: z.string(),
|
||||||
|
academicYearId: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HolidaySchema = z.infer<typeof holidaySchema>;
|
||||||
|
|
||||||
|
export const schoolTimetableSlotSchema = z.object({
|
||||||
|
id: z.coerce.number().optional(),
|
||||||
|
name: z.string().min(1, { message: "Slot name is required!" }),
|
||||||
|
startTime: z.string().min(1, { message: "Start time is required!" }),
|
||||||
|
endTime: z.string().min(1, { message: "End time is required!" }),
|
||||||
|
isTeachingSlot: z.boolean().default(true),
|
||||||
|
schoolId: z.string(),
|
||||||
|
schoolTimetableId: z.coerce.number().optional(),
|
||||||
|
position: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SchoolTimetableSlotSchema = z.infer<typeof schoolTimetableSlotSchema>;
|
||||||
|
|
||||||
|
export const timetableTemplateSchema = z.object({
|
||||||
|
id: z.coerce.number().optional(),
|
||||||
|
name: z.string().min(1, { message: "Template name is required!" }),
|
||||||
|
teacherId: z.string().min(1, { message: "Teacher is required!" }),
|
||||||
|
schoolId: z.string(),
|
||||||
|
schoolTimetableId: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TimetableTemplateSchema = z.infer<typeof timetableTemplateSchema>;
|
||||||
|
|
||||||
|
export const timetableEntrySchema = z.object({
|
||||||
|
id: z.coerce.number().optional(),
|
||||||
|
timetableTemplateId: z.coerce.number({ message: "Template ID is required!" }),
|
||||||
|
teacherTimetableTemplateId: z.coerce.number().optional(),
|
||||||
|
schoolTimetableSlotId: z.coerce.number({ message: "Time Slot is required!" }),
|
||||||
|
classId: z.coerce.number({ message: "Class is required!" }),
|
||||||
|
subjectId: z.coerce.number({ message: "Subject is required!" }),
|
||||||
|
dayOfWeek: z.coerce.number().min(1).max(7, { message: "Invalid day of week!" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TimetableEntrySchema = z.infer<typeof timetableEntrySchema>;
|
||||||
|
|||||||
@ -7,6 +7,7 @@ type RouteAccessMap = {
|
|||||||
export const routeAccessMap: RouteAccessMap = {
|
export const routeAccessMap: RouteAccessMap = {
|
||||||
"/admin(.*)": ["admin"],
|
"/admin(.*)": ["admin"],
|
||||||
"/student(.*)": ["student"],
|
"/student(.*)": ["student"],
|
||||||
|
"/teacher-sign-up": [], // public: allow unauthenticated (middleware skips redirect when role is null)
|
||||||
"/teacher(.*)": ["teacher"],
|
"/teacher(.*)": ["teacher"],
|
||||||
"/parent(.*)": ["parent"],
|
"/parent(.*)": ["parent"],
|
||||||
"/list/teachers": ["admin", "teacher"],
|
"/list/teachers": ["admin", "teacher"],
|
||||||
|
|||||||
@ -14,8 +14,23 @@ export const getLessonWhiteboard = async (lessonId: number) => {
|
|||||||
.eq("lessonId", lessonId)
|
.eq("lessonId", lessonId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
// TODO: This doesn't feel right...
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code === 'PGRST116') return { success: true, data: null }; // not found
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default clerkMiddleware((auth, req) => {
|
|||||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||||
|
|
||||||
for (const { matcher, allowedRoles } of matchers) {
|
for (const { matcher, allowedRoles } of matchers) {
|
||||||
if (matcher(req) && !allowedRoles.includes(role!)) {
|
if (matcher(req) && role != null && !allowedRoles.includes(role)) {
|
||||||
return NextResponse.redirect(new URL(`/${role}`, req.url));
|
return NextResponse.redirect(new URL(`/${role}`, req.url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
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