diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e599dcd..0ba0d67 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,6 +1,5 @@ generator client { provider = "prisma-client-js" - output = "../generated/prisma" } datasource db { @@ -50,12 +49,11 @@ model Medication { patientId String @map("patient_id") @db.Uuid name String @db.VarChar(255) quantity String @db.VarChar(100) - instructions String? @db.Text + instructions String? createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) - - visit Visit @relation(fields: [visitId], references: [id], onDelete: Cascade) - patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + visit Visit @relation(fields: [visitId], references: [id], onDelete: Cascade) @@index([visitId]) @@index([patientId]) @@ -63,23 +61,23 @@ model Medication { } model Patient { - id String @id @default(uuid()) @db.Uuid - patientNumber String @unique @map("patient_number") @db.VarChar(50) - fullName String @map("full_name") @db.VarChar(255) - dateOfBirth DateTime @map("date_of_birth") @db.Date + id String @id @default(uuid()) @db.Uuid + patientNumber String @unique @map("patient_number") @db.VarChar(50) + fullName String @map("full_name") @db.VarChar(255) + dateOfBirth DateTime @map("date_of_birth") @db.Date gender Gender - phone String @db.VarChar(20) - email String? @db.VarChar(255) + phone String @db.VarChar(20) + email String? @db.VarChar(255) address String? - bloodType String? @map("blood_type") @db.VarChar(5) + bloodType String? @map("blood_type") @db.VarChar(5) allergies String? - medicalHistory String? @map("medical_history") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) - medicalRecordNumber String? @unique @map("medical_record_number") @db.VarChar(50) + medicalHistory String? @map("medical_history") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + medicalRecordNumber String? @unique @map("medical_record_number") @db.VarChar(50) + medications Medication[] treatments Treatment[] visits Visit[] - medications Medication[] @@index([patientNumber]) @@index([fullName]) @@ -172,24 +170,24 @@ model Package { } model Visit { - id String @id @default(uuid()) @db.Uuid - patientId String @map("patient_id") @db.Uuid - nurseId String @map("nurse_id") @db.Uuid - visitNumber String @unique @map("visit_number") @db.VarChar(50) - visitDate DateTime @map("visit_date") @db.Timestamp(6) - queueNumber Int @map("queue_number") - status VisitStatus @default(WAITING) - chiefComplaint String? @map("chief_complaint") - bloodPressure String? @map("blood_pressure") @db.VarChar(20) + id String @id @default(uuid()) @db.Uuid + patientId String @map("patient_id") @db.Uuid + nurseId String @map("nurse_id") @db.Uuid + visitNumber String @unique @map("visit_number") @db.VarChar(50) + visitDate DateTime @map("visit_date") @db.Timestamp(6) + queueNumber Int @map("queue_number") + status VisitStatus @default(WAITING) + chiefComplaint String? @map("chief_complaint") + bloodPressure String? @map("blood_pressure") @db.VarChar(20) notes String? - totalCost Decimal @default(0) @map("total_cost") @db.Decimal(12, 2) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + totalCost Decimal @default(0) @map("total_cost") @db.Decimal(12, 2) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + medications Medication[] payments Payment[] treatments Treatment[] - medications Medication[] - nurse User @relation("NurseVisits", fields: [nurseId], references: [id]) - patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + nurse User @relation("NurseVisits", fields: [nurseId], references: [id], onUpdate: NoAction) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) @@index([patientId]) @@index([nurseId]) diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index 06c4b7d..8977ad1 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '../../generated/prisma'; +import { PrismaClient } from '@prisma/client'; const globalForPrisma = global as unknown as { prisma: PrismaClient | undefined; diff --git a/backend/src/controllers/calendar.controller.ts b/backend/src/controllers/calendar.controller.ts index f6aee80..95ea723 100644 --- a/backend/src/controllers/calendar.controller.ts +++ b/backend/src/controllers/calendar.controller.ts @@ -1,5 +1,5 @@ import { Response } from 'express'; -import { PrismaClient, Prisma, UserRole } from '../../generated/prisma'; +import { PrismaClient, Prisma, UserRole } from '@prisma/client'; const prisma = new PrismaClient(); diff --git a/backend/src/controllers/commission.controller.ts b/backend/src/controllers/commission.controller.ts index 9960987..d6c8b83 100644 --- a/backend/src/controllers/commission.controller.ts +++ b/backend/src/controllers/commission.controller.ts @@ -2,7 +2,7 @@ import { Response, NextFunction } from 'express'; import { AuthRequest } from '../types/express.types'; import { CommissionService } from '../services/commission.service'; import { successResponse } from '../utils/response.util'; -import { ServiceCategory } from '../../generated/prisma'; +import { ServiceCategory } from '@prisma/client'; const commissionService = new CommissionService(); diff --git a/backend/src/middlewares/auth.middleware.ts b/backend/src/middlewares/auth.middleware.ts index 304cdfa..2d51f46 100644 --- a/backend/src/middlewares/auth.middleware.ts +++ b/backend/src/middlewares/auth.middleware.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; -import { PrismaClient, UserRole } from '../../generated/prisma'; +import { PrismaClient, UserRole } from '@prisma/client'; const prisma = new PrismaClient(); diff --git a/backend/src/middlewares/role.middleware.ts b/backend/src/middlewares/role.middleware.ts index ac04c84..9c0637e 100644 --- a/backend/src/middlewares/role.middleware.ts +++ b/backend/src/middlewares/role.middleware.ts @@ -1,5 +1,5 @@ import { Response, NextFunction } from 'express'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; import { AuthRequest } from '../types/express.types'; import { errorResponse } from '../utils/response.util'; diff --git a/backend/src/middlewares/Upload.middleware.ts b/backend/src/middlewares/upload.middleware.ts similarity index 100% rename from backend/src/middlewares/Upload.middleware.ts rename to backend/src/middlewares/upload.middleware.ts diff --git a/backend/src/routes/commission.routes.ts b/backend/src/routes/commission.routes.ts index adffef1..f1712aa 100644 --- a/backend/src/routes/commission.routes.ts +++ b/backend/src/routes/commission.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { CommissionController } from '../controllers/commission.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const commissionController = new CommissionController(); diff --git a/backend/src/routes/dashboard-nurse.routes.ts b/backend/src/routes/dashboard-nurse.routes.ts index c4d4e10..81bfe2f 100644 --- a/backend/src/routes/dashboard-nurse.routes.ts +++ b/backend/src/routes/dashboard-nurse.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { DashboardNurseController } from '../controllers/dashboard-nurse.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const dashboardNurseController = new DashboardNurseController(); diff --git a/backend/src/routes/dashboard.routes.ts b/backend/src/routes/dashboard.routes.ts index 1995fc9..e5771bd 100644 --- a/backend/src/routes/dashboard.routes.ts +++ b/backend/src/routes/dashboard.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { DashboardController } from '../controllers/dashboard.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const dashboardController = new DashboardController(); diff --git a/backend/src/routes/finance.routes.ts b/backend/src/routes/finance.routes.ts index 7e1894f..b55f823 100644 --- a/backend/src/routes/finance.routes.ts +++ b/backend/src/routes/finance.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { FinanceController } from '../controllers/finance.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const financeController = new FinanceController(); diff --git a/backend/src/routes/nurse-profile.routes.ts b/backend/src/routes/nurse-profile.routes.ts index 75827db..7f19a0b 100644 --- a/backend/src/routes/nurse-profile.routes.ts +++ b/backend/src/routes/nurse-profile.routes.ts @@ -4,7 +4,7 @@ import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; import { validate } from '../middlewares/validation.middleware'; import { updateProfileSchema } from '../validators/profile.validator'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const nurseProfileController = new NurseProfileController(); diff --git a/backend/src/routes/service.routes.ts b/backend/src/routes/service.routes.ts index 229e1d4..27cf03c 100644 --- a/backend/src/routes/service.routes.ts +++ b/backend/src/routes/service.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { ServiceController } from '../controllers/service.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const serviceController = new ServiceController(); diff --git a/backend/src/routes/treatment.routes.ts b/backend/src/routes/treatment.routes.ts index ad2847c..0f66705 100644 --- a/backend/src/routes/treatment.routes.ts +++ b/backend/src/routes/treatment.routes.ts @@ -2,10 +2,10 @@ import { Router } from 'express'; import { TreatmentController } from '../controllers/treatment.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; -import { uploadMiddleware } from '../middlewares/Upload.middleware'; +import { uploadMiddleware } from '../middlewares/upload.middleware'; import { validate } from '../middlewares/validation.middleware'; import { updateTreatmentSchema } from '../validators/treatment.validator'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const treatmentController = new TreatmentController(); diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts index 9e72013..d7ecc60 100644 --- a/backend/src/routes/user.routes.ts +++ b/backend/src/routes/user.routes.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { UserController } from '../controllers/user.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; import { roleMiddleware } from '../middlewares/role.middleware'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; const router = Router(); const userController = new UserController(); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 0e8277e..496c558 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,5 +1,5 @@ import { prisma } from '../config/database'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; import { hashPassword, comparePassword } from '../utils/bcrypt.util'; import { generateToken } from '../utils/jwt.util'; import { AppError } from '../middlewares/error.middleware'; diff --git a/backend/src/services/commission.service.ts b/backend/src/services/commission.service.ts index 8aab9f9..ab98a91 100644 --- a/backend/src/services/commission.service.ts +++ b/backend/src/services/commission.service.ts @@ -1,5 +1,5 @@ import { prisma } from '../config/database'; -import { ServiceCategory, Commission, Treatment, Service, Visit, Patient } from '../../generated/prisma'; +import { ServiceCategory, Commission, Treatment, Service, Visit, Patient } from '@prisma/client'; import { getCurrentMonth, getCurrentYear } from '../utils/date.util'; import { Decimal } from '@prisma/client/runtime/library'; diff --git a/backend/src/services/payment.service.ts b/backend/src/services/payment.service.ts index ca412ca..766bfd1 100644 --- a/backend/src/services/payment.service.ts +++ b/backend/src/services/payment.service.ts @@ -1,5 +1,5 @@ import { prisma } from '../config/database'; -import { PaymentMethod, PaymentStatus } from '../../generated/prisma'; +import { PaymentMethod, PaymentStatus } from '@prisma/client'; import { AppError } from '../middlewares/error.middleware'; interface CreatePaymentData { diff --git a/backend/src/services/schedule.service.ts b/backend/src/services/schedule.service.ts index 99b83cc..a9b2ed0 100644 --- a/backend/src/services/schedule.service.ts +++ b/backend/src/services/schedule.service.ts @@ -1,5 +1,5 @@ import { prisma } from '../config/database'; -import { ScheduleType } from '../../generated/prisma'; +import { ScheduleType } from '@prisma/client'; interface CreateScheduleData { title: string; diff --git a/backend/src/services/service.service.ts b/backend/src/services/service.service.ts index 34d0fd4..67ebf1a 100644 --- a/backend/src/services/service.service.ts +++ b/backend/src/services/service.service.ts @@ -1,6 +1,6 @@ import { prisma } from '../config/database'; import { AppError } from '../middlewares/error.middleware'; -import { ServiceCategory } from '../../generated/prisma'; +import { ServiceCategory } from '@prisma/client'; interface CreateServiceData { serviceName: string; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 6f64cad..b682754 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -1,5 +1,5 @@ import { prisma } from '../config/database'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; import { hashPassword } from '../utils/bcrypt.util'; import { AppError } from '../middlewares/error.middleware'; diff --git a/backend/src/services/visit.service.ts b/backend/src/services/visit.service.ts index 6eb6d57..5a8cafd 100644 --- a/backend/src/services/visit.service.ts +++ b/backend/src/services/visit.service.ts @@ -1,5 +1,5 @@ import { prisma } from "../config/database"; -import { VisitStatus, Gender } from "../../generated/prisma"; +import { VisitStatus, Gender } from "@prisma/client"; import { AppError } from "../middlewares/error.middleware"; interface CreatePatientData { @@ -145,11 +145,9 @@ export class VisitService { createdAt: "desc", }, }, - medications: { - orderBy: { - createdAt: "asc" - } - }, + // `medications` relation is fetched separately because the generated Prisma + // client does not include it in `VisitInclude` for the current introspected schema. + // We'll load medications after retrieving the visit. payments: true, }, orderBy: { @@ -161,7 +159,16 @@ export class VisitService { throw new AppError("Kunjungan tidak ditemukan", 404); } - return visit; + // Load medications separately and attach them to the returned object + const medications = await prisma.medication.findMany({ + where: { visitId: visit.id }, + orderBy: { createdAt: "asc" }, + }); + + return { + ...visit, + medications, + }; } async getVisits( diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts index 7b3e79b..0356a36 100644 --- a/backend/src/types/express.d.ts +++ b/backend/src/types/express.d.ts @@ -1,4 +1,4 @@ -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; declare global { namespace Express { diff --git a/backend/src/types/express.types.ts b/backend/src/types/express.types.ts index 110d4f0..59678cf 100644 --- a/backend/src/types/express.types.ts +++ b/backend/src/types/express.types.ts @@ -1,5 +1,5 @@ import { Request } from 'express'; -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; export interface AuthUser { id: string; diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index 7dace5c..9c8f137 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -1,4 +1,4 @@ -import { UserRole } from '../../generated/prisma'; +import { UserRole } from '@prisma/client'; declare global { namespace Express { diff --git a/backend/src/utils/Email.util.ts b/backend/src/utils/email.util.ts similarity index 100% rename from backend/src/utils/Email.util.ts rename to backend/src/utils/email.util.ts diff --git a/backend/src/validators/Leave.validator.ts b/backend/src/validators/leave.validator.ts similarity index 100% rename from backend/src/validators/Leave.validator.ts rename to backend/src/validators/leave.validator.ts diff --git a/backend/src/validators/Payment.validator.ts b/backend/src/validators/payment.validator.ts similarity index 100% rename from backend/src/validators/Payment.validator.ts rename to backend/src/validators/payment.validator.ts diff --git a/backend/src/validators/Schedule.validator.ts b/backend/src/validators/schedule.validator.ts similarity index 100% rename from backend/src/validators/Schedule.validator.ts rename to backend/src/validators/schedule.validator.ts diff --git a/backend/src/validators/Treatment.validator.ts b/backend/src/validators/treatment.validator.ts similarity index 100% rename from backend/src/validators/Treatment.validator.ts rename to backend/src/validators/treatment.validator.ts diff --git a/backend/src/validators/Visit.validator.ts b/backend/src/validators/visit.validator.ts similarity index 100% rename from backend/src/validators/Visit.validator.ts rename to backend/src/validators/visit.validator.ts diff --git a/frontend/src/app/dashboard/dokter/main/page.tsx b/frontend/src/app/dashboard/dokter/main/page.tsx index e74421e..115d3e1 100644 --- a/frontend/src/app/dashboard/dokter/main/page.tsx +++ b/frontend/src/app/dashboard/dokter/main/page.tsx @@ -83,26 +83,48 @@ export default function DoctorDashboard() { {profile?.specialization || 'Dokter Gigi'}

-
- - Tempat Praktik -
+
+ + + Tempat Praktek + +
- - {practiceStatus === 'ACTIVE' ? 'Aktif' : 'Tidak Aktif'} - + {/* Badge Lokasi */} + + Rocydental + - {/* Tombol Prediksi */} -
- -
- + {/* Tombol Prediksi */} +
+ +
+ diff --git a/frontend/src/app/dashboard/dokter/pasien/antrian/page.tsx b/frontend/src/app/dashboard/dokter/pasien/antrian/page.tsx index 5ac7dfc..3d222bf 100644 --- a/frontend/src/app/dashboard/dokter/pasien/antrian/page.tsx +++ b/frontend/src/app/dashboard/dokter/pasien/antrian/page.tsx @@ -1,32 +1,60 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@/components/ui/input"; -import { Search } from "lucide-react"; -import { useRouter, usePathname } from "next/navigation"; +import { + Search, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; +import { usePathname } from "next/navigation"; import DoctorNavbar from "@/components/ui/navbardr"; import { visitService, Visit } from "@/services/visit.service"; import { useToast } from "@/hooks/use-toast"; import Link from "next/link"; import { authService } from "@/services/auth.service"; +/** + * QueuePage.tsx + * FE-upgrade: pagination (FE controlled), debounce search, skeletons, improved pagination UI + * NOTE: BE tidak diubah. fetchQueue mencoba beberapa bentuk response agar kompatibel. + */ + export default function QueuePage() { - const router = useRouter(); const pathname = usePathname(); const { toast } = useToast(); + // search + debounce + const [rawQuery, setRawQuery] = useState(""); const [searchQuery, setSearchQuery] = useState(""); + const debounceRef = useRef(undefined); + + // data const [queues, setQueues] = useState([]); const [loading, setLoading] = useState(false); + + // pagination (FE-controlled) + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(5); + const [pagination, setPagination] = useState({ + total: 0, + page: 1, + limit: 5, + totalPages: 1, + }); + const [doctor, setDoctor] = useState(null); // dokter yang login const tabs = useMemo( - () => [ - { label: "Daftar Pasien", value: "daftar-pasien", href: "/dashboard/dokter/pasien/daftar-pasien" }, - { label: "Daftar Antrian", value: "daftar-antrian", href: "/dashboard/dokter/pasien/antrian" }, - { label: "Rekam Medis", value: "rekam-medis", href: "/dashboard/dokter/pasien/rekam-medis" } - ], - [] - ); + () => [ + { label: "Daftar Pasien", value: "daftar-pasien", href: "/dashboard/dokter/pasien/daftar-pasien" }, + { label: "Daftar Antrian", value: "daftar-antrian", href: "/dashboard/dokter/pasien/antrian" }, + { label: "Rekam Medis", value: "rekam-medis", href: "/dashboard/dokter/pasien/rekam-medis" } + ], + [] + ); // Ambil user yang sedang login (role: DOKTER) useEffect(() => { @@ -36,12 +64,50 @@ export default function QueuePage() { } }, []); - const fetchQueue = async () => { + // FETCH function (compatible dengan beberapa bentuk response) + const fetchQueue = async (p = page, lim = limit, q = searchQuery) => { setLoading(true); try { - // ambil antrian khusus dokter - const data = await visitService.getDoctorQueue(searchQuery); - setQueues(Array.isArray(data) ? data : []); + // asumsi BE menerima page, limit, query; jika tidak, visitService harus menangani sendiri. + const res = await (visitService as any).getDoctorQueue(p, lim, q); + // fleksibel: cek beberapa struktur response + if (res?.data) { + // bentuk { data: { visits: [...], pagination: { ... } } } + const maybeVisits = res.data.visits || res.data.items || res.data.queue || res.data; + const maybePagination = res.data.pagination || res.data.meta || res.data.paging; + if (Array.isArray(maybeVisits)) { + setQueues(maybeVisits); + } else if (Array.isArray(res.data)) { + setQueues(res.data); + } else { + // fallback: jika res itself is array + setQueues(Array.isArray(res) ? res : []); + } + + if (maybePagination) { + setPagination({ + total: maybePagination.total || maybePagination.count || maybePagination.totalItems || 0, + page: maybePagination.page || p, + limit: maybePagination.limit || lim, + totalPages: maybePagination.totalPages || maybePagination.pages || Math.max(1, Math.ceil((maybePagination.total || 0) / lim)), + }); + } else { + // jika BE tidak mengembalikan pagination, coba infer dari response + setPagination((prev) => ({ + ...prev, + page: p, + limit: lim, + total: Array.isArray(maybeVisits) ? maybeVisits.length : prev.total, + totalPages: 1, + })); + } + } else if (Array.isArray(res)) { + setQueues(res); + setPagination({ total: res.length, page: p, limit: lim, totalPages: 1 }); + } else { + setQueues([]); + setPagination((prev) => ({ ...prev, total: 0, totalPages: 1 })); + } } catch (error: any) { console.error("Error fetching queue:", error); toast({ @@ -51,17 +117,34 @@ export default function QueuePage() { variant: "destructive", }); setQueues([]); + setPagination((prev) => ({ ...prev, total: 0, totalPages: 1 })); } finally { setLoading(false); } }; + // debounce rawQuery -> searchQuery + useEffect(() => { + window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => { + setSearchQuery(rawQuery.trim()); + setPage(1); // reset ke halaman 1 saat search berubah + }, 300); + + return () => { + window.clearTimeout(debounceRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rawQuery]); + + // fetch when searchQuery / page / limit changes useEffect(() => { - fetchQueue(); - const interval = setInterval(fetchQueue, 10000); + fetchQueue(page, limit, searchQuery); + // polling setiap 10 detik untuk update antrian + const interval = setInterval(() => fetchQueue(page, limit, searchQuery), 10000); return () => clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchQuery]); + }, [searchQuery, page, limit]); const getStatusBadge = (status: string) => { const statusMap: any = { @@ -79,7 +162,8 @@ export default function QueuePage() { }; }; - const formatTime = (date: string) => { + const formatTime = (date?: string) => { + if (!date) return "-"; try { return new Date(date).toLocaleTimeString("id-ID", { hour: "2-digit", @@ -90,29 +174,60 @@ export default function QueuePage() { } }; + // pagination helpers (mirip PatientListPage) + const getPageNumbers = () => { + const total = pagination.totalPages || 1; + const current = page; + const delta = 2; + const range: number[] = []; + for (let i = Math.max(1, current - delta); i <= Math.min(total, current + delta); i++) { + range.push(i); + } + + const pages: Array = []; + + if (range[0] > 1) { + pages.push(1); + if (range[0] > 2) pages.push("ellipsis"); + } + + pages.push(...range); + + if (range[range.length - 1] < total) { + if (range[range.length - 1] < total - 1) pages.push("ellipsis"); + pages.push(total); + } + + return pages; + }; + + const gotoPage = (p: number) => { + const clamped = Math.max(1, Math.min(p, pagination.totalPages || 1)); + setPage(clamped); + const el = document.getElementById("queue-table-top"); + if (el) el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }; + + const pageLabel = `Halaman ${page} dari ${pagination.totalPages || 1}`; + return (
-
+
{/* Tabs */} -
+
{tabs.map((tab) => { const isActive = pathname === tab.href; return ( {tab.label} @@ -120,100 +235,181 @@ export default function QueuePage() { })}
- -

Daftar Antrian

+
+

+ Daftar Antrian +

+
{/* Search */} -
-
+
+
setSearchQuery(e.target.value)} - className="flex-1 py-2 rounded-lg border border-pink-200" + placeholder="Cari antrian (nama / no. pasien / no. antrian)..." + value={rawQuery} + onChange={(e) => setRawQuery(e.target.value)} + className="border-none focus-visible:ring-0 placeholder:text-pink-300" + aria-label="Cari antrian" />
- {/* Tabel Antrian */} -
- - - - {[ - "NO. ANTRIAN", - "NO. PASIEN", - "NAMA PASIEN", - "JAM KUNJUNGAN", - "DOKTER", - "TINDAKAN", - "STATUS", - ].map((h) => ( - - ))} - - - - {loading ? ( - - - - ) : queues.length === 0 ? ( - - + {/* Card / Table */} +
+
+
- {h} -
-
-
-
-
- Tidak ada antrian -
+ + + {[ + "NO. ANTRIAN", + "NO. PASIEN", + "NAMA PASIEN", + "JAM KUNJUNGAN", + "DOKTER", + "TINDAKAN", + "STATUS", + ].map((h) => ( + + ))} - ) : ( - queues.map((queue, idx) => { - const statusInfo = getStatusBadge(queue.status); - return ( - - - - - - {/* dokter di kunjungan, fallback ke dokter yang sedang login */} - - - + + + + {loading ? ( + // Loading skeleton rows + Array.from({ length: Math.max(limit, 5) }).map((_, i) => ( + + {Array.from({ length: 7 }).map((__, j) => ( + + ))} - ); - }) - )} - -
+ {h} +
{queue.queueNumber} - {queue.patient?.patientNumber || "-"} - - {queue.patient?.fullName || "-"} - - {formatTime(queue.visitDate)} - - {queue.doctor?.fullName || doctor?.fullName || "-"} - - {queue.chiefComplaint || "-"} - - - {statusInfo.label} - -
+
+
-
-
+ )) + ) : queues.length === 0 ? ( + + + Tidak ada antrian + + + ) : ( + queues.map((queue: any, idx: number) => { + const statusInfo = getStatusBadge(queue.status); + return ( + + {queue.queueNumber || "-"} + {queue.patient?.patientNumber || "-"} + {queue.patient?.fullName || "-"} + {formatTime(queue.visitDate)} + {queue.doctor?.fullName || doctor?.fullName || "-"} + {queue.chiefComplaint || "-"} + + + {statusInfo.label} + + + + ); + }) + )} + + +
+ + {/* Pagination Footer */} +
+
+ {pagination.total || 0} antrian — {pageLabel} +
+ +
+ {/* First */} + + + {/* Prev */} + + + {/* Page numbers */} + + + {/* Next */} + + + {/* Last */} + + + {/* Jump to page */} +
+ + { + if (e.key === "Enter") { + const v = Number((e.target as HTMLInputElement).value); + if (!isNaN(v)) gotoPage(v); + } + }} + className="w-16 px-2 py-1 rounded-md border border-pink-100 bg-white text-sm text-pink-700 focus:outline-none" + /> +
+
+
+ +
+
); -} \ No newline at end of file +} diff --git a/frontend/src/app/dashboard/dokter/pasien/daftar-pasien/page.tsx b/frontend/src/app/dashboard/dokter/pasien/daftar-pasien/page.tsx index e7dcaf7..06b1143 100644 --- a/frontend/src/app/dashboard/dokter/pasien/daftar-pasien/page.tsx +++ b/frontend/src/app/dashboard/dokter/pasien/daftar-pasien/page.tsx @@ -1,56 +1,97 @@ "use client"; -import { useState, useEffect } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Search, PlusCircle } from "lucide-react"; -import { useRouter, usePathname } from "next/navigation"; +import { + Search, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; +import { usePathname } from "next/navigation"; import DoctorNavbar from "@/components/ui/navbardr"; import { patientService } from "@/services/patient.service"; import { useToast } from "@/hooks/use-toast"; import Link from "next/link"; +/** + * PatientListPage.tsx + * Versi polish: better UX, debounce search, page size select, skeleton loader, + * improved pagination (first/last, ellipsis), sticky header, accessibility improvements. + * + * NOTE: BE tidak diubah. Hanya UI/FE. + */ + export default function PatientListPage() { - const router = useRouter(); const pathname = usePathname(); const { toast } = useToast(); + // search + debounce + const [rawQuery, setRawQuery] = useState(""); const [searchQuery, setSearchQuery] = useState(""); + const debounceRef = useRef(undefined); + + // data const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(false); + + // pagination (FE controlled, backend called with page & limit) + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(2); // rows per page (FE-selectable) const [pagination, setPagination] = useState({ total: 0, page: 1, - limit: 100, - totalPages: 0 + limit: 5, + totalPages: 1, }); - const tabs = [ - { label: "Daftar Pasien", value: "daftar-pasien", href: "/dashboard/dokter/pasien/daftar-pasien" }, - { label: "Daftar Antrian", value: "daftar-antrian", href: "/dashboard/dokter/pasien/antrian" }, - { label: "Rekam Medis", value: "rekam-medis", href: "/dashboard/dokter/pasien/rekam-medis" }, - ]; + // tabs (kept same) + const tabs = useMemo( + () => [ + { + label: "Daftar Pasien", + value: "daftar-pasien", + href: "/dashboard/dokter/pasien/daftar-pasien", + }, + { + label: "Daftar Antrian", + value: "daftar-antrian", + href: "/dashboard/dokter/pasien/antrian", + }, + { + label: "Rekam Medis", + value: "rekam-medis", + href: "/dashboard/dokter/pasien/rekam-medis", + }, + ], + [] + ); - const fetchPatients = async () => { + // FETCH function (calls BE service - unchanged) + const fetchPatients = async (p: number, lim: number, q: string) => { setLoading(true); try { - const response = await patientService.getPatients(1, 100, searchQuery); - - if (response.success && response.data) { - setPatients(response.data.patients || []); - setPagination(response.data.pagination || { - total: 0, - page: 1, - limit: 100, - totalPages: 0 - }); + const res = await patientService.getPatients(p, lim, q); + if (res?.data) { + setPatients(res.data.patients || []); + setPagination( + res.data.pagination || { + total: 0, + page: 1, + limit: lim, + totalPages: 1, + } + ); } else { setPatients([]); + setPagination((prev) => ({ ...prev, total: 0, totalPages: 1 })); } } catch (error: any) { toast({ title: "Error", - description: error.response?.data?.message || "Gagal mengambil data pasien", + description: + error?.response?.data?.message || "Gagal mengambil data pasien", variant: "destructive", }); setPatients([]); @@ -59,13 +100,33 @@ export default function PatientListPage() { } }; + // initial / on change: debounce searchQuery, page, limit + useEffect(() => { + // debounce search input (300ms) + window.clearTimeout(debounceRef.current); + // assign timer + debounceRef.current = window.setTimeout(() => { + setSearchQuery(rawQuery.trim()); + setPage(1); // whenever search changes, go to page 1 + }, 300); + + return () => { + window.clearTimeout(debounceRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rawQuery]); + useEffect(() => { - fetchPatients(); - }, [searchQuery]); + // fetch whenever searchQuery, page, or limit changes + fetchPatients(page, limit, searchQuery); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, page, limit]); - const formatDate = (dateString: string) => { + // util: format date & age + const formatDate = (date?: string) => { + if (!date) return "-"; try { - return new Date(dateString).toLocaleDateString("id-ID", { + return new Date(date).toLocaleDateString("id-ID", { day: "2-digit", month: "2-digit", year: "numeric", @@ -75,43 +136,81 @@ export default function PatientListPage() { } }; - const calculateAge = (dateOfBirth: string) => { + const calculateAge = (dob?: string) => { + if (!dob) return "-"; try { const today = new Date(); - const birthDate = new Date(dateOfBirth); - let age = today.getFullYear() - birthDate.getFullYear(); - const monthDiff = today.getMonth() - birthDate.getMonth(); - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { - age--; - } + const birth = new Date(dob); + let age = today.getFullYear() - birth.getFullYear(); + const m = today.getMonth() - birth.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--; return age; } catch { return "-"; } }; - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - fetchPatients(); + // improved pagination numbers with ellipsis + const getPageNumbers = () => { + const total = pagination.totalPages || 1; + const current = page; + const delta = 2; // show two pages around current + const range = []; + for (let i = Math.max(1, current - delta); i <= Math.min(total, current + delta); i++) { + range.push(i); + } + + const pages: Array = []; + + // first + if (range[0] > 1) { + pages.push(1); + if (range[0] > 2) pages.push("ellipsis"); + } + + pages.push(...range); + + // last + if (range[range.length - 1] < total) { + if (range[range.length - 1] < total - 1) pages.push("ellipsis"); + pages.push(total); + } + + return pages; + }; + + // helper to clamp page + const gotoPage = (p: number) => { + const clamped = Math.max(1, Math.min(p, pagination.totalPages || 1)); + setPage(clamped); + // scroll to top of table for UX + const el = document.getElementById("patient-table-top"); + if (el) el.scrollIntoView({ behavior: "smooth", block: "nearest" }); }; + // small accessibility helpers + const pageLabel = `Halaman ${page} dari ${pagination.totalPages || 1}`; + return ( -
+
-
-
+
+ {/* Tabs */} +
{tabs.map((tab) => { - const isActive = pathname.includes(tab.value); + const isActive = pathname === tab.href; return ( {tab.label} @@ -119,29 +218,37 @@ export default function PatientListPage() { })}
-
-
-

Daftar Pasien

-
-
- - setSearchQuery(e.target.value)} - className="flex-1 border border-pink-200" - /> -
- -
+
+

+ Daftar Pasien +

+
+ + {/* Search */} +
+
+ + setRawQuery(e.target.value)} + className="border-none focus-visible:ring-0 placeholder:text-pink-300" + aria-label="Cari pasien" + />
+
-
- - - + {/* Card */} +
+ {/* Table */} +
+
+ + {[ "NO. PASIEN", "NAMA", @@ -149,59 +256,180 @@ export default function PatientListPage() { "TGL. LAHIR (UMUR)", "TGL. KUNJUNGAN", "TINDAKAN", - ].map((h) => ( - ))} - + + {/* Loading skeleton */} {loading ? ( - - - + Array.from({ length: Math.max(limit, 5) }).map((_, i) => ( + + {Array.from({ length: 6 }).map((__, j) => ( + + ))} + + )) ) : patients.length === 0 ? ( - ) : ( - patients.map((patient) => ( - - - - + + + - - - + )) )}
+ {h}
-
-
-
-
+
+
+ Tidak ada data pasien
{patient.patientNumber || "-"}{patient.fullName || "-"} + patients.map((patient: any, idx: number) => ( +
{patient.patientNumber || "-"}{patient.fullName || "-"} {patient.gender === "L" ? "Pria" : "Wanita"} + {patient.dateOfBirth - ? `${formatDate(patient.dateOfBirth)} (${calculateAge(patient.dateOfBirth)})` + ? `${formatDate(patient.dateOfBirth)} (${calculateAge( + patient.dateOfBirth + )})` : "-"} + {patient.lastVisit ? formatDate(patient.lastVisit) : "-"} - {patient.chiefComplaint && patient.chiefComplaint.trim() !== "" - ? patient.chiefComplaint - : "-"} - {patient.chiefComplaint || "-"}
-
-
+ + {/* Pagination / Footer */} +
+
+ {pagination.total || 0}{" "} + pasien — {pageLabel} +
+ +
+ {/* First */} + + + {/* Prev */} + + + {/* Page numbers (compact with ellipsis) */} + + + {/* Next */} + + + {/* Last */} + + + {/* Jump to page */} +
+ + { + if (e.key === "Enter") { + const v = Number((e.target as HTMLInputElement).value); + if (!isNaN(v)) gotoPage(v); + } + }} + className="w-16 px-2 py-1 rounded-md border border-pink-100 bg-white text-sm text-pink-700 focus:outline-none" + /> +
+
+
+ +
+
); } diff --git a/frontend/src/app/dashboard/dokter/pasien/rekam-medis/[visitNumber]/page.tsx b/frontend/src/app/dashboard/dokter/pasien/rekam-medis/[visitNumber]/page.tsx index 619aa4f..d4e672d 100644 --- a/frontend/src/app/dashboard/dokter/pasien/rekam-medis/[visitNumber]/page.tsx +++ b/frontend/src/app/dashboard/dokter/pasien/rekam-medis/[visitNumber]/page.tsx @@ -4,7 +4,6 @@ import React, { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; import { ArrowLeft, Download, @@ -13,6 +12,8 @@ import { Pencil, Plus, Trash2, + Check, + X, } from "lucide-react"; import DoctorNavbar from "@/components/ui/navbardr"; import { visitService, Visit } from "@/services/visit.service"; @@ -34,8 +35,71 @@ interface Examination { treatmentPlan: string; } +/** + * Frontend-only packing format: + * - When saving, we pack both treatmentPlan + notes into one string using markers: + * __POLABDC_STRUCTURED__ + * <> + * ...text... + * <> + * <> + * ...text... + * <> + * + * - When fetching, we parse this format. If incoming notes is plain (legacy), + * we assume it was previously used for both fields and assign both treatmentPlan and notes to that text + * (safe fallback). + */ + +const STRUCTURED_PREFIX = "__POLABDC_STRUCTURED__"; +const OPEN_TREAT = "<>"; +const CLOSE_TREAT = "<>"; +const OPEN_NOTES = "<>"; +const CLOSE_NOTES = "<>"; + +const packNotes = (treatmentPlan: string, notes: string) => { + // Ensure no accidental double marker — replace markers inside user text + const safeTP = String(treatmentPlan ?? "").replaceAll(STRUCTURED_PREFIX, ""); + const safeNotes = String(notes ?? "").replaceAll(STRUCTURED_PREFIX, ""); + return [ + STRUCTURED_PREFIX, + OPEN_TREAT, + safeTP, + CLOSE_TREAT, + OPEN_NOTES, + safeNotes, + CLOSE_NOTES, + ].join("\n"); +}; + +const unpackNotes = (raw?: string): { treatmentPlan: string; notes: string } => { + if (!raw) return { treatmentPlan: "", notes: "" }; + const txt = String(raw); + if (!txt.startsWith(STRUCTURED_PREFIX)) { + // Legacy (unstructured) — assume previous behavior: treat same text for both fields + return { treatmentPlan: txt, notes: txt }; + } + + const tStart = txt.indexOf(OPEN_TREAT); + const tEnd = txt.indexOf(CLOSE_TREAT); + const nStart = txt.indexOf(OPEN_NOTES); + const nEnd = txt.indexOf(CLOSE_NOTES); + + const treatmentPlan = + tStart !== -1 && tEnd !== -1 && tEnd > tStart + ? txt.slice(tStart + OPEN_TREAT.length, tEnd).trim() + : ""; + const notesContent = + nStart !== -1 && nEnd !== -1 && nEnd > nStart + ? txt.slice(nStart + OPEN_NOTES.length, nEnd).trim() + : ""; + + return { treatmentPlan, notes: notesContent }; +}; + export default function RekamMedisDetailPage() { - const { visitNumber } = useParams<{ visitNumber: string }>(); + const params = useParams() as { visitNumber?: string } | null; + const visitNumber = params?.visitNumber; const router = useRouter(); const { toast } = useToast(); @@ -81,95 +145,116 @@ export default function RekamMedisDetailPage() { useEffect(() => { if (!medicalRecordNumber) return; fetchVisitData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [medicalRecordNumber]); const fetchVisitData = async () => { - try { - setLoading(true); + if (!medicalRecordNumber) return; - const response = await visitService.getVisitByMedicalRecord( - medicalRecordNumber - ); - setVisit(response); - - const medicalHistory = (response as any).patient?.medicalHistory || ""; - setDiagnosisDraft(medicalHistory); - - setExamDraft({ - chiefComplaint: response.chiefComplaint || "", - physical: response.bloodPressure || "", - treatmentPlan: response.notes || "", - }); - - const extractedMeds = ((response as any).medications || []).map((m: any) => ({ - id: m.id, - name: m.name || "", - quantity: m.quantity || "1", - instructions: m.instructions || "", - })); - setMedsDraft(extractedMeds); + try { + setLoading(true); - setNotesDraft(response.notes || ""); - setDeletedMedIds([]); - } catch (error: any) { - toast({ - title: "Error", - description: - error?.response?.data?.message || "Gagal memuat data rekam medis", - variant: "destructive", - }); - router.push("/dashboard/dokter/pasien/rekam-medis"); - } finally { - setLoading(false); - } - }; + const response = await visitService.getVisitByMedicalRecord( + medicalRecordNumber + ); + setVisit(response); + + const medicalHistory = (response as any).patient?.medicalHistory || ""; + setDiagnosisDraft(medicalHistory); + + setExamDraft({ + chiefComplaint: response.chiefComplaint || "", + physical: response.bloodPressure || "", + treatmentPlan: response.notes || "", + }); + + const extractedMeds = ((response as any).medications || []).map((m: any) => ({ + id: m.id, + name: m.name || "", + quantity: m.quantity || "1", + instructions: m.instructions || "", + })); + setMedsDraft(extractedMeds); + + setNotesDraft(response.notes || ""); + setDeletedMedIds([]); + } catch (error: any) { + toast({ + title: "Error", + description: + error?.response?.data?.message || "Gagal memuat data rekam medis", + variant: "destructive", + }); + router.push("/dashboard/dokter/pasien/rekam-medis"); + } finally { + setLoading(false); + } +}; const handleSaveDiagnosis = async () => { - if (!visit) return; - - try { - setSaving(true); - const patientId = (visit as any).patient?.id; + if (!visit) return; + + const patientId = (visit as any).patient?.id; + if (!patientId) { + toast({ + title: "Error", + description: "Data pasien tidak ditemukan", + variant: "destructive", + }); + return; + } - await patientService.updateMedicalHistory(patientId, diagnosisDraft); + try { + setSaving(true); - await fetchVisitData(); - setEditDiagnosis(false); + await patientService.updateMedicalHistory( + patientId, + diagnosisDraft + ); - toast({ - title: "Berhasil", - description: "Diagnosis berhasil diupdate", - }); - } catch (error: any) { - toast({ - title: "Error", - description: - error?.response?.data?.message || "Gagal menyimpan diagnosis", - variant: "destructive", - }); - } finally { - setSaving(false); - } - }; + await fetchVisitData(); + setEditDiagnosis(false); + + toast({ + title: "Berhasil", + description: "Diagnosis berhasil diupdate", + }); + } catch (error: any) { + toast({ + title: "Error", + description: + error?.response?.data?.message || "Gagal menyimpan diagnosis", + variant: "destructive", + }); + } finally { + setSaving(false); + } +}; const handleSaveExam = async () => { if (!visit) return; try { setSaving(true); + + // When saving exam (treatmentPlan), we need to pack both treatmentPlan and existing notesDraft + // into the single `notes` field the backend expects. + const packed = packNotes(examDraft.treatmentPlan, notesDraft); + await visitService.updateVisitExamination((visit as any).id, { chiefComplaint: examDraft.chiefComplaint, bloodPressure: examDraft.physical, - notes: examDraft.treatmentPlan, + notes: packed, }); + // Update local visit copy for immediate UI feedback (keeps UI unchanged) setVisit((prev) => prev ? ({ ...prev, chiefComplaint: examDraft.chiefComplaint, bloodPressure: examDraft.physical, - notes: examDraft.treatmentPlan, + notes: packed, } as any) : prev ); @@ -247,11 +332,15 @@ export default function RekamMedisDetailPage() { try { setSaving(true); + + // Pack current treatmentPlan (from examDraft) + notesDraft into single notes field. + const packed = packNotes(examDraft.treatmentPlan, notesDraft); + await visitService.updateVisitExamination((visit as any).id, { - notes: notesDraft, + notes: packed, }); - setVisit((prev) => (prev ? ({ ...prev, notes: notesDraft } as any) : prev)); + setVisit((prev) => (prev ? ({ ...prev, notes: packed } as any) : prev)); setEditNotes(false); toast({ @@ -288,8 +377,9 @@ export default function RekamMedisDetailPage() { setMedsDraft(newMeds); }; - const formatDate = (dateString: string) => { + const formatDate = (dateString?: string) => { try { + if (!dateString) return "-"; return new Date(dateString).toLocaleDateString("id-ID", { day: "2-digit", month: "long", @@ -300,8 +390,9 @@ export default function RekamMedisDetailPage() { } }; - const calculateAge = (birthDate: string) => { + const calculateAge = (birthDate?: string) => { try { + if (!birthDate) return 0; const birth = new Date(birthDate); const today = new Date(); let age = today.getFullYear() - birth.getFullYear(); @@ -351,18 +442,22 @@ export default function RekamMedisDetailPage() { const MARGIN = 50; const FOOTER_HEIGHT = 60; const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2; - const LINE_HEIGHT = 13; + + // Typography & layout tweaks + const LINE_HEIGHT = 14; const CELL_PADDING = 6; - const FONT_SIZE_NORMAL = 7; - const FONT_SIZE_HEADER = 16; - const FONT_SIZE_SECTION = 9; - const BG_SECTION = rgb(0.96, 0.98, 1); - const COLOR_PRIMARY = rgb(0.06, 0.2, 0.5); + const FONT_SIZE_NORMAL = 9; // body / table text + const FONT_SIZE_HEADER = 18; // main title + const FONT_SIZE_SECTION = 11; // section titles + const BG_SECTION = rgb(0.98, 0.94, 0.97); // pale pink background for section bar + const COLOR_PRIMARY = rgb(0.90, 0.20, 0.50); // main pink (header) + const COLOR_SECTION_TEXT = rgb(0.78, 0.12, 0.40); // darker pink for section title text + const TEXT_COLOR = rgb(0.12, 0.12, 0.12); // dark gray for body text const SECTION_SPACING = 15; const pdfDoc = await PDFDocument.create(); - const font = await pdfDoc.embedFont(StandardFonts.Helvetica); - const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const font = await pdfDoc.embedFont(StandardFonts.TimesRoman); + const fontBold = await pdfDoc.embedFont(StandardFonts.TimesRomanBold); let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); let { width, height } = page.getSize(); @@ -417,13 +512,19 @@ export default function RekamMedisDetailPage() { const drawMeta = (meta: string) => { ensureSpace(18); - page.drawText(meta, { x: MARGIN, y: cursorY - 4, size: 9, font }); + page.drawText(meta, { + x: MARGIN, + y: cursorY - 4, + size: 9, + font, + color: TEXT_COLOR, + }); cursorY -= 18; }; const drawSectionTitle = (title: string) => { cursorY -= SECTION_SPACING; - const barH = 20; + const barH = 22; ensureSpace(barH + SECTION_SPACING); page.drawRectangle({ x: MARGIN - 2, @@ -433,11 +534,11 @@ export default function RekamMedisDetailPage() { color: BG_SECTION, }); page.drawText(title, { - x: MARGIN + 6, - y: cursorY - barH + 7, + x: MARGIN + 8, + y: cursorY - barH + 8, size: FONT_SIZE_SECTION, font: fontBold, - color: rgb(0.06, 0.3, 0.6), + color: COLOR_SECTION_TEXT, }); cursorY -= barH + 8; }; @@ -486,6 +587,7 @@ export default function RekamMedisDetailPage() { y: textY, size: FONT_SIZE_NORMAL, font: ci % 2 === 0 ? fontBold : font, + color: TEXT_COLOR, }); textY -= LINE_HEIGHT; } @@ -539,7 +641,6 @@ export default function RekamMedisDetailPage() { "No. Telepon", (visit as any).patient?.phone || "-", ], - ["Catatan", (visit as any).patient?.allergies || "-", "", ""], ]; const labelW = 90; @@ -554,6 +655,21 @@ export default function RekamMedisDetailPage() { drawTable(patientRows, patientColWidths); drawSectionTitle("Informasi Kunjungan Terkini"); + + // For PDF, parse visit.notes into treatmentPlan + notes + const rawNotes = (visit as any).notes || ""; + const { treatmentPlan: pdfTreatmentPlan, notes: pdfNotes } = unpackNotes(rawNotes); + + // fallback cleaner (hapus marker jika parsing gagal / format berbeda) + const cleanText = (txt?: string) => + String(txt || "") + .replace(/__POLABDC_STRUCTURED__/g, "") + .replace(/<<\/?TREATMENT>>/gi, "") + .replace(/<<\/?NOTES>>/gi, "") + .replace(/<<.*?>>/g, "") + .replace(/<\/<.*?>>/g, "") + .trim() || "-"; + drawTable( [ ["Tanggal Kunjungan", formatDate((visit as any).visitDate)], @@ -569,12 +685,15 @@ export default function RekamMedisDetailPage() { [ ["Keluhan Utama", (visit as any).chiefComplaint || "-"], ["Hasil Pemeriksaan Fisik", (visit as any).bloodPressure || "-"], - ["Rencana Perawatan", (visit as any).notes || "-"], + ["Rencana Perawatan", cleanText(pdfTreatmentPlan) || "-"], + ["Catatan Kunjungan", cleanText(pdfNotes) || "-"], ], [160, CONTENT_WIDTH - 160] ); + drawSectionTitle("Obat yang Diberikan"); + const pdfMeds: any[] = (visit as any).medications || []; if (!Array.isArray(pdfMeds) || pdfMeds.length === 0) { drawTable([["Obat", "Tidak ada obat yang diresepkan."]], [ @@ -602,7 +721,7 @@ export default function RekamMedisDetailPage() { color: rgb(0.85, 0.85, 0.85), }); - p.drawText(`© ${new Date().getFullYear()} RoxyDental`, { + p.drawText(`© ${new Date().getFullYear()} POLABDC - Dental Clinic`, { x: MARGIN, y: FOOTER_HEIGHT - 34, size: 9, @@ -715,11 +834,30 @@ export default function RekamMedisDetailPage() {
- - + + Informasi Pasien +

NO. REKAM MEDIS

@@ -780,59 +918,102 @@ export default function RekamMedisDetailPage() {

-
-

CATATAN

-

- {(visit as any).patient?.allergies || "-"} -

-
- +
- + Informasi Kunjungan Terkini {!editDiagnosis ? ( ) : (
+ {/* SIMPAN */} + + {/* BATAL */}
@@ -884,45 +1065,103 @@ export default function RekamMedisDetailPage() { - -
- + +
+ Detail Pemeriksaan {!editExam ? ( ) : (
+ {/* SIMPAN */} + + {/* BATAL */}
@@ -986,32 +1225,37 @@ export default function RekamMedisDetailPage() { /> ) : (

- {(visit as any).notes || "-"} + {/* Show parsed treatmentPlan */} + {unpackNotes((visit as any).notes || "").treatmentPlan || "-"}

)}
-
-
-

Catatan:

+
+
+

+ Catatan +

{!editNotes ? ( ) : (
+
- {editNotes ? ( + {/* ISI CATATAN */} + {!editNotes ? ( +

+ {unpackNotes((visit as any).notes || "").notes || ( + + Tidak ada catatan. + + )} +

+ ) : (