mian commit
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
37
.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
26
Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
# Use Node.js as the base image
|
||||
FROM node:18
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Generate Database
|
||||
RUN npx prisma migrate dev --name init
|
||||
|
||||
# Build the Next.js application
|
||||
RUN npm run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the Next.js application
|
||||
CMD ["npm", "start"]
|
||||
27
docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgress:
|
||||
image: postgres:15
|
||||
container_name: postgres_db
|
||||
environment:
|
||||
POSTGRES_USER: myuser
|
||||
POSTGRES_PASSWORD: mypassword
|
||||
POSTGRES_DB: mydb
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: nextjs_app
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://myuser:mypassword@192.168.0.48:5432/mydb
|
||||
depends_on:
|
||||
- postgress
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
10
jwt_dump.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"aud": "authenticated",
|
||||
"exp": 1772176558,
|
||||
"iat": 1772176498,
|
||||
"iss": "https://kind-burro-23.clerk.accounts.dev",
|
||||
"jti": "0689311f9e0ff06da236",
|
||||
"nbf": 1772176493,
|
||||
"role": "admin",
|
||||
"sub": "user_3AE7OSCzF8rz7XnvTkOUmFV4AKp"
|
||||
}
|
||||
8
next.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [{ hostname: "images.pexels.com" }],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7404
package-lock.json
generated
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "lama-dev-next-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"seed:users": "tsx scripts/seed.ts",
|
||||
"seed:schedule": "tsx scripts/seed_schedule.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/elements": "^0.14.6",
|
||||
"@clerk/nextjs": "^5.4.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@prisma/client": "^5.19.1",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@types/react-big-calendar": "^1.8.9",
|
||||
"moment": "^2.30.1",
|
||||
"next": "14.2.5",
|
||||
"next-cloudinary": "^6.13.0",
|
||||
"prisma": "^5.19.1",
|
||||
"react": "^18",
|
||||
"react-big-calendar": "^1.13.2",
|
||||
"react-calendar": "^5.0.0",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-toastify": "^10.0.5",
|
||||
"recharts": "^2.12.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/announcement.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assignment.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/attendance.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/avatar.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/blood.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/calendar.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/class.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/close.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/create.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/date.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/delete.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/exam.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/filter.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/finance.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/home.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/lesson.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/logout.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/mail.png
Normal file
|
After Width: | Height: | Size: 830 B |
BIN
public/maleFemale.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/message.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/more.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/moreDark.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/noAvatar.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/parent.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/phone.png
Normal file
|
After Width: | Height: | Size: 370 B |
BIN
public/profile.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/result.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/search.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/setting.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/singleAttendance.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/singleBranch.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/singleClass.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/singleLesson.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/sort.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/student.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/subject.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/teacher.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/update.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/upload.png
Normal file
|
After Width: | Height: | Size: 650 B |
BIN
public/view.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
142
scripts/seed-data.json
Normal file
@ -0,0 +1,142 @@
|
||||
{
|
||||
"teacherMap": {
|
||||
"1": "user_3AJAkSqshofbdPsW7lZh6q2eCe5",
|
||||
"2": "user_3AJAkVye7wKBBK8seog6gljyc7L",
|
||||
"3": "user_3AJAkXA9lrOHxsGcJNoJGOXKNOr",
|
||||
"4": "user_3AJAkc3f42kZzVPLvaRBFHor14J",
|
||||
"5": "user_3AJAkbsXvHmZcGu3wFpRZm732LX",
|
||||
"6": "user_3AJAke4rOtbf26yRM9JoHhtEjyD",
|
||||
"7": "user_3AJAklVf8Pnm2pH6Ql1PnfQBIID",
|
||||
"8": "user_3AJAki8tuWw9rWvZZD9xIP6CxVp",
|
||||
"9": "user_3AJAkn3ET3kFyUrx636ayvHrLds",
|
||||
"10": "user_3AJAkuiTMWimGNBtw7ymHVUqwVW",
|
||||
"11": "user_3AJAkvjGTDDsscx7g9dD998d6Gq",
|
||||
"12": "user_3AJAkr08HMjIKMG1Guzw3HJqB9L",
|
||||
"13": "user_3AJAl1xiX1ZtMg0ohV7QB2TpHm5",
|
||||
"14": "user_3AJAl1tyuI5t3DfmleToHAw6HgJ",
|
||||
"15": "user_3AJAl3Rr5gq7K1W0fhl6AVNFXrY"
|
||||
},
|
||||
"parentMap": {
|
||||
"1": "user_3AJAl6KD5fVP5MbjDZLpyoZ7sDG",
|
||||
"2": "user_3AJAl9gkapHVzn5Tik7RteIEJVZ",
|
||||
"3": "user_3AJAl5BLWwfpho7phY16SrCleE8",
|
||||
"4": "user_3AJAl7BN77L27mpmHzjCp96OlFx",
|
||||
"5": "user_3AJAlFko0fvXyleuRuyqmGkpgTV",
|
||||
"6": "user_3AJAlEyIdgY7vVA7yZQUbTwimOC",
|
||||
"7": "user_3AJAlIlHOwSpcR92ENGiHrz0nCg",
|
||||
"8": "user_3AJAlKekpw2nrtGnnMBvN0xdh3c",
|
||||
"9": "user_3AJAlLmCzuvKoqh9yh2ulnmz26h",
|
||||
"10": "user_3AJAlPbUuestp7PvH2BuX2GQK6R",
|
||||
"11": "user_3AJAlWR17pNYP0S24hiNnUKI2R1",
|
||||
"12": "user_3AJAlWTNINh2uIOEueR8dPKXjlc",
|
||||
"13": "user_3AJAlUx3a2u65hTIoxWLjmDnDuj",
|
||||
"14": "user_3AJAlac7l5vhi9lbnvuOIEZ2zoz",
|
||||
"15": "user_3AJAldy0F23q4Jm8kz8olXdoao0",
|
||||
"16": "user_3AJAlekANUdcMJeZ34EFcCjluAq",
|
||||
"17": "user_3AJAllI3cBBjs5JbcUimhrLEYvf",
|
||||
"18": "user_3AJAlp8YJPzQwvcutFoCsHAz3Sn",
|
||||
"19": "user_3AJAllVI1vR6vwSPlLPCyIb9oZB",
|
||||
"20": "user_3AJAlwjmMMnvV9la8fFlH7TYuOS",
|
||||
"21": "user_3AJAlxGYIRJnKZmvkt2HB9ukrlC",
|
||||
"22": "user_3AJAltSwlQoPrRGcsAZcJpH2sH9",
|
||||
"23": "user_3AJAlvcnUE4GGbYKK0odLUjwxqu",
|
||||
"24": "user_3AJAm16YficrMn5CeAOCAxV8iu1",
|
||||
"25": "user_3AJAlxj9Z9gE4EIwQ5pljEywIV0"
|
||||
},
|
||||
"studentMap": {
|
||||
"1": "user_3AJAm7o0RjL16Gt1jE9YLG0tib7",
|
||||
"2": "user_3AJAm9SoYdWl5sQfbf8slNI2Qn4",
|
||||
"3": "user_3AJAm8NIt39YG9DPjnAwq2G2V3G",
|
||||
"4": "user_3AJAm5KPpNLvz8DjwNU6MRCBO5e",
|
||||
"5": "user_3AJAmGDojjfnVtn7LrmLVemoCiM",
|
||||
"6": "user_3AJAmDK9jVHWfe3FjdR7emQ329K",
|
||||
"7": "user_3AJAmMNLtdeplBIS9kwXYW0FC9Z",
|
||||
"8": "user_3AJAmNXiIZz0FYILKDOZxq1ET56",
|
||||
"9": "user_3AJAmMeKtN7oMMrLgcxIEWsm0EV",
|
||||
"10": "user_3AJAmQsp9Xg8LDpxCkkmccjDCuU",
|
||||
"11": "user_3AJAmZtzvmAwwu7QJRvIpxZCNYF",
|
||||
"12": "user_3AJAmXM1fL4b0fONRdM5uIRIxBS",
|
||||
"13": "user_3AJAmi4pxmDtV24YaIaNmmack33",
|
||||
"14": "user_3AJAmf864NjSUdQ6ExiMR3zaDCn",
|
||||
"15": "user_3AJAmbZArRtgzFrBdW77qbd7P1z",
|
||||
"16": "user_3AJAmpWu9wzc4ve2kwTKPLveFST",
|
||||
"17": "user_3AJAmoVVKEmMeE8GMd7A2fgn5nY",
|
||||
"18": "user_3AJAmlYITikQW1km45TqvQ2biBm",
|
||||
"19": "user_3AJAmuEU2s2CLT9391jnj6Uxb3e",
|
||||
"20": "user_3AJAmwfQjhHcnd6Y2HGvXWTxTj8",
|
||||
"21": "user_3AJAmxavPXIvafOo84MWRqKPyRN",
|
||||
"22": "user_3AJAmxtksJ1U2UjHoP1wzXgk3JB",
|
||||
"23": "user_3AJAn3gfMCCyfaFrX0yY69XfQfQ",
|
||||
"24": "user_3AJAn1UL8HJe9pfcZboyDCqg4Zd",
|
||||
"25": "user_3AJAn4fAYAtcYCLKv1fBkBJSyxw",
|
||||
"26": "user_3AJAnBlPYjZBRY7HrMaY95WRQ59",
|
||||
"27": "user_3AJAnD31sahtYeq9Zfzu867VgB8",
|
||||
"28": "user_3AJAnK8qcNr259cZnIKpH4ZuoRH",
|
||||
"29": "user_3AJAnECM3SCOTVaHQvwaIkV1YnJ",
|
||||
"30": "user_3AJAnH4sNG5lbNwuvYHmmbU9J3U",
|
||||
"31": "user_3AJAnRjFXzyoALI4egtIvZEUFuA",
|
||||
"32": "user_3AJAnYhwtMvevCuQScoLSJePp79",
|
||||
"33": "user_3AJAnXQkG2mOAFxFm1lHaOV2aJQ",
|
||||
"34": "user_3AJAnYU4Frx0bX863nwBE9EJaGH",
|
||||
"35": "user_3AJAncF3uP2xfkU6WixizrfSSrl",
|
||||
"36": "user_3AJAngPagGao0QCe5AcGeeGGpDS",
|
||||
"37": "user_3AJAnazAi3ERTCfqVQr7jDtTOK4",
|
||||
"38": "user_3AJAnoJnVGxwohyI3ot6cwnZ6iO",
|
||||
"39": "user_3AJAnoCbmMiBBlXE3PvUN1vg3CT",
|
||||
"40": "user_3AJAniemPzyyEwPSZZ2h7iXK9lV",
|
||||
"41": "user_3AJAnvqsElaS1dwqPtmhw4N7dCS",
|
||||
"42": "user_3AJAnqp0r30RwkxKEJK3nrPJcwR",
|
||||
"43": "user_3AJAnsBcWHQQGYCpKxLbfWpSWdE",
|
||||
"44": "user_3AJAnrDUy5R1iFD1Vsb8FNlDO1I",
|
||||
"45": "user_3AJAo1kdbtoIYYx3gvEcr3D9WT8",
|
||||
"46": "user_3AJAo0CALCnqJle4D4JGt5VNqHJ",
|
||||
"47": "user_3AJAo1fZPiFTp8U0NYaprnET0Ls",
|
||||
"48": "user_3AJAoB35YG2oZHKLvT4UFBQVBqj",
|
||||
"49": "user_3AJAoDZ1nOyp3f3KO2Lefzgi6Ah",
|
||||
"50": "user_3AJAo6si4CGdeQ3teM04nPmrJe2"
|
||||
},
|
||||
"classes": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "1A",
|
||||
"gradeId": 1,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkSqshofbdPsW7lZh6q2eCe5"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "2A",
|
||||
"gradeId": 2,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkVye7wKBBK8seog6gljyc7L"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "3A",
|
||||
"gradeId": 3,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkXA9lrOHxsGcJNoJGOXKNOr"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "4A",
|
||||
"gradeId": 4,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkc3f42kZzVPLvaRBFHor14J"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "5A",
|
||||
"gradeId": 5,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkbsXvHmZcGu3wFpRZm732LX"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "6A",
|
||||
"gradeId": 6,
|
||||
"capacity": 20,
|
||||
"supervisorId": "user_3AJAkSqshofbdPsW7lZh6q2eCe5"
|
||||
}
|
||||
]
|
||||
}
|
||||
261
scripts/seed.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import "dotenv/config";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
"sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz",
|
||||
{ auth: { persistSession: false } }
|
||||
);
|
||||
|
||||
const PASSWORD = "&%5C400l&%";
|
||||
|
||||
async function cleanClerk() {
|
||||
console.log("Cleaning up old Clerk users...");
|
||||
const clerk = clerkClient();
|
||||
const users = await clerk.users.getUserList({ limit: 500 });
|
||||
for (const user of users.data) {
|
||||
if (
|
||||
user.publicMetadata.role === "teacher" ||
|
||||
user.publicMetadata.role === "student" ||
|
||||
user.publicMetadata.role === "parent"
|
||||
) {
|
||||
await clerk.users.deleteUser(user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanSupabase() {
|
||||
console.log("Cleaning up Supabase tables...");
|
||||
const tables = [
|
||||
"Result", "Assignment", "Exam", "Attendance", "Event", "Announcement",
|
||||
"Lesson", "TeacherSubject", "Student", "Teacher", "Parent", "Class", "Subject", "Grade"
|
||||
];
|
||||
for (const table of tables) {
|
||||
await supabase.from(table).delete().neq("id", "0" as any);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAdmin() {
|
||||
console.log("Syncing Admin...");
|
||||
const clerk = clerkClient();
|
||||
const users = await clerk.users.getUserList({ limit: 100 });
|
||||
const adminUser = users.data.find(u => u.username === "admin" || u.emailAddresses[0]?.emailAddress?.includes("admin"));
|
||||
|
||||
if (adminUser) {
|
||||
await supabase.from("Admin").upsert({ id: adminUser.id, username: adminUser.username || "admin" });
|
||||
console.log(`Synced Admin ID: ${adminUser.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const clerk = clerkClient();
|
||||
await cleanClerk();
|
||||
await cleanSupabase();
|
||||
await seedAdmin();
|
||||
|
||||
console.log("Seeding Grades and Subjects...");
|
||||
const grades = [1, 2, 3, 4, 5, 6].map((level) => ({ id: level, level }));
|
||||
await supabase.from("Grade").insert(grades);
|
||||
|
||||
const subjectsArray = [
|
||||
{ id: 1, name: "Mathematics" },
|
||||
{ id: 2, name: "Science" },
|
||||
{ id: 3, name: "English" },
|
||||
{ id: 4, name: "History" },
|
||||
{ id: 5, name: "Geography" },
|
||||
{ id: 6, name: "Physics" },
|
||||
{ id: 7, name: "Chemistry" },
|
||||
{ id: 8, name: "Biology" },
|
||||
{ id: 9, name: "Computer Science" },
|
||||
{ id: 10, name: "Art" },
|
||||
];
|
||||
await supabase.from("Subject").insert(subjectsArray);
|
||||
|
||||
console.log("Creating 15 Teachers...");
|
||||
const teacherMap: Record<number, string> = {};
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
const user = await clerk.users.createUser({
|
||||
username: `teacher${i}`,
|
||||
password: PASSWORD,
|
||||
firstName: `TName${i}`,
|
||||
lastName: `TSurname${i}`,
|
||||
publicMetadata: { role: "teacher" }
|
||||
});
|
||||
teacherMap[i] = user.id;
|
||||
|
||||
await supabase.from("Teacher").insert({
|
||||
id: user.id,
|
||||
username: `teacher${i}`,
|
||||
name: `TName${i}`,
|
||||
surname: `TSurname${i}`,
|
||||
email: `teacher${i}@example.com`,
|
||||
phone: `123-456-789${i}`,
|
||||
address: `Address${i}`,
|
||||
bloodType: "A+",
|
||||
sex: i % 2 === 0 ? "MALE" : "FEMALE",
|
||||
birthday: "1996-02-27T00:26:35.280Z"
|
||||
});
|
||||
|
||||
await supabase.from("TeacherSubject").insert([
|
||||
{ subjectId: (i % 10) + 1, teacherId: user.id, isPrimary: true },
|
||||
{ subjectId: ((i + 1) % 10) + 1, teacherId: user.id }
|
||||
]);
|
||||
}
|
||||
|
||||
console.log("Creating 6 Classes...");
|
||||
const classesArray = [
|
||||
{ id: 1, name: "1A", gradeId: 1, capacity: 20, supervisorId: teacherMap[1] },
|
||||
{ id: 2, name: "2A", gradeId: 2, capacity: 20, supervisorId: teacherMap[2] },
|
||||
{ id: 3, name: "3A", gradeId: 3, capacity: 20, supervisorId: teacherMap[3] },
|
||||
{ id: 4, name: "4A", gradeId: 4, capacity: 20, supervisorId: teacherMap[4] },
|
||||
{ id: 5, name: "5A", gradeId: 5, capacity: 20, supervisorId: teacherMap[5] },
|
||||
{ id: 6, name: "6A", gradeId: 6, capacity: 20, supervisorId: teacherMap[1] },
|
||||
];
|
||||
await supabase.from("Class").insert(classesArray);
|
||||
|
||||
console.log("Creating 25 Parents...");
|
||||
const parentMap: Record<number, string> = {};
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const user = await clerk.users.createUser({
|
||||
username: `parent${i}`,
|
||||
password: PASSWORD,
|
||||
firstName: `PName${i}`,
|
||||
lastName: `PSurname${i}`,
|
||||
publicMetadata: { role: "parent" }
|
||||
});
|
||||
parentMap[i] = user.id;
|
||||
|
||||
await supabase.from("Parent").insert({
|
||||
id: user.id,
|
||||
username: `parent${i}`,
|
||||
name: `PName${i}`,
|
||||
surname: `PSurname${i}`,
|
||||
email: `parent${i}@example.com`,
|
||||
phone: `123-456-789${i}`,
|
||||
address: `Address${i}`
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Creating 50 Students...");
|
||||
const studentMap: Record<number, string> = {};
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const user = await clerk.users.createUser({
|
||||
username: `student${i}`,
|
||||
password: PASSWORD,
|
||||
firstName: `SName${i}`,
|
||||
lastName: `SSurname${i}`,
|
||||
publicMetadata: { role: "student" }
|
||||
});
|
||||
studentMap[i] = user.id;
|
||||
|
||||
const classInfo = classesArray[(i % 6)];
|
||||
await supabase.from("Student").insert({
|
||||
id: user.id,
|
||||
username: `student${i}`,
|
||||
name: `SName${i}`,
|
||||
surname: `SSurname${i}`,
|
||||
email: `student${i}@example.com`,
|
||||
phone: `987-654-321${i}`,
|
||||
address: `Address${i}`,
|
||||
bloodType: "O-",
|
||||
sex: i % 2 === 0 ? "MALE" : "FEMALE",
|
||||
parentId: parentMap[(i % 25) + 1],
|
||||
gradeId: classInfo.gradeId,
|
||||
classId: classInfo.id,
|
||||
birthday: "2016-02-27T00:26:35.281Z"
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Exporting User IDs to seed-data.json...");
|
||||
const seedDataPath = path.join(process.cwd(), "scripts", "seed-data.json");
|
||||
const seedData = {
|
||||
teacherMap,
|
||||
parentMap,
|
||||
studentMap,
|
||||
classes: classesArray
|
||||
};
|
||||
fs.writeFileSync(seedDataPath, JSON.stringify(seedData, null, 2));
|
||||
|
||||
console.log("User generation complete! Saved to seed-data.json.");
|
||||
|
||||
/*
|
||||
const daysOfWeek: ("MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY")[] = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"];
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const classIndex = i % 6;
|
||||
const classInfo = classesArray[classIndex];
|
||||
|
||||
const teacherIndex = (i % 15) + 1;
|
||||
const teacherId = teacherMap[teacherIndex];
|
||||
const subjectId = (teacherIndex % 10) + 1;
|
||||
|
||||
// Distribute across 5 days (Monday to Friday)
|
||||
const dayIndex = i % 5;
|
||||
const dayName = daysOfWeek[dayIndex];
|
||||
|
||||
// Distribute across 6 periods (e.g., 8:00 AM to 2:00 PM)
|
||||
const periodIndex = (i % 6);
|
||||
|
||||
// Base date (a Monday): 2026-02-23T00:00:00.000Z
|
||||
const lessonDate = new Date("2026-02-23T00:00:00.000Z");
|
||||
lessonDate.setDate(lessonDate.getDate() + dayIndex);
|
||||
|
||||
// Create start time and end time (1 hour lesson)
|
||||
const startHour = 8 + periodIndex;
|
||||
const startTime = new Date(lessonDate);
|
||||
startTime.setUTCHours(startHour, 0, 0, 0);
|
||||
|
||||
const endTime = new Date(lessonDate);
|
||||
endTime.setUTCHours(startHour + 1, 0, 0, 0);
|
||||
|
||||
await supabase.from("Lesson").insert({
|
||||
id: i,
|
||||
name: `Lesson${i}`,
|
||||
day: dayName,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
subjectId: subjectId,
|
||||
classId: classInfo.id,
|
||||
teacherId: teacherId
|
||||
});
|
||||
|
||||
await supabase.from("Exam").insert({
|
||||
id: i,
|
||||
title: `Exam ${i}`,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
lessonId: i
|
||||
});
|
||||
|
||||
await supabase.from("Assignment").insert({
|
||||
id: i,
|
||||
title: `Assignment ${i}`,
|
||||
startDate: startTime.toISOString(),
|
||||
dueDate: new Date(startTime.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), // Due in 7 days
|
||||
lessonId: i
|
||||
});
|
||||
|
||||
// Result requires a student from the class 'classIndex'.
|
||||
// Student 'j' is in class 'j % 6'.
|
||||
const studentBase = classIndex === 0 ? 6 : classIndex;
|
||||
const studentIndex = studentBase + 6 * (i % 8); // Ensures variation 1-48
|
||||
|
||||
await supabase.from("Result").insert({
|
||||
id: i,
|
||||
score: 90 + (i % 10),
|
||||
studentId: studentMap[studentIndex],
|
||||
examId: i
|
||||
});
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
} catch (err) {
|
||||
console.error("Seed error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
226
scripts/seed_schedule.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import "dotenv/config";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
"sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz",
|
||||
{ auth: { persistSession: false } }
|
||||
);
|
||||
|
||||
const SUBJECTS: Record<string, number> = {
|
||||
Math: 1,
|
||||
Science: 2,
|
||||
English: 3,
|
||||
History: 4,
|
||||
Art: 5,
|
||||
Music: 6,
|
||||
PE: 7,
|
||||
Computer: 8,
|
||||
Geography: 9,
|
||||
Biology: 10
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CONFIGURATION: Adjust the timeline spread and generation odds
|
||||
// -------------------------------------------------------------
|
||||
const CONFIG = {
|
||||
monthsPast: 3, // How many months backward to generate lessons
|
||||
monthsFuture: 9, // How many months forward to generate lessons
|
||||
baseDate: new Date("2026-02-23T00:00:00.000Z"), // "Present" date
|
||||
examProbability: 0.05, // 5% chance of an Exam per lesson
|
||||
assignmentProbability: 0.1, // 10% chance of an Assignment per lesson
|
||||
attendanceProbability: 0.95, // 95% attendance rate for past lessons
|
||||
};
|
||||
|
||||
// Helper to chunk large arrays for Supabase batch inserts
|
||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function insertInChunks(table: string, data: any[]) {
|
||||
if (data.length === 0) return;
|
||||
console.log(`Inserting ${data.length} records into ${table}...`);
|
||||
const chunks = chunkArray(data, 1000);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const { error } = await supabase.from(table).insert(chunk);
|
||||
if (error) {
|
||||
console.error(`Error inserting into ${table} (chunk ${i}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("Loading user IDs from seed-data.json...");
|
||||
const seedDataPath = path.join(process.cwd(), "scripts", "seed-data.json");
|
||||
if (!fs.existsSync(seedDataPath)) {
|
||||
throw new Error("seed-data.json not found! Please run `npm run seed:users` first.");
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(seedDataPath, "utf-8"));
|
||||
const { teacherMap, studentMap, classes } = data;
|
||||
|
||||
console.log("Cleaning old Schedule & Attendance data...");
|
||||
const tablesToClean = ["Attendance", "Result", "Assignment", "Exam", "Lesson"];
|
||||
for (const table of tablesToClean) {
|
||||
await supabase.from(table).delete().neq("id", "0" as any);
|
||||
}
|
||||
|
||||
console.log(`Building full year timetable...`);
|
||||
console.log(`- Past months: ${CONFIG.monthsPast}`);
|
||||
console.log(`- Future months: ${CONFIG.monthsFuture}`);
|
||||
|
||||
const days = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"];
|
||||
const periods = [8, 9, 10, 11, 13, 14]; // Expanded periods (8 AM - 2 PM, skipping 12 PM lunch)
|
||||
|
||||
let lessonIdCounter = 1;
|
||||
let examIdCounter = 1;
|
||||
let assignmentIdCounter = 1;
|
||||
let resultIdCounter = 1;
|
||||
let attendanceIdCounter = 1;
|
||||
|
||||
const lessonsData = [];
|
||||
const examsData = [];
|
||||
const assignmentsData = [];
|
||||
const resultsData = [];
|
||||
const attendancesData = [];
|
||||
|
||||
// Compute start of generation (aligned to a Monday)
|
||||
const startDate = new Date(CONFIG.baseDate);
|
||||
startDate.setMonth(startDate.getMonth() - CONFIG.monthsPast);
|
||||
const startDayOfWeek = startDate.getDay();
|
||||
const daysToMonday = startDayOfWeek === 0 ? -6 : 1 - startDayOfWeek;
|
||||
startDate.setDate(startDate.getDate() + daysToMonday);
|
||||
|
||||
// Compute end of generation
|
||||
const endDate = new Date(CONFIG.baseDate);
|
||||
endDate.setMonth(endDate.getMonth() + CONFIG.monthsFuture);
|
||||
|
||||
let currentWeekStart = new Date(startDate);
|
||||
|
||||
while (currentWeekStart < endDate) {
|
||||
for (let classIndex = 0; classIndex < classes.length; classIndex++) {
|
||||
const classInfo = classes[classIndex];
|
||||
|
||||
// Gather students belonging to this class based on seed logic
|
||||
const studentList: string[] = [];
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const studentBase = classIndex === 0 ? 6 : classIndex;
|
||||
const studentIdx = studentBase + 6 * (i % 8);
|
||||
if (studentMap[studentIdx] && !studentList.includes(studentMap[studentIdx])) {
|
||||
studentList.push(studentMap[studentIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let dayOffset = 0; dayOffset < days.length; dayOffset++) {
|
||||
const dayName = days[dayOffset];
|
||||
const currentDate = new Date(currentWeekStart);
|
||||
currentDate.setDate(currentDate.getDate() + dayOffset);
|
||||
|
||||
for (let period = 0; period < periods.length; period++) {
|
||||
const startHour = periods[period];
|
||||
|
||||
const subjectIdx = (classIndex + dayOffset + period) % 10;
|
||||
const subjectKey = Object.keys(SUBJECTS)[subjectIdx];
|
||||
const subjectId = SUBJECTS[subjectKey];
|
||||
const teacherId = teacherMap[subjectId]; // Authorized teacher
|
||||
|
||||
const startTime = new Date(currentDate);
|
||||
startTime.setUTCHours(startHour, 0, 0, 0);
|
||||
|
||||
const endTime = new Date(currentDate);
|
||||
endTime.setUTCHours(startHour + 1, 0, 0, 0);
|
||||
|
||||
// 1. Insert Lesson
|
||||
lessonsData.push({
|
||||
id: lessonIdCounter,
|
||||
name: `${classInfo.name} ${subjectKey} (${dayName})`,
|
||||
day: dayName,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
subjectId: subjectId,
|
||||
classId: classInfo.id,
|
||||
teacherId: teacherId
|
||||
});
|
||||
|
||||
// 2. Insert Exam / Results (Random Probability)
|
||||
if (Math.random() < CONFIG.examProbability) {
|
||||
examsData.push({
|
||||
id: examIdCounter,
|
||||
title: `${subjectKey} Assessment`,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
lessonId: lessonIdCounter
|
||||
});
|
||||
|
||||
// Only give results to a subset to prevent table explosions
|
||||
for (const sId of studentList.slice(0, 4)) {
|
||||
resultsData.push({
|
||||
id: resultIdCounter++,
|
||||
score: 60 + Math.floor(Math.random() * 41), // 60-100
|
||||
studentId: sId,
|
||||
examId: examIdCounter
|
||||
});
|
||||
}
|
||||
examIdCounter++;
|
||||
}
|
||||
|
||||
// 3. Insert Assignment
|
||||
if (Math.random() < CONFIG.assignmentProbability) {
|
||||
assignmentsData.push({
|
||||
id: assignmentIdCounter++,
|
||||
title: `${subjectKey} Practice`,
|
||||
startDate: startTime.toISOString(),
|
||||
dueDate: new Date(startTime.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lessonId: lessonIdCounter
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Insert Attendance (ONLY for dates occurring in the past)
|
||||
if (startTime < CONFIG.baseDate) {
|
||||
for (const sId of studentList) {
|
||||
attendancesData.push({
|
||||
id: attendanceIdCounter++,
|
||||
date: startTime.toISOString(),
|
||||
present: Math.random() < CONFIG.attendanceProbability,
|
||||
studentId: sId,
|
||||
lessonId: lessonIdCounter
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lessonIdCounter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance to the next week safely
|
||||
currentWeekStart.setDate(currentWeekStart.getDate() + 7);
|
||||
}
|
||||
|
||||
// Send payload in bulk batches to Supabase to prevent network/memory bottlenecks
|
||||
await insertInChunks("Lesson", lessonsData);
|
||||
await insertInChunks("Exam", examsData);
|
||||
await insertInChunks("Assignment", assignmentsData);
|
||||
await insertInChunks("Result", resultsData);
|
||||
await insertInChunks("Attendance", attendancesData);
|
||||
|
||||
console.log(`\n✅ Timeline successfully generated!`);
|
||||
console.log(`- Lessons: ${lessonsData.length}`);
|
||||
console.log(`- Exams: ${examsData.length}`);
|
||||
console.log(`- Assignments: ${assignmentsData.length}`);
|
||||
console.log(`- Results: ${resultsData.length}`);
|
||||
console.log(`- Attendance Records: ${attendancesData.length}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Schedule Seeding Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
17
scripts/test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
"sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz",
|
||||
{ auth: { persistSession: false } }
|
||||
);
|
||||
|
||||
async function test() {
|
||||
const { data, error } = await supabase.from("Admin").select("*");
|
||||
console.log("Admins:", data);
|
||||
console.log("Error:", error);
|
||||
}
|
||||
|
||||
test();
|
||||
49
src/app/(dashboard)/admin/page.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import Announcements from "@/components/Announcements";
|
||||
import AttendanceChartContainer from "@/components/AttendanceChartContainer";
|
||||
import CountChartContainer from "@/components/CountChartContainer";
|
||||
import EventCalendarContainer from "@/components/EventCalendarContainer";
|
||||
import FinanceChart from "@/components/FinanceChart";
|
||||
import UserCard from "@/components/UserCard";
|
||||
|
||||
const AdminPage = ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [keys: string]: string | undefined };
|
||||
}) => {
|
||||
return (
|
||||
<div className="p-4 flex gap-4 flex-col md:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="w-full lg:w-2/3 flex flex-col gap-8">
|
||||
{/* USER CARDS */}
|
||||
<div className="flex gap-4 justify-between flex-wrap">
|
||||
<UserCard type="admin" />
|
||||
<UserCard type="teacher" />
|
||||
<UserCard type="student" />
|
||||
<UserCard type="parent" />
|
||||
</div>
|
||||
{/* MIDDLE CHARTS */}
|
||||
<div className="flex gap-4 flex-col lg:flex-row">
|
||||
{/* COUNT CHART */}
|
||||
<div className="w-full lg:w-1/3 h-[450px]">
|
||||
<CountChartContainer />
|
||||
</div>
|
||||
{/* ATTENDANCE CHART */}
|
||||
<div className="w-full lg:w-2/3 h-[450px]">
|
||||
<AttendanceChartContainer />
|
||||
</div>
|
||||
</div>
|
||||
{/* BOTTOM CHART */}
|
||||
<div className="w-full h-[500px]">
|
||||
<FinanceChart />
|
||||
</div>
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
<div className="w-full lg:w-1/3 flex flex-col gap-8">
|
||||
<EventCalendarContainer searchParams={searchParams}/>
|
||||
<Announcements />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
31
src/app/(dashboard)/layout.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import Menu from "@/components/Menu";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="h-screen flex">
|
||||
{/* LEFT */}
|
||||
<div className="w-[14%] md:w-[8%] lg:w-[16%] xl:w-[14%] p-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center lg:justify-start gap-2"
|
||||
>
|
||||
<Image src="/logo.png" alt="logo" width={32} height={32} />
|
||||
<span className="hidden lg:block font-bold">SchooLama</span>
|
||||
</Link>
|
||||
<Menu />
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
<div className="w-[86%] md:w-[92%] lg:w-[84%] xl:w-[86%] bg-[#F7F8FA] overflow-scroll flex flex-col">
|
||||
<Navbar />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/app/(dashboard)/list/announcements/page.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
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 AnnouncementList = Tables<"Announcement"> & { class: Tables<"Class"> | null };
|
||||
const AnnouncementListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const currentUserId = userId;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Title",
|
||||
accessor: "title",
|
||||
},
|
||||
{
|
||||
header: "Class",
|
||||
accessor: "class",
|
||||
},
|
||||
{
|
||||
header: "Date",
|
||||
accessor: "date",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
...(role === "admin"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: AnnouncementList) => (
|
||||
<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.title}</td>
|
||||
<td>{item.class?.name || "-"}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{new Intl.DateTimeFormat("en-US").format(new Date(item.date))}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{role === "admin" && (
|
||||
<>
|
||||
<FormContainer table="announcement" type="update" data={item} />
|
||||
<FormContainer table="announcement" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Announcement")
|
||||
.select("*, class:Class(*)", { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "search":
|
||||
query = query.ilike("title", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROLE CONDITIONS
|
||||
// Authorization is now handled by Supabase Postgres RLS policies.
|
||||
// The initialized `supabase` client automatically passes the Clerk user JWT.
|
||||
|
||||
// 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 announcements from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []) as unknown as AnnouncementList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">
|
||||
All Announcements
|
||||
</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="announcement" type="create" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementListPage;
|
||||
162
src/app/(dashboard)/list/assignments/page.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
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 AssignmentList = Tables<"Assignment"> & {
|
||||
lesson: Tables<"Lesson"> & {
|
||||
subject: Tables<"Subject">;
|
||||
class: Tables<"Class">;
|
||||
teacher: Tables<"Teacher">;
|
||||
};
|
||||
};
|
||||
|
||||
const AssignmentListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const currentUserId = userId;
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Subject Name",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
header: "Class",
|
||||
accessor: "class",
|
||||
},
|
||||
{
|
||||
header: "Teacher",
|
||||
accessor: "teacher",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Due Date",
|
||||
accessor: "dueDate",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
...(role === "admin" || role === "teacher"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: AssignmentList) => (
|
||||
<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.lesson?.subject?.name || "-"}</td>
|
||||
<td>{item.lesson?.class?.name || "-"}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.lesson?.teacher ? item.lesson.teacher.name + " " + item.lesson.teacher.surname : "-"}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{new Intl.DateTimeFormat("en-US").format(new Date(item.dueDate))}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{(role === "admin" || role === "teacher") && (
|
||||
<>
|
||||
<FormContainer table="assignment" type="update" data={item} />
|
||||
<FormContainer table="assignment" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Assignment")
|
||||
.select(
|
||||
"*, lesson:Lesson!inner(*, subject:Subject(*), class:Class(*), teacher:Teacher(*))",
|
||||
{ count: "exact" }
|
||||
);
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "classId":
|
||||
query = query.eq("lesson.classId", parseInt(value));
|
||||
break;
|
||||
case "teacherId":
|
||||
query = query.eq("lesson.teacherId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("lesson.subject.name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROLE CONDITIONS
|
||||
// Authorization is now handled by Supabase Postgres RLS policies.
|
||||
|
||||
// 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 assignments from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []) as unknown as AssignmentList[];
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">
|
||||
All Assignments
|
||||
</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" ||
|
||||
(role === "teacher" && (
|
||||
<FormContainer table="assignment" type="create" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentListPage;
|
||||
141
src/app/(dashboard)/list/classes/page.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
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 ClassList = Tables<"Class"> & { supervisor: Tables<"Teacher"> | null };
|
||||
|
||||
const ClassListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Class Name",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
header: "Capacity",
|
||||
accessor: "capacity",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Grade",
|
||||
accessor: "grade",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Supervisor",
|
||||
accessor: "supervisor",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
...(role === "admin"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: ClassList) => (
|
||||
<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">{item.capacity}</td>
|
||||
<td className="hidden md:table-cell">{item.name[0]}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.supervisor ? item.supervisor.name + " " + item.supervisor.surname : "-"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{role === "admin" && (
|
||||
<>
|
||||
<FormContainer table="class" type="update" data={item} />
|
||||
<FormContainer table="class" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Class")
|
||||
.select("*, supervisor:Teacher(*)", { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "supervisorId":
|
||||
query = query.eq("supervisorId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 classes from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []) as unknown as ClassList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Classes</h1>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<TableSearch />
|
||||
<div className="flex items-center gap-4 self-end">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
{role === "admin" && <FormContainer table="class" type="create" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassListPage;
|
||||
160
src/app/(dashboard)/list/events/page.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
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 EventList = Tables<"Event"> & { class: Tables<"Class"> | null };
|
||||
|
||||
const EventListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const currentUserId = userId;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Title",
|
||||
accessor: "title",
|
||||
},
|
||||
{
|
||||
header: "Class",
|
||||
accessor: "class",
|
||||
},
|
||||
{
|
||||
header: "Date",
|
||||
accessor: "date",
|
||||
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",
|
||||
},
|
||||
...(role === "admin"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: EventList) => (
|
||||
<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.title}</td>
|
||||
<td>{item.class?.name || "-"}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{new Intl.DateTimeFormat("en-US").format(new Date(item.startTime))}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{new Date(item.startTime).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{new Date(item.endTime).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{role === "admin" && (
|
||||
<>
|
||||
<FormContainer table="event" type="update" data={item} />
|
||||
<FormContainer table="event" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Event")
|
||||
.select("*, class:Class(*)", { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "search":
|
||||
query = query.ilike("title", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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 events from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []) as unknown as EventList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Events</h1>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<TableSearch />
|
||||
<div className="flex items-center gap-4 self-end">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
{role === "admin" && <FormContainer table="event" type="create" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventListPage;
|
||||
160
src/app/(dashboard)/list/exams/page.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
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 ExamList = Tables<"Exam"> & {
|
||||
lesson: Tables<"Lesson"> & {
|
||||
subject: Tables<"Subject">;
|
||||
class: Tables<"Class">;
|
||||
teacher: Tables<"Teacher">;
|
||||
};
|
||||
};
|
||||
|
||||
const ExamListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const currentUserId = userId;
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Subject Name",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
header: "Class",
|
||||
accessor: "class",
|
||||
},
|
||||
{
|
||||
header: "Teacher",
|
||||
accessor: "teacher",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Date",
|
||||
accessor: "date",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
...(role === "admin" || role === "teacher"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: ExamList) => (
|
||||
<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.lesson?.subject?.name || "-"}</td>
|
||||
<td>{item.lesson?.class?.name || "-"}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.lesson?.teacher ? item.lesson.teacher.name + " " + item.lesson.teacher.surname : "-"}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{new Intl.DateTimeFormat("en-US").format(new Date(item.startTime))}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{(role === "admin" || role === "teacher") && (
|
||||
<>
|
||||
<FormContainer table="exam" type="update" data={item} />
|
||||
<FormContainer table="exam" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Exam")
|
||||
.select(
|
||||
"*, lesson:Lesson!inner(*, subject:Subject(*), class:Class(*), teacher:Teacher(*))",
|
||||
{ count: "exact" }
|
||||
);
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "classId":
|
||||
query = query.eq("lesson.classId", parseInt(value));
|
||||
break;
|
||||
case "teacherId":
|
||||
query = query.eq("lesson.teacherId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("lesson.subject.name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROLE CONDITIONS
|
||||
// Authorization is now handled by Supabase Postgres RLS policies.
|
||||
|
||||
// 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 exams from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []) as unknown as ExamList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Exams</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" || role === "teacher") && (
|
||||
<FormContainer table="exam" type="create" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamListPage;
|
||||
142
src/app/(dashboard)/list/lessons/page.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
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 LessonList = Tables<"Lesson"> & {
|
||||
subject: Tables<"Subject">;
|
||||
class: Tables<"Class">;
|
||||
teacher: Tables<"Teacher">;
|
||||
};
|
||||
|
||||
|
||||
const LessonListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Subject Name",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
header: "Class",
|
||||
accessor: "class",
|
||||
},
|
||||
{
|
||||
header: "Teacher",
|
||||
accessor: "teacher",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
...(role === "admin"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: LessonList) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-gray-200 even:bg-slate-50 text-sm hover:bg-lamaPurpleLight"
|
||||
>
|
||||
<td className="flex items-center gap-4 p-4">{item.subject?.name || "-"}</td>
|
||||
<td>{item.class?.name || "-"}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.teacher ? item.teacher.name + " " + item.teacher.surname : "-"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{role === "admin" && (
|
||||
<>
|
||||
<FormContainer table="lesson" type="update" data={item} />
|
||||
<FormContainer table="lesson" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Lesson")
|
||||
.select("*, subject:Subject(*), class:Class(*), teacher:Teacher(*)", { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "classId":
|
||||
query = query.eq("classId", parseInt(value));
|
||||
break;
|
||||
case "teacherId":
|
||||
query = query.eq("teacherId", value);
|
||||
break;
|
||||
case "search":
|
||||
query = query.or(`subject.name.ilike.%${value}%,teacher.name.ilike.%${value}%,class.name.ilike.%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 lessons from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []) as unknown as LessonList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Lessons</h1>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<TableSearch />
|
||||
<div className="flex items-center gap-4 self-end">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
{role === "admin" && <FormContainer table="lesson" type="create" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonListPage;
|
||||
29
src/app/(dashboard)/list/loading.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="p-8 animate-pulse">
|
||||
<div className=" rounded-lg overflow-hidden shadow-md">
|
||||
<div className="p-8 bg-gray-200 flex space-x-32">
|
||||
<div className="h-6 bg-gray-300 rounded w-1/6"></div>
|
||||
<div className="h-6 bg-gray-300 rounded w-2/6"></div>
|
||||
<div className="h-6 bg-gray-300 rounded w-1/6"></div>
|
||||
<div className="h-6 bg-gray-300 rounded w-1/6"></div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between mb-4 py-2 mt-4"
|
||||
>
|
||||
<div className="h-8 bg-gray-200 rounded w-1/6 mr-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-2/6 mr-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-1/6 mr-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-1/6"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
144
src/app/(dashboard)/list/parents/page.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
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 ParentList = Tables<"Parent"> & { students: Tables<"Student">[] };
|
||||
|
||||
const ParentListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Info",
|
||||
accessor: "info",
|
||||
},
|
||||
{
|
||||
header: "Student Names",
|
||||
accessor: "students",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Phone",
|
||||
accessor: "phone",
|
||||
className: "hidden lg:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Address",
|
||||
accessor: "address",
|
||||
className: "hidden lg:table-cell",
|
||||
},
|
||||
...(role === "admin"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: ParentList) => (
|
||||
<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">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
<p className="text-xs text-gray-500">{item?.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.students.map((student) => student.name).join(",")}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">{item.phone}</td>
|
||||
<td className="hidden md:table-cell">{item.address}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{role === "admin" && (
|
||||
<>
|
||||
<FormContainer table="parent" type="update" data={item} />
|
||||
<FormContainer table="parent" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Parent")
|
||||
.select("*, students:Student(*)", { count: "exact" });
|
||||
|
||||
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 parents from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []) as unknown as ParentList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Parents</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="parent" type="create" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentListPage;
|
||||
212
src/app/(dashboard)/list/results/page.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
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 ResultList = {
|
||||
id: number;
|
||||
title: string;
|
||||
studentName: string;
|
||||
studentSurname: string;
|
||||
teacherName: string;
|
||||
teacherSurname: string;
|
||||
score: number;
|
||||
className: string;
|
||||
startTime: Date;
|
||||
};
|
||||
|
||||
|
||||
const ResultListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const currentUserId = userId;
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Title",
|
||||
accessor: "title",
|
||||
},
|
||||
{
|
||||
header: "Student",
|
||||
accessor: "student",
|
||||
},
|
||||
{
|
||||
header: "Score",
|
||||
accessor: "score",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Teacher",
|
||||
accessor: "teacher",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Class",
|
||||
accessor: "class",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Date",
|
||||
accessor: "date",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
...(role === "admin" || role === "teacher"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: ResultList) => (
|
||||
<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.title}</td>
|
||||
<td>{item.studentName + " " + item.studentName}</td>
|
||||
<td className="hidden md:table-cell">{item.score}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.teacherName + " " + item.teacherSurname}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">{item.className}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{new Intl.DateTimeFormat("en-US").format(new Date(item.startTime))}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{(role === "admin" || role === "teacher") && (
|
||||
<>
|
||||
<FormContainer table="result" type="update" data={item} />
|
||||
<FormContainer table="result" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Result")
|
||||
.select(`
|
||||
*,
|
||||
student:Student(*),
|
||||
exam:Exam(
|
||||
*,
|
||||
lesson:Lesson!inner(
|
||||
class:Class(*),
|
||||
teacher:Teacher(*)
|
||||
)
|
||||
),
|
||||
assignment:Assignment(
|
||||
*,
|
||||
lesson:Lesson!inner(
|
||||
class:Class(*),
|
||||
teacher:Teacher(*)
|
||||
)
|
||||
)
|
||||
`, { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "studentId":
|
||||
query = query.eq("studentId", value);
|
||||
break;
|
||||
case "search":
|
||||
// Supabase JS doesn't easily support OR across joined tables via .or() string syntax
|
||||
// like `exam.title.ilike.%val%,student.name.ilike.%val%`.
|
||||
// Workaround: We will fetch broadly and filter below, or just rely on RLS.
|
||||
// For true broad search in Supabase over joins,
|
||||
// an RPC function is usually required. For now, dropping search.
|
||||
console.warn("Cross-table search text not supported in standard Supabase JS select without RPC.");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROLE CONDITIONS: Handled by Supabase Postgres RLS.
|
||||
|
||||
// PAGINATION
|
||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||
|
||||
const { data: dataRes, count, error } = await query;
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching results from Supabase:", error);
|
||||
}
|
||||
|
||||
const dlist = (dataRes || []) as unknown as any[];
|
||||
const data = dlist.map((item) => {
|
||||
const assessment = item.exam || item.assignment;
|
||||
|
||||
if (!assessment) return null;
|
||||
|
||||
const isExam = "startTime" in assessment;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: assessment.title,
|
||||
studentName: item.student?.name || "Unknown",
|
||||
studentSurname: item.student?.surname || "",
|
||||
teacherName: assessment.lesson?.teacher?.name || "-",
|
||||
teacherSurname: assessment.lesson?.teacher?.surname || "",
|
||||
score: item.score,
|
||||
className: assessment.lesson?.class?.name || "-",
|
||||
startTime: isExam ? assessment.startTime : assessment.startDate,
|
||||
};
|
||||
}).filter(item => item !== null) as ResultList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Results</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" || role === "teacher") && (
|
||||
<FormContainer table="result" type="create" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultListPage;
|
||||
221
src/app/(dashboard)/list/students/[id]/page.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import Announcements from "@/components/Announcements";
|
||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import FormContainer from "@/components/FormContainer";
|
||||
import Performance from "@/components/Performance";
|
||||
import StudentAttendanceCard from "@/components/StudentAttendanceCard";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { Tables } from "@/types/supabase";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
const SingleStudentPage = async ({
|
||||
params: { id },
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) => {
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("Student")
|
||||
.select(`
|
||||
*,
|
||||
class:Class(
|
||||
*,
|
||||
lessons:Lesson(count)
|
||||
)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Extract count from the array returned by PostgREST inner select count
|
||||
const lessonsCount = Array.isArray(data.class?.lessons)
|
||||
? (data.class?.lessons as any)[0]?.count || 0
|
||||
: 0;
|
||||
|
||||
const student = {
|
||||
...data,
|
||||
class: {
|
||||
...data.class,
|
||||
_count: { lessons: lessonsCount }
|
||||
}
|
||||
} as any;
|
||||
|
||||
if (!student) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-4 flex flex-col gap-4 xl:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="w-full xl:w-2/3">
|
||||
{/* TOP */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* USER INFO CARD */}
|
||||
<div className="bg-lamaSky py-6 px-4 rounded-md flex-1 flex gap-4">
|
||||
<div className="w-1/3">
|
||||
<Image
|
||||
src={student.img || "/noAvatar.png"}
|
||||
alt=""
|
||||
width={144}
|
||||
height={144}
|
||||
className="w-36 h-36 rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-2/3 flex flex-col justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{student.name + " " + student.surname}
|
||||
</h1>
|
||||
{role === "admin" && (
|
||||
<FormContainer table="student" type="update" data={student} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap text-xs font-medium">
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/blood.png" alt="" width={14} height={14} />
|
||||
<span>{student.bloodType}</span>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/date.png" alt="" width={14} height={14} />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("en-GB").format(new Date(student.birthday))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/mail.png" alt="" width={14} height={14} />
|
||||
<span>{student.email || "-"}</span>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/phone.png" alt="" width={14} height={14} />
|
||||
<span>{student.phone || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* SMALL CARDS */}
|
||||
<div className="flex-1 flex gap-4 justify-between flex-wrap">
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleAttendance.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<Suspense fallback="loading...">
|
||||
<StudentAttendanceCard id={student.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleBranch.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{student.class.name.charAt(0)}th
|
||||
</h1>
|
||||
<span className="text-sm text-gray-400">Grade</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleLesson.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{student.class._count.lessons}
|
||||
</h1>
|
||||
<span className="text-sm text-gray-400">Lessons</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleClass.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">{student.class.name}</h1>
|
||||
<span className="text-sm text-gray-400">Class</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* BOTTOM */}
|
||||
<div className="mt-4 bg-white rounded-md p-4 h-[800px]">
|
||||
<h1>Student's Schedule</h1>
|
||||
<BigCalendarContainer type="classId" id={student.class.id} />
|
||||
</div>
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
<div className="w-full xl:w-1/3 flex flex-col gap-4">
|
||||
<div className="bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">Shortcuts</h1>
|
||||
<div className="mt-4 flex gap-4 flex-wrap text-xs text-gray-500">
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/lessons?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Lessons
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaPurpleLight"
|
||||
href={`/list/teachers?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Teachers
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-pink-50"
|
||||
href={`/list/exams?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Exams
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/assignments?classId=${student.class.id}`}
|
||||
>
|
||||
Student's Assignments
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaYellowLight"
|
||||
href={`/list/results?studentId=${student.id}`}
|
||||
>
|
||||
Student's Results
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Performance />
|
||||
<Announcements />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleStudentPage;
|
||||
186
src/app/(dashboard)/list/students/page.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
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 StudentList = Tables<"Student"> & { class: Tables<"Class"> };
|
||||
|
||||
const StudentListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Info",
|
||||
accessor: "info",
|
||||
},
|
||||
{
|
||||
header: "Student ID",
|
||||
accessor: "studentId",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Grade",
|
||||
accessor: "grade",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Phone",
|
||||
accessor: "phone",
|
||||
className: "hidden lg:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Address",
|
||||
accessor: "address",
|
||||
className: "hidden lg:table-cell",
|
||||
},
|
||||
...(role === "admin"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: StudentList) => (
|
||||
<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">
|
||||
<Image
|
||||
src={item.img || "/noAvatar.png"}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="md:hidden xl:block w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
<p className="text-xs text-gray-500">{item.class.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden md:table-cell">{item.username}</td>
|
||||
<td className="hidden md:table-cell">{item.class.name[0]}</td>
|
||||
<td className="hidden md:table-cell">{item.phone}</td>
|
||||
<td className="hidden md:table-cell">{item.address}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/list/students/${item.id}`}>
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded-full bg-lamaSky">
|
||||
<Image src="/view.png" alt="" width={16} height={16} />
|
||||
</button>
|
||||
</Link>
|
||||
{role === "admin" && (
|
||||
// <button className="w-7 h-7 flex items-center justify-center rounded-full bg-lamaPurple">
|
||||
// <Image src="/delete.png" alt="" width={16} height={16} />
|
||||
// </button>
|
||||
<FormContainer table="student" type="delete" id={item.id} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
// Note: we need lessons if teacherId is provided
|
||||
let query = supabase
|
||||
.from("Student")
|
||||
.select("*, class:Class(*, lessons:Lesson(*))", { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "teacherId":
|
||||
// Filter by teacherId within the joined lessons.
|
||||
// Supabase postgREST filters on JSON: class.lessons.teacherId=eq...
|
||||
// It's tricky to filter the main rows based on a nested condition.
|
||||
// Instead we can use an inner join via class!inner(lessons!inner(*)).
|
||||
query = query.eq("class.lessons.teacherId", value);
|
||||
// It might require adjustments, but RLS generally restricts this.
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PAGINATION
|
||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||
|
||||
let { data: rawData, count, error } = await query;
|
||||
|
||||
if (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[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Students</h1>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<TableSearch />
|
||||
<div className="flex items-center gap-4 self-end">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
{role === "admin" && (
|
||||
// <button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
// <Image src="/plus.png" alt="" width={14} height={14} />
|
||||
// </button>
|
||||
<FormContainer table="student" type="create" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentListPage;
|
||||
129
src/app/(dashboard)/list/subjects/page.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
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 SubjectList = Tables<"Subject"> & { teachers: Tables<"Teacher">[] };
|
||||
|
||||
const SubjectListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Subject Name",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
header: "Teachers",
|
||||
accessor: "teachers",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
];
|
||||
|
||||
const renderRow = (item: SubjectList) => (
|
||||
<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">
|
||||
{item.teachers.map((teacher) => teacher.name).join(",")}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
{role === "admin" && (
|
||||
<>
|
||||
<FormContainer table="subject" type="update" data={item} />
|
||||
<FormContainer table="subject" type="delete" id={item.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
let query = supabase
|
||||
.from("Subject")
|
||||
.select("*, TeacherSubject(Teacher(*))", { count: "exact" });
|
||||
|
||||
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 subjects from Supabase:", error);
|
||||
}
|
||||
|
||||
const data = (rawData || []).map(subject => {
|
||||
// @ts-ignore
|
||||
const teachers = subject.TeacherSubject?.map((rel: any) => rel.Teacher) || [];
|
||||
return {
|
||||
...subject,
|
||||
teachers
|
||||
}
|
||||
}) as unknown as SubjectList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Subjects</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="subject" type="create" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubjectListPage;
|
||||
221
src/app/(dashboard)/list/teachers/[id]/page.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import Announcements from "@/components/Announcements";
|
||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import BigCalendar from "@/components/BigCalender";
|
||||
import FormContainer from "@/components/FormContainer";
|
||||
import Performance from "@/components/Performance";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { Tables } from "@/types/supabase";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
const SingleTeacherPage = async ({
|
||||
params: { id },
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) => {
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("Teacher")
|
||||
.select(`
|
||||
*,
|
||||
TeacherSubject(count),
|
||||
lessons:Lesson(count),
|
||||
classes:Class(count)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const subjectsCount = Array.isArray(data.TeacherSubject) ? (data.TeacherSubject as any)[0]?.count || 0 : 0;
|
||||
const lessonsCount = Array.isArray(data.lessons) ? (data.lessons as any)[0]?.count || 0 : 0;
|
||||
const classesCount = Array.isArray(data.classes) ? (data.classes as any)[0]?.count || 0 : 0;
|
||||
|
||||
const teacher = {
|
||||
...data,
|
||||
_count: {
|
||||
subjects: subjectsCount,
|
||||
lessons: lessonsCount,
|
||||
classes: classesCount,
|
||||
}
|
||||
} as any;
|
||||
|
||||
if (!teacher) {
|
||||
return notFound();
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 p-4 flex flex-col gap-4 xl:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="w-full xl:w-2/3">
|
||||
{/* TOP */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* USER INFO CARD */}
|
||||
<div className="bg-lamaSky py-6 px-4 rounded-md flex-1 flex gap-4">
|
||||
<div className="w-1/3">
|
||||
<Image
|
||||
src={teacher.img || "/noAvatar.png"}
|
||||
alt=""
|
||||
width={144}
|
||||
height={144}
|
||||
className="w-36 h-36 rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-2/3 flex flex-col justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{teacher.name + " " + teacher.surname}
|
||||
</h1>
|
||||
{role === "admin" && (
|
||||
<FormContainer table="teacher" type="update" data={teacher} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap text-xs font-medium">
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/blood.png" alt="" width={14} height={14} />
|
||||
<span>{teacher.bloodType}</span>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/date.png" alt="" width={14} height={14} />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("en-GB").format(new Date(teacher.birthday))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/mail.png" alt="" width={14} height={14} />
|
||||
<span>{teacher.email || "-"}</span>
|
||||
</div>
|
||||
<div className="w-full md:w-1/3 lg:w-full 2xl:w-1/3 flex items-center gap-2">
|
||||
<Image src="/phone.png" alt="" width={14} height={14} />
|
||||
<span>{teacher.phone || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* SMALL CARDS */}
|
||||
<div className="flex-1 flex gap-4 justify-between flex-wrap">
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleAttendance.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">90%</h1>
|
||||
<span className="text-sm text-gray-400">Attendance</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleBranch.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{teacher._count.subjects}
|
||||
</h1>
|
||||
<span className="text-sm text-gray-400">Branches</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleLesson.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{teacher._count.lessons}
|
||||
</h1>
|
||||
<span className="text-sm text-gray-400">Lessons</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* CARD */}
|
||||
<div className="bg-white p-4 rounded-md flex gap-4 w-full md:w-[48%] xl:w-[45%] 2xl:w-[48%]">
|
||||
<Image
|
||||
src="/singleClass.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{teacher._count.classes}
|
||||
</h1>
|
||||
<span className="text-sm text-gray-400">Classes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* BOTTOM */}
|
||||
<div className="mt-4 bg-white rounded-md p-4 h-[800px]">
|
||||
<h1>Teacher's Schedule</h1>
|
||||
<BigCalendarContainer type="teacherId" id={teacher.id} />
|
||||
</div>
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
<div className="w-full xl:w-1/3 flex flex-col gap-4">
|
||||
<div className="bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">Shortcuts</h1>
|
||||
<div className="mt-4 flex gap-4 flex-wrap text-xs text-gray-500">
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/classes?supervisorId=${teacher.id}`}
|
||||
>
|
||||
Teacher's Classes
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaPurpleLight"
|
||||
href={`/list/students?teacherId=${teacher.id}`}
|
||||
>
|
||||
Teacher's Students
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaYellowLight"
|
||||
href={`/list/lessons?teacherId=${teacher.id}`}
|
||||
>
|
||||
Teacher's Lessons
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-pink-50"
|
||||
href={`/list/exams?teacherId=${teacher.id}`}
|
||||
>
|
||||
Teacher's Exams
|
||||
</Link>
|
||||
<Link
|
||||
className="p-3 rounded-md bg-lamaSkyLight"
|
||||
href={`/list/assignments?teacherId=${teacher.id}`}
|
||||
>
|
||||
Teacher's Assignments
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Performance />
|
||||
<Announcements />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleTeacherPage;
|
||||
190
src/app/(dashboard)/list/teachers/page.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
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 { Tables } from "@/types/supabase";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ITEM_PER_PAGE } from "@/lib/settings";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
type TeacherList = Tables<"Teacher"> & { subjects: Tables<"Subject">[] } & { classes: Tables<"Class">[] };
|
||||
|
||||
const TeacherListPage = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | undefined };
|
||||
}) => {
|
||||
const { sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const columns = [
|
||||
{
|
||||
header: "Info",
|
||||
accessor: "info",
|
||||
},
|
||||
{
|
||||
header: "Teacher ID",
|
||||
accessor: "teacherId",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Subjects",
|
||||
accessor: "subjects",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Classes",
|
||||
accessor: "classes",
|
||||
className: "hidden md:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Phone",
|
||||
accessor: "phone",
|
||||
className: "hidden lg:table-cell",
|
||||
},
|
||||
{
|
||||
header: "Address",
|
||||
accessor: "address",
|
||||
className: "hidden lg:table-cell",
|
||||
},
|
||||
...(role === "admin"
|
||||
? [
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "action",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderRow = (item: TeacherList) => (
|
||||
<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">
|
||||
<Image
|
||||
src={item.img || "/noAvatar.png"}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="md:hidden xl:block w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
<p className="text-xs text-gray-500">{item?.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden md:table-cell">{item.username}</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.subjects.map((subject) => subject.name).join(",")}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">
|
||||
{item.classes.map((classItem) => classItem.name).join(",")}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">{item.phone}</td>
|
||||
<td className="hidden md:table-cell">{item.address}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/list/teachers/${item.id}`}>
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded-full bg-lamaSky">
|
||||
<Image src="/view.png" alt="" width={16} height={16} />
|
||||
</button>
|
||||
</Link>
|
||||
{role === "admin" && (
|
||||
// <button className="w-7 h-7 flex items-center justify-center rounded-full bg-lamaPurple">
|
||||
// <Image src="/delete.png" alt="" width={16} height={16} />
|
||||
// </button>
|
||||
<FormContainer table="teacher" type="delete" id={item.id} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
const { page, ...queryParams } = searchParams;
|
||||
|
||||
const p = page ? parseInt(page) : 1;
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
// URL PARAMS CONDITION
|
||||
// Note: we need lessons if classId is provided
|
||||
let query = supabase
|
||||
.from("Teacher")
|
||||
.select("*, TeacherSubject(Subject(*)), classes:Class(*), lessons:Lesson(*)", { count: "exact" });
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value !== undefined) {
|
||||
switch (key) {
|
||||
case "classId":
|
||||
// Filter by classId within the joined lessons.
|
||||
query = query.eq("lessons.classId", parseInt(value));
|
||||
break;
|
||||
case "search":
|
||||
query = query.ilike("name", `%${value}%`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PAGINATION
|
||||
query = query.range(ITEM_PER_PAGE * (p - 1), ITEM_PER_PAGE * p - 1);
|
||||
|
||||
let { data: rawData, count, error } = await query;
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching teachers from Supabase:", error);
|
||||
}
|
||||
|
||||
// Workaround for `classId` query:
|
||||
if (queryParams.classId && rawData) {
|
||||
rawData = rawData.filter(teacher =>
|
||||
// @ts-ignore
|
||||
teacher.lessons?.some((lesson: any) => lesson.classId === parseInt(queryParams.classId!))
|
||||
);
|
||||
count = rawData.length;
|
||||
}
|
||||
|
||||
// Map the nested TeacherSubject data back to a flat subjects array for the datatable
|
||||
const data = (rawData || []).map(teacher => {
|
||||
// @ts-ignore
|
||||
const subjects = teacher.TeacherSubject?.map((rel: any) => rel.Subject) || [];
|
||||
return {
|
||||
...teacher,
|
||||
subjects
|
||||
}
|
||||
}) as unknown as TeacherList[];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md flex-1 m-4 mt-0">
|
||||
{/* TOP */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="hidden md:block text-lg font-semibold">All Teachers</h1>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<TableSearch />
|
||||
<div className="flex items-center gap-4 self-end">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/filter.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-full bg-lamaYellow">
|
||||
<Image src="/sort.png" alt="" width={14} height={14} />
|
||||
</button>
|
||||
{role === "admin" && (
|
||||
<FormContainer table="teacher" type="create" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIST */}
|
||||
<Table columns={columns} renderRow={renderRow} data={data} />
|
||||
{/* PAGINATION */}
|
||||
<Pagination page={p} count={count || 0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeacherListPage;
|
||||
48
src/app/(dashboard)/parent/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import Announcements from "@/components/Announcements";
|
||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { Tables } from "@/types/supabase";
|
||||
|
||||
|
||||
const ParentPage = async () => {
|
||||
const { userId } = auth();
|
||||
const currentUserId = userId;
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: students, error } = await supabase
|
||||
.from("Student")
|
||||
.select("*")
|
||||
// RLS policies should handle identifying parent id
|
||||
// .eq("parentId", currentUserId!)
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching parent students:", error);
|
||||
}
|
||||
|
||||
const studentsList = (students || []) as Tables<"Student">[];
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="">
|
||||
{studentsList.map((student) => (
|
||||
<div className="w-full xl:w-2/3" key={student.id}>
|
||||
<div className="h-full bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">
|
||||
Schedule ({student.name + " " + student.surname})
|
||||
</h1>
|
||||
<BigCalendarContainer type="classId" id={student.classId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
<div className="w-full xl:w-1/3 flex flex-col gap-8">
|
||||
<Announcements />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentPage;
|
||||
57
src/app/(dashboard)/student/page.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import Announcements from "@/components/Announcements";
|
||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import BigCalendar from "@/components/BigCalender";
|
||||
import EventCalendar from "@/components/EventCalendar";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import { Tables } from "@/types/supabase";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
const StudentPage = async () => {
|
||||
const { userId } = auth();
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: studentItem, error: studentError } = await supabase
|
||||
.from("Student")
|
||||
.select("classId")
|
||||
.eq("id", userId!)
|
||||
.single();
|
||||
|
||||
if (studentError) {
|
||||
console.error("Error fetching student details:", studentError);
|
||||
}
|
||||
|
||||
const { data: classItems, error } = await supabase
|
||||
.from("Class")
|
||||
.select("*")
|
||||
.eq("id", studentItem?.classId || 0);
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching student class:", error);
|
||||
}
|
||||
|
||||
const classItem = (classItems || []) as Tables<"Class">[];
|
||||
const studentClassId = classItem.length > 0 ? classItem[0].id : null;
|
||||
|
||||
return (
|
||||
<div className="p-4 flex gap-4 flex-col xl:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="w-full xl:w-2/3">
|
||||
<div className="h-full bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">Schedule (4A)</h1>
|
||||
{studentClassId ? (
|
||||
<BigCalendarContainer type="classId" id={studentClassId} />
|
||||
) : (
|
||||
<div className="text-gray-500 mt-4">No schedule found for your assigned class.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
<div className="w-full xl:w-1/3 flex flex-col gap-8">
|
||||
<EventCalendar />
|
||||
<Announcements />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentPage;
|
||||
24
src/app/(dashboard)/teacher/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import Announcements from "@/components/Announcements";
|
||||
import BigCalendarContainer from "@/components/BigCalendarContainer";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
const TeacherPage = () => {
|
||||
const { userId } = auth();
|
||||
return (
|
||||
<div className="flex-1 p-4 flex gap-4 flex-col xl:flex-row">
|
||||
{/* LEFT */}
|
||||
<div className="w-full xl:w-2/3">
|
||||
<div className="h-full bg-white p-4 rounded-md">
|
||||
<h1 className="text-xl font-semibold">Schedule</h1>
|
||||
<BigCalendarContainer type="teacherId" id={userId!} />
|
||||
</div>
|
||||
</div>
|
||||
{/* RIGHT */}
|
||||
<div className="w-full xl:w-1/3 flex flex-col gap-8">
|
||||
<Announcements />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeacherPage;
|
||||
70
src/app/[[...sign-in]]/page.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as Clerk from "@clerk/elements/common";
|
||||
import * as SignIn from "@clerk/elements/sign-in";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const LoginPage = () => {
|
||||
const { isLoaded, isSignedIn, user } = useUser();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const role = user?.publicMetadata.role;
|
||||
|
||||
if (role) {
|
||||
router.push(`/${role}`);
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-lamaSkyLight">
|
||||
<SignIn.Root>
|
||||
<SignIn.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">Sign in to your account</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
|
||||
</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>
|
||||
<SignIn.Action
|
||||
submit
|
||||
className="bg-blue-500 text-white my-1 rounded-md text-sm p-[10px]"
|
||||
>
|
||||
Sign In
|
||||
</SignIn.Action>
|
||||
</SignIn.Step>
|
||||
</SignIn.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
110
src/app/globals.css
Normal file
@ -0,0 +1,110 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.react-calendar {
|
||||
width: 100% !important;
|
||||
border: none !important;
|
||||
font-family: "Inter", sans-serif !important;
|
||||
}
|
||||
|
||||
.react-calendar__navigation__label__labelText {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.react-calendar__tile--active {
|
||||
background-color: #c3ebfa !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.rbc-btn-group:first-child button {
|
||||
border: none !important;
|
||||
background-color: #f1f0ff !important;
|
||||
margin-right: 2px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.rbc-toolbar-label {
|
||||
text-align: right !important;
|
||||
padding: 0px 20px !important;
|
||||
}
|
||||
|
||||
.rbc-btn-group:last-child {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.rbc-btn-group:last-child button {
|
||||
border: none !important;
|
||||
background-color: #f1f0ff !important;
|
||||
margin-left: 2px !important;
|
||||
}
|
||||
|
||||
.rbc-toolbar button.rbc-active {
|
||||
background-color: #dbdafe !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.rbc-time-view {
|
||||
border-color: #eee !important;
|
||||
}
|
||||
|
||||
.rbc-time-gutter.rbc-time-column {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.rbc-time-gutter.rbc-time-column .rbc-timeslot-group {
|
||||
padding: 0px 20px !important;
|
||||
}
|
||||
|
||||
.rbc-timeslot-group {
|
||||
background-color: #f7fdff !important;
|
||||
}
|
||||
|
||||
.rbc-day-slot {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.rbc-event {
|
||||
border: none !important;
|
||||
color: black !important;
|
||||
padding: 5px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.rbc-event-content {
|
||||
display: block !important;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(1) {
|
||||
background-color: #e2f8ff !important;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(2) {
|
||||
background-color: #fefce8 !important;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(3) {
|
||||
background-color: #f2f1ff !important;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(4) {
|
||||
background-color: #fdf2fb !important;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(5) {
|
||||
background-color: #e2f8ff !important;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(6) {
|
||||
background-color: #fefce8 !important;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(7) {
|
||||
background-color: #f2f1ff !important;
|
||||
}
|
||||
|
||||
.rbc-event:nth-child(8) {
|
||||
background-color: #fdf2fb !important;
|
||||
}
|
||||
31
src/app/layout.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import ConsoleSuppressor from "@/components/ConsoleSuppressor";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Lama Dev School Management Dashboard",
|
||||
description: "Next.js School Management System",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<ConsoleSuppressor />
|
||||
{children} <ToastContainer position="bottom-right" theme="dark" />
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
66
src/components/Announcements.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
const Announcements = async () => {
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: rawData, error } = await supabase
|
||||
.from("Announcement")
|
||||
.select("*")
|
||||
.order("date", { ascending: false })
|
||||
.limit(3);
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching announcements:", error);
|
||||
}
|
||||
|
||||
const data = rawData || [];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Announcements</h1>
|
||||
<span className="text-xs text-gray-400">View All</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{data[0] && (
|
||||
<div className="bg-lamaSkyLight rounded-md p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium">{data[0].title}</h2>
|
||||
<span className="text-xs text-gray-400 bg-white rounded-md px-1 py-1">
|
||||
{new Intl.DateTimeFormat("en-GB").format(new Date(data[0].date))}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">{data[0].description}</p>
|
||||
</div>
|
||||
)}
|
||||
{data[1] && (
|
||||
<div className="bg-lamaPurpleLight rounded-md p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium">{data[1].title}</h2>
|
||||
<span className="text-xs text-gray-400 bg-white rounded-md px-1 py-1">
|
||||
{new Intl.DateTimeFormat("en-GB").format(new Date(data[1].date))}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">{data[1].description}</p>
|
||||
</div>
|
||||
)}
|
||||
{data[2] && (
|
||||
<div className="bg-lamaYellowLight rounded-md p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium">{data[2].title}</h2>
|
||||
<span className="text-xs text-gray-400 bg-white rounded-md px-1 py-1">
|
||||
{new Intl.DateTimeFormat("en-GB").format(new Date(data[2].date))}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">{data[2].description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Announcements;
|
||||
57
src/components/AttendanceChart.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
Rectangle,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
const AttendanceChart = ({
|
||||
data,
|
||||
}: {
|
||||
data: { name: string; present: number; absent: number }[];
|
||||
}) => {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<BarChart width={500} height={300} data={data} barSize={20}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#ddd" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tick={{ fill: "#d1d5db" }}
|
||||
tickLine={false}
|
||||
height={30}
|
||||
/>
|
||||
<YAxis axisLine={false} tick={{ fill: "#d1d5db" }} tickLine={false} width={40} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: "10px", borderColor: "lightgray" }}
|
||||
/>
|
||||
<Legend
|
||||
align="left"
|
||||
verticalAlign="top"
|
||||
wrapperStyle={{ paddingTop: "20px", paddingBottom: "40px" }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="present"
|
||||
fill="#FAE27C"
|
||||
legendType="circle"
|
||||
radius={[10, 10, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="absent"
|
||||
fill="#C3EBFA"
|
||||
legendType="circle"
|
||||
radius={[10, 10, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendanceChart;
|
||||
71
src/components/AttendanceChartContainer.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import Image from "next/image";
|
||||
import AttendanceChart from "./AttendanceChart";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
|
||||
const AttendanceChartContainer = async () => {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
|
||||
const lastMonday = new Date(today);
|
||||
|
||||
lastMonday.setDate(today.getDate() - daysSinceMonday);
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: rawData, error } = await supabase
|
||||
.from("Attendance")
|
||||
.select("date, present")
|
||||
.gte("date", lastMonday.toISOString());
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching attendance data:", error);
|
||||
}
|
||||
|
||||
const resData = rawData || [];
|
||||
|
||||
// console.log(data)
|
||||
|
||||
const daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri"];
|
||||
|
||||
const attendanceMap: { [key: string]: { present: number; absent: number } } =
|
||||
{
|
||||
Mon: { present: 0, absent: 0 },
|
||||
Tue: { present: 0, absent: 0 },
|
||||
Wed: { present: 0, absent: 0 },
|
||||
Thu: { present: 0, absent: 0 },
|
||||
Fri: { present: 0, absent: 0 },
|
||||
};
|
||||
|
||||
resData.forEach((item) => {
|
||||
const itemDate = new Date(item.date);
|
||||
const dayOfWeek = itemDate.getDay();
|
||||
|
||||
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
|
||||
const dayName = daysOfWeek[dayOfWeek - 1];
|
||||
|
||||
if (item.present) {
|
||||
attendanceMap[dayName].present += 1;
|
||||
} else {
|
||||
attendanceMap[dayName].absent += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const data = daysOfWeek.map((day) => ({
|
||||
name: day,
|
||||
present: attendanceMap[day].present,
|
||||
absent: attendanceMap[day].absent,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 h-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-semibold">Attendance</h1>
|
||||
<Image src="/moreDark.png" alt="" width={20} height={20} />
|
||||
</div>
|
||||
<AttendanceChart data={data} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendanceChartContainer;
|
||||
40
src/components/BigCalendarContainer.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import BigCalendar from "./BigCalender";
|
||||
|
||||
const BigCalendarContainer = async ({
|
||||
type,
|
||||
id,
|
||||
}: {
|
||||
type: "teacherId" | "classId";
|
||||
id: string | number;
|
||||
}) => {
|
||||
const supabase = await getSupabaseClient();
|
||||
|
||||
let query = supabase.from("Lesson").select("*");
|
||||
if (type === "teacherId") {
|
||||
query = query.eq("teacherId", id as string);
|
||||
} else {
|
||||
query = query.eq("classId", id as number);
|
||||
}
|
||||
|
||||
const { data: rawData, error } = await query;
|
||||
if (error) {
|
||||
console.error("Error fetching calendar lessons:", error);
|
||||
}
|
||||
|
||||
const dataRes = rawData || [];
|
||||
|
||||
const data = dataRes.map((lesson) => ({
|
||||
title: lesson.name,
|
||||
start: new Date(lesson.startTime),
|
||||
end: new Date(lesson.endTime),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<BigCalendar data={data} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BigCalendarContainer;
|
||||
41
src/components/BigCalender.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar, momentLocalizer, View, Views } from "react-big-calendar";
|
||||
import moment from "moment";
|
||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||
import { useState } from "react";
|
||||
|
||||
const localizer = momentLocalizer(moment);
|
||||
|
||||
const BigCalendar = ({
|
||||
data,
|
||||
}: {
|
||||
data: { title: string; start: Date; end: Date }[];
|
||||
}) => {
|
||||
const [view, setView] = useState<View>(Views.WORK_WEEK);
|
||||
const [date, setDate] = useState<Date>(new Date());
|
||||
|
||||
const handleOnChangeView = (selectedView: View) => {
|
||||
setView(selectedView);
|
||||
};
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={data}
|
||||
components={{}}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
views={["month", "work_week", "day"]}
|
||||
view={view}
|
||||
date={date}
|
||||
style={{ height: "98%" }}
|
||||
onView={handleOnChangeView}
|
||||
onNavigate={setDate}
|
||||
min={new Date(2025, 1, 0, 8, 0, 0)}
|
||||
max={new Date(2025, 1, 0, 17, 0, 0)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BigCalendar;
|
||||
18
src/components/ConsoleSuppressor.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const originalError = console.error;
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].includes("Support for defaultProps will be removed from function components")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError(...args);
|
||||
};
|
||||
}
|
||||
|
||||
export default function ConsoleSuppressor() {
|
||||
return null;
|
||||
}
|
||||
54
src/components/CountChart.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
RadialBarChart,
|
||||
RadialBar,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
|
||||
const CountChart = ({ boys, girls }: { boys: number; girls: number }) => {
|
||||
const data = [
|
||||
{
|
||||
name: "Total",
|
||||
count: boys+girls,
|
||||
fill: "white",
|
||||
},
|
||||
{
|
||||
name: "Girls",
|
||||
count: girls,
|
||||
fill: "#FAE27C",
|
||||
},
|
||||
{
|
||||
name: "Boys",
|
||||
count: boys,
|
||||
fill: "#C3EBFA",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="relative w-full h-[75%]">
|
||||
<ResponsiveContainer>
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="40%"
|
||||
outerRadius="100%"
|
||||
barSize={32}
|
||||
data={data}
|
||||
>
|
||||
<RadialBar background dataKey="count" />
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<Image
|
||||
src="/maleFemale.png"
|
||||
alt=""
|
||||
width={50}
|
||||
height={50}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountChart;
|
||||
50
src/components/CountChartContainer.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import Image from "next/image";
|
||||
import CountChart from "./CountChart";
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
|
||||
const CountChartContainer = async () => {
|
||||
const supabase = await getSupabaseClient();
|
||||
const { count: boysCount } = await supabase
|
||||
.from("Student")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("sex", "MALE");
|
||||
|
||||
const { count: girlsCount } = await supabase
|
||||
.from("Student")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("sex", "FEMALE");
|
||||
|
||||
const boys = boysCount || 0;
|
||||
const girls = girlsCount || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl w-full h-full p-4">
|
||||
{/* TITLE */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-semibold">Students</h1>
|
||||
<Image src="/moreDark.png" alt="" width={20} height={20} />
|
||||
</div>
|
||||
{/* CHART */}
|
||||
<CountChart boys={boys} girls={girls} />
|
||||
{/* BOTTOM */}
|
||||
<div className="flex justify-center gap-16">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="w-5 h-5 bg-lamaSky rounded-full" />
|
||||
<h1 className="font-bold">{boys}</h1>
|
||||
<h2 className="text-xs text-gray-300">
|
||||
Boys ({Math.round((boys / (boys + girls)) * 100)}%)
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="w-5 h-5 bg-lamaYellow rounded-full" />
|
||||
<h1 className="font-bold">{girls}</h1>
|
||||
<h2 className="text-xs text-gray-300">
|
||||
Girls ({Math.round((girls / (boys + girls)) * 100)}%)
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountChartContainer;
|
||||
38
src/components/EventCalendar.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Calendar from "react-calendar";
|
||||
import "react-calendar/dist/Calendar.css";
|
||||
|
||||
type ValuePiece = Date | null;
|
||||
|
||||
type Value = ValuePiece | [ValuePiece, ValuePiece];
|
||||
|
||||
const EventCalendar = () => {
|
||||
const [value, onChange] = useState<Value>(new Date());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// TEMPORARY FIX: Suppress hydration warning for react-calendar
|
||||
// react-calendar uses browser locale for formatting which mismatches server rendering
|
||||
useEffect(() => {
|
||||
if (value instanceof Date) {
|
||||
router.push(`?date=${value}`);
|
||||
}
|
||||
}, [value, router]);
|
||||
|
||||
// Prevent rendering on the server to avoid hydration mismatch
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
if (!isClient) {
|
||||
return null; // or a loading skeleton
|
||||
}
|
||||
|
||||
return <Calendar onChange={onChange} value={value} />;
|
||||
};
|
||||
|
||||
export default EventCalendar;
|
||||
25
src/components/EventCalendarContainer.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import Image from "next/image";
|
||||
import EventCalendar from "./EventCalendar";
|
||||
import EventList from "./EventList";
|
||||
|
||||
const EventCalendarContainer = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [keys: string]: string | undefined };
|
||||
}) => {
|
||||
const { date } = searchParams;
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md">
|
||||
<EventCalendar />
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold my-4">Events</h1>
|
||||
<Image src="/moreDark.png" alt="" width={20} height={20} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<EventList dateParam={date} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCalendarContainer;
|
||||
45
src/components/EventList.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
|
||||
const EventList = async ({ dateParam }: { dateParam: string | undefined }) => {
|
||||
const date = dateParam ? new Date(dateParam) : new Date();
|
||||
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: rawData, error } = await supabase
|
||||
.from("Event")
|
||||
.select("*")
|
||||
.gte("startTime", startOfDay.toISOString())
|
||||
.lte("startTime", endOfDay.toISOString());
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching events:", error);
|
||||
}
|
||||
|
||||
const data = rawData || [];
|
||||
|
||||
return data.map((event) => (
|
||||
<div
|
||||
className="p-5 rounded-md border-2 border-gray-100 border-t-4 odd:border-t-lamaSky even:border-t-lamaPurple"
|
||||
key={event.id}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-semibold text-gray-600">{event.title}</h1>
|
||||
<span className="text-gray-300 text-xs">
|
||||
{new Date(event.startTime).toLocaleTimeString("en-UK", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-gray-400 text-sm">{event.description}</p>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
126
src/components/FinanceChart.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: "Jan",
|
||||
income: 4000,
|
||||
expense: 2400,
|
||||
},
|
||||
{
|
||||
name: "Feb",
|
||||
income: 3000,
|
||||
expense: 1398,
|
||||
},
|
||||
{
|
||||
name: "Mar",
|
||||
income: 2000,
|
||||
expense: 9800,
|
||||
},
|
||||
{
|
||||
name: "Apr",
|
||||
income: 2780,
|
||||
expense: 3908,
|
||||
},
|
||||
{
|
||||
name: "May",
|
||||
income: 1890,
|
||||
expense: 4800,
|
||||
},
|
||||
{
|
||||
name: "Jun",
|
||||
income: 2390,
|
||||
expense: 3800,
|
||||
},
|
||||
{
|
||||
name: "Jul",
|
||||
income: 3490,
|
||||
expense: 4300,
|
||||
},
|
||||
{
|
||||
name: "Aug",
|
||||
income: 3490,
|
||||
expense: 4300,
|
||||
},
|
||||
{
|
||||
name: "Sep",
|
||||
income: 3490,
|
||||
expense: 4300,
|
||||
},
|
||||
{
|
||||
name: "Oct",
|
||||
income: 3490,
|
||||
expense: 4300,
|
||||
},
|
||||
{
|
||||
name: "Nov",
|
||||
income: 3490,
|
||||
expense: 4300,
|
||||
},
|
||||
{
|
||||
name: "Dec",
|
||||
income: 3490,
|
||||
expense: 4300,
|
||||
},
|
||||
];
|
||||
|
||||
const FinanceChart = () => {
|
||||
return (
|
||||
<div className="bg-white rounded-xl w-full h-full p-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-semibold">Finance</h1>
|
||||
<Image src="/moreDark.png" alt="" width={20} height={20} />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<LineChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#ddd" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tick={{ fill: "#d1d5db" }}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
height={30}
|
||||
/>
|
||||
<YAxis axisLine={false} tick={{ fill: "#d1d5db" }} tickLine={false} tickMargin={20} width={40} />
|
||||
<Tooltip />
|
||||
<Legend
|
||||
align="center"
|
||||
verticalAlign="top"
|
||||
wrapperStyle={{ paddingTop: "10px", paddingBottom: "30px" }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
stroke="#C3EBFA"
|
||||
strokeWidth={5}
|
||||
/>
|
||||
<Line type="monotone" dataKey="expense" stroke="#CFCEFF" strokeWidth={5} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceChart;
|
||||
117
src/components/FormContainer.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import FormModal from "./FormModal";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export type FormContainerProps = {
|
||||
table:
|
||||
| "teacher"
|
||||
| "student"
|
||||
| "parent"
|
||||
| "subject"
|
||||
| "class"
|
||||
| "lesson"
|
||||
| "exam"
|
||||
| "assignment"
|
||||
| "result"
|
||||
| "attendance"
|
||||
| "event"
|
||||
| "announcement";
|
||||
type: "create" | "update" | "delete";
|
||||
data?: any;
|
||||
id?: number | string;
|
||||
};
|
||||
|
||||
const FormContainer = async ({ table, type, data, id }: FormContainerProps) => {
|
||||
let relatedData = {};
|
||||
|
||||
const { userId, sessionClaims } = auth();
|
||||
const role = (sessionClaims?.metadata as { role?: string })?.role;
|
||||
const currentUserId = userId;
|
||||
|
||||
if (type !== "delete") {
|
||||
const supabase = await getSupabaseClient();
|
||||
switch (table) {
|
||||
case "subject": {
|
||||
const { data: subjectTeachers } = await supabase.from("Teacher").select("id, name, surname");
|
||||
relatedData = { teachers: subjectTeachers };
|
||||
break;
|
||||
}
|
||||
case "class": {
|
||||
const { data: classGrades } = await supabase.from("Grade").select("id, level");
|
||||
const { data: classTeachers } = await supabase.from("Teacher").select("id, name, surname");
|
||||
relatedData = { teachers: classTeachers, grades: classGrades };
|
||||
break;
|
||||
}
|
||||
case "teacher": {
|
||||
const { data: teacherSubjects } = await supabase.from("Subject").select("id, name");
|
||||
relatedData = { subjects: teacherSubjects };
|
||||
break;
|
||||
}
|
||||
case "student": {
|
||||
const { data: studentGrades } = await supabase.from("Grade").select("id, level");
|
||||
const { data: studentClasses } = await supabase.from("Class").select("*, students:Student(count)");
|
||||
const classesWithCount = studentClasses?.map(c => ({
|
||||
...c,
|
||||
_count: { students: Array.isArray(c.students) ? (c.students as any)[0]?.count || 0 : 0 }
|
||||
}));
|
||||
relatedData = { classes: classesWithCount, grades: studentGrades };
|
||||
break;
|
||||
}
|
||||
case "lesson": {
|
||||
const { data: lessonSubjects } = await supabase.from("Subject").select("id, name");
|
||||
const { data: lessonClasses } = await supabase.from("Class").select("id, name");
|
||||
const { data: lessonTeachers } = await supabase.from("Teacher").select("id, name, surname");
|
||||
relatedData = { subjects: lessonSubjects, classes: lessonClasses, teachers: lessonTeachers };
|
||||
break;
|
||||
}
|
||||
case "assignment": {
|
||||
const { data: assignmentLessons } = await supabase.from("Lesson").select("id, name");
|
||||
relatedData = { lessons: assignmentLessons };
|
||||
break;
|
||||
}
|
||||
case "result": {
|
||||
const { data: resultStudents } = await supabase.from("Student").select("id, name, surname");
|
||||
const { data: resultExams } = await supabase.from("Exam").select("id, title");
|
||||
const { data: resultAssignments } = await supabase.from("Assignment").select("id, title");
|
||||
relatedData = { students: resultStudents, exams: resultExams, assignments: resultAssignments };
|
||||
break;
|
||||
}
|
||||
case "exam": {
|
||||
let query = supabase.from("Lesson").select("id, name");
|
||||
if (role === "teacher") {
|
||||
query = query.eq("teacherId", currentUserId!);
|
||||
}
|
||||
const { data: examLessons } = await query;
|
||||
relatedData = { lessons: examLessons };
|
||||
break;
|
||||
}
|
||||
|
||||
case "event": {
|
||||
const { data: eventClasses } = await supabase.from("Class").select("id, name");
|
||||
relatedData = { classes: eventClasses };
|
||||
break;
|
||||
}
|
||||
case "announcement": {
|
||||
const { data: announcementClasses } = await supabase.from("Class").select("id, name");
|
||||
relatedData = { classes: announcementClasses };
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<FormModal
|
||||
table={table}
|
||||
type={type}
|
||||
data={data}
|
||||
id={id}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormContainer;
|
||||
246
src/components/FormModal.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
deleteClass,
|
||||
deleteExam,
|
||||
deleteStudent,
|
||||
deleteSubject,
|
||||
deleteTeacher,
|
||||
deleteLesson,
|
||||
deleteAssignment,
|
||||
deleteResult,
|
||||
deleteEvent,
|
||||
deleteAnnouncement,
|
||||
} from "@/lib/actions";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { FormContainerProps } from "./FormContainer";
|
||||
|
||||
const deleteActionMap = {
|
||||
subject: deleteSubject,
|
||||
class: deleteClass,
|
||||
teacher: deleteTeacher,
|
||||
student: deleteStudent,
|
||||
exam: deleteExam,
|
||||
// TODO: OTHER DELETE ACTIONS
|
||||
parent: deleteSubject,
|
||||
lesson: deleteLesson,
|
||||
assignment: deleteAssignment,
|
||||
result: deleteResult,
|
||||
attendance: deleteSubject,
|
||||
event: deleteEvent,
|
||||
announcement: deleteAnnouncement,
|
||||
};
|
||||
|
||||
// USE LAZY LOADING
|
||||
|
||||
// import TeacherForm from "./forms/TeacherForm";
|
||||
// import StudentForm from "./forms/StudentForm";
|
||||
|
||||
const TeacherForm = dynamic(() => import("./forms/TeacherForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const StudentForm = dynamic(() => import("./forms/StudentForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const SubjectForm = dynamic(() => import("./forms/SubjectForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const ClassForm = dynamic(() => import("./forms/ClassForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const ExamForm = dynamic(() => import("./forms/ExamForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const LessonForm = dynamic(() => import("./forms/LessonForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const AssignmentForm = dynamic(() => import("./forms/AssignmentForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const ResultForm = dynamic(() => import("./forms/ResultForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const EventForm = dynamic(() => import("./forms/EventForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
const AnnouncementForm = dynamic(() => import("./forms/AnnouncementForm"), {
|
||||
loading: () => <h1>Loading...</h1>,
|
||||
});
|
||||
// TODO: OTHER FORMS
|
||||
|
||||
const forms: {
|
||||
[key: string]: (
|
||||
setOpen: Dispatch<SetStateAction<boolean>>,
|
||||
type: "create" | "update",
|
||||
data?: any,
|
||||
relatedData?: any
|
||||
) => JSX.Element;
|
||||
} = {
|
||||
subject: (setOpen, type, data, relatedData) => (
|
||||
<SubjectForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
class: (setOpen, type, data, relatedData) => (
|
||||
<ClassForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
teacher: (setOpen, type, data, relatedData) => (
|
||||
<TeacherForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
student: (setOpen, type, data, relatedData) => (
|
||||
<StudentForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
exam: (setOpen, type, data, relatedData) => (
|
||||
<ExamForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
lesson: (setOpen, type, data, relatedData) => (
|
||||
<LessonForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
assignment: (setOpen, type, data, relatedData) => (
|
||||
<AssignmentForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
result: (setOpen, type, data, relatedData) => (
|
||||
<ResultForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
event: (setOpen, type, data, relatedData) => (
|
||||
<EventForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
),
|
||||
announcement: (setOpen, type, data, relatedData) => (
|
||||
<AnnouncementForm
|
||||
type={type}
|
||||
data={data}
|
||||
setOpen={setOpen}
|
||||
relatedData={relatedData}
|
||||
/>
|
||||
// TODO OTHER LIST ITEMS
|
||||
),
|
||||
};
|
||||
|
||||
const FormModal = ({
|
||||
table,
|
||||
type,
|
||||
data,
|
||||
id,
|
||||
relatedData,
|
||||
}: FormContainerProps & { relatedData?: any }) => {
|
||||
const size = type === "create" ? "w-8 h-8" : "w-7 h-7";
|
||||
const bgColor =
|
||||
type === "create"
|
||||
? "bg-lamaYellow"
|
||||
: type === "update"
|
||||
? "bg-lamaSky"
|
||||
: "bg-lamaPurple";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const Form = () => {
|
||||
const [state, formAction] = useFormState(deleteActionMap[table], {
|
||||
success: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success) {
|
||||
toast(`${table} has been deleted!`);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
}, [state, router]);
|
||||
|
||||
return type === "delete" && id ? (
|
||||
<form action={formAction} className="p-4 flex flex-col gap-4">
|
||||
<input type="text | number" name="id" defaultValue={id} hidden />
|
||||
<span className="text-center font-medium">
|
||||
All data will be lost. Are you sure you want to delete this {table}?
|
||||
</span>
|
||||
<button className="bg-red-700 text-white py-2 px-4 rounded-md border-none w-max self-center">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
) : type === "create" || type === "update" ? (
|
||||
forms[table] ? (
|
||||
forms[table](setOpen, type, data, relatedData)
|
||||
) : (
|
||||
"Form not found!"
|
||||
)
|
||||
) : (
|
||||
"Form not found!"
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`${size} flex items-center justify-center rounded-full ${bgColor}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Image src={`/${type}.png`} alt="" width={16} height={16} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="w-screen h-screen absolute left-0 top-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||
<div className="bg-white p-4 rounded-md relative w-[90%] md:w-[70%] lg:w-[60%] xl:w-[50%] 2xl:w-[40%]">
|
||||
<Form />
|
||||
<div
|
||||
className="absolute top-4 right-4 cursor-pointer"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Image src="/close.png" alt="" width={14} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormModal;
|
||||
41
src/components/InputField.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { FieldError } from "react-hook-form";
|
||||
|
||||
type InputFieldProps = {
|
||||
label: string;
|
||||
type?: string;
|
||||
register: any;
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
error?: FieldError;
|
||||
hidden?: boolean;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
};
|
||||
|
||||
const InputField = ({
|
||||
label,
|
||||
type = "text",
|
||||
register,
|
||||
name,
|
||||
defaultValue,
|
||||
error,
|
||||
hidden,
|
||||
inputProps,
|
||||
}: InputFieldProps) => {
|
||||
return (
|
||||
<div className={hidden ? "hidden" : "flex flex-col gap-2 w-full md:w-1/4"}>
|
||||
<label className="text-xs text-gray-500">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
{...register(name)}
|
||||
className="ring-[1.5px] ring-gray-300 p-2 rounded-md text-sm w-full"
|
||||
{...inputProps}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
{error?.message && (
|
||||
<p className="text-xs text-red-400">{error.message.toString()}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputField;
|
||||
150
src/components/Menu.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: "MENU",
|
||||
items: [
|
||||
{
|
||||
icon: "/home.png",
|
||||
label: "Home",
|
||||
href: "/",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/teacher.png",
|
||||
label: "Teachers",
|
||||
href: "/list/teachers",
|
||||
visible: ["admin", "teacher"],
|
||||
},
|
||||
{
|
||||
icon: "/student.png",
|
||||
label: "Students",
|
||||
href: "/list/students",
|
||||
visible: ["admin", "teacher"],
|
||||
},
|
||||
{
|
||||
icon: "/parent.png",
|
||||
label: "Parents",
|
||||
href: "/list/parents",
|
||||
visible: ["admin", "teacher"],
|
||||
},
|
||||
{
|
||||
icon: "/subject.png",
|
||||
label: "Subjects",
|
||||
href: "/list/subjects",
|
||||
visible: ["admin"],
|
||||
},
|
||||
{
|
||||
icon: "/class.png",
|
||||
label: "Classes",
|
||||
href: "/list/classes",
|
||||
visible: ["admin", "teacher"],
|
||||
},
|
||||
{
|
||||
icon: "/lesson.png",
|
||||
label: "Lessons",
|
||||
href: "/list/lessons",
|
||||
visible: ["admin", "teacher"],
|
||||
},
|
||||
{
|
||||
icon: "/exam.png",
|
||||
label: "Exams",
|
||||
href: "/list/exams",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/assignment.png",
|
||||
label: "Assignments",
|
||||
href: "/list/assignments",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/result.png",
|
||||
label: "Results",
|
||||
href: "/list/results",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/attendance.png",
|
||||
label: "Attendance",
|
||||
href: "/list/attendance",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/calendar.png",
|
||||
label: "Events",
|
||||
href: "/list/events",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/message.png",
|
||||
label: "Messages",
|
||||
href: "/list/messages",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/announcement.png",
|
||||
label: "Announcements",
|
||||
href: "/list/announcements",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "OTHER",
|
||||
items: [
|
||||
{
|
||||
icon: "/profile.png",
|
||||
label: "Profile",
|
||||
href: "/profile",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/setting.png",
|
||||
label: "Settings",
|
||||
href: "/settings",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
{
|
||||
icon: "/logout.png",
|
||||
label: "Logout",
|
||||
href: "/logout",
|
||||
visible: ["admin", "teacher", "student", "parent"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const Menu = async () => {
|
||||
const user = await currentUser();
|
||||
const role = user?.publicMetadata.role as string;
|
||||
return (
|
||||
<div className="mt-4 text-sm">
|
||||
{menuItems.map((i) => (
|
||||
<div className="flex flex-col gap-2" key={i.title}>
|
||||
<span className="hidden lg:block text-gray-400 font-light my-4">
|
||||
{i.title}
|
||||
</span>
|
||||
{i.items.map((item) => {
|
||||
if (item.visible.includes(role)) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
key={item.label}
|
||||
className="flex items-center justify-center lg:justify-start gap-4 text-gray-500 py-2 md:px-2 rounded-md hover:bg-lamaSkyLight"
|
||||
>
|
||||
<Image src={item.icon} alt="" width={20} height={20} />
|
||||
<span className="hidden lg:block">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
42
src/components/Navbar.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import Image from "next/image";
|
||||
|
||||
const Navbar = async () => {
|
||||
const user = await currentUser();
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{/* SEARCH BAR */}
|
||||
<div className="hidden md:flex items-center gap-2 text-xs rounded-full ring-[1.5px] ring-gray-300 px-2">
|
||||
<Image src="/search.png" alt="" width={14} height={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-[200px] p-2 bg-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/* ICONS AND USER */}
|
||||
<div className="flex items-center gap-6 justify-end w-full">
|
||||
<div className="bg-white rounded-full w-7 h-7 flex items-center justify-center cursor-pointer">
|
||||
<Image src="/message.png" alt="" width={20} height={20} />
|
||||
</div>
|
||||
<div className="bg-white rounded-full w-7 h-7 flex items-center justify-center cursor-pointer relative">
|
||||
<Image src="/announcement.png" alt="" width={20} height={20} />
|
||||
<div className="absolute -top-3 -right-3 w-5 h-5 flex items-center justify-center bg-purple-500 text-white rounded-full text-xs">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs leading-3 font-medium">John Doe</span>
|
||||
<span className="text-[10px] text-gray-500 text-right">
|
||||
{user?.publicMetadata?.role as string}
|
||||
</span>
|
||||
</div>
|
||||
{/* <Image src="/avatar.png" alt="" width={36} height={36} className="rounded-full"/> */}
|
||||
<UserButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
62
src/components/Pagination.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { ITEM_PER_PAGE } from "@/lib/settings";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const Pagination = ({ page, count }: { page: number; count: number }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const hasPrev = ITEM_PER_PAGE * (page - 1) > 0;
|
||||
const hasNext = ITEM_PER_PAGE * (page - 1) + ITEM_PER_PAGE < count;
|
||||
|
||||
const changePage = (newPage: number) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("page", newPage.toString());
|
||||
router.push(`${window.location.pathname}?${params}`);
|
||||
};
|
||||
return (
|
||||
<div className="p-4 flex items-center justify-between text-gray-500">
|
||||
<button
|
||||
disabled={!hasPrev}
|
||||
className="py-2 px-4 rounded-md bg-slate-200 text-xs font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
changePage(page - 1);
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{Array.from(
|
||||
{ length: Math.ceil(count / ITEM_PER_PAGE) },
|
||||
(_, index) => {
|
||||
const pageIndex = index + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageIndex}
|
||||
className={`px-2 rounded-sm ${
|
||||
page === pageIndex ? "bg-lamaSky" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
changePage(pageIndex);
|
||||
}}
|
||||
>
|
||||
{pageIndex}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="py-2 px-4 rounded-md bg-slate-200 text-xs font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!hasNext}
|
||||
onClick={() => {
|
||||
changePage(page + 1);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
40
src/components/Performance.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import { PieChart, Pie, Sector, Cell, ResponsiveContainer } from "recharts";
|
||||
|
||||
const data = [
|
||||
{ name: "Group A", value: 92, fill: "#C3EBFA" },
|
||||
{ name: "Group B", value: 8, fill: "#FAE27C" },
|
||||
];
|
||||
|
||||
const Performance = () => {
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-md h-80 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Performance</h1>
|
||||
<Image src="/moreDark.png" alt="" width={16} height={16} />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
dataKey="value"
|
||||
startAngle={180}
|
||||
endAngle={0}
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={70}
|
||||
fill="#8884d8"
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<h1 className="text-3xl font-bold">9.2</h1>
|
||||
<p className="text-xs text-gray-300">of 10 max LTS</p>
|
||||
</div>
|
||||
<h2 className="font-medium absolute bottom-16 left-0 right-0 m-auto text-center">1st Semester - 2nd Semester</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Performance;
|
||||
28
src/components/StudentAttendanceCard.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
|
||||
const StudentAttendanceCard = async ({ id }: { id: string }) => {
|
||||
const supabase = await getSupabaseClient();
|
||||
const { data: attendanceData, error } = await supabase
|
||||
.from("Attendance")
|
||||
.select("present")
|
||||
.eq("studentId", id)
|
||||
.gte("date", new Date(new Date().getFullYear(), 0, 1).toISOString());
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching student attendance:", error);
|
||||
}
|
||||
|
||||
const attendance = attendanceData || [];
|
||||
|
||||
const totalDays = attendance.length;
|
||||
const presentDays = attendance.filter((day) => day.present).length;
|
||||
const percentage = (presentDays / totalDays) * 100;
|
||||
return (
|
||||
<div className="">
|
||||
<h1 className="text-xl font-semibold">{percentage || "-"}%</h1>
|
||||
<span className="text-sm text-gray-400">Attendance</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentAttendanceCard;
|
||||
24
src/components/Table.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
const Table = ({
|
||||
columns,
|
||||
renderRow,
|
||||
data,
|
||||
}: {
|
||||
columns: { header: string; accessor: string; className?: string }[];
|
||||
renderRow: (item: any) => React.ReactNode;
|
||||
data: any[];
|
||||
}) => {
|
||||
return (
|
||||
<table className="w-full mt-4">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-sm">
|
||||
{columns.map((col) => (
|
||||
<th key={col.accessor} className={col.className}>{col.header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{data.map((item) => renderRow(item))}</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
34
src/components/TableSearch.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const TableSearch = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const value = (e.currentTarget[0] as HTMLInputElement).value;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("search", value);
|
||||
router.push(`${window.location.pathname}?${params}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full md:w-auto flex items-center gap-2 text-xs rounded-full ring-[1.5px] ring-gray-300 px-2"
|
||||
>
|
||||
<Image src="/search.png" alt="" width={14} height={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-[200px] p-2 bg-transparent outline-none"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableSearch;
|
||||
38
src/components/UserCard.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { getSupabaseClient } from "@/lib/supabase";
|
||||
import Image from "next/image";
|
||||
|
||||
const UserCard = async ({
|
||||
type,
|
||||
}: {
|
||||
type: "admin" | "teacher" | "student" | "parent";
|
||||
}) => {
|
||||
const supabase = await getSupabaseClient();
|
||||
const tableMap: Record<typeof type, any> = {
|
||||
admin: "Admin",
|
||||
teacher: "Teacher",
|
||||
student: "Student",
|
||||
parent: "Parent",
|
||||
};
|
||||
const tableName = tableMap[type] as "Admin" | "Teacher" | "Student" | "Parent";
|
||||
|
||||
const { count } = await supabase
|
||||
.from(tableName)
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
const data = count || 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl odd:bg-lamaPurple even:bg-lamaYellow p-4 flex-1 min-w-[130px]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] bg-white px-2 py-1 rounded-full text-green-600">
|
||||
2024/25
|
||||
</span>
|
||||
<Image src="/more.png" alt="" width={20} height={20} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold my-4">{data}</h1>
|
||||
<h2 className="capitalize text-sm font-medium text-gray-500">{type}s</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserCard;
|
||||