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
104 changed files with 1486 additions and 8725 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 node_modules
# Keep environment variables out of version control # Keep environment variables out of version control
.env* .env
/src/generated/prisma /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", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@bytescale/sdk": "^3.53.0",
"@editorjs/editorjs": "^2.31.4", "@editorjs/editorjs": "^2.31.4",
"@editorjs/header": "^2.8.8", "@editorjs/header": "^2.8.8",
"@editorjs/list": "^2.0.9", "@editorjs/list": "^2.0.9",
@@ -22,21 +21,13 @@
"express-session": "^1.19.0", "express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"node-fetch": "^3.3.2",
"postmark": "^4.0.7", "postmark": "^4.0.7",
"prisma": "^6.19.2", "prisma": "^6.19.2"
"slugify": "^1.6.9"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.11" "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": { "node_modules/@codexteam/icons": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz", "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz",
@@ -608,15 +599,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -917,29 +899,6 @@
"node": ">=8.0.0" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1031,18 +990,6 @@
"node": ">= 0.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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1574,44 +1521,6 @@
"node": "^18 || ^20 || >= 21" "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": { "node_modules/node-fetch-native": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@@ -1818,6 +1727,7 @@
"integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "6.19.2", "@prisma/config": "6.19.2",
"@prisma/engines": "6.19.2" "@prisma/engines": "6.19.2"
@@ -2154,15 +2064,6 @@
"node": ">=10" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2306,15 +2207,6 @@
"node": ">= 0.8" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+2 -6
View File
@@ -8,15 +8,13 @@
"start": "node src/app.js", "start": "node src/app.js",
"prisma": "prisma", "prisma": "prisma",
"migrate": "npx prisma migrate dev", "migrate": "npx prisma migrate dev",
"generate": "npx prisma generate", "generate": "npx prisma generate"
"create-user": "node src/utils/createUser.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@bytescale/sdk": "^3.53.0",
"@editorjs/editorjs": "^2.31.4", "@editorjs/editorjs": "^2.31.4",
"@editorjs/header": "^2.8.8", "@editorjs/header": "^2.8.8",
"@editorjs/list": "^2.0.9", "@editorjs/list": "^2.0.9",
@@ -29,10 +27,8 @@
"express-session": "^1.19.0", "express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"node-fetch": "^3.3.2",
"postmark": "^4.0.7", "postmark": "^4.0.7",
"prisma": "^6.19.2", "prisma": "^6.19.2"
"slugify": "^1.6.9"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.11" "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,50 +0,0 @@
/*
Warnings:
- 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" ADD COLUMN "professionalSummary" TEXT,
ADD COLUMN "seoId" INTEGER;
-- 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")
);
-- 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");
-- 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;
-- 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 "experience" INTEGER;
+21 -128
View File
@@ -21,17 +21,10 @@ model Doctor {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
doctorId String @unique doctorId String @unique
name String name String
image String?
designation String? designation String?
experience Int?
workingStatus String? workingStatus String?
qualification String? qualification String?
isActive Boolean @default(true)
globalSortOrder Int @default(1000)
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id])
departments DoctorDepartment[] departments DoctorDepartment[]
appointments Appointment[] appointments Appointment[]
@@ -43,8 +36,6 @@ model Department {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
departmentId String @unique departmentId String @unique
name String name String
image String?
para1 String? para1 String?
para2 String? para2 String?
@@ -52,9 +43,6 @@ model Department {
facilities String? facilities String?
services String? services String?
isActive Boolean @default(true)
sortOrder Int @default(1000)
doctors DoctorDepartment[] doctors DoctorDepartment[]
appointments Appointment[] appointments Appointment[]
@@ -70,7 +58,7 @@ model DoctorDepartment {
doctor Doctor @relation(fields: [doctorId], references: [id]) doctor Doctor @relation(fields: [doctorId], references: [id])
department Department @relation(fields: [departmentId], references: [id]) department Department @relation(fields: [departmentId], references: [id])
sortOrder Int @default(1000)
timing DoctorTiming? timing DoctorTiming?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -105,7 +93,6 @@ model Blog {
image String? image String?
content Json content Json
isActive Boolean @default(true) isActive Boolean @default(true)
slug String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -120,8 +107,7 @@ model Career {
email String? email String?
number String? number String?
status String @default("new") status String @default("new")
isActive Boolean @default(true)
sortOrder Int @default(1000)
candidates Candidate[] candidates Candidate[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -163,20 +149,20 @@ model Appointment {
} }
model Inquiry { model Inquiry {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
fullName String fullName String
number String number String
emailId String? emailId String?
subject String? subject String?
message String? message String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model AcademicsResearch { model AcademicsResearch {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
fullName String fullName String
number String number String
@@ -185,23 +171,24 @@ model AcademicsResearch {
courseName String? courseName String?
message String? message String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model EmailConfig { model EmailConfig {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
email String email String
type String type String
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model NewsMedia { model NewsMedia {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
headline String headline String
content String? content String?
@@ -209,103 +196,9 @@ model NewsMedia {
secondPara String? secondPara String?
author String? author String?
date DateTime? date DateTime?
images NewsImage[]
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NewsImage { createdAt DateTime @default(now())
id Int @id @default(autoincrement()) updatedAt DateTime @updatedAt
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
} }
+2 -8
View File
@@ -14,16 +14,11 @@ import inquiryRoutes from "./routes/inquiry.routes.js";
import academicsResearchRoutes from "./routes/academicsResearch.routes.js"; import academicsResearchRoutes from "./routes/academicsResearch.routes.js";
import emailConfigRoutes from "./routes/emailConfig.routes.js"; import emailConfigRoutes from "./routes/emailConfig.routes.js";
import newsMediaRoutes from "./routes/newsMedia.routes.js"; import newsMediaRoutes from "./routes/newsMedia.routes.js";
import importRoutes from "./routes/importRoutes.js";
import healthCheckRoutes from "./routes/healthCheck.route.js";
dotenv.config(); dotenv.config();
const app = express(); const app = express();
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
? process.env.CORS_ALLOWED_ORIGINS.split(" ") ? process.env.CORS_ALLOWED_ORIGINS.split(" ")
: ["http://localhost:3001"]; : ["http://localhost:3001"];
@@ -40,6 +35,7 @@ const corsOptions = {
allowedHeaders: "*", allowedHeaders: "*",
}; };
app.use(express.json());
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.use("/api/departments", departmentRoutes); app.use("/api/departments", departmentRoutes);
@@ -55,10 +51,8 @@ app.use("/api/inquiry", inquiryRoutes);
app.use("/api/academics", academicsResearchRoutes); app.use("/api/academics", academicsResearchRoutes);
app.use("/api/email", emailConfigRoutes); app.use("/api/email", emailConfigRoutes);
app.use("/api/newsMedia", newsMediaRoutes); 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, () => { app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);
}); });
@@ -35,73 +35,18 @@ export const createAcademicsResearch = async (req, res) => {
to: emailList, to: emailList,
subject: "New Academics & Research Inquiry", subject: "New Academics & Research Inquiry",
html: ` html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;"> <h2>New Academics & Research Inquiry</h2>
<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>
<!-- Body --> <p><b>Name:</b> ${fullName}</p>
<div style="padding: 20px; color: #333;"> <p><b>Phone:</b> ${number}</p>
<p><b>Email:</b> ${emailId || "-"}</p>
<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>
<!-- Message Box --> <p><b>Course:</b> ${courseName || "-"}</p>
<div style="margin-top: 20px;"> <p><b>Subject:</b> ${subject || "-"}</p>
<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> <p><b>Message:</b></p>
<p>${message || "-"}</p>
<!-- 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>
`,
}); });
} }
} catch (err) { } catch (err) {
+76 -141
View File
@@ -38,88 +38,15 @@ export const createAppointment = async (req, res) => {
to: emailList, to: emailList,
subject: "New Appointment Booked", subject: "New Appointment Booked",
html: ` html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;"> <h2>New Appointment Booked</h2>
<p><b>Name:</b> ${name}</p>
<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);"> <p><b>Phone:</b> ${mobileNumber}</p>
<p><b>Email:</b> ${email || "-"}</p>
<!-- Header --> <p><b>Doctor:</b> ${appointment.doctor?.name}</p>
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;"> <p><b>Department:</b> ${appointment.department?.name}</p>
<h2 style="margin: 0;">GG Hospital</h2> <p><b>Date:</b> ${new Date(date).toLocaleDateString()}</p>
<p style="margin: 5px 0 0; font-size: 14px;"> <p><b>Message:</b> ${message || "-"}</p>
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>
`,
}); });
} }
} catch (err) { } catch (err) {
@@ -144,60 +71,74 @@ export const createAppointment = async (req, res) => {
export const getAppointments = async (req, res) => { export const getAppointments = async (req, res) => {
try { try {
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page);
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit);
const skip = (page - 1) * 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() !== ""; return res.status(200).json({
success: true,
const hasRange = data: appointments,
(startDate && startDate.trim() !== "") || meta: null,
(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,
};
} }
if (!hasSingleDate && hasRange) { const currentPage = page || 1;
const dateFilter = {}; const currentLimit = limit || 10;
const skip = (currentPage - 1) * currentLimit;
if (startDate && startDate.trim() !== "") { const where = {
const start = new Date(startDate); AND: [
start.setHours(0, 0, 0, 0); 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() !== "") { department
const end = new Date(endDate); ? {
end.setHours(23, 59, 59, 999); department: {
name: { contains: department, mode: "insensitive" },
},
}
: {},
dateFilter.lte = end; date
} ? {
date: {
where.date = dateFilter; gte: new Date(date),
} lt: new Date(
new Date(date).setDate(new Date(date).getDate() + 1),
if (search && search.trim() !== "") { ),
where.OR = [ },
{ name: { contains: search, mode: "insensitive" } }, }
{ mobileNumber: { contains: search } }, : {},
{ email: { contains: search, mode: "insensitive" } }, ],
]; };
}
const [appointments, total] = await Promise.all([ const [appointments, total] = await Promise.all([
prisma.appointment.findMany({ prisma.appointment.findMany({
@@ -206,31 +147,25 @@ export const getAppointments = async (req, res) => {
doctor: true, doctor: true,
department: true, department: true,
}, },
orderBy: { orderBy: { createdAt: "desc" },
createdAt: "desc",
},
skip, skip,
take: limit, take: currentLimit,
}),
prisma.appointment.count({
where,
}), }),
prisma.appointment.count({ where }),
]); ]);
res.status(200).json({ return res.status(200).json({
success: true, success: true,
data: appointments, data: appointments,
pagination: { meta: {
total, total,
page, page: currentPage,
limit, limit: currentLimit,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / currentLimit),
}, },
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: "Failed to fetch appointments", message: "Failed to fetch appointments",
@@ -1,5 +1,4 @@
import prisma from "../prisma/client.js"; import prisma from "../prisma/client.js";
import slugify from "slugify";
/* CREATE BLOG */ /* CREATE BLOG */
@@ -14,7 +13,6 @@ export async function createBlog(req, res) {
image, image,
content, content,
isActive, isActive,
slug: slugify(title),
}, },
}); });
@@ -56,26 +54,6 @@ export async function getAllBlogs(req, res) {
/* GET SINGLE BLOG */ /* GET SINGLE BLOG */
export async function getBlog(req, res) { export async function getBlog(req, res) {
try {
const slug = req.params.slug;
const blog = await prisma.blog.findUnique({
where: {slug},
});
if (!blog) {
return res.status(404).json({error: "Blog not found"});
}
res.json(blog);
} catch (error) {
res.status(500).json({error: error.message});
}
}
/* GET SINGLE BLOG (ADMIN)*/
export async function getBlogForAdmin(req, res) {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
+10 -73
View File
@@ -39,82 +39,19 @@ export const createCandidate = async (req, res) => {
to: emailList, to: emailList,
subject: "New Job Application Received", subject: "New Job Application Received",
html: ` html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;"> <h2>New Candidate Application</h2>
<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>
<!-- Body --> <p><b>Name:</b> ${fullName}</p>
<div style="padding: 20px; color: #333;"> <p><b>Phone:</b> ${mobile}</p>
<p><b>Email:</b> ${email}</p>
<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>
<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;"> <p><b>Subject:</b> ${subject || "-"}</p>
<tr> <p><b>Cover Letter:</b></p>
<td style="padding: 8px 0;"><b>Applied For:</b></td> <p>${coverLetter || "-"}</p>
<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>
`,
}); });
} }
} catch (err) { } catch (err) {
+6 -20
View File
@@ -4,11 +4,8 @@ import prisma from "../prisma/client.js";
export const getAllCareers = async (req, res) => { export const getAllCareers = async (req, res) => {
try { try {
const { admin } = req.query;
const careers = await prisma.career.findMany({ const careers = await prisma.career.findMany({
where: admin === "true" ? {} : { isActive: true }, orderBy: {createdAt: "desc"},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
}); });
const response = careers.map((c) => ({ const response = careers.map((c) => ({
@@ -20,8 +17,6 @@ export const getAllCareers = async (req, res) => {
email: c.email, email: c.email,
number: c.number, number: c.number,
status: c.status, status: c.status,
isActive: c.isActive,
sortOrder: c.sortOrder,
})); }));
return res.status(200).json({ return res.status(200).json({
@@ -49,8 +44,6 @@ export const createCareer = async (req, res) => {
email, email,
number, number,
status, status,
isActive,
sortOrder,
} = req.body; } = req.body;
if (!post || !designation) { if (!post || !designation) {
@@ -69,8 +62,6 @@ export const createCareer = async (req, res) => {
email, email,
number, number,
status, 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) => { export const updateCareer = async (req, res) => {
try { try {
const { id } = req.params; const {id} = req.params;
const updateData = { ...req.body };
if (updateData.sortOrder !== undefined) {
updateData.sortOrder = Number(updateData.sortOrder);
}
const career = await prisma.career.update({ const career = await prisma.career.update({
where: { id: Number(id) }, where: {id: Number(id)},
data: updateData, data: req.body,
}); });
return res.status(200).json({ return res.status(200).json({
@@ -122,10 +108,10 @@ export const updateCareer = async (req, res) => {
export const deleteCareer = async (req, res) => { export const deleteCareer = async (req, res) => {
try { try {
const { id } = req.params; const {id} = req.params;
await prisma.career.delete({ await prisma.career.delete({
where: { id: Number(id) }, where: {id: Number(id)},
}); });
return res.status(200).json({ return res.status(200).json({
@@ -2,24 +2,18 @@ import prisma from "../prisma/client.js";
export const getAllDepartments = async (req, res) => { export const getAllDepartments = async (req, res) => {
try { try {
const {admin} = req.query;
const departments = await prisma.department.findMany({ const departments = await prisma.department.findMany({
where: admin === "true" ? {} : {isActive: true}, orderBy: {name: "asc"},
orderBy: [{sortOrder: "asc"}, {name: "asc"}],
}); });
const response = departments.map((dep) => ({ const response = departments.map((dep) => ({
departmentId: dep.departmentId, departmentId: dep.departmentId,
name: dep.name, name: dep.name,
image: dep.image ?? "",
para1: dep.para1 ?? "", para1: dep.para1 ?? "",
para2: dep.para2 ?? "", para2: dep.para2 ?? "",
para3: dep.para3 ?? "", para3: dep.para3 ?? "",
facilities: dep.facilities ?? "", facilities: dep.facilities ?? "",
services: dep.services ?? "", services: dep.services ?? "",
isActive: dep.isActive,
sortOrder: dep.sortOrder,
})); }));
return res.status(200).json({ 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) { export async function createDepartment(req, res) {
try { try {
const { const {departmentId, name, para1, para2, para3, facilities, services} =
departmentId, req.body;
name,
image,
para1,
para2,
para3,
facilities,
services,
isActive,
sortOrder,
} = req.body;
if (!departmentId || !name) { if (!departmentId || !name) {
return res return res
@@ -111,14 +44,11 @@ export async function createDepartment(req, res) {
data: { data: {
departmentId, departmentId,
name, name,
image,
para1, para1,
para2, para2,
para3, para3,
facilities, facilities,
services, 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") { if (error.code === "P2002") {
return res.status(409).json({error: "Department already exists"}); return res.status(409).json({error: "Department already exists"});
} }
console.error(error);
res.status(500).json({error: "Failed to create department"}); 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) => { export const updateDepartment = async (req, res) => {
try { try {
const {departmentId} = req.params; const {departmentId} = req.params;
const updateData = {...req.body};
if (updateData.sortOrder !== undefined) { const {name, para1, para2, para3, facilities, services} = req.body;
updateData.sortOrder = Number(updateData.sortOrder);
}
const department = await prisma.department.update({ const department = await prisma.department.update({
where: {departmentId}, where: {departmentId},
data: updateData, data: {
name,
para1,
para2,
para3,
facilities,
services,
},
}); });
return res.status(200).json({ return res.status(200).json({
+151 -419
View File
@@ -4,76 +4,50 @@ import prisma from "../prisma/client.js";
export const getAllDoctors = async (req, res) => { export const getAllDoctors = async (req, res) => {
try { try {
const {admin} = req.query;
const doctors = await prisma.doctor.findMany({ const doctors = await prisma.doctor.findMany({
where: admin === "true" ? {} : {isActive: true},
include: { include: {
seo: true,
departments: { departments: {
include: { include: {
department: true, department: true,
timing: true, timing: true,
}, },
}, },
specializations: {
orderBy: {
createdAt: "asc",
},
},
}, },
orderBy: [{globalSortOrder: "asc"}, {name: "asc"}], orderBy: {name: "asc"},
}); });
const formatted = doctors.map((doc, index) => ({ const formatted = doctors.map((doc, index) => {
SL_NO: String(index + 1), return {
doctorId: doc.doctorId, SL_NO: String(index + 1),
name: doc.name, doctorId: doc.doctorId,
image: doc.image ?? "", name: doc.name,
designation: doc.designation, designation: doc.designation,
workingStatus: doc.workingStatus, workingStatus: doc.workingStatus,
qualification: doc.qualification, 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 ?? [],
ogTitle: doc.seo?.ogTitle ?? "", departments: doc.departments.map((d) => {
ogDescription: doc.seo?.ogDescription ?? "", const t = d.timing || {};
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);
return { const timingArray = [
departmentId: d.department.departmentId, t.monday && `Monday ${t.monday}`,
departmentName: d.department.name, t.tuesday && `Tuesday ${t.tuesday}`,
timing: timingArray.join(" & "), t.wednesday && `Wednesday ${t.wednesday}`,
deptSortOrder: d.sortOrder, 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({ res.status(200).json({
success: true, success: true,
@@ -97,8 +71,6 @@ export const getDoctorByDoctorId = async (req, res) => {
const doctor = await prisma.doctor.findUnique({ const doctor = await prisma.doctor.findUnique({
where: {doctorId}, where: {doctorId},
include: { include: {
seo: true,
specializations: true,
departments: { departments: {
include: { include: {
department: true, department: true,
@@ -118,28 +90,9 @@ export const getDoctorByDoctorId = async (req, res) => {
const response = { const response = {
doctorId: doctor.doctorId, doctorId: doctor.doctorId,
name: doctor.name, name: doctor.name,
image: doctor.image ?? "",
designation: doctor.designation, designation: doctor.designation,
workingStatus: doctor.workingStatus, workingStatus: doctor.workingStatus,
qualification: doctor.qualification, 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) => ({ departments: doctor.departments.map((d) => ({
departmentId: d.department.departmentId, departmentId: d.department.departmentId,
departmentName: d.department.name, departmentName: d.department.name,
@@ -160,131 +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 // add doctors
export const createDoctor = async (req, res) => { export const createDoctor = async (req, res) => {
try { try {
const { const {
doctorId, doctorId,
name, name,
image,
designation, designation,
workingStatus, workingStatus,
qualification, qualification,
isActive,
globalSortOrder,
departments, departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
ogTitle,
ogDescription,
ogImage,
} = req.body; } = req.body;
const messages = [];
if (!doctorId) messages.push("Doctor ID is required");
if (!name?.trim()) messages.push("Doctor name is required");
if (!designation?.trim()) messages.push("Designation is required");
if (!qualification?.trim()) messages.push("Qualification is required");
if (!departments || departments.length === 0) {
messages.push("At least one department is required");
}
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(", "),
});
}
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({ const doctor = await prisma.doctor.create({
data: { data: {
doctorId, doctorId,
name, name,
image,
designation, designation,
workingStatus, workingStatus,
qualification, qualification,
experience: experience ? Number(experience) : null,
professionalSummary,
seoId: seo.id,
isActive: isActive !== undefined ? isActive : true,
globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
}, },
}); });
@@ -299,7 +146,6 @@ export const createDoctor = async (req, res) => {
data: { data: {
doctorId: doctor.id, doctorId: doctor.id,
departmentId: department.id, departmentId: department.id,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
}, },
}); });
@@ -312,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({ res.status(201).json({
success: true, success: true,
@@ -339,215 +174,10 @@ export const createDoctor = async (req, res) => {
//update doctors //update doctors
export const updateDoctor = async (req, res) => { export const updateDoctor = async (req, res) => {
try {
const {doctorId, action} = req.params;
const {
name,
designation,
image,
workingStatus,
qualification,
isActive,
globalSortOrder,
departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
} = req.body;
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 not found"});
if (action === "toggleStatus") {
await prisma.doctor.update({
where: {id: doctor.id},
data: {
isActive: !doctor.isActive,
},
});
return res.status(200).json({
success: true,
message: `Doctor has been ${
doctor.isActive ? "deactivated" : "activated"
} successfully`,
});
}
const messages = [];
if (!doctorId) messages.push("Doctor ID is required");
if (!name?.trim()) messages.push("Doctor name is required");
if (!qualification?.trim()) messages.push("Qualification is required");
if (!designation?.trim()) messages.push("Designation is required");
if (!departments || departments.length === 0) {
messages.push("At least one department is required");
}
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(", "),
});
}
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,
},
});
}
// Update Departments & Timings
if (Array.isArray(departments)) {
const oldRelations = await prisma.doctorDepartment.findMany({
where: {
doctorId: doctor.id,
},
include: {
timing: true,
},
});
// Delete old timings
for (const rel of oldRelations) {
if (rel.timing) {
await prisma.doctorTiming.deleteMany({
where: {
doctorDepartmentId: rel.id,
},
});
}
}
// Delete old departments
await prisma.doctorDepartment.deleteMany({
where: {
doctorId: doctor.id,
},
});
// Recreate departments + timings
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,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
},
});
if (dep.timing && Object.keys(dep.timing).length > 0) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...cleanTiming,
},
});
}
}
}
// 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,
})),
});
}
}
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 { try {
const {doctorId} = req.params; const {doctorId} = req.params;
const {name, designation, workingStatus, qualification, departments} =
req.body;
const doctor = await prisma.doctor.findUnique({ const doctor = await prisma.doctor.findUnique({
where: {doctorId}, where: {doctorId},
@@ -560,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({ const doctorDepartments = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id}, where: {doctorId: doctor.id},
}); });
@@ -580,7 +294,7 @@ export const deleteDoctor = async (req, res) => {
res.status(200).json({ res.status(200).json({
success: true, success: true,
message: "Doctor deleted successfully", message: `Doctor ${doctorId} deleted successfully`,
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -606,19 +320,23 @@ export const getDoctorTimings = async (req, res) => {
}); });
const result = doctors.map((doc) => { const result = doctors.map((doc) => {
const timing = doc.departments[0]?.timing || {}; let timing = {};
if (doc.departments.length > 0) {
timing = doc.departments[0].timing ?? {};
}
return { return {
Doctor_ID: doc.doctorId, Doctor_ID: doc.doctorId,
Doctor: doc.name, Doctor: doc.name,
Monday: timing.monday || "", Monday: timing?.monday ?? "",
Tuesday: timing.tuesday || "", Tuesday: timing?.tuesday ?? "",
Wednesday: timing.wednesday || "", Wednesday: timing?.wednesday ?? "",
Thursday: timing.thursday || "", Thursday: timing?.thursday ?? "",
Friday: timing.friday || "", Friday: timing?.friday ?? "",
Saturday: timing.saturday || "", Saturday: timing?.saturday ?? "",
Sunday: timing.sunday || "", Sunday: timing?.sunday ?? "",
Additional: timing.additional || "", Additional: timing?.additional ?? "",
}; };
}); });
@@ -662,12 +380,26 @@ export const getDoctorTimingById = async (req, res) => {
const result = { const result = {
doctorId: doctor.doctorId, doctorId: doctor.doctorId,
doctorName: doctor.name, doctorName: doctor.name,
departments: doctor.departments.map((d) => ({
departmentId: d.department.departmentId, departments: doctor.departments.map((d) => {
departmentName: d.department.name, const t = d.timing || {};
deptSortOrder: d.sortOrder,
timing: 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({ 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 prisma from "../prisma/client.js";
import {sendEmail} from "../utils/sendEmail.js";
import {getEmailsByType} from "../utils/getEmailByTypes.js";
/* CREATE INQUIRY */ /* CREATE INQUIRY */
export const createInquiry = async (req, res) => { export const createInquiry = async (req, res) => {
try { try {
@@ -24,80 +21,6 @@ export const createInquiry = async (req, res) => {
message, 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({ res.status(200).json({
success: true, success: true,
+33 -61
View File
@@ -4,41 +4,43 @@ import prisma from "../prisma/client.js";
export const getAllNews = async (req, res) => { export const getAllNews = async (req, res) => {
try { try {
const page = req.query.page ? parseInt(req.query.page) : null; const page = parseInt(req.query.page);
const limit = req.query.limit ? parseInt(req.query.limit) : null; const limit = parseInt(req.query.limit);
const search = req.query.search?.trim() || "";
const includeImages = { if (!page && !limit) {
images: true, const news = await prisma.newsMedia.findMany({
}; orderBy: { createdAt: "desc" },
});
const searchFilter = search const response = news.map((n) => ({
? { Id: n.id.toString(),
headline: { Headline: n.headline,
contains: search, Content: n.content,
mode: "insensitive", FirstPara: n.firstPara,
}, SecondPara: n.secondPara,
} Date: n.date,
: {}; Author: n.author,
}));
const whereCondition = { return res.status(200).json({
...searchFilter, success: true,
}; data: response,
meta: null,
});
}
const skip = page && limit ? (page - 1) * limit : undefined; const currentPage = page || 1;
const take = limit ? limit : undefined; const currentLimit = limit || 10;
const skip = (currentPage - 1) * currentLimit;
const [news, total] = await Promise.all([ const [news, total] = await Promise.all([
prisma.newsMedia.findMany({ prisma.newsMedia.findMany({
where: whereCondition,
include: includeImages,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
skip, skip,
take, take: currentLimit,
}),
prisma.newsMedia.count({
where: whereCondition,
}), }),
prisma.newsMedia.count(),
]); ]);
const response = news.map((n) => ({ const response = news.map((n) => ({
@@ -49,10 +51,6 @@ export const getAllNews = async (req, res) => {
SecondPara: n.secondPara, SecondPara: n.secondPara,
Date: n.date, Date: n.date,
Author: n.author, Author: n.author,
Images: n.images.map((img) => ({
id: img.id,
image: img.url,
})),
})); }));
return res.status(200).json({ return res.status(200).json({
@@ -60,9 +58,9 @@ export const getAllNews = async (req, res) => {
data: response, data: response,
meta: { meta: {
total, total,
page: page || 1, page: currentPage,
limit: limit || total, limit: currentLimit,
totalPages: limit ? Math.ceil(total / limit) : 1, totalPages: Math.ceil(total / currentLimit),
}, },
}); });
} catch (error) { } catch (error) {
@@ -73,6 +71,7 @@ export const getAllNews = async (req, res) => {
}); });
} }
}; };
// GET NEWS BY ID // GET NEWS BY ID
export const getNewsById = async (req, res) => { export const getNewsById = async (req, res) => {
@@ -81,7 +80,6 @@ export const getNewsById = async (req, res) => {
const n = await prisma.newsMedia.findUnique({ const n = await prisma.newsMedia.findUnique({
where: { id: Number(id) }, where: { id: Number(id) },
include: { images: true },
}); });
if (!n) { if (!n) {
@@ -99,10 +97,6 @@ export const getNewsById = async (req, res) => {
SecondPara: n.secondPara, SecondPara: n.secondPara,
Date: n.date, Date: n.date,
Author: n.author, Author: n.author,
Images: n.images.map((img) => ({
id: img.id,
image: img.url,
})),
}; };
return res.status(200).json({ return res.status(200).json({
@@ -122,15 +116,7 @@ export const getNewsById = async (req, res) => {
export const createNews = async (req, res) => { export const createNews = async (req, res) => {
try { try {
const { const { headline, content, firstPara, secondPara, date, author } = req.body;
headline,
content,
firstPara,
secondPara,
date,
author,
imageUrls,
} = req.body;
if (!headline) { if (!headline) {
return res.status(400).json({ return res.status(400).json({
@@ -147,13 +133,7 @@ export const createNews = async (req, res) => {
secondPara, secondPara,
date: date ? new Date(date) : null, date: date ? new Date(date) : null,
author, author,
images: imageUrls
? {
create: imageUrls.map((url) => ({ url })),
}
: undefined,
}, },
include: { images: true },
}); });
return res.status(201).json({ return res.status(201).json({
@@ -175,21 +155,13 @@ export const createNews = async (req, res) => {
export const updateNews = async (req, res) => { export const updateNews = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { imageUrls, ...otherData } = req.body;
const news = await prisma.newsMedia.update({ const news = await prisma.newsMedia.update({
where: { id: Number(id) }, where: { id: Number(id) },
data: { data: {
...otherData, ...req.body,
date: req.body.date ? new Date(req.body.date) : undefined, 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({ return res.status(200).json({
@@ -11,8 +11,8 @@ import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router(); const router = express.Router();
router.post("/", createAcademicsResearch); router.post("/", createAcademicsResearch);
router.get("/getAll", jwtAuthMiddleware, getAcademicsResearch); router.get("/getAll", getAcademicsResearch);
router.get("/:id", jwtAuthMiddleware, getSingleAcademicsResearch); router.get("/:id", getSingleAcademicsResearch);
router.delete("/:id", jwtAuthMiddleware, deleteAcademicsResearch); router.delete("/:id", jwtAuthMiddleware, deleteAcademicsResearch);
export default router; export default router;
+3 -3
View File
@@ -13,11 +13,11 @@ const router = express.Router();
/* PUBLIC */ /* PUBLIC */
router.get("/getall", jwtAuthMiddleware, getAppointments); router.get("/getall", getAppointments);
router.post("/", createAppointment); router.post("/", createAppointment);
router.get("/:id", jwtAuthMiddleware, getAppointment); router.get("/:id", getAppointment);
router.patch("/:id", jwtAuthMiddleware, updateAppointment); router.patch("/:id", updateAppointment);
router.delete("/:id", jwtAuthMiddleware, deleteAppointment); router.delete("/:id", jwtAuthMiddleware, deleteAppointment);
export default router; export default router;
+2 -1
View File
@@ -1,8 +1,9 @@
import express from "express"; import express from "express";
import { login } from "../controllers/auth.controller.js"; import {register, login} from "../controllers/auth.controller.js";
const router = express.Router(); const router = express.Router();
router.post("/register", register);
router.post("/login", login); router.post("/login", login);
export default router; export default router;
+1 -5
View File
@@ -6,7 +6,6 @@ import {
updateBlog, updateBlog,
deleteBlog, deleteBlog,
getAllBlogs, getAllBlogs,
getBlogForAdmin,
} from "../controllers/blog.controller.js"; } from "../controllers/blog.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js"; import jwtAuthMiddleware from "../middleware/auth.js";
@@ -16,14 +15,11 @@ const router = express.Router();
/* PUBLIC */ /* PUBLIC */
router.get("/", getBlogs); router.get("/", getBlogs);
router.get("/:slug", getBlog); router.get("/:id", getBlog);
// Protected // Protected
router.get("/admin/all", jwtAuthMiddleware, getAllBlogs); router.get("/admin/all", jwtAuthMiddleware, getAllBlogs);
router.get("/admin/:id", jwtAuthMiddleware, getBlogForAdmin);
router.post("/", jwtAuthMiddleware, createBlog); router.post("/", jwtAuthMiddleware, createBlog);
router.put("/:id", jwtAuthMiddleware, updateBlog); router.put("/:id", jwtAuthMiddleware, updateBlog);
router.delete("/:id", jwtAuthMiddleware, deleteBlog); router.delete("/:id", jwtAuthMiddleware, deleteBlog);
+6 -6
View File
@@ -13,13 +13,13 @@ import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router(); const router = express.Router();
/* PUBLIC */ /* PUBLIC */
router.get("/getAll", getCandidates);
router.get("/:id", getCandidate);
router.get("/career/:careerId", getCandidatesByCareer);
router.post("/", createCandidate); router.post("/", createCandidate);
router.patch("/:id", updateCandidate);
router.get("/getAll", jwtAuthMiddleware, getCandidates);
router.get("/:id", jwtAuthMiddleware, getCandidate);
router.get("/career/:careerId", jwtAuthMiddleware, getCandidatesByCareer);
router.patch("/:id", jwtAuthMiddleware, updateCandidate);
router.delete("/:id", jwtAuthMiddleware, deleteCandidate); router.delete("/:id", jwtAuthMiddleware, deleteCandidate);
export default router; export default router;
+3 -5
View File
@@ -6,14 +6,12 @@ import {
deleteCareer, deleteCareer,
} from "../controllers/career.controller.js"; } from "../controllers/career.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router(); const router = express.Router();
router.get("/getAll", getAllCareers); router.get("/getAll", getAllCareers);
router.post("/", jwtAuthMiddleware, createCareer); router.post("/", createCareer);
router.patch("/:id", jwtAuthMiddleware, updateCareer); router.patch("/:id", updateCareer);
router.delete("/:id", jwtAuthMiddleware, deleteCareer); router.delete("/:id", deleteCareer);
export default router; export default router;
-2
View File
@@ -1,7 +1,6 @@
import express from "express"; import express from "express";
import { import {
getAllDepartments, getAllDepartments,
getDepartmentByName,
createDepartment, createDepartment,
updateDepartment, updateDepartment,
deleteDepartment, deleteDepartment,
@@ -12,7 +11,6 @@ const router = express.Router();
// Public // Public
router.get("/getAll", getAllDepartments); router.get("/getAll", getAllDepartments);
router.get("/search", getDepartmentByName);
// Protected // Protected
router.post("/", jwtAuthMiddleware, createDepartment); router.post("/", jwtAuthMiddleware, createDepartment);
+2 -4
View File
@@ -7,7 +7,6 @@ import {
getDoctorTimings, getDoctorTimings,
getDoctorTimingById, getDoctorTimingById,
getDoctorByDoctorId, getDoctorByDoctorId,
getDoctorsByDepartmentId,
} from "../controllers/doctor.controller.js"; } from "../controllers/doctor.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js"; import jwtAuthMiddleware from "../middleware/auth.js";
@@ -15,13 +14,12 @@ import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router(); const router = express.Router();
router.get("/getAll", getAllDoctors); router.get("/getAll", getAllDoctors);
router.get("/search", getDoctorsByDepartmentId); router.get("/:doctorId", getDoctorByDoctorId);
router.get("/getTimings", getDoctorTimings); router.get("/getTimings", getDoctorTimings);
router.get("/getTimings/:doctorId", getDoctorTimingById); router.get("/getTimings/:doctorId", getDoctorTimingById);
router.get("/:doctorId", getDoctorByDoctorId);
router.post("/", jwtAuthMiddleware, createDoctor); router.post("/", jwtAuthMiddleware, createDoctor);
router.patch("/:doctorId/:action", jwtAuthMiddleware, updateDoctor); router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor);
router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor); router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor);
export default router; export default router;
-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.post("/", createInquiry);
router.get("/getAll", jwtAuthMiddleware, getInquiries); router.get("/getAll", getInquiries);
router.get("/:id", jwtAuthMiddleware, getInquiry); router.get("/:id", getInquiry);
router.delete("/:id", jwtAuthMiddleware, deleteInquiry); router.delete("/:id", jwtAuthMiddleware, deleteInquiry);
export default router; export default router;
+8 -28
View File
@@ -1,35 +1,15 @@
import express from "express"; import express from "express";
import * as Bytescale from "@bytescale/sdk"; import {upload} from "../controllers/upload.controller.js";
import multer from "multer";
const router = express.Router(); const router = express.Router();
const uploadManager = new Bytescale.UploadManager({ router.post("/image", upload.single("image"), (req, res) => {
apiKey: process.env.BYTESCALE_SECRET_API_KEY, res.json({
}); success: 1,
file: {
const storage = multer.memoryStorage(); url: `http://localhost:3000/uploads/blog/${req.file.filename}`,
const upload = multer({storage}); },
});
router.post("/", upload.single("file"), async (req, res) => {
try {
const file = req.file;
const {folderPath} = req.body;
const result = await uploadManager.upload({
data: file.buffer,
name: file.originalname,
mime: file.mimetype,
path: {
folderPath: folderPath || "/general",
},
});
res.json({fileUrl: result.fileUrl});
} catch (error) {
console.error(error);
res.status(500).json({error: "Upload failed"});
}
}); });
export default router; export default router;
-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"]
-12
View File
@@ -1,12 +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 "Executing command: $@"
exec "$@"
-5
View File
@@ -22,8 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.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 Currently, two official plugins are available:
Build Tool: Vite + TypeScript
Styling: Tailwind CSS 4 + shadcn/ui
Rich Text: Editor.js
State/Fetch: Axios + React Hooks
Export: XLSX + File-saver
## 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/ ## React Compiler
├── src/
│ ├── api/
│ ├── assets/
│ ├── components/
│ ├── context/
│ ├── lib/
│ ├── layout/
│ ├── pages/
│ ├── services/
│ ├── utils/
│ └── App.tsx
├── .env
├── index.html
└── package.json
## 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** ## Expanding the ESLint configuration
Node.js (v20+)
**2. Environment Variables** If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
VITE_API_URL="http://localhost:5008/api"
**3. Install Dependencies** ```js
npm install export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
**4. Development** // Remove tseslint.configs.recommended and replace with this
npm run dev 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. You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
npm run build: Compiles TypeScript and builds the production-ready assets.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+10 -10
View File
@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GG Admin Dashboard</title> <title>frontend</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+19 -28
View File
@@ -28,7 +28,6 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"shadcn": "^4.0.5", "shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
@@ -116,6 +115,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1760,6 +1760,7 @@
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
}, },
@@ -4092,6 +4093,7 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -4102,6 +4104,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -4112,6 +4115,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -4173,6 +4177,7 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.0",
@@ -4463,6 +4468,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4722,6 +4728,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -5188,6 +5195,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
@@ -5550,6 +5558,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5802,6 +5811,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -6352,15 +6362,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -8129,6 +8131,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -8138,6 +8141,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -8145,23 +8149,6 @@
"react": "^19.2.4" "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": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -8918,7 +8905,8 @@
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tailwindcss-animate": { "node_modules/tailwindcss-animate": {
"version": "1.0.7", "version": "1.0.7",
@@ -9125,6 +9113,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -9325,6 +9314,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -9669,6 +9659,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
-1
View File
@@ -30,7 +30,6 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"shadcn": "^4.0.5", "shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
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 { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import {Toaster} from "react-hot-toast";
import Login from "@/pages/Login"; import Login from "@/pages/Login";
@@ -9,7 +8,7 @@ import DashboardLayout from "./layouts/DashboardLayout";
import ProtectedRoute from "./auth/ProtectedRoute"; import ProtectedRoute from "./auth/ProtectedRoute";
import PublicRoute from "./auth/PublicRoute"; import PublicRoute from "./auth/PublicRoute";
import {AuthProvider} from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import Department from "./pages/Department"; import Department from "./pages/Department";
import Doctor from "./pages/Doctor"; import Doctor from "./pages/Doctor";
import Blog from "./pages/Blog"; import Blog from "./pages/Blog";
@@ -21,15 +20,10 @@ import CandidatePage from "./pages/candidates";
import InquiryPage from "./pages/inquiry"; import InquiryPage from "./pages/inquiry";
import AcademicsPage from "./pages/Academics"; import AcademicsPage from "./pages/Academics";
import NewsPage from "./pages/newsMedia"; import NewsPage from "./pages/newsMedia";
import BlogDetail from "./pages/BlogDetails";
import ImportData from "./pages/ImportData";
import HealthPackagePage from "./pages/HealthPackagePage";
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Toaster position="top-right" />
<AuthProvider> <AuthProvider>
<Routes> <Routes>
<Route element={<PublicRoute />}> <Route element={<PublicRoute />}>
@@ -41,7 +35,6 @@ export default function App() {
<Route path="/department" element={<Department />} /> <Route path="/department" element={<Department />} />
<Route path="/doctor" element={<Doctor />} /> <Route path="/doctor" element={<Doctor />} />
<Route path="/blog" element={<Blog />} /> <Route path="/blog" element={<Blog />} />
<Route path="/blog/:id" element={<BlogDetail />} />
<Route path="/blog/create" element={<BlogEditorPage />} /> <Route path="/blog/create" element={<BlogEditorPage />} />
<Route path="/blog/edit/:id" element={<BlogEditorPage />} /> <Route path="/blog/edit/:id" element={<BlogEditorPage />} />
<Route path="/appointment" element={<Appointment />} /> <Route path="/appointment" element={<Appointment />} />
@@ -51,8 +44,6 @@ export default function App() {
<Route path="/inquiry" element={<InquiryPage />} /> <Route path="/inquiry" element={<InquiryPage />} />
<Route path="/academics" element={<AcademicsPage />} /> <Route path="/academics" element={<AcademicsPage />} />
<Route path="/news" element={<NewsPage />} /> <Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
</Route> </Route>
</Route> </Route>
+15 -15
View File
@@ -1,22 +1,22 @@
import apiClient from "@/api/client"; import apiClient from "@/api/client";
export const getAppointmentsApi = async ( export const getAppointmentsApi = async (
page = 1, page?: number,
limit = 10, limit?: number,
date = "", search?: string,
startDate = "", doctor?: string,
endDate = "", department?: string,
search = "", date?: string,
) => { ) => {
const params = new URLSearchParams({ let url = "/appointments/getAll";
page: String(page),
limit: String(limit), if (page && limit) {
...(date && { date }), url += `?page=${page}&limit=${limit}&search=${search || ""}&doctor=${
...(startDate && { startDate }), doctor || ""
...(endDate && { endDate }), }&department=${department || ""}&date=${date || ""}`;
...(search && { search }), }
});
const res = await apiClient.get(`/appointments/getall?${params}`); const res = await apiClient.get(url);
return res.data; return res.data;
}; };
+1 -1
View File
@@ -14,7 +14,7 @@ export const getAllBlogsApi = async () => {
}; };
export const getBlogByIdApi = async (id: number) => { export const getBlogByIdApi = async (id: number) => {
const res = await apiClient.get(`/blogs/admin/${id}`); const res = await apiClient.get(`/blogs/${id}`);
return res.data; return res.data;
}; };
+3 -41
View File
@@ -1,49 +1,11 @@
import apiClient from "@/api/client"; import apiClient from "@/api/client";
import toast from "react-hot-toast";
export const getCareersApi = async () => { export const getCareersApi = async () => {
const res = await apiClient.get("/careers/getAll?admin=true"); const res = await apiClient.get("/careers/getAll");
return res.data; 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) => { export const deleteCareerApi = async (id: number) => {
try { const res = await apiClient.delete(`/careers/${id}`);
const res = await apiClient.delete(`/careers/${id}`); return res.data;
toast.success("Career deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete career");
throw error;
}
}; };
+2 -2
View File
@@ -1,10 +1,10 @@
import axios from "axios"; import axios from "axios";
import type {InternalAxiosRequestConfig} 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({ const apiClient = axios.create({
baseURL: baseURL, baseURL: BASE_URL,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
+7 -44
View File
@@ -1,21 +1,17 @@
import apiClient from "@/api/client"; import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface Department { export interface Department {
departmentId: string; departmentId: string;
name: string; name: string;
image?: string;
para1: string; para1: string;
para2: string; para2: string;
para3: string; para3: string;
facilities: string; facilities: string;
services: string; services: string;
isActive?: boolean;
sortOrder?: number;
} }
export const getDepartmentsApi = async () => { export const getDepartmentsApi = async () => {
const res = await apiClient.get("/departments/getAll?admin=true"); const res = await apiClient.get("/departments/getAll");
return res.data; return res.data;
}; };
@@ -28,19 +24,8 @@ export const createDepartmentApi = async (data: {
facilities?: string; facilities?: string;
services?: string; services?: string;
}) => { }) => {
try { const res = await apiClient.post("/departments", data);
const res = await apiClient.post("/departments", data); return res.data;
toast.success("Department created successfully");
return res.data;
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Failed to create department",
);
throw error;
}
}; };
export const updateDepartmentApi = async ( export const updateDepartmentApi = async (
@@ -54,33 +39,11 @@ export const updateDepartmentApi = async (
services?: string; services?: string;
}, },
) => { ) => {
try { const res = await apiClient.put(`/departments/${departmentId}`, data);
const res = await apiClient.put(`/departments/${departmentId}`, data); return res.data;
toast.success("Department updated successfully");
return res.data;
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Failed to update department",
);
throw error;
}
}; };
export const deleteDepartmentApi = async (departmentId: string) => { export const deleteDepartmentApi = async (departmentId: string) => {
try { const res = await apiClient.delete(`/departments/${departmentId}`);
const res = await apiClient.delete(`/departments/${departmentId}`); return res.data;
toast.success("Department deleted successfully");
return res.data;
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Failed to delete department",
);
throw error;
}
}; };
+8 -40
View File
@@ -1,17 +1,13 @@
import apiClient from "@/api/client"; import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface Doctor { export interface Doctor {
doctorId: string; doctorId: string;
name: string; name: string;
image?: string;
designation?: string; designation?: string;
workingStatus?: string; workingStatus?: string;
qualification?: string; qualification?: string;
isActive: boolean;
globalSortOrder: number;
departments?: { departments: {
departmentId: string; departmentId: string;
timing?: { timing?: {
monday?: string; monday?: string;
@@ -27,7 +23,7 @@ export interface Doctor {
} }
export const getDoctorsApi = async () => { export const getDoctorsApi = async () => {
const res = await apiClient.get("/doctors/getAll?admin=true"); const res = await apiClient.get("/doctors/getAll");
return res.data; return res.data;
}; };
@@ -37,49 +33,21 @@ export const getDoctorByIdApi = async (doctorId: string) => {
}; };
export const createDoctorApi = async (data: Doctor) => { export const createDoctorApi = async (data: Doctor) => {
try { const res = await apiClient.post("/doctors", data);
const res = await apiClient.post("/doctors", data); return res.data;
toast.success("Doctor created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create doctor");
throw error;
}
}; };
export const updateDoctorApi = async ( export const updateDoctorApi = async (
doctorId: string, doctorId: string,
data: Partial<Doctor>, data: Partial<Doctor>,
action: "toggleStatus" | "updateDetails" = "updateDetails",
) => { ) => {
try { const res = await apiClient.patch(`/doctors/${doctorId}`, data);
const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data); return res.data;
toast.success("Doctor updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update doctor");
throw error;
}
}; };
export const deleteDoctorApi = async (doctorId: string) => { export const deleteDoctorApi = async (doctorId: string) => {
try { const res = await apiClient.delete(`/doctors/${doctorId}`);
const res = await apiClient.delete(`/doctors/${doctorId}`); return res.data;
toast.success("Doctor deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete doctor");
throw error;
}
}; };
export const getDoctorTimingApi = async (doctorId: string) => { 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"; 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( const res = await apiClient.get(
`/newsMedia/getAll?page=${page}&limit=${limit}&search=${search}`, `/newsMedia/getAll?page=${page}&limit=${limit}`,
); );
return res.data; 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", name: "Doctor",
path: "/doctor", path: "/doctor",
}, },
{
name: "Health Check",
path: "/health-check",
},
{ {
name: "Appointments", name: "Appointments",
path: "/appointment", path: "/appointment",
@@ -69,8 +65,7 @@ export default function Sidebar() {
<Link key={item.path} to={item.path}> <Link key={item.path} to={item.path}>
<Button <Button
variant={active ? "secondary" : "ghost"} variant={active ? "secondary" : "ghost"}
className="w-full justify-start" className="w-full justify-start">
>
{item.name} {item.name}
</Button> </Button>
</Link> </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" import { cn } from "@/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { 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", "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", 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", 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: "size-8",
"icon-xs": "icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "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 <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -127,10 +127,7 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn("text-base leading-none font-medium", className)}
"text-base leading-none font-medium",
className
)}
{...props} {...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"; } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { import { Loader2, Trash, RefreshCw, Download } from "lucide-react";
Loader2,
Trash,
RefreshCw,
Download,
ChevronLeft,
ChevronRight,
Eye,
BookOpen,
} from "lucide-react";
export default function AcademicsPage() { export default function AcademicsPage() {
const [records, setRecords] = useState<any[]>([]); const [records, setRecords] = useState<any[]>([]);
@@ -40,12 +25,6 @@ export default function AcademicsPage() {
const [searchText, setSearchText] = useState(""); 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 () => { const fetchAll = useCallback(async () => {
setLoading(true); setLoading(true);
try { 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) { async function handleDelete(id: number) {
if (!confirm("Delete record?")) return; if (!confirm("Delete record?")) return;
await deleteAcademicsApi(id); await deleteAcademicsApi(id);
@@ -109,29 +74,24 @@ export default function AcademicsPage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex justify-between items-center gap-3 flex-wrap">
<h1 className="text-3xl font-bold">Academics & Research</h1> <h1 className="text-2xl font-bold">Academics & Research</h1>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-2">
<Input <Input
placeholder="Search name / course / email..." placeholder="Search name / phone / email / subject..."
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="w-[280px] text-base" className="w-[260px]"
/> />
<Button <Button variant="outline" onClick={fetchAll} disabled={loading}>
variant="outline" <RefreshCw className="mr-2 h-4 w-4" />
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh Refresh
</Button> </Button>
<Button onClick={handleExport} className="text-base"> <Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-5 w-5" /> <Download className="mr-2 h-4 w-4" />
Export Export
</Button> </Button>
</div> </div>
@@ -139,108 +99,65 @@ export default function AcademicsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Academic Records</CardTitle> <CardTitle>Academics Records</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent>
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <div className="overflow-x-auto">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0"> <Table className="min-w-[1000px]">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm"> <TableHead>ID</TableHead>
ID <TableHead>Name</TableHead>
</TableHead> <TableHead>Phone</TableHead>
<TableHead className="w-[220px] bg-background font-bold text-sm"> <TableHead>Email</TableHead>
Full Name <TableHead>Course</TableHead>
</TableHead> <TableHead>Subject</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm"> <TableHead>Message</TableHead>
Course <TableHead>Date</TableHead>
</TableHead> <TableHead>Actions</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">
Subject
</TableHead>
<TableHead className="w-[140px] bg-background font-bold text-sm">
Applied Date
</TableHead>
<TableHead className="w-[220px] bg-background font-bold text-sm">
Message
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-10"> <TableCell colSpan={9} className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> <Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentItems.length === 0 ? ( ) : filteredRecords.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={9} className="text-center">
colSpan={7}
className="text-center text-muted-foreground py-10 text-base"
>
No records found No records found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
currentItems.map((item) => ( filteredRecords.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50"> <TableRow key={item.id}>
<TableCell className="font-mono text-xs"> <TableCell>{item.id}</TableCell>
{item.id} <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>
<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()} {new Date(item.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic"> <Button
{item.message || "-"} size="sm"
</div> variant="destructive"
</TableCell> onClick={() => handleDelete(item.id)}>
<TableCell className="text-right"> <Trash className="h-4 w-4" />
<div className="flex justify-end gap-2"> </Button>
<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> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -248,117 +165,8 @@ export default function AcademicsPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </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> </CardContent>
</Card> </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> </div>
); );
} }
+177 -347
View File
@@ -15,13 +15,6 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { import {
Loader2, Loader2,
@@ -30,7 +23,6 @@ import {
Download, Download,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Eye,
} from "lucide-react"; } from "lucide-react";
export default function AppointmentPage() { export default function AppointmentPage() {
@@ -39,180 +31,157 @@ export default function AppointmentPage() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [filterDoctor, setFilterDoctor] = useState(""); const [filterDoctor, setFilterDoctor] = useState("");
const [filterDepartment, setFilterDepartment] = useState("");
const [filterDate, setFilterDate] = 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 [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [meta, setMeta] = useState<any>({});
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getAppointmentsApi( const res = await getAppointmentsApi(
currentPage, currentPage,
itemsPerPage, itemsPerPage,
filterDate,
startDate,
endDate,
searchText, searchText,
filterDoctor,
filterDepartment,
filterDate,
); );
setAppointments(res?.data || []); setAppointments(res?.data || []);
setTotalPages(res?.pagination?.totalPages || 1); setMeta(res?.meta || {});
setTotalItems(res?.pagination?.total || 0);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, itemsPerPage, filterDate, startDate, endDate, searchText]); }, [
currentPage,
itemsPerPage,
searchText,
filterDoctor,
filterDepartment,
filterDate,
]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
}, [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) { async function handleDelete(id: number) {
if (!confirm("Delete appointment?")) return; if (!confirm("Delete appointment?")) return;
await deleteAppointmentApi(id); await deleteAppointmentApi(id);
fetchAll(); fetchAll();
} }
const handleExport = () => { const handleExport = async () => {
const exportData = filteredAppointments.map((item) => ({ try {
ID: item.id, const res = await getAppointmentsApi();
Name: item.name,
Phone: item.mobileNumber, let data = res?.data || [];
Email: item.email,
Doctor: item.doctor?.name, data = data.filter((item: any) => {
Department: item.department?.name, const matchesSearch =
Date: new Date(item.date).toLocaleDateString(), item.name?.toLowerCase().includes(searchText.toLowerCase()) ||
Message: item.message, item.mobileNumber?.includes(searchText) ||
})); item.email?.toLowerCase().includes(searchText.toLowerCase());
exportToExcel(exportData, "appointments");
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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex justify-between items-center flex-wrap gap-3">
<h1 className="text-3xl font-bold">Appointments</h1> <h1 className="text-2xl font-bold">Appointments</h1>
<div className="flex flex-wrap gap-4 items-end"> <div className="flex flex-wrap gap-2 items-center">
<div className="flex flex-col gap-1"> <Input
<label className="text-xs font-medium text-muted-foreground"> placeholder="Search..."
Search value={searchText}
</label> onChange={(e) => {
<Input setSearchText(e.target.value);
placeholder="Search name / phone..." setCurrentPage(1);
value={searchText} }}
onChange={(e) => { className="w-[200px]"
setSearchText(e.target.value); />
setCurrentPage(1);
}}
className="w-[220px] text-base"
/>
</div>
<div className="flex flex-col gap-1"> <Input
<label className="text-xs font-medium text-muted-foreground"> placeholder="Doctor"
Date value={filterDoctor}
</label> onChange={(e) => setFilterDoctor(e.target.value)}
<Input className="w-[160px]"
type="date" />
value={filterDate}
onChange={(e) => {
setFilterDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!startDate || !!endDate}
/>
</div>
<div className="flex flex-col gap-1"> <Input
<label className="text-xs font-medium text-muted-foreground"> placeholder="Department"
From value={filterDepartment}
</label> onChange={(e) => setFilterDepartment(e.target.value)}
<Input className="w-[160px]"
type="date" />
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1"> <Input
<label className="text-xs font-medium text-muted-foreground"> type="date"
To value={filterDate}
</label> onChange={(e) => setFilterDate(e.target.value)}
<Input className="w-[160px]"
type="date" />
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1"> <select
<label className="text-xs font-medium text-muted-foreground"> value={itemsPerPage}
Rows onChange={(e) => {
</label> setItemsPerPage(Number(e.target.value));
<select setCurrentPage(1);
value={itemsPerPage} }}
onChange={(e) => { className="border px-2 py-1 rounded">
setItemsPerPage(Number(e.target.value)); <option value={5}>5</option>
setCurrentPage(1); <option value={10}>10</option>
}} <option value={20}>20</option>
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary" </select>
>
<option value={5}>5 / page</option>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
</select>
</div>
<Button <Button onClick={fetchAll}>
variant="outline" <RefreshCw className="mr-2 h-4 w-4" />
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button> </Button>
<Button onClick={handleExport} className="text-base"> <Button onClick={handleExport}>
<Download className="mr-2 h-5 w-5" /> <Download className="mr-2 h-4 w-4" />
Export Export
</Button> </Button>
</div> </div>
@@ -220,223 +189,84 @@ export default function AppointmentPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Appointment List</CardTitle> <CardTitle>Appointment List</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent>
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <Table>
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0"> <TableHeader>
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <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> <TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm"> <TableCell colSpan={7} className="text-center">
ID <Loader2 className="animate-spin mx-auto" />
</TableHead> </TableCell>
<TableHead className="w-[200px] bg-background font-bold text-sm">
Patient
</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">
Doctor
</TableHead>
<TableHead className="w-[150px] bg-background font-bold text-sm">
Date
</TableHead>
<TableHead className="w-[250px] bg-background font-bold text-sm">
Message
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow> </TableRow>
</TableHeader> ) : appointments.length === 0 ? (
<TableRow>
<TableBody> <TableCell
{loading ? ( colSpan={7}
<TableRow> className="text-center py-6 text-gray-500">
<TableCell colSpan={6} className="text-center py-10"> No appointments found
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> </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> </TableCell>
</TableRow> </TableRow>
) : filteredAppointments.length === 0 ? ( ))
<TableRow> )}
<TableCell </TableBody>
colSpan={6} </Table>
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>
{!loading && totalItems > 0 && ( <div className="flex justify-between mt-4">
<div className="flex items-center justify-between px-2 py-6 border-t"> <p>
<div className="text-base text-muted-foreground"> Page {meta.page || 1} of {meta.totalPages || 1}
Showing{" "} </p>
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold"> <div className="flex gap-2">
{Math.min(currentPage * itemsPerPage, totalItems)} <Button
</span>{" "} disabled={currentPage === 1}
of <span className="font-semibold">{totalItems}</span> onClick={() => setCurrentPage((p) => p - 1)}>
</div> <ChevronLeft />
<div className="flex items-center gap-6"> </Button>
<div className="text-base font-semibold">
Page {currentPage} of {totalPages} <Button
</div> disabled={currentPage === meta.totalPages}
<div className="flex gap-2"> onClick={() => setCurrentPage((p) => p + 1)}>
<Button <ChevronRight />
variant="outline" </Button>
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> </div>
)} </div>
</CardContent> </CardContent>
</Card> </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> </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 {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input"; 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 { interface Blog {
id: number; id: number;
@@ -161,13 +161,6 @@ export default function BlogPage() {
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/blog/${blog.id}`)}
>
<Eye className="h-4 w-4" />
</Button>
<Button <Button
size="sm" size="sm"
variant="destructive" 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 {useEffect, useRef, useState} from "react";
import {useNavigate, useParams} from "react-router-dom"; import {useNavigate, useParams} from "react-router-dom";
import {BytescaleUploader} from "@/components/BytescaleUploader/BytescaleUploader";
import EditorJS, {OutputData} from "@editorjs/editorjs"; import EditorJS, {OutputData} from "@editorjs/editorjs";
import Header from "@editorjs/header"; import Header from "@editorjs/header";
import List from "@editorjs/list"; import List from "@editorjs/list";
@@ -10,7 +10,6 @@ import Table from "@editorjs/table";
import CodeTool from "@editorjs/code"; import CodeTool from "@editorjs/code";
import Embed from "@editorjs/embed"; import Embed from "@editorjs/embed";
import Delimiter from "@editorjs/delimiter"; import Delimiter from "@editorjs/delimiter";
import axios from "axios";
import { import {
createBlogApi, createBlogApi,
@@ -24,7 +23,6 @@ import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
export default function BlogEditorPage() { export default function BlogEditorPage() {
const baseURL = import.meta.env.VITE_API_URL;
const {id} = useParams(); const {id} = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -81,41 +79,12 @@ export default function BlogEditorPage() {
config: { config: {
uploader: { uploader: {
uploadByFile: async (file: File) => { uploadByFile: async (file: File) => {
if (file.size > 5 * 1024 * 1024) { const res = await uploadImageApi(file);
alert("File is too large (Max 5MB)");
return {success: 0, file: {url: ""}};
}
const formData = new FormData(); return {
formData.append("file", file); success: 1,
formData.append("folderPath", "/blog"); file: {url: res.file.url},
};
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: ""},
};
}
}, },
}, },
}, },
@@ -148,6 +117,18 @@ export default function BlogEditorPage() {
initEditor(); initEditor();
}, [id, isEdit]); }, [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 () => { const handleSave = async () => {
if (!editorRef.current) return; if (!editorRef.current) return;
@@ -201,11 +182,7 @@ export default function BlogEditorPage() {
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Cover Image</label> <label className="text-sm font-medium">Cover Image</label>
<BytescaleUploader <Input type="file" onChange={handleCoverUpload} />
value={coverImage}
folderPath="/blog"
onChange={(url) => setCoverImage(url)}
/>
{coverImage && ( {coverImage && (
<img <img
+120 -271
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from "react"; 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 { import {
Table, Table,
@@ -13,6 +15,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -22,18 +25,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; 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 { import { Loader2, Plus, Pencil, Trash, RefreshCw } from "lucide-react";
Loader2,
Plus,
Pencil,
RefreshCw,
ChevronLeft,
ChevronRight,
} from "lucide-react";
export default function CareerPage() { export default function CareerPage() {
const [careers, setCareers] = useState<any[]>([]); const [careers, setCareers] = useState<any[]>([]);
@@ -44,9 +37,6 @@ export default function CareerPage() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [form, setForm] = useState({ const [form, setForm] = useState({
post: "", post: "",
designation: "", designation: "",
@@ -55,8 +45,6 @@ export default function CareerPage() {
email: "", email: "",
number: "", number: "",
status: "new", status: "new",
isActive: true,
sortOrder: 0,
}); });
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
@@ -75,37 +63,14 @@ export default function CareerPage() {
fetchAll(); fetchAll();
}, [fetchAll]); }, [fetchAll]);
const filteredCareers = careers.filter( const filteredCareers = careers.filter((item) =>
(item) => item.post?.toLowerCase().includes(searchText.toLowerCase()),
item.post?.toLowerCase().includes(searchText.toLowerCase()) ||
item.designation?.toLowerCase().includes(searchText.toLowerCase()),
); );
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
const totalPages = Math.ceil(filteredCareers.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredCareers.slice(indexOfFirstItem, indexOfLastItem);
function handleChange(e: any) { function handleChange(e: any) {
setForm({ ...form, [e.target.name]: e.target.value }); 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() { function openAdd() {
setEditing(null); setEditing(null);
setForm({ setForm({
@@ -116,14 +81,13 @@ export default function CareerPage() {
email: "", email: "",
number: "", number: "",
status: "new", status: "new",
isActive: true,
sortOrder: 0,
}); });
setOpenModal(true); setOpenModal(true);
} }
function openEdit(item: any) { function openEdit(item: any) {
setEditing(item); setEditing(item);
setForm({ setForm({
post: item.post || "", post: item.post || "",
designation: item.designation || "", designation: item.designation || "",
@@ -132,18 +96,17 @@ export default function CareerPage() {
email: item.email || "", email: item.email || "",
number: item.number || "", number: item.number || "",
status: item.status || "new", status: item.status || "new",
isActive: item.isActive ?? true,
sortOrder: item.sortOrder ?? 0,
}); });
setOpenModal(true); setOpenModal(true);
} }
async function handleSubmit() { async function handleSubmit() {
try { try {
if (editing) { if (editing) {
await updateCareerApi(editing.id, form); await apiClient.patch(`/careers/${editing.id}`, form);
} else { } else {
await createCareerApi(form); await apiClient.post("/careers", form);
} }
setOpenModal(false); 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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex justify-between items-center gap-3 flex-wrap">
<h1 className="text-3xl font-bold">Careers</h1> <h1 className="text-2xl font-bold">Careers</h1>
<div className="flex flex-wrap gap-3"> <div className="flex gap-2 flex-wrap">
<Input <Input
placeholder="Search post / designation..." placeholder="Search career..."
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base" className="w-[220px]"
/> />
<Button <Button variant="outline" onClick={fetchAll} disabled={loading}>
variant="outline" <RefreshCw className="mr-2 h-4 w-4" />
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh Refresh
</Button> </Button>
<Button onClick={openAdd} className="text-base"> <Button onClick={openAdd}>
<Plus className="mr-2 h-5 w-5" /> <Plus className="mr-2 h-4 w-4" />
Add Career Add Career
</Button> </Button>
</div> </div>
@@ -185,99 +149,67 @@ export default function CareerPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Career Opportunities</CardTitle> <CardTitle>Career List</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent>
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <div className="overflow-x-auto">
<Table className="w-full min-w-[800px] table-fixed border-separate border-spacing-0"> <Table className="min-w-[900px]">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[80px] bg-background font-bold text-sm"> <TableHead>ID</TableHead>
Priority <TableHead>Post</TableHead>
</TableHead> <TableHead>Designation</TableHead>
<TableHead className="w-[250px] bg-background font-bold text-sm"> <TableHead>Qualification</TableHead>
Post & Designation <TableHead>Experience</TableHead>
</TableHead> <TableHead>Email</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm"> <TableHead>Phone</TableHead>
Qualification <TableHead>Status</TableHead>
</TableHead> <TableHead>Actions</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>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-10"> <TableCell colSpan={9} className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> <Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentItems.length === 0 ? ( ) : filteredCareers.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={9} className="text-center">
colSpan={6}
className="text-center text-muted-foreground py-10 text-base"
>
No careers found No careers found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
currentItems.map((item) => ( filteredCareers.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50"> <TableRow key={item.id}>
<TableCell className="font-mono text-xs"> <TableCell>{item.id}</TableCell>
{item.sortOrder} <TableCell>{item.post}</TableCell>
</TableCell> <TableCell>{item.designation}</TableCell>
<TableCell> <TableCell>{item.qualification}</TableCell>
<div className="font-semibold text-base truncate"> <TableCell>{item.experienceNeed}</TableCell>
{item.post} <TableCell>{item.email}</TableCell>
</div> <TableCell>{item.number}</TableCell>
<div className="text-xs text-muted-foreground truncate"> <TableCell>{item.status}</TableCell>
{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>
<TableCell className="text-right"> <TableCell className="flex gap-2">
<div className="flex justify-end gap-2"> <Button
<Button size="sm"
size="icon" variant="outline"
variant="ghost" onClick={() => openEdit(item)}
className="h-9 w-9" >
onClick={() => openEdit(item)} <Pencil className="h-4 w-4" />
> </Button>
<Pencil className="h-4 w-4" />
</Button> <Button
</div> size="sm"
variant="destructive"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -285,150 +217,67 @@ export default function CareerPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </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> </CardContent>
</Card> </Card>
{/* MODAL */}
<Dialog open={openModal} onOpenChange={setOpenModal}> <Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="w-full max-w-lg"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl"> <DialogTitle>{editing ? "Edit Career" : "Add Career"}</DialogTitle>
{editing ? "Edit Career" : "Add New Career"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-3">
<div className="grid grid-cols-1 gap-4"> <Input
<Input name="post"
name="post" placeholder="Post"
placeholder="Post (e.g. Staff Nurse)" value={form.post}
value={form.post} onChange={handleChange}
onChange={handleChange} />
className="text-base" <Input
/> name="designation"
<Input placeholder="Designation"
name="designation" value={form.designation}
placeholder="Designation" onChange={handleChange}
value={form.designation} />
onChange={handleChange} <Input
className="text-base" name="qualification"
/> placeholder="Qualification"
<Input value={form.qualification}
name="qualification" onChange={handleChange}
placeholder="Qualification" />
value={form.qualification} <Input
onChange={handleChange} name="experienceNeed"
className="text-base" placeholder="Experience Needed"
/> value={form.experienceNeed}
<Input onChange={handleChange}
name="experienceNeed" />
placeholder="Experience Needed" <Input
value={form.experienceNeed} name="email"
onChange={handleChange} placeholder="Email"
className="text-base" value={form.email}
/> onChange={handleChange}
<Input />
name="email" <Input
type="email" name="number"
placeholder="HR Email Address" placeholder="Phone Number"
value={form.email} value={form.number}
onChange={handleChange} onChange={handleChange}
className="text-base" />
/> <Input
<Input name="status"
name="number" placeholder="Status"
placeholder="Contact Number" value={form.status}
value={form.number} onChange={handleChange}
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> </div>
<DialogFooter className="pt-4 border-t"> <DialogFooter>
<Button <Button variant="outline" onClick={() => setOpenModal(false)}>
variant="ghost"
onClick={() => setOpenModal(false)}
className="text-base"
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit} className="px-8 text-base"> <Button onClick={handleSubmit}>
{editing ? "Save Changes" : "Create Career"} {editing ? "Update" : "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+100 -239
View File
@@ -1,11 +1,11 @@
import {useState, useEffect, useCallback} from "react"; import { useState, useEffect, useCallback } from "react";
import {AxiosError} from "axios"; import { AxiosError } from "axios";
import {BytescaleUploader} from "@/components/BytescaleUploader/BytescaleUploader";
import { import {
getDepartmentsApi, getDepartmentsApi,
createDepartmentApi, createDepartmentApi,
updateDepartmentApi, updateDepartmentApi,
deleteDepartmentApi,
} from "@/api/department"; } from "@/api/department";
import { import {
@@ -17,8 +17,8 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {Button} from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -28,33 +28,19 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {Input} from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {Textarea} from "@/components/ui/textarea"; 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 { import { Loader2, RefreshCw, Plus, Pencil, Trash, Eye } from "lucide-react";
Loader2,
RefreshCw,
Plus,
Pencil,
Eye,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface Department { interface Department {
departmentId: string; departmentId: string;
name: string; name: string;
image?: string;
para1: string; para1: string;
para2: string; para2: string;
para3: string; para3: string;
facilities: string; facilities: string;
services: string; services: string;
isActive: boolean;
sortOrder: number;
} }
export default function DepartmentPage() { export default function DepartmentPage() {
@@ -70,20 +56,14 @@ export default function DepartmentPage() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [form, setForm] = useState<Department>({ const [form, setForm] = useState<Department>({
departmentId: "", departmentId: "",
name: "", name: "",
image: "",
para1: "", para1: "",
para2: "", para2: "",
para3: "", para3: "",
facilities: "", facilities: "",
services: "", services: "",
isActive: true,
sortOrder: 0,
}); });
const fetchDepartments = useCallback(async () => { const fetchDepartments = useCallback(async () => {
@@ -108,67 +88,36 @@ export default function DepartmentPage() {
fetchDepartments(); fetchDepartments();
}, [fetchDepartments]); }, [fetchDepartments]);
const filteredDepartments = departments.filter( const filteredDepartments = departments.filter((dep) =>
(dep) => dep.name.toLowerCase().includes(searchText.toLowerCase()),
dep.name.toLowerCase().includes(searchText.toLowerCase()) ||
dep.departmentId.toLowerCase().includes(searchText.toLowerCase()),
);
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
const totalPages = Math.ceil(filteredDepartments.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredDepartments.slice(
indexOfFirstItem,
indexOfLastItem,
); );
function handleChange(e: any) { function handleChange(e: any) {
const value = setForm({ ...form, [e.target.name]: e.target.value });
e.target.type === "number" ? Number(e.target.value) : e.target.value;
setForm({...form, [e.target.name]: value});
} }
const handleToggleStatus = async (dep: Department) => { function truncate(text: string, limit = 60) {
try { if (!text) return "-";
const {departmentId, ...updateData} = dep; return text.length > limit ? text.substring(0, limit) + "..." : text;
await updateDepartmentApi(departmentId, { }
...updateData,
isActive: !dep.isActive,
} as any);
fetchDepartments();
} catch (error) {
console.error("Failed to toggle status", error);
}
};
function openAdd() { function openAdd() {
setEditing(null); setEditing(null);
setForm({ setForm({
departmentId: "", departmentId: "",
name: "", name: "",
image: "",
para1: "", para1: "",
para2: "", para2: "",
para3: "", para3: "",
facilities: "", facilities: "",
services: "", services: "",
isActive: true,
sortOrder: 0,
}); });
setOpenModal(true); setOpenModal(true);
} }
function openEdit(dep: Department) { function openEdit(dep: Department) {
setEditing(dep); setEditing(dep);
setForm({ setForm(dep);
...dep,
isActive: dep.isActive ?? true,
sortOrder: dep.sortOrder ?? 0,
});
setOpenModal(true); setOpenModal(true);
} }
@@ -180,8 +129,8 @@ export default function DepartmentPage() {
async function handleSubmit() { async function handleSubmit() {
try { try {
if (editing) { if (editing) {
const {departmentId, ...updateData} = form; const { departmentId, ...updateData } = form;
await updateDepartmentApi(editing.departmentId, form as any); await updateDepartmentApi(editing.departmentId, updateData);
} else { } else {
await createDepartmentApi(form); 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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<h1 className="text-3xl font-bold">Departments</h1> <h1 className="text-2xl font-bold">Departments</h1>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Input <Input
placeholder="Search department..." placeholder="Search department..."
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base" className="w-[220px]"
/> />
<Button <Button
variant="outline" variant="outline"
onClick={fetchDepartments} onClick={fetchDepartments}
disabled={loading} disabled={loading}>
className="text-base" <RefreshCw className="mr-2 h-4 w-4" />
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh Refresh
</Button> </Button>
<Button onClick={openAdd} className="text-base"> <Button onClick={openAdd}>
<Plus className="mr-2 h-5 w-5" /> <Plus className="mr-2 h-4 w-4" />
Add Department Add Department
</Button> </Button>
</div> </div>
</div> </div>
{error && ( {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} {error}
</div> </div>
)} )}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Department List</CardTitle> <CardTitle>Department List</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent>
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <div className="border rounded-md max-h-[500px] overflow-y-auto">
<Table className="w-full min-w-[700px] table-fixed border-separate border-spacing-0"> <Table className="w-full table-fixed">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[100px] bg-background text-sm font-bold"> <TableHead className="w-[80px]">ID</TableHead>
Priority <TableHead className="w-[180px]">Name</TableHead>
</TableHead> <TableHead className="w-[250px]">Para1</TableHead>
<TableHead className="w-[300px] bg-background text-sm font-bold"> <TableHead className="w-[220px]">Facilities</TableHead>
Name <TableHead className="w-[220px]">Services</TableHead>
</TableHead> <TableHead className="w-[120px]">Actions</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>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-10"> <TableCell colSpan={6} className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> <Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentItems.length === 0 ? ( ) : filteredDepartments.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={6} className="text-center">
colSpan={4}
className="text-center text-muted-foreground py-10 text-base"
>
No departments found No departments found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
currentItems.map((dep) => ( filteredDepartments.map((dep) => (
<TableRow <TableRow key={dep.departmentId}>
key={dep.departmentId} <TableCell>{dep.departmentId}</TableCell>
className="hover:bg-muted/50"
> <TableCell>
<TableCell className="font-mono text-sm"> <div className="break-words">{dep.name}</div>
{dep.sortOrder}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div <div className="break-words whitespace-normal">
className="font-semibold text-base truncate" {truncate(dep.para1)}
title={dep.name}
>
{dep.name}
</div>
<div className="text-xs text-muted-foreground">
{dep.departmentId}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="break-words whitespace-normal">
<Switch {truncate(dep.facilities)}
checked={dep.isActive}
onCheckedChange={() => handleToggleStatus(dep)}
/>
<Badge
variant={dep.isActive ? "default" : "secondary"}
>
{dep.isActive ? "Active" : "Hidden"}
</Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell>
<div className="flex justify-end gap-2"> <div className="break-words whitespace-normal">
<Button {truncate(dep.services)}
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>
</div> </div>
</TableCell> </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> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </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> </CardContent>
</Card> </Card>
@@ -390,14 +286,6 @@ export default function DepartmentPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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 <Input
name="departmentId" name="departmentId"
value={form.departmentId} value={form.departmentId}
@@ -443,32 +331,6 @@ export default function DepartmentPage() {
onChange={handleChange} onChange={handleChange}
placeholder="Services" 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> </div>
<DialogFooter> <DialogFooter>
@@ -476,7 +338,7 @@ export default function DepartmentPage() {
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit}> <Button onClick={handleSubmit}>
{editing ? "Save Changes" : "Create"} {editing ? "Update" : "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -484,45 +346,44 @@ export default function DepartmentPage() {
<Dialog open={viewOpen} onOpenChange={setViewOpen}> <Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-5xl max-h-[90vh] overflow-y-auto"> <DialogContent className="w-full !max-w-5xl max-h-[90vh] overflow-y-auto">
{" "}
<DialogHeader> <DialogHeader>
<DialogTitle>Department Details</DialogTitle> <DialogTitle>Department Details</DialogTitle>
</DialogHeader> </DialogHeader>
{viewData && ( {viewData && (
<div className="space-y-4 text-sm"> <div className="space-y-4 text-sm">
<div className="flex gap-4 items-center border-b pb-4"> <p>
<Badge variant={viewData.isActive ? "default" : "secondary"}> <b>ID:</b> {viewData.departmentId}
{viewData.isActive ? "ACTIVE" : "HIDDEN"} </p>
</Badge>
<p>
<b>Sort Order:</b> {viewData.sortOrder}
</p>
<p>
<b>ID:</b> {viewData.departmentId}
</p>
</div>
<p> <p>
<b>Name:</b> {viewData.name} <b>Name:</b> {viewData.name}
</p> </p>
<p> <p>
<b>Para1:</b> <b>Para1:</b>
<br /> <br />
{viewData.para1} {viewData.para1}
</p> </p>
<p> <p>
<b>Para2:</b> <b>Para2:</b>
<br /> <br />
{viewData.para2} {viewData.para2}
</p> </p>
<p> <p>
<b>Para3:</b> <b>Para3:</b>
<br /> <br />
{viewData.para3} {viewData.para3}
</p> </p>
<p> <p>
<b>Facilities:</b> <b>Facilities:</b>
<br /> <br />
{viewData.facilities} {viewData.facilities}
</p> </p>
<p> <p>
<b>Services:</b> <b>Services:</b>
<br /> <br />
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-158
View File
@@ -1,158 +0,0 @@
import React, { useState, ChangeEvent } from "react";
import * as XLSX from "xlsx";
import apiClient from "@/api/client";
interface ImportPayload {
departments: any[];
doctors: any[];
timings: any[];
careers: any[];
inquiries: any[];
academics: any[];
appointments: any[];
candidates: any[];
news: any[];
}
const ImportData: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false);
const [status, setStatus] = useState<string>("");
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setLoading(true);
setStatus("Reading Excel file...");
const reader = new FileReader();
reader.onload = async (evt: ProgressEvent<FileReader>) => {
try {
const bstr = evt.target?.result;
if (!bstr) throw new Error("Failed to read file content.");
const wb = XLSX.read(bstr, { type: "binary" });
const payload: ImportPayload = {
departments: XLSX.utils.sheet_to_json(wb.Sheets["Departments"]) || [],
doctors: XLSX.utils.sheet_to_json(wb.Sheets["Doctors"]) || [],
timings: XLSX.utils.sheet_to_json(wb.Sheets["Doctor Timings"]) || [],
careers: XLSX.utils.sheet_to_json(wb.Sheets["Careers"]) || [],
inquiries: XLSX.utils.sheet_to_json(wb.Sheets["Inquiry"]) || [],
academics:
XLSX.utils.sheet_to_json(wb.Sheets["Academics & Research"]) || [],
appointments:
XLSX.utils.sheet_to_json(wb.Sheets["Appointment"]) || [],
candidates: XLSX.utils.sheet_to_json(wb.Sheets["Candidate"]) || [],
news: XLSX.utils.sheet_to_json(wb.Sheets["News & Media"]) || [],
};
setStatus("Uploading data to server (this may take a moment)...");
const response = await apiClient.post("/import/bulk", payload);
if (response.status === 200) {
setStatus("✅ ALL DATA IMPORT COMPLETED SUCCESSFULLY!");
} else {
setStatus("❌ Server responded with an error.");
}
} catch (err: any) {
console.error("Import Error:", err);
const errorMsg = err.response?.data?.error || "Error processing file.";
setStatus(`${errorMsg}`);
} finally {
setLoading(false);
if (e.target) e.target.value = "";
}
};
reader.onerror = () => {
setStatus("❌ Failed to read the file.");
setLoading(false);
};
reader.readAsBinaryString(file);
};
return (
<div style={containerStyle}>
<div style={cardStyle}>
<h2 style={{ color: "#333", marginBottom: "10px" }}>
Database Bulk Import
</h2>
<p style={{ color: "#666", marginBottom: "30px" }}>
Select the <b>gg_hospital.xlsx</b> file. This will update all tables.
</p>
<div style={{ marginBottom: "20px" }}>
<input
type="file"
accept=".xlsx, .xls"
onChange={handleFileUpload}
id="excel-upload"
style={{ display: "none" }}
disabled={loading}
/>
<label
htmlFor="excel-upload"
style={{
...buttonStyle,
backgroundColor: loading ? "#a0aec0" : "#3182ce",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "⌛ Processing..." : "📂 Choose Excel File"}
</label>
</div>
{status && (
<div
style={{
marginTop: "25px",
padding: "15px",
borderRadius: "8px",
backgroundColor: status.includes("✅") ? "#f0fff4" : "#fff5f5",
color: status.includes("✅") ? "#2f855a" : "#c53030",
border: `1px solid ${status.includes("✅") ? "#c6f6d5" : "#fed7d7"}`,
fontWeight: "500",
whiteSpace: "pre-wrap",
}}
>
{status}
</div>
)}
</div>
</div>
);
};
const containerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "80vh",
backgroundColor: "#f7fafc",
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
};
const cardStyle: React.CSSProperties = {
backgroundColor: "white",
padding: "40px",
borderRadius: "12px",
boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
maxWidth: "500px",
width: "100%",
textAlign: "center",
};
const buttonStyle: React.CSSProperties = {
padding: "12px 24px",
color: "white",
borderRadius: "6px",
fontSize: "16px",
fontWeight: "bold",
transition: "all 0.2s ease",
display: "inline-block",
};
export default ImportData;
+55 -252
View File
@@ -13,26 +13,11 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { import { Loader2, Trash, RefreshCw, Download } from "lucide-react";
Loader2,
Trash,
RefreshCw,
Download,
ChevronLeft,
ChevronRight,
Eye,
User,
} from "lucide-react";
export default function CandidatePage() { export default function CandidatePage() {
const [candidates, setCandidates] = useState<any[]>([]); const [candidates, setCandidates] = useState<any[]>([]);
@@ -41,12 +26,6 @@ export default function CandidatePage() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [filterCareer, setFilterCareer] = useState(""); const [filterCareer, setFilterCareer] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -76,23 +55,6 @@ export default function CandidatePage() {
return matchesSearch && matchesCareer; return matchesSearch && matchesCareer;
}); });
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterCareer]);
const totalPages = Math.ceil(filteredCandidates.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredCandidates.slice(
indexOfFirstItem,
indexOfLastItem,
);
function openView(item: any) {
setViewData(item);
setViewOpen(true);
}
async function handleDelete(id: number) { async function handleDelete(id: number) {
if (!confirm("Delete candidate?")) return; if (!confirm("Delete candidate?")) return;
await deleteCandidateApi(id); await deleteCandidateApi(id);
@@ -109,7 +71,7 @@ export default function CandidatePage() {
Designation: item.career?.designation, Designation: item.career?.designation,
Subject: item.subject, Subject: item.subject,
CoverLetter: item.coverLetter, CoverLetter: item.coverLetter,
AppliedDate: new Date(item.createdAt).toLocaleDateString(), Date: new Date(item.createdAt).toLocaleDateString(),
})); }));
exportToExcel(exportData, "candidates"); exportToExcel(exportData, "candidates");
@@ -117,36 +79,31 @@ export default function CandidatePage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex justify-between items-center gap-3 flex-wrap">
<h1 className="text-3xl font-bold">Candidates</h1> <h1 className="text-2xl font-bold">Candidates</h1>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-2">
<Input <Input
placeholder="Search candidate..." placeholder="Search name / phone / email..."
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base" className="w-[220px]"
/> />
<Input <Input
placeholder="Filter by Career" placeholder="Filter Career"
value={filterCareer} value={filterCareer}
onChange={(e) => setFilterCareer(e.target.value)} onChange={(e) => setFilterCareer(e.target.value)}
className="w-[200px] text-base" className="w-[200px]"
/> />
<Button <Button variant="outline" onClick={fetchAll} disabled={loading}>
variant="outline" <RefreshCw className="mr-2 h-4 w-4" />
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh Refresh
</Button> </Button>
<Button onClick={handleExport} className="text-base"> <Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-5 w-5" /> <Download className="mr-2 h-4 w-4" />
Export Export
</Button> </Button>
</div> </div>
@@ -154,104 +111,68 @@ export default function CandidatePage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Application List</CardTitle> <CardTitle>Candidate List</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent>
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <div className="overflow-x-auto">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0"> <Table className="min-w-[900px]">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm"> <TableHead>ID</TableHead>
ID <TableHead>Name</TableHead>
</TableHead> <TableHead>Phone</TableHead>
<TableHead className="w-[220px] bg-background font-bold text-sm"> <TableHead>Email</TableHead>
Full Name <TableHead>Career</TableHead>
</TableHead> <TableHead>Designation</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm"> <TableHead>Subject</TableHead>
Career & Post <TableHead>Cover Letter</TableHead>
</TableHead> <TableHead>Applied On</TableHead>
<TableHead className="w-[150px] bg-background font-bold text-sm"> <TableHead>Actions</TableHead>
Contact
</TableHead>
<TableHead className="w-[140px] bg-background font-bold text-sm">
Applied On
</TableHead>
<TableHead className="w-[250px] bg-background font-bold text-sm">
Cover Letter
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-10"> <TableCell colSpan={10} className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> <Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentItems.length === 0 ? ( ) : filteredCandidates.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={10} className="text-center">
colSpan={7}
className="text-center text-muted-foreground py-10 text-base"
>
No candidates found No candidates found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
currentItems.map((item) => ( filteredCandidates.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50"> <TableRow key={item.id}>
<TableCell className="font-mono text-xs"> <TableCell>{item.id}</TableCell>
{item.id} <TableCell>{item.fullName}</TableCell>
<TableCell>{item.mobile}</TableCell>
<TableCell>{item.email}</TableCell>
<TableCell>{item.career?.post}</TableCell>
<TableCell>{item.career?.designation}</TableCell>
<TableCell>{item.subject}</TableCell>
<TableCell className="max-w-[250px] whitespace-normal">
{item.coverLetter}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="font-semibold text-base truncate">
{item.fullName}
</div>
<div className="text-xs text-muted-foreground truncate">
{item.email}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium">
{item.career?.post || "-"}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{item.career?.designation}
</div>
</TableCell>
<TableCell className="text-sm">{item.mobile}</TableCell>
<TableCell className="text-sm">
{new Date(item.createdAt).toLocaleDateString()} {new Date(item.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic"> <Button
{item.coverLetter || "No cover letter provided."} size="sm"
</div> variant="destructive"
</TableCell> onClick={() => handleDelete(item.id)}>
<TableCell className="text-right"> <Trash className="h-4 w-4" />
<div className="flex justify-end gap-2"> </Button>
<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> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -259,126 +180,8 @@ export default function CandidatePage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{!loading && filteredCandidates.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredCandidates.length)}
</span>{" "}
of{" "}
<span className="font-semibold">
{filteredCandidates.length}
</span>
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
<User className="h-6 w-6" /> Candidate Details
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Personal Information
</p>
<p className="text-lg font-bold text-primary">
{viewData.fullName}
</p>
<p className="text-sm font-medium">{viewData.email}</p>
<p className="text-sm">{viewData.mobile}</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Applied For
</p>
<p className="text-base font-semibold">
{viewData.career?.post || "General Application"}
</p>
<p className="text-sm text-muted-foreground">
{viewData.career?.designation}
</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Application Date
</p>
<p className="text-sm">
{new Date(viewData.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Subject
</p>
<p className="text-sm font-semibold">
{viewData.subject || "N/A"}
</p>
</div>
<div className="p-4 bg-muted/30 rounded-lg">
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
Cover Letter / Message
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap italic">
{viewData.coverLetter || "No cover letter provided."}
</p>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setViewOpen(false)}
className="w-full md:w-auto"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+10 -16
View File
@@ -1,4 +1,4 @@
import {useState, useEffect, useCallback} from "react"; import { useState, useEffect, useCallback } from "react";
import { import {
getEmailConfigsApi, getEmailConfigsApi,
@@ -16,9 +16,9 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {Button} from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {Input} from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Dialog, Dialog,
@@ -28,7 +28,7 @@ import {
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {Loader2, Plus, Pencil, Trash, RefreshCw} from "lucide-react"; import { Loader2, Plus, Pencil, Trash, RefreshCw } from "lucide-react";
export default function EmailPage() { export default function EmailPage() {
const [emails, setEmails] = useState<any[]>([]); const [emails, setEmails] = useState<any[]>([]);
@@ -69,7 +69,7 @@ export default function EmailPage() {
); );
function handleChange(e: any) { function handleChange(e: any) {
setForm({...form, [e.target.name]: e.target.value}); setForm({ ...form, [e.target.name]: e.target.value });
} }
function openAdd() { function openAdd() {
@@ -181,16 +181,14 @@ export default function EmailPage() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => openEdit(item)} onClick={() => openEdit(item)}>
>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
onClick={() => handleDelete(item.id)} onClick={() => handleDelete(item.id)}>
>
<Trash className="h-4 w-4" /> <Trash className="h-4 w-4" />
</Button> </Button>
</TableCell> </TableCell>
@@ -227,13 +225,10 @@ export default function EmailPage() {
name="type" name="type"
value={form.type} value={form.type}
onChange={handleChange} onChange={handleChange}
className="border rounded px-2 py-2 w-full" className="border rounded px-2 py-2 w-full">
>
<option value="APPOINTMENT">APPOINTMENT</option> <option value="APPOINTMENT">APPOINTMENT</option>
<option value="CANDIDATE">CANDIDATE</option> <option value="CANDIDATE">CANDIDATE</option>
<option value="ACADEMICS">ACADEMICS</option> <option value="ACADEMICS">ACADEMICS</option>
<option value="INQUIRY">INQUIRY</option>
<option value="HCINQUIRY">HC-APPOINTMENT</option>
</select> </select>
<select <select
@@ -245,8 +240,7 @@ export default function EmailPage() {
isActive: e.target.value === "true", isActive: e.target.value === "true",
}) })
} }
className="border rounded px-2 py-2 w-full" className="border rounded px-2 py-2 w-full">
>
<option value="true">Active</option> <option value="true">Active</option>
<option value="false">Inactive</option> <option value="false">Inactive</option>
</select> </select>

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