Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 671a3c4e3a | |||
| e356aa8fd9 | |||
| 740631d376 | |||
| 39e162f65c | |||
| 959440e1c6 | |||
| 809a0a4798 | |||
| 5cf73a6351 | |||
| 7eab5fe3ff | |||
| 16cf582e2c | |||
| 5b4aacda04 | |||
| dc3228a07a | |||
| fd60419c26 | |||
| c21ab02c2a | |||
| c282b1825e | |||
| e74a5b09c2 | |||
| 86afb86d3c | |||
| 0fddd7a656 | |||
| de53008e2d | |||
| 0f6b34487e | |||
| fb298cb846 | |||
| 9c44c66b22 | |||
| 29d2ed6b96 | |||
| c4ebd19c15 | |||
| 1d55cfc4b8 | |||
| 661bf7a77f | |||
| 7bce00800b | |||
| 427775a038 | |||
| 8004a7a21c | |||
| 57f95661cc | |||
| 9d149e6abe | |||
| 2ed1bee149 | |||
| 24a8bc4113 | |||
| f35eab14e6 | |||
| 380cb4d999 | |||
| e546519e7a | |||
| b9f372145b | |||
| de854ed538 | |||
| 8277641077 | |||
| 6e999c36c5 | |||
| 834eaad3c3 | |||
| 1bbf7f9c1c | |||
| 2584539fb0 | |||
| 101c235855 | |||
| b89b2b1ba5 | |||
| c11a3f9a7d | |||
| 763b887d65 | |||
| db8cee836a | |||
| 46bbd8106b | |||
| aaa62ae3f5 | |||
| 9ae190754a | |||
| 9faa512c0b | |||
| 7955465be4 | |||
| 3ac50d4132 | |||
| 1206e51f6d |
@@ -0,0 +1,87 @@
|
|||||||
|
# Docker Setup (Backend + Frontend + PostgreSQL)
|
||||||
|
|
||||||
|
This project provides a complete development environment using **Docker Compose** for:
|
||||||
|
|
||||||
|
- Backend (Node.js / Express / Prisma)
|
||||||
|
- Frontend (Vite / React)
|
||||||
|
- PostgreSQL Database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── backend/
|
||||||
|
├── frontend/
|
||||||
|
├── docker/
|
||||||
|
│ └── dev/
|
||||||
|
│ ├── Dockerfile.main
|
||||||
|
│ └── Dockerfile.frontend
|
||||||
|
├── docker-compose.dev.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Make sure you have installed:
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Backend (`backend/.env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://user:password@db:5432/mydb
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=your_secret_here
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
BYTESCALE_SECRET_API_KEY=your_key
|
||||||
|
POSTMARK_API_KEY=your_key
|
||||||
|
EMAIL_FROM=admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (`frontend/.env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running the Project
|
||||||
|
|
||||||
|
### Start containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stop containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database (PostgreSQL)
|
||||||
|
|
||||||
|
- User: `user`
|
||||||
|
- Password: `password`
|
||||||
|
- DB: `mydb`
|
||||||
|
|
||||||
|
Data is persisted using Docker volume:
|
||||||
|
|
||||||
|
```
|
||||||
|
postgres_data
|
||||||
|
```
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
**GG-Node-Backend**
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
Runtime: Node.js (ES Modules)
|
||||||
|
Framework: Express.js (v5.x)
|
||||||
|
ORM: Prisma (PostgreSQL)
|
||||||
|
Storage: Bytescale (Image uploads)
|
||||||
|
Auth: JSON Web Tokens (JWT) & Bcrypt
|
||||||
|
Email: Postmark
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
backend/
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma
|
||||||
|
├──── src/
|
||||||
|
│ ├── app.js
|
||||||
|
│ ├── controllers/
|
||||||
|
│ ├── middlewares/
|
||||||
|
│ ├── routes/
|
||||||
|
│ ├── prisma/
|
||||||
|
│ └── utils/
|
||||||
|
├── .env
|
||||||
|
└── package.json
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
**1. Prerequisites**
|
||||||
|
Node.js (v18+)
|
||||||
|
PostgreSQL Database
|
||||||
|
|
||||||
|
**2. Environment Variables**
|
||||||
|
DATABASE_URL=""
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=""
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3001 http://localhost:3003 http://localhost:5174 http://localhost:5173
|
||||||
|
BYTESCALE_SECRET_API_KEY=""
|
||||||
|
POSTMARK_API_KEY=""
|
||||||
|
|
||||||
|
**3. Install Dependencies**
|
||||||
|
npm install
|
||||||
|
|
||||||
|
**4. Database Initialization**
|
||||||
|
|
||||||
|
# Generate Prisma Client
|
||||||
|
|
||||||
|
npm run generate
|
||||||
|
|
||||||
|
# Run migrations to create database tables
|
||||||
|
|
||||||
|
npm run migrate
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
1. npm start: Runs the server in production mode.
|
||||||
|
2. npm run migrate: Syncs your local database with the current Prisma schema.
|
||||||
|
3. npm run generate: Regenerates the Prisma Client (run this after schema changes).
|
||||||
|
4. npx prisma studio: Opens a visual editor to view and manage your database data.
|
||||||
Generated
+2325
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bytescale/sdk": "^3.53.0",
|
||||||
"@editorjs/editorjs": "^2.31.4",
|
"@editorjs/editorjs": "^2.31.4",
|
||||||
"@editorjs/header": "^2.8.8",
|
"@editorjs/header": "^2.8.8",
|
||||||
"@editorjs/list": "^2.0.9",
|
"@editorjs/list": "^2.0.9",
|
||||||
@@ -27,7 +28,10 @@
|
|||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"prisma": "^6.19.2"
|
"node-fetch": "^3.3.2",
|
||||||
|
"postmark": "^4.0.7",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
|
"slugify": "^1.6.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.11"
|
"nodemon": "^3.1.11"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EmailConfig" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NewsMedia" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"headline" TEXT NOT NULL,
|
||||||
|
"content" TEXT,
|
||||||
|
"firstPara" TEXT,
|
||||||
|
"secondPara" TEXT,
|
||||||
|
"author" TEXT,
|
||||||
|
"date" TIMESTAMP(3),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "NewsMedia_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Doctor" ADD COLUMN "image" TEXT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `slug` to the `Blog` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Blog" ADD COLUMN "slug" TEXT NOT NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[slug]` on the table `Blog` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Blog_slug_key" ON "Blog"("slug");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Department" ADD COLUMN "image" TEXT;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NewsImage" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"newsMediaId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "NewsImage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NewsImage" ADD CONSTRAINT "NewsImage_newsMediaId_fkey" FOREIGN KEY ("newsMediaId") REFERENCES "NewsMedia"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -22,21 +21,24 @@ model Doctor {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
doctorId String @unique
|
doctorId String @unique
|
||||||
name String
|
name String
|
||||||
|
image 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
|
||||||
|
image String?
|
||||||
|
|
||||||
|
|
||||||
para1 String?
|
para1 String?
|
||||||
para2 String?
|
para2 String?
|
||||||
@@ -45,6 +47,7 @@ model Department {
|
|||||||
services String?
|
services String?
|
||||||
|
|
||||||
doctors DoctorDepartment[]
|
doctors DoctorDepartment[]
|
||||||
|
appointments Appointment[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -86,7 +89,6 @@ model DoctorTiming {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
model Blog {
|
model Blog {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
@@ -94,7 +96,121 @@ model Blog {
|
|||||||
image String?
|
image String?
|
||||||
content Json
|
content Json
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
model EmailConfig {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
email String
|
||||||
|
type String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model NewsMedia {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
|
headline String
|
||||||
|
content String?
|
||||||
|
firstPara String?
|
||||||
|
secondPara String?
|
||||||
|
author String?
|
||||||
|
date DateTime?
|
||||||
|
images NewsImage[]
|
||||||
|
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model NewsImage {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
url String
|
||||||
|
newsMediaId Int
|
||||||
|
newsMedia NewsMedia @relation(fields: [newsMediaId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ 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";
|
||||||
|
import emailConfigRoutes from "./routes/emailConfig.routes.js";
|
||||||
|
import newsMediaRoutes from "./routes/newsMedia.routes.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -35,6 +43,14 @@ 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);
|
||||||
|
app.use("/api/email", emailConfigRoutes);
|
||||||
|
app.use("/api/newsMedia", newsMediaRoutes);
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
import { sendEmail } from "../utils/sendEmail.js";
|
||||||
|
import { getEmailsByType } from "../utils/getEmailByTypes.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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailList = await getEmailsByType("ACADEMICS");
|
||||||
|
|
||||||
|
if (emailList && emailList.length > 0) {
|
||||||
|
await sendEmail({
|
||||||
|
to: emailList,
|
||||||
|
subject: "New Academics & Research Inquiry",
|
||||||
|
html: `
|
||||||
|
<h2>New Academics & Research Inquiry</h2>
|
||||||
|
|
||||||
|
<p><b>Name:</b> ${fullName}</p>
|
||||||
|
<p><b>Phone:</b> ${number}</p>
|
||||||
|
<p><b>Email:</b> ${emailId || "-"}</p>
|
||||||
|
|
||||||
|
<p><b>Course:</b> ${courseName || "-"}</p>
|
||||||
|
<p><b>Subject:</b> ${subject || "-"}</p>
|
||||||
|
|
||||||
|
<p><b>Message:</b></p>
|
||||||
|
<p>${message || "-"}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Academics email failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
import {sendEmail} from "../utils/sendEmail.js";
|
||||||
|
import {getEmailsByType} from "../utils/getEmailByTypes.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailList = await getEmailsByType("APPOINTMENT");
|
||||||
|
|
||||||
|
if (emailList) {
|
||||||
|
await sendEmail({
|
||||||
|
to: emailList,
|
||||||
|
subject: "New Appointment Booked",
|
||||||
|
html: `
|
||||||
|
<h2>New Appointment Booked</h2>
|
||||||
|
<p><b>Name:</b> ${name}</p>
|
||||||
|
<p><b>Phone:</b> ${mobileNumber}</p>
|
||||||
|
<p><b>Email:</b> ${email || "-"}</p>
|
||||||
|
<p><b>Doctor:</b> ${appointment.doctor?.name}</p>
|
||||||
|
<p><b>Department:</b> ${appointment.department?.name}</p>
|
||||||
|
<p><b>Date:</b> ${new Date(date).toLocaleDateString()}</p>
|
||||||
|
<p><b>Message:</b> ${message || "-"}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Email failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from "../prisma/client.js";
|
import prisma from "../prisma/client.js";
|
||||||
|
import slugify from "slugify";
|
||||||
|
|
||||||
/* CREATE BLOG */
|
/* CREATE BLOG */
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export async function createBlog(req, res) {
|
|||||||
image,
|
image,
|
||||||
content,
|
content,
|
||||||
isActive,
|
isActive,
|
||||||
|
slug: slugify(title),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +56,26 @@ export async function getAllBlogs(req, res) {
|
|||||||
/* GET SINGLE BLOG */
|
/* GET SINGLE BLOG */
|
||||||
|
|
||||||
export async function getBlog(req, res) {
|
export async function getBlog(req, res) {
|
||||||
|
try {
|
||||||
|
const slug = req.params.slug;
|
||||||
|
|
||||||
|
const blog = await prisma.blog.findUnique({
|
||||||
|
where: {slug},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
return res.status(404).json({error: "Blog not found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(blog);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET SINGLE BLOG (ADMIN)*/
|
||||||
|
|
||||||
|
export async function getBlogForAdmin(req, res) {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
import { sendEmail } from "../utils/sendEmail.js";
|
||||||
|
import { getEmailsByType } from "../utils/getEmailByTypes.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),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
career: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailList = await getEmailsByType("CANDIDATE");
|
||||||
|
|
||||||
|
if (emailList && emailList.length > 0) {
|
||||||
|
await sendEmail({
|
||||||
|
to: emailList,
|
||||||
|
subject: "New Job Application Received",
|
||||||
|
html: `
|
||||||
|
<h2>New Candidate Application</h2>
|
||||||
|
|
||||||
|
<p><b>Name:</b> ${fullName}</p>
|
||||||
|
<p><b>Phone:</b> ${mobile}</p>
|
||||||
|
<p><b>Email:</b> ${email}</p>
|
||||||
|
|
||||||
|
<p><b>Applied For:</b> ${candidate.career?.post || "-"}</p>
|
||||||
|
<p><b>Designation:</b> ${candidate.career?.designation || "-"}</p>
|
||||||
|
|
||||||
|
<p><b>Subject:</b> ${subject || "-"}</p>
|
||||||
|
<p><b>Cover Letter:</b></p>
|
||||||
|
<p>${coverLetter || "-"}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Candidate email failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,8 @@ 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,
|
||||||
|
image: dep.image ?? "",
|
||||||
para1: dep.para1 ?? "",
|
para1: dep.para1 ?? "",
|
||||||
para2: dep.para2 ?? "",
|
para2: dep.para2 ?? "",
|
||||||
para3: dep.para3 ?? "",
|
para3: dep.para3 ?? "",
|
||||||
@@ -29,10 +30,66 @@ export const getAllDepartments = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDepartmentByName = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {name} = req.query;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Department name is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = await prisma.department.findFirst({
|
||||||
|
where: {
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Department not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
departmentId: department.departmentId,
|
||||||
|
name: department.name,
|
||||||
|
image: department.image ?? "",
|
||||||
|
para1: department.para1 ?? "",
|
||||||
|
para2: department.para2 ?? "",
|
||||||
|
para3: department.para3 ?? "",
|
||||||
|
facilities: department.facilities ?? "",
|
||||||
|
services: department.services ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 department",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function createDepartment(req, res) {
|
export async function createDepartment(req, res) {
|
||||||
try {
|
try {
|
||||||
const {departmentId, name, para1, para2, para3, facilities, services} =
|
const {
|
||||||
req.body;
|
departmentId,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
para1,
|
||||||
|
para2,
|
||||||
|
para3,
|
||||||
|
facilities,
|
||||||
|
services,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!departmentId || !name) {
|
if (!departmentId || !name) {
|
||||||
return res
|
return res
|
||||||
@@ -44,6 +101,7 @@ export async function createDepartment(req, res) {
|
|||||||
data: {
|
data: {
|
||||||
departmentId,
|
departmentId,
|
||||||
name,
|
name,
|
||||||
|
image,
|
||||||
para1,
|
para1,
|
||||||
para2,
|
para2,
|
||||||
para3,
|
para3,
|
||||||
@@ -64,3 +122,57 @@ 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, image, para1, para2, para3, facilities, services} = req.body;
|
||||||
|
|
||||||
|
const department = await prisma.department.update({
|
||||||
|
where: {departmentId},
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
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) => ({
|
||||||
|
SL_NO: String(index + 1),
|
||||||
|
doctorId: doc.doctorId,
|
||||||
|
name: doc.name,
|
||||||
|
image: doc.image ?? "",
|
||||||
|
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,
|
||||||
|
image: doctor.image ?? "",
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// get doctors by department
|
||||||
|
export const getDoctorsByDepartmentId = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {Department_ID} = req.query;
|
||||||
|
|
||||||
|
if (!Department_ID) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Department_ID is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = await prisma.department.findUnique({
|
||||||
|
where: {departmentId: Department_ID},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Department not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctors = await prisma.doctorDepartment.findMany({
|
||||||
|
where: {departmentId: department.id},
|
||||||
|
include: {
|
||||||
|
doctor: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = doctors.map((d) => ({
|
||||||
|
GG_ID: d.doctor.doctorId,
|
||||||
|
Name: d.doctor.name,
|
||||||
|
image: d.doctor.image ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch doctors",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// add doctors
|
||||||
|
export const createDoctor = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
doctorId,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
designation,
|
||||||
|
workingStatus,
|
||||||
|
qualification,
|
||||||
|
departments,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const doctor = await prisma.doctor.create({
|
||||||
|
data: {
|
||||||
|
doctorId,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
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,
|
||||||
|
image,
|
||||||
|
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, image, 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) {
|
||||||
|
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
|
||||||
|
dep.timing;
|
||||||
|
|
||||||
|
await prisma.doctorTiming.create({
|
||||||
|
data: {
|
||||||
|
doctorDepartmentId: doctorDepartment.id,
|
||||||
|
...cleanTiming,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json({success: true, message: "Doctor updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update 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;
|
||||||
|
|
||||||
|
const doctor = await prisma.doctor.findUnique({
|
||||||
|
where: {doctorId},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doctor) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Doctor 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 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) => {
|
||||||
|
const 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) => ({
|
||||||
|
departmentId: d.department.departmentId,
|
||||||
|
departmentName: d.department.name,
|
||||||
|
timing: d.timing || {},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
export const createEmailConfig = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {name, email, type, isActive} = req.body;
|
||||||
|
|
||||||
|
if (!name || !email || !type) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Name, Email and Type are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEmail = await prisma.emailConfig.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
type,
|
||||||
|
isActive: isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Email config created",
|
||||||
|
data: newEmail,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create email config",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//GET ALL
|
||||||
|
export const getEmailConfigs = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const emails = await prisma.emailConfig.findMany({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: emails,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch email configs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET SINGLE
|
||||||
|
export const getEmailConfig = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const email = await prisma.emailConfig.findUnique({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Email config not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: email,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch email config",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
export const updateEmailConfig = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
const updated = await prisma.emailConfig.update({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Email config updated",
|
||||||
|
data: updated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update email config",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
export const deleteEmailConfig = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
await prisma.emailConfig.delete({
|
||||||
|
where: {
|
||||||
|
id: Number(id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Email config deleted",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete email config",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
import {sendEmail} from "../utils/sendEmail.js";
|
||||||
|
import {getEmailsByType} from "../utils/getEmailByTypes.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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const emailList = await getEmailsByType("INQUIRY");
|
||||||
|
|
||||||
|
if (emailList && emailList.length > 0) {
|
||||||
|
await sendEmail({
|
||||||
|
to: emailList,
|
||||||
|
subject: "New Inquiry Received",
|
||||||
|
html: `
|
||||||
|
<h2>New Inquiry</h2>
|
||||||
|
|
||||||
|
<p><b>Name:</b> ${fullName}</p>
|
||||||
|
<p><b>Phone:</b> ${number}</p>
|
||||||
|
<p><b>Email:</b> ${emailId}</p>
|
||||||
|
|
||||||
|
<p><b>Subject:</b> ${subject}</p>
|
||||||
|
<p><b>Message:</b> ${message}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Inquiry email failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
// GET ALL NEWS
|
||||||
|
|
||||||
|
export const getAllNews = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page);
|
||||||
|
const limit = parseInt(req.query.limit);
|
||||||
|
|
||||||
|
const includeImages = {
|
||||||
|
images: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!page && !limit) {
|
||||||
|
const news = await prisma.newsMedia.findMany({
|
||||||
|
include: includeImages,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = news.map((n) => ({
|
||||||
|
Id: n.id.toString(),
|
||||||
|
Headline: n.headline,
|
||||||
|
Content: n.content,
|
||||||
|
FirstPara: n.firstPara,
|
||||||
|
SecondPara: n.secondPara,
|
||||||
|
Date: n.date,
|
||||||
|
Author: n.author,
|
||||||
|
Images: n.images.map((img) => ({
|
||||||
|
id: img.id,
|
||||||
|
image: img.url,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
meta: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = page || 1;
|
||||||
|
const currentLimit = limit || 10;
|
||||||
|
|
||||||
|
const skip = (currentPage - 1) * currentLimit;
|
||||||
|
|
||||||
|
const [news, total] = await Promise.all([
|
||||||
|
prisma.newsMedia.findMany({
|
||||||
|
include: includeImages,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: currentLimit,
|
||||||
|
}),
|
||||||
|
prisma.newsMedia.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = news.map((n) => ({
|
||||||
|
Id: n.id.toString(),
|
||||||
|
Headline: n.headline,
|
||||||
|
Content: n.content,
|
||||||
|
FirstPara: n.firstPara,
|
||||||
|
SecondPara: n.secondPara,
|
||||||
|
Date: n.date,
|
||||||
|
Author: n.author,
|
||||||
|
Images: n.images.map((img) => ({
|
||||||
|
id: img.id,
|
||||||
|
image: img.url,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page: currentPage,
|
||||||
|
limit: currentLimit,
|
||||||
|
totalPages: Math.ceil(total / currentLimit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to fetch news",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET NEWS BY ID
|
||||||
|
|
||||||
|
export const getNewsById = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const n = await prisma.newsMedia.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: { images: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!n) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "News not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
Id: n.id.toString(),
|
||||||
|
Headline: n.headline,
|
||||||
|
Content: n.content,
|
||||||
|
FirstPara: n.firstPara,
|
||||||
|
SecondPara: n.secondPara,
|
||||||
|
Date: n.date,
|
||||||
|
Author: n.author,
|
||||||
|
Images: n.images.map((img) => ({
|
||||||
|
id: img.id,
|
||||||
|
image: img.url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 news",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// CREATE NEWS
|
||||||
|
|
||||||
|
export const createNews = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
headline,
|
||||||
|
content,
|
||||||
|
firstPara,
|
||||||
|
secondPara,
|
||||||
|
date,
|
||||||
|
author,
|
||||||
|
imageUrls,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!headline) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Headline is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const news = await prisma.newsMedia.create({
|
||||||
|
data: {
|
||||||
|
headline,
|
||||||
|
content,
|
||||||
|
firstPara,
|
||||||
|
secondPara,
|
||||||
|
date: date ? new Date(date) : null,
|
||||||
|
author,
|
||||||
|
images: imageUrls
|
||||||
|
? {
|
||||||
|
create: imageUrls.map((url) => ({ url })),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: { images: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "News created successfully",
|
||||||
|
data: news,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create news",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPDATE NEWS
|
||||||
|
|
||||||
|
export const updateNews = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { imageUrls, ...otherData } = req.body;
|
||||||
|
|
||||||
|
const news = await prisma.newsMedia.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
...otherData,
|
||||||
|
date: req.body.date ? new Date(req.body.date) : undefined,
|
||||||
|
images: imageUrls
|
||||||
|
? {
|
||||||
|
deleteMany: {},
|
||||||
|
create: imageUrls.map((url) => ({ url })),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: { images: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "News updated successfully",
|
||||||
|
data: news,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update news",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE NEWS
|
||||||
|
|
||||||
|
export const deleteNews = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await prisma.newsMedia.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "News deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete news",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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", jwtAuthMiddleware, getAcademicsResearch);
|
||||||
|
router.get("/:id", jwtAuthMiddleware, getSingleAcademicsResearch);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteAcademicsResearch);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -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", jwtAuthMiddleware, getAppointments);
|
||||||
|
router.post("/", createAppointment);
|
||||||
|
|
||||||
|
router.get("/:id", jwtAuthMiddleware, getAppointment);
|
||||||
|
router.patch("/:id", jwtAuthMiddleware, updateAppointment);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteAppointment);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
updateBlog,
|
updateBlog,
|
||||||
deleteBlog,
|
deleteBlog,
|
||||||
getAllBlogs,
|
getAllBlogs,
|
||||||
|
getBlogForAdmin,
|
||||||
} from "../controllers/blog.controller.js";
|
} from "../controllers/blog.controller.js";
|
||||||
|
|
||||||
import jwtAuthMiddleware from "../middleware/auth.js";
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
@@ -15,11 +16,14 @@ const router = express.Router();
|
|||||||
/* PUBLIC */
|
/* PUBLIC */
|
||||||
|
|
||||||
router.get("/", getBlogs);
|
router.get("/", getBlogs);
|
||||||
router.get("/:id", getBlog);
|
router.get("/:slug", getBlog);
|
||||||
|
|
||||||
// Protected
|
// Protected
|
||||||
|
|
||||||
router.get("/admin/all", jwtAuthMiddleware, getAllBlogs);
|
router.get("/admin/all", jwtAuthMiddleware, getAllBlogs);
|
||||||
|
|
||||||
|
router.get("/admin/:id", jwtAuthMiddleware, getBlogForAdmin);
|
||||||
|
|
||||||
router.post("/", jwtAuthMiddleware, createBlog);
|
router.post("/", jwtAuthMiddleware, createBlog);
|
||||||
router.put("/:id", jwtAuthMiddleware, updateBlog);
|
router.put("/:id", jwtAuthMiddleware, updateBlog);
|
||||||
router.delete("/:id", jwtAuthMiddleware, deleteBlog);
|
router.delete("/:id", jwtAuthMiddleware, deleteBlog);
|
||||||
|
|||||||
@@ -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.post("/", createCandidate);
|
||||||
|
|
||||||
|
router.get("/getAll", jwtAuthMiddleware, getCandidates);
|
||||||
|
router.get("/:id", jwtAuthMiddleware, getCandidate);
|
||||||
|
router.get("/career/:careerId", jwtAuthMiddleware, getCandidatesByCareer);
|
||||||
|
|
||||||
|
router.patch("/:id", jwtAuthMiddleware, updateCandidate);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteCandidate);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getAllCareers,
|
||||||
|
createCareer,
|
||||||
|
updateCareer,
|
||||||
|
deleteCareer,
|
||||||
|
} from "../controllers/career.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/getAll", getAllCareers);
|
||||||
|
|
||||||
|
router.post("/", jwtAuthMiddleware, createCareer);
|
||||||
|
router.patch("/:id", jwtAuthMiddleware, updateCareer);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteCareer);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import {
|
import {
|
||||||
getAllDepartments,
|
getAllDepartments,
|
||||||
|
getDepartmentByName,
|
||||||
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";
|
||||||
|
|
||||||
@@ -9,8 +12,11 @@ const router = express.Router();
|
|||||||
|
|
||||||
// Public
|
// Public
|
||||||
router.get("/getAll", getAllDepartments);
|
router.get("/getAll", getAllDepartments);
|
||||||
|
router.get("/search", getDepartmentByName);
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getAllDoctors,
|
||||||
|
createDoctor,
|
||||||
|
updateDoctor,
|
||||||
|
deleteDoctor,
|
||||||
|
getDoctorTimings,
|
||||||
|
getDoctorTimingById,
|
||||||
|
getDoctorByDoctorId,
|
||||||
|
getDoctorsByDepartmentId,
|
||||||
|
} from "../controllers/doctor.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/getAll", getAllDoctors);
|
||||||
|
router.get("/search", getDoctorsByDepartmentId);
|
||||||
|
router.get("/getTimings", getDoctorTimings);
|
||||||
|
router.get("/getTimings/:doctorId", getDoctorTimingById);
|
||||||
|
router.get("/:doctorId", getDoctorByDoctorId);
|
||||||
|
|
||||||
|
router.post("/", jwtAuthMiddleware, createDoctor);
|
||||||
|
router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor);
|
||||||
|
router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getEmailConfigs,
|
||||||
|
createEmailConfig,
|
||||||
|
updateEmailConfig,
|
||||||
|
deleteEmailConfig,
|
||||||
|
} from "../controllers/emailConfig.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/getAll", getEmailConfigs);
|
||||||
|
|
||||||
|
router.post("/", jwtAuthMiddleware, createEmailConfig);
|
||||||
|
router.patch("/:id", jwtAuthMiddleware, updateEmailConfig);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteEmailConfig);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -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", jwtAuthMiddleware, getInquiries);
|
||||||
|
router.get("/:id", jwtAuthMiddleware, getInquiry);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteInquiry);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
createNews,
|
||||||
|
getAllNews,
|
||||||
|
getNewsById,
|
||||||
|
updateNews,
|
||||||
|
deleteNews,
|
||||||
|
} from "../controllers/newsMedia.controller.js";
|
||||||
|
|
||||||
|
import jwtAuthMiddleware from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// PUBLIC ROUTES
|
||||||
|
router.get("/getAll", getAllNews);
|
||||||
|
router.get("/:id", getNewsById);
|
||||||
|
|
||||||
|
// PROTECTED ROUTES
|
||||||
|
router.post("/", jwtAuthMiddleware, createNews);
|
||||||
|
router.patch("/:id", jwtAuthMiddleware, updateNews);
|
||||||
|
router.delete("/:id", jwtAuthMiddleware, deleteNews);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,15 +1,35 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import {upload} from "../controllers/upload.controller.js";
|
import * as Bytescale from "@bytescale/sdk";
|
||||||
|
import multer from "multer";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/image", upload.single("image"), (req, res) => {
|
const uploadManager = new Bytescale.UploadManager({
|
||||||
res.json({
|
apiKey: process.env.BYTESCALE_SECRET_API_KEY,
|
||||||
success: 1,
|
});
|
||||||
file: {
|
|
||||||
url: `http://localhost:3000/uploads/blog/${req.file.filename}`,
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({storage});
|
||||||
|
|
||||||
|
router.post("/", upload.single("file"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = req.file;
|
||||||
|
const {folderPath} = req.body;
|
||||||
|
|
||||||
|
const result = await uploadManager.upload({
|
||||||
|
data: file.buffer,
|
||||||
|
name: file.originalname,
|
||||||
|
mime: file.mimetype,
|
||||||
|
path: {
|
||||||
|
folderPath: folderPath || "/general",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
res.json({fileUrl: result.fileUrl});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({error: "Upload failed"});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import prisma from "../prisma/client.js";
|
||||||
|
|
||||||
|
export const getEmailsByType = async (type) => {
|
||||||
|
try {
|
||||||
|
const emails = await prisma.emailConfig.findMany({
|
||||||
|
where: {
|
||||||
|
type,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return emails.map((e) => e.email).join(",");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch email config error:", error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import postmark from "postmark";
|
||||||
|
|
||||||
|
const client = new postmark.ServerClient(process.env.POSTMARK_API_KEY);
|
||||||
|
|
||||||
|
export const sendEmail = async ({to, subject, html, text}) => {
|
||||||
|
try {
|
||||||
|
await client.sendEmail({
|
||||||
|
From: process.env.EMAIL_FROM,
|
||||||
|
To: to,
|
||||||
|
Subject: subject,
|
||||||
|
HtmlBody: html,
|
||||||
|
TextBody: text || "",
|
||||||
|
MessageStream: "outbound",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Email send error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/dev/Dockerfile.main
|
||||||
|
ports:
|
||||||
|
- "5000:3000"
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/dev/Dockerfile.frontend
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
env_file:
|
||||||
|
- ./frontend/.env
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: postgres_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: user
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: mydb
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ARG NODE_VERSION=22.11.0
|
||||||
|
FROM node:${NODE_VERSION}-alpine
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
COPY ./frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY ./frontend .
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Serve built app (no hot reload)
|
||||||
|
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
ARG NODE_VERSION=22.11.0
|
||||||
|
FROM node:${NODE_VERSION}-alpine
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Use cache mounts for faster installs
|
||||||
|
RUN --mount=type=bind,source=backend/package.json,target=package.json \
|
||||||
|
--mount=type=bind,source=backend/package-lock.json,target=package-lock.json \
|
||||||
|
--mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Copy the backend source
|
||||||
|
COPY ./backend .
|
||||||
|
|
||||||
|
# Copy and setup entrypoint
|
||||||
|
COPY ./docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
ENTRYPOINT [ "entrypoint.sh" ]
|
||||||
|
|
||||||
|
# This '$@' will be replaced by the CMD
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e # Exit immediately if a command exits with a non-zero status
|
||||||
|
|
||||||
|
echo "Generating Prisma Client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# echo "Running migrate..."
|
||||||
|
# npx prisma migrate deploy
|
||||||
|
echo "Running PUSH..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
echo "Executing command: $@"
|
||||||
|
exec "$@"
|
||||||
|
|
||||||
@@ -22,3 +22,8 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
#env files
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
|||||||
+36
-62
@@ -1,73 +1,47 @@
|
|||||||
# React + TypeScript + Vite
|
**GG-Dashboard**
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
## Tech Stack
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Framework: React 19
|
||||||
|
Build Tool: Vite + TypeScript
|
||||||
|
Styling: Tailwind CSS 4 + shadcn/ui
|
||||||
|
Rich Text: Editor.js
|
||||||
|
State/Fetch: Axios + React Hooks
|
||||||
|
Export: XLSX + File-saver
|
||||||
|
|
||||||
- [@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
|
## Project Structure
|
||||||
- [@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
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── api/
|
||||||
|
│ ├── assets/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── context/
|
||||||
|
│ ├── lib/
|
||||||
|
│ ├── layout/
|
||||||
|
│ ├── pages/
|
||||||
|
│ ├── services/
|
||||||
|
│ ├── utils/
|
||||||
|
│ └── App.tsx
|
||||||
|
├── .env
|
||||||
|
├── index.html
|
||||||
|
└── package.json
|
||||||
|
|
||||||
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).
|
## Installation & Setup
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
**1. Prerequisites**
|
||||||
|
Node.js (v20+)
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
**2. Environment Variables**
|
||||||
|
VITE_API_URL="http://localhost:3000/api"
|
||||||
|
|
||||||
```js
|
**3. Install Dependencies**
|
||||||
export default defineConfig([
|
npm install
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
**4. Development**
|
||||||
tseslint.configs.recommendedTypeChecked,
|
npm run dev
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
## Scripts
|
||||||
],
|
|
||||||
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:
|
npm run dev: Starts the Vite development server with Hot Module Replacement.
|
||||||
|
npm run build: Compiles TypeScript and builds the production-ready assets.
|
||||||
```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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
Generated
+852
-584
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -10,10 +10,22 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
@@ -21,21 +33,24 @@
|
|||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"shadcn": "^4.0.5",
|
"shadcn": "^4.0.5",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/estree": "^1.0.8",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"autoprefixer": "^10.4.27",
|
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^3.4.19",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
+42
-30
@@ -1,45 +1,57 @@
|
|||||||
import {BrowserRouter, Routes, Route} from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
|
||||||
import Login from "@/pages/Login";
|
import Login from "@/pages/Login";
|
||||||
import Dashboard from "@/pages/Dashboard";
|
|
||||||
import Blog from "@/pages/Blog";
|
|
||||||
import Department from "@/pages/Department";
|
|
||||||
|
|
||||||
import ProtectedRoute from "./components/ProtectedRoutes/ProtectedRoutes";
|
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";
|
||||||
|
import Appointment from "./pages/Appointment";
|
||||||
|
import EmailPage from "./pages/email";
|
||||||
|
import CareerPage from "./pages/Career";
|
||||||
|
import CandidatePage from "./pages/candidates";
|
||||||
|
import InquiryPage from "./pages/inquiry";
|
||||||
|
import AcademicsPage from "./pages/Academics";
|
||||||
|
import NewsPage from "./pages/newsMedia";
|
||||||
|
import BlogDetail from "./pages/BlogDetails";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route element={<PublicRoute />}>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Login />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route
|
<Route element={<ProtectedRoute />}>
|
||||||
path="/dashboard"
|
<Route element={<DashboardLayout />}>
|
||||||
element={
|
<Route path="/department" element={<Department />} />
|
||||||
<ProtectedRoute>
|
<Route path="/doctor" element={<Doctor />} />
|
||||||
<Dashboard />
|
<Route path="/blog" element={<Blog />} />
|
||||||
</ProtectedRoute>
|
<Route path="/blog/:id" element={<BlogDetail />} />
|
||||||
}
|
<Route path="/blog/create" element={<BlogEditorPage />} />
|
||||||
/>
|
<Route path="/blog/edit/:id" element={<BlogEditorPage />} />
|
||||||
|
<Route path="/appointment" element={<Appointment />} />
|
||||||
|
<Route path="/email" element={<EmailPage />} />
|
||||||
|
<Route path="/career" element={<CareerPage />} />
|
||||||
|
<Route path="/candidate" element={<CandidatePage />} />
|
||||||
|
<Route path="/inquiry" element={<InquiryPage />} />
|
||||||
|
<Route path="/academics" element={<AcademicsPage />} />
|
||||||
|
<Route path="/news" element={<NewsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route
|
<Route path="*" element={<Navigate to="/department" replace />} />
|
||||||
path="/blog"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Blog />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/department"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Department />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export const getAcademicsApi = async () => {
|
||||||
|
const res = await apiClient.get("/academics/getAll");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAcademicsApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/academics/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export const getAppointmentsApi = async () => {
|
||||||
|
const res = await apiClient.get("/appointments/getall");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAppointmentApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/appointments/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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/admin/${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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export const getCandidatesApi = async () => {
|
||||||
|
const res = await apiClient.get("/candidates/getAll");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCandidateApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/candidates/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export const getCareersApi = async () => {
|
||||||
|
const res = await apiClient.get("/careers/getAll");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCareerApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/careers/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import type {InternalAxiosRequestConfig} from "axios";
|
||||||
|
|
||||||
|
const baseURL: string = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: baseURL,
|
||||||
|
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;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
departmentId: string;
|
||||||
|
name: string;
|
||||||
|
image?: 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export interface Doctor {
|
||||||
|
doctorId: string;
|
||||||
|
name: string;
|
||||||
|
image?: 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export interface EmailConfig {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
type: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ALL
|
||||||
|
export const getEmailConfigsApi = async () => {
|
||||||
|
const res = await apiClient.get("/email/getAll");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
export const createEmailConfigApi = async (data: EmailConfig) => {
|
||||||
|
const res = await apiClient.post("/email", data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
export const updateEmailConfigApi = async (
|
||||||
|
id: number,
|
||||||
|
data: Partial<EmailConfig>,
|
||||||
|
) => {
|
||||||
|
const res = await apiClient.patch(`/email/${id}`, data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
export const deleteEmailConfigApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/email/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export const getInquiriesApi = async () => {
|
||||||
|
const res = await apiClient.get("/inquiry/getAll");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteInquiryApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/inquiry/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export const getNewsApi = async (page = 1, limit = 10) => {
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/newsMedia/getAll?page=${page}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNewsApi = async (data: any) => {
|
||||||
|
const res = await apiClient.post("/newsMedia", data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateNewsApi = async (id: number, data: any) => {
|
||||||
|
const res = await apiClient.patch(`/newsMedia/${id}`, data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNewsApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/newsMedia/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {useState, useRef} from "react";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {User, X, Loader2} from "lucide-react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface BytescaleUploaderProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
folderPath: "/doctors" | "/departments" | "/news" | "/blog";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BytescaleUploader({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
folderPath,
|
||||||
|
}: BytescaleUploaderProps) {
|
||||||
|
const baseURL = import.meta.env.VITE_API_URL;
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const onFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert("File is too large (Max 5MB)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folderPath", folderPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${baseURL}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {fileUrl} = response.data;
|
||||||
|
onChange(fileUrl);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Upload Error:", e);
|
||||||
|
const errorMessage =
|
||||||
|
e.response?.data?.error || e.message || "Upload failed";
|
||||||
|
alert(`Upload Error: ${errorMessage}`);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 border rounded-md bg-muted/5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
{value ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
className="w-16 h-16 rounded-full object-cover border-2 border-primary/20"
|
||||||
|
alt="Preview"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="absolute -top-1 -right-1 bg-destructive text-white rounded-full p-0.5 shadow-sm hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
) : (
|
||||||
|
<User className="w-8 h-8 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={onFileSelected}
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : value ? (
|
||||||
|
"Change Photo"
|
||||||
|
) : (
|
||||||
|
"Upload Photo"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<p className="text-xs text-amber-600 pl-[72px]">
|
||||||
|
⚠️ Make sure to save the changes by clicking the "Save Changes"
|
||||||
|
button.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import Sidebar from "./Sidebar"
|
|
||||||
|
|
||||||
export default function DashboardLayout({children}:{children:React.ReactNode}){
|
|
||||||
|
|
||||||
return(
|
|
||||||
|
|
||||||
<div className="flex">
|
|
||||||
|
|
||||||
<Sidebar/>
|
|
||||||
|
|
||||||
<div className="flex-1 p-6 bg-slate-50 min-h-screen">
|
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,77 @@
|
|||||||
import {Link} from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
name: "Department",
|
||||||
|
path: "/department",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Doctor",
|
||||||
|
path: "/doctor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Appointments",
|
||||||
|
path: "/appointment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Career",
|
||||||
|
path: "/career",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Candidates",
|
||||||
|
path: "/candidate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Inquiry",
|
||||||
|
path: "/inquiry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Academics & Research",
|
||||||
|
path: "/academics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "News & Media",
|
||||||
|
path: "/news",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Email",
|
||||||
|
path: "/email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blog",
|
||||||
|
path: "/blog",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[220px] h-screen border-r bg-white p-4">
|
<div className="w-64 border-r bg-card">
|
||||||
<h2 className="text-lg font-semibold mb-6">Admin</h2>
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold">GG Dashboard</h2>
|
||||||
<div className="space-y-3">
|
|
||||||
<Link to="/dashboard">Dashboard</Link>
|
|
||||||
|
|
||||||
<Link to="/blog">Blog</Link>
|
|
||||||
|
|
||||||
<Link to="/department">Department</Link>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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};
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
+124
-3
@@ -1,3 +1,124 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
@import "tw-animate-css";
|
||||||
@tailwind utilities;
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
import { getAcademicsApi, deleteAcademicsApi } from "@/api/academics";
|
||||||
|
import { exportToExcel } from "@/utils/exportToExcel";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Trash,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
BookOpen,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function AcademicsPage() {
|
||||||
|
const [records, setRecords] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const [viewOpen, setViewOpen] = useState(false);
|
||||||
|
const [viewData, setViewData] = useState<any>(null);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getAcademicsApi();
|
||||||
|
setRecords(res?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredRecords = records.filter((item) => {
|
||||||
|
return (
|
||||||
|
item.fullName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.number?.includes(searchText) ||
|
||||||
|
item.emailId?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.subject?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.courseName?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredRecords.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredRecords.slice(indexOfFirstItem, indexOfLastItem);
|
||||||
|
|
||||||
|
function openView(item: any) {
|
||||||
|
setViewData(item);
|
||||||
|
setViewOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Delete record?")) return;
|
||||||
|
await deleteAcademicsApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const exportData = filteredRecords.map((item) => ({
|
||||||
|
ID: item.id,
|
||||||
|
Name: item.fullName,
|
||||||
|
Phone: item.number,
|
||||||
|
Email: item.emailId,
|
||||||
|
Course: item.courseName,
|
||||||
|
Subject: item.subject,
|
||||||
|
Message: item.message,
|
||||||
|
Date: new Date(item.createdAt).toLocaleDateString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
exportToExcel(exportData, "academics");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold">Academics & Research</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search name / course / email..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[280px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleExport} className="text-base">
|
||||||
|
<Download className="mr-2 h-5 w-5" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Academic Records</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] bg-background font-bold text-sm">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[220px] bg-background font-bold text-sm">
|
||||||
|
Full Name
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px] bg-background font-bold text-sm">
|
||||||
|
Course
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px] bg-background font-bold text-sm">
|
||||||
|
Subject
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[140px] bg-background font-bold text-sm">
|
||||||
|
Applied Date
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[220px] bg-background font-bold text-sm">
|
||||||
|
Message
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No records found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
currentItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{item.id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold text-base truncate">
|
||||||
|
{item.fullName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{item.emailId}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-medium">
|
||||||
|
{item.number}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm font-medium line-clamp-2">
|
||||||
|
{item.courseName || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm line-clamp-2">
|
||||||
|
{item.subject || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm line-clamp-2 text-muted-foreground italic">
|
||||||
|
{item.message || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openView(item)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && filteredRecords.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-6 border-t">
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{Math.min(indexOfLastItem, filteredRecords.length)}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold">{filteredRecords.length}</span>{" "}
|
||||||
|
records
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
||||||
|
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6" /> Academic Detail View
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewData && (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Applicant Information
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-primary">
|
||||||
|
{viewData.fullName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{viewData.emailId}</p>
|
||||||
|
<p className="text-sm">{viewData.number}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Course & Subject
|
||||||
|
</p>
|
||||||
|
<p className="text-base font-semibold">
|
||||||
|
{viewData.courseName || "N/A"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{viewData.subject}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Submission Date
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{new Date(viewData.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-muted/30 rounded-lg border">
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
|
||||||
|
Message / Research Inquiry
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap italic">
|
||||||
|
{viewData.message || "No message content provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setViewOpen(false)}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
import { getAppointmentsApi, deleteAppointmentApi } from "@/api/appointment";
|
||||||
|
import { exportToExcel } from "@/utils/exportToExcel";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Trash,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function AppointmentPage() {
|
||||||
|
const [appointments, setAppointments] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [filterDoctor, setFilterDoctor] = useState("");
|
||||||
|
const [filterDate, setFilterDate] = useState("");
|
||||||
|
|
||||||
|
const [viewOpen, setViewOpen] = useState(false);
|
||||||
|
const [viewData, setViewData] = useState<any>(null);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getAppointmentsApi();
|
||||||
|
setAppointments(res?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredAppointments = appointments.filter((item) => {
|
||||||
|
const matchesSearch =
|
||||||
|
item.name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.mobileNumber?.includes(searchText) ||
|
||||||
|
item.email?.toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
|
||||||
|
const matchesDoctor = filterDoctor
|
||||||
|
? item.doctor?.name?.toLowerCase().includes(filterDoctor.toLowerCase())
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const matchesDate = filterDate
|
||||||
|
? new Date(item.date).toISOString().split("T")[0] === filterDate
|
||||||
|
: true;
|
||||||
|
|
||||||
|
return matchesSearch && matchesDoctor && matchesDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchText, filterDoctor, filterDate]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredAppointments.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredAppointments.slice(
|
||||||
|
indexOfFirstItem,
|
||||||
|
indexOfLastItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
function openView(item: any) {
|
||||||
|
setViewData(item);
|
||||||
|
setViewOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Delete appointment?")) return;
|
||||||
|
await deleteAppointmentApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const exportData = filteredAppointments.map((item) => ({
|
||||||
|
ID: item.id,
|
||||||
|
Name: item.name,
|
||||||
|
Phone: item.mobileNumber,
|
||||||
|
Email: item.email,
|
||||||
|
Doctor: item.doctor?.name,
|
||||||
|
Department: item.department?.name,
|
||||||
|
Date: new Date(item.date).toLocaleDateString(),
|
||||||
|
Message: item.message,
|
||||||
|
}));
|
||||||
|
exportToExcel(exportData, "appointments");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold">Appointments</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search name / phone..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[220px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filterDate}
|
||||||
|
onChange={(e) => setFilterDate(e.target.value)}
|
||||||
|
className="w-[160px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleExport} className="text-base">
|
||||||
|
<Download className="mr-2 h-5 w-5" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Appointment List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] bg-background font-bold text-sm">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[200px] bg-background font-bold text-sm">
|
||||||
|
Patient
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px] bg-background font-bold text-sm">
|
||||||
|
Doctor
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px] bg-background font-bold text-sm">
|
||||||
|
Date
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[250px] bg-background font-bold text-sm">
|
||||||
|
Message
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No appointments found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
currentItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{item.id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold text-base truncate">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.mobileNumber}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{item.doctor?.name || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{item.department?.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
{new Date(item.date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm line-clamp-2 text-muted-foreground italic">
|
||||||
|
{item.message || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openView(item)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && filteredAppointments.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-6 border-t">
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{Math.min(indexOfLastItem, filteredAppointments.length)}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{filteredAppointments.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
||||||
|
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl border-b pb-2">
|
||||||
|
Appointment Details
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewData && (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Patient Information
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-primary">
|
||||||
|
{viewData.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{viewData.mobileNumber}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{viewData.email || "No email provided"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Appointment Date
|
||||||
|
</p>
|
||||||
|
<p className="text-base font-semibold">
|
||||||
|
{new Date(viewData.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Booked on: {new Date(viewData.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Doctor / Department
|
||||||
|
</p>
|
||||||
|
<p className="text-base font-bold">
|
||||||
|
{viewData.doctor?.name || "Not Assigned"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{viewData.department?.name || "General"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-muted/30 rounded-lg">
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
|
||||||
|
Message from Patient
|
||||||
|
</p>
|
||||||
|
<p className="text-sm italic leading-relaxed whitespace-pre-wrap">
|
||||||
|
{viewData.message || "No message provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setViewOpen(false)}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+184
-8
@@ -1,13 +1,189 @@
|
|||||||
import DashboardLayout from "@/components/layout/DashboardLayout";
|
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, Eye} 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();
|
||||||
|
|
||||||
export default function Blog() {
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
b.title?.toLowerCase().includes(text) ||
|
||||||
<h1 className="text-xl font-semibold mb-4">Blog Management</h1>
|
b.writer?.toLowerCase().includes(text)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
<button className="px-4 py-2 bg-black text-white rounded">
|
async function handleDelete(id: number) {
|
||||||
Create Blog
|
const confirmDelete = confirm("Delete this blog?");
|
||||||
</button>
|
if (!confirmDelete) return;
|
||||||
</DashboardLayout>
|
|
||||||
|
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="secondary"
|
||||||
|
onClick={() => navigate(`/blog/${blog.id}`)}
|
||||||
|
>
|
||||||
|
<Eye 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {useParams, useNavigate} from "react-router-dom";
|
||||||
|
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {getBlogByIdApi} from "@/api/blog";
|
||||||
|
|
||||||
|
/* ---------------- LIST RENDERER ---------------- */
|
||||||
|
const renderList = (items, style) => {
|
||||||
|
// ✅ Checklist
|
||||||
|
if (style === "checklist") {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.meta?.checked || false}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={item.meta?.checked ? "line-through opacity-60" : ""}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: item.content || "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Ordered / Unordered
|
||||||
|
const ListTag = style === "ordered" ? "ol" : "ul";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListTag
|
||||||
|
className={`pl-6 mb-4 ${
|
||||||
|
style === "ordered" ? "list-decimal" : "list-disc"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{items
|
||||||
|
.filter((item) => item.content)
|
||||||
|
.map((item, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: item.content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.items?.length > 0 && renderList(item.items, style)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ListTag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- BLOCK RENDERER ---------------- */
|
||||||
|
const renderBlock = (block, index) => {
|
||||||
|
switch (block.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={index}
|
||||||
|
className="mb-4 leading-7 text-gray-800"
|
||||||
|
dangerouslySetInnerHTML={{__html: block.data.text}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "header":
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
key={index}
|
||||||
|
className="text-2xl font-semibold mb-4"
|
||||||
|
dangerouslySetInnerHTML={{__html: block.data.text}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={block.data.file?.url}
|
||||||
|
alt={block.data.caption || "blog-image"}
|
||||||
|
className="w-full rounded-lg mb-4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
return (
|
||||||
|
<div key={index}>{renderList(block.data.items, block.data.style)}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "quote":
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
key={index}
|
||||||
|
className="border-l-4 border-gray-300 pl-4 italic my-4 text-gray-600"
|
||||||
|
>
|
||||||
|
{block.data.text}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
key={index}
|
||||||
|
className="bg-gray-100 p-4 rounded-md overflow-x-auto text-sm mb-4"
|
||||||
|
>
|
||||||
|
<code>{block.data.code}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "table":
|
||||||
|
return (
|
||||||
|
<div key={index} className="overflow-x-auto mb-6">
|
||||||
|
<table className="w-full border border-gray-200">
|
||||||
|
<tbody>
|
||||||
|
{block.data.content.map((row, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{row.map((cell, j) => (
|
||||||
|
<td
|
||||||
|
key={j}
|
||||||
|
className="border px-3 py-2"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: cell,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "delimiter":
|
||||||
|
return <hr key={index} className="my-6 border-gray-300" />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- MAIN COMPONENT ---------------- */
|
||||||
|
const BlogDetail = () => {
|
||||||
|
const {id} = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [blog, setBlog] = useState(null);
|
||||||
|
|
||||||
|
const fetchBlog = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getBlogByIdApi(Number(id));
|
||||||
|
console.log({res});
|
||||||
|
setBlog(res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching blog", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBlog();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
return <p className="mt-40 text-center">Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex flex-col ">
|
||||||
|
{/* Back Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mb-4 w-fit text-black px-0"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-3xl md:text-5xl font-bold mb-2">{blog.title}</h1>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
{blog.writer} • {new Date(blog.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Image (only if exists) */}
|
||||||
|
{blog.image?.trim() && (
|
||||||
|
<img
|
||||||
|
src={blog.image}
|
||||||
|
alt="blog"
|
||||||
|
className="w-full h-[220px] md:h-[400px] object-cover rounded-lg mb-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div>
|
||||||
|
{blog.content?.blocks?.map((block, index) => renderBlock(block, index))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogDetail;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
|
import {BytescaleUploader} from "@/components/BytescaleUploader/BytescaleUploader";
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
<BytescaleUploader
|
||||||
|
value={coverImage}
|
||||||
|
folderPath="/blog"
|
||||||
|
onChange={(url) => setCoverImage(url)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
import { getCareersApi, deleteCareerApi } from "@/api/career";
|
||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
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 { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function CareerPage() {
|
||||||
|
const [careers, setCareers] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
post: "",
|
||||||
|
designation: "",
|
||||||
|
qualification: "",
|
||||||
|
experienceNeed: "",
|
||||||
|
email: "",
|
||||||
|
number: "",
|
||||||
|
status: "new",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getCareersApi();
|
||||||
|
setCareers(res?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredCareers = careers.filter(
|
||||||
|
(item) =>
|
||||||
|
item.post?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.designation?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredCareers.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredCareers.slice(indexOfFirstItem, indexOfLastItem);
|
||||||
|
|
||||||
|
function handleChange(e: any) {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({
|
||||||
|
post: "",
|
||||||
|
designation: "",
|
||||||
|
qualification: "",
|
||||||
|
experienceNeed: "",
|
||||||
|
email: "",
|
||||||
|
number: "",
|
||||||
|
status: "new",
|
||||||
|
});
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: any) {
|
||||||
|
setEditing(item);
|
||||||
|
setForm({
|
||||||
|
post: item.post || "",
|
||||||
|
designation: item.designation || "",
|
||||||
|
qualification: item.qualification || "",
|
||||||
|
experienceNeed: item.experienceNeed || "",
|
||||||
|
email: item.email || "",
|
||||||
|
number: item.number || "",
|
||||||
|
status: item.status || "new",
|
||||||
|
});
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await apiClient.patch(`/careers/${editing.id}`, form);
|
||||||
|
} else {
|
||||||
|
await apiClient.post("/careers", form);
|
||||||
|
}
|
||||||
|
setOpenModal(false);
|
||||||
|
fetchAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Delete career?")) return;
|
||||||
|
await deleteCareerApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold">Careers</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search post / designation..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[250px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={openAdd} className="text-base">
|
||||||
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
Add Career
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Career Opportunities</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] bg-background font-bold text-sm">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[200px] bg-background font-bold text-sm">
|
||||||
|
Post & Designation
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[200px] bg-background font-bold text-sm">
|
||||||
|
Qualification
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px] bg-background font-bold text-sm">
|
||||||
|
Experience
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[200px] bg-background font-bold text-sm">
|
||||||
|
Contact Info
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[100px] bg-background font-bold text-sm">
|
||||||
|
Status
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No careers found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
currentItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{item.id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold text-base truncate">
|
||||||
|
{item.post}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{item.designation}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm line-clamp-2">
|
||||||
|
{item.qualification}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{item.experienceNeed}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm font-medium">{item.email}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.number}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
item.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && filteredCareers.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-6 border-t">
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{Math.min(indexOfLastItem, filteredCareers.length)}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold">{filteredCareers.length}</span>{" "}
|
||||||
|
careers
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||||
|
<DialogContent className="w-full max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl">
|
||||||
|
{editing ? "Edit Career" : "Add New Career"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Input
|
||||||
|
name="post"
|
||||||
|
placeholder="Post (e.g. Staff Nurse)"
|
||||||
|
value={form.post}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="designation"
|
||||||
|
placeholder="Designation"
|
||||||
|
value={form.designation}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="qualification"
|
||||||
|
placeholder="Qualification"
|
||||||
|
value={form.qualification}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="experienceNeed"
|
||||||
|
placeholder="Experience Needed"
|
||||||
|
value={form.experienceNeed}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="HR Email Address"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="number"
|
||||||
|
placeholder="Contact Number"
|
||||||
|
value={form.number}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="status"
|
||||||
|
placeholder="Status (e.g. active / closed)"
|
||||||
|
value={form.status}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpenModal(false)}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} className="px-8 text-base">
|
||||||
|
{editing ? "Save Changes" : "Create Career"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import DashboardLayout from "@/components/layout/DashboardLayout";
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
return (
|
|
||||||
<DashboardLayout>
|
|
||||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-6">
|
|
||||||
<div className="p-6 bg-white shadow rounded">Blogs</div>
|
|
||||||
|
|
||||||
<div className="p-6 bg-white shadow rounded">Departments</div>
|
|
||||||
|
|
||||||
<div className="p-6 bg-white shadow rounded">Users</div>
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import DashboardLayout from "@/components/layout/DashboardLayout";
|
|
||||||
|
|
||||||
export default function Department() {
|
|
||||||
return (
|
|
||||||
<DashboardLayout>
|
|
||||||
<h1 className="text-xl font-semibold mb-4">Departments</h1>
|
|
||||||
</DashboardLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import {useState, useEffect, useCallback} from "react";
|
||||||
|
import {AxiosError} from "axios";
|
||||||
|
import {BytescaleUploader} from "@/components/BytescaleUploader/BytescaleUploader";
|
||||||
|
|
||||||
|
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,
|
||||||
|
Eye,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Department {
|
||||||
|
departmentId: string;
|
||||||
|
name: string;
|
||||||
|
image?: 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 [viewOpen, setViewOpen] = useState(false);
|
||||||
|
const [viewData, setViewData] = useState<Department | null>(null);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Department>({
|
||||||
|
departmentId: "",
|
||||||
|
name: "",
|
||||||
|
image: "",
|
||||||
|
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) =>
|
||||||
|
dep.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
dep.departmentId.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredDepartments.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredDepartments.slice(
|
||||||
|
indexOfFirstItem,
|
||||||
|
indexOfLastItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(e: any) {
|
||||||
|
setForm({...form, [e.target.name]: e.target.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, limit = 60) {
|
||||||
|
if (!text) return "-";
|
||||||
|
return text.length > limit ? text.substring(0, limit) + "..." : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({
|
||||||
|
departmentId: "",
|
||||||
|
name: "",
|
||||||
|
image: "",
|
||||||
|
para1: "",
|
||||||
|
para2: "",
|
||||||
|
para3: "",
|
||||||
|
facilities: "",
|
||||||
|
services: "",
|
||||||
|
});
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(dep: Department) {
|
||||||
|
setEditing(dep);
|
||||||
|
setForm(dep);
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openView(dep: Department) {
|
||||||
|
setViewData(dep);
|
||||||
|
setViewOpen(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(id: string) {
|
||||||
|
if (!confirm("Delete this department?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteDepartmentApi(id);
|
||||||
|
fetchDepartments();
|
||||||
|
} 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-4">
|
||||||
|
<h1 className="text-3xl 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-[250px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchDepartments}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={openAdd} className="text-base">
|
||||||
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
Add Department
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Department List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[900px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px] bg-background text-sm font-bold">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[200px] bg-background text-sm font-bold">
|
||||||
|
Name
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[250px] bg-background text-sm font-bold">
|
||||||
|
Para 1
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[220px] bg-background text-sm font-bold">
|
||||||
|
Facilities
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[220px] bg-background text-sm font-bold">
|
||||||
|
Services
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[140px] bg-background text-right text-sm font-bold">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No departments found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
currentItems.map((dep) => (
|
||||||
|
<TableRow
|
||||||
|
key={dep.departmentId}
|
||||||
|
className="hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{dep.departmentId}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className="font-semibold text-base truncate"
|
||||||
|
title={dep.name}
|
||||||
|
>
|
||||||
|
{dep.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm break-words whitespace-normal">
|
||||||
|
{truncate(dep.para1)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm break-words whitespace-normal">
|
||||||
|
{truncate(dep.facilities)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm break-words whitespace-normal">
|
||||||
|
{truncate(dep.services)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openView(dep)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openEdit(dep)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(dep.departmentId)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && filteredDepartments.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-6 border-t">
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{Math.min(indexOfLastItem, filteredDepartments.length)}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{filteredDepartments.length}
|
||||||
|
</span>{" "}
|
||||||
|
departments
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||||
|
<DialogContent className="w-full !max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? "Edit Department" : "Add Department"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Department Image</label>
|
||||||
|
<BytescaleUploader
|
||||||
|
value={form.image}
|
||||||
|
folderPath="/departments"
|
||||||
|
onChange={(url) => setForm({...form, image: url})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
name="departmentId"
|
||||||
|
value={form.departmentId}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!!editing}
|
||||||
|
placeholder="Department ID"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Department Name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="para1"
|
||||||
|
value={form.para1}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Para1"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
name="para2"
|
||||||
|
value={form.para2}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Para2"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
name="para3"
|
||||||
|
value={form.para3}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Para3"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
name="facilities"
|
||||||
|
value={form.facilities}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Facilities"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
name="services"
|
||||||
|
value={form.services}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Services"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpenModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>
|
||||||
|
{editing ? "Save Changes" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
||||||
|
<DialogContent className="w-full !max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Department Details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewData && (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<p>
|
||||||
|
<b>ID:</b> {viewData.departmentId}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Name:</b> {viewData.name}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Para1:</b>
|
||||||
|
<br />
|
||||||
|
{viewData.para1}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Para2:</b>
|
||||||
|
<br />
|
||||||
|
{viewData.para2}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Para3:</b>
|
||||||
|
<br />
|
||||||
|
{viewData.para3}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Facilities:</b>
|
||||||
|
<br />
|
||||||
|
{viewData.facilities}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Services:</b>
|
||||||
|
<br />
|
||||||
|
{viewData.services}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setViewOpen(false)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
|
||||||
|
|
||||||
|
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 { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Department {
|
||||||
|
departmentId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
"sunday",
|
||||||
|
"additional",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DoctorPage() {
|
||||||
|
const [doctors, setDoctors] = useState<any[]>([]);
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [filterDepartment, setFilterDepartment] = useState("");
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<any>({
|
||||||
|
doctorId: "",
|
||||||
|
name: "",
|
||||||
|
image: "",
|
||||||
|
designation: "",
|
||||||
|
workingStatus: "",
|
||||||
|
qualification: "",
|
||||||
|
departments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [docRes, depRes] = await Promise.all([
|
||||||
|
getDoctorsApi(),
|
||||||
|
getDepartmentsApi(),
|
||||||
|
]);
|
||||||
|
setDoctors(docRes?.data || []);
|
||||||
|
setDepartments(depRes?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
setError(err.response?.data?.message || "Failed to load data");
|
||||||
|
} else {
|
||||||
|
setError("Something went wrong");
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchText, filterDepartment]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredDoctors.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
|
||||||
|
|
||||||
|
function handleChange(e: any) {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDepartmentToggle(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: "",
|
||||||
|
image: "",
|
||||||
|
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 || [];
|
||||||
|
setForm({
|
||||||
|
doctorId: doc.doctorId,
|
||||||
|
name: doc.name,
|
||||||
|
image: doc.image || "",
|
||||||
|
designation: doc.designation,
|
||||||
|
workingStatus: doc.workingStatus,
|
||||||
|
qualification: doc.qualification,
|
||||||
|
departments: timingData.map((d: any) => ({
|
||||||
|
departmentId: d.departmentId,
|
||||||
|
timing: d.timing || {},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
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 (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Delete this doctor?")) return;
|
||||||
|
try {
|
||||||
|
await deleteDoctorApi(id);
|
||||||
|
fetchAll();
|
||||||
|
} 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-4">
|
||||||
|
<h1 className="text-3xl font-bold">Doctors</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search doctor..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[250px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterDepartment}
|
||||||
|
onChange={(e) => setFilterDepartment(e.target.value)}
|
||||||
|
className="flex h-10 w-[220px] rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={openAdd} className="text-base">
|
||||||
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
Add Doctor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Doctor List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[900px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px] bg-background text-sm font-bold">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[200px] bg-background text-sm font-bold">
|
||||||
|
Name
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px] bg-background text-sm font-bold">
|
||||||
|
Designation
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px] bg-background text-sm font-bold">
|
||||||
|
Qualification
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[220px] bg-background text-sm font-bold">
|
||||||
|
Departments
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px] bg-background text-right text-sm font-bold">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No doctors found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
currentItems.map((doc) => (
|
||||||
|
<TableRow key={doc.doctorId} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="truncate font-mono text-xs">
|
||||||
|
{doc.doctorId}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className="font-semibold text-base truncate"
|
||||||
|
title={doc.name}
|
||||||
|
>
|
||||||
|
{doc.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate italic">
|
||||||
|
{doc.workingStatus}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className="truncate text-sm"
|
||||||
|
title={doc.designation}
|
||||||
|
>
|
||||||
|
{doc.designation || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className="truncate text-sm"
|
||||||
|
title={doc.qualification}
|
||||||
|
>
|
||||||
|
{doc.qualification || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{doc.departments?.map((d: any) => (
|
||||||
|
<Badge
|
||||||
|
key={d.departmentId}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-2 h-5 leading-none"
|
||||||
|
>
|
||||||
|
{d.departmentName}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{doc.departments?.length === 0 && (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openEdit(doc)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(doc.doctorId)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && filteredDoctors.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-6 border-t">
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{Math.min(indexOfLastItem, filteredDoctors.length)}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold">{filteredDoctors.length}</span>{" "}
|
||||||
|
doctors
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||||
|
<DialogContent className="w-full !max-w-5xl h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||||
|
<DialogHeader className="p-6 border-b bg-background z-10">
|
||||||
|
<DialogTitle className="text-2xl">
|
||||||
|
{editing ? "Edit Doctor" : "Add Doctor"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="font-bold text-base border-b pb-2">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
Doctor Photo
|
||||||
|
</label>
|
||||||
|
<BytescaleUploader
|
||||||
|
value={form.image}
|
||||||
|
folderPath="/doctors"
|
||||||
|
onChange={(url) => setForm({ ...form, image: url })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">Doctor ID</label>
|
||||||
|
<Input
|
||||||
|
name="doctorId"
|
||||||
|
placeholder="GG-DOC-001"
|
||||||
|
value={form.doctorId}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!!editing}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">Full Name</label>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
placeholder="Dr. John Doe"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">Designation</label>
|
||||||
|
<Input
|
||||||
|
name="designation"
|
||||||
|
placeholder="Senior Consultant"
|
||||||
|
value={form.designation}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
Working Status
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
name="workingStatus"
|
||||||
|
placeholder="Active / On Call"
|
||||||
|
value={form.workingStatus}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
Qualification
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
name="qualification"
|
||||||
|
placeholder="MBBS, MD"
|
||||||
|
value={form.qualification}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 border rounded-md bg-muted/20">
|
||||||
|
<p className="text-base font-bold mb-4">Assign Departments</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{departments.map((dep) => {
|
||||||
|
const isSelected = form.departments.some(
|
||||||
|
(d: any) => d.departmentId === dep.departmentId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={dep.departmentId}
|
||||||
|
type="button"
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="justify-start text-sm min-h-11 whitespace-normal break-words text-left py-2"
|
||||||
|
onClick={() =>
|
||||||
|
handleDepartmentToggle(dep.departmentId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dep.name}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="font-bold text-base border-b pb-2">
|
||||||
|
Working Hours / Timing
|
||||||
|
</h3>
|
||||||
|
{form.departments.length === 0 ? (
|
||||||
|
<div className="text-base text-muted-foreground italic py-24 text-center border-2 border-dashed rounded-lg">
|
||||||
|
Select a department to configure timing slots
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{form.departments.map((dep: any) => {
|
||||||
|
const depName = departments.find(
|
||||||
|
(d) => d.departmentId === dep.departmentId,
|
||||||
|
)?.name;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dep.departmentId}
|
||||||
|
className="space-y-4 p-5 border rounded-lg bg-background shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-bold text-base text-primary">
|
||||||
|
{depName}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Timing Slot
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<div key={day} className="space-y-1">
|
||||||
|
<label className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
{day}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
className="h-9 text-sm"
|
||||||
|
placeholder="e.g. 09:00 AM - 01:00 PM"
|
||||||
|
value={dep.timing?.[day] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleTimingChange(
|
||||||
|
dep.departmentId,
|
||||||
|
day,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="p-6 border-t bg-background z-10 mt-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpenModal(false)}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} className="px-10 text-base">
|
||||||
|
{editing ? "Save Changes" : "Create Doctor Profile"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
import { getCandidatesApi, deleteCandidateApi } from "@/api/candidates";
|
||||||
|
import { exportToExcel } from "@/utils/exportToExcel";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Trash,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function CandidatePage() {
|
||||||
|
const [candidates, setCandidates] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [filterCareer, setFilterCareer] = useState("");
|
||||||
|
|
||||||
|
const [viewOpen, setViewOpen] = useState(false);
|
||||||
|
const [viewData, setViewData] = useState<any>(null);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getCandidatesApi();
|
||||||
|
setCandidates(res?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredCandidates = candidates.filter((item) => {
|
||||||
|
const matchesSearch =
|
||||||
|
item.fullName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.mobile?.includes(searchText) ||
|
||||||
|
item.email?.toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
|
||||||
|
const matchesCareer = filterCareer
|
||||||
|
? item.career?.post?.toLowerCase().includes(filterCareer.toLowerCase())
|
||||||
|
: true;
|
||||||
|
|
||||||
|
return matchesSearch && matchesCareer;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchText, filterCareer]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredCandidates.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredCandidates.slice(
|
||||||
|
indexOfFirstItem,
|
||||||
|
indexOfLastItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
function openView(item: any) {
|
||||||
|
setViewData(item);
|
||||||
|
setViewOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Delete candidate?")) return;
|
||||||
|
await deleteCandidateApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const exportData = filteredCandidates.map((item) => ({
|
||||||
|
ID: item.id,
|
||||||
|
Name: item.fullName,
|
||||||
|
Phone: item.mobile,
|
||||||
|
Email: item.email,
|
||||||
|
Career: item.career?.post,
|
||||||
|
Designation: item.career?.designation,
|
||||||
|
Subject: item.subject,
|
||||||
|
CoverLetter: item.coverLetter,
|
||||||
|
AppliedDate: new Date(item.createdAt).toLocaleDateString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
exportToExcel(exportData, "candidates");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold">Candidates</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search candidate..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[250px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by Career"
|
||||||
|
value={filterCareer}
|
||||||
|
onChange={(e) => setFilterCareer(e.target.value)}
|
||||||
|
className="w-[200px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleExport} className="text-base">
|
||||||
|
<Download className="mr-2 h-5 w-5" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Application List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] bg-background font-bold text-sm">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[220px] bg-background font-bold text-sm">
|
||||||
|
Full Name
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px] bg-background font-bold text-sm">
|
||||||
|
Career & Post
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px] bg-background font-bold text-sm">
|
||||||
|
Contact
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[140px] bg-background font-bold text-sm">
|
||||||
|
Applied On
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[250px] bg-background font-bold text-sm">
|
||||||
|
Cover Letter
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No candidates found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
currentItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{item.id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold text-base truncate">
|
||||||
|
{item.fullName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{item.email}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{item.career?.post || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{item.career?.designation}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{item.mobile}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm line-clamp-2 text-muted-foreground italic">
|
||||||
|
{item.coverLetter || "No cover letter provided."}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openView(item)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && filteredCandidates.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-6 border-t">
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{Math.min(indexOfLastItem, filteredCandidates.length)}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{filteredCandidates.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
||||||
|
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
|
||||||
|
<User className="h-6 w-6" /> Candidate Details
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewData && (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Personal Information
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-primary">
|
||||||
|
{viewData.fullName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{viewData.email}</p>
|
||||||
|
<p className="text-sm">{viewData.mobile}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Applied For
|
||||||
|
</p>
|
||||||
|
<p className="text-base font-semibold">
|
||||||
|
{viewData.career?.post || "General Application"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{viewData.career?.designation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Application Date
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{new Date(viewData.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Subject
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{viewData.subject || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-muted/30 rounded-lg">
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
|
||||||
|
Cover Letter / Message
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap italic">
|
||||||
|
{viewData.coverLetter || "No cover letter provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setViewOpen(false)}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import {useState, useEffect, useCallback} from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getEmailConfigsApi,
|
||||||
|
createEmailConfigApi,
|
||||||
|
updateEmailConfigApi,
|
||||||
|
deleteEmailConfigApi,
|
||||||
|
} from "@/api/email";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {Loader2, Plus, Pencil, Trash, RefreshCw} from "lucide-react";
|
||||||
|
|
||||||
|
export default function EmailPage() {
|
||||||
|
const [emails, setEmails] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
type: "APPOINTMENT",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getEmailConfigsApi();
|
||||||
|
setEmails(res?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filtered = emails.filter(
|
||||||
|
(e) =>
|
||||||
|
e.email.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
e.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(e: any) {
|
||||||
|
setForm({...form, [e.target.name]: e.target.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
type: "APPOINTMENT",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: any) {
|
||||||
|
setEditing(item);
|
||||||
|
setForm(item);
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateEmailConfigApi(editing.id, form);
|
||||||
|
} else {
|
||||||
|
await createEmailConfigApi(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenModal(false);
|
||||||
|
fetchAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Delete email config?")) return;
|
||||||
|
await deleteEmailConfigApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex justify-between items-center flex-wrap gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Email Config</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={fetchAll}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Email List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center">
|
||||||
|
<Loader2 className="animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center">
|
||||||
|
No data
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filtered.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.id}</TableCell>
|
||||||
|
<TableCell>{item.name}</TableCell>
|
||||||
|
<TableCell>{item.email}</TableCell>
|
||||||
|
<TableCell>{item.type}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.isActive ? "Active" : "Inactive"}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit Email" : "Add Email"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
placeholder="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
value={form.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="border rounded px-2 py-2 w-full"
|
||||||
|
>
|
||||||
|
<option value="APPOINTMENT">APPOINTMENT</option>
|
||||||
|
<option value="CANDIDATE">CANDIDATE</option>
|
||||||
|
<option value="ACADEMICS">ACADEMICS</option>
|
||||||
|
<option value="INQUIRY">INQUIRY</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="isActive"
|
||||||
|
value={form.isActive ? "true" : "false"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
isActive: e.target.value === "true",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border rounded px-2 py-2 w-full"
|
||||||
|
>
|
||||||
|
<option value="true">Active</option>
|
||||||
|
<option value="false">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpenModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>
|
||||||
|
{editing ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
import { getInquiriesApi, deleteInquiryApi } from "@/api/inquiry";
|
||||||
|
import { exportToExcel } from "@/utils/exportToExcel";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Trash,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
Mail,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function InquiryPage() {
|
||||||
|
const [inquiries, setInquiries] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const [viewOpen, setViewOpen] = useState(false);
|
||||||
|
const [viewData, setViewData] = useState<any>(null);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getInquiriesApi();
|
||||||
|
setInquiries(res?.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredInquiries = inquiries.filter((item) => {
|
||||||
|
return (
|
||||||
|
item.fullName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.number?.includes(searchText) ||
|
||||||
|
item.emailId?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.subject?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredInquiries.length / itemsPerPage);
|
||||||
|
const indexOfLastItem = currentPage * itemsPerPage;
|
||||||
|
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||||
|
const currentItems = filteredInquiries.slice(
|
||||||
|
indexOfFirstItem,
|
||||||
|
indexOfLastItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
function openView(item: any) {
|
||||||
|
setViewData(item);
|
||||||
|
setViewOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Delete inquiry?")) return;
|
||||||
|
await deleteInquiryApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const exportData = filteredInquiries.map((item) => ({
|
||||||
|
ID: item.id,
|
||||||
|
Name: item.fullName,
|
||||||
|
Phone: item.number,
|
||||||
|
Email: item.emailId,
|
||||||
|
Subject: item.subject,
|
||||||
|
Message: item.message,
|
||||||
|
Date: new Date(item.createdAt).toLocaleDateString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
exportToExcel(exportData, "inquiries");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold">Inquiries</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search name / phone / subject..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[280px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleExport} className="text-base">
|
||||||
|
<Download className="mr-2 h-5 w-5" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Customer Inquiries</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] bg-background font-bold text-sm">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[220px] bg-background font-bold text-sm">
|
||||||
|
Customer Details
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[200px] bg-background font-bold text-sm">
|
||||||
|
Subject
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px] bg-background font-bold text-sm">
|
||||||
|
Date
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[280px] bg-background font-bold text-sm">
|
||||||
|
Message Snippet
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : currentItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No inquiries found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
currentItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{item.id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold text-base truncate">
|
||||||
|
{item.fullName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{item.emailId}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-medium">
|
||||||
|
{item.number}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm font-medium line-clamp-2">
|
||||||
|
{item.subject || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm line-clamp-2 text-muted-foreground italic">
|
||||||
|
{item.message || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openView(item)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && filteredInquiries.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-6 border-t">
|
||||||
|
<div className="text-base text-muted-foreground">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{Math.min(indexOfLastItem, filteredInquiries.length)}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{filteredInquiries.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
||||||
|
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
|
||||||
|
<Mail className="h-6 w-6" /> Inquiry Details
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewData && (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Customer Information
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-primary">
|
||||||
|
{viewData.fullName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{viewData.emailId}</p>
|
||||||
|
<p className="text-sm">{viewData.number}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Received Date
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{new Date(viewData.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground">
|
||||||
|
Subject
|
||||||
|
</p>
|
||||||
|
<p className="text-base font-semibold">
|
||||||
|
{viewData.subject || "No Subject"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-muted/30 rounded-lg border">
|
||||||
|
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
|
||||||
|
Message
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{viewData.message || "No message content."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setViewOpen(false)}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getNewsApi,
|
||||||
|
createNewsApi,
|
||||||
|
updateNewsApi,
|
||||||
|
deleteNewsApi,
|
||||||
|
} from "@/api/newsMedia";
|
||||||
|
|
||||||
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash,
|
||||||
|
RefreshCw,
|
||||||
|
Eye,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Newspaper,
|
||||||
|
ImageIcon,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function NewsPage() {
|
||||||
|
const [news, setNews] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
|
||||||
|
const [viewOpen, setViewOpen] = useState(false);
|
||||||
|
const [viewData, setViewData] = useState<any>(null);
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
headline: "",
|
||||||
|
content: "",
|
||||||
|
imageUrls: [] as string[],
|
||||||
|
firstPara: "",
|
||||||
|
secondPara: "",
|
||||||
|
date: "",
|
||||||
|
author: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getNewsApi(currentPage, itemsPerPage);
|
||||||
|
|
||||||
|
setNews(res?.data || []);
|
||||||
|
setTotalItems(res?.meta?.total || 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentPage, itemsPerPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredNews = news.filter(
|
||||||
|
(item) =>
|
||||||
|
item.Headline?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
item.Author?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||||
|
|
||||||
|
function handleChange(e: any) {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImageUrl(index: number) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
imageUrls: prev.imageUrls.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({
|
||||||
|
headline: "",
|
||||||
|
content: "",
|
||||||
|
imageUrls: [],
|
||||||
|
firstPara: "",
|
||||||
|
secondPara: "",
|
||||||
|
date: "",
|
||||||
|
author: "",
|
||||||
|
});
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: any) {
|
||||||
|
setEditing(item);
|
||||||
|
setForm({
|
||||||
|
headline: item.Headline || "",
|
||||||
|
content: item.Content || "",
|
||||||
|
imageUrls: item.Images ? item.Images.map((img: any) => img.image) : [],
|
||||||
|
firstPara: item.FirstPara || "",
|
||||||
|
secondPara: item.SecondPara || "",
|
||||||
|
date: item.Date ? item.Date.split("T")[0] : "",
|
||||||
|
author: item.Author || "",
|
||||||
|
});
|
||||||
|
setOpenModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openView(item: any) {
|
||||||
|
setViewData(item);
|
||||||
|
setViewOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateNewsApi(editing.Id, form);
|
||||||
|
} else {
|
||||||
|
await createNewsApi(form);
|
||||||
|
}
|
||||||
|
setOpenModal(false);
|
||||||
|
fetchAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Delete news?")) return;
|
||||||
|
await deleteNewsApi(id);
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold font-sans tracking-tight">
|
||||||
|
News Media
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter headline..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[250px] text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setItemsPerPage(Number(e.target.value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value={5}>5 / page</option>
|
||||||
|
<option value={10}>10 / page</option>
|
||||||
|
<option value={20}>20 / page</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={fetchAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-5 w-5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={openAdd} className="text-base">
|
||||||
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
Add News
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">News Archives</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 sm:p-6 space-y-4">
|
||||||
|
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
|
||||||
|
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[80px] bg-background font-bold">
|
||||||
|
ID
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[100px] bg-background font-bold">
|
||||||
|
Cover
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[280px] bg-background font-bold">
|
||||||
|
Headline
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px] bg-background font-bold">
|
||||||
|
Author
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[140px] bg-background font-bold">
|
||||||
|
Date
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[250px] bg-background font-bold">
|
||||||
|
Content Preview
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px] bg-background font-bold text-right">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-10">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredNews.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-10 text-base"
|
||||||
|
>
|
||||||
|
No news articles found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredNews.map((item) => (
|
||||||
|
<TableRow key={item.Id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{item.Id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.Images?.[0] ? (
|
||||||
|
<img
|
||||||
|
src={item.Images[0].image}
|
||||||
|
className="w-10 h-10 object-cover rounded border"
|
||||||
|
alt="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 bg-muted flex items-center justify-center rounded">
|
||||||
|
<ImageIcon className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className="font-semibold text-base line-clamp-2"
|
||||||
|
title={item.Headline}
|
||||||
|
>
|
||||||
|
{item.Headline}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">
|
||||||
|
{item.Author || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{item.Date
|
||||||
|
? new Date(item.Date).toLocaleDateString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm line-clamp-2 text-muted-foreground">
|
||||||
|
{item.Content}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openView(item)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(Number(item.Id))}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && totalItems > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-4 border-t">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Total <span className="font-bold">{totalItems}</span> articles
|
||||||
|
(Page <span className="font-bold">{currentPage}</span> of{" "}
|
||||||
|
{totalPages})
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||||
|
<DialogContent className="w-full !max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">
|
||||||
|
{editing ? "Edit News Article" : "Add New News Article"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<h3 className="font-bold text-base border-b pb-2 text-primary">
|
||||||
|
Article Information
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<label className="text-sm font-semibold">Headline</label>
|
||||||
|
<Input
|
||||||
|
name="headline"
|
||||||
|
value={form.headline}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">Author</label>
|
||||||
|
<Input
|
||||||
|
name="author"
|
||||||
|
value={form.author}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">Publish Date</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
name="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">Intro Paragraph</label>
|
||||||
|
<Textarea
|
||||||
|
name="firstPara"
|
||||||
|
value={form.firstPara}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="min-h-[100px] text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
Full Story Content
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
name="content"
|
||||||
|
value={form.content}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="min-h-[200px] text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Management Sidebar */}
|
||||||
|
<div className="space-y-6 border-l pl-6">
|
||||||
|
<h3 className="font-bold text-base border-b pb-2 text-primary flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-4 h-4" /> Gallery Management
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-sm font-semibold">Upload Images</label>
|
||||||
|
<BytescaleUploader
|
||||||
|
value=""
|
||||||
|
folderPath="/news"
|
||||||
|
onChange={(url) => {
|
||||||
|
if (url) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
imageUrls: [...prev.imageUrls, url],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 max-h-[400px] overflow-y-auto pr-2 mt-4">
|
||||||
|
{form.imageUrls.map((url, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative group border rounded-lg overflow-hidden h-24 bg-muted"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt="upload"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeImageUrl(index)}
|
||||||
|
className="absolute top-1 right-1 bg-destructive text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.imageUrls.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-10 border-2 border-dashed rounded-lg">
|
||||||
|
No images uploaded yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-10 pt-6 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpenModal(false)}
|
||||||
|
className="text-base"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="px-10 text-base bg-primary text-white"
|
||||||
|
>
|
||||||
|
{editing ? "Save Changes" : "Publish Now"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
||||||
|
<DialogContent className="w-full !max-w-4xl max-h-[85vh] overflow-y-auto p-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
|
||||||
|
<Newspaper className="h-6 w-6 text-primary" /> News Preview
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{viewData && (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-bold leading-tight">
|
||||||
|
{viewData.Headline}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-4 text-sm text-muted-foreground font-medium italic">
|
||||||
|
<span>By: {viewData.Author || "Anonymous"}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{viewData.Date
|
||||||
|
? new Date(viewData.Date).toLocaleDateString("en-IN", {
|
||||||
|
dateStyle: "long",
|
||||||
|
})
|
||||||
|
: "No Date"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 md:grid-cols-4 gap-2">
|
||||||
|
{viewData.Images?.map((img: any, i: number) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={img.image}
|
||||||
|
className="w-full h-24 object-cover rounded-md border"
|
||||||
|
alt="gallery"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 leading-relaxed text-base">
|
||||||
|
<div className="bg-muted/30 p-4 rounded-lg border-l-4 border-primary">
|
||||||
|
<p className="whitespace-pre-line">{viewData.FirstPara}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-1">
|
||||||
|
<p className="whitespace-pre-line">{viewData.SecondPara}</p>
|
||||||
|
<hr />
|
||||||
|
<p className="whitespace-pre-line text-muted-foreground">
|
||||||
|
{viewData.Content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setViewOpen(false)}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Close Preview
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: "http://localhost:3000/api",
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import {saveAs} from "file-saver";
|
||||||
|
|
||||||
|
export const exportToExcel = (data: any[], fileName: string = "data") => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
|
||||||
|
|
||||||
|
const excelBuffer = XLSX.write(workbook, {
|
||||||
|
bookType: "xlsx",
|
||||||
|
type: "array",
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([excelBuffer], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8",
|
||||||
|
});
|
||||||
|
|
||||||
|
saveAs(blob, `${fileName}.xlsx`);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user