Compare commits

..

1 Commits

Author SHA1 Message Date
ARJUN S THAMPI c6a041ac10 feat: add pagination in appointment 2026-03-27 12:09:46 +05:30
109 changed files with 1486 additions and 8717 deletions
-34
View File
@@ -1,34 +0,0 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
# **/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md
-1
View File
@@ -1 +0,0 @@
.env
-102
View File
@@ -1,102 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Repository layout
Monorepo with two independent npm packages plus a shared Docker dev stack:
- `backend/` — Node.js + Express 5 (ESM) API, Prisma ORM over PostgreSQL.
- `frontend/` — React 19 + Vite + TypeScript admin dashboard, styled with Tailwind 4 + shadcn/ui.
- `docker/` — Dev Dockerfiles + `entrypoint.sh` (used by `docker-compose.dev.yml` at repo root).
There is no root-level package script — run npm commands inside `backend/` or `frontend/`.
## Common commands
### Docker (full stack)
```bash
docker compose -f docker-compose.dev.yml up --build # backend :5008, frontend :3008, postgres :5432 (internal)
docker compose -f docker-compose.dev.yml down
```
The backend container's `entrypoint.sh` runs `prisma generate` then `prisma db push` (NOT `migrate deploy`) on every start — schema changes propagate without a migration step in the Docker dev flow.
### Backend (`cd backend`)
```bash
npm run dev # nodemon src/app.js
npm start # node src/app.js
npm run migrate # npx prisma migrate dev (use this locally; Docker uses db push)
npm run generate # npx prisma generate
npx prisma studio # GUI for the DB
npm run create-user <username> <password> [role] # role defaults to "admin"
```
In Docker, create an admin via:
```bash
docker exec -it gg-backend-api-backend-1 node src/utils/createUser.js <name> <password> <role>
```
### Frontend (`cd frontend`)
```bash
npm run dev # Vite dev server
npm run build # tsc -b && vite build
npm run lint # eslint .
npm run preview
```
There is no test runner configured in either package.
## Architecture
### Backend
Single Express app assembled in `backend/src/app.js`. Each domain follows the same triplet:
- `src/routes/<domain>.routes.js` — declares the Express router, applies `jwtAuthMiddleware` per-route (mixed public/protected within the same router; see `blog.routes.js` for the pattern: list/detail public, admin endpoints + writes protected).
- `src/controllers/<domain>.controller.js` — handler logic; uses Prisma directly.
- Mounted at `/api/<domain>` in `app.js`.
Domains: `departments`, `auth`, `blogs`, `upload`, `doctors`, `careers`, `candidates`, `appointments`, `inquiry`, `academics`, `email`, `newsMedia`, `import`. Static `uploads/` is served at `/uploads`.
Cross-cutting pieces:
- **Prisma client** — `src/prisma/client.js` exports a singleton. Always import from there rather than instantiating `new PrismaClient()` (the import controller currently instantiates its own — match the singleton pattern when adding new code).
- **Auth** — `src/middleware/auth.js` expects `Authorization: Bearer <jwt>`, attaches the decoded payload to `req.user`. Tokens are issued by `src/utils/jwt.js` using `JWT_SECRET`. Passwords hashed via `src/utils/password.js` (bcrypt).
- **Email** — `src/utils/sendEmail.js` wraps Postmark with `EMAIL_FROM` as sender. Recipient lists are looked up by category via `getEmailsByType(type)` against the `EmailConfig` table — controllers call this to fan out notifications (e.g. on new appointments/inquiries).
- **CORS** — `CORS_ALLOWED_ORIGINS` is a **space-separated** list (not comma-separated). Requests with no `Origin` header are allowed.
- **Body limits** — JSON/urlencoded bodies are capped at 50mb to support image-laden Editor.js payloads and bulk Excel imports.
### Data model (Prisma — `backend/prisma/schema.prisma`)
Note the natural-key relations: `Appointment.doctorId` references `Doctor.doctorId` (the string business ID), not `Doctor.id`. Same for `Department`. The `SL_NO` / `GG_ID` columns from imports become these business IDs. `Doctor``Department` is many-to-many through `DoctorDepartment`, which has a 1:1 `DoctorTiming` for weekday schedules. `NewsMedia` has cascading `NewsImage` children.
### Frontend
- Routing in `src/App.tsx`: `PublicRoute` wraps `/` (Login); everything else is wrapped by `ProtectedRoute``DashboardLayout`. Unknown paths redirect to `/department`.
- `src/context/AuthContext.tsx` is the auth source of truth; `src/services/api.ts` is the shared Axios instance that auto-injects `Authorization: Bearer <token>` from `localStorage.getItem("token")`.
- `src/api/<domain>.ts` files are thin wrappers around that axios client, one per backend domain — keep this pattern when adding endpoints.
- Path alias `@/*``src/*` (configured in both `vite.config.ts` and `tsconfig`).
- File uploads go through Bytescale (`src/components/BytescaleUploader`) — server only stores the resulting URL.
- The `/import` page parses Excel via `xlsx` and POSTs structured payloads to `/api/import`, where `importController.js` upserts across multiple tables. When changing import behavior, the frontend's column names (`SL_NO`, `GG_ID`, `Department`, `Working Status`, etc.) and the controller's destructured keys (`departments`, `doctors`, `timings`, …) must stay in sync.
## Environment variables
`backend/.env`:
```
DATABASE_URL=postgresql://user:password@db:5432/mydb
PORT=5008
JWT_SECRET=...
CORS_ALLOWED_ORIGINS=http://localhost:5173 # space-separated for multiple
BYTESCALE_SECRET_API_KEY=...
POSTMARK_API_KEY=...
EMAIL_FROM=admin@example.com
```
`frontend/.env`:
```
VITE_API_URL=http://localhost:5008/api
```
Vendored
-221
View File
@@ -1,221 +0,0 @@
// Single source of truth for env injection.
// Adding/removing a variable means editing one `keys` list — nothing else.
// `path` is the Vault KV path; `file` is the output path relative to ${WORKSPACE}.
def ENV_LAYOUT = [
[path: 'ggh-dev/root', file: '.env', keys: [
'POSTGRES_DB', 'POSTGRES_PASSWORD', 'POSTGRES_USER',
]],
[path: 'ggh-dev/backend', file: 'backend/.env', keys: [
'BYTESCALE_SECRET_API_KEY',
'CORS_ALLOWED_ORIGINS',
'DATABASE_URL',
'EMAIL_FROM',
'JWT_SECRET',
'PORT',
'POSTMARK_API_KEY',
]],
[path: 'ggh-dev/dashboard-frontend', file: 'frontend/.env', keys: [
'VITE_API_URL',
]],
]
pipeline {
agent { label 'jagent06' }
options {
disableConcurrentBuilds()
}
triggers {
GenericTrigger(
causeString: 'Triggered by Gitea webhook',
tokenCredentialId: 'ggh-dev',
printContributedVariables: true,
printPostContent: true
)
}
environment {
DOCKER_COMPOSE_FILE = "${WORKSPACE}/docker-compose.dev.yml"
DOCKER_BUILDKIT = '1'
COMPOSE_DOCKER_CLI_BUILD = '1'
}
stages {
stage('Checkout Code') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: '*/dev']],
userRemoteConfigs: [[
url: 'https://gt.mgsigma.net/GGH/gg-backend.git',
credentialsId: 'SYS_BOT_PAT'
]]
])
}
}
stage('Inject .env from Vault') {
steps {
script {
def vaultSecrets = ENV_LAYOUT.collect { layer ->
[
path: layer.path,
engineVersion: 2,
secretValues: layer.keys.collect { k ->
[vaultKey: k, envVar: k]
}
]
}
def writtenFiles = ENV_LAYOUT.collect { it.file }.join(', ')
withVault(
configuration: [
vaultUrl: 'https://vault.mgsigma.net',
vaultCredentialId: 'vault-cicd-01'
],
vaultSecrets: vaultSecrets
) {
// Single-quoted sh + withEnv keeps secret names out of the
// Groovy GString. Values are read at shell runtime via
// `printenv`, so Jenkins's static analyzer has nothing to flag.
ENV_LAYOUT.each { layer ->
withEnv([
"ENV_FILE=${layer.file}",
"ENV_KEYS=${layer.keys.join(' ')}"
]) {
sh '''
set +x
umask 077
mkdir -p "$(dirname "$WORKSPACE/$ENV_FILE")"
: > "$WORKSPACE/$ENV_FILE"
for k in $ENV_KEYS; do
printf '%s=%s\n' "$k" "$(printenv "$k")" >> "$WORKSPACE/$ENV_FILE"
done
'''
}
}
echo "[INFO] env files written: ${writtenFiles}"
}
}
}
}
stage('Deploy') {
steps {
script {
if (!fileExists(env.DOCKER_COMPOSE_FILE)) {
error "docker-compose.dev.yml not found at ${env.DOCKER_COMPOSE_FILE}"
}
}
sh '''
set -e
if [ -n "$(docker compose -f docker-compose.dev.yml ps -q 2>/dev/null)" ]; then
echo "[INFO] Stopping existing containers..."
docker compose -f docker-compose.dev.yml down --remove-orphans
else
echo "[INFO] No running containers — skipping stop."
fi
# Dangling-only prune — keeps tagged images and BuildKit cache so
# subsequent builds reuse layers. Do NOT use `docker system prune -af`.
docker image prune -f >/dev/null 2>&1 || true
echo "[INFO] Building and starting services..."
docker compose -f docker-compose.dev.yml up -d --build --remove-orphans
'''
}
}
}
post {
always {
script {
env.BUILD_INFO_HASH = sh(
script: "git rev-parse --short HEAD 2>/dev/null || echo N/A",
returnStdout: true
).trim()
env.BUILD_INFO_AUTHOR = sh(
script: "git log -1 --pretty=format:'%an' 2>/dev/null || echo N/A",
returnStdout: true
).trim()
env.BUILD_INFO_MESSAGE = sh(
script: "git log -1 --pretty=format:'%s' 2>/dev/null || echo 'No commit message found'",
returnStdout: true
).trim()
def cause = currentBuild.getBuildCauses()?.getAt(0)?.shortDescription ?: 'Unknown'
env.BUILD_INFO_TRIGGER = cause.contains('Started by user') ? 'Manual trigger' : cause
}
}
success {
script {
emailext(
subject: "[DEV] ${env.JOB_NAME} #${env.BUILD_NUMBER} succeeded",
body: renderEmail('Build succeeded', '#16a34a', buildInfo()),
mimeType: 'text/html',
to: 'admin@msigmagokulam.com, ashir@mgsigma.net, kailasdevdas@msigmagokulam.com'
)
}
}
failure {
script {
emailext(
subject: "[DEV] ${env.JOB_NAME} #${env.BUILD_NUMBER} failed",
body: renderEmail('Build failed', '#dc2626', buildInfo()),
mimeType: 'text/html',
to: 'admin@msigmagokulam.com, ashir@mgsigma.net, kailasdevdas@msigmagokulam.com'
)
}
}
}
}
def buildInfo() {
return [
hash: env.BUILD_INFO_HASH ?: 'N/A',
author: env.BUILD_INFO_AUTHOR ?: 'N/A',
message: env.BUILD_INFO_MESSAGE ?: 'N/A',
trigger: env.BUILD_INFO_TRIGGER ?: 'Unknown',
duration: currentBuild.durationString.replace(' and counting', '')
]
}
def renderEmail(String label, String accent, Map info) {
return """\
<!DOCTYPE html>
<html>
<body style="margin:0; padding:0; background-color:#f6f7f9;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f6f7f9; padding:40px 16px;">
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="560" style="max-width:560px; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(15,23,42,0.08); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="height:3px; background:${accent};"></td></tr>
<tr><td style="padding:32px 40px 0 40px;">
<div style="font-size:12px; font-weight:600; color:${accent}; letter-spacing:0.8px; text-transform:uppercase; margin-bottom:6px;">${label} · development</div>
<div style="font-size:20px; font-weight:600; color:#0f172a; line-height:1.35;">${env.JOB_NAME}<span style="color:#94a3b8; font-weight:400;"> &middot; #${env.BUILD_NUMBER}</span></div>
</td></tr>
<tr><td style="padding:20px 40px 8px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:14px; color:#0f172a;">
<tr><td width="110" style="padding:7px 0; color:#64748b;">Commit</td><td style="padding:7px 0; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:13px;">${info.hash}</td></tr>
<tr><td style="padding:7px 0; color:#64748b;">Author</td><td style="padding:7px 0;">${info.author}</td></tr>
<tr><td style="padding:7px 0; color:#64748b; vertical-align:top;">Message</td><td style="padding:7px 0;">${info.message}</td></tr>
<tr><td style="padding:7px 0; color:#64748b;">Triggered by</td><td style="padding:7px 0;">${info.trigger}</td></tr>
<tr><td style="padding:7px 0; color:#64748b;">Duration</td><td style="padding:7px 0;">${info.duration}</td></tr>
</table>
</td></tr>
<tr><td style="padding:20px 40px 32px 40px;">
<a href="${env.BUILD_URL}" style="display:inline-block; padding:10px 18px; background:#0f172a; color:#ffffff; text-decoration:none; border-radius:8px; font-size:14px; font-weight:500;">View build</a>
<a href="${env.BUILD_URL}console" style="display:inline-block; padding:10px 14px; color:#475569; text-decoration:none; font-size:14px; font-weight:500;">Console output &rarr;</a>
</td></tr>
<tr><td style="padding:14px 40px; background:#f8fafc; border-top:1px solid #e2e8f0; font-size:12px; color:#94a3b8;">
Automated notification from Jenkins
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
}
-224
View File
@@ -1,224 +0,0 @@
// Single source of truth for env injection.
// Adding/removing a variable means editing one `keys` list — nothing else.
// `path` is the Vault KV path; `file` is the output path relative to ${WORKSPACE}.
def ENV_LAYOUT = [
[path: 'ggh-prod/root', file: '.env', keys: [
'POSTGRES_DB', 'POSTGRES_PASSWORD', 'POSTGRES_USER',
]],
[path: 'ggh-prod/backend', file: 'backend/.env', keys: [
'BYTESCALE_SECRET_API_KEY',
'CORS_ALLOWED_ORIGINS',
'DATABASE_URL',
'EMAIL_FROM',
'JWT_SECRET',
'PORT',
'POSTMARK_API_KEY',
]],
[path: 'ggh-prod/dashboard-frontend', file: 'frontend/.env', keys: [
'VITE_API_URL',
]],
]
pipeline {
agent {
label 'jagent06'
}
options {
disableConcurrentBuilds()
}
environment {
REMOTE_HOST = "root@170.187.237.83"
REMOTE_DEPLOY_DIR = "/root/gg-backend"
SSH_CREDENTIALS_ID = "ssh_id_ed25519"
COMPOSE_FILE = "docker-compose.prod.yml"
}
stages {
stage('Checkout Code') {
steps {
git credentialsId: 'SYS_BOT_PAT', url: 'https://gt.mgsigma.net/GGH/gg-backend.git', branch: 'main'
}
}
stage('Inject .env from Vault') {
steps {
script {
def vaultSecrets = ENV_LAYOUT.collect { layer ->
[
path: layer.path,
engineVersion: 2,
secretValues: layer.keys.collect { k ->
[vaultKey: k, envVar: k]
}
]
}
def writtenFiles = ENV_LAYOUT.collect { it.file }.join(', ')
withVault(
configuration: [
vaultUrl: 'https://vault.mgsigma.net',
vaultCredentialId: 'vault-cicd-01'
],
vaultSecrets: vaultSecrets
) {
// Single-quoted sh + withEnv keeps secret names out of the
// Groovy GString. Values are read at shell runtime via
// `printenv`, so Jenkins's static analyzer has nothing to flag.
ENV_LAYOUT.each { layer ->
withEnv([
"ENV_FILE=${layer.file}",
"ENV_KEYS=${layer.keys.join(' ')}"
]) {
sh '''
set +x
umask 077
mkdir -p "$(dirname "$WORKSPACE/$ENV_FILE")"
: > "$WORKSPACE/$ENV_FILE"
for k in $ENV_KEYS; do
printf '%s=%s\n' "$k" "$(printenv "$k")" >> "$WORKSPACE/$ENV_FILE"
done
'''
}
}
echo "[INFO] env files written: ${writtenFiles}"
}
}
}
}
stage('Sync sources to prod VPS') {
steps {
sshagent (credentials: ["${env.SSH_CREDENTIALS_ID}"]) {
sh """
ssh -o StrictHostKeyChecking=no $REMOTE_HOST 'mkdir -p $REMOTE_DEPLOY_DIR'
rsync -az --delete \\
--exclude='.git/' \\
--exclude='node_modules/' \\
--exclude='backend/node_modules/' \\
--exclude='frontend/node_modules/' \\
--exclude='frontend/dist/' \\
-e "ssh -o StrictHostKeyChecking=no" \\
./ $REMOTE_HOST:$REMOTE_DEPLOY_DIR/
"""
}
}
}
stage('Deploy on prod VPS') {
steps {
sshagent (credentials: ["${env.SSH_CREDENTIALS_ID}"]) {
sh """
ssh -o StrictHostKeyChecking=no $REMOTE_HOST '
set -e
cd $REMOTE_DEPLOY_DIR
if [ -n "\$(docker compose -f $COMPOSE_FILE ps -q 2>/dev/null)" ]; then
echo "[INFO] Stopping existing containers..."
docker compose -f $COMPOSE_FILE down --remove-orphans
else
echo "[INFO] No running containers — skipping stop."
fi
docker image prune -f >/dev/null 2>&1 || true
echo "[INFO] Building and starting services..."
docker compose -f $COMPOSE_FILE up -d --build --remove-orphans
'
"""
}
}
}
}
post {
always {
script {
env.BUILD_INFO_HASH = sh(
script: "git rev-parse --short HEAD 2>/dev/null || echo N/A",
returnStdout: true
).trim()
env.BUILD_INFO_AUTHOR = sh(
script: "git log -1 --pretty=format:'%an' 2>/dev/null || echo N/A",
returnStdout: true
).trim()
env.BUILD_INFO_MESSAGE = sh(
script: "git log -1 --pretty=format:'%s' 2>/dev/null || echo 'No commit message found'",
returnStdout: true
).trim()
def cause = currentBuild.getBuildCauses()?.getAt(0)?.shortDescription ?: 'Unknown'
env.BUILD_INFO_TRIGGER = cause.contains('Started by user') ? 'Manual trigger' : cause
}
}
success {
script {
emailext(
subject: "[PROD] ${env.JOB_NAME} #${env.BUILD_NUMBER} succeeded",
body: renderEmail('Build succeeded', '#16a34a', buildInfo()),
mimeType: 'text/html',
to: 'admin@msigmagokulam.com, ashir@mgsigma.net, kailasdevdas@msigmagokulam.com'
)
}
}
failure {
script {
emailext(
subject: "[PROD] ${env.JOB_NAME} #${env.BUILD_NUMBER} failed",
body: renderEmail('Build failed', '#dc2626', buildInfo()),
mimeType: 'text/html',
to: 'admin@msigmagokulam.com, ashir@mgsigma.net, kailasdevdas@msigmagokulam.com'
)
}
}
}
}
def buildInfo() {
return [
hash: env.BUILD_INFO_HASH ?: 'N/A',
author: env.BUILD_INFO_AUTHOR ?: 'N/A',
message: env.BUILD_INFO_MESSAGE ?: 'N/A',
trigger: env.BUILD_INFO_TRIGGER ?: 'Unknown',
duration: currentBuild.durationString.replace(' and counting', '')
]
}
def renderEmail(String label, String accent, Map info) {
return """\
<!DOCTYPE html>
<html>
<body style="margin:0; padding:0; background-color:#f6f7f9;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f6f7f9; padding:40px 16px;">
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="560" style="max-width:560px; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(15,23,42,0.08); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="height:3px; background:${accent};"></td></tr>
<tr><td style="padding:32px 40px 0 40px;">
<div style="font-size:12px; font-weight:600; color:${accent}; letter-spacing:0.8px; text-transform:uppercase; margin-bottom:6px;">${label} · production</div>
<div style="font-size:20px; font-weight:600; color:#0f172a; line-height:1.35;">${env.JOB_NAME}<span style="color:#94a3b8; font-weight:400;"> &middot; #${env.BUILD_NUMBER}</span></div>
</td></tr>
<tr><td style="padding:20px 40px 8px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:14px; color:#0f172a;">
<tr><td width="110" style="padding:7px 0; color:#64748b;">Commit</td><td style="padding:7px 0; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:13px;">${info.hash}</td></tr>
<tr><td style="padding:7px 0; color:#64748b;">Author</td><td style="padding:7px 0;">${info.author}</td></tr>
<tr><td style="padding:7px 0; color:#64748b; vertical-align:top;">Message</td><td style="padding:7px 0;">${info.message}</td></tr>
<tr><td style="padding:7px 0; color:#64748b;">Triggered by</td><td style="padding:7px 0;">${info.trigger}</td></tr>
<tr><td style="padding:7px 0; color:#64748b;">Duration</td><td style="padding:7px 0;">${info.duration}</td></tr>
</table>
</td></tr>
<tr><td style="padding:20px 40px 32px 40px;">
<a href="${env.BUILD_URL}" style="display:inline-block; padding:10px 18px; background:#0f172a; color:#ffffff; text-decoration:none; border-radius:8px; font-size:14px; font-weight:500;">View build</a>
<a href="${env.BUILD_URL}console" style="display:inline-block; padding:10px 14px; color:#475569; text-decoration:none; font-size:14px; font-weight:500;">Console output &rarr;</a>
</td></tr>
<tr><td style="padding:14px 40px; background:#f8fafc; border-top:1px solid #e2e8f0; font-size:12px; color:#94a3b8;">
Automated notification from Jenkins
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
}
-94
View File
@@ -1,94 +0,0 @@
# 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=5008
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_URL=http://localhost:5008/api
```
---
## Running the Project
### Start containers
```bash
docker compose -f docker-compose.dev.yml up --build
```
## Create User
```bash
docker exec -it gg-backend-api-backend-1 node src/utils/createUser.js <name> <password> <role>
```
---
### 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
```
+1 -1
View File
@@ -1,5 +1,5 @@
node_modules
# Keep environment variables out of version control
.env*
.env
/src/generated/prisma
-59
View File
@@ -1,59 +0,0 @@
**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=5008
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.
+2 -110
View File
@@ -9,7 +9,6 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@bytescale/sdk": "^3.53.0",
"@editorjs/editorjs": "^2.31.4",
"@editorjs/header": "^2.8.8",
"@editorjs/list": "^2.0.9",
@@ -22,21 +21,13 @@
"express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"node-fetch": "^3.3.2",
"postmark": "^4.0.7",
"prisma": "^6.19.2",
"slugify": "^1.6.9"
"prisma": "^6.19.2"
},
"devDependencies": {
"nodemon": "^3.1.11"
}
},
"node_modules/@bytescale/sdk": {
"version": "3.53.0",
"resolved": "https://registry.npmjs.org/@bytescale/sdk/-/sdk-3.53.0.tgz",
"integrity": "sha512-qCeNup3pSjaklXuBrO9JeKbozZEs/PjQEvuqCiOAWLBRl6lDjd0V9gRVYqyttPimXYFoV+J/7dmPWtK6RfOABQ==",
"license": "MIT"
},
"node_modules/@codexteam/icons": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz",
@@ -608,15 +599,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -917,29 +899,6 @@
"node": ">=8.0.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1031,18 +990,6 @@
"node": ">= 0.6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1574,44 +1521,6 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@@ -1818,6 +1727,7 @@
"integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.19.2",
"@prisma/engines": "6.19.2"
@@ -2154,15 +2064,6 @@
"node": ">=10"
}
},
"node_modules/slugify": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2306,15 +2207,6 @@
"node": ">= 0.8"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+2 -6
View File
@@ -8,15 +8,13 @@
"start": "node src/app.js",
"prisma": "prisma",
"migrate": "npx prisma migrate dev",
"generate": "npx prisma generate",
"create-user": "node src/utils/createUser.js"
"generate": "npx prisma generate"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@bytescale/sdk": "^3.53.0",
"@editorjs/editorjs": "^2.31.4",
"@editorjs/header": "^2.8.8",
"@editorjs/list": "^2.0.9",
@@ -29,10 +27,8 @@
"express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"node-fetch": "^3.3.2",
"postmark": "^4.0.7",
"prisma": "^6.19.2",
"slugify": "^1.6.9"
"prisma": "^6.19.2"
},
"devDependencies": {
"nodemon": "^3.1.11"
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "image" TEXT;
@@ -1,8 +0,0 @@
/*
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;
@@ -1,8 +0,0 @@
/*
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");
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Department" ADD COLUMN "image" TEXT;
@@ -1,12 +0,0 @@
-- 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,14 +0,0 @@
-- AlterTable
ALTER TABLE "Career" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Department" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "globalSortOrder" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "DoctorDepartment" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
@@ -1,11 +0,0 @@
-- AlterTable
ALTER TABLE "Career" ALTER COLUMN "sortOrder" SET DEFAULT 1000;
-- AlterTable
ALTER TABLE "Department" ALTER COLUMN "sortOrder" SET DEFAULT 1000;
-- AlterTable
ALTER TABLE "Doctor" ALTER COLUMN "globalSortOrder" SET DEFAULT 1000;
-- AlterTable
ALTER TABLE "DoctorDepartment" ALTER COLUMN "sortOrder" SET DEFAULT 1000;
@@ -1,63 +0,0 @@
-- CreateTable
CREATE TABLE "HealthCheckCategory" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthCheckCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackage" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"price" DECIMAL(10,2),
"discountedPrice" DECIMAL(10,2),
"inclusions" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"categoryId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackageInquiry" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"mobileNumber" TEXT NOT NULL,
"email" TEXT,
"preferredDate" TIMESTAMP(3),
"message" TEXT,
"packageId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackageInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_name_key" ON "HealthCheckCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_slug_key" ON "HealthCheckCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "HealthPackage_slug_key" ON "HealthPackage"("slug");
-- AddForeignKey
ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "HealthCheckCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HealthPackageInquiry" ADD CONSTRAINT "HealthPackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "HealthPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "HealthPackageInquiry" ADD COLUMN "age" INTEGER,
ADD COLUMN "gender" TEXT;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "HealthPackage" ALTER COLUMN "inclusions" SET DEFAULT '{}';
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "HealthCheckCategory" ALTER COLUMN "slug" DROP NOT NULL;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "HealthPackage" ADD COLUMN "image" TEXT;
@@ -1,37 +0,0 @@
/*
Warnings:
- You are about to drop the `HealthCheckCategory` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `HealthPackage` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `HealthPackageInquiry` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "HealthPackage" DROP CONSTRAINT "HealthPackage_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "HealthPackageInquiry" DROP CONSTRAINT "HealthPackageInquiry_packageId_fkey";
-- DropTable
DROP TABLE "HealthCheckCategory";
-- DropTable
DROP TABLE "HealthPackage";
-- DropTable
DROP TABLE "HealthPackageInquiry";
-- CreateTable
CREATE TABLE "DoctorSpecialization" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"doctorId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DoctorSpecialization_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "DoctorSpecialization" ADD CONSTRAINT "DoctorSpecialization_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "professionalSummary" TEXT;
@@ -1,7 +0,0 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER,
ADD COLUMN "focusKeyphrase" TEXT,
ADD COLUMN "metaDescription" TEXT,
ADD COLUMN "seoTitle" TEXT,
ADD COLUMN "slug" TEXT,
ADD COLUMN "tags" TEXT[];
@@ -1,4 +0,0 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "ogDescription" TEXT,
ADD COLUMN "ogImage" TEXT,
ADD COLUMN "ogTitle" TEXT;
@@ -1,19 +0,0 @@
-- CreateTable
CREATE TABLE "Seo" (
"id" SERIAL NOT NULL,
"seoTitle" TEXT,
"metaDescription" TEXT,
"focusKeyphrase" TEXT,
"slug" TEXT,
"tags" TEXT[],
"ogTitle" TEXT,
"ogDescription" TEXT,
"ogImage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Seo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Seo_slug_key" ON "Seo"("slug");
@@ -1,30 +0,0 @@
/*
Warnings:
- You are about to drop the column `focusKeyphrase` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `metaDescription` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `ogDescription` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `ogImage` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `ogTitle` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `seoTitle` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `slug` on the `Doctor` table. All the data in the column will be lost.
- You are about to drop the column `tags` on the `Doctor` table. All the data in the column will be lost.
- A unique constraint covering the columns `[seoId]` on the table `Doctor` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Doctor" DROP COLUMN "focusKeyphrase",
DROP COLUMN "metaDescription",
DROP COLUMN "ogDescription",
DROP COLUMN "ogImage",
DROP COLUMN "ogTitle",
DROP COLUMN "seoTitle",
DROP COLUMN "slug",
DROP COLUMN "tags",
ADD COLUMN "seoId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Doctor_seoId_key" ON "Doctor"("seoId");
-- AddForeignKey
ALTER TABLE "Doctor" ADD CONSTRAINT "Doctor_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,66 +0,0 @@
-- CreateTable
CREATE TABLE "HealthCheckCategory" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthCheckCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackage" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"price" DECIMAL(10,2),
"image" TEXT,
"discountedPrice" DECIMAL(10,2),
"inclusions" JSONB NOT NULL DEFAULT '{}',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"categoryId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackageInquiry" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"mobileNumber" TEXT NOT NULL,
"email" TEXT,
"age" INTEGER,
"gender" TEXT,
"preferredDate" TIMESTAMP(3),
"message" TEXT,
"packageId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackageInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_name_key" ON "HealthCheckCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_slug_key" ON "HealthCheckCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "HealthPackage_slug_key" ON "HealthPackage"("slug");
-- AddForeignKey
ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "HealthCheckCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HealthPackageInquiry" ADD CONSTRAINT "HealthPackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "HealthPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+22 -125
View File
@@ -21,19 +21,13 @@ model Doctor {
id Int @id @default(autoincrement())
doctorId String @unique
name String
image String?
designation String?
experience Int?
workingStatus String?
qualification String?
isActive Boolean @default(true)
globalSortOrder Int @default(1000)
departments DoctorDepartment[]
appointments Appointment[]
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -42,8 +36,6 @@ model Department {
id Int @id @default(autoincrement())
departmentId String @unique
name String
image String?
para1 String?
para2 String?
@@ -51,9 +43,6 @@ model Department {
facilities String?
services String?
isActive Boolean @default(true)
sortOrder Int @default(1000)
doctors DoctorDepartment[]
appointments Appointment[]
@@ -69,7 +58,7 @@ model DoctorDepartment {
doctor Doctor @relation(fields: [doctorId], references: [id])
department Department @relation(fields: [departmentId], references: [id])
sortOrder Int @default(1000)
timing DoctorTiming?
createdAt DateTime @default(now())
@@ -104,7 +93,6 @@ model Blog {
image String?
content Json
isActive Boolean @default(true)
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -119,8 +107,7 @@ model Career {
email String?
number String?
status String @default("new")
isActive Boolean @default(true)
sortOrder Int @default(1000)
candidates Candidate[]
createdAt DateTime @default(now())
@@ -162,20 +149,20 @@ model Appointment {
}
model Inquiry {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
fullName String
number String
emailId String?
subject String?
message String?
fullName String
number String
emailId String?
subject String?
message String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AcademicsResearch {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
fullName String
number String
@@ -184,23 +171,24 @@ model AcademicsResearch {
courseName String?
message String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EmailConfig {
id Int @id @default(autoincrement())
name String
email String
type String
isActive Boolean @default(true)
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())
id Int @id @default(autoincrement())
headline String
content String?
@@ -208,100 +196,9 @@ model NewsMedia {
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())
}
model HealthCheckCategory {
id Int @id @default(autoincrement())
name String @unique
slug String? @unique
description String?
isActive Boolean @default(true)
sortOrder Int @default(1000)
packages HealthPackage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HealthPackage {
id Int @id @default(autoincrement())
name String
slug String @unique
description String?
price Decimal? @db.Decimal(10, 2)
image String?
discountedPrice Decimal? @db.Decimal(10, 2)
inclusions Json @default("{}")
isActive Boolean @default(true)
isFeatured Boolean @default(false)
sortOrder Int @default(1000)
categoryId Int
category HealthCheckCategory @relation(fields: [categoryId], references: [id])
inquiries HealthPackageInquiry[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HealthPackageInquiry {
id Int @id @default(autoincrement())
fullName String
mobileNumber String
email String?
age Int?
gender String?
preferredDate DateTime?
message String?
packageId Int
healthPackage HealthPackage @relation(fields: [packageId], references: [id])
status String @default("pending")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DoctorSpecialization {
id Int @id @default(autoincrement())
name String
description String? @db.Text
doctorId Int
doctor Doctor @relation(fields: [doctorId],references: [id],onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Seo {
id Int @id @default(autoincrement())
doctor Doctor?
seoTitle String?
metaDescription String? @db.Text
focusKeyphrase String?
slug String? @unique
tags String[]
ogTitle String?
ogDescription String? @db.Text
ogImage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+2 -8
View File
@@ -14,16 +14,11 @@ 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";
import importRoutes from "./routes/importRoutes.js";
import healthCheckRoutes from "./routes/healthCheck.route.js";
dotenv.config();
const app = express();
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
? process.env.CORS_ALLOWED_ORIGINS.split(" ")
: ["http://localhost:3001"];
@@ -40,6 +35,7 @@ const corsOptions = {
allowedHeaders: "*",
};
app.use(express.json());
app.use(cors(corsOptions));
app.use("/api/departments", departmentRoutes);
@@ -55,10 +51,8 @@ app.use("/api/inquiry", inquiryRoutes);
app.use("/api/academics", academicsResearchRoutes);
app.use("/api/email", emailConfigRoutes);
app.use("/api/newsMedia", newsMediaRoutes);
app.use("/api/import", importRoutes);
app.use("/api/health-check", healthCheckRoutes);
const PORT = process.env.PORT || 5008;
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
@@ -35,73 +35,18 @@ export const createAcademicsResearch = async (req, res) => {
to: emailList,
subject: "New Academics & Research Inquiry",
html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<h2 style="margin: 0;">GG Hospital</h2>
<p style="margin: 5px 0 0; font-size: 14px;">
New Academics & Research Inquiry
</p>
</div>
<h2>New Academics & Research Inquiry</h2>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Contact Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Name:</b></td>
<td style="padding: 8px 0;">${fullName}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${number}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${emailId || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Course:</b></td>
<td style="padding: 8px 0;">${courseName || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Subject:</b></td>
<td style="padding: 8px 0;">${subject || "-"}</td>
</tr>
</table>
<p><b>Name:</b> ${fullName}</p>
<p><b>Phone:</b> ${number}</p>
<p><b>Email:</b> ${emailId || "-"}</p>
<!-- Message Box -->
<div style="margin-top: 20px;">
<h3>Message</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${message ? message.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
<p><b>Course:</b> ${courseName || "-"}</p>
<p><b>Subject:</b> ${subject || "-"}</p>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This message was sent from the GG Hospital website (Academics & Research Inquiry).
</div>
</div>
</div>
`,
<p><b>Message:</b></p>
<p>${message || "-"}</p>
`,
});
}
} catch (err) {
+76 -141
View File
@@ -38,88 +38,15 @@ export const createAppointment = async (req, res) => {
to: emailList,
subject: "New Appointment Booked",
html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<h2 style="margin: 0;">GG Hospital</h2>
<p style="margin: 5px 0 0; font-size: 14px;">
New Appointment Booked
</p>
</div>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Patient Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Name:</b></td>
<td style="padding: 8px 0;">${name}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${mobileNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${email || "-"}</td>
</tr>
</table>
<h3 style="margin-top: 20px;">Appointment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Doctor:</b></td>
<td style="padding: 8px 0;">${appointment.doctor?.name || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Department:</b></td>
<td style="padding: 8px 0;">${appointment.department?.name || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Date:</b></td>
<td style="padding: 8px 0;">
${new Date(date).toLocaleDateString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</td>
</tr>
</table>
<!-- Message Box -->
<div style="margin-top: 20px;">
<h3>Message</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${message ? message.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This appointment was booked via the GG Hospital website.
</div>
</div>
</div>
`,
<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) {
@@ -144,60 +71,74 @@ export const createAppointment = async (req, res) => {
export const getAppointments = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
const page = parseInt(req.query.page);
const limit = parseInt(req.query.limit);
const { date, startDate, endDate, search } = req.query;
const search = req.query.search || "";
const doctor = req.query.doctor || "";
const department = req.query.department || "";
const date = req.query.date || "";
const where = {};
if (!page && !limit) {
const appointments = await prisma.appointment.findMany({
include: {
doctor: true,
department: true,
},
orderBy: { createdAt: "desc" },
});
const hasSingleDate = date && date.trim() !== "";
const hasRange =
(startDate && startDate.trim() !== "") ||
(endDate && endDate.trim() !== "");
if (hasSingleDate) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setHours(23, 59, 59, 999);
where.date = {
gte: start,
lte: end,
};
return res.status(200).json({
success: true,
data: appointments,
meta: null,
});
}
if (!hasSingleDate && hasRange) {
const dateFilter = {};
const currentPage = page || 1;
const currentLimit = limit || 10;
const skip = (currentPage - 1) * currentLimit;
if (startDate && startDate.trim() !== "") {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const where = {
AND: [
search
? {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ mobileNumber: { contains: search } },
{ email: { contains: search, mode: "insensitive" } },
],
}
: {},
dateFilter.gte = start;
}
doctor
? {
doctor: {
name: { contains: doctor, mode: "insensitive" },
},
}
: {},
if (endDate && endDate.trim() !== "") {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
department
? {
department: {
name: { contains: department, mode: "insensitive" },
},
}
: {},
dateFilter.lte = end;
}
where.date = dateFilter;
}
if (search && search.trim() !== "") {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ mobileNumber: { contains: search } },
{ email: { contains: search, mode: "insensitive" } },
];
}
date
? {
date: {
gte: new Date(date),
lt: new Date(
new Date(date).setDate(new Date(date).getDate() + 1),
),
},
}
: {},
],
};
const [appointments, total] = await Promise.all([
prisma.appointment.findMany({
@@ -206,31 +147,25 @@ export const getAppointments = async (req, res) => {
doctor: true,
department: true,
},
orderBy: {
createdAt: "desc",
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.appointment.count({
where,
take: currentLimit,
}),
prisma.appointment.count({ where }),
]);
res.status(200).json({
return res.status(200).json({
success: true,
data: appointments,
pagination: {
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
page: currentPage,
limit: currentLimit,
totalPages: Math.ceil(total / currentLimit),
},
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch appointments",
@@ -1,5 +1,4 @@
import prisma from "../prisma/client.js";
import slugify from "slugify";
/* CREATE BLOG */
@@ -14,7 +13,6 @@ export async function createBlog(req, res) {
image,
content,
isActive,
slug: slugify(title),
},
});
@@ -56,26 +54,6 @@ export async function getAllBlogs(req, res) {
/* GET SINGLE BLOG */
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 {
const id = Number(req.params.id);
+10 -73
View File
@@ -39,82 +39,19 @@ export const createCandidate = async (req, res) => {
to: emailList,
subject: "New Job Application Received",
html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<h2 style="margin: 0;">GG Hospital</h2>
<p style="margin: 5px 0 0; font-size: 14px;">
New Job Application Received
</p>
</div>
<h2>New Candidate Application</h2>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Candidate Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Name:</b></td>
<td style="padding: 8px 0;">${fullName}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${mobile}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${email}</td>
</tr>
</table>
<p><b>Name:</b> ${fullName}</p>
<p><b>Phone:</b> ${mobile}</p>
<p><b>Email:</b> ${email}</p>
<h3 style="margin-top: 20px;">Application Details</h3>
<p><b>Applied For:</b> ${candidate.career?.post || "-"}</p>
<p><b>Designation:</b> ${candidate.career?.designation || "-"}</p>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Applied For:</b></td>
<td style="padding: 8px 0;">${candidate.career?.post || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Designation:</b></td>
<td style="padding: 8px 0;">${candidate.career?.designation || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Subject:</b></td>
<td style="padding: 8px 0;">${subject || "-"}</td>
</tr>
</table>
<!-- Cover Letter -->
<div style="margin-top: 20px;">
<h3>Cover Letter</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${coverLetter ? coverLetter.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This application was submitted via the GG Hospital careers page.
</div>
</div>
</div>
`,
<p><b>Subject:</b> ${subject || "-"}</p>
<p><b>Cover Letter:</b></p>
<p>${coverLetter || "-"}</p>
`,
});
}
} catch (err) {
+6 -20
View File
@@ -4,11 +4,8 @@ import prisma from "../prisma/client.js";
export const getAllCareers = async (req, res) => {
try {
const { admin } = req.query;
const careers = await prisma.career.findMany({
where: admin === "true" ? {} : { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
orderBy: {createdAt: "desc"},
});
const response = careers.map((c) => ({
@@ -20,8 +17,6 @@ export const getAllCareers = async (req, res) => {
email: c.email,
number: c.number,
status: c.status,
isActive: c.isActive,
sortOrder: c.sortOrder,
}));
return res.status(200).json({
@@ -49,8 +44,6 @@ export const createCareer = async (req, res) => {
email,
number,
status,
isActive,
sortOrder,
} = req.body;
if (!post || !designation) {
@@ -69,8 +62,6 @@ export const createCareer = async (req, res) => {
email,
number,
status,
isActive: isActive !== undefined ? isActive : true,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
},
});
@@ -92,16 +83,11 @@ export const createCareer = async (req, res) => {
export const updateCareer = async (req, res) => {
try {
const { id } = req.params;
const updateData = { ...req.body };
if (updateData.sortOrder !== undefined) {
updateData.sortOrder = Number(updateData.sortOrder);
}
const {id} = req.params;
const career = await prisma.career.update({
where: { id: Number(id) },
data: updateData,
where: {id: Number(id)},
data: req.body,
});
return res.status(200).json({
@@ -122,10 +108,10 @@ export const updateCareer = async (req, res) => {
export const deleteCareer = async (req, res) => {
try {
const { id } = req.params;
const {id} = req.params;
await prisma.career.delete({
where: { id: Number(id) },
where: {id: Number(id)},
});
return res.status(200).json({
@@ -2,24 +2,18 @@ import prisma from "../prisma/client.js";
export const getAllDepartments = async (req, res) => {
try {
const {admin} = req.query;
const departments = await prisma.department.findMany({
where: admin === "true" ? {} : {isActive: true},
orderBy: [{sortOrder: "asc"}, {name: "asc"}],
orderBy: {name: "asc"},
});
const response = departments.map((dep) => ({
departmentId: dep.departmentId,
name: dep.name,
image: dep.image ?? "",
para1: dep.para1 ?? "",
para2: dep.para2 ?? "",
para3: dep.para3 ?? "",
facilities: dep.facilities ?? "",
services: dep.services ?? "",
isActive: dep.isActive,
sortOrder: dep.sortOrder,
}));
return res.status(200).json({
@@ -35,71 +29,10 @@ 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,
isActive: true,
},
});
if (!department) {
return res.status(404).json({
success: false,
message: "Department not found or inactive",
});
}
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 ?? "",
isActive: department.isActive,
sortOrder: department.sortOrder,
};
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) {
try {
const {
departmentId,
name,
image,
para1,
para2,
para3,
facilities,
services,
isActive,
sortOrder,
} = req.body;
const {departmentId, name, para1, para2, para3, facilities, services} =
req.body;
if (!departmentId || !name) {
return res
@@ -111,14 +44,11 @@ export async function createDepartment(req, res) {
data: {
departmentId,
name,
image,
para1,
para2,
para3,
facilities,
services,
isActive: isActive !== undefined ? isActive : true,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
},
});
@@ -130,7 +60,7 @@ export async function createDepartment(req, res) {
if (error.code === "P2002") {
return res.status(409).json({error: "Department already exists"});
}
console.error(error);
res.status(500).json({error: "Failed to create department"});
}
}
@@ -138,15 +68,19 @@ export async function createDepartment(req, res) {
export const updateDepartment = async (req, res) => {
try {
const {departmentId} = req.params;
const updateData = {...req.body};
if (updateData.sortOrder !== undefined) {
updateData.sortOrder = Number(updateData.sortOrder);
}
const {name, para1, para2, para3, facilities, services} = req.body;
const department = await prisma.department.update({
where: {departmentId},
data: updateData,
data: {
name,
para1,
para2,
para3,
facilities,
services,
},
});
return res.status(200).json({
+151 -347
View File
@@ -4,76 +4,50 @@ import prisma from "../prisma/client.js";
export const getAllDoctors = async (req, res) => {
try {
const {admin} = req.query;
const doctors = await prisma.doctor.findMany({
where: admin === "true" ? {} : {isActive: true},
include: {
seo: true,
departments: {
include: {
department: true,
timing: true,
},
},
specializations: {
orderBy: {
createdAt: "asc",
},
},
},
orderBy: [{globalSortOrder: "asc"}, {name: "asc"}],
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,
isActive: doc.isActive,
experience: doc.experience,
professionalSummary: doc.professionalSummary,
globalSortOrder: doc.globalSortOrder,
specializations: doc.specializations.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
})),
seo: {
seoTitle: doc.seo?.seoTitle ?? "",
metaDescription: doc.seo?.metaDescription ?? "",
focusKeyphrase: doc.seo?.focusKeyphrase ?? "",
slug: doc.seo?.slug ?? "",
tags: doc.seo?.tags ?? [],
const formatted = doctors.map((doc, index) => {
return {
SL_NO: String(index + 1),
doctorId: doc.doctorId,
name: doc.name,
designation: doc.designation,
workingStatus: doc.workingStatus,
qualification: doc.qualification,
ogTitle: doc.seo?.ogTitle ?? "",
ogDescription: doc.seo?.ogDescription ?? "",
ogImage: doc.seo?.ogImage ?? "",
},
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);
departments: doc.departments.map((d) => {
const t = d.timing || {};
return {
departmentId: d.department.departmentId,
departmentName: d.department.name,
timing: timingArray.join(" & "),
deptSortOrder: d.sortOrder,
};
}),
}));
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,
@@ -97,8 +71,6 @@ export const getDoctorByDoctorId = async (req, res) => {
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
include: {
seo: true,
specializations: true,
departments: {
include: {
department: true,
@@ -118,28 +90,9 @@ export const getDoctorByDoctorId = async (req, res) => {
const response = {
doctorId: doctor.doctorId,
name: doctor.name,
image: doctor.image ?? "",
designation: doctor.designation,
workingStatus: doctor.workingStatus,
qualification: doctor.qualification,
experience: doctor.experience,
professionalSummary: doctor.professionalSummary,
seo: {
seoTitle: doctor.seo?.seoTitle ?? "",
metaDescription: doctor.seo?.metaDescription ?? "",
focusKeyphrase: doctor.seo?.focusKeyphrase ?? "",
slug: doctor.seo?.slug ?? "",
tags: doctor.seo?.tags ?? [],
ogTitle: doctor.seo?.ogTitle ?? "",
ogDescription: doctor.seo?.ogDescription ?? "",
ogImage: doctor.seo?.ogImage ?? "",
},
specializations:
doctor.specializations?.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
})) ?? [],
departments: doctor.departments.map((d) => ({
departmentId: d.department.departmentId,
departmentName: d.department.name,
@@ -160,115 +113,25 @@ export const getDoctorByDoctorId = async (req, res) => {
}
};
// 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 doctorsInDept = await prisma.doctorDepartment.findMany({
where: {
departmentId: department.id,
doctor: {isActive: true},
},
include: {
doctor: true,
},
orderBy: {sortOrder: "asc"},
});
const result = doctorsInDept.map((d) => ({
GG_ID: d.doctor.doctorId,
Name: d.doctor.name,
image: d.doctor.image ?? "",
designation: d.doctor.designation,
hierarchyOrder: d.sortOrder,
}));
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,
isActive,
globalSortOrder,
departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
ogTitle,
ogDescription,
ogImage,
} = req.body;
const seo = await prisma.seo.create({
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
// Open Graph
ogTitle,
ogDescription,
ogImage,
},
});
const doctor = await prisma.doctor.create({
data: {
doctorId,
name,
image,
designation,
workingStatus,
qualification,
experience: experience ? Number(experience) : null,
professionalSummary,
seoId: seo.id,
isActive: isActive !== undefined ? isActive : true,
globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
},
});
@@ -283,7 +146,6 @@ export const createDoctor = async (req, res) => {
data: {
doctorId: doctor.id,
departmentId: department.id,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
},
});
@@ -296,17 +158,6 @@ export const createDoctor = async (req, res) => {
});
}
}
if (specializations?.length) {
await prisma.doctorSpecialization.createMany({
data: specializations
.filter((item) => item.name?.trim())
.map((item) => ({
name: item.name.trim(),
description: item.description?.trim() || null,
doctorId: doctor.id,
})),
});
}
res.status(201).json({
success: true,
@@ -325,157 +176,8 @@ export const createDoctor = async (req, res) => {
export const updateDoctor = async (req, res) => {
try {
const {doctorId} = req.params;
const {
name,
designation,
image,
workingStatus,
qualification,
isActive,
globalSortOrder,
departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
} = 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,
isActive,
experience: experience ? Number(experience) : null,
professionalSummary,
globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
},
});
if (doctor.seoId) {
await prisma.seo.update({
where: {
id: doctor.seoId,
},
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
} else {
const seo = await prisma.seo.create({
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
await prisma.doctor.update({
where: {
id: doctor.id,
},
data: {
seoId: seo.id,
},
});
}
const hasTimingData = departments?.some(
(dep) => dep.timing && Object.keys(dep.timing).length > 0,
);
// Update Specializations
if (Array.isArray(specializations)) {
await prisma.doctorSpecialization.deleteMany({
where: {
doctorId: doctor.id,
},
});
if (specializations.length) {
await prisma.doctorSpecialization.createMany({
data: specializations
.filter((item) => item.name?.trim())
.map((item) => ({
name: item.name.trim(),
description: item.description?.trim() || null,
doctorId: doctor.id,
})),
});
}
}
if (departments && Array.isArray(departments) && hasTimingData) {
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 targetDept = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!targetDept) continue;
const newDD = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: targetDept.id,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
},
});
if (dep.timing) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctorTiming.create({
data: {doctorDepartmentId: newDD.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 {name, designation, workingStatus, qualification, departments} =
req.body;
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
@@ -488,6 +190,90 @@ export const deleteDoctor = async (req, res) => {
});
}
await prisma.doctor.update({
where: {id: doctor.id},
data: {
name,
designation,
workingStatus,
qualification,
},
});
const oldRelations = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id},
});
for (const rel of oldRelations) {
await prisma.doctorTiming.deleteMany({
where: {doctorDepartmentId: rel.id},
});
}
await prisma.doctorDepartment.deleteMany({
where: {doctorId: doctor.id},
});
for (const dep of departments) {
const department = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!department) continue;
const doctorDepartment = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: department.id,
},
});
if (dep.timing) {
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...dep.timing,
},
});
}
}
res.status(200).json({
success: true,
message: "Doctor updated successfully",
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to update doctor",
});
}
};
//delete doctor
export const deleteDoctor = async (req, res) => {
try {
const {doctorId} = req.params;
if (!doctorId) {
return res.status(400).json({
success: false,
message: "Doctor ID is required",
});
}
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
});
if (!doctor) {
return res.status(404).json({
success: false,
message: `Doctor with ID ${doctorId} not found`,
});
}
const doctorDepartments = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id},
});
@@ -508,7 +294,7 @@ export const deleteDoctor = async (req, res) => {
res.status(200).json({
success: true,
message: "Doctor deleted successfully",
message: `Doctor ${doctorId} deleted successfully`,
});
} catch (error) {
console.error(error);
@@ -534,19 +320,23 @@ export const getDoctorTimings = async (req, res) => {
});
const result = doctors.map((doc) => {
const timing = doc.departments[0]?.timing || {};
let timing = {};
if (doc.departments.length > 0) {
timing = doc.departments[0].timing ?? {};
}
return {
Doctor_ID: doc.doctorId,
Doctor: doc.name,
Monday: timing.monday || "",
Tuesday: timing.tuesday || "",
Wednesday: timing.wednesday || "",
Thursday: timing.thursday || "",
Friday: timing.friday || "",
Saturday: timing.saturday || "",
Sunday: timing.sunday || "",
Additional: timing.additional || "",
Monday: timing?.monday ?? "",
Tuesday: timing?.tuesday ?? "",
Wednesday: timing?.wednesday ?? "",
Thursday: timing?.thursday ?? "",
Friday: timing?.friday ?? "",
Saturday: timing?.saturday ?? "",
Sunday: timing?.sunday ?? "",
Additional: timing?.additional ?? "",
};
});
@@ -590,12 +380,26 @@ export const getDoctorTimingById = async (req, res) => {
const result = {
doctorId: doctor.doctorId,
doctorName: doctor.name,
departments: doctor.departments.map((d) => ({
departmentId: d.department.departmentId,
departmentName: d.department.name,
deptSortOrder: d.sortOrder,
timing: d.timing || {},
})),
departments: doctor.departments.map((d) => {
const t = d.timing || {};
return {
departmentId: d.department.departmentId,
departmentName: d.department.name,
timing: {
monday: t.monday || "",
tuesday: t.tuesday || "",
wednesday: t.wednesday || "",
thursday: t.thursday || "",
friday: t.friday || "",
saturday: t.saturday || "",
sunday: t.sunday || "",
additional: t.additional || "",
},
};
}),
};
res.status(200).json({
@@ -1,440 +0,0 @@
import prisma from "../prisma/client.js";
import { sendEmail } from "../utils/sendEmail.js";
import { getEmailsByType } from "../utils/getEmailByTypes.js";
export const getAllCategories = async (req, res) => {
try {
const { admin } = req.query;
const categories = await prisma.healthCheckCategory.findMany({
where: admin === "true" ? {} : { isActive: true },
orderBy: { sortOrder: "asc" },
include: {
_count: { select: { packages: true } },
},
});
return res.status(200).json({ success: true, data: categories });
} catch (error) {
return res
.status(500)
.json({ success: false, message: "Failed to fetch categories" });
}
};
export const createCategory = async (req, res) => {
try {
const { name, slug, description, isActive, sortOrder } = req.body;
const category = await prisma.healthCheckCategory.create({
data: {
name,
slug: slug || null,
description,
isActive: isActive ?? true,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
},
});
return res
.status(201)
.json({ success: true, message: "Category created", data: category });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to create category" });
}
};
export const updateCategory = async (req, res) => {
try {
const { id } = req.params;
const data = { ...req.body };
delete data.id;
delete data._count;
delete data.createdAt;
delete data.updatedAt;
if (data.sortOrder !== undefined) data.sortOrder = Number(data.sortOrder);
if (data.slug === "") data.slug = null;
const updatedCategory = await prisma.$transaction(async (tx) => {
const category = await tx.healthCheckCategory.update({
where: { id: Number(id) },
data,
});
if (data.isActive === false) {
await tx.healthPackage.updateMany({
where: { categoryId: Number(id) },
data: { isActive: false },
});
}
return category;
});
return res.status(200).json({
success: true,
message: "Category updated",
data: updatedCategory,
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to update category" });
}
};
export const deleteCategory = async (req, res) => {
try {
const { id } = req.params;
await prisma.healthCheckCategory.delete({
where: { id: Number(id) },
});
return res
.status(200)
.json({ success: true, message: "Category deleted successfully" });
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message:
"Failed to delete category. Ensure no packages are linked to it.",
});
}
};
export const getAllPackages = async (req, res) => {
try {
const { admin, categorySlug } = req.query;
const packages = await prisma.healthPackage.findMany({
where: {
AND: [
admin === "true" ? {} : { isActive: true },
categorySlug ? { category: { slug: categorySlug } } : {},
],
},
include: { category: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return res.status(200).json({ success: true, data: packages });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to fetch packages" });
}
};
export const createPackage = async (req, res) => {
try {
const {
name,
slug,
description,
price,
image,
discountedPrice,
inclusions,
categoryId,
isActive,
isFeatured,
sortOrder,
} = req.body;
const healthPackage = await prisma.healthPackage.create({
data: {
name,
slug,
description,
price,
image,
discountedPrice,
inclusions,
categoryId: Number(categoryId),
isActive: isActive ?? true,
isFeatured: isFeatured ?? false,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
},
});
return res
.status(201)
.json({ success: true, message: "Package created", data: healthPackage });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to create package" });
}
};
export const updatePackage = async (req, res) => {
try {
const { id } = req.params;
const data = { ...req.body };
delete data.id;
delete data.category;
if (data.categoryId) data.categoryId = Number(data.categoryId);
if (data.sortOrder) data.sortOrder = Number(data.sortOrder);
const updated = await prisma.healthPackage.update({
where: { id: Number(id) },
data,
});
return res
.status(200)
.json({ success: true, message: "Package updated", data: updated });
} catch (error) {
console.error(error);
return res.status(500).json({ success: false, message: "Update failed" });
}
};
export const deletePackage = async (req, res) => {
try {
const { id } = req.params;
await prisma.healthPackage.delete({ where: { id: Number(id) } });
return res.status(200).json({ success: true, message: "Package deleted" });
} catch (error) {
console.error(error);
return res.status(500).json({ success: false, message: "Delete failed" });
}
};
export const createPackageInquiry = async (req, res) => {
try {
const {
fullName,
mobileNumber,
email,
age,
gender,
preferredDate,
packageId,
message,
} = req.body;
const inquiry = await prisma.healthPackageInquiry.create({
data: {
fullName,
mobileNumber,
email,
age: age ? Number(age) : null,
gender,
preferredDate: preferredDate ? new Date(preferredDate) : null,
message,
packageId: Number(packageId),
},
include: {
healthPackage: true,
},
});
try {
const emailList = await getEmailsByType("HCINQUIRY");
if (emailList) {
await sendEmail({
to: emailList,
subject: "New Health Checkup Package Inquiry",
html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<h2 style="margin: 0;">GG Hospital</h2>
<p style="margin: 5px 0 0; font-size: 14px;">
New Health Checkup Package Inquiry
</p>
</div>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Inquirer Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; width: 35%;"><b>Name:</b></td>
<td style="padding: 8px 0;">${fullName}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${mobileNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${email || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Age:</b></td>
<td style="padding: 8px 0;">${age || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Gender:</b></td>
<td style="padding: 8px 0;">${gender || "-"}</td>
</tr>
</table>
<h3 style="margin-top: 20px;">Package Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; width: 35%;"><b>Package Name:</b></td>
<td style="padding: 8px 0;">${inquiry.healthPackage?.name || "Unknown Package"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Preferred Date:</b></td>
<td style="padding: 8px 0;">
${
preferredDate
? new Date(preferredDate).toLocaleDateString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
})
: "Not specified"
}
</td>
</tr>
</table>
<!-- Message Box -->
<div style="margin-top: 20px;">
<h3>Message</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${message ? message.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This inquiry was submitted via the GG Hospital website.
</div>
</div>
</div>
`,
});
}
} catch (err) {
console.error("Email failed:", err);
}
return res.status(201).json({
success: true,
message: "Booking inquiry sent successfully",
data: inquiry,
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to submit inquiry" });
}
};
export const getPackageBySlug = async (req, res) => {
try {
const { slug } = req.params;
const healthPackage = await prisma.healthPackage.findFirst({
where: { slug, isActive: true },
include: { category: true },
});
if (!healthPackage) {
return res
.status(404)
.json({ success: false, message: "Package not found" });
}
return res.status(200).json({ success: true, data: healthPackage });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to fetch package" });
}
};
export const getAllInquiries = async (req, res) => {
try {
const { page = 1, limit = 10, filterDate, startDate, endDate } = req.query;
const queryPage = parseInt(page);
const queryLimit = parseInt(limit);
const skip = (queryPage - 1) * queryLimit;
let where = {};
if (filterDate) {
where.preferredDate = {
gte: new Date(`${filterDate}T00:00:00.000Z`),
lte: new Date(`${filterDate}T23:59:59.999Z`),
};
} else if (startDate || endDate) {
where.preferredDate = {};
if (startDate) {
where.preferredDate.gte = new Date(`${startDate}T00:00:00.000Z`);
}
if (endDate) {
where.preferredDate.lte = new Date(`${endDate}T23:59:59.999Z`);
}
}
const [total, inquiries] = await prisma.$transaction([
prisma.healthPackageInquiry.count({ where }),
prisma.healthPackageInquiry.findMany({
where,
skip,
take: queryLimit,
include: {
healthPackage: {
include: { category: true },
},
},
orderBy: { createdAt: "desc" },
}),
]);
return res.status(200).json({
success: true,
data: inquiries,
pagination: {
total,
page: queryPage,
limit: queryLimit,
totalPages: Math.ceil(total / queryLimit),
},
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to fetch inquiries" });
}
};
-304
View File
@@ -1,304 +0,0 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const bulkImportExcelData = async (req, res) => {
try {
const {
departments,
doctors,
timings,
careers,
inquiries,
academics,
appointments,
candidates,
news,
} = req.body;
console.log("🚀 Starting Robust Data Import...");
// 1. DEPARTMENTS
if (departments) {
for (const row of departments) {
if (!row.SL_NO) continue;
await prisma.department.upsert({
where: { departmentId: row.SL_NO.toString() },
update: {
name: row.Department?.toString(),
para1: row.para1?.toString() || null,
para2: row.para2?.toString() || null,
para3: row.para3?.toString() || null,
facilities: row.facilities?.toString() || null,
services: row.services?.toString() || null,
},
create: {
departmentId: row.SL_NO.toString(),
name: row.Department?.toString(),
para1: row.para1?.toString() || null,
para2: row.para2?.toString() || null,
para3: row.para3?.toString() || null,
facilities: row.facilities?.toString() || null,
services: row.services?.toString() || null,
},
});
}
}
// 2. DOCTORS
if (doctors) {
for (const row of doctors) {
if (!row.GG_ID) continue;
const doctor = await prisma.doctor.upsert({
where: { doctorId: row.GG_ID.toString() },
update: {
name: row.Name?.toString(),
designation: row.Designation?.toString() || null,
workingStatus: row["Working Status"]?.toString() || null,
qualification: row.Qualification?.toString() || null,
},
create: {
doctorId: row.GG_ID.toString(),
name: row.Name?.toString(),
designation: row.Designation?.toString() || null,
workingStatus: row["Working Status"]?.toString() || null,
qualification: row.Qualification?.toString() || null,
},
});
if (row.Department_ID) {
const dept = await prisma.department.findUnique({
where: { departmentId: row.Department_ID.toString() },
});
if (dept) {
await prisma.doctorDepartment.upsert({
where: {
doctorId_departmentId: {
doctorId: doctor.id,
departmentId: dept.id,
},
},
update: {},
create: {
doctorId: doctor.id,
departmentId: dept.id,
},
});
}
}
}
}
// 3. TIMINGS
if (timings) {
for (const row of timings) {
if (!row.GG_ID) continue;
const doctor = await prisma.doctor.findUnique({
where: { doctorId: row.GG_ID.toString() },
include: { departments: true },
});
if (doctor && doctor.departments.length > 0) {
const doctorDeptId = doctor.departments[0].id;
const rawAdd = row.Additional?.toString() || "";
const rawMon = row.Monday?.toString() || "";
const isAppt = (val) => /appointment|basis|on call/i.test(val);
let finalAdd = rawAdd;
if (!finalAdd && isAppt(rawMon)) finalAdd = rawMon;
await prisma.doctorTiming.upsert({
where: { doctorDepartmentId: doctorDeptId },
update: {
monday: isAppt(rawMon) ? null : row.Monday?.toString() || null,
tuesday: row.Tuesday?.toString() || null,
wednesday: row.Wednesday?.toString() || null,
thursday: row.Thursday?.toString() || null,
friday: row.Friday?.toString() || null,
saturday: row.Saturday?.toString() || null,
sunday: row.Sunday?.toString() || null,
additional: finalAdd || null,
},
create: {
doctorDepartmentId: doctorDeptId,
monday: isAppt(rawMon) ? null : row.Monday?.toString() || null,
tuesday: row.Tuesday?.toString() || null,
wednesday: row.Wednesday || null,
thursday: row.Thursday || null,
friday: row.Friday || null,
saturday: row.Saturday || null,
sunday: row.Sunday || null,
additional: finalAdd || null,
},
});
}
}
}
// 4. CAREERS
if (careers) {
for (const row of careers) {
if (!row.Post) continue;
const cId = row.Id ? parseInt(row.Id) : undefined;
const data = {
post: row.Post?.toString(),
designation: row.Designation?.toString() || null,
qualification: row.Qualification?.toString() || null,
experienceNeed: row.ExperienceNeed?.toString() || null,
email: row.HiringEmail?.toString() || null,
number: row.Number?.toString() || null,
status: row.Status?.toString() || "new",
};
if (cId) {
await prisma.career.upsert({
where: { id: cId },
update: data,
create: { ...data, id: cId },
});
} else {
await prisma.career.create({ data });
}
}
}
// 5. INQUIRIES
if (inquiries) {
for (const row of inquiries) {
if (!row.FullName) continue;
await prisma.inquiry.create({
data: {
fullName: row.FullName.toString(),
number: row.Number?.toString() || "",
emailId: row.EmailId?.toString() || null,
subject: row.Subject?.toString() || null,
message: row.Message?.toString() || null,
createdAt: row.Date ? new Date(row.Date) : new Date(),
},
});
}
}
// 6. ACADEMICS & RESEARCH (FIXED HERE)
if (academics) {
for (const row of academics) {
if (!row.FullName) continue;
await prisma.academicsResearch.create({
data: {
fullName: row.FullName.toString(),
number: row.Number?.toString() || "",
emailId: row.EmailId?.toString() || null,
subject: row.Subject?.toString() || null, // Force String
courseName: row["Course Name"]?.toString() || null,
message: row.Message?.toString() || null,
createdAt: row.Date ? new Date(row.Date) : new Date(),
},
});
}
}
// 7. APPOINTMENTS
if (appointments) {
for (const row of appointments) {
if (!row.FullName) continue;
const doctorName = row.Doctor?.toString();
const departmentName = row["Department Id"]?.toString();
const doctor = await prisma.doctor.findFirst({
where: { name: doctorName },
});
const department = await prisma.department.findFirst({
where: { name: departmentName },
});
const parseDate = (value) => {
if (!value) return new Date();
// Excel numeric date
if (typeof value === "number") {
return new Date((value - 25569) * 86400 * 1000);
}
if (typeof value === "string") {
const v = value.trim();
// Handle DD/MM/YYYY
const ddmmyyyy = /^(\d{2})\/(\d{2})\/(\d{4})$/;
const match = v.match(ddmmyyyy);
if (match) {
const [_, dd, mm, yyyy] = match;
return new Date(`${yyyy}-${mm}-${dd}`);
}
// Fallback (ISO or other valid formats)
const d = new Date(v);
if (!isNaN(d.getTime()) && d.getFullYear() < 2100) {
return d;
}
}
console.warn("⚠️ Invalid date, using current date:", value);
return new Date();
};
if (doctor && department) {
await prisma.appointment.create({
data: {
name: row.FullName.toString(),
mobileNumber: row.Number?.toString() || "",
email: row["Email Id"]?.toString() || null,
message: row.Message?.toString() || null,
date: parseDate(row.Date),
doctorId: doctor.doctorId,
departmentId: department.departmentId,
},
});
}
}
}
// 8. CANDIDATES
if (candidates) {
for (const row of candidates) {
if (!row.FullName || !row.CareerId) continue;
await prisma.candidate
.create({
data: {
fullName: row.FullName.toString(),
mobile: row.Number?.toString() || "",
email: row.EmailId?.toString() || "",
subject: row.Subject?.toString() || "",
coverLetter: row["Cover Letter"]?.toString() || "",
careerId: parseInt(row.CareerId),
createdAt: row.Date ? new Date(row.Date) : new Date(),
},
})
.catch(() => {});
}
}
// 9. NEWS & MEDIA
if (news) {
for (const row of news) {
if (!row.Headline) continue;
await prisma.newsMedia.create({
data: {
headline: row.Headline.toString(),
content: row.Content?.toString() || null,
firstPara: row.FirstPara?.toString() || null,
secondPara: row.SecondPara?.toString() || null,
author: row.Author?.toString() || null,
date: row.Date ? new Date(row.Date) : null,
},
});
}
}
res
.status(200)
.json({ success: true, message: "✅ Import completed successfully!" });
} catch (error) {
console.error("❌ Bulk Import Error:", error);
res.status(500).json({ success: false, error: error.message });
}
};
@@ -1,8 +1,5 @@
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 {
@@ -24,80 +21,6 @@ export const createInquiry = async (req, res) => {
message,
},
});
try {
const emailList = await getEmailsByType("INQUIRY");
if (emailList && emailList.length > 0) {
await sendEmail({
to: emailList,
subject: "New Inquiry Received",
html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<h2 style="margin: 0;">GG Hospital</h2>
<p style="margin: 5px 0 0; font-size: 14px;">New Inquiry Received</p>
</div>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Contact Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Name:</b></td>
<td style="padding: 8px 0;">${fullName}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${number}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${emailId}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Subject:</b></td>
<td style="padding: 8px 0;">${subject}</td>
</tr>
</table>
<!-- Message Box -->
<div style="margin-top: 20px;">
<h3>Message</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${message ? message.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This message was sent from the GG Hospital website contact form.
</div>
</div>
</div>
`,
});
}
} catch (err) {
console.error("Inquiry email failed:", err);
}
res.status(200).json({
success: true,
+33 -61
View File
@@ -4,41 +4,43 @@ import prisma from "../prisma/client.js";
export const getAllNews = async (req, res) => {
try {
const page = req.query.page ? parseInt(req.query.page) : null;
const limit = req.query.limit ? parseInt(req.query.limit) : null;
const search = req.query.search?.trim() || "";
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({
orderBy: { createdAt: "desc" },
});
const searchFilter = search
? {
headline: {
contains: search,
mode: "insensitive",
},
}
: {};
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,
}));
const whereCondition = {
...searchFilter,
};
return res.status(200).json({
success: true,
data: response,
meta: null,
});
}
const skip = page && limit ? (page - 1) * limit : undefined;
const take = limit ? limit : undefined;
const currentPage = page || 1;
const currentLimit = limit || 10;
const skip = (currentPage - 1) * currentLimit;
const [news, total] = await Promise.all([
prisma.newsMedia.findMany({
where: whereCondition,
include: includeImages,
orderBy: { createdAt: "desc" },
skip,
take,
}),
prisma.newsMedia.count({
where: whereCondition,
take: currentLimit,
}),
prisma.newsMedia.count(),
]);
const response = news.map((n) => ({
@@ -49,10 +51,6 @@ export const getAllNews = async (req, res) => {
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({
@@ -60,9 +58,9 @@ export const getAllNews = async (req, res) => {
data: response,
meta: {
total,
page: page || 1,
limit: limit || total,
totalPages: limit ? Math.ceil(total / limit) : 1,
page: currentPage,
limit: currentLimit,
totalPages: Math.ceil(total / currentLimit),
},
});
} catch (error) {
@@ -73,6 +71,7 @@ export const getAllNews = async (req, res) => {
});
}
};
// GET NEWS BY ID
export const getNewsById = async (req, res) => {
@@ -81,7 +80,6 @@ export const getNewsById = async (req, res) => {
const n = await prisma.newsMedia.findUnique({
where: { id: Number(id) },
include: { images: true },
});
if (!n) {
@@ -99,10 +97,6 @@ export const getNewsById = async (req, res) => {
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({
@@ -122,15 +116,7 @@ export const getNewsById = async (req, res) => {
export const createNews = async (req, res) => {
try {
const {
headline,
content,
firstPara,
secondPara,
date,
author,
imageUrls,
} = req.body;
const { headline, content, firstPara, secondPara, date, author } = req.body;
if (!headline) {
return res.status(400).json({
@@ -147,13 +133,7 @@ export const createNews = async (req, res) => {
secondPara,
date: date ? new Date(date) : null,
author,
images: imageUrls
? {
create: imageUrls.map((url) => ({ url })),
}
: undefined,
},
include: { images: true },
});
return res.status(201).json({
@@ -175,21 +155,13 @@ export const createNews = async (req, res) => {
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,
...req.body,
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({
@@ -11,8 +11,8 @@ import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.post("/", createAcademicsResearch);
router.get("/getAll", jwtAuthMiddleware, getAcademicsResearch);
router.get("/:id", jwtAuthMiddleware, getSingleAcademicsResearch);
router.get("/getAll", getAcademicsResearch);
router.get("/:id", getSingleAcademicsResearch);
router.delete("/:id", jwtAuthMiddleware, deleteAcademicsResearch);
export default router;
+3 -3
View File
@@ -13,11 +13,11 @@ const router = express.Router();
/* PUBLIC */
router.get("/getall", jwtAuthMiddleware, getAppointments);
router.get("/getall", getAppointments);
router.post("/", createAppointment);
router.get("/:id", jwtAuthMiddleware, getAppointment);
router.patch("/:id", jwtAuthMiddleware, updateAppointment);
router.get("/:id", getAppointment);
router.patch("/:id", updateAppointment);
router.delete("/:id", jwtAuthMiddleware, deleteAppointment);
export default router;
+2 -1
View File
@@ -1,8 +1,9 @@
import express from "express";
import { login } from "../controllers/auth.controller.js";
import {register, login} from "../controllers/auth.controller.js";
const router = express.Router();
router.post("/register", register);
router.post("/login", login);
export default router;
+1 -5
View File
@@ -6,7 +6,6 @@ import {
updateBlog,
deleteBlog,
getAllBlogs,
getBlogForAdmin,
} from "../controllers/blog.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
@@ -16,14 +15,11 @@ const router = express.Router();
/* PUBLIC */
router.get("/", getBlogs);
router.get("/:slug", getBlog);
router.get("/:id", getBlog);
// Protected
router.get("/admin/all", jwtAuthMiddleware, getAllBlogs);
router.get("/admin/:id", jwtAuthMiddleware, getBlogForAdmin);
router.post("/", jwtAuthMiddleware, createBlog);
router.put("/:id", jwtAuthMiddleware, updateBlog);
router.delete("/:id", jwtAuthMiddleware, deleteBlog);
+6 -6
View File
@@ -13,13 +13,13 @@ import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
/* PUBLIC */
router.get("/getAll", getCandidates);
router.get("/:id", getCandidate);
router.get("/career/:careerId", getCandidatesByCareer);
router.post("/", createCandidate);
router.get("/getAll", jwtAuthMiddleware, getCandidates);
router.get("/:id", jwtAuthMiddleware, getCandidate);
router.get("/career/:careerId", jwtAuthMiddleware, getCandidatesByCareer);
router.patch("/:id", jwtAuthMiddleware, updateCandidate);
router.patch("/:id", updateCandidate);
router.delete("/:id", jwtAuthMiddleware, deleteCandidate);
export default router;
+3 -5
View File
@@ -6,14 +6,12 @@ import {
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);
router.post("/", createCareer);
router.patch("/:id", updateCareer);
router.delete("/:id", deleteCareer);
export default router;
-2
View File
@@ -1,7 +1,6 @@
import express from "express";
import {
getAllDepartments,
getDepartmentByName,
createDepartment,
updateDepartment,
deleteDepartment,
@@ -12,7 +11,6 @@ const router = express.Router();
// Public
router.get("/getAll", getAllDepartments);
router.get("/search", getDepartmentByName);
// Protected
router.post("/", jwtAuthMiddleware, createDepartment);
+1 -3
View File
@@ -7,7 +7,6 @@ import {
getDoctorTimings,
getDoctorTimingById,
getDoctorByDoctorId,
getDoctorsByDepartmentId,
} from "../controllers/doctor.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
@@ -15,10 +14,9 @@ import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.get("/getAll", getAllDoctors);
router.get("/search", getDoctorsByDepartmentId);
router.get("/:doctorId", getDoctorByDoctorId);
router.get("/getTimings", getDoctorTimings);
router.get("/getTimings/:doctorId", getDoctorTimingById);
router.get("/:doctorId", getDoctorByDoctorId);
router.post("/", jwtAuthMiddleware, createDoctor);
router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor);
-39
View File
@@ -1,39 +0,0 @@
import express from "express";
import {
// Categories
getAllCategories,
getPackageBySlug,
createCategory,
updateCategory,
deleteCategory,
// Packages
getAllPackages,
createPackage,
updatePackage,
deletePackage,
// Inquiries
createPackageInquiry,
getAllInquiries,
} from "../controllers/healthCheck.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.get("/packages", getAllPackages);
router.get("/packages/:slug", getPackageBySlug);
router.get("/categories", getAllCategories);
router.post("/inquiry", createPackageInquiry);
router.get("/inquiries", jwtAuthMiddleware, getAllInquiries);
router.post("/", jwtAuthMiddleware, createPackage);
router.patch("/:id", jwtAuthMiddleware, updatePackage);
router.delete("/:id", jwtAuthMiddleware, deletePackage);
router.post("/categories", jwtAuthMiddleware, createCategory);
router.patch("/categories/:id", jwtAuthMiddleware, updateCategory);
router.delete("/categories/:id", jwtAuthMiddleware, deleteCategory);
export default router;
-9
View File
@@ -1,9 +0,0 @@
import express from "express";
import { bulkImportExcelData } from "../controllers/importController.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.post("/bulk", jwtAuthMiddleware, bulkImportExcelData);
export default router;
+2 -2
View File
@@ -12,8 +12,8 @@ const router = express.Router();
router.post("/", createInquiry);
router.get("/getAll", jwtAuthMiddleware, getInquiries);
router.get("/:id", jwtAuthMiddleware, getInquiry);
router.get("/getAll", getInquiries);
router.get("/:id", getInquiry);
router.delete("/:id", jwtAuthMiddleware, deleteInquiry);
export default router;
+8 -28
View File
@@ -1,35 +1,15 @@
import express from "express";
import * as Bytescale from "@bytescale/sdk";
import multer from "multer";
import {upload} from "../controllers/upload.controller.js";
const router = express.Router();
const uploadManager = new Bytescale.UploadManager({
apiKey: process.env.BYTESCALE_SECRET_API_KEY,
});
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"});
}
router.post("/image", upload.single("image"), (req, res) => {
res.json({
success: 1,
file: {
url: `http://localhost:3000/uploads/blog/${req.file.filename}`,
},
});
});
export default router;
-47
View File
@@ -1,47 +0,0 @@
import prisma from "../prisma/client.js";
import { hashPassword } from "./password.js";
async function main() {
const username = process.argv[2];
const password = process.argv[3];
const role = process.argv[4] || "admin";
if (!username || !password) {
console.log(
"Usage: node scripts/createUser.js <username> <password> [role]",
);
process.exit(1);
}
const existingUser = await prisma.user.findUnique({
where: { username },
});
if (existingUser) {
console.log("User already exists");
process.exit(1);
}
const hashedPassword = await hashPassword(password);
const user = await prisma.user.create({
data: {
username,
password: hashedPassword,
role,
},
});
console.log("User created:", {
id: user.id,
username: user.username,
role: user.role,
});
}
main()
.catch((e) => {
console.error(e);
})
.finally(async () => {
await prisma.$disconnect();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

-45
View File
@@ -1,45 +0,0 @@
version: "3.8"
services:
backend:
build:
context: .
dockerfile: docker/dev/Dockerfile.main
ports:
- "127.0.0.1:5008:5008"
env_file:
- ./backend/.env
depends_on:
db:
condition: service_healthy
restart: unless-stopped
frontend:
build:
context: .
dockerfile: docker/dev/Dockerfile.frontend
ports:
- "127.0.0.1:3008:3000"
env_file:
- ./frontend/.env
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres_data:
external: true
name: gg-backend_postgres_data
-41
View File
@@ -1,41 +0,0 @@
services:
backend:
build:
context: .
dockerfile: docker/dev/Dockerfile.main
ports:
- "127.0.0.1:5008:5008"
env_file:
- ./backend/.env
depends_on:
db:
condition: service_healthy
restart: always
frontend:
build:
context: .
dockerfile: docker/dev/Dockerfile.frontend
ports:
- "127.0.0.1:3008:3000"
env_file:
- ./frontend/.env
restart: always
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
restart: always
volumes:
postgres_data:
-19
View File
@@ -1,19 +0,0 @@
ARG NODE_VERSION=24.15.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
RUN npm install -g serve
EXPOSE 3000
# Serve built app
CMD ["serve", "-s", "dist"]
-24
View File
@@ -1,24 +0,0 @@
ARG NODE_VERSION=24.15.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 5008
ENTRYPOINT [ "entrypoint.sh" ]
# This '$@' will be replaced by the CMD
CMD ["npm", "start"]
-14
View File
@@ -1,14 +0,0 @@
#!/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 "$@"
-5
View File
@@ -22,8 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
#env files
.env*
.env.*.local
+62 -36
View File
@@ -1,47 +1,73 @@
**GG-Dashboard**
# React + TypeScript + Vite
## Tech Stack
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
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
Currently, two official plugins are available:
## Project Structure
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
frontend/
├── src/
│ ├── api/
│ ├── assets/
│ ├── components/
│ ├── context/
│ ├── lib/
│ ├── layout/
│ ├── pages/
│ ├── services/
│ ├── utils/
│ └── App.tsx
├── .env
├── index.html
└── package.json
## React Compiler
## Installation & Setup
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).
**1. Prerequisites**
Node.js (v20+)
## Expanding the ESLint configuration
**2. Environment Variables**
VITE_API_URL="http://localhost:5008/api"
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
**3. Install Dependencies**
npm install
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
**4. Development**
npm run dev
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
## Scripts
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
npm run dev: Starts the Vite development server with Hot Module Replacement.
npm run build: Compiles TypeScript and builds the production-ready assets.
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+10 -10
View File
@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GG Admin Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19 -28
View File
@@ -28,7 +28,6 @@
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0",
@@ -116,6 +115,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1760,6 +1760,7 @@
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -4092,6 +4093,7 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -4102,6 +4104,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4112,6 +4115,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4173,6 +4177,7 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
@@ -4463,6 +4468,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4722,6 +4728,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5188,6 +5195,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@@ -5550,6 +5558,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5802,6 +5811,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -6352,15 +6362,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -6465,6 +6466,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -8129,6 +8131,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8138,6 +8141,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8145,23 +8149,6 @@
"react": "^19.2.4"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -8918,7 +8905,8 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -9125,6 +9113,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9325,6 +9314,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -9669,6 +9659,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
-1
View File
@@ -30,7 +30,6 @@
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -11
View File
@@ -1,5 +1,4 @@
import {BrowserRouter, Routes, Route, Navigate} from "react-router-dom";
import {Toaster} from "react-hot-toast";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Login from "@/pages/Login";
@@ -9,7 +8,7 @@ import DashboardLayout from "./layouts/DashboardLayout";
import ProtectedRoute from "./auth/ProtectedRoute";
import PublicRoute from "./auth/PublicRoute";
import {AuthProvider} from "./context/AuthContext";
import { AuthProvider } from "./context/AuthContext";
import Department from "./pages/Department";
import Doctor from "./pages/Doctor";
import Blog from "./pages/Blog";
@@ -21,15 +20,10 @@ 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";
import ImportData from "./pages/ImportData";
import HealthPackagePage from "./pages/HealthPackagePage";
export default function App() {
return (
<BrowserRouter>
<Toaster position="top-right" />
<AuthProvider>
<Routes>
<Route element={<PublicRoute />}>
@@ -41,7 +35,6 @@ export default function App() {
<Route path="/department" element={<Department />} />
<Route path="/doctor" element={<Doctor />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:id" element={<BlogDetail />} />
<Route path="/blog/create" element={<BlogEditorPage />} />
<Route path="/blog/edit/:id" element={<BlogEditorPage />} />
<Route path="/appointment" element={<Appointment />} />
@@ -51,8 +44,6 @@ export default function App() {
<Route path="/inquiry" element={<InquiryPage />} />
<Route path="/academics" element={<AcademicsPage />} />
<Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
</Route>
</Route>
+15 -15
View File
@@ -1,22 +1,22 @@
import apiClient from "@/api/client";
export const getAppointmentsApi = async (
page = 1,
limit = 10,
date = "",
startDate = "",
endDate = "",
search = "",
page?: number,
limit?: number,
search?: string,
doctor?: string,
department?: string,
date?: string,
) => {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
...(date && { date }),
...(startDate && { startDate }),
...(endDate && { endDate }),
...(search && { search }),
});
const res = await apiClient.get(`/appointments/getall?${params}`);
let url = "/appointments/getAll";
if (page && limit) {
url += `?page=${page}&limit=${limit}&search=${search || ""}&doctor=${
doctor || ""
}&department=${department || ""}&date=${date || ""}`;
}
const res = await apiClient.get(url);
return res.data;
};
+1 -1
View File
@@ -14,7 +14,7 @@ export const getAllBlogsApi = async () => {
};
export const getBlogByIdApi = async (id: number) => {
const res = await apiClient.get(`/blogs/admin/${id}`);
const res = await apiClient.get(`/blogs/${id}`);
return res.data;
};
+3 -41
View File
@@ -1,49 +1,11 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export const getCareersApi = async () => {
const res = await apiClient.get("/careers/getAll?admin=true");
const res = await apiClient.get("/careers/getAll");
return res.data;
};
export const createCareerApi = async (data: any) => {
try {
const res = await apiClient.post("/careers", data);
toast.success("Career created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create career");
throw error;
}
};
export const updateCareerApi = async (id: number, data: any) => {
try {
const res = await apiClient.patch(`/careers/${id}`, data);
toast.success("Career updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update career");
throw error;
}
};
export const deleteCareerApi = async (id: number) => {
try {
const res = await apiClient.delete(`/careers/${id}`);
toast.success("Career deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete career");
throw error;
}
const res = await apiClient.delete(`/careers/${id}`);
return res.data;
};
+2 -2
View File
@@ -1,10 +1,10 @@
import axios from "axios";
import type {InternalAxiosRequestConfig} from "axios";
const baseURL: string = import.meta.env.VITE_API_URL;
const BASE_URL: string = "http://localhost:3000/api";
const apiClient = axios.create({
baseURL: baseURL,
baseURL: BASE_URL,
headers: {
"Content-Type": "application/json",
},
+7 -44
View File
@@ -1,21 +1,17 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface Department {
departmentId: string;
name: string;
image?: string;
para1: string;
para2: string;
para3: string;
facilities: string;
services: string;
isActive?: boolean;
sortOrder?: number;
}
export const getDepartmentsApi = async () => {
const res = await apiClient.get("/departments/getAll?admin=true");
const res = await apiClient.get("/departments/getAll");
return res.data;
};
@@ -28,19 +24,8 @@ export const createDepartmentApi = async (data: {
facilities?: string;
services?: string;
}) => {
try {
const res = await apiClient.post("/departments", data);
toast.success("Department created successfully");
return res.data;
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Failed to create department",
);
throw error;
}
const res = await apiClient.post("/departments", data);
return res.data;
};
export const updateDepartmentApi = async (
@@ -54,33 +39,11 @@ export const updateDepartmentApi = async (
services?: string;
},
) => {
try {
const res = await apiClient.put(`/departments/${departmentId}`, data);
toast.success("Department updated successfully");
return res.data;
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Failed to update department",
);
throw error;
}
const res = await apiClient.put(`/departments/${departmentId}`, data);
return res.data;
};
export const deleteDepartmentApi = async (departmentId: string) => {
try {
const res = await apiClient.delete(`/departments/${departmentId}`);
toast.success("Department deleted successfully");
return res.data;
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Failed to delete department",
);
throw error;
}
const res = await apiClient.delete(`/departments/${departmentId}`);
return res.data;
};
+8 -39
View File
@@ -1,17 +1,13 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface Doctor {
doctorId: string;
name: string;
image?: string;
designation?: string;
workingStatus?: string;
qualification?: string;
isActive: boolean;
globalSortOrder: number;
departments?: {
departments: {
departmentId: string;
timing?: {
monday?: string;
@@ -27,7 +23,7 @@ export interface Doctor {
}
export const getDoctorsApi = async () => {
const res = await apiClient.get("/doctors/getAll?admin=true");
const res = await apiClient.get("/doctors/getAll");
return res.data;
};
@@ -37,48 +33,21 @@ export const getDoctorByIdApi = async (doctorId: string) => {
};
export const createDoctorApi = async (data: Doctor) => {
try {
const res = await apiClient.post("/doctors", data);
toast.success("Doctor created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create doctor");
throw error;
}
const res = await apiClient.post("/doctors", data);
return res.data;
};
export const updateDoctorApi = async (
doctorId: string,
data: Partial<Doctor>,
) => {
try {
const res = await apiClient.patch(`/doctors/${doctorId}`, data);
toast.success("Doctor updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update doctor");
throw error;
}
const res = await apiClient.patch(`/doctors/${doctorId}`, data);
return res.data;
};
export const deleteDoctorApi = async (doctorId: string) => {
try {
const res = await apiClient.delete(`/doctors/${doctorId}`);
toast.success("Doctor deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete doctor");
throw error;
}
const res = await apiClient.delete(`/doctors/${doctorId}`);
return res.data;
};
export const getDoctorTimingApi = async (doctorId: string) => {
-160
View File
@@ -1,160 +0,0 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface HealthPackage {
id?: number;
name: string;
slug: string;
description?: string;
price: number;
image?: string;
discountedPrice?: number;
inclusions: Record<string, string[]>;
categoryId: number;
isActive: boolean;
isFeatured: boolean;
sortOrder: number;
category?: {
name: string;
};
}
export interface HealthCategory {
id: number;
name: string;
slug: string;
sortOrder: number;
isActive: boolean;
}
export interface HealthInquiry {
id: number;
fullName: string;
mobileNumber: string;
email?: string;
age: string;
gender: string;
preferredDate: string;
message?: string;
createdAt: string;
healthPackage?: {
name: string;
category?: {
name: string;
};
};
}
export const getHealthCategoriesApi = async () => {
const res = await apiClient.get("/health-check/categories?admin=true");
return res.data;
};
export const getHealthPackagesApi = async () => {
const res = await apiClient.get("/health-check/packages?admin=true");
return res.data;
};
export const createHealthPackageApi = async (data: Partial<HealthPackage>) => {
try {
const res = await apiClient.post("/health-check", data);
toast.success("Package created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create package");
throw error;
}
};
export const updateHealthPackageApi = async (
id: number,
data: Partial<HealthPackage>,
) => {
try {
const res = await apiClient.patch(`/health-check/${id}`, data);
toast.success("Package updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update package");
throw error;
}
};
export const deleteHealthPackageApi = async (id: number) => {
try {
const res = await apiClient.delete(`/health-check/${id}`);
toast.success("Package deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete package");
throw error;
}
};
export const createCategoryApi = async (data: {
name: string;
slug: string;
sortOrder: number;
}) => {
try {
const res = await apiClient.post("/health-check/categories", data);
toast.success("Category created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create category");
throw error;
}
};
export const updateCategoryApi = async (id: number, data: any) => {
try {
const res = await apiClient.patch(`/health-check/categories/${id}`, data);
toast.success("Category updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update category");
throw error;
}
};
export const deleteCategoryApi = async (id: number) => {
try {
const res = await apiClient.delete(`/health-check/categories/${id}`);
toast.success("Category deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete category");
throw error;
}
};
export const getAllInquiriesApi = async (
page = 1,
limit = 10,
filterDate = "",
startDate = "",
endDate = "",
) => {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
if (filterDate) params.append("filterDate", filterDate);
if (startDate) params.append("startDate", startDate);
if (endDate) params.append("endDate", endDate);
const res = await apiClient.get(
`/health-check/inquiries?${params.toString()}`,
);
return res.data;
};
+2 -2
View File
@@ -1,8 +1,8 @@
import apiClient from "@/api/client";
export const getNewsApi = async (page = 1, limit = 10, search = "") => {
export const getNewsApi = async (page = 1, limit = 10) => {
const res = await apiClient.get(
`/newsMedia/getAll?page=${page}&limit=${limit}&search=${search}`,
`/newsMedia/getAll?page=${page}&limit=${limit}`,
);
return res.data;
};
@@ -1,128 +0,0 @@
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"
| "/health-packages"
| "/doctor-og";
}
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,273 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { getAllInquiriesApi, HealthInquiry } from "@/api/healthCheck";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Loader2, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
export default function PackageInquiriesTab() {
const [inquiries, setInquiries] = useState<HealthInquiry[]>([]);
const [loading, setLoading] = useState(true);
const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const fetchInquiries = useCallback(async () => {
setLoading(true);
try {
const res = await getAllInquiriesApi(
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
);
setInquiries(res.data || []);
setTotalItems(res.pagination?.total || 0);
setTotalPages(res.pagination?.totalPages || 1);
} catch (err) {
console.error("Failed to fetch inquiries", err);
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, filterDate, startDate, endDate]);
useEffect(() => {
fetchInquiries();
}, [fetchInquiries]);
const handleFilterChange = (
setter: React.Dispatch<React.SetStateAction<string>>,
value: string,
) => {
setter(value);
setCurrentPage(1);
};
const indexOfFirstItem = (currentPage - 1) * itemsPerPage;
const indexOfLastItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<Card>
<CardHeader className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<CardTitle className="text-xl">Package Inquiries</CardTitle>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Specific Date
</label>
<Input
type="date"
value={filterDate}
onChange={(e) =>
handleFilterChange(setFilterDate, e.target.value)
}
className="w-[140px] text-sm"
disabled={!!startDate || !!endDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
From
</label>
<Input
type="date"
value={startDate}
onChange={(e) => handleFilterChange(setStartDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
To
</label>
<Input
type="date"
value={endDate}
onChange={(e) => handleFilterChange(setEndDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Rows
</label>
<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>
<option value={50}>50 / page</option>
</select>
</div>
<Button variant="outline" onClick={fetchInquiries} disabled={loading}>
<RefreshCw
className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="p-0 sm:p-6 sm:pt-0">
<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-[150px] font-bold bg-background">
Requested Date
</TableHead>
<TableHead className="w-[220px] font-bold bg-background">
Patient Details
</TableHead>
<TableHead className="w-[250px] font-bold bg-background">
Requested Package
</TableHead>
<TableHead className="w-[120px] font-bold bg-background">
Age/Gender
</TableHead>
<TableHead className="w-[250px] font-bold bg-background">
Message
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : inquiries.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-10">
No inquiries found for the selected criteria
</TableCell>
</TableRow>
) : (
inquiries.map((inq) => (
<TableRow key={inq.id} className="hover:bg-muted/50">
<TableCell>
<div className="font-semibold text-primary">
{new Date(inq.preferredDate).toLocaleDateString()}
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Submitted:{" "}
{new Date(inq.createdAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-base">
{inq.fullName}
</div>
<div className="text-sm">{inq.mobileNumber}</div>
<div className="text-xs text-muted-foreground">
{inq.email || "-"}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-sm truncate">
{inq.healthPackage?.name || "N/A"}
</div>
</TableCell>
<TableCell>
<div className="font-medium">
{inq.age} yrs / {inq.gender}
</div>
</TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm italic line-clamp-3 text-muted-foreground whitespace-pre-wrap cursor-pointer">
{inq.message || "No message provided."}
</div>
</TooltipTrigger>
<TooltipContent className="max-w-md whitespace-pre-wrap">
{inq.message || "No message provided."}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && totalItems > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between px-2 py-4 border-t gap-4 mt-2">
<div className="text-sm text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">{indexOfLastItem}</span> of{" "}
<span className="font-semibold">{totalItems}</span> inquiries
</div>
<div className="flex items-center gap-6">
<div className="text-sm font-semibold">
Page {currentPage} of {totalPages || 1}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+1 -6
View File
@@ -15,10 +15,6 @@ export default function Sidebar() {
name: "Doctor",
path: "/doctor",
},
{
name: "Health Check",
path: "/health-check",
},
{
name: "Appointments",
path: "/appointment",
@@ -69,8 +65,7 @@ export default function Sidebar() {
<Link key={item.path} to={item.path}>
<Button
variant={active ? "secondary" : "ghost"}
className="w-full justify-start"
>
className="w-full justify-start">
{item.name}
</Button>
</Link>
-49
View File
@@ -1,49 +0,0 @@
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 }
+2 -2
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -25,7 +25,7 @@ const buttonVariants = cva(
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+2 -5
View File
@@ -59,7 +59,7 @@ function DialogContent({
<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-popover p-4 text-sm text-popover-foreground 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",
"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}
@@ -127,10 +127,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base leading-none font-medium",
className
)}
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
-190
View File
@@ -1,190 +0,0 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
-88
View File
@@ -1,88 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
-55
View File
@@ -1,55 +0,0 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+49 -241
View File
@@ -13,26 +13,11 @@ import {
} 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";
import { Loader2, Trash, RefreshCw, Download } from "lucide-react";
export default function AcademicsPage() {
const [records, setRecords] = useState<any[]>([]);
@@ -40,12 +25,6 @@ export default function AcademicsPage() {
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 {
@@ -72,20 +51,6 @@ export default function AcademicsPage() {
);
});
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);
@@ -109,29 +74,24 @@ export default function AcademicsPage() {
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 justify-between items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Academics & Research</h1>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-2">
<Input
placeholder="Search name / course / email..."
placeholder="Search name / phone / email / subject..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[280px] text-base"
className="w-[260px]"
/>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
<Button variant="outline" onClick={fetchAll} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" />
<Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
@@ -139,108 +99,65 @@ export default function AcademicsPage() {
<Card>
<CardHeader>
<CardTitle className="text-xl">Academic Records</CardTitle>
<CardTitle>Academics 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">
<CardContent>
<div className="overflow-x-auto">
<Table className="min-w-[1000px]">
<TableHeader>
<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>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Email</TableHead>
<TableHead>Course</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Message</TableHead>
<TableHead>Date</TableHead>
<TableHead>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 colSpan={9} className="text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
) : filteredRecords.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-10 text-base"
>
<TableCell colSpan={9} className="text-center">
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}
filteredRecords.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.fullName}</TableCell>
<TableCell>{item.number}</TableCell>
<TableCell>{item.emailId}</TableCell>
<TableCell>{item.courseName}</TableCell>
<TableCell>{item.subject}</TableCell>
<TableCell className="max-w-[250px] whitespace-normal">
{item.message}
</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>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(item.id)}>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
@@ -248,117 +165,8 @@ export default function AcademicsPage() {
</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>
);
}
+177 -347
View File
@@ -15,13 +15,6 @@ import {
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,
@@ -30,7 +23,6 @@ import {
Download,
ChevronLeft,
ChevronRight,
Eye,
} from "lucide-react";
export default function AppointmentPage() {
@@ -39,180 +31,157 @@ export default function AppointmentPage() {
const [searchText, setSearchText] = useState("");
const [filterDoctor, setFilterDoctor] = useState("");
const [filterDepartment, setFilterDepartment] = useState("");
const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [meta, setMeta] = useState<any>({});
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res = await getAppointmentsApi(
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
searchText,
filterDoctor,
filterDepartment,
filterDate,
);
setAppointments(res?.data || []);
setTotalPages(res?.pagination?.totalPages || 1);
setTotalItems(res?.pagination?.total || 0);
setMeta(res?.meta || {});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, filterDate, startDate, endDate, searchText]);
}, [
currentPage,
itemsPerPage,
searchText,
filterDoctor,
filterDepartment,
filterDate,
]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredAppointments = appointments.filter((item) => {
const matchesDoctor = filterDoctor
? item.doctor?.name?.toLowerCase().includes(filterDoctor.toLowerCase())
: true;
return matchesDoctor;
});
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterDoctor, filterDate]);
const indexOfFirstItem = (currentPage - 1) * itemsPerPage;
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");
const handleExport = async () => {
try {
const res = await getAppointmentsApi();
let data = res?.data || [];
data = data.filter((item: any) => {
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 matchesDepartment = filterDepartment
? item.department?.name
?.toLowerCase()
.includes(filterDepartment.toLowerCase())
: true;
const matchesDate = filterDate
? new Date(item.date).toISOString().split("T")[0] === filterDate
: true;
return (
matchesSearch && matchesDoctor && matchesDepartment && matchesDate
);
});
const exportData = data.map((item: any) => ({
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");
} catch (err) {
console.error(err);
}
};
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 justify-between items-center flex-wrap gap-3">
<h1 className="text-2xl font-bold">Appointments</h1>
<div className="flex flex-wrap gap-4 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Search
</label>
<Input
placeholder="Search name / phone..."
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1);
}}
className="w-[220px] text-base"
/>
</div>
<div className="flex flex-wrap gap-2 items-center">
<Input
placeholder="Search..."
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1);
}}
className="w-[200px]"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Date
</label>
<Input
type="date"
value={filterDate}
onChange={(e) => {
setFilterDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!startDate || !!endDate}
/>
</div>
<Input
placeholder="Doctor"
value={filterDoctor}
onChange={(e) => setFilterDoctor(e.target.value)}
className="w-[160px]"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
From
</label>
<Input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<Input
placeholder="Department"
value={filterDepartment}
onChange={(e) => setFilterDepartment(e.target.value)}
className="w-[160px]"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
To
</label>
<Input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<Input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="w-[160px]"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Rows
</label>
<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>
</div>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="border px-2 py-1 rounded">
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
</select>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
<Button onClick={fetchAll}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" />
<Button onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
@@ -220,223 +189,84 @@ export default function AppointmentPage() {
<Card>
<CardHeader>
<CardTitle className="text-xl">Appointment List</CardTitle>
<CardTitle>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">
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Doctor</TableHead>
<TableHead>Department</TableHead>
<TableHead>Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<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>
<TableCell colSpan={7} className="text-center">
<Loader2 className="animate-spin mx-auto" />
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
) : appointments.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-6 text-gray-500">
No appointments found
</TableCell>
</TableRow>
) : (
appointments.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.mobileNumber}</TableCell>
<TableCell>{item.doctor?.name}</TableCell>
<TableCell>{item.department?.name}</TableCell>
<TableCell>
{new Date(item.date).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(item.id)}>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
) : filteredAppointments.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-10 text-base"
>
No appointments found
</TableCell>
</TableRow>
) : (
filteredAppointments.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>
))
)}
</TableBody>
</Table>
{!loading && totalItems > 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(currentPage * itemsPerPage, totalItems)}
</span>{" "}
of <span className="font-semibold">{totalItems}</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 className="flex justify-between mt-4">
<p>
Page {meta.page || 1} of {meta.totalPages || 1}
</p>
<div className="flex gap-2">
<Button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => p - 1)}>
<ChevronLeft />
</Button>
<Button
disabled={currentPage === meta.totalPages}
onClick={() => setCurrentPage((p) => p + 1)}>
<ChevronRight />
</Button>
</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>
);
}
+1 -8
View File
@@ -17,7 +17,7 @@ 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";
import {Loader2, RefreshCw, Plus, Pencil, Trash} from "lucide-react";
interface Blog {
id: number;
@@ -161,13 +161,6 @@ export default function BlogPage() {
<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"
-204
View File
@@ -1,204 +0,0 @@
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;
+19 -42
View File
@@ -1,6 +1,6 @@
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";
@@ -10,7 +10,6 @@ import Table from "@editorjs/table";
import CodeTool from "@editorjs/code";
import Embed from "@editorjs/embed";
import Delimiter from "@editorjs/delimiter";
import axios from "axios";
import {
createBlogApi,
@@ -24,7 +23,6 @@ import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
export default function BlogEditorPage() {
const baseURL = import.meta.env.VITE_API_URL;
const {id} = useParams();
const navigate = useNavigate();
@@ -81,41 +79,12 @@ export default function BlogEditorPage() {
config: {
uploader: {
uploadByFile: async (file: File) => {
if (file.size > 5 * 1024 * 1024) {
alert("File is too large (Max 5MB)");
return {success: 0, file: {url: ""}};
}
const res = await uploadImageApi(file);
const formData = new FormData();
formData.append("file", file);
formData.append("folderPath", "/blog");
try {
const response = await axios.post(
`${baseURL}/upload`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return {
success: 1,
file: {url: response.data.fileUrl},
};
} catch (e: any) {
console.error("EditorJS Image Upload Error:", e);
const errorMessage =
e.response?.data?.error || e.message || "Upload failed";
alert(`Upload Error: ${errorMessage}`);
return {
success: 0,
file: {url: ""},
};
}
return {
success: 1,
file: {url: res.file.url},
};
},
},
},
@@ -148,6 +117,18 @@ export default function BlogEditorPage() {
initEditor();
}, [id, isEdit]);
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const res = await uploadImageApi(file);
setCoverImage(res.file.url);
} catch (err) {
console.error(err);
}
};
const handleSave = async () => {
if (!editorRef.current) return;
@@ -201,11 +182,7 @@ export default function BlogEditorPage() {
<div className="space-y-2">
<label className="text-sm font-medium">Cover Image</label>
<BytescaleUploader
value={coverImage}
folderPath="/blog"
onChange={(url) => setCoverImage(url)}
/>
<Input type="file" onChange={handleCoverUpload} />
{coverImage && (
<img
+120 -271
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { getCareersApi, updateCareerApi, createCareerApi } from "@/api/career";
import { getCareersApi, deleteCareerApi } from "@/api/career";
import apiClient from "@/api/client";
import {
Table,
@@ -13,6 +15,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -22,18 +25,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Loader2,
Plus,
Pencil,
RefreshCw,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Loader2, Plus, Pencil, Trash, RefreshCw } from "lucide-react";
export default function CareerPage() {
const [careers, setCareers] = useState<any[]>([]);
@@ -44,9 +37,6 @@ export default function CareerPage() {
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [form, setForm] = useState({
post: "",
designation: "",
@@ -55,8 +45,6 @@ export default function CareerPage() {
email: "",
number: "",
status: "new",
isActive: true,
sortOrder: 0,
});
const fetchAll = useCallback(async () => {
@@ -75,37 +63,14 @@ export default function CareerPage() {
fetchAll();
}, [fetchAll]);
const filteredCareers = careers.filter(
(item) =>
item.post?.toLowerCase().includes(searchText.toLowerCase()) ||
item.designation?.toLowerCase().includes(searchText.toLowerCase()),
const filteredCareers = careers.filter((item) =>
item.post?.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 });
}
const handleToggleStatus = async (item: any) => {
try {
await updateCareerApi(item.id, {
...item,
isActive: !item.isActive,
} as any);
fetchAll();
} catch (error) {
console.error("Failed to toggle status", error);
}
};
function openAdd() {
setEditing(null);
setForm({
@@ -116,14 +81,13 @@ export default function CareerPage() {
email: "",
number: "",
status: "new",
isActive: true,
sortOrder: 0,
});
setOpenModal(true);
}
function openEdit(item: any) {
setEditing(item);
setForm({
post: item.post || "",
designation: item.designation || "",
@@ -132,18 +96,17 @@ export default function CareerPage() {
email: item.email || "",
number: item.number || "",
status: item.status || "new",
isActive: item.isActive ?? true,
sortOrder: item.sortOrder ?? 0,
});
setOpenModal(true);
}
async function handleSubmit() {
try {
if (editing) {
await updateCareerApi(editing.id, form);
await apiClient.patch(`/careers/${editing.id}`, form);
} else {
await createCareerApi(form);
await apiClient.post("/careers", form);
}
setOpenModal(false);
@@ -153,31 +116,32 @@ export default function CareerPage() {
}
}
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 justify-between items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Careers</h1>
<div className="flex flex-wrap gap-3">
<div className="flex gap-2 flex-wrap">
<Input
placeholder="Search post / designation..."
placeholder="Search career..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
className="w-[220px]"
/>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
<Button variant="outline" onClick={fetchAll} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onClick={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
<Button onClick={openAdd}>
<Plus className="mr-2 h-4 w-4" />
Add Career
</Button>
</div>
@@ -185,99 +149,67 @@ export default function CareerPage() {
<Card>
<CardHeader>
<CardTitle className="text-xl">Career Opportunities</CardTitle>
<CardTitle>Career 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-[800px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<CardContent>
<div className="overflow-x-auto">
<Table className="min-w-[900px]">
<TableHeader>
<TableRow>
<TableHead className="w-[80px] bg-background font-bold text-sm">
Priority
</TableHead>
<TableHead className="w-[250px] 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-[80px] bg-background font-bold text-sm">
Status (Active)
</TableHead>
<TableHead className="w-[80px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
<TableHead>ID</TableHead>
<TableHead>Post</TableHead>
<TableHead>Designation</TableHead>
<TableHead>Qualification</TableHead>
<TableHead>Experience</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Status</TableHead>
<TableHead>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 colSpan={9} className="text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
) : filteredCareers.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-10 text-base"
>
<TableCell colSpan={9} className="text-center">
No careers found
</TableCell>
</TableRow>
) : (
currentItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{item.sortOrder}
</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="flex items-center gap-2">
<Switch
checked={item.isActive}
onCheckedChange={() => handleToggleStatus(item)}
/>
<Badge
variant={item.isActive ? "default" : "secondary"}
className="capitalize"
>
{item.isActive ? "Active" : "Hidden"}
</Badge>
</div>
</TableCell>
filteredCareers.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.post}</TableCell>
<TableCell>{item.designation}</TableCell>
<TableCell>{item.qualification}</TableCell>
<TableCell>{item.experienceNeed}</TableCell>
<TableCell>{item.email}</TableCell>
<TableCell>{item.number}</TableCell>
<TableCell>{item.status}</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>
</div>
<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>
))
@@ -285,150 +217,67 @@ export default function CareerPage() {
</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>
{/* MODAL */}
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="w-full max-w-lg">
<DialogContent>
<DialogHeader>
<DialogTitle className="text-2xl">
{editing ? "Edit Career" : "Add New Career"}
</DialogTitle>
<DialogTitle>{editing ? "Edit Career" : "Add 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 className="flex items-center justify-between p-2 border rounded-md">
<Label htmlFor="isActive" className="text-base">
Active
</Label>
<Switch
id="isActive"
checked={form.isActive}
onCheckedChange={(val) => setForm({ ...form, isActive: val })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sortOrder" className="text-sm">
Sort Priority (Lower numbers show first)
</Label>
<Input
id="sortOrder"
name="sortOrder"
type="number"
placeholder="Sort Order"
value={form.sortOrder}
onChange={handleChange}
className="text-base"
/>
</div>
</div>
<div className="space-y-3">
<Input
name="post"
placeholder="Post"
value={form.post}
onChange={handleChange}
/>
<Input
name="designation"
placeholder="Designation"
value={form.designation}
onChange={handleChange}
/>
<Input
name="qualification"
placeholder="Qualification"
value={form.qualification}
onChange={handleChange}
/>
<Input
name="experienceNeed"
placeholder="Experience Needed"
value={form.experienceNeed}
onChange={handleChange}
/>
<Input
name="email"
placeholder="Email"
value={form.email}
onChange={handleChange}
/>
<Input
name="number"
placeholder="Phone Number"
value={form.number}
onChange={handleChange}
/>
<Input
name="status"
placeholder="Status"
value={form.status}
onChange={handleChange}
/>
</div>
<DialogFooter className="pt-4 border-t">
<Button
variant="ghost"
onClick={() => setOpenModal(false)}
className="text-base"
>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenModal(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} className="px-8 text-base">
{editing ? "Save Changes" : "Create Career"}
<Button onClick={handleSubmit}>
{editing ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
+100 -239
View File
@@ -1,11 +1,11 @@
import {useState, useEffect, useCallback} from "react";
import {AxiosError} from "axios";
import {BytescaleUploader} from "@/components/BytescaleUploader/BytescaleUploader";
import { useState, useEffect, useCallback } from "react";
import { AxiosError } from "axios";
import {
getDepartmentsApi,
createDepartmentApi,
updateDepartmentApi,
deleteDepartmentApi,
} from "@/api/department";
import {
@@ -17,8 +17,8 @@ import {
TableRow,
} from "@/components/ui/table";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -28,33 +28,19 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import {Input} from "@/components/ui/input";
import {Textarea} from "@/components/ui/textarea";
import {Switch} from "@/components/ui/switch";
import {Label} from "@/components/ui/label";
import {Badge} from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Loader2,
RefreshCw,
Plus,
Pencil,
Eye,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Loader2, RefreshCw, Plus, Pencil, Trash, Eye } from "lucide-react";
interface Department {
departmentId: string;
name: string;
image?: string;
para1: string;
para2: string;
para3: string;
facilities: string;
services: string;
isActive: boolean;
sortOrder: number;
}
export default function DepartmentPage() {
@@ -70,20 +56,14 @@ export default function DepartmentPage() {
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: "",
isActive: true,
sortOrder: 0,
});
const fetchDepartments = useCallback(async () => {
@@ -108,67 +88,36 @@ export default function DepartmentPage() {
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,
const filteredDepartments = departments.filter((dep) =>
dep.name.toLowerCase().includes(searchText.toLowerCase()),
);
function handleChange(e: any) {
const value =
e.target.type === "number" ? Number(e.target.value) : e.target.value;
setForm({...form, [e.target.name]: value});
setForm({ ...form, [e.target.name]: e.target.value });
}
const handleToggleStatus = async (dep: Department) => {
try {
const {departmentId, ...updateData} = dep;
await updateDepartmentApi(departmentId, {
...updateData,
isActive: !dep.isActive,
} as any);
fetchDepartments();
} catch (error) {
console.error("Failed to toggle status", error);
}
};
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: "",
isActive: true,
sortOrder: 0,
});
setOpenModal(true);
}
function openEdit(dep: Department) {
setEditing(dep);
setForm({
...dep,
isActive: dep.isActive ?? true,
sortOrder: dep.sortOrder ?? 0,
});
setForm(dep);
setOpenModal(true);
}
@@ -180,8 +129,8 @@ export default function DepartmentPage() {
async function handleSubmit() {
try {
if (editing) {
const {departmentId, ...updateData} = form;
await updateDepartmentApi(editing.departmentId, form as any);
const { departmentId, ...updateData } = form;
await updateDepartmentApi(editing.departmentId, updateData);
} else {
await createDepartmentApi(form);
}
@@ -193,191 +142,138 @@ export default function DepartmentPage() {
}
}
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-col md:flex-row md:justify-between md:items-center gap-3">
<h1 className="text-2xl font-bold">Departments</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search department..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
className="w-[220px]"
/>
<Button
variant="outline"
onClick={fetchDepartments}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onClick={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
<Button onClick={openAdd}>
<Plus className="mr-2 h-4 w-4" />
Add Department
</Button>
</div>
</div>
{error && (
<div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">
<div className="p-4 text-red-600 bg-red-50 border rounded-md">
{error}
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-xl">Department List</CardTitle>
<CardTitle>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-[700px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<CardContent>
<div className="border rounded-md max-h-[500px] overflow-y-auto">
<Table className="w-full table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[100px] bg-background text-sm font-bold">
Priority
</TableHead>
<TableHead className="w-[300px] bg-background text-sm font-bold">
Name
</TableHead>
<TableHead className="w-[80px] bg-background text-sm font-bold">
Status (Active)
</TableHead>
<TableHead className="w-[80px] bg-background text-right text-sm font-bold">
Actions
</TableHead>
<TableHead className="w-[80px]">ID</TableHead>
<TableHead className="w-[180px]">Name</TableHead>
<TableHead className="w-[250px]">Para1</TableHead>
<TableHead className="w-[220px]">Facilities</TableHead>
<TableHead className="w-[220px]">Services</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
<TableCell colSpan={6} className="text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
) : filteredDepartments.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground py-10 text-base"
>
<TableCell colSpan={6} className="text-center">
No departments found
</TableCell>
</TableRow>
) : (
currentItems.map((dep) => (
<TableRow
key={dep.departmentId}
className="hover:bg-muted/50"
>
<TableCell className="font-mono text-sm">
{dep.sortOrder}
filteredDepartments.map((dep) => (
<TableRow key={dep.departmentId}>
<TableCell>{dep.departmentId}</TableCell>
<TableCell>
<div className="break-words">{dep.name}</div>
</TableCell>
<TableCell>
<div
className="font-semibold text-base truncate"
title={dep.name}
>
{dep.name}
</div>
<div className="text-xs text-muted-foreground">
{dep.departmentId}
<div className="break-words whitespace-normal">
{truncate(dep.para1)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={dep.isActive}
onCheckedChange={() => handleToggleStatus(dep)}
/>
<Badge
variant={dep.isActive ? "default" : "secondary"}
>
{dep.isActive ? "Active" : "Hidden"}
</Badge>
<div className="break-words whitespace-normal">
{truncate(dep.facilities)}
</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>
<TableCell>
<div className="break-words whitespace-normal">
{truncate(dep.services)}
</div>
</TableCell>
<TableCell className="flex gap-2 whitespace-nowrap">
<Button
size="sm"
variant="outline"
onClick={() => openView(dep)}>
<Eye className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openEdit(dep)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(dep.departmentId)}>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!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>
@@ -390,14 +286,6 @@ export default function DepartmentPage() {
</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}
@@ -443,32 +331,6 @@ export default function DepartmentPage() {
onChange={handleChange}
placeholder="Services"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-4">
<div className="flex items-center justify-between p-3 border rounded-md">
<Label htmlFor="isActive" className="text-base cursor-pointer">
Active Visibility
</Label>
<Switch
id="isActive"
checked={form.isActive}
onCheckedChange={(val) => setForm({...form, isActive: val})}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sortOrder">
Sort Priority (Lower numbers show first)
</Label>
<Input
id="sortOrder"
name="sortOrder"
type="number"
value={form.sortOrder}
onChange={handleChange}
placeholder="0"
/>
</div>
</div>
</div>
<DialogFooter>
@@ -476,7 +338,7 @@ export default function DepartmentPage() {
Cancel
</Button>
<Button onClick={handleSubmit}>
{editing ? "Save Changes" : "Create"}
{editing ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
@@ -484,45 +346,44 @@ export default function DepartmentPage() {
<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">
<div className="flex gap-4 items-center border-b pb-4">
<Badge variant={viewData.isActive ? "default" : "secondary"}>
{viewData.isActive ? "ACTIVE" : "HIDDEN"}
</Badge>
<p>
<b>Sort Order:</b> {viewData.sortOrder}
</p>
<p>
<b>ID:</b> {viewData.departmentId}
</p>
</div>
<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 />

Some files were not shown because too many files have changed in this diff Show More