Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bbf7f9c1c | ||
|
|
2584539fb0 | ||
|
|
101c235855 | ||
|
|
b89b2b1ba5 | ||
|
|
c11a3f9a7d | ||
|
|
763b887d65 | ||
|
|
db8cee836a | ||
|
|
46bbd8106b | ||
|
|
aaa62ae3f5 | ||
|
|
9ae190754a | ||
|
|
9faa512c0b | ||
|
|
7955465be4 | ||
| 3ac50d4132 | |||
|
|
ded04fca7f | ||
|
|
1206e51f6d |
2076
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Career" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"post" TEXT NOT NULL,
|
||||||
|
"designation" TEXT,
|
||||||
|
"qualification" TEXT,
|
||||||
|
"experienceNeed" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"number" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'new',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Career_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Candidate" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"mobile" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"coverLetter" TEXT NOT NULL,
|
||||||
|
"careerId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Candidate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_careerId_fkey" FOREIGN KEY ("careerId") REFERENCES "Career"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Appointment" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"mobileNumber" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"message" TEXT,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"doctorId" INTEGER NOT NULL,
|
||||||
|
"departmentId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Appointment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Appointment" DROP CONSTRAINT "Appointment_departmentId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Appointment" DROP CONSTRAINT "Appointment_doctorId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Appointment" ALTER COLUMN "doctorId" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "departmentId" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("doctorId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("departmentId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Inquiry" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"number" TEXT NOT NULL,
|
||||||
|
"emailId" TEXT,
|
||||||
|
"subject" TEXT,
|
||||||
|
"message" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Inquiry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AcademicsResearch" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"number" TEXT NOT NULL,
|
||||||
|
"emailId" TEXT,
|
||||||
|
"subject" TEXT,
|
||||||
|
"courseName" TEXT,
|
||||||
|
"message" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AcademicsResearch_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -9,92 +8,169 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
password String
|
password String
|
||||||
role String? @default("admin")
|
role String? @default("admin")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Doctor {
|
model Doctor {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
doctorId String @unique
|
doctorId String @unique
|
||||||
name String
|
name String
|
||||||
designation String?
|
designation String?
|
||||||
workingStatus String?
|
workingStatus String?
|
||||||
qualification String?
|
qualification String?
|
||||||
|
|
||||||
departments DoctorDepartment[]
|
departments DoctorDepartment[]
|
||||||
|
appointments Appointment[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
model Department {
|
model Department {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
departmentId String @unique
|
departmentId String @unique
|
||||||
name String
|
name String
|
||||||
|
|
||||||
para1 String?
|
para1 String?
|
||||||
para2 String?
|
para2 String?
|
||||||
para3 String?
|
para3 String?
|
||||||
facilities String?
|
facilities String?
|
||||||
services String?
|
services String?
|
||||||
|
|
||||||
doctors DoctorDepartment[]
|
doctors DoctorDepartment[]
|
||||||
|
appointments Appointment[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model DoctorDepartment {
|
model DoctorDepartment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
doctorId Int
|
doctorId Int
|
||||||
departmentId Int
|
departmentId Int
|
||||||
|
|
||||||
doctor Doctor @relation(fields: [doctorId], references: [id])
|
doctor Doctor @relation(fields: [doctorId], references: [id])
|
||||||
department Department @relation(fields: [departmentId], references: [id])
|
department Department @relation(fields: [departmentId], references: [id])
|
||||||
|
|
||||||
timing DoctorTiming?
|
timing DoctorTiming?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([doctorId, departmentId])
|
@@unique([doctorId, departmentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DoctorTiming {
|
model DoctorTiming {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
doctorDepartmentId Int @unique
|
doctorDepartmentId Int @unique
|
||||||
doctorDepartment DoctorDepartment @relation(fields: [doctorDepartmentId], references: [id])
|
doctorDepartment DoctorDepartment @relation(fields: [doctorDepartmentId], references: [id])
|
||||||
|
|
||||||
monday String?
|
monday String?
|
||||||
tuesday String?
|
tuesday String?
|
||||||
wednesday String?
|
wednesday String?
|
||||||
thursday String?
|
thursday String?
|
||||||
friday String?
|
friday String?
|
||||||
saturday String?
|
saturday String?
|
||||||
sunday String?
|
sunday String?
|
||||||
additional String?
|
additional String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
model Blog {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
title String
|
|
||||||
writer String?
|
|
||||||
image String?
|
|
||||||
content Json
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Blog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
writer String?
|
||||||
|
image String?
|
||||||
|
content Json
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Career {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
post String
|
||||||
|
designation String?
|
||||||
|
qualification String?
|
||||||
|
experienceNeed String?
|
||||||
|
email String?
|
||||||
|
number String?
|
||||||
|
status String @default("new")
|
||||||
|
|
||||||
|
candidates Candidate[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Candidate {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
fullName String
|
||||||
|
mobile String
|
||||||
|
email String
|
||||||
|
subject String
|
||||||
|
coverLetter String
|
||||||
|
careerId Int
|
||||||
|
|
||||||
|
career Career @relation(fields: [careerId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Appointment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
|
name String
|
||||||
|
mobileNumber String
|
||||||
|
email String?
|
||||||
|
message String?
|
||||||
|
date DateTime
|
||||||
|
|
||||||
|
doctorId String
|
||||||
|
departmentId String
|
||||||
|
|
||||||
|
doctor Doctor @relation(fields: [doctorId], references: [doctorId])
|
||||||
|
department Department @relation(fields: [departmentId], references: [departmentId])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Inquiry {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
|
fullName String
|
||||||
|
number String
|
||||||
|
emailId String?
|
||||||
|
subject String?
|
||||||
|
message String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model AcademicsResearch {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
|
fullName String
|
||||||
|
number String
|
||||||
|
emailId String?
|
||||||
|
subject String?
|
||||||
|
courseName String?
|
||||||
|
message String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,12 @@ import departmentRoutes from "./routes/department.routes.js";
|
|||||||
import authRoutes from "./routes/auth.routes.js";
|
import authRoutes from "./routes/auth.routes.js";
|
||||||
import blogRoutes from "./routes/blog.routes.js";
|
import blogRoutes from "./routes/blog.routes.js";
|
||||||
import uploadRoutes from "./routes/upload.routes.js";
|
import uploadRoutes from "./routes/upload.routes.js";
|
||||||
|
import doctorRoutes from "./routes/doctor.routes.js";
|
||||||
|
import careerRoutes from "./routes/career.routes.js";
|
||||||
|
import candidateRoutes from "./routes/candidate.routes.js";
|
||||||
|
import appointmentRoutes from "./routes/appointment.routes.js";
|
||||||
|
import inquiryRoutes from "./routes/inquiry.routes.js";
|
||||||
|
import academicsResearchRoutes from "./routes/academicsResearch.routes.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -35,6 +41,12 @@ app.use("/api/auth", authRoutes);
|
|||||||
app.use("/api/blogs", blogRoutes);
|
app.use("/api/blogs", blogRoutes);
|
||||||
app.use("/uploads", express.static("uploads"));
|
app.use("/uploads", express.static("uploads"));
|
||||||
app.use("/api/upload", uploadRoutes);
|
app.use("/api/upload", uploadRoutes);
|
||||||
|
app.use("/api/doctors", doctorRoutes);
|
||||||
|
app.use("/api/careers", careerRoutes);
|
||||||
|
app.use("/api/candidates", candidateRoutes);
|
||||||
|
app.use("/api/appointments", appointmentRoutes);
|
||||||
|
app.use("/api/inquiry", inquiryRoutes);
|
||||||
|
app.use("/api/academics", academicsResearchRoutes);
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
117
backend/src/controllers/academicsResearch.controller.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
// CREATE ACADEMICS & RESEARCH
|
||||||
|
|
||||||
|
export const createAcademicsResearch = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {fullName, number, emailId, subject, courseName, message} = req.body;
|
||||||
|
|
||||||
|
if (!fullName || !number) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Full name and number are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await prisma.academicsResearch.create({
|
||||||
|
data: {
|
||||||
|
fullName,
|
||||||
|
number,
|
||||||
|
emailId,
|
||||||
|
subject,
|
||||||
|
courseName,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
status: 200,
|
||||||
|
data,
|
||||||
|
message: "Academics & Research added successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to add Academics & Research inquiry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET ALL
|
||||||
|
|
||||||
|
export const getAcademicsResearch = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await prisma.academicsResearch.findMany({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch records",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET SINGLE
|
||||||
|
|
||||||
|
export const getSingleAcademicsResearch = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const data = await prisma.academicsResearch.findUnique({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Record not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch record",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
|
||||||
|
export const deleteAcademicsResearch = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
await prisma.academicsResearch.delete({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Record deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete record",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
224
backend/src/controllers/appointment.controller.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
//CREATE APPOINTMENT
|
||||||
|
|
||||||
|
export const createAppointment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {name, mobileNumber, email, message, date, doctorId, departmentId} =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
if (!name || !mobileNumber || !doctorId || !departmentId || !date) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Required fields missing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointment = await prisma.appointment.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
mobileNumber,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
date: new Date(date),
|
||||||
|
doctorId,
|
||||||
|
departmentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
doctor: true,
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Appointment booked successfully",
|
||||||
|
data: appointment,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create appointment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET ALL APPOINTMENTS
|
||||||
|
|
||||||
|
export const getAppointments = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const appointments = await prisma.appointment.findMany({
|
||||||
|
include: {
|
||||||
|
doctor: true,
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: appointments,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch appointments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET SINGLE APPOINTMENT
|
||||||
|
|
||||||
|
export const getAppointment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const appointment = await prisma.appointment.findUnique({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
doctor: true,
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Appointment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: appointment,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch appointment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET APPOINTMENTS BY DOCTOR
|
||||||
|
|
||||||
|
export const getAppointmentsByDoctor = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {doctorId} = req.params;
|
||||||
|
|
||||||
|
const appointments = await prisma.appointment.findMany({
|
||||||
|
where: {
|
||||||
|
doctorId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
doctor: true,
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: appointments,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch doctor appointments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET APPOINTMENTS BY DEPARTMENT
|
||||||
|
|
||||||
|
export const getAppointmentsByDepartment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {departmentId} = req.params;
|
||||||
|
|
||||||
|
const appointments = await prisma.appointment.findMany({
|
||||||
|
where: {
|
||||||
|
departmentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
doctor: true,
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: appointments,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch department appointments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPDATE APPOINTMENT
|
||||||
|
|
||||||
|
export const updateAppointment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const appointment = await prisma.appointment.update({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
data: req.body,
|
||||||
|
include: {
|
||||||
|
doctor: true,
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Appointment updated successfully",
|
||||||
|
data: appointment,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update appointment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//DELETE APPOINTMENT
|
||||||
|
|
||||||
|
export const deleteAppointment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
await prisma.appointment.delete({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Appointment deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete appointment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
183
backend/src/controllers/candidate.controller.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
// CREATE CANDIDATE
|
||||||
|
|
||||||
|
export const createCandidate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {fullName, mobile, email, subject, coverLetter, careerId} = req.body;
|
||||||
|
|
||||||
|
if (!fullName || !mobile || !email || !careerId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Required fields missing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = await prisma.candidate.create({
|
||||||
|
data: {
|
||||||
|
fullName,
|
||||||
|
mobile,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
coverLetter,
|
||||||
|
careerId: Number(careerId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Application submitted successfully",
|
||||||
|
data: candidate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create candidate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET ALL CANDIDATES
|
||||||
|
|
||||||
|
export const getCandidates = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const candidates = await prisma.candidate.findMany({
|
||||||
|
include: {
|
||||||
|
career: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: candidates,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch candidates",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET SINGLE CANDIDATE
|
||||||
|
|
||||||
|
export const getCandidate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const candidate = await prisma.candidate.findUnique({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
career: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Candidate not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: candidate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch candidate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET CANDIDATES BY CAREER
|
||||||
|
|
||||||
|
export const getCandidatesByCareer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {careerId} = req.params;
|
||||||
|
|
||||||
|
const candidates = await prisma.candidate.findMany({
|
||||||
|
where: {
|
||||||
|
careerId: Number(careerId),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
career: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: candidates,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch candidates",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPDATE CANDIDATE
|
||||||
|
|
||||||
|
export const updateCandidate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const candidate = await prisma.candidate.update({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Candidate updated successfully",
|
||||||
|
data: candidate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update candidate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE CANDIDATE
|
||||||
|
|
||||||
|
export const deleteCandidate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
await prisma.candidate.delete({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Candidate deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete candidate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
128
backend/src/controllers/career.controller.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
// GET ALL CAREERS
|
||||||
|
|
||||||
|
export const getAllCareers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const careers = await prisma.career.findMany({
|
||||||
|
orderBy: {createdAt: "desc"},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = careers.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
post: c.post,
|
||||||
|
designation: c.designation,
|
||||||
|
qualification: c.qualification,
|
||||||
|
experienceNeed: c.experienceNeed,
|
||||||
|
email: c.email,
|
||||||
|
number: c.number,
|
||||||
|
status: c.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch careers",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// CREATE CAREER
|
||||||
|
|
||||||
|
export const createCareer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
post,
|
||||||
|
designation,
|
||||||
|
qualification,
|
||||||
|
experienceNeed,
|
||||||
|
email,
|
||||||
|
number,
|
||||||
|
status,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!post || !designation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Post and designation are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const career = await prisma.career.create({
|
||||||
|
data: {
|
||||||
|
post,
|
||||||
|
designation,
|
||||||
|
qualification,
|
||||||
|
experienceNeed,
|
||||||
|
email,
|
||||||
|
number,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Career created successfully",
|
||||||
|
data: career,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create career",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPDATE CAREER (PATCH)
|
||||||
|
|
||||||
|
export const updateCareer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const career = await prisma.career.update({
|
||||||
|
where: {id: Number(id)},
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Career updated successfully",
|
||||||
|
data: career,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update career",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE CAREER
|
||||||
|
|
||||||
|
export const deleteCareer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
await prisma.career.delete({
|
||||||
|
where: {id: Number(id)},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Career deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete career",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ export const getAllDepartments = async (req, res) => {
|
|||||||
|
|
||||||
const response = departments.map((dep) => ({
|
const response = departments.map((dep) => ({
|
||||||
departmentId: dep.departmentId,
|
departmentId: dep.departmentId,
|
||||||
Department: dep.name,
|
name: dep.name,
|
||||||
para1: dep.para1 ?? "",
|
para1: dep.para1 ?? "",
|
||||||
para2: dep.para2 ?? "",
|
para2: dep.para2 ?? "",
|
||||||
para3: dep.para3 ?? "",
|
para3: dep.para3 ?? "",
|
||||||
@@ -64,3 +64,56 @@ export async function createDepartment(req, res) {
|
|||||||
res.status(500).json({error: "Failed to create department"});
|
res.status(500).json({error: "Failed to create department"});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updateDepartment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {departmentId} = req.params;
|
||||||
|
|
||||||
|
const {name, para1, para2, para3, facilities, services} = req.body;
|
||||||
|
|
||||||
|
const department = await prisma.department.update({
|
||||||
|
where: {departmentId},
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
para1,
|
||||||
|
para2,
|
||||||
|
para3,
|
||||||
|
facilities,
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Department updated successfully",
|
||||||
|
data: department,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update department",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDepartment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {departmentId} = req.params;
|
||||||
|
|
||||||
|
await prisma.department.delete({
|
||||||
|
where: {departmentId},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Department deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete department",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
416
backend/src/controllers/doctor.controller.js
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
// get doctors
|
||||||
|
|
||||||
|
export const getAllDoctors = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doctors = await prisma.doctor.findMany({
|
||||||
|
include: {
|
||||||
|
departments: {
|
||||||
|
include: {
|
||||||
|
department: true,
|
||||||
|
timing: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {name: "asc"},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatted = doctors.map((doc, index) => {
|
||||||
|
return {
|
||||||
|
SL_NO: String(index + 1),
|
||||||
|
doctorId: doc.doctorId,
|
||||||
|
name: doc.name,
|
||||||
|
designation: doc.designation,
|
||||||
|
workingStatus: doc.workingStatus,
|
||||||
|
qualification: doc.qualification,
|
||||||
|
|
||||||
|
departments: doc.departments.map((d) => {
|
||||||
|
const t = d.timing || {};
|
||||||
|
|
||||||
|
const timingArray = [
|
||||||
|
t.monday && `Monday ${t.monday}`,
|
||||||
|
t.tuesday && `Tuesday ${t.tuesday}`,
|
||||||
|
t.wednesday && `Wednesday ${t.wednesday}`,
|
||||||
|
t.thursday && `Thursday ${t.thursday}`,
|
||||||
|
t.friday && `Friday ${t.friday}`,
|
||||||
|
t.saturday && `Saturday ${t.saturday}`,
|
||||||
|
t.sunday && `Sunday ${t.sunday}`,
|
||||||
|
t.additional && t.additional,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
departmentId: d.department.departmentId,
|
||||||
|
departmentName: d.department.name,
|
||||||
|
|
||||||
|
timing: timingArray.join(" & "),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: formatted,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch doctors",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// get doctor by id
|
||||||
|
|
||||||
|
export const getDoctorByDoctorId = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {doctorId} = req.params;
|
||||||
|
|
||||||
|
const doctor = await prisma.doctor.findUnique({
|
||||||
|
where: {doctorId},
|
||||||
|
include: {
|
||||||
|
departments: {
|
||||||
|
include: {
|
||||||
|
department: true,
|
||||||
|
timing: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doctor) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Doctor not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
doctorId: doctor.doctorId,
|
||||||
|
name: doctor.name,
|
||||||
|
designation: doctor.designation,
|
||||||
|
workingStatus: doctor.workingStatus,
|
||||||
|
qualification: doctor.qualification,
|
||||||
|
departments: doctor.departments.map((d) => ({
|
||||||
|
departmentId: d.department.departmentId,
|
||||||
|
departmentName: d.department.name,
|
||||||
|
timing: d.timing || {},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch doctor",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// add doctors
|
||||||
|
export const createDoctor = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
doctorId,
|
||||||
|
name,
|
||||||
|
designation,
|
||||||
|
workingStatus,
|
||||||
|
qualification,
|
||||||
|
departments,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const doctor = await prisma.doctor.create({
|
||||||
|
data: {
|
||||||
|
doctorId,
|
||||||
|
name,
|
||||||
|
designation,
|
||||||
|
workingStatus,
|
||||||
|
qualification,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const dep of departments) {
|
||||||
|
const department = await prisma.department.findUnique({
|
||||||
|
where: {departmentId: dep.departmentId},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!department) continue;
|
||||||
|
|
||||||
|
const doctorDepartment = await prisma.doctorDepartment.create({
|
||||||
|
data: {
|
||||||
|
doctorId: doctor.id,
|
||||||
|
departmentId: department.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dep.timing) {
|
||||||
|
await prisma.doctorTiming.create({
|
||||||
|
data: {
|
||||||
|
doctorDepartmentId: doctorDepartment.id,
|
||||||
|
...dep.timing,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Doctor created successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create doctor",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//update doctors
|
||||||
|
export const updateDoctor = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {doctorId} = req.params;
|
||||||
|
const {name, designation, workingStatus, qualification, departments} =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
const doctor = await prisma.doctor.findUnique({
|
||||||
|
where: {doctorId},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doctor) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Doctor not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.doctor.update({
|
||||||
|
where: {id: doctor.id},
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
designation,
|
||||||
|
workingStatus,
|
||||||
|
qualification,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldRelations = await prisma.doctorDepartment.findMany({
|
||||||
|
where: {doctorId: doctor.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rel of oldRelations) {
|
||||||
|
await prisma.doctorTiming.deleteMany({
|
||||||
|
where: {doctorDepartmentId: rel.id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.doctorDepartment.deleteMany({
|
||||||
|
where: {doctorId: doctor.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const dep of departments) {
|
||||||
|
const department = await prisma.department.findUnique({
|
||||||
|
where: {departmentId: dep.departmentId},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!department) continue;
|
||||||
|
|
||||||
|
const doctorDepartment = await prisma.doctorDepartment.create({
|
||||||
|
data: {
|
||||||
|
doctorId: doctor.id,
|
||||||
|
departmentId: department.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dep.timing) {
|
||||||
|
await prisma.doctorTiming.create({
|
||||||
|
data: {
|
||||||
|
doctorDepartmentId: doctorDepartment.id,
|
||||||
|
...dep.timing,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Doctor updated successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update doctor",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
//delete doctor
|
||||||
|
|
||||||
|
export const deleteDoctor = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {doctorId} = req.params;
|
||||||
|
|
||||||
|
if (!doctorId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Doctor ID is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctor = await prisma.doctor.findUnique({
|
||||||
|
where: {doctorId},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doctor) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: `Doctor with ID ${doctorId} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctorDepartments = await prisma.doctorDepartment.findMany({
|
||||||
|
where: {doctorId: doctor.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const dd of doctorDepartments) {
|
||||||
|
await prisma.doctorTiming.deleteMany({
|
||||||
|
where: {doctorDepartmentId: dd.id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.doctorDepartment.deleteMany({
|
||||||
|
where: {doctorId: doctor.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.doctor.delete({
|
||||||
|
where: {id: doctor.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `Doctor ${doctorId} deleted successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete doctor",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// get doctor timings
|
||||||
|
|
||||||
|
export const getDoctorTimings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const doctors = await prisma.doctor.findMany({
|
||||||
|
include: {
|
||||||
|
departments: {
|
||||||
|
include: {
|
||||||
|
timing: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = doctors.map((doc) => {
|
||||||
|
let timing = {};
|
||||||
|
|
||||||
|
if (doc.departments.length > 0) {
|
||||||
|
timing = doc.departments[0].timing ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Doctor_ID: doc.doctorId,
|
||||||
|
Doctor: doc.name,
|
||||||
|
Monday: timing?.monday ?? "",
|
||||||
|
Tuesday: timing?.tuesday ?? "",
|
||||||
|
Wednesday: timing?.wednesday ?? "",
|
||||||
|
Thursday: timing?.thursday ?? "",
|
||||||
|
Friday: timing?.friday ?? "",
|
||||||
|
Saturday: timing?.saturday ?? "",
|
||||||
|
Sunday: timing?.sunday ?? "",
|
||||||
|
Additional: timing?.additional ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch doctor timings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// get doctor timings by id
|
||||||
|
|
||||||
|
export const getDoctorTimingById = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {doctorId} = req.params;
|
||||||
|
|
||||||
|
const doctor = await prisma.doctor.findUnique({
|
||||||
|
where: {doctorId},
|
||||||
|
include: {
|
||||||
|
departments: {
|
||||||
|
include: {
|
||||||
|
department: true,
|
||||||
|
timing: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doctor) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Doctor not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
doctorId: doctor.doctorId,
|
||||||
|
doctorName: doctor.name,
|
||||||
|
|
||||||
|
departments: doctor.departments.map((d) => {
|
||||||
|
const t = d.timing || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
departmentId: d.department.departmentId,
|
||||||
|
departmentName: d.department.name,
|
||||||
|
|
||||||
|
timing: {
|
||||||
|
monday: t.monday || "",
|
||||||
|
tuesday: t.tuesday || "",
|
||||||
|
wednesday: t.wednesday || "",
|
||||||
|
thursday: t.thursday || "",
|
||||||
|
friday: t.friday || "",
|
||||||
|
saturday: t.saturday || "",
|
||||||
|
sunday: t.sunday || "",
|
||||||
|
additional: t.additional || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch doctor timing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
108
backend/src/controllers/inquiry.controller.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
/* CREATE INQUIRY */
|
||||||
|
export const createInquiry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {fullName, number, emailId, subject, message} = req.body;
|
||||||
|
|
||||||
|
if (!fullName || !number) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Full name and number are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const inquiry = await prisma.inquiry.create({
|
||||||
|
data: {
|
||||||
|
fullName,
|
||||||
|
number,
|
||||||
|
emailId,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
status: 200,
|
||||||
|
data: inquiry,
|
||||||
|
message: "Inquiry added successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to add inquiry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* GET ALL */
|
||||||
|
export const getInquiries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const inquiries = await prisma.inquiry.findMany({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: inquiries,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch inquiries",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* GET SINGLE */
|
||||||
|
export const getInquiry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const inquiry = await prisma.inquiry.findUnique({
|
||||||
|
where: {id: Number(id)},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inquiry) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Inquiry not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: inquiry,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch inquiry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* DELETE */
|
||||||
|
export const deleteInquiry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
await prisma.inquiry.delete({
|
||||||
|
where: {id: Number(id)},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Inquiry deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete inquiry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
18
backend/src/routes/academicsResearch.routes.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
createAcademicsResearch,
|
||||||
|
getAcademicsResearch,
|
||||||
|
getSingleAcademicsResearch,
|
||||||
|
deleteAcademicsResearch,
|
||||||
|
} from "../controllers/academicsResearch.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post("/", createAcademicsResearch);
|
||||||
|
router.get("/getAll", getAcademicsResearch);
|
||||||
|
router.get("/:id", getSingleAcademicsResearch);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteAcademicsResearch);
|
||||||
|
|
||||||
|
export default router;
|
||||||
23
backend/src/routes/appointment.routes.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
createAppointment,
|
||||||
|
getAppointments,
|
||||||
|
getAppointment,
|
||||||
|
updateAppointment,
|
||||||
|
deleteAppointment,
|
||||||
|
} from "../controllers/appointment.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/* PUBLIC */
|
||||||
|
|
||||||
|
router.get("/getall", getAppointments);
|
||||||
|
router.post("/", createAppointment);
|
||||||
|
|
||||||
|
router.get("/:id", getAppointment);
|
||||||
|
router.patch("/:id", updateAppointment);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteAppointment);
|
||||||
|
|
||||||
|
export default router;
|
||||||
25
backend/src/routes/candidate.routes.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
createCandidate,
|
||||||
|
getCandidates,
|
||||||
|
getCandidate,
|
||||||
|
getCandidatesByCareer,
|
||||||
|
updateCandidate,
|
||||||
|
deleteCandidate,
|
||||||
|
} from "../controllers/candidate.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/* PUBLIC */
|
||||||
|
|
||||||
|
router.get("/getAll", getCandidates);
|
||||||
|
router.get("/:id", getCandidate);
|
||||||
|
router.get("/career/:careerId", getCandidatesByCareer);
|
||||||
|
|
||||||
|
router.post("/", createCandidate);
|
||||||
|
router.patch("/:id", updateCandidate);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteCandidate);
|
||||||
|
|
||||||
|
export default router;
|
||||||
17
backend/src/routes/career.routes.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getAllCareers,
|
||||||
|
createCareer,
|
||||||
|
updateCareer,
|
||||||
|
deleteCareer,
|
||||||
|
} from "../controllers/career.controller.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/getAll", getAllCareers);
|
||||||
|
|
||||||
|
router.post("/", createCareer);
|
||||||
|
router.patch("/:id", updateCareer);
|
||||||
|
router.delete("/:id", deleteCareer);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,6 +2,8 @@ import express from "express";
|
|||||||
import {
|
import {
|
||||||
getAllDepartments,
|
getAllDepartments,
|
||||||
createDepartment,
|
createDepartment,
|
||||||
|
updateDepartment,
|
||||||
|
deleteDepartment,
|
||||||
} from "../controllers/department.controller.js";
|
} from "../controllers/department.controller.js";
|
||||||
import jwtAuthMiddleware from "../middleware/auth.js";
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
@@ -12,5 +14,7 @@ router.get("/getAll", getAllDepartments);
|
|||||||
|
|
||||||
// Protected
|
// Protected
|
||||||
router.post("/", jwtAuthMiddleware, createDepartment);
|
router.post("/", jwtAuthMiddleware, createDepartment);
|
||||||
|
router.put("/:departmentId", jwtAuthMiddleware, updateDepartment);
|
||||||
|
router.delete("/:departmentId", jwtAuthMiddleware, deleteDepartment);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
25
backend/src/routes/doctor.routes.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getAllDoctors,
|
||||||
|
createDoctor,
|
||||||
|
updateDoctor,
|
||||||
|
deleteDoctor,
|
||||||
|
getDoctorTimings,
|
||||||
|
getDoctorTimingById,
|
||||||
|
getDoctorByDoctorId,
|
||||||
|
} from "../controllers/doctor.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/getAll", getAllDoctors);
|
||||||
|
router.get("/:doctorId", getDoctorByDoctorId);
|
||||||
|
router.get("/getTimings", getDoctorTimings);
|
||||||
|
router.get("/getTimings/:doctorId", getDoctorTimingById);
|
||||||
|
|
||||||
|
router.post("/", jwtAuthMiddleware, createDoctor);
|
||||||
|
router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor);
|
||||||
|
router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor);
|
||||||
|
|
||||||
|
export default router;
|
||||||
19
backend/src/routes/inquiry.routes.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
createInquiry,
|
||||||
|
getInquiries,
|
||||||
|
getInquiry,
|
||||||
|
deleteInquiry,
|
||||||
|
} from "../controllers/inquiry.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post("/", createInquiry);
|
||||||
|
|
||||||
|
router.get("/getAll", getInquiries);
|
||||||
|
router.get("/:id", getInquiry);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteInquiry);
|
||||||
|
|
||||||
|
export default router;
|
||||||
BIN
backend/uploads/blog/1773814232254.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
backend/uploads/blog/1773814239753.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
backend/uploads/blog/1773814266558.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
backend/uploads/blog/1773814356620.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
backend/uploads/blog/1773814805822.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9579
frontend/package-lock.json
generated
Normal file
56
frontend/package.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/code": "^2.9.4",
|
||||||
|
"@editorjs/delimiter": "^1.4.2",
|
||||||
|
"@editorjs/editorjs": "^2.31.5",
|
||||||
|
"@editorjs/embed": "^2.8.0",
|
||||||
|
"@editorjs/header": "^2.8.8",
|
||||||
|
"@editorjs/image": "^2.10.3",
|
||||||
|
"@editorjs/list": "^2.0.9",
|
||||||
|
"@editorjs/quote": "^2.7.6",
|
||||||
|
"@editorjs/table": "^2.4.5",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"shadcn": "^4.0.5",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/estree": "^1.0.8",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
41
frontend/src/App.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {BrowserRouter, Routes, Route, Navigate} from "react-router-dom";
|
||||||
|
|
||||||
|
import Login from "@/pages/Login";
|
||||||
|
|
||||||
|
import DashboardLayout from "./layouts/DashboardLayout";
|
||||||
|
|
||||||
|
// import ProtectedRoute from "./components/ProtectedRoutes/ProtectedRoutes";
|
||||||
|
|
||||||
|
import ProtectedRoute from "./auth/ProtectedRoute";
|
||||||
|
import PublicRoute from "./auth/PublicRoute";
|
||||||
|
import {AuthProvider} from "./context/AuthContext";
|
||||||
|
import Department from "./pages/Department";
|
||||||
|
import Doctor from "./pages/Doctor";
|
||||||
|
import Blog from "./pages/Blog";
|
||||||
|
import BlogEditorPage from "./pages/BlogEditor";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<PublicRoute />}>
|
||||||
|
<Route path="/" element={<Login />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<DashboardLayout />}>
|
||||||
|
<Route path="/department" element={<Department />} />
|
||||||
|
<Route path="/doctor" element={<Doctor />} />
|
||||||
|
<Route path="/blog" element={<Blog />} />
|
||||||
|
<Route path="/blog/create" element={<BlogEditorPage />} />
|
||||||
|
<Route path="/blog/edit/:id" element={<BlogEditorPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/department" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import apiClient from "./client";
|
||||||
|
|
||||||
|
export const loginApi = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<any> => {
|
||||||
|
const response = await apiClient.post("/auth/login/", {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
48
frontend/src/api/blog.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export interface Blog {
|
||||||
|
id?: number;
|
||||||
|
title: string;
|
||||||
|
writer: string;
|
||||||
|
image?: string;
|
||||||
|
content: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAllBlogsApi = async () => {
|
||||||
|
const res = await apiClient.get("/blogs");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBlogByIdApi = async (id: number) => {
|
||||||
|
const res = await apiClient.get(`/blogs/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBlogApi = async (data: Blog) => {
|
||||||
|
const res = await apiClient.post("/blogs", data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBlogApi = async (id: number, data: Blog) => {
|
||||||
|
const res = await apiClient.put(`/blogs/${id}`, data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBlogApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/blogs/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* IMAGE UPLOAD */
|
||||||
|
export const uploadImageApi = async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", file);
|
||||||
|
|
||||||
|
const res = await apiClient.post("/upload/image", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
48
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import type {InternalAxiosRequestConfig} from "axios";
|
||||||
|
|
||||||
|
const BASE_URL: string = "http://localhost:3000/api";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setAxiosAuthToken = (token: string | null): void => {
|
||||||
|
if (token) {
|
||||||
|
apiClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
delete apiClient.defaults.headers.common["Authorization"];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: any) => Promise.reject(error),
|
||||||
|
);
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
console.error("Unauthorized - token missing or invalid");
|
||||||
|
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
49
frontend/src/api/department.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
departmentId: string;
|
||||||
|
name: string;
|
||||||
|
para1: string;
|
||||||
|
para2: string;
|
||||||
|
para3: string;
|
||||||
|
facilities: string;
|
||||||
|
services: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDepartmentsApi = async () => {
|
||||||
|
const res = await apiClient.get("/departments/getAll");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDepartmentApi = async (data: {
|
||||||
|
departmentId: string;
|
||||||
|
name: string;
|
||||||
|
para1?: string;
|
||||||
|
para2?: string;
|
||||||
|
para3?: string;
|
||||||
|
facilities?: string;
|
||||||
|
services?: string;
|
||||||
|
}) => {
|
||||||
|
const res = await apiClient.post("/departments", data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDepartmentApi = async (
|
||||||
|
departmentId: string,
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
para1?: string;
|
||||||
|
para2?: string;
|
||||||
|
para3?: string;
|
||||||
|
facilities?: string;
|
||||||
|
services?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const res = await apiClient.put(`/departments/${departmentId}`, data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDepartmentApi = async (departmentId: string) => {
|
||||||
|
const res = await apiClient.delete(`/departments/${departmentId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
56
frontend/src/api/doctor.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export interface Doctor {
|
||||||
|
doctorId: string;
|
||||||
|
name: string;
|
||||||
|
designation?: string;
|
||||||
|
workingStatus?: string;
|
||||||
|
qualification?: string;
|
||||||
|
|
||||||
|
departments: {
|
||||||
|
departmentId: string;
|
||||||
|
timing?: {
|
||||||
|
monday?: string;
|
||||||
|
tuesday?: string;
|
||||||
|
wednesday?: string;
|
||||||
|
thursday?: string;
|
||||||
|
friday?: string;
|
||||||
|
saturday?: string;
|
||||||
|
sunday?: string;
|
||||||
|
additional?: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDoctorsApi = async () => {
|
||||||
|
const res = await apiClient.get("/doctors/getAll");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDoctorByIdApi = async (doctorId: string) => {
|
||||||
|
const res = await apiClient.get(`/doctors/${doctorId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDoctorApi = async (data: Doctor) => {
|
||||||
|
const res = await apiClient.post("/doctors", data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDoctorApi = async (
|
||||||
|
doctorId: string,
|
||||||
|
data: Partial<Doctor>,
|
||||||
|
) => {
|
||||||
|
const res = await apiClient.patch(`/doctors/${doctorId}`, data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDoctorApi = async (doctorId: string) => {
|
||||||
|
const res = await apiClient.delete(`/doctors/${doctorId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDoctorTimingApi = async (doctorId: string) => {
|
||||||
|
const res = await apiClient.get(`/doctors/getTimings/${doctorId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
7
frontend/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import {Navigate, Outlet} from "react-router-dom";
|
||||||
|
import {useAuth} from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export default function ProtectedRoute() {
|
||||||
|
const {token} = useAuth();
|
||||||
|
return token ? <Outlet /> : <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
7
frontend/src/auth/PublicRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import {Navigate, Outlet} from "react-router-dom";
|
||||||
|
import {useAuth} from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export default function PublicRoute() {
|
||||||
|
const {token} = useAuth();
|
||||||
|
return token ? <Navigate to="/dashboard" replace /> : <Outlet />;
|
||||||
|
}
|
||||||
12
frontend/src/components/ProtectedRoutes/ProtectedRoutes.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import {Navigate} from "react-router-dom";
|
||||||
|
import {useAuth} from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export default function ProtectedRoute({children}: any) {
|
||||||
|
const {token} = useAuth();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
35
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {useState, useEffect} from "react";
|
||||||
|
import {useAuth} from "@/context/AuthContext";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Switch} from "@/components/ui/switch";
|
||||||
|
import {log} from "console";
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const {user, logout} = useAuth();
|
||||||
|
const [darkMode, setDarkMode] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (darkMode) document.documentElement.classList.add("dark");
|
||||||
|
else document.documentElement.classList.remove("dark");
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b bg-card">
|
||||||
|
<div className="flex items-center justify-between px-6 h-16">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Welcome back</p>
|
||||||
|
<p className="font-semibold">{user?.username}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">Dark</span>
|
||||||
|
<Switch checked={darkMode} onCheckedChange={setDarkMode} />
|
||||||
|
</div>
|
||||||
|
<Button variant="destructive" onClick={logout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {Link, useLocation} from "react-router-dom";
|
||||||
|
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
name: "Department",
|
||||||
|
path: "/department",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Doctor",
|
||||||
|
path: "/doctor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blog",
|
||||||
|
path: "/blog",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 border-r bg-card">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold">GG Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<nav className="p-4 space-y-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const active = location.pathname === item.path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={item.path} to={item.path}>
|
||||||
|
<Button
|
||||||
|
variant={active ? "secondary" : "ghost"}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
103
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
193
frontend/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
163
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-base leading-none font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
156
frontend/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
19
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
22
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
87
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
53
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
26
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
31
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {Switch as SwitchPrimitive} from "radix-ui";
|
||||||
|
|
||||||
|
import {cn} from "@/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {Switch};
|
||||||
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
62
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {createContext, useContext, useState} from "react";
|
||||||
|
import api from "@/services/api";
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
user: any;
|
||||||
|
token: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({children}: {children: React.ReactNode}) {
|
||||||
|
const [token, setToken] = useState<string | null>(
|
||||||
|
localStorage.getItem("token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
|
||||||
|
async function login(username: string, password: string) {
|
||||||
|
const response = await api.post("/auth/login", {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = response.data.token;
|
||||||
|
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used inside AuthProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
124
frontend/src/index.css
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/src/layouts/DashboardLayout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {Outlet} from "react-router-dom";
|
||||||
|
|
||||||
|
import Sidebar from "@/components/layout/Sidebar";
|
||||||
|
import Header from "@/components/layout/Header";
|
||||||
|
|
||||||
|
export default function DashboardLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
import {AuthProvider} from "./context/AuthContext";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
182
frontend/src/pages/Blog.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import {useState, useEffect, useCallback} from "react";
|
||||||
|
import {AxiosError} from "axios";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
|
||||||
|
import {getAllBlogsApi, deleteBlogApi} from "@/api/blog";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
|
||||||
|
import {Loader2, RefreshCw, Plus, Pencil, Trash} from "lucide-react";
|
||||||
|
|
||||||
|
interface Blog {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
writer: string;
|
||||||
|
image: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlogPage() {
|
||||||
|
const [blogs, setBlogs] = useState<Blog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const fetchBlogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAllBlogsApi();
|
||||||
|
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
setBlogs(res);
|
||||||
|
} else {
|
||||||
|
setBlogs([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
setError(err.response?.data?.message || "Failed to load blogs");
|
||||||
|
} else {
|
||||||
|
setError("Something went wrong");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBlogs();
|
||||||
|
}, [fetchBlogs]);
|
||||||
|
|
||||||
|
const filteredBlogs = blogs.filter((b) => {
|
||||||
|
const text = searchText.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
b.title?.toLowerCase().includes(text) ||
|
||||||
|
b.writer?.toLowerCase().includes(text)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
const confirmDelete = confirm("Delete this blog?");
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteBlogApi(id);
|
||||||
|
fetchBlogs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Blogs</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search blog..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[220px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={fetchBlogs} disabled={loading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={() => navigate("/blog/create")}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Blog
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-red-600 bg-red-50 border rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Blog List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="border rounded-md overflow-x-auto max-w-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Writer</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredBlogs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center">
|
||||||
|
No blogs found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredBlogs.map((blog) => (
|
||||||
|
<TableRow key={blog.id}>
|
||||||
|
<TableCell>{blog.id}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{blog.title}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{blog.writer}</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/blog/edit/${blog.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(blog.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
frontend/src/pages/BlogEditor.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
|
|
||||||
|
import EditorJS, {OutputData} from "@editorjs/editorjs";
|
||||||
|
import Header from "@editorjs/header";
|
||||||
|
import List from "@editorjs/list";
|
||||||
|
import ImageTool from "@editorjs/image";
|
||||||
|
import Quote from "@editorjs/quote";
|
||||||
|
import Table from "@editorjs/table";
|
||||||
|
import CodeTool from "@editorjs/code";
|
||||||
|
import Embed from "@editorjs/embed";
|
||||||
|
import Delimiter from "@editorjs/delimiter";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBlogApi,
|
||||||
|
updateBlogApi,
|
||||||
|
getBlogByIdApi,
|
||||||
|
uploadImageApi,
|
||||||
|
} from "@/api/blog";
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function BlogEditorPage() {
|
||||||
|
const {id} = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const editorRef = useRef<EditorJS | null>(null);
|
||||||
|
const hasInitialized = useRef(false);
|
||||||
|
const hasRenderedContent = useRef(false);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [writer, setWriter] = useState("");
|
||||||
|
const [coverImage, setCoverImage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasInitialized.current) return;
|
||||||
|
hasInitialized.current = true;
|
||||||
|
|
||||||
|
let editor: EditorJS;
|
||||||
|
|
||||||
|
const initEditor = async () => {
|
||||||
|
editor = new EditorJS({
|
||||||
|
holder: "editorjs",
|
||||||
|
|
||||||
|
placeholder: "Write blog content...",
|
||||||
|
|
||||||
|
tools: {
|
||||||
|
header: {
|
||||||
|
class: Header,
|
||||||
|
inlineToolbar: true,
|
||||||
|
config: {
|
||||||
|
placeholder: "Enter heading",
|
||||||
|
levels: [1, 2, 3, 4],
|
||||||
|
defaultLevel: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list: {
|
||||||
|
class: List,
|
||||||
|
inlineToolbar: true,
|
||||||
|
config: {
|
||||||
|
defaultStyle: "unordered",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
quote: Quote,
|
||||||
|
table: Table,
|
||||||
|
code: CodeTool,
|
||||||
|
embed: Embed,
|
||||||
|
delimiter: Delimiter,
|
||||||
|
|
||||||
|
image: {
|
||||||
|
class: ImageTool,
|
||||||
|
config: {
|
||||||
|
uploader: {
|
||||||
|
uploadByFile: async (file: File) => {
|
||||||
|
const res = await uploadImageApi(file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: 1,
|
||||||
|
file: {url: res.file.url},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await editor.isReady;
|
||||||
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
if (isEdit && id && !hasRenderedContent.current) {
|
||||||
|
try {
|
||||||
|
const res = await getBlogByIdApi(Number(id));
|
||||||
|
|
||||||
|
setTitle(res.title);
|
||||||
|
setWriter(res.writer);
|
||||||
|
setCoverImage(res.image || "");
|
||||||
|
|
||||||
|
if (res.content) {
|
||||||
|
await editor.blocks.clear();
|
||||||
|
await editor.render(res.content);
|
||||||
|
hasRenderedContent.current = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initEditor();
|
||||||
|
}, [id, isEdit]);
|
||||||
|
|
||||||
|
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await uploadImageApi(file);
|
||||||
|
setCoverImage(res.file.url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content: OutputData = await editorRef.current.save();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
writer,
|
||||||
|
image: coverImage,
|
||||||
|
content,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
await updateBlogApi(Number(id), payload);
|
||||||
|
} else {
|
||||||
|
await createBlogApi(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate("/blog");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{isEdit ? "Edit Blog" : "Create Blog"}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Blog Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Writer Name"
|
||||||
|
value={writer}
|
||||||
|
onChange={(e) => setWriter(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Cover Image</label>
|
||||||
|
|
||||||
|
<Input type="file" onChange={handleCoverUpload} />
|
||||||
|
|
||||||
|
{coverImage && (
|
||||||
|
<img
|
||||||
|
src={coverImage}
|
||||||
|
alt="cover"
|
||||||
|
className="w-full max-h-[250px] object-cover rounded-md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="editorjs"
|
||||||
|
className="border rounded-md p-4 bg-white min-h-[300px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? "Saving..." : "Save Blog"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
frontend/src/pages/Department.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import {useState, useEffect, useCallback} from "react";
|
||||||
|
import {AxiosError} from "axios";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDepartmentsApi,
|
||||||
|
createDepartmentApi,
|
||||||
|
updateDepartmentApi,
|
||||||
|
deleteDepartmentApi,
|
||||||
|
} from "@/api/department";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Textarea} from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import {Loader2, RefreshCw, Plus, Pencil, Trash} from "lucide-react";
|
||||||
|
|
||||||
|
interface Department {
|
||||||
|
departmentId: string;
|
||||||
|
name: string;
|
||||||
|
para1: string;
|
||||||
|
para2: string;
|
||||||
|
para3: string;
|
||||||
|
facilities: string;
|
||||||
|
services: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DepartmentPage() {
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Department | null>(null);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Department>({
|
||||||
|
departmentId: "",
|
||||||
|
name: "",
|
||||||
|
para1: "",
|
||||||
|
para2: "",
|
||||||
|
para3: "",
|
||||||
|
facilities: "",
|
||||||
|
services: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchDepartments = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getDepartmentsApi();
|
||||||
|
setDepartments(res?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
setError(err.response?.data?.message || "Failed to load departments");
|
||||||
|
} else {
|
||||||
|
setError("Something went wrong");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDepartments();
|
||||||
|
}, [fetchDepartments]);
|
||||||
|
|
||||||
|
const filteredDepartments = departments.filter((dep) => {
|
||||||
|
const text = searchText.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
dep.name.toLowerCase().includes(text) ||
|
||||||
|
dep.departmentId.toLowerCase().includes(text) ||
|
||||||
|
dep.para1.toLowerCase().includes(text) ||
|
||||||
|
dep.para2.toLowerCase().includes(text) ||
|
||||||
|
dep.para3.toLowerCase().includes(text) ||
|
||||||
|
dep.facilities.toLowerCase().includes(text) ||
|
||||||
|
dep.services.toLowerCase().includes(text)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChange(
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
) {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditing(null);
|
||||||
|
|
||||||
|
setForm({
|
||||||
|
departmentId: "",
|
||||||
|
name: "",
|
||||||
|
para1: "",
|
||||||
|
para2: "",
|
||||||
|
para3: "",
|
||||||
|
facilities: "",
|
||||||
|
services: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(dep: Department) {
|
||||||
|
setEditing(dep);
|
||||||
|
setForm(dep);
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const {departmentId, ...updateData} = form;
|
||||||
|
await updateDepartmentApi(editing.departmentId, updateData);
|
||||||
|
} else {
|
||||||
|
await createDepartmentApi(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenModal(false);
|
||||||
|
fetchDepartments();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(departmentId: string) {
|
||||||
|
const confirmDelete = confirm("Delete this department?");
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteDepartmentApi(departmentId);
|
||||||
|
fetchDepartments();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* HEADER */}
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Departments</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search department..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[220px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchDepartments}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Department
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-red-600 bg-red-50 border rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Department List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="border rounded-md overflow-x-auto max-w-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Para1</TableHead>
|
||||||
|
<TableHead>Para2</TableHead>
|
||||||
|
<TableHead>Para3</TableHead>
|
||||||
|
<TableHead>Facilities</TableHead>
|
||||||
|
<TableHead>Services</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredDepartments.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center">
|
||||||
|
No departments found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredDepartments.map((dep) => (
|
||||||
|
<TableRow key={dep.departmentId}>
|
||||||
|
<TableCell>{dep.departmentId}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{dep.name}</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="max-w-[300px] whitespace-normal break-words">
|
||||||
|
{dep.para1}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="max-w-[300px] whitespace-normal break-words">
|
||||||
|
{dep.para2}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="max-w-[300px] whitespace-normal break-words">
|
||||||
|
{dep.para3}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="max-w-[300px] whitespace-normal break-words">
|
||||||
|
{dep.facilities}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="max-w-[300px] whitespace-normal break-words">
|
||||||
|
{dep.services}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEdit(dep)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(dep.departmentId)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* MODAL */}
|
||||||
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? "Edit Department" : "Add Department"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-2">
|
||||||
|
<Input
|
||||||
|
name="departmentId"
|
||||||
|
placeholder="Department ID"
|
||||||
|
value={form.departmentId}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!!editing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
placeholder="Department Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="para1"
|
||||||
|
placeholder="Paragraph 1"
|
||||||
|
value={form.para1}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="para2"
|
||||||
|
placeholder="Paragraph 2"
|
||||||
|
value={form.para2}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="para3"
|
||||||
|
placeholder="Paragraph 3"
|
||||||
|
value={form.para3}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="facilities"
|
||||||
|
placeholder="Facilities"
|
||||||
|
value={form.facilities}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="services"
|
||||||
|
placeholder="Services"
|
||||||
|
value={form.services}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpenModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleSubmit}>
|
||||||
|
{editing ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
471
frontend/src/pages/Doctor.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import {useState, useEffect, useCallback} from "react";
|
||||||
|
import {
|
||||||
|
getDoctorsApi,
|
||||||
|
createDoctorApi,
|
||||||
|
updateDoctorApi,
|
||||||
|
deleteDoctorApi,
|
||||||
|
getDoctorTimingApi,
|
||||||
|
} from "@/api/doctor";
|
||||||
|
|
||||||
|
import {getDepartmentsApi} from "@/api/department";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandInput,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Loader2, Plus, Pencil, Trash, RefreshCw} from "lucide-react";
|
||||||
|
|
||||||
|
interface Department {
|
||||||
|
departmentId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DoctorPage() {
|
||||||
|
const [doctors, setDoctors] = useState<any[]>([]);
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [filterDepartment, setFilterDepartment] = useState("");
|
||||||
|
|
||||||
|
const [form, setForm] = useState<any>({
|
||||||
|
doctorId: "",
|
||||||
|
name: "",
|
||||||
|
designation: "",
|
||||||
|
workingStatus: "",
|
||||||
|
qualification: "",
|
||||||
|
departments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [docRes, depRes] = await Promise.all([
|
||||||
|
getDoctorsApi(),
|
||||||
|
getDepartmentsApi(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setDoctors(docRes?.data || []);
|
||||||
|
setDepartments(depRes?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredDoctors = doctors.filter((doc) => {
|
||||||
|
const matchesSearch =
|
||||||
|
doc.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
doc.doctorId.toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
|
||||||
|
const matchesDepartment = filterDepartment
|
||||||
|
? doc.departments?.some((d: any) => d.departmentId === filterDepartment)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
return matchesSearch && matchesDepartment;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChange(e: any) {
|
||||||
|
setForm({...form, [e.target.name]: e.target.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDepartmentChange(depId: string) {
|
||||||
|
const exists = form.departments.find((d: any) => d.departmentId === depId);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
departments: form.departments.filter(
|
||||||
|
(d: any) => d.departmentId !== depId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
departments: [...form.departments, {departmentId: depId, timing: {}}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimingChange(depId: string, day: string, value: string) {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
departments: form.departments.map((d: any) =>
|
||||||
|
d.departmentId === depId
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
timing: {...d.timing, [day]: value},
|
||||||
|
}
|
||||||
|
: d,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({
|
||||||
|
doctorId: "",
|
||||||
|
name: "",
|
||||||
|
designation: "",
|
||||||
|
workingStatus: "",
|
||||||
|
qualification: "",
|
||||||
|
departments: [],
|
||||||
|
});
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(doc: any) {
|
||||||
|
setEditing(doc);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timingRes = await getDoctorTimingApi(doc.doctorId);
|
||||||
|
const timingData = timingRes?.data?.departments || [];
|
||||||
|
|
||||||
|
const mappedDepartments = timingData.map((d: any) => ({
|
||||||
|
departmentId: d.departmentId,
|
||||||
|
timing: d.timing || {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setForm({
|
||||||
|
doctorId: doc.doctorId,
|
||||||
|
name: doc.name,
|
||||||
|
designation: doc.designation,
|
||||||
|
workingStatus: doc.workingStatus,
|
||||||
|
qualification: doc.qualification,
|
||||||
|
departments: mappedDepartments,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpenModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateDoctorApi(editing.doctorId, form);
|
||||||
|
} else {
|
||||||
|
await createDoctorApi(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenModal(false);
|
||||||
|
fetchAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Delete doctor?")) return;
|
||||||
|
await deleteDoctorApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* HEADER */}
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Doctors</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search doctor..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterDepartment}
|
||||||
|
onChange={(e) => setFilterDepartment(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="">All Departments</option>
|
||||||
|
{departments.map((dep) => (
|
||||||
|
<option key={dep.departmentId} value={dep.departmentId}>
|
||||||
|
{dep.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={fetchAll} disabled={loading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Doctor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TABLE */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Doctor List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="min-w-[1000px]">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Designation</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Qualification</TableHead>
|
||||||
|
<TableHead>Departments</TableHead>
|
||||||
|
<TableHead>Timing</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredDoctors.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center">
|
||||||
|
No doctors found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredDoctors.map((doc) => (
|
||||||
|
<TableRow key={doc.doctorId}>
|
||||||
|
<TableCell>{doc.doctorId}</TableCell>
|
||||||
|
<TableCell>{doc.name}</TableCell>
|
||||||
|
<TableCell>{doc.designation}</TableCell>
|
||||||
|
<TableCell>{doc.workingStatus}</TableCell>
|
||||||
|
<TableCell>{doc.qualification}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{doc.departments
|
||||||
|
?.map((d: any) => d.departmentName)
|
||||||
|
.join(", ")}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="max-w-[250px] whitespace-normal">
|
||||||
|
{doc.departments?.map((d: any) => (
|
||||||
|
<div key={d.departmentId}>
|
||||||
|
<b>{d.departmentName}:</b>{" "}
|
||||||
|
{JSON.stringify(d.timing)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEdit(doc)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(doc.doctorId)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* MODAL */}
|
||||||
|
{/* MODAL */}
|
||||||
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||||
|
<DialogContent className="overflow-y-auto max-h-[80vh] ">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit Doctor" : "Add Doctor"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
name="doctorId"
|
||||||
|
placeholder="Doctor ID"
|
||||||
|
value={form.doctorId}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!!editing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
placeholder="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="designation"
|
||||||
|
placeholder="Designation"
|
||||||
|
value={form.designation}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="workingStatus"
|
||||||
|
placeholder="Working Status"
|
||||||
|
value={form.workingStatus}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="qualification"
|
||||||
|
placeholder="Qualification"
|
||||||
|
value={form.qualification}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Departments */}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium mb-2">Departments</p>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button className="w-full justify-between h-auto min-h-[40px]">
|
||||||
|
{form.departments.length > 0 ? (
|
||||||
|
<div className="flex flex-col items-start gap-1 text-left">
|
||||||
|
{form.departments.map((d: any) => {
|
||||||
|
const name = departments.find(
|
||||||
|
(dep) => dep.departmentId === d.departmentId,
|
||||||
|
)?.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={d.departmentId} className="text-sm">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>Select Departments</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-[300px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search department..." />
|
||||||
|
|
||||||
|
<CommandGroup className="max-h-[250px] overflow-y-auto">
|
||||||
|
{departments.map((dep) => {
|
||||||
|
const selected = form.departments.some(
|
||||||
|
(d: any) => d.departmentId === dep.departmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={dep.departmentId}
|
||||||
|
className="flex justify-between"
|
||||||
|
onSelect={() =>
|
||||||
|
handleDepartmentChange(dep.departmentId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{dep.name}</span>
|
||||||
|
{selected && <span>✔</span>}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.departments.map((dep: any) => {
|
||||||
|
const depName = departments.find(
|
||||||
|
(d) => d.departmentId === dep.departmentId,
|
||||||
|
)?.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dep.departmentId}
|
||||||
|
className="tw-border tw-p-3 tw-rounded"
|
||||||
|
>
|
||||||
|
<p className="tw-font-semibold">{depName}</p>
|
||||||
|
|
||||||
|
{[
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
"sunday",
|
||||||
|
"additional",
|
||||||
|
].map((day) => (
|
||||||
|
<Input
|
||||||
|
key={day}
|
||||||
|
placeholder={day}
|
||||||
|
value={dep.timing?.[day] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTimingChange(
|
||||||
|
dep.departmentId,
|
||||||
|
day,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpenModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>
|
||||||
|
{editing ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {useState, ChangeEvent} from "react";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import {useAuth} from "@/context/AuthContext";
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Label} from "@/components/ui/label";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {login} = useAuth();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
await login(username, password);
|
||||||
|
|
||||||
|
navigate("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
setError("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-100">
|
||||||
|
<Card className="w-[420px] shadow-xl border">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<CardTitle className="text-2xl font-semibold">Admin Login</CardTitle>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Enter your credentials to access the dashboard
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-100 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
placeholder="Enter username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setUsername(e.target.value)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
>
|
||||||
|
{loading ? "Logging in..." : "Login"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: "http://localhost:3000/api",
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
4
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
};
|
||||||
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"module": "ESNext",
|
||||||
|
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"strict": false,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{"path": "./tsconfig.node.json"}]
|
||||||
|
}
|
||||||
9
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import {defineConfig} from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||