Compare commits

...

42 Commits

Author SHA1 Message Date
rishalkv 667e15513c fix:added validations for api 2026-05-21 11:20:09 +05:30
rishalkv 2a786ef118 fix:unwanted query exec on update 2026-05-20 10:43:05 +05:30
rishalkv da6587c83d fix:editing doctor dept 2026-05-20 10:28:46 +05:30
rishalkv 5fea2a306d feat:add seo and more about doctores 2026-05-20 10:15:53 +05:30
kailasdevdas 08b9c2647e Merge pull request 'chore: add toast show validation errors' (#32) from fix/health-checkup-validation into dev
Reviewed-on: #32
2026-05-18 11:21:57 +00:00
Kailasdevdas 98194283df chore: add toast show validation errors 2026-05-18 16:41:38 +05:30
kailasdevdas 1320ce6fe6 Merge pull request 'chore: show required image dimension' (#31) from feat/healthcheckup-crud into dev
Reviewed-on: #31
2026-05-18 07:47:06 +00:00
Kailasdevdas 3dbbb2e77e chore: show required image dimension 2026-05-18 13:15:00 +05:30
kailasdevdas 5b1d626661 Merge pull request 'feat: health checkup CRUD apis' (#30) from feat/healthcheckup-crud into dev
Reviewed-on: #30
2026-05-18 06:26:27 +00:00
Kailasdevdas 098fe12fd7 feat: add image upload for health package 2026-05-18 11:55:55 +05:30
Kailasdevdas 852a25269a feat: add toast 2026-05-18 10:58:24 +05:30
Kailasdevdas d92e0538bd fix: make category slug optional 2026-05-18 10:58:14 +05:30
Kailasdevdas 8d60afdc49 feat: health checkup page 2026-05-15 17:58:25 +05:30
Kailasdevdas 9bc0bf406a feat: health checkup CRUD apis 2026-05-15 17:46:52 +05:30
kailasdevdas 3140d72e28 Merge pull request 'fix:blog editor image upload' (#28) from fix/blog-text-editor-image-uploader into dev
Reviewed-on: #28
2026-05-13 11:49:49 +00:00
rishalkv 6117805467 fix:blog editor image upload 2026-05-13 17:16:25 +05:30
kailasdevdas b002c053ae Merge pull request 'fix: doctor toggle logic' (#27) from feat/appointment-date-filter into dev
Reviewed-on: #27
2026-05-13 09:12:35 +00:00
kailasdevdas e6044518d2 Merge pull request 'feat: update date format in mail' (#26) from feat/email-date-format into dev
Reviewed-on: #26
2026-05-13 09:03:56 +00:00
Kailasdevdas 6889137164 feat: add appointment date range filter 2026-05-13 14:20:51 +05:30
Kailasdevdas 988fbd28f1 fix: doctor toggle logic 2026-05-13 14:19:42 +05:30
Kailasdevdas fa2b02ad23 feat: update date format in mail 2026-05-13 11:57:33 +05:30
kailasdevdas 199797fdf4 Merge pull request 'feat:sorting according to the prio of dept' (#24) from feat/department-internal-sort into dev
Reviewed-on: #24
2026-05-11 11:37:53 +00:00
rishalkv 5efe049fbd feat:sorting according to the prio of dept 2026-05-11 16:27:20 +05:30
kailasdevdas a008f09923 Merge pull request 'feat: implement sorting and visibility changes' (#23) from feat/inactive-field into dev
Reviewed-on: #23
2026-05-11 05:35:39 +00:00
Kailasdevdas 903a541e35 chore: correct lablel 2026-05-11 10:57:52 +05:30
Kailasdevdas 4808e99ae5 feat: remove delete action and add status toggle to tables 2026-05-11 10:52:30 +05:30
Kailasdevdas 2c6da93dfb feat: add toast 2026-05-11 10:51:34 +05:30
Kailasdevdas 1717507555 feat: implement sorting and visibility changes 2026-05-11 00:04:22 +05:30
kailasdevdas 9aaf1879a8 Merge pull request 'fix: edit form fields and update form submission logic' (#21) from fix/news-media-UI into dev
Reviewed-on: #21
2026-05-05 06:44:06 +00:00
Kailasdevdas d847789564 fix: edit form fields and update form submission logic 2026-05-05 10:48:18 +05:30
Ashir Ali eab661bb4a refactor: email updates 2026-05-01 01:31:41 +05:30
Ashir Ali 60ace745f3 feat: disableConcurrentBuilds 2026-05-01 01:27:33 +05:30
Ashir Ali 5fec4dd37d feat: email template added, jenkins file bug fix 2026-05-01 00:50:31 +05:30
Ashir Ali bb42581c17 feat: added email recepient 2026-05-01 00:47:17 +05:30
Ashir Ali 621bc8efcd feat: Prod docker compose and Jenkinsfile added 2026-05-01 00:06:27 +05:30
Ashir Ali e32a005340 fix: update docker compose to reuse old data 2026-04-30 21:28:20 +05:30
Ashir Ali 1e98bb4115 fix: agent 6 for jenkins 2026-04-30 21:04:15 +05:30
Ashir Ali c4ef2af9df fix: PAT update 2026-04-30 20:51:24 +05:30
Ashir Ali dda6b75dce feat: Jenkinsfile added 2026-04-30 20:33:42 +05:30
kailasdevdas 2c37cd042f Merge pull request 'feat: improve email template' (#18) from feat/ui-enhancements into dev
Reviewed-on: #18
2026-04-29 04:28:58 +00:00
Kailasdevdas 082e227111 feat: improve email template 2026-04-27 17:29:33 +05:30
kailasdevdas a0dbf32cf3 Merge pull request 'fix/news-media-pagination' (#17) from fix/news-media-pagination into dev
Reviewed-on: #17
2026-04-27 05:39:47 +00:00
49 changed files with 4832 additions and 371 deletions
Vendored
+221
View File
@@ -0,0 +1,221 @@
// 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
@@ -0,0 +1,224 @@
// 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>
"""
}
@@ -0,0 +1,14 @@
-- 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;
@@ -0,0 +1,11 @@
-- 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;
@@ -0,0 +1,63 @@
-- 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;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "HealthPackageInquiry" ADD COLUMN "age" INTEGER,
ADD COLUMN "gender" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthPackage" ALTER COLUMN "inclusions" SET DEFAULT '{}';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthCheckCategory" ALTER COLUMN "slug" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthPackage" ADD COLUMN "image" TEXT;
@@ -0,0 +1,50 @@
/*
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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER;
+98 -3
View File
@@ -23,9 +23,15 @@ model Doctor {
name String name String
image 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[]
@@ -46,6 +52,9 @@ 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[]
@@ -61,7 +70,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())
@@ -111,7 +120,8 @@ 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())
@@ -214,3 +224,88 @@ model NewsImage {
createdAt DateTime @default(now()) 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
View File
@@ -15,6 +15,7 @@ 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 importRoutes from "./routes/importRoutes.js";
import healthCheckRoutes from "./routes/healthCheck.route.js";
dotenv.config(); dotenv.config();
@@ -55,6 +56,7 @@ 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/import", importRoutes);
app.use("/api/health-check", healthCheckRoutes);
const PORT = process.env.PORT || 5008; const PORT = process.env.PORT || 5008;
app.listen(PORT, () => { app.listen(PORT, () => {
@@ -35,17 +35,72 @@ export const createAcademicsResearch = async (req, res) => {
to: emailList, to: emailList,
subject: "New Academics & Research Inquiry", subject: "New Academics & Research Inquiry",
html: ` html: `
<h2>New Academics & Research Inquiry</h2> <div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<p><b>Name:</b> ${fullName}</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> ${number}</p>
<p><b>Email:</b> ${emailId || "-"}</p>
<p><b>Course:</b> ${courseName || "-"}</p> <!-- Header -->
<p><b>Subject:</b> ${subject || "-"}</p> <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>
<p><b>Message:</b></p> <!-- Body -->
<p>${message || "-"}</p> <div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Contact Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Name:</b></td>
<td style="padding: 8px 0;">${fullName}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${number}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${emailId || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Course:</b></td>
<td style="padding: 8px 0;">${courseName || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Subject:</b></td>
<td style="padding: 8px 0;">${subject || "-"}</td>
</tr>
</table>
<!-- 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 (Academics & Research Inquiry).
</div>
</div>
</div>
`, `,
}); });
} }
+141 -20
View File
@@ -38,14 +38,87 @@ export const createAppointment = async (req, res) => {
to: emailList, to: emailList,
subject: "New Appointment Booked", subject: "New Appointment Booked",
html: ` html: `
<h2>New Appointment Booked</h2> <div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<p><b>Name:</b> ${name}</p>
<p><b>Phone:</b> ${mobileNumber}</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>Email:</b> ${email || "-"}</p>
<p><b>Doctor:</b> ${appointment.doctor?.name}</p> <!-- Header -->
<p><b>Department:</b> ${appointment.department?.name}</p> <div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<p><b>Date:</b> ${new Date(date).toLocaleDateString()}</p> <h2 style="margin: 0;">GG Hospital</h2>
<p><b>Message:</b> ${message || "-"}</p> <p style="margin: 5px 0 0; font-size: 14px;">
New Appointment Booked
</p>
</div>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Patient Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Name:</b></td>
<td style="padding: 8px 0;">${name}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${mobileNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${email || "-"}</td>
</tr>
</table>
<h3 style="margin-top: 20px;">Appointment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Doctor:</b></td>
<td style="padding: 8px 0;">${appointment.doctor?.name || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Department:</b></td>
<td style="padding: 8px 0;">${appointment.department?.name || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Date:</b></td>
<td style="padding: 8px 0;">
${new Date(date).toLocaleDateString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</td>
</tr>
</table>
<!-- Message Box -->
<div style="margin-top: 20px;">
<h3>Message</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${message ? message.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This appointment was booked via the GG Hospital website.
</div>
</div>
</div>
`, `,
}); });
} }
@@ -74,18 +147,51 @@ export const getAppointments = async (req, res) => {
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const { date, search } = req.query;
const { date, startDate, endDate, search } = req.query;
const where = {}; const where = {};
if (date) { const hasSingleDate = date && date.trim() !== "";
const hasRange =
(startDate && startDate.trim() !== "") ||
(endDate && endDate.trim() !== "");
if (hasSingleDate) {
const start = new Date(date); const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date); const end = new Date(date);
end.setDate(end.getDate() + 1); end.setHours(23, 59, 59, 999);
where.date = { gte: start, lt: end };
where.date = {
gte: start,
lte: end,
};
} }
if (search) { if (!hasSingleDate && hasRange) {
const dateFilter = {};
if (startDate && startDate.trim() !== "") {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
dateFilter.gte = start;
}
if (endDate && endDate.trim() !== "") {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
dateFilter.lte = end;
}
where.date = dateFilter;
}
if (search && search.trim() !== "") {
where.OR = [ where.OR = [
{ name: { contains: search, mode: "insensitive" } }, { name: { contains: search, mode: "insensitive" } },
{ mobileNumber: { contains: search } }, { mobileNumber: { contains: search } },
@@ -96,24 +202,39 @@ export const getAppointments = async (req, res) => {
const [appointments, total] = await Promise.all([ const [appointments, total] = await Promise.all([
prisma.appointment.findMany({ prisma.appointment.findMany({
where, where,
include: { doctor: true, department: true }, include: {
orderBy: { createdAt: "desc" }, doctor: true,
department: true,
},
orderBy: {
createdAt: "desc",
},
skip, skip,
take: limit, take: limit,
}), }),
prisma.appointment.count({ where }),
prisma.appointment.count({
where,
}),
]); ]);
res.status(200).json({ res.status(200).json({
success: true, success: true,
data: appointments, data: appointments,
pagination: { total, page, limit, totalPages: Math.ceil(total / limit) }, pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res
.status(500) res.status(500).json({
.json({ success: false, message: "Failed to fetch appointments" }); success: false,
message: "Failed to fetch appointments",
});
} }
}; };
@@ -39,18 +39,81 @@ export const createCandidate = async (req, res) => {
to: emailList, to: emailList,
subject: "New Job Application Received", subject: "New Job Application Received",
html: ` html: `
<h2>New Candidate Application</h2> <div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<p><b>Name:</b> ${fullName}</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> ${mobile}</p>
<p><b>Email:</b> ${email}</p>
<p><b>Applied For:</b> ${candidate.career?.post || "-"}</p> <!-- Header -->
<p><b>Designation:</b> ${candidate.career?.designation || "-"}</p> <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>
<p><b>Subject:</b> ${subject || "-"}</p> <!-- Body -->
<p><b>Cover Letter:</b></p> <div style="padding: 20px; color: #333;">
<p>${coverLetter || "-"}</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>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0;"><b>Applied For:</b></td>
<td style="padding: 8px 0;">${candidate.career?.post || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Designation:</b></td>
<td style="padding: 8px 0;">${candidate.career?.designation || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Subject:</b></td>
<td style="padding: 8px 0;">${subject || "-"}</td>
</tr>
</table>
<!-- Cover Letter -->
<div style="margin-top: 20px;">
<h3>Cover Letter</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${coverLetter ? coverLetter.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This application was submitted via the GG Hospital careers page.
</div>
</div>
</div>
`, `,
}); });
} }
+16 -2
View File
@@ -4,8 +4,11 @@ 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({
orderBy: {createdAt: "desc"}, where: admin === "true" ? {} : { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
}); });
const response = careers.map((c) => ({ const response = careers.map((c) => ({
@@ -17,6 +20,8 @@ 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({
@@ -44,6 +49,8 @@ export const createCareer = async (req, res) => {
email, email,
number, number,
status, status,
isActive,
sortOrder,
} = req.body; } = req.body;
if (!post || !designation) { if (!post || !designation) {
@@ -62,6 +69,8 @@ export const createCareer = async (req, res) => {
email, email,
number, number,
status, status,
isActive: isActive !== undefined ? isActive : true,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
}, },
}); });
@@ -84,10 +93,15 @@ 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: req.body, data: updateData,
}); });
return res.status(200).json({ return res.status(200).json({
@@ -2,8 +2,11 @@ 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({
orderBy: {name: "asc"}, where: admin === "true" ? {} : {isActive: true},
orderBy: [{sortOrder: "asc"}, {name: "asc"}],
}); });
const response = departments.map((dep) => ({ const response = departments.map((dep) => ({
@@ -15,6 +18,8 @@ export const getAllDepartments = async (req, res) => {
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({
@@ -44,13 +49,14 @@ export const getDepartmentByName = async (req, res) => {
const department = await prisma.department.findFirst({ const department = await prisma.department.findFirst({
where: { where: {
name: name, name: name,
isActive: true,
}, },
}); });
if (!department) { if (!department) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: "Department not found", message: "Department not found or inactive",
}); });
} }
@@ -63,6 +69,8 @@ export const getDepartmentByName = async (req, res) => {
para3: department.para3 ?? "", para3: department.para3 ?? "",
facilities: department.facilities ?? "", facilities: department.facilities ?? "",
services: department.services ?? "", services: department.services ?? "",
isActive: department.isActive,
sortOrder: department.sortOrder,
}; };
return res.status(200).json({ return res.status(200).json({
@@ -89,6 +97,8 @@ export async function createDepartment(req, res) {
para3, para3,
facilities, facilities,
services, services,
isActive,
sortOrder,
} = req.body; } = req.body;
if (!departmentId || !name) { if (!departmentId || !name) {
@@ -107,6 +117,8 @@ export async function createDepartment(req, res) {
para3, para3,
facilities, facilities,
services, services,
isActive: isActive !== undefined ? isActive : true,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
}, },
}); });
@@ -118,7 +130,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"});
} }
} }
@@ -126,20 +138,15 @@ 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};
const {name, image, para1, para2, para3, facilities, services} = req.body; if (updateData.sortOrder !== undefined) {
updateData.sortOrder = Number(updateData.sortOrder);
}
const department = await prisma.department.update({ const department = await prisma.department.update({
where: {departmentId}, where: {departmentId},
data: { data: updateData,
name,
image,
para1,
para2,
para3,
facilities,
services,
},
}); });
return res.status(200).json({ return res.status(200).json({
+293 -21
View File
@@ -4,16 +4,25 @@ 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: {name: "asc"}, },
},
orderBy: [{globalSortOrder: "asc"}, {name: "asc"}],
}); });
const formatted = doctors.map((doc, index) => ({ const formatted = doctors.map((doc, index) => ({
@@ -24,10 +33,28 @@ export const getAllDoctors = async (req, res) => {
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 ?? "",
ogDescription: doc.seo?.ogDescription ?? "",
ogImage: doc.seo?.ogImage ?? "",
},
departments: doc.departments.map((d) => { departments: doc.departments.map((d) => {
const t = d.timing || {}; const t = d.timing || {};
const timingArray = [ const timingArray = [
t.monday && `Monday ${t.monday}`, t.monday && `Monday ${t.monday}`,
t.tuesday && `Tuesday ${t.tuesday}`, t.tuesday && `Tuesday ${t.tuesday}`,
@@ -43,6 +70,7 @@ export const getAllDoctors = async (req, res) => {
departmentId: d.department.departmentId, departmentId: d.department.departmentId,
departmentName: d.department.name, departmentName: d.department.name,
timing: timingArray.join(" & "), timing: timingArray.join(" & "),
deptSortOrder: d.sortOrder,
}; };
}), }),
})); }));
@@ -69,6 +97,8 @@ 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,
@@ -92,6 +122,24 @@ export const getDoctorByDoctorId = async (req, res) => {
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,
@@ -135,17 +183,23 @@ export const getDoctorsByDepartmentId = async (req, res) => {
}); });
} }
const doctors = await prisma.doctorDepartment.findMany({ const doctorsInDept = await prisma.doctorDepartment.findMany({
where: {departmentId: department.id}, where: {
departmentId: department.id,
doctor: {isActive: true},
},
include: { include: {
doctor: true, doctor: true,
}, },
orderBy: {sortOrder: "asc"},
}); });
const result = doctors.map((d) => ({ const result = doctorsInDept.map((d) => ({
GG_ID: d.doctor.doctorId, GG_ID: d.doctor.doctorId,
Name: d.doctor.name, Name: d.doctor.name,
image: d.doctor.image ?? "", image: d.doctor.image ?? "",
designation: d.doctor.designation,
hierarchyOrder: d.sortOrder,
})); }));
res.status(200).json({ res.status(200).json({
@@ -171,8 +225,51 @@ export const createDoctor = async (req, res) => {
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: {
@@ -182,6 +279,12 @@ export const createDoctor = async (req, res) => {
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,
}, },
}); });
@@ -196,6 +299,7 @@ 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,
}, },
}); });
@@ -208,6 +312,17 @@ 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,
@@ -232,24 +347,183 @@ export const updateDoctor = async (req, res) => {
image, image,
workingStatus, workingStatus,
qualification, qualification,
isActive,
globalSortOrder,
departments, departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
} = req.body; } = req.body;
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");
const doctor = await prisma.doctor.findUnique({ if (!departments || departments.length === 0) {
where: {doctorId}, messages.push("At least one department is required");
}
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(", "),
}); });
}
if (!doctor) { const doctor = await prisma.doctor.findUnique({where: {doctorId}});
if (!doctor)
return res return res
.status(404) .status(404)
.json({success: false, message: "Doctor not found"}); .json({success: false, message: "Doctor not found"});
}
await prisma.doctor.update({ await prisma.doctor.update({
where: {id: doctor.id}, where: {id: doctor.id},
data: {name, designation, image, workingStatus, qualification}, data: {
name,
designation,
image,
workingStatus,
qualification,
isActive,
experience: experience ? Number(experience) : null,
professionalSummary,
globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
},
});
const existingDepartments = await prisma.doctorDepartment.findMany({
where: {
doctorId: doctor.id,
},
include: {
timing: true,
},
}); });
for (const dep of departments) {
const department = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!department) continue;
const existing = existingDepartments.find(
(d) => d.departmentId === department.id,
);
const newSortOrder =
dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0;
const isSameDepartment = existing && existing.sortOrder === newSortOrder;
const isSameTiming =
JSON.stringify(existing?.timing || {}) ===
JSON.stringify(dep.timing || {});
if (isSameDepartment && isSameTiming) {
continue;
}
let doctorDepartment = existing;
if (!existing) {
doctorDepartment = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: department.id,
sortOrder: newSortOrder,
},
});
} else if (existing.sortOrder !== newSortOrder) {
doctorDepartment = await prisma.doctorDepartment.update({
where: {id: existing.id},
data: {
sortOrder: newSortOrder,
},
});
}
if (dep.timing) {
if (existing?.timing) {
await prisma.doctorTiming.update({
where: {id: existing.timing.id},
data: dep.timing,
});
} else {
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...dep.timing,
},
});
}
}
}
if (doctor.seoId) {
await prisma.seo.update({
where: {
id: doctor.seoId,
},
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
} else {
const seo = await prisma.seo.create({
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
await prisma.doctor.update({
where: {
id: doctor.id,
},
data: {
seoId: seo.id,
},
});
}
const hasTimingData = departments?.some(
(dep) => dep.timing && Object.keys(dep.timing).length > 0,
);
// Update Specializations
if (Array.isArray(specializations)) {
await prisma.doctorSpecialization.deleteMany({
where: {
doctorId: doctor.id,
},
});
if (specializations.length) {
await prisma.doctorSpecialization.createMany({
data: specializations
.filter((item) => item.name?.trim())
.map((item) => ({
name: item.name.trim(),
description: item.description?.trim() || null,
doctorId: doctor.id,
})),
});
}
}
if (departments && Array.isArray(departments) && hasTimingData) {
const oldRelations = await prisma.doctorDepartment.findMany({ const oldRelations = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id}, where: {doctorId: doctor.id},
}); });
@@ -265,31 +539,28 @@ export const updateDoctor = async (req, res) => {
}); });
for (const dep of departments) { for (const dep of departments) {
const department = await prisma.department.findUnique({ const targetDept = await prisma.department.findUnique({
where: {departmentId: dep.departmentId}, where: {departmentId: dep.departmentId},
}); });
if (!targetDept) continue;
if (!department) continue; const newDD = await prisma.doctorDepartment.create({
const doctorDepartment = await prisma.doctorDepartment.create({
data: { data: {
doctorId: doctor.id, doctorId: doctor.id,
departmentId: department.id, departmentId: targetDept.id,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
}, },
}); });
if (dep.timing) { if (dep.timing) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} = const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing; dep.timing;
await prisma.doctorTiming.create({ await prisma.doctorTiming.create({
data: { data: {doctorDepartmentId: newDD.id, ...cleanTiming},
doctorDepartmentId: doctorDepartment.id,
...cleanTiming,
},
}); });
} }
} }
}
res res
.status(200) .status(200)
@@ -421,6 +692,7 @@ export const getDoctorTimingById = async (req, res) => {
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,
deptSortOrder: d.sortOrder,
timing: d.timing || {}, timing: d.timing || {},
})), })),
}; };
@@ -0,0 +1,440 @@
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" });
}
};
+58 -6
View File
@@ -32,14 +32,66 @@ export const createInquiry = async (req, res) => {
to: emailList, to: emailList,
subject: "New Inquiry Received", subject: "New Inquiry Received",
html: ` html: `
<h2>New Inquiry</h2> <div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<p><b>Name:</b> ${fullName}</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> ${number}</p>
<p><b>Email:</b> ${emailId}</p>
<p><b>Subject:</b> ${subject}</p> <!-- Header -->
<p><b>Message:</b> ${message}</p> <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>
`, `,
}); });
} }
@@ -4,8 +4,8 @@ import prisma from "../prisma/client.js";
export const getAllNews = async (req, res) => { export const getAllNews = async (req, res) => {
try { try {
const page = parseInt(req.query.page) || 1; const page = req.query.page ? parseInt(req.query.page) : null;
const limit = parseInt(req.query.limit) || 10; const limit = req.query.limit ? parseInt(req.query.limit) : null;
const search = req.query.search?.trim() || ""; const search = req.query.search?.trim() || "";
const includeImages = { const includeImages = {
@@ -25,7 +25,8 @@ export const getAllNews = async (req, res) => {
...searchFilter, ...searchFilter,
}; };
const skip = (page - 1) * limit; const skip = page && limit ? (page - 1) * limit : undefined;
const take = limit ? limit : undefined;
const [news, total] = await Promise.all([ const [news, total] = await Promise.all([
prisma.newsMedia.findMany({ prisma.newsMedia.findMany({
@@ -33,7 +34,7 @@ export const getAllNews = async (req, res) => {
include: includeImages, include: includeImages,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
skip, skip,
take: limit, take,
}), }),
prisma.newsMedia.count({ prisma.newsMedia.count({
where: whereCondition, where: whereCondition,
@@ -59,9 +60,9 @@ export const getAllNews = async (req, res) => {
data: response, data: response,
meta: { meta: {
total, total,
page, page: page || 1,
limit, limit: limit || total,
totalPages: Math.ceil(total / limit), totalPages: limit ? Math.ceil(total / limit) : 1,
}, },
}); });
} catch (error) { } catch (error) {
+39
View File
@@ -0,0 +1,39 @@
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;
+2 -1
View File
@@ -26,7 +26,6 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: postgres_db
environment: environment:
- POSTGRES_USER=${POSTGRES_USER} - POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
@@ -42,3 +41,5 @@ services:
volumes: volumes:
postgres_data: postgres_data:
external: true
name: gg-backend_postgres_data
+41
View File
@@ -0,0 +1,41 @@
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:
+27 -1
View File
@@ -28,6 +28,7 @@
"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",
@@ -5187,7 +5188,6 @@
"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": {
@@ -6352,6 +6352,15 @@
"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",
@@ -8136,6 +8145,23 @@
"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",
+1
View File
@@ -30,6 +30,7 @@
"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",
+5
View File
@@ -1,4 +1,5 @@
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";
@@ -22,10 +23,13 @@ import AcademicsPage from "./pages/Academics";
import NewsPage from "./pages/newsMedia"; import NewsPage from "./pages/newsMedia";
import BlogDetail from "./pages/BlogDetails"; import BlogDetail from "./pages/BlogDetails";
import ImportData from "./pages/ImportData"; 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 />}>
@@ -48,6 +52,7 @@ export default function App() {
<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="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
</Route> </Route>
</Route> </Route>
+4
View File
@@ -4,12 +4,16 @@ export const getAppointmentsApi = async (
page = 1, page = 1,
limit = 10, limit = 10,
date = "", date = "",
startDate = "",
endDate = "",
search = "", search = "",
) => { ) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(page), page: String(page),
limit: String(limit), limit: String(limit),
...(date && { date }), ...(date && { date }),
...(startDate && { startDate }),
...(endDate && { endDate }),
...(search && { search }), ...(search && { search }),
}); });
const res = await apiClient.get(`/appointments/getall?${params}`); const res = await apiClient.get(`/appointments/getall?${params}`);
+39 -1
View File
@@ -1,11 +1,49 @@
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"); const res = await apiClient.get("/careers/getAll?admin=true");
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}`);
toast.success("Career deleted successfully");
return res.data; return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete career");
throw error;
}
}; };
+37 -1
View File
@@ -1,4 +1,5 @@
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;
@@ -9,10 +10,12 @@ export interface Department {
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"); const res = await apiClient.get("/departments/getAll?admin=true");
return res.data; return res.data;
}; };
@@ -25,8 +28,19 @@ 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);
toast.success("Department created successfully");
return res.data; 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 (
@@ -40,11 +54,33 @@ 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);
toast.success("Department updated successfully");
return res.data; 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}`);
toast.success("Department deleted successfully");
return res.data; return res.data;
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Failed to delete department",
);
throw error;
}
}; };
+32 -2
View File
@@ -1,4 +1,5 @@
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;
@@ -7,8 +8,10 @@ export interface Doctor {
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;
@@ -24,7 +27,7 @@ export interface Doctor {
} }
export const getDoctorsApi = async () => { export const getDoctorsApi = async () => {
const res = await apiClient.get("/doctors/getAll"); const res = await apiClient.get("/doctors/getAll?admin=true");
return res.data; return res.data;
}; };
@@ -34,21 +37,48 @@ 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);
toast.success("Doctor created successfully");
return res.data; 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>,
) => { ) => {
try {
const res = await apiClient.patch(`/doctors/${doctorId}`, data); const res = await apiClient.patch(`/doctors/${doctorId}`, data);
toast.success("Doctor updated successfully");
return res.data; 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}`);
toast.success("Doctor deleted successfully");
return res.data; 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
@@ -0,0 +1,160 @@
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;
};
@@ -6,7 +6,12 @@ import axios from "axios";
interface BytescaleUploaderProps { interface BytescaleUploaderProps {
value: string; value: string;
onChange: (url: string) => void; onChange: (url: string) => void;
folderPath: "/doctors" | "/departments" | "/news" | "/blog"; folderPath:
| "/doctors"
| "/departments"
| "/news"
| "/blog"
| "/health-packages";
} }
export function BytescaleUploader({ export function BytescaleUploader({
@@ -0,0 +1,273 @@
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>
);
}
+6 -1
View File
@@ -15,6 +15,10 @@ 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",
@@ -65,7 +69,8 @@ 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>
+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: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: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",
{ {
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-3 has-data-[icon=inline-start]:pl-3", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
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",
+5 -2
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-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", "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",
className className
)} )}
{...props} {...props}
@@ -127,7 +127,10 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)} className={cn(
"text-base leading-none font-medium",
className
)}
{...props} {...props}
/> />
) )
+190
View File
@@ -0,0 +1,190 @@
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
@@ -0,0 +1,88 @@
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
@@ -0,0 +1,55 @@
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 }
+54 -3
View File
@@ -40,7 +40,8 @@ export default function AppointmentPage() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [filterDoctor, setFilterDoctor] = useState(""); const [filterDoctor, setFilterDoctor] = useState("");
const [filterDate, setFilterDate] = useState(""); const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [viewOpen, setViewOpen] = useState(false); const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null); const [viewData, setViewData] = useState<any>(null);
@@ -56,6 +57,8 @@ export default function AppointmentPage() {
currentPage, currentPage,
itemsPerPage, itemsPerPage,
filterDate, filterDate,
startDate,
endDate,
searchText, searchText,
); );
setAppointments(res?.data || []); setAppointments(res?.data || []);
@@ -66,7 +69,7 @@ export default function AppointmentPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, itemsPerPage, filterDate, searchText]); }, [currentPage, itemsPerPage, filterDate, startDate, endDate, searchText]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
@@ -116,7 +119,11 @@ export default function AppointmentPage() {
<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-4">
<h1 className="text-3xl font-bold">Appointments</h1> <h1 className="text-3xl font-bold">Appointments</h1>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-4 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Search
</label>
<Input <Input
placeholder="Search name / phone..." placeholder="Search name / phone..."
value={searchText} value={searchText}
@@ -126,7 +133,12 @@ export default function AppointmentPage() {
}} }}
className="w-[220px] text-base" className="w-[220px] text-base"
/> />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Date
</label>
<Input <Input
type="date" type="date"
value={filterDate} value={filterDate}
@@ -135,8 +147,46 @@ export default function AppointmentPage() {
setCurrentPage(1); setCurrentPage(1);
}} }}
className="w-[160px] text-base" className="w-[160px] text-base"
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) => {
setStartDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
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) => {
setEndDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Rows
</label>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
@@ -149,6 +199,7 @@ export default function AppointmentPage() {
<option value={10}>10 / page</option> <option value={10}>10 / page</option>
<option value={20}>20 / page</option> <option value={20}>20 / page</option>
</select> </select>
</div>
<Button <Button
variant="outline" variant="outline"
+33 -2
View File
@@ -10,6 +10,7 @@ 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,
@@ -23,6 +24,7 @@ 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();
@@ -79,12 +81,41 @@ export default function BlogEditorPage() {
config: { config: {
uploader: { uploader: {
uploadByFile: async (file: File) => { uploadByFile: async (file: File) => {
const res = await uploadImageApi(file); if (file.size > 5 * 1024 * 1024) {
alert("File is too large (Max 5MB)");
return {success: 0, file: {url: ""}};
}
const formData = new FormData();
formData.append("file", file);
formData.append("folderPath", "/blog");
try {
const response = await axios.post(
`${baseURL}/upload`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return { return {
success: 1, success: 1,
file: {url: res.file.url}, 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: ""},
};
}
}, },
}, },
}, },
+66 -43
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { getCareersApi, deleteCareerApi } from "@/api/career"; import { getCareersApi, updateCareerApi, createCareerApi } from "@/api/career";
import apiClient from "@/api/client";
import { import {
Table, Table,
@@ -24,12 +23,13 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { import {
Loader2, Loader2,
Plus, Plus,
Pencil, Pencil,
Trash,
RefreshCw, RefreshCw,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -55,6 +55,8 @@ export default function CareerPage() {
email: "", email: "",
number: "", number: "",
status: "new", status: "new",
isActive: true,
sortOrder: 0,
}); });
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
@@ -92,6 +94,18 @@ export default function CareerPage() {
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({
@@ -102,6 +116,8 @@ export default function CareerPage() {
email: "", email: "",
number: "", number: "",
status: "new", status: "new",
isActive: true,
sortOrder: 0,
}); });
setOpenModal(true); setOpenModal(true);
} }
@@ -116,6 +132,8 @@ 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);
} }
@@ -123,10 +141,11 @@ export default function CareerPage() {
async function handleSubmit() { async function handleSubmit() {
try { try {
if (editing) { if (editing) {
await apiClient.patch(`/careers/${editing.id}`, form); await updateCareerApi(editing.id, form);
} else { } else {
await apiClient.post("/careers", form); await createCareerApi(form);
} }
setOpenModal(false); setOpenModal(false);
fetchAll(); fetchAll();
} catch (err) { } catch (err) {
@@ -134,12 +153,6 @@ 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 flex-col md:flex-row md:justify-between md:items-center gap-4">
@@ -177,13 +190,13 @@ export default function CareerPage() {
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <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"> <Table className="w-full min-w-[800px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm"> <TableHead className="w-[80px] bg-background font-bold text-sm">
ID Priority
</TableHead> </TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm"> <TableHead className="w-[250px] bg-background font-bold text-sm">
Post & Designation Post & Designation
</TableHead> </TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm"> <TableHead className="w-[200px] bg-background font-bold text-sm">
@@ -192,13 +205,10 @@ export default function CareerPage() {
<TableHead className="w-[120px] bg-background font-bold text-sm"> <TableHead className="w-[120px] bg-background font-bold text-sm">
Experience Experience
</TableHead> </TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm"> <TableHead className="w-[80px] bg-background font-bold text-sm">
Contact Info Status (Active)
</TableHead> </TableHead>
<TableHead className="w-[100px] bg-background font-bold text-sm"> <TableHead className="w-[80px] bg-background font-bold text-right text-sm">
Status
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions Actions
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -207,14 +217,14 @@ export default function CareerPage() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-10"> <TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> <Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentItems.length === 0 ? ( ) : currentItems.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={7} colSpan={6}
className="text-center text-muted-foreground py-10 text-base" className="text-center text-muted-foreground py-10 text-base"
> >
No careers found No careers found
@@ -224,7 +234,7 @@ export default function CareerPage() {
currentItems.map((item) => ( currentItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50"> <TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs"> <TableCell className="font-mono text-xs">
{item.id} {item.sortOrder}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="font-semibold text-base truncate"> <div className="font-semibold text-base truncate">
@@ -243,20 +253,18 @@ export default function CareerPage() {
{item.experienceNeed} {item.experienceNeed}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm font-medium">{item.email}</div> <div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground"> <Switch
{item.number} checked={item.isActive}
</div> onCheckedChange={() => handleToggleStatus(item)}
</TableCell> />
<TableCell>
<Badge <Badge
variant={ variant={item.isActive ? "default" : "secondary"}
item.status === "active" ? "default" : "secondary"
}
className="capitalize" className="capitalize"
> >
{item.status} {item.isActive ? "Active" : "Hidden"}
</Badge> </Badge>
</div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@@ -269,15 +277,6 @@ export default function CareerPage() {
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -393,6 +392,30 @@ export default function CareerPage() {
onChange={handleChange} onChange={handleChange}
className="text-base" 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> </div>
+85 -58
View File
@@ -6,7 +6,6 @@ import {
getDepartmentsApi, getDepartmentsApi,
createDepartmentApi, createDepartmentApi,
updateDepartmentApi, updateDepartmentApi,
deleteDepartmentApi,
} from "@/api/department"; } from "@/api/department";
import { import {
@@ -31,13 +30,15 @@ import {
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, Loader2,
RefreshCw, RefreshCw,
Plus, Plus,
Pencil, Pencil,
Trash,
Eye, Eye,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -52,6 +53,8 @@ interface Department {
para3: string; para3: string;
facilities: string; facilities: string;
services: string; services: string;
isActive: boolean;
sortOrder: number;
} }
export default function DepartmentPage() { export default function DepartmentPage() {
@@ -79,6 +82,8 @@ export default function DepartmentPage() {
para3: "", para3: "",
facilities: "", facilities: "",
services: "", services: "",
isActive: true,
sortOrder: 0,
}); });
const fetchDepartments = useCallback(async () => { const fetchDepartments = useCallback(async () => {
@@ -122,13 +127,23 @@ export default function DepartmentPage() {
); );
function handleChange(e: any) { function handleChange(e: any) {
setForm({...form, [e.target.name]: e.target.value}); const value =
e.target.type === "number" ? Number(e.target.value) : e.target.value;
setForm({...form, [e.target.name]: value});
} }
function truncate(text: string, limit = 60) { const handleToggleStatus = async (dep: Department) => {
if (!text) return "-"; try {
return text.length > limit ? text.substring(0, limit) + "..." : text; const {departmentId, ...updateData} = dep;
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);
@@ -141,13 +156,19 @@ export default function DepartmentPage() {
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(dep); setForm({
...dep,
isActive: dep.isActive ?? true,
sortOrder: dep.sortOrder ?? 0,
});
setOpenModal(true); setOpenModal(true);
} }
@@ -160,7 +181,7 @@ export default function DepartmentPage() {
try { try {
if (editing) { if (editing) {
const {departmentId, ...updateData} = form; const {departmentId, ...updateData} = form;
await updateDepartmentApi(editing.departmentId, updateData); await updateDepartmentApi(editing.departmentId, form as any);
} else { } else {
await createDepartmentApi(form); await createDepartmentApi(form);
} }
@@ -172,17 +193,6 @@ 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-4">
@@ -226,25 +236,19 @@ export default function DepartmentPage() {
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[900px] table-fixed border-separate border-spacing-0"> <Table className="w-full min-w-[700px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[100px] bg-background text-sm font-bold"> <TableHead className="w-[100px] bg-background text-sm font-bold">
ID Priority
</TableHead> </TableHead>
<TableHead className="w-[200px] bg-background text-sm font-bold"> <TableHead className="w-[300px] bg-background text-sm font-bold">
Name Name
</TableHead> </TableHead>
<TableHead className="w-[250px] bg-background text-sm font-bold"> <TableHead className="w-[80px] bg-background text-sm font-bold">
Para 1 Status (Active)
</TableHead> </TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold"> <TableHead className="w-[80px] bg-background text-right text-sm font-bold">
Facilities
</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold">
Services
</TableHead>
<TableHead className="w-[140px] bg-background text-right text-sm font-bold">
Actions Actions
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -253,14 +257,14 @@ export default function DepartmentPage() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-10"> <TableCell colSpan={4} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> <Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentItems.length === 0 ? ( ) : currentItems.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={4}
className="text-center text-muted-foreground py-10 text-base" className="text-center text-muted-foreground py-10 text-base"
> >
No departments found No departments found
@@ -272,8 +276,8 @@ export default function DepartmentPage() {
key={dep.departmentId} key={dep.departmentId}
className="hover:bg-muted/50" className="hover:bg-muted/50"
> >
<TableCell className="font-mono text-xs"> <TableCell className="font-mono text-sm">
{dep.departmentId} {dep.sortOrder}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -283,23 +287,21 @@ export default function DepartmentPage() {
> >
{dep.name} {dep.name}
</div> </div>
</TableCell> <div className="text-xs text-muted-foreground">
{dep.departmentId}
<TableCell>
<div className="text-sm break-words whitespace-normal">
{truncate(dep.para1)}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm break-words whitespace-normal"> <div className="flex items-center gap-2">
{truncate(dep.facilities)} <Switch
</div> checked={dep.isActive}
</TableCell> onCheckedChange={() => handleToggleStatus(dep)}
/>
<TableCell> <Badge
<div className="text-sm break-words whitespace-normal"> variant={dep.isActive ? "default" : "secondary"}
{truncate(dep.services)} >
{dep.isActive ? "Active" : "Hidden"}
</Badge>
</div> </div>
</TableCell> </TableCell>
@@ -322,15 +324,6 @@ export default function DepartmentPage() {
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(dep.departmentId)}
>
<Trash className="h-4 w-4" />
</Button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -450,6 +443,32 @@ 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>
@@ -470,9 +489,17 @@ export default function DepartmentPage() {
</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">
<Badge variant={viewData.isActive ? "default" : "secondary"}>
{viewData.isActive ? "ACTIVE" : "HIDDEN"}
</Badge>
<p>
<b>Sort Order:</b> {viewData.sortOrder}
</p>
<p> <p>
<b>ID:</b> {viewData.departmentId} <b>ID:</b> {viewData.departmentId}
</p> </p>
</div>
<p> <p>
<b>Name:</b> {viewData.name} <b>Name:</b> {viewData.name}
</p> </p>
+562 -61
View File
@@ -7,7 +7,6 @@ import {
getDoctorsApi, getDoctorsApi,
createDoctorApi, createDoctorApi,
updateDoctorApi, updateDoctorApi,
deleteDoctorApi,
getDoctorTimingApi, getDoctorTimingApi,
} from "@/api/doctor"; } from "@/api/doctor";
import { getDepartmentsApi } from "@/api/department"; import { getDepartmentsApi } from "@/api/department";
@@ -31,16 +30,18 @@ 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 { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { import {
Loader2, Loader2,
RefreshCw, RefreshCw,
Plus, Plus,
Pencil, Pencil,
Trash,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
User,
} from "lucide-react"; } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
interface Department { interface Department {
departmentId: string; departmentId: string;
@@ -80,7 +81,24 @@ export default function DoctorPage() {
designation: "", designation: "",
workingStatus: "", workingStatus: "",
qualification: "", qualification: "",
isActive: true,
globalSortOrder: 0,
departments: [], departments: [],
professionalSummary: "",
seoTitle: "",
metaDescription: "",
ogTitle: "",
ogDescription: "",
ogImage: "",
specializations: [
{
name: "",
description: "",
},
],
focusKeyphrase: "",
slug: "",
tags: [],
}); });
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
@@ -108,7 +126,8 @@ export default function DoctorPage() {
fetchAll(); fetchAll();
}, [fetchAll]); }, [fetchAll]);
const filteredDoctors = doctors.filter((doc) => { const filteredDoctors = doctors
.filter((doc) => {
const matchesSearch = const matchesSearch =
doc.name.toLowerCase().includes(searchText.toLowerCase()) || doc.name.toLowerCase().includes(searchText.toLowerCase()) ||
doc.doctorId.toLowerCase().includes(searchText.toLowerCase()); doc.doctorId.toLowerCase().includes(searchText.toLowerCase());
@@ -118,6 +137,24 @@ export default function DoctorPage() {
: true; : true;
return matchesSearch && matchesDepartment; return matchesSearch && matchesDepartment;
})
.sort((a, b) => {
if (!filterDepartment) {
return a.globalSortOrder - b.globalSortOrder;
}
const aDept = a.departments.find(
(d: any) => d.departmentId === filterDepartment,
);
const bDept = b.departments.find(
(d: any) => d.departmentId === filterDepartment,
);
return (
(aDept?.deptSortOrder ?? Number.MAX_SAFE_INTEGER) -
(bDept?.deptSortOrder ?? Number.MAX_SAFE_INTEGER)
);
}); });
useEffect(() => { useEffect(() => {
@@ -130,9 +167,35 @@ export default function DoctorPage() {
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem); const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
function handleChange(e: any) { function handleChange(e: any) {
setForm({ ...form, [e.target.name]: e.target.value }); let value =
e.target.type === "number" ? Number(e.target.value) : e.target.value;
if (e.target.name === "slug") {
value = value
.toLowerCase()
.replace(/\s+/g, "-") // replace spaces with -
.replace(/[^\w-]+/g, "") // remove special chars
.replace(/--+/g, "-"); // remove duplicate -
} }
setForm({ ...form, [e.target.name]: value });
}
const handleToggleStatus = async (doc: any) => {
try {
const newStatus = !doc.isActive;
const payload = {
isActive: newStatus,
};
await updateDoctorApi(doc.doctorId, payload);
fetchAll();
} catch (err) {
console.error("Failed to update status", err);
}
};
function handleDepartmentToggle(depId: string) { function handleDepartmentToggle(depId: string) {
const exists = form.departments.find((d: any) => d.departmentId === depId); const exists = form.departments.find((d: any) => d.departmentId === depId);
if (exists) { if (exists) {
@@ -145,11 +208,23 @@ export default function DoctorPage() {
} else { } else {
setForm({ setForm({
...form, ...form,
departments: [...form.departments, { departmentId: depId, timing: {} }], departments: [
...form.departments,
{ departmentId: depId, sortOrder: 0, timing: {} },
],
}); });
} }
} }
function handleDeptSortChange(depId: string, value: string) {
setForm({
...form,
departments: form.departments.map((d: any) =>
d.departmentId === depId ? { ...d, sortOrder: Number(value) } : d,
),
});
}
function handleTimingChange(depId: string, day: string, value: string) { function handleTimingChange(depId: string, day: string, value: string) {
setForm({ setForm({
...form, ...form,
@@ -170,7 +245,25 @@ export default function DoctorPage() {
designation: "", designation: "",
workingStatus: "", workingStatus: "",
qualification: "", qualification: "",
experience: "",
professionalSummary: "",
isActive: true,
globalSortOrder: 0,
specializations: [
{
name: "",
description: "",
},
],
departments: [], departments: [],
seoTitle: "",
metaDescription: "",
focusKeyphrase: "",
slug: "",
tags: [],
ogTitle: "",
ogDescription: "",
ogImage: "",
}); });
setOpenModal(true); setOpenModal(true);
} }
@@ -180,6 +273,7 @@ export default function DoctorPage() {
try { try {
const timingRes = await getDoctorTimingApi(doc.doctorId); const timingRes = await getDoctorTimingApi(doc.doctorId);
const timingData = timingRes?.data?.departments || []; const timingData = timingRes?.data?.departments || [];
setForm({ setForm({
doctorId: doc.doctorId, doctorId: doc.doctorId,
name: doc.name, name: doc.name,
@@ -187,17 +281,43 @@ export default function DoctorPage() {
designation: doc.designation, designation: doc.designation,
workingStatus: doc.workingStatus, workingStatus: doc.workingStatus,
qualification: doc.qualification, qualification: doc.qualification,
isActive: doc.isActive ?? true,
globalSortOrder: doc.globalSortOrder ?? 0,
experience: doc.experience || "",
professionalSummary: doc.professionalSummary || "",
seoTitle: doc.seo?.seoTitle || "",
metaDescription: doc.seo?.metaDescription || "",
focusKeyphrase: doc.seo?.focusKeyphrase || "",
slug: doc.seo?.slug || "",
tags: doc.seo?.tags || [],
ogTitle: doc.seo?.ogTitle || "",
ogDescription: doc.seo?.ogDescription || "",
ogImage: doc.seo?.ogImage || "",
specializations: doc.specializations?.length
? doc.specializations.map((item: any) => ({
name: item.name || "",
description: item.description || "",
}))
: [
{
name: "",
description: "",
},
],
departments: timingData.map((d: any) => ({ departments: timingData.map((d: any) => ({
departmentId: d.departmentId, departmentId: d.departmentId,
sortOrder: d.deptSortOrder ?? 0,
timing: d.timing || {}, timing: d.timing || {},
})), })),
}); });
setOpenModal(true); setOpenModal(true);
} catch (err) { } catch (err) {
console.error(err); console.error("Error fetching doctor details:", err);
} }
} }
console.log("Current form state:", form); // Debug log to check form state
async function handleSubmit() { async function handleSubmit() {
try { try {
if (editing) { if (editing) {
@@ -212,16 +332,6 @@ export default function DoctorPage() {
} }
} }
async function handleDelete(id: string) {
if (!confirm("Delete this doctor?")) return;
try {
await deleteDoctorApi(id);
fetchAll();
} 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-4">
@@ -278,25 +388,25 @@ export default function DoctorPage() {
<CardContent className="p-0 sm:p-6 space-y-4"> <CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[900px] table-fixed border-separate border-spacing-0"> <Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm"> <TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[100px] bg-background text-sm font-bold"> <TableHead className="w-[80px] bg-background text-sm font-bold">
ID Priority{" "}
</TableHead>
<TableHead className="w-[200px] bg-background text-sm font-bold">
Name
</TableHead> </TableHead>
<TableHead className="w-[180px] bg-background text-sm font-bold"> <TableHead className="w-[180px] bg-background text-sm font-bold">
Doctor Info
</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">
Designation Designation
</TableHead> </TableHead>
<TableHead className="w-[180px] bg-background text-sm font-bold">
Qualification
</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold"> <TableHead className="w-[220px] bg-background text-sm font-bold">
Departments Departments (Hierarchy)
</TableHead> </TableHead>
<TableHead className="w-[120px] bg-background text-right text-sm font-bold"> <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 Actions
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -321,8 +431,8 @@ export default function DoctorPage() {
) : ( ) : (
currentItems.map((doc) => ( currentItems.map((doc) => (
<TableRow key={doc.doctorId} className="hover:bg-muted/50"> <TableRow key={doc.doctorId} className="hover:bg-muted/50">
<TableCell className="truncate font-mono text-xs"> <TableCell className="font-mono text-sm">
{doc.doctorId} {doc.globalSortOrder}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -332,26 +442,20 @@ export default function DoctorPage() {
> >
{doc.name} {doc.name}
</div> </div>
<div className="text-xs text-muted-foreground truncate italic"> <div className="text-xs text-muted-foreground truncate font-mono">
{doc.workingStatus} {doc.doctorId}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div <div
className="truncate text-sm" className="truncate text-sm font-medium"
title={doc.designation} title={doc.designation}
> >
{doc.designation || "-"} {doc.designation || "-"}
</div> </div>
</TableCell> <div className="text-xs italic text-muted-foreground truncate">
{doc.workingStatus}
<TableCell>
<div
className="truncate text-sm"
title={doc.qualification}
>
{doc.qualification || "-"}
</div> </div>
</TableCell> </TableCell>
@@ -361,14 +465,28 @@ export default function DoctorPage() {
<Badge <Badge
key={d.departmentId} key={d.departmentId}
variant="secondary" variant="secondary"
className="text-xs px-2 h-5 leading-none" className="text-xs px-2 h-6 leading-none flex items-center gap-1"
> >
{d.departmentName} {d.departmentName}
<span className="bg-primary text-primary-foreground px-1 rounded-full text-[10px]">
{d.deptSortOrder}
</span>
</Badge> </Badge>
))} ))}
{doc.departments?.length === 0 && ( </div>
<span className="text-muted-foreground">-</span> </TableCell>
)}
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={doc.isActive}
onCheckedChange={() => handleToggleStatus(doc)}
/>
<Badge
variant={doc.isActive ? "default" : "secondary"}
>
{doc.isActive ? "Active" : "Hidden"}
</Badge>
</div> </div>
</TableCell> </TableCell>
@@ -382,14 +500,6 @@ export default function DoctorPage() {
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(doc.doctorId)}
>
<Trash className="h-4 w-4" />
</Button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -457,7 +567,7 @@ export default function DoctorPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2"> <h3 className="font-bold text-base border-b pb-2">
Basic Information Profile & Visibility
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -471,6 +581,39 @@ export default function DoctorPage() {
/> />
</div> </div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label
htmlFor="isActive"
className="text-base font-semibold cursor-pointer"
>
Active
</Label>
<Switch
id="isActive"
checked={form.isActive}
onCheckedChange={(val) =>
setForm({ ...form, isActive: val })
}
/>
</div>
<div className="space-y-1">
<Label
htmlFor="globalSortOrder"
className="text-sm font-semibold"
>
Sort Priority (Lower numbers show first)
</Label>
<Input
id="globalSortOrder"
name="globalSortOrder"
type="number"
value={form.globalSortOrder}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-semibold">Doctor ID</label> <label className="text-sm font-semibold">Doctor ID</label>
<Input <Input
@@ -514,6 +657,24 @@ export default function DoctorPage() {
className="text-base" className="text-base"
/> />
</div> </div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Brief Professional Summary
</label>
<Textarea
name="professionalSummary"
placeholder="Write a brief professional summary about the doctor..."
value={form.professionalSummary || ""}
onChange={(e) =>
setForm({
...form,
professionalSummary: e.target.value,
})
}
className="min-h-[120px] text-base"
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-semibold"> <label className="text-sm font-semibold">
Qualification Qualification
@@ -526,6 +687,25 @@ export default function DoctorPage() {
className="text-base" className="text-base"
/> />
</div> </div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Years of Experience
</label>
<Input
name="experience"
type="number"
min={0}
placeholder="e.g. 15"
value={form.experience || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
Enter total years of professional experience
</p>
</div>
</div> </div>
<div className="p-5 border rounded-md bg-muted/20"> <div className="p-5 border rounded-md bg-muted/20">
@@ -552,15 +732,323 @@ export default function DoctorPage() {
})} })}
</div> </div>
</div> </div>
<div className="space-y-4 p-5 border rounded-md bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-base font-bold">Specializations</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setForm({
...form,
specializations: [
...(form.specializations || []),
{
name: "",
description: "",
},
],
})
}
>
+ Add
</Button>
</div>
{form.specializations?.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No specializations added
</p>
) : (
<div className="space-y-4">
{form.specializations?.map(
(specialization: any, index: number) => (
<div
key={index}
className="border rounded-lg p-4 space-y-3 bg-background"
>
<div className="flex items-center justify-between">
<p className="font-medium text-sm">
Specialization {index + 1}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const updated = form.specializations.filter(
(_: any, i: number) => i !== index,
);
setForm({
...form,
specializations: updated,
});
}}
>
Remove
</Button>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. Cardiology"
value={specialization.name}
onChange={(e) => {
const updated = [...form.specializations];
updated[index].name = e.target.value;
setForm({
...form,
specializations: updated,
});
}}
/>
</div>
<div className="space-y-2">
<Label>Doctor-specific Description</Label>
<Textarea
placeholder="Describe how this doctor specializes in this area..."
value={specialization.description}
onChange={(e) => {
const updated = [...form.specializations];
updated[index].description = e.target.value;
setForm({
...form,
specializations: updated,
});
}}
/>
</div>
</div>
),
)}
</div>
)}
</div>
<div className="space-y-4 p-5 border rounded-md bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-base font-bold">SEO Settings</p>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">SEO Title</Label>
<Input
name="seoTitle"
placeholder="Best Cardiologist in Kochi | Dr John Doe"
value={form.seoTitle || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
Title shown in Google search results
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Meta Description
</Label>
<Textarea
name="metaDescription"
placeholder="Short description shown in Google search..."
value={form.metaDescription || ""}
onChange={(e) =>
setForm({
...form,
metaDescription: e.target.value,
})
}
className="min-h-[100px] text-base"
/>
<p className="text-xs text-muted-foreground">
Recommended: 150160 characters
</p>
</div>
<div className="border-t pt-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-base font-bold">
Open Graph (Social Preview)
</p>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Title</Label>
<Input
name="ogTitle"
placeholder="Title for WhatsApp / Facebook sharing"
value={form.ogTitle || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
If empty, SEO title will be used
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
OG Description
</Label>
<Textarea
name="ogDescription"
placeholder="Description for social sharing..."
value={form.ogDescription || ""}
onChange={(e) =>
setForm({
...form,
ogDescription: e.target.value,
})
}
className="min-h-[100px] text-base"
/>
<p className="text-xs text-muted-foreground">
If empty, meta description will be used
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Image</Label>
<BytescaleUploader
value={form.ogImage}
folderPath="/doctor-og"
onChange={(url) =>
setForm({
...form,
ogImage: url,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Focus Keyphrase
</Label>
<Input
name="focusKeyphrase"
placeholder="best cardiologist in kochi"
value={form.focusKeyphrase || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
Main keyword people may search in Google
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">URL Slug</Label>
<Input
name="slug"
placeholder="dr-john-doe"
value={form.slug || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
URL:
<span className="font-medium">
{" "}
/doctors/
{form.slug || "doctor-slug"}
</span>
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tags / Keywords
</Label>
<div className="flex flex-wrap gap-2 border rounded-md p-3 min-h-[48px] bg-background">
{form.tags?.map((tag: string, index: number) => (
<div
key={index}
className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm flex items-center gap-2"
>
{tag}
<button
type="button"
onClick={() => {
const updated = form.tags.filter(
(_: string, i: number) => i !== index,
);
setForm({
...form,
tags: updated,
});
}}
>
×
</button>
</div>
))}
<Input
placeholder="Type keyword and press Enter"
className="border-0 shadow-none focus-visible:ring-0 min-w-[220px]"
onKeyDown={(e) => {
if (
e.key === "Enter" &&
e.currentTarget.value.trim()
) {
e.preventDefault();
setForm({
...form,
tags: [
...(form.tags || []),
e.currentTarget.value.trim(),
],
});
e.currentTarget.value = "";
}
}}
/>
</div>
</div>
</div>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2"> <h3 className="font-bold text-base border-b pb-2">
Working Hours / Timing Department Hierarchy & Timing
</h3> </h3>
{form.departments.length === 0 ? ( {form.departments.length === 0 ? (
<div className="text-base text-muted-foreground italic py-24 text-center border-2 border-dashed rounded-lg"> <div className="text-base text-muted-foreground italic py-24 text-center border-2 border-dashed rounded-lg">
Select a department to configure timing slots Select a department to configure hierarchy and timing
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
@@ -571,15 +1059,28 @@ export default function DoctorPage() {
return ( return (
<div <div
key={dep.departmentId} key={dep.departmentId}
className="space-y-4 p-5 border rounded-lg bg-background shadow-sm" className="space-y-4 p-5 border rounded-lg bg-background shadow-sm border-primary/20"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between border-b pb-2">
<p className="font-bold text-base text-primary"> <p className="font-bold text-base text-primary">
{depName} {depName}
</p> </p>
<Badge variant="outline" className="text-xs"> <div className="flex items-center gap-2">
Timing Slot <Label className="text-xs font-bold">
</Badge> Hierarchy Order:
</Label>
<Input
type="number"
className="w-20 h-8 text-sm"
value={dep.sortOrder}
onChange={(e) =>
handleDeptSortChange(
dep.departmentId,
e.target.value,
)
}
/>
</div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
{DAYS.map((day) => ( {DAYS.map((day) => (
File diff suppressed because it is too large Load Diff
+1
View File
@@ -233,6 +233,7 @@ export default function EmailPage() {
<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="INQUIRY">INQUIRY</option>
<option value="HCINQUIRY">HC-APPOINTMENT</option>
</select> </select>
<select <select
+14 -16
View File
@@ -136,10 +136,19 @@ export default function NewsPage() {
async function handleSubmit() { async function handleSubmit() {
try { try {
const submissionData = {
...form,
firstPara: form.headline,
content:
form.secondPara.length > 100
? form.secondPara.substring(0, 100) + "..."
: form.secondPara,
};
if (editing) { if (editing) {
await updateNewsApi(editing.Id, form); await updateNewsApi(editing.Id, submissionData);
} else { } else {
await createNewsApi(form); await createNewsApi(submissionData);
} }
setOpenModal(false); setOpenModal(false);
fetchAll(); fetchAll();
@@ -411,21 +420,10 @@ export default function NewsPage() {
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-semibold">Intro Paragraph</label> <label className="text-sm font-semibold">Story Content</label>
<Textarea <Textarea
name="firstPara" name="secondPara"
value={form.firstPara} value={form.secondPara}
onChange={handleChange}
className="min-h-[100px] text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Full Story Content
</label>
<Textarea
name="content"
value={form.content}
onChange={handleChange} onChange={handleChange}
className="min-h-[200px] text-base" className="min-h-[200px] text-base"
/> />