diff --git a/api-gateway/src/app.ts b/api-gateway/src/app.ts index 16147a7..c22a196 100644 --- a/api-gateway/src/app.ts +++ b/api-gateway/src/app.ts @@ -4,11 +4,12 @@ import healthRoutes from './routes/health'; import { patientAuthProxy } from './proxy/patient.auth.proxy'; import { patientDataProxy } from './proxy/patient.data.proxy'; import { staffAuthProxy } from './proxy/staff.auth.proxy'; -import staffDataRouter from './routes/staff.data.router'; +// import staffDataRouter from './routes/staff.data.router'; import { authenticate } from './middlewares/auth.middleware'; import { requirePatientSelf } from './middlewares/patient.guard'; import { createProxyMiddleware } from 'http-proxy-middleware'; - +import { requirePermission } from './middlewares/rbac.middleware'; +import { staffDataProxy } from './proxy/staff.data.proxy'; const app = express(); app.use( @@ -39,8 +40,38 @@ app.use( ); // STAFF auth app.use('/staff/public', staffAuthProxy); -app.use('/staff', authenticate, staffDataRouter); +const injectStaffHeaders = (req: any, _res: any, next: any) => { + if (req.user) { + if (req.user.sub) req.headers['x-user-id'] = String(req.user.sub); + if (req.user.role) req.headers['x-user-role'] = String(req.user.role); + if (req.user.type) req.headers['x-user-type'] = String(req.user.type); + } + next(); +}; + +app.use('/staff', authenticate, injectStaffHeaders, staffDataProxy); + +app.use('/admin', authenticate, injectStaffHeaders, staffDataProxy); + +app.use( + '/admissions', + authenticate, + (req: any, _res, next) => { + if (req.user?.sub) req.headers['x-user-id'] = String(req.user.sub); + + if (req.user?.role) req.headers['x-user-role'] = String(req.user.role); + + if (req.user?.type) req.headers['x-user-type'] = String(req.user.type); + + next(); + }, + createProxyMiddleware({ + target: process.env.PATIENT_SERVICE_URL, + changeOrigin: true, + pathRewrite: (path) => `/admissions${path}`, + }) +); //Appointment routes (accessible by PATIENT + STAFF + ADMIN) app.use( @@ -64,4 +95,20 @@ app.use( }) ); +app.use( + '/prescriptions', + authenticate, + (req: any, _res, next) => { + if (req.user?.sub) { + req.headers['x-user-id'] = String(req.user.sub); + } + next(); + }, + createProxyMiddleware({ + target: process.env.STAFF_SERVICE_URL, + changeOrigin: true, + pathRewrite: (path) => `/staff/pharmacy${path}`, + }) +); + export default app; diff --git a/api-gateway/src/middlewares/rbac.middleware.ts b/api-gateway/src/middlewares/rbac.middleware.ts index 9632051..e1235d9 100644 --- a/api-gateway/src/middlewares/rbac.middleware.ts +++ b/api-gateway/src/middlewares/rbac.middleware.ts @@ -1,13 +1,16 @@ import { Response, NextFunction } from 'express'; import { AuthenticatedRequest } from './auth.middleware'; -// what actions exist in the system -export type Permission = 'CREATE_STAFF' | 'UPDATE_STAFF' | 'DEACTIVATE_STAFF'; +export type Permission = + | 'CREATE_STAFF' + | 'UPDATE_STAFF' + | 'DEACTIVATE_STAFF' + | 'MANAGE_BEDS'; -// staff role → permissions const STAFF_ROLE_PERMISSIONS: Record = { DOCTOR: [], - NURSE: [], + NURSE: ['MANAGE_BEDS'], + RECEPTIONIST: ['MANAGE_BEDS'], }; export function requirePermission(permission: Permission) { @@ -18,12 +21,22 @@ export function requirePermission(permission: Permission) { return res.status(401).json({ error: 'Unauthorized' }); } - // ✅ ADMIN can do everything + // ADMIN can do everything if (user.type === 'ADMIN') { return next(); } - // ❌ STAFF cannot do admin actions - return res.status(403).json({ error: 'Forbidden' }); + // Only STAFF can have role permissions + if (user.type !== 'STAFF' || !user.role) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const rolePermissions = STAFF_ROLE_PERMISSIONS[user.role] ?? []; + + if (!rolePermissions.includes(permission)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + next(); }; } diff --git a/api-gateway/src/proxy/staff.data.proxy.ts b/api-gateway/src/proxy/staff.data.proxy.ts index abced43..87e56a5 100644 --- a/api-gateway/src/proxy/staff.data.proxy.ts +++ b/api-gateway/src/proxy/staff.data.proxy.ts @@ -3,7 +3,15 @@ import { createProxyMiddleware } from 'http-proxy-middleware'; export const staffDataProxy = createProxyMiddleware({ target: 'http://localhost:3002', changeOrigin: true, + pathRewrite: (path, req: any) => { + if (req.originalUrl.startsWith('/admin')) { + return `/admin${path}`; + } - // /staff/* → /staff/* - pathRewrite: (path) => `/staff${path}`, + if (req.originalUrl.startsWith('/staff')) { + return `/staff${path}`; + } + + return path; + }, }); diff --git a/api-gateway/src/routes/staff.data.router.ts b/api-gateway/src/routes/staff.data.router.ts index 14bd51b..cb1fe15 100644 --- a/api-gateway/src/routes/staff.data.router.ts +++ b/api-gateway/src/routes/staff.data.router.ts @@ -4,10 +4,13 @@ import { staffDataProxy } from '../proxy/staff.data.proxy'; const router = express.Router(); -// 🔒 ADMIN ONLY: create staff +// 🔒 ADMIN ONLY router.post('/', requirePermission('CREATE_STAFF'), staffDataProxy); -// everything else just passes through +// 🔒 BED MANAGEMENT +router.use('/beds', requirePermission('MANAGE_BEDS'), staffDataProxy); + +// everything else router.use(staffDataProxy); export default router; diff --git a/frontend/src/app/(patient)/dashboard/layout.tsx b/frontend/src/app/(patient)/dashboard/layout.tsx index 13f2679..16ab33c 100644 --- a/frontend/src/app/(patient)/dashboard/layout.tsx +++ b/frontend/src/app/(patient)/dashboard/layout.tsx @@ -29,8 +29,8 @@ const navItems = [ { label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard' }, { label: 'Appointments', icon: Calendar, path: '/dashboard/appointments' }, { label: 'Book Appointment', icon: PlusCircle, path: '/dashboard/book' }, - { label: 'Profile', icon: User, path: '/dashboard/profile' }, { label: 'Documents', icon: FileText, path: '/dashboard/documents' }, + { label: 'Profile', icon: User, path: '/dashboard/profile' }, ]; export default function PatientDashboardLayout({ @@ -48,11 +48,11 @@ export default function PatientDashboardLayout({ } }, [auth.isRestoring, auth.accessToken, router]); - if (auth.isRestoring) return
-
-
-
-
; + if (auth.isRestoring) return
+
+
+
+
; if (!auth.accessToken) return null; // Safely extract initials @@ -85,11 +85,10 @@ export default function PatientDashboardLayout({ diff --git a/frontend/src/app/(patient)/dashboard/page.tsx b/frontend/src/app/(patient)/dashboard/page.tsx index 708e969..9362739 100644 --- a/frontend/src/app/(patient)/dashboard/page.tsx +++ b/frontend/src/app/(patient)/dashboard/page.tsx @@ -11,6 +11,7 @@ import { getPatientDocuments } from '@/src/features/patient/api/getDocument'; import { getProfile } from '@/src/features/patient/api/getProfile'; import { Calendar, FileText, CalendarPlus, Upload, CloudCog } from 'lucide-react'; +import AdmissionStatus from '@/src/features/patient/components/AdmissionStatus'; export default function DashboardHome() { @@ -32,8 +33,8 @@ export default function DashboardHome() { const docs = await getPatientDocuments(); const profileData = await getProfile(); - console.log('profiledata',profileData); - + console.log('profiledata', profileData); + setProfile(profileData); setPatientName(profileData.name); @@ -105,6 +106,9 @@ export default function DashboardHome() { + {/* Admission Status */} + + {/* Quick Actions */}
diff --git a/frontend/src/app/admin/dashboard/page.tsx b/frontend/src/app/admin/dashboard/page.tsx index 072c01e..b5ad02d 100644 --- a/frontend/src/app/admin/dashboard/page.tsx +++ b/frontend/src/app/admin/dashboard/page.tsx @@ -1,17 +1,59 @@ import CreateStaffForm from "@/src/features/admin/components/CreateStaffForm"; - +import CreateWardForm from "@/src/features/admin/components/CreateWardForm"; +import CreateRoomForm from "@/src/features/admin/components/CreateRoomForm"; +import CreateBedForm from "@/src/features/admin/components/CreateBedForm"; export default function AdminDashboard() { return ( -
-

- Admin Dashboard -

+
+
{/* Narrower container for better readability */} + + {/* Page Header */} +
+

Admin Management

+

Add new staff members and hospital infrastructure

+
+ + {/* Form 1: Staff */} +
+
+

1. Staff Registration

+
+
+ +
+
+ + {/* Form 2: Wards */} +
+
+

2. Ward Management

+
+
+ +
+
+ + {/* Form 3: Rooms */} +
+
+

3. Room Assignment

+
+
+ +
+
-

Welcome Admin

+ {/* Form 4: Beds */} +
+
+

4. Bed Setup

+
+
+ +
+
-
-
); diff --git a/frontend/src/app/staff/dashboard/admissions/page.tsx b/frontend/src/app/staff/dashboard/admissions/page.tsx new file mode 100644 index 0000000..2c35a4f --- /dev/null +++ b/frontend/src/app/staff/dashboard/admissions/page.tsx @@ -0,0 +1,18 @@ +'use client' + +import AdmissionQueue from '@/src/features/admissions/components/AdmissionQueue' + +export default function StaffAdmissionsPage() { + + return ( +
+ +

+ Admissions +

+ + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/staff/dashboard/beds/page.tsx b/frontend/src/app/staff/dashboard/beds/page.tsx new file mode 100644 index 0000000..a00be7d --- /dev/null +++ b/frontend/src/app/staff/dashboard/beds/page.tsx @@ -0,0 +1,23 @@ +'use client' + +import BedsBoard from '@/src/features/beds/components/BedsBoard' +import CreateWardForm from '@/src/features/admin/components/CreateWardForm' +import CreateRoomForm from '@/src/features/admin/components/CreateRoomForm' +import CreateBedForm from '@/src/features/admin/components/CreateBedForm' + +export default function StaffBedsPage() { + + return ( + +
+ +

+ Bed Management +

+ + + +
+ + ) +} \ No newline at end of file diff --git a/frontend/src/app/staff/dashboard/discharge/page.tsx b/frontend/src/app/staff/dashboard/discharge/page.tsx new file mode 100644 index 0000000..8402610 --- /dev/null +++ b/frontend/src/app/staff/dashboard/discharge/page.tsx @@ -0,0 +1,14 @@ +import DischargeRequests from "@/src/features/admissions/components/DischargeRequests"; + +export default function DischargeRequestsPage(){ + return( +
+

+ Discharge Requests +

+
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/staff/dashboard/inventory/page.tsx b/frontend/src/app/staff/dashboard/inventory/page.tsx new file mode 100644 index 0000000..7a82348 --- /dev/null +++ b/frontend/src/app/staff/dashboard/inventory/page.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useState } from 'react' +import { Package, Plus, Search } from 'lucide-react' +import { useInventory } from '../../../../features/inventory/hooks/useInventory' +import { AddStockModal } from '../../../../features/inventory/components/AddStockModal' +import { useStaffAuth } from '../../../../staff/auth/staff.auth.provider' +import { useRouter } from 'next/navigation' + +export default function InventoryPage() { + const { auth } = useStaffAuth(); + const router = useRouter(); + + if (auth.staff?.role === 'DOCTOR') { + router.replace('/staff/dashboard'); + return null; + } + + const { data: items, isLoading } = useInventory(); + const [showAddModal, setShowAddModal] = useState(false); + const [search, setSearch] = useState(''); + + if (isLoading) return ( +
+
+
+ ); + + const filteredItems = items?.filter((item: any) => + item.name.toLowerCase().includes(search.toLowerCase()) || + item.category.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ {/* 🔴 Header */} +
+

+
+ +
+ Inventory Management +

+ + +
+ + {/* Search Bar */} +
+ + setSearch(e.target.value)} + /> +
+ + {/* Grid */} + {filteredItems?.length === 0 ? ( +
+ No inventory items found. Add stock to begin. +
+ ) : ( +
+ {filteredItems?.map((item: any) => ( +
+
+ + {item.category} + +

{item.name}

+
+
+
+

Stock

+

+ {item.quantity} +

+
+
+
+ ))} +
+ )} + + {/* Modal */} + {showAddModal && setShowAddModal(false)} items={items || []} />} +
+ ) +} diff --git a/frontend/src/app/staff/dashboard/layout.tsx b/frontend/src/app/staff/dashboard/layout.tsx index 59222f4..68eec89 100644 --- a/frontend/src/app/staff/dashboard/layout.tsx +++ b/frontend/src/app/staff/dashboard/layout.tsx @@ -4,6 +4,26 @@ import { ReactNode, useEffect } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useStaffAuth } from '../../../staff/auth/staff.auth.provider'; +import { motion } from 'framer-motion'; +import { + LayoutDashboard, + Calendar, + User, + LogOut, + Activity, + Settings, + Bed, + ClipboardPlus, + FileMinus, + Package, + Pill +} from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; export default function StaffLayout({ children, @@ -20,70 +40,105 @@ export default function StaffLayout({ } }, [auth.isRestoring, auth.accessToken, router]); - if (auth.isRestoring) return
Loading...
; + if (auth.isRestoring) { + return ( +
+
+
+
+
+ ); + } if (!auth.accessToken) return null; - function SidebarLink({ - href, - label, - active, -}: { - href: string; - label: string; - active: boolean; -}) { - return ( - - {label} - - ); -} - - return ( -
- + {/* Bottom Actions */} +
+ + + + + Settings + + + + + + + Sign out + +
+ -
- {children} -
-
+
+ {children} +
+
+ ); } \ No newline at end of file diff --git a/frontend/src/app/staff/dashboard/page.tsx b/frontend/src/app/staff/dashboard/page.tsx index baa1db5..2871518 100644 --- a/frontend/src/app/staff/dashboard/page.tsx +++ b/frontend/src/app/staff/dashboard/page.tsx @@ -3,6 +3,8 @@ import { useStaffAuth } from '../../../staff/auth/staff.auth.provider'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; +import Link from 'next/link'; +import { LayoutDashboard, Calendar, FileText, User, Activity, ClipboardPlus, Bed } from 'lucide-react'; export default function StaffDashboardPage() { const { auth, logout } = useStaffAuth(); @@ -14,40 +16,124 @@ export default function StaffDashboardPage() { } }, [auth.accessToken, auth.isRestoring, router]); - if (auth.isRestoring) { - return ( -
-
+ if (auth.isRestoring) return ( +
+
+
- ); - } +
+ ); if (!auth.accessToken) return null; + const staff = auth.staff; + const isDoctor = staff?.role === 'DOCTOR'; + return ( -
- - {/* 🔥 LOGOUT BUTTON */} -
- +
+ + {/* Header */} +
+
+
+ {staff?.name?.charAt(0) || 'S'} +
+ +
+

+ Welcome back{staff?.name ? `, ${staff?.name}` : ''} +

+

+ {isDoctor ? "Manage your patients and queue for the day." : "Oversee hospital operations and admission requests."} +

+
+
+
+ + {/* Stats Cards */} +
+ +
+
+ +
+
+

Role

+

{staff?.role}

+
+
+ +
+
+ +
+
+

Department

+

{staff?.department}

+
+
+ +
+
+ +
+
+

Job Title

+

{staff?.job_title}

+
+
+
-

- Staff Dashboard -

+ {/* Quick Actions */} +
+

Quick Actions

-
-

Name: {auth.staff?.name}

-

Email: {auth.staff?.email}

-

Department: {auth.staff?.department}

-

Role: {auth.staff?.role}

-

Job Title: {auth.staff?.job_title}

+ {isDoctor ? ( +
+ +
+ +
+
+

Today's Queue

+

View your queued patients

+
+ + +
+ +
+
+

My Patients

+

View admitted patients

+
+ +
+ ) : ( +
+ +
+ +
+
+

Admission Requests

+

Acknowledge pending requests

+
+ + +
+ +
+
+

Bed Management

+

Manage ward allocations

+
+ +
+ )}
+
); } diff --git a/frontend/src/app/staff/dashboard/patients/page.tsx b/frontend/src/app/staff/dashboard/patients/page.tsx new file mode 100644 index 0000000..1e4ffe2 --- /dev/null +++ b/frontend/src/app/staff/dashboard/patients/page.tsx @@ -0,0 +1,16 @@ +import DoctorPatients from "@/src/features/admissions/components/DoctorPatients" + +export default function DoctorPatientsPage(){ + return ( +
+

+ My Admitted Patients +

+ +
+ ) +} + + + + diff --git a/frontend/src/app/staff/dashboard/pharmacy/page.tsx b/frontend/src/app/staff/dashboard/pharmacy/page.tsx new file mode 100644 index 0000000..3055a8e --- /dev/null +++ b/frontend/src/app/staff/dashboard/pharmacy/page.tsx @@ -0,0 +1,99 @@ +'use client' + +import { Pill, CheckCircle } from 'lucide-react' +import { usePendingPrescriptions } from '../../../../features/pharmacy/hooks/usePharmacy' +import { useStaffAuth } from '../../../../staff/auth/staff.auth.provider' +import { useDispensePrescription } from '../../../../features/pharmacy/hooks/usePharmacyActions' +import { useRouter } from 'next/navigation' + +export default function PharmacyPage() { + const { auth } = useStaffAuth(); + const router = useRouter(); + + if (auth.staff?.role === 'DOCTOR') { + router.replace('/staff/dashboard'); + return null; + } + + const { data: prescriptions, isLoading } = usePendingPrescriptions(); + const dispenseMutation = useDispensePrescription(); + + if (isLoading) return ( +
+
+
+ ); + + return ( +
+
+

+
+ +
+ Pharmacy & Dispensing +

+
+ + {prescriptions?.length === 0 ? ( +
+ No pending prescriptions right now. You're all caught up! +
+ ) : ( +
+ {prescriptions?.map((prescription: any) => { + const canDispense = prescription.items.every((item: any) => item.stock_available >= item.quantity); + + return ( +
+
+
+
+

Patient

+

{prescription.patient_name || 'Anonymous Patient'}

+

+ Prescribed by: Dr. {prescription.doctor_name} +

+
+ + {prescription.status} + +
+ +
+

Prescribed Medicines

+ {prescription.items.map((item: any) => ( +
+
+

{item.item_name}

+ {item.instructions &&

{item.instructions}

} +

+ Stock: {item.stock_available} +

+
+
+ x{item.quantity} +
+
+ ))} +
+
+ +
+ +
+
+ ); + })} +
+ )} +
+ ) +} diff --git a/frontend/src/app/staff/dashboard/queue/page.tsx b/frontend/src/app/staff/dashboard/queue/page.tsx index eda0b13..2996d4a 100644 --- a/frontend/src/app/staff/dashboard/queue/page.tsx +++ b/frontend/src/app/staff/dashboard/queue/page.tsx @@ -9,26 +9,104 @@ import { useCheckInAppointment, } from '../../../../features/appointments/hooks/useStaffActions'; import { useEmergency } from '../../../../features/appointments/hooks/useEmergency'; +import { useRequestAdmission } from '../../../../features/admissions/hooks/useRequestAdmission'; +import { Activity, Clock, Users, Timer, CheckCircle, AlertCircle } from 'lucide-react'; +import { PrescribeModal } from '../../../../features/pharmacy/components/PrescribeModal'; +import { useDepartments, useDoctorsByDepartment } from '../../../../features/staff/hooks/useStaffData'; export default function QueuePage() { const { auth } = useStaffAuth(); - const doctorId = auth.staff?.id; + const isDoctor = auth.staff?.role === 'DOCTOR'; + const [selectedDeptId, setSelectedDeptId] = useState(''); + const [selectedDoctorId, setSelectedDoctorId] = useState(isDoctor ? auth.staff?.id : ''); const [showEmergency, setShowEmergency] = useState(false); const [patientId, setPatientId] = useState(''); + const [prescribePatient, setPrescribePatient] = useState<{ id: string, name: string } | null>(null); - if (auth.isRestoring) return

Loading authentication...

; - if (!doctorId) return

Doctor information not available.

; + const { data: departments } = useDepartments(); + const { data: doctorsInDept, isLoading: isLoadingDoctors } = useDoctorsByDepartment(selectedDeptId); - const { data, isLoading } = useDoctorQueue(doctorId); + const statuses = isDoctor ? ['CHECKED_IN', 'IN_PROGRESS'] : ['SCHEDULED', 'CHECKED_IN', 'IN_PROGRESS']; + const { data, isLoading } = useDoctorQueue(selectedDoctorId, statuses); const startMutation = useStartAppointment(); const completeMutation = useCompleteAppointment(); const checkInMutation = useCheckInAppointment(); - const emergencyMutation = useEmergency(doctorId); + const emergencyMutation = useEmergency(selectedDoctorId || ''); + const admitMutation = useRequestAdmission(); - if (isLoading) return

Loading queue...

; - if (!data) return

No queue found.

; + if (auth.isRestoring) return ( +
+
+
+ ); + + if (!isDoctor && !selectedDoctorId) { + return ( +
+
+
+ +

Staff Check-in Panel

+

Select a department and doctor to manage their queue.

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+ ); + } + + const doctorId = selectedDoctorId; + + if (isLoading) return ( +
+
+
+ ); + + if (!data) return ( +
+ No queue found for this doctor. + {!isDoctor && ( + + )} +
+ ); const { queue, doctor_status } = data; const someoneInProgress = queue.some((q: any) => q.status === 'IN_PROGRESS'); @@ -36,102 +114,210 @@ export default function QueuePage() { return (
- {/* 🔴 Emergency Button */} -
-

Today's Queue

+ {/* 🔴 Header */} +
+
+

+
+ +
+ {isDoctor ? "Today's Queue" : "Staff Check-in"} +

+ {!isDoctor && ( + + )} +
{/* Doctor Status Panel */} -
-

Total: {doctor_status.total_appointments}

-

Checked In: {doctor_status.checked_in_count}

-

Delay: {doctor_status.doctor_delay_minutes} min

-

Remaining: {doctor_status.remaining_queue_minutes} min

+
+ +
+
+
+

Total Appointments

+

{doctor_status.total_appointments}

+
+
+ +
+
+
+

Checked In

+

{doctor_status.checked_in_count}

+
+
+ +
+
+
+

Current Delay

+

{doctor_status.doctor_delay_minutes} min

+
+
+ +
+
+
+

Remaining Time

+

{doctor_status.remaining_queue_minutes} min

+
+
+
{/* Queue List */} -
- {queue.map((item: any) => ( +
+ {queue.length === 0 ? ( +
+ {isDoctor ? "No patients ready for consultation." : "No upcoming appointments for this doctor."} +
+ ) : queue.map((item: any) => (
-

- Patient ID: {item.patient_id} -

-

Status: {item.status}

-

Position: {item.position}

-
+
+
+ {item.patient_name?.charAt(0) || '?'} +
+
+

+ {item.patient_name} ({item.patient_id.split('-')[0]}...) +

+
+ + {item.status.replace('_', ' ')} + + + Pos: {item.position} + +
+
+
+ +
- {item.status === 'SCHEDULED' && ( + {/* Check In - STAFF ONLY */} + {!isDoctor && item.status === 'SCHEDULED' && ( )} - {item.status === 'CHECKED_IN' && !someoneInProgress && ( + {/* Start Consultation - DOCTOR ONLY */} + {isDoctor && item.status === 'CHECKED_IN' && !someoneInProgress && ( )} - {item.status === 'IN_PROGRESS' && ( - + {/* Complete Consultation - DOCTOR ONLY */} + {isDoctor && item.status === 'IN_PROGRESS' && ( + <> + + + + + {/* 🏥 Admit Patient */} + {item.admission_requested ? ( + + Request Sent + + ) : ( + + )} + )}
+
))}
{/* 🔴 Emergency Modal */} {showEmergency && ( -
- -
- -

- Create Emergency Case -

+
+
- setPatientId(e.target.value)} - className="w-full border rounded px-3 py-2" - /> +
+
+ +
+

+ Create Emergency Case +

+
-
+
+ + setPatientId(e.target.value)} + className="w-full border border-slate-300 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition" + /> +
+
@@ -142,17 +328,24 @@ export default function QueuePage() { setShowEmergency(false); setPatientId(''); }} - className="px-4 py-2 bg-red-600 text-white rounded" + className="px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl font-medium shadow-md transition" > - Confirm Emergency + Confirm Case -
-
)} + {/* 🔴 Prescribe Modal */} + {prescribePatient && ( + setPrescribePatient(null)} + /> + )} +
); } \ No newline at end of file diff --git a/frontend/src/features/admin/api/beds.api.ts b/frontend/src/features/admin/api/beds.api.ts new file mode 100644 index 0000000..4743c1f --- /dev/null +++ b/frontend/src/features/admin/api/beds.api.ts @@ -0,0 +1,30 @@ +import { api } from '@/src/lib/api'; + +export async function createWard(data: { name: string; description?: string }) { + const res = await api.post('/admin/beds/wards', data); + return res.data; +} + +export async function createRoom(data: { wardId: string; roomNumber: string }) { + const res = await api.post('/admin/beds/rooms', data); + return res.data; +} + +export async function createBed(data: { roomId: string; bedNumber: string }) { + const res = await api.post('/admin/beds/beds', data); + return res.data; +} + +export async function getBeds() { + const res = await api.get('/staff/beds'); + return res.data; +} +export async function getWards() { + const res = await api.get('/admin/beds/wards'); + return res.data; +} + +export async function getRooms(wardId: string) { + const res = await api.get(`/admin/beds/rooms/${wardId}`); + return res.data; +} diff --git a/frontend/src/features/admin/components/CreateBedForm.tsx b/frontend/src/features/admin/components/CreateBedForm.tsx new file mode 100644 index 0000000..3c85a1b --- /dev/null +++ b/frontend/src/features/admin/components/CreateBedForm.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useState } from 'react' +import { useCreateBed } from '../hooks/useCreateBed' +import { useQuery } from '@tanstack/react-query' +import { getWards, getRooms } from '../api/beds.api' + +export default function CreateBedForm() { + + const [wardId, setWardId] = useState('') + const [roomId, setRoomId] = useState('') + const [bedNumber, setBedNumber] = useState('') + + const mutation = useCreateBed() + + const { data: wards } = useQuery({ + queryKey: ['wards'], + queryFn: getWards + }) + + const { data: rooms } = useQuery({ + queryKey: ['rooms', wardId], + queryFn: () => getRooms(wardId), + enabled: !!wardId + }) + + const handleSubmit = async () => { + + if (!roomId || !bedNumber) return + + try { + await mutation.mutateAsync({ + roomId, + bedNumber + }) + + alert('✅ Bed created successfully') + + setBedNumber('') + setRoomId('') + setWardId('') + + } catch { + alert('❌ Failed to create bed') + } + } + + return ( +
+ +

Create Bed

+ + {/* Ward dropdown */} + + + {/* Room dropdown */} + + + setBedNumber(e.target.value)} + placeholder="Bed Number" + className="border p-2 rounded w-full" + /> + + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/features/admin/components/CreateRoomForm.tsx b/frontend/src/features/admin/components/CreateRoomForm.tsx new file mode 100644 index 0000000..88f069a --- /dev/null +++ b/frontend/src/features/admin/components/CreateRoomForm.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useState } from 'react' +import { useCreateRoom } from '../hooks/useCreateRoom' +import { useQuery } from '@tanstack/react-query' +import { getWards } from '../api/beds.api' + +export default function CreateRoomForm() { + + const [wardId, setWardId] = useState('') + const [roomNumber, setRoomNumber] = useState('') + + const mutation = useCreateRoom() + + const { data: wards } = useQuery({ + queryKey: ['wards'], + queryFn: getWards + }) + + const handleSubmit = async () => { + + if (!wardId || !roomNumber) return + + try { + await mutation.mutateAsync({ + wardId, + roomNumber + }) + + alert('✅ Room created successfully') + + setRoomNumber('') + setWardId('') + + } catch { + alert('❌ Failed to create room') + } + } + + return ( +
+ +

Create Room

+ + + + setRoomNumber(e.target.value)} + placeholder="Room Number" + className="border p-2 rounded w-full" + /> + + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/features/admin/components/CreateWardForm.tsx b/frontend/src/features/admin/components/CreateWardForm.tsx new file mode 100644 index 0000000..e3c07a9 --- /dev/null +++ b/frontend/src/features/admin/components/CreateWardForm.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useState } from 'react' +import { useCreateWard } from '../hooks/useCreateWard' + +export default function CreateWardForm() { + + const [name,setName] = useState('') + const mutation = useCreateWard() + + const handleSubmit = () => { + mutation.mutate({ name }) + setName('') + } + + return ( + +
+ + setName(e.target.value)} + placeholder="Ward name" + className="border p-2 rounded" + /> + + + +
+ + ) +} \ No newline at end of file diff --git a/frontend/src/features/admin/hooks/useCreateBed.ts b/frontend/src/features/admin/hooks/useCreateBed.ts new file mode 100644 index 0000000..109d58f --- /dev/null +++ b/frontend/src/features/admin/hooks/useCreateBed.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query'; +import { createBed } from '../api/beds.api'; + +export function useCreateBed() { + return useMutation({ + mutationFn: createBed, + }); +} diff --git a/frontend/src/features/admin/hooks/useCreateRoom.ts b/frontend/src/features/admin/hooks/useCreateRoom.ts new file mode 100644 index 0000000..d9a160a --- /dev/null +++ b/frontend/src/features/admin/hooks/useCreateRoom.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query'; +import { createRoom } from '../api/beds.api'; + +export function useCreateRoom() { + return useMutation({ + mutationFn: createRoom, + }); +} diff --git a/frontend/src/features/admin/hooks/useCreateWard.ts b/frontend/src/features/admin/hooks/useCreateWard.ts new file mode 100644 index 0000000..dca165b --- /dev/null +++ b/frontend/src/features/admin/hooks/useCreateWard.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createWard } from '../api/beds.api'; + +export function useCreateWard() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createWard, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['beds'] }); + }, + }); +} diff --git a/frontend/src/features/admissions/api/admissions.api.ts b/frontend/src/features/admissions/api/admissions.api.ts new file mode 100644 index 0000000..eb39d53 --- /dev/null +++ b/frontend/src/features/admissions/api/admissions.api.ts @@ -0,0 +1,11 @@ +import { api } from '@/src/lib/api'; + +export async function getPendingAdmissions() { + const res = await api.get('/admissions/pending'); + return res.data; +} + +export async function requestAdmission(data: any) { + const res = await api.post('/admissions', data); + return res.data; +} diff --git a/frontend/src/features/admissions/api/assignBed.api.ts b/frontend/src/features/admissions/api/assignBed.api.ts new file mode 100644 index 0000000..9ba0ef9 --- /dev/null +++ b/frontend/src/features/admissions/api/assignBed.api.ts @@ -0,0 +1,12 @@ +import { api } from '@/src/lib/api'; +import { StepId } from 'framer-motion'; + +export async function assignBed(data: { + bedId: string; + patientId: string; + admissionId: string; +}) { + const res = await api.post('/staff/beds/assign', data); + + return res.data; +} diff --git a/frontend/src/features/admissions/api/dischargePatients.api.ts b/frontend/src/features/admissions/api/dischargePatients.api.ts new file mode 100644 index 0000000..4d4ac19 --- /dev/null +++ b/frontend/src/features/admissions/api/dischargePatients.api.ts @@ -0,0 +1,9 @@ +import { api } from '@/src/lib/api'; + +export async function dischargePatient(data: { + bedId: string; + admissionId: string; +}) { + const res = await api.post('/staff/beds/discharge', data); + return res.data; +} diff --git a/frontend/src/features/admissions/api/getDischargeRequests.api.ts b/frontend/src/features/admissions/api/getDischargeRequests.api.ts new file mode 100644 index 0000000..98fb62e --- /dev/null +++ b/frontend/src/features/admissions/api/getDischargeRequests.api.ts @@ -0,0 +1,7 @@ +import { api } from '@/src/lib/api'; + +export async function getDischargeRequests() { + const res = await api.get('/admissions/discharge-requests'); + + return res.data; +} diff --git a/frontend/src/features/admissions/api/getDoctorAdmissions.api.ts b/frontend/src/features/admissions/api/getDoctorAdmissions.api.ts new file mode 100644 index 0000000..2a4cca5 --- /dev/null +++ b/frontend/src/features/admissions/api/getDoctorAdmissions.api.ts @@ -0,0 +1,7 @@ +import { api } from '@/src/lib/api'; + +export async function getDoctorAdmissions() { + const res = await api.get('/admissions/doctor'); + + return res.data; +} diff --git a/frontend/src/features/admissions/api/requestAdmission.api.ts b/frontend/src/features/admissions/api/requestAdmission.api.ts new file mode 100644 index 0000000..a360bf2 --- /dev/null +++ b/frontend/src/features/admissions/api/requestAdmission.api.ts @@ -0,0 +1,11 @@ +import { api } from '@/src/lib/api'; + +export async function requestAdmission(data: { + patientId: string; + doctorId: string; + departmentId: string; +}) { + const res = await api.post('/admissions/request', data); + + return res.data; +} diff --git a/frontend/src/features/admissions/api/requestDischarge.api.ts b/frontend/src/features/admissions/api/requestDischarge.api.ts new file mode 100644 index 0000000..72cf774 --- /dev/null +++ b/frontend/src/features/admissions/api/requestDischarge.api.ts @@ -0,0 +1,7 @@ +import { api } from '@/src/lib/api'; + +export async function requestDischarge(admissionId: string) { + const res = await api.post(`/admissions/${admissionId}/request-discharge`); + + return res.data; +} diff --git a/frontend/src/features/admissions/components/AdmissionQueue.tsx b/frontend/src/features/admissions/components/AdmissionQueue.tsx new file mode 100644 index 0000000..74de636 --- /dev/null +++ b/frontend/src/features/admissions/components/AdmissionQueue.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useState } from 'react' +import { useAdmissions } from '../hooks/useAdmissions' +import AssignBedModal from './AssignBedModal' +import { UserPlus, Stethoscope, Building2 } from 'lucide-react' + +export default function AdmissionQueue() { + const { data, isLoading } = useAdmissions() + const [selectedAdmission, setSelectedAdmission] = useState(null) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!data?.length) { + return ( +
+ No pending admissions +
+ ) + } + + return ( + <> +
+ {data.map((admission: any) => ( +
+
+
+
+ +
+
+
+ {admission.patient_name} +
+
+ ID: {admission.patient_id.split('-')[0]}... +
+
+
+ +
+
+ +
+ Dr. {admission.doctor_name} + {admission.doctor_id && ({admission.doctor_id.split('-')[0]}...)} +
+
+ +
+ + {admission.department_name} +
+
+
+ +
+ +
+
+ ))} +
+ + {selectedAdmission && ( + setSelectedAdmission(null)} + /> + )} + + ) +} \ No newline at end of file diff --git a/frontend/src/features/admissions/components/AssignBedModal.tsx b/frontend/src/features/admissions/components/AssignBedModal.tsx new file mode 100644 index 0000000..268e2ef --- /dev/null +++ b/frontend/src/features/admissions/components/AssignBedModal.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useState } from 'react' +import { useAssignBed } from '../hooks/useAssignBed' +import { useWards } from '../hooks/useWards' +import { useRooms } from '../hooks/useRooms' +import { useBeds } from '../hooks/useBeds' + +export default function AssignBedModal({ admission, onClose }: any) { + + const [wardId, setWardId] = useState('') + const [roomId, setRoomId] = useState('') + const [bedId, setBedId] = useState('') + + const { data: wards = [] } = useWards() + const { data: rooms = [] } = useRooms(wardId) + const { data: beds = [] } = useBeds(roomId) + + const assignMutation = useAssignBed() + + const handleAssign = () => { + + assignMutation.mutate({ + admissionId: admission.id, + patientId: admission.patient_id, + bedId + },{ + onSuccess(){ + onClose() + } + }) + + } + + return ( + +
+ +
+ +

+ Assign Bed +

+ + {/* Ward */} + + + + {/* Room */} + + + + {/* Bed */} + + + +
+ + + + + +
+ +
+ +
+ ) +} \ No newline at end of file diff --git a/frontend/src/features/admissions/components/DischargeRequests.tsx b/frontend/src/features/admissions/components/DischargeRequests.tsx new file mode 100644 index 0000000..9be9ca4 --- /dev/null +++ b/frontend/src/features/admissions/components/DischargeRequests.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useDischargeRequests } from '../hooks/useDischargeRequests' +import { useDischargePatient } from '../hooks/useDischargePatient' +import { LogOut, Building, BedDouble } from 'lucide-react' + +export default function DischargeRequests() { + const { data, isLoading } = useDischargeRequests() + const dischargeMutation = useDischargePatient() + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!data?.length) { + return ( +
+ No discharge requests +
+ ) + } + + return ( +
+ {data.map((req: any) => ( +
+
+
+
+ +
+
+
+ {req.patient_name} +
+
+ ID: {req.patient_id.split('-')[0]}... +
+
+
+ +
+
+ + {req.ward} +
+ +
+ + Bed {req.bed_number} +
+
+
+ +
+ +
+
+ ))} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/features/admissions/components/DoctorPatients.tsx b/frontend/src/features/admissions/components/DoctorPatients.tsx new file mode 100644 index 0000000..83be06b --- /dev/null +++ b/frontend/src/features/admissions/components/DoctorPatients.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useDoctorAdmissions } from '../hooks/useDoctorAdmissions' +import { useRequestDischarge } from '../hooks/useRequestDischarge' +import { User, Building, BedDouble, Plus } from 'lucide-react' +import { UseItemModal } from '../../inventory/components/UseItemModal' +import { useState } from 'react' + +export default function DoctorPatients() { + const { data, isLoading } = useDoctorAdmissions() + const dischargeMutation = useRequestDischarge() + const [useItemPatientId, setUseItemPatientId] = useState(null) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!data?.length) { + return ( +
+ No admitted patients +
+ ) + } + + return ( +
+ {data.map((admission: any) => ( +
+
+
+
+
+ +
+
+
+ {admission.patient_name} +
+
+ ID: {admission.patient_id.split('-')[0]}... +
+
+
+
+ +
+
+ + {admission.ward} +
+ +
+ + Bed {admission.bed_number} +
+
+
+ +
+ + {admission.discharge_requested ? ( + + Request Sent + + ) : ( + + )} +
+
+ ))} + + {useItemPatientId && ( + setUseItemPatientId(null)} + patientId={useItemPatientId} + /> + )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/features/admissions/components/StaffQueueCard.tsx b/frontend/src/features/admissions/components/StaffQueueCard.tsx new file mode 100644 index 0000000..bc55cfb --- /dev/null +++ b/frontend/src/features/admissions/components/StaffQueueCard.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useRequestAdmission } from '@/src/features/admissions/hooks/useRequestAdmission' + +export default function StaffQueueCard({ appointment }: any) { + + const requestAdmissionMutation = useRequestAdmission() + + const handleAdmit = () => { + requestAdmissionMutation.mutate({ + patientId: appointment.patient_id, + doctorId: appointment.doctor_id, + departmentId: appointment.department_id + }) + } + + return ( + +
+ +
+
+ Patient: {appointment.patient_name} +
+ +
+ Time: {appointment.appointment_time} +
+
+ + + +
+ + ) +} \ No newline at end of file diff --git a/frontend/src/features/admissions/hooks/useAdmissions.ts b/frontend/src/features/admissions/hooks/useAdmissions.ts new file mode 100644 index 0000000..05117ac --- /dev/null +++ b/frontend/src/features/admissions/hooks/useAdmissions.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPendingAdmissions } from '../api/admissions.api'; + +export function useAdmissions() { + return useQuery({ + queryKey: ['admissions'], + queryFn: getPendingAdmissions, + }); +} diff --git a/frontend/src/features/admissions/hooks/useAssignBed.ts b/frontend/src/features/admissions/hooks/useAssignBed.ts new file mode 100644 index 0000000..7b4e313 --- /dev/null +++ b/frontend/src/features/admissions/hooks/useAssignBed.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { assignBed } from '../api/assignBed.api'; + +export const useAssignBed = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: assignBed, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['admissions'], + }); + }, + }); +}; diff --git a/frontend/src/features/admissions/hooks/useBeds.ts b/frontend/src/features/admissions/hooks/useBeds.ts new file mode 100644 index 0000000..d6e990b --- /dev/null +++ b/frontend/src/features/admissions/hooks/useBeds.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/src/lib/api'; + +export const useBeds = (roomId: string) => { + return useQuery({ + queryKey: ['beds', roomId], + enabled: !!roomId, + queryFn: async () => { + const res = await api.get(`/staff/beds/beds/${roomId}`); + return res.data; + }, + }); +}; diff --git a/frontend/src/features/admissions/hooks/useDischargePatient.ts b/frontend/src/features/admissions/hooks/useDischargePatient.ts new file mode 100644 index 0000000..390d334 --- /dev/null +++ b/frontend/src/features/admissions/hooks/useDischargePatient.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { dischargePatient } from '../api/dischargePatients.api'; + +export function useDischargePatient() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: dischargePatient, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['discharge-requests'], + }); + + queryClient.invalidateQueries({ + queryKey: ['beds'], + }); + }, + }); +} diff --git a/frontend/src/features/admissions/hooks/useDischargeRequests.ts b/frontend/src/features/admissions/hooks/useDischargeRequests.ts new file mode 100644 index 0000000..6f09312 --- /dev/null +++ b/frontend/src/features/admissions/hooks/useDischargeRequests.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDischargeRequests } from '../api/getDischargeRequests.api'; + +export function useDischargeRequests() { + return useQuery({ + queryKey: ['discharge-requests'], + queryFn: getDischargeRequests, + }); +} diff --git a/frontend/src/features/admissions/hooks/useDoctorAdmissions.ts b/frontend/src/features/admissions/hooks/useDoctorAdmissions.ts new file mode 100644 index 0000000..108daa5 --- /dev/null +++ b/frontend/src/features/admissions/hooks/useDoctorAdmissions.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDoctorAdmissions } from '../api/getDoctorAdmissions.api'; + +export const useDoctorAdmissions = () => { + return useQuery({ + queryKey: ['doctor-admissions'], + queryFn: getDoctorAdmissions, + }); +}; diff --git a/frontend/src/features/admissions/hooks/useRequestAdmission.ts b/frontend/src/features/admissions/hooks/useRequestAdmission.ts new file mode 100644 index 0000000..e0b20dd --- /dev/null +++ b/frontend/src/features/admissions/hooks/useRequestAdmission.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { requestAdmission } from '../api/requestAdmission.api'; + +export function useRequestAdmission() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: requestAdmission, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['doctorQueue'] }); + }, + }); +} diff --git a/frontend/src/features/admissions/hooks/useRequestDischarge.ts b/frontend/src/features/admissions/hooks/useRequestDischarge.ts new file mode 100644 index 0000000..e05b63a --- /dev/null +++ b/frontend/src/features/admissions/hooks/useRequestDischarge.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { requestDischarge } from '../api/requestDischarge.api'; + +export const useRequestDischarge = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: requestDischarge, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['doctorAdmissions'] }); + }, + }); +}; diff --git a/frontend/src/features/admissions/hooks/useRooms.ts b/frontend/src/features/admissions/hooks/useRooms.ts new file mode 100644 index 0000000..c1f50ce --- /dev/null +++ b/frontend/src/features/admissions/hooks/useRooms.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/src/lib/api'; + +export const useRooms = (wardId: string) => { + return useQuery({ + queryKey: ['rooms', wardId], + enabled: !!wardId, + queryFn: async () => { + const res = await api.get(`/staff/beds/rooms/${wardId}`); + return res.data; + }, + }); +}; diff --git a/frontend/src/features/admissions/hooks/useWards.ts b/frontend/src/features/admissions/hooks/useWards.ts new file mode 100644 index 0000000..5cf5774 --- /dev/null +++ b/frontend/src/features/admissions/hooks/useWards.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/src/lib/api'; + +export const useWards = () => { + return useQuery({ + queryKey: ['wards'], + queryFn: async () => { + const res = await api.get('/staff/beds/wards'); + return res.data; + }, + }); +}; diff --git a/frontend/src/features/appointments/api/getDoctorQueue.ts b/frontend/src/features/appointments/api/getDoctorQueue.ts index 35abedd..08bde9c 100644 --- a/frontend/src/features/appointments/api/getDoctorQueue.ts +++ b/frontend/src/features/appointments/api/getDoctorQueue.ts @@ -1,6 +1,9 @@ import { api } from '@/src/lib/api'; -export async function getDoctorQueue(doctorId: string) { - const { data } = await api.get(`/appointments/doctor/today/${doctorId}`); +export async function getDoctorQueue(doctorId: string, statuses?: string[]) { + const params = statuses ? { statuses: statuses.join(',') } : {}; + const { data } = await api.get(`/appointments/doctor/today/${doctorId}`, { + params, + }); return data; } diff --git a/frontend/src/features/appointments/components/AppointmentCard.tsx b/frontend/src/features/appointments/components/AppointmentCard.tsx index 8474153..29056c4 100644 --- a/frontend/src/features/appointments/components/AppointmentCard.tsx +++ b/frontend/src/features/appointments/components/AppointmentCard.tsx @@ -1,9 +1,12 @@ 'use client'; import { useState } from 'react'; -import { ChevronDown, ChevronUp } from 'lucide-react'; +import { ChevronDown, ChevronUp, MapPin, Bed, User, Clock, Pill } from 'lucide-react'; import { useCancelAppointment } from '../hooks/useCancelAppointment'; import RescheduleModal from './RescheduleModal'; +import { getCurrentAdmission } from '../../patient/api/getCurrentAdmission'; +import { getPrescriptions } from '../../patient/api/getPrescriptions'; +import { useEffect } from 'react'; interface Props { appointment: any; @@ -24,6 +27,32 @@ export default function AppointmentCard({ appointment }: Props) { const [expanded, setExpanded] = useState(false); const [showReschedule, setShowReschedule] = useState(false); + const [admission, setAdmission] = useState(null); + const [prescriptions, setPrescriptions] = useState([]); + const [isLoadingExtra, setIsLoadingExtra] = useState(false); + + useEffect(() => { + if (expanded && !admission && prescriptions.length === 0) { + const loadData = async () => { + setIsLoadingExtra(true); + try { + const [adm, pres] = await Promise.all([ + getCurrentAdmission(), + getPrescriptions() + ]); + setAdmission(adm); + // Show all prescriptions for now, or filter by doctor if needed + setPrescriptions(pres || []); + } catch (err) { + console.error("Failed to load extra appointment data", err); + } finally { + setIsLoadingExtra(false); + } + }; + loadData(); + } + }, [expanded]); + const appointmentDate = new Date(appointment.appointment_time); const now = new Date(); @@ -81,47 +110,107 @@ export default function AppointmentCard({ appointment }: Props) {
{/* Status */} -
- - {appointment.status.replace("_", " ")} - -
+
+ + {appointment.status.replace("_", " ")} + +
{/* Expanded section */} {expanded && ( -
- - {appointment.status === 'SCHEDULED' && !isPast && ( - +
+ + {isLoadingExtra ? ( +
+
+
+ ) : ( + <> + {/* Admission Status */} +
+

+ + Admission Status +

+ {admission ? ( +
+
+
+

Ward

+

{admission.ward}

+
+
+

Bed

+

{admission.bed_number}

+
+
+
+ ) : ( +

Not currently admitted

+ )} +
+ + {/* Prescriptions */} +
+

+ + Prescribed Medicines +

+ {prescriptions.length > 0 ? ( +
+ {prescriptions.map((pres: any) => ( +
+

Prescription from Dr. {pres.doctor_name}

+
    + {pres.items.map((item: any) => ( +
  • + {item.item_name} × {item.quantity} + {item.instructions} +
  • + ))} +
+
+ ))} +
+ ) : ( +

No medicines prescribed recently

+ )} +
+ )} - {canReschedule && ( - - )} - - {appointment.status === 'CANCELLED' && ( - - Appointment Cancelled - - )} +
+ {appointment.status === 'SCHEDULED' && !isPast && ( + + )} + + {canReschedule && ( + + )} + + {appointment.status === 'CANCELLED' && ( + + Appointment Cancelled + + )} +
diff --git a/frontend/src/features/appointments/hooks/useDoctorQueue.ts b/frontend/src/features/appointments/hooks/useDoctorQueue.ts index cbae61c..cf80d7e 100644 --- a/frontend/src/features/appointments/hooks/useDoctorQueue.ts +++ b/frontend/src/features/appointments/hooks/useDoctorQueue.ts @@ -1,12 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { getDoctorQueue } from '../api/getDoctorQueue'; -export function useDoctorQueue(doctorId?: string) { +export function useDoctorQueue(doctorId?: string, statuses?: string[]) { return useQuery({ - queryKey: ['doctor-queue', doctorId], + queryKey: ['doctor-queue', doctorId, statuses], queryFn: async () => { // This will only run if doctorId exists - return getDoctorQueue(doctorId as string); + return getDoctorQueue(doctorId as string, statuses); }, enabled: !!doctorId, // Prevent query from running if undefined refetchInterval: 10000, // Auto refresh every 10 seconds diff --git a/frontend/src/features/beds/api/beds.api.ts b/frontend/src/features/beds/api/beds.api.ts new file mode 100644 index 0000000..261dd94 --- /dev/null +++ b/frontend/src/features/beds/api/beds.api.ts @@ -0,0 +1,16 @@ +import { api } from '@/src/lib/api'; + +export async function getBeds() { + const res = await api.get('/staff/beds'); + return res.data; +} + +export async function assignBed(data: { bedId: string; patientId: string }) { + const res = await api.post('/staff/beds/assign', data); + return res.data; +} + +export async function dischargeBed(bedId: string) { + const res = await api.post('/staff/beds/discharge', { bedId }); + return res.data; +} diff --git a/frontend/src/features/beds/components/BedsBoard.tsx b/frontend/src/features/beds/components/BedsBoard.tsx new file mode 100644 index 0000000..91477f2 --- /dev/null +++ b/frontend/src/features/beds/components/BedsBoard.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState, useMemo } from 'react' +import { useBeds } from '../hooks/useBeds' +import { BedDouble, Building, Hash, Filter, User } from 'lucide-react' + +export default function BedsBoard() { + const { data, isLoading } = useBeds() + + const [selectedWard, setSelectedWard] = useState('All') + const [selectedRoom, setSelectedRoom] = useState('All') + + // Extract unique wards and rooms for the dropdowns + const { wards, rooms } = useMemo(() => { + if (!data) return { wards: [], rooms: [] } + + // Set object ensures uniqueness + const uniqueWards = Array.from(new Set(data.map((b: any) => b.ward))) as string[] + + // If a specific ward is selected, only show rooms for that ward + let filteredForRooms = data + if (selectedWard !== 'All') { + filteredForRooms = data.filter((b: any) => b.ward === selectedWard) + } + const uniqueRooms = Array.from(new Set(filteredForRooms.map((b: any) => b.room_number))) as string[] + + return { wards: uniqueWards.sort(), rooms: uniqueRooms.sort() } + }, [data, selectedWard]) + + // Handle ward change: reset room if the new ward doesn't have the selected room + const handleWardChange = (e: React.ChangeEvent) => { + const newWard = e.target.value + setSelectedWard(newWard) + setSelectedRoom('All') // Reset room selection when ward changes + } + + // Filter the actual bed data + const filteredBeds = useMemo(() => { + if (!data) return [] + return data.filter((bed: any) => { + const matchWard = selectedWard === 'All' || bed.ward === selectedWard + const matchRoom = selectedRoom === 'All' || bed.room_number === selectedRoom + return matchWard && matchRoom + }) + }, [data, selectedWard, selectedRoom]) + + if (isLoading) return ( +
+
+
+ ) + + if (!data?.length) { + return ( +
+ No beds configured yet. +
+ ) + } + + return ( +
+ {/* 🔴 Filter Controls */} +
+
+
+ +
+ Filter Beds +
+ +
+ + +
+ +
+ + +
+ +
+ Showing {filteredBeds.length} bed{filteredBeds.length !== 1 ? 's' : ''} +
+
+ + {/* 🛏️ Bed Grid */} + {filteredBeds.length === 0 ? ( +
+ No beds found matching the selected filters. +
+ ) : ( +
+ {filteredBeds.map((bed: any) => ( +
+
+
+
+ +
+
+

Bed {bed.bed_number}

+
+
+ + + {bed.status} + +
+ +
+
+ + Ward: {bed.ward} +
+
+ + Room: {bed.room_number} +
+
+ + {bed.status === 'OCCUPIED' && ( +
+
+ + Occupied By: {bed.patient_name} +
+
+ + Assigned By: Dr. {bed.doctor_name} +
+
+ )} +
+ ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/features/beds/hooks/useAssignedBed.ts b/frontend/src/features/beds/hooks/useAssignedBed.ts new file mode 100644 index 0000000..f694bd1 --- /dev/null +++ b/frontend/src/features/beds/hooks/useAssignedBed.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { assignBed } from '../api/beds.api'; + +export function useAssignBed() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: assignBed, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['beds'] }); + }, + }); +} diff --git a/frontend/src/features/beds/hooks/useBeds.ts b/frontend/src/features/beds/hooks/useBeds.ts new file mode 100644 index 0000000..96ab64d --- /dev/null +++ b/frontend/src/features/beds/hooks/useBeds.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBeds } from '../api/beds.api'; + +export function useBeds() { + return useQuery({ + queryKey: ['beds'], + queryFn: getBeds, + }); +} diff --git a/frontend/src/features/inventory/api/inventory.api.ts b/frontend/src/features/inventory/api/inventory.api.ts new file mode 100644 index 0000000..3529d02 --- /dev/null +++ b/frontend/src/features/inventory/api/inventory.api.ts @@ -0,0 +1,29 @@ +import { api } from '../../../lib/api'; + +export const getInventoryItems = async () => { + const res = await api.get('/staff/inventory/items'); + return res.data; +}; + +export const addStock = async (data: { itemId: string; quantity: number }) => { + const res = await api.post('/staff/inventory/stock', data); + return res.data; +}; + +export const useItem = async (data: { + itemId: string; + quantity: number; + patientId?: string; +}) => { + const res = await api.post('/staff/inventory/use', data); + return res.data; +}; + +export const createItem = async (data: { + name: string; + category: string; + quantity: number; +}) => { + const res = await api.post('/staff/inventory/items', data); + return res.data; +}; diff --git a/frontend/src/features/inventory/components/AddStockModal.tsx b/frontend/src/features/inventory/components/AddStockModal.tsx new file mode 100644 index 0000000..d0a8188 --- /dev/null +++ b/frontend/src/features/inventory/components/AddStockModal.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { useAddStock, useCreateItem } from '../hooks/useInventoryActions'; +import { Package, X } from 'lucide-react'; + +export function AddStockModal({ onClose, items }: { onClose: () => void, items: any[] }) { + const [tab, setTab] = useState<'EXISTING' | 'NEW'>('EXISTING'); + + const [selectedItemId, setSelectedItemId] = useState(items?.[0]?.id || ''); + const [quantity, setQuantity] = useState(1); + const [name, setName] = useState(''); + const [category, setCategory] = useState('MEDICINE'); + + const addStockMutation = useAddStock(); + const createItemMutation = useCreateItem(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (tab === 'EXISTING') { + addStockMutation.mutate({ itemId: selectedItemId || items[0].id, quantity }, { + onSuccess: () => onClose() + }); + } else { + createItemMutation.mutate({ name, category, quantity }, { + onSuccess: () => onClose() + }); + } + }; + + return ( +
+
+
+
+
+

Add Stock

+
+ +
+ +
+ + +
+ +
+ {tab === 'EXISTING' ? ( +
+ + +
+ ) : ( + <> +
+ + setName(e.target.value)} + placeholder="e.g. Paracetamol 500mg" + className="w-full border border-slate-300 rounded-xl px-4 py-3 bg-slate-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition" + required + /> +
+
+ + +
+ + )} + +
+ + setQuantity(parseInt(e.target.value) || 1)} + className="w-full border border-slate-300 rounded-xl px-4 py-3 bg-slate-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition" + required + /> +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/inventory/components/UseItemModal.tsx b/frontend/src/features/inventory/components/UseItemModal.tsx new file mode 100644 index 0000000..8478e38 --- /dev/null +++ b/frontend/src/features/inventory/components/UseItemModal.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { useConsumeItem } from '../hooks/useInventoryActions'; +import { useInventory } from '../hooks/useInventory'; +import { PackageMinus, X } from 'lucide-react'; + +export function UseItemModal({ onClose, patientId, preselectedCategory }: { onClose: () => void, patientId?: string, preselectedCategory?: string }) { + const { data: items } = useInventory(); + + const availableItems = items?.filter((i: any) => i.quantity > 0 && (!preselectedCategory || i.category === preselectedCategory)) || []; + + const [selectedItemId, setSelectedItemId] = useState(availableItems[0]?.id || ''); + const [quantity, setQuantity] = useState(1); + const consumeMutation = useConsumeItem(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const resolvedId = selectedItemId || availableItems[0]?.id; + if (!resolvedId) return; + + consumeMutation.mutate({ itemId: resolvedId, quantity, patientId }, { + onSuccess: () => onClose() + }); + }; + + return ( +
+
+
+
+
+

Use Inventory

+
+ +
+ + {availableItems.length === 0 ? ( +
+ No matching items available in stock. +
+ ) : ( +
+
+ + +
+ +
+ + i.id === (selectedItemId || availableItems[0]?.id))?.quantity || 1} + value={quantity} + onChange={e => setQuantity(parseInt(e.target.value) || 1)} + className="w-full border border-slate-300 rounded-xl px-4 py-3 bg-slate-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-rose-500 transition" + required + /> +
+ + +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/inventory/hooks/useInventory.ts b/frontend/src/features/inventory/hooks/useInventory.ts new file mode 100644 index 0000000..8086971 --- /dev/null +++ b/frontend/src/features/inventory/hooks/useInventory.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getInventoryItems } from '../api/inventory.api'; + +export function useInventory() { + return useQuery({ + queryKey: ['inventory'], + queryFn: getInventoryItems, + }); +} diff --git a/frontend/src/features/inventory/hooks/useInventoryActions.ts b/frontend/src/features/inventory/hooks/useInventoryActions.ts new file mode 100644 index 0000000..9b68691 --- /dev/null +++ b/frontend/src/features/inventory/hooks/useInventoryActions.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addStock, useItem, createItem } from '../api/inventory.api'; + +export function useAddStock() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: addStock, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['inventory'] }); + }, + }); +} + +export function useConsumeItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: useItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['inventory'] }); + }, + }); +} + +export function useCreateItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['inventory'] }); + }, + }); +} diff --git a/frontend/src/features/patient/api/getCurrentAdmission.ts b/frontend/src/features/patient/api/getCurrentAdmission.ts new file mode 100644 index 0000000..573fd94 --- /dev/null +++ b/frontend/src/features/patient/api/getCurrentAdmission.ts @@ -0,0 +1,6 @@ +import { api } from '@/src/lib/api'; + +export async function getCurrentAdmission() { + const res = await api.get('/admissions/current'); + return res.data; +} diff --git a/frontend/src/features/patient/api/getPrescriptions.ts b/frontend/src/features/patient/api/getPrescriptions.ts new file mode 100644 index 0000000..ec20a67 --- /dev/null +++ b/frontend/src/features/patient/api/getPrescriptions.ts @@ -0,0 +1,6 @@ +import { api } from '@/src/lib/api'; + +export async function getPrescriptions() { + const res = await api.get('/prescriptions/me'); + return res.data; +} diff --git a/frontend/src/features/patient/components/AdmissionStatus.tsx b/frontend/src/features/patient/components/AdmissionStatus.tsx new file mode 100644 index 0000000..60df217 --- /dev/null +++ b/frontend/src/features/patient/components/AdmissionStatus.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getCurrentAdmission } from '../api/getCurrentAdmission'; +import { Bed, User, Clock, MapPin, AlertCircle } from 'lucide-react'; + +export default function AdmissionStatus() { + const [admission, setAdmission] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchStatus() { + try { + const data = await getCurrentAdmission(); + setAdmission(data); + } catch (err) { + console.error('Failed to fetch admission status', err); + } finally { + setLoading(false); + } + } + fetchStatus(); + }, []); + + if (loading) { + return ( +
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ); + } + + if (!admission) { + return ( +
+
+ +
+
+

No Active Admission

+

You are currently not admitted to any ward.

+
+
+ ); + } + + const admissionDate = new Date(admission.admitted_at || admission.created_at); + const formattedDate = admissionDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + const formattedTime = admissionDate.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + + return ( +
+
+

+ Current Admission Status + + {admission.status} + +

+
+ +
+
+
+ +
+
+

Ward / Department

+

{admission.ward || 'General'}

+
+
+ +
+
+ +
+
+

Bed Number

+

{admission.bed_number || 'TBD'}

+
+
+ +
+
+ +
+
+

Admitting Doctor

+

Dr. {admission.doctor_name || 'Assigned Doctor'}

+
+
+ +
+
+ +
+
+

Admitted On

+

{formattedDate} at {formattedTime}

+
+
+
+
+ ); +} diff --git a/frontend/src/features/pharmacy/api/pharmacy.api.ts b/frontend/src/features/pharmacy/api/pharmacy.api.ts new file mode 100644 index 0000000..1aeff81 --- /dev/null +++ b/frontend/src/features/pharmacy/api/pharmacy.api.ts @@ -0,0 +1,20 @@ +import { api } from '../../../lib/api'; + +export const createPrescription = async (data: { + patientId: string; + patientName: string; + items: { itemId: string; quantity: number; instructions?: string }[]; +}) => { + const res = await api.post('/staff/pharmacy', data); + return res.data; +}; + +export const getPendingPrescriptions = async () => { + const res = await api.get('/staff/pharmacy/pending'); + return res.data; +}; + +export const dispensePrescription = async (prescriptionId: string) => { + const res = await api.post(`/staff/pharmacy/${prescriptionId}/dispense`); + return res.data; +}; diff --git a/frontend/src/features/pharmacy/components/PrescribeModal.tsx b/frontend/src/features/pharmacy/components/PrescribeModal.tsx new file mode 100644 index 0000000..8738f51 --- /dev/null +++ b/frontend/src/features/pharmacy/components/PrescribeModal.tsx @@ -0,0 +1,136 @@ +import { useState, useEffect } from 'react'; +import { useCreatePrescription } from '../hooks/usePharmacyActions'; +import { useInventory } from '../../inventory/hooks/useInventory'; +import { Pill, X, Plus, Trash2 } from 'lucide-react'; + +export function PrescribeModal({ onClose, patientId, patientName }: { onClose: () => void, patientId: string, patientName: string }) { + const { data: inventory } = useInventory(); + const medicines = inventory?.filter((i: any) => i.category === 'MEDICINE') || []; + + const [items, setItems] = useState<{ itemId: string; quantity: number | string; instructions: string }[]>([]); + const prescribeMutation = useCreatePrescription(); + + // Auto-populate first row if medicines exist + useEffect(() => { + if (medicines.length > 0 && items.length === 0) { + setItems([{ itemId: medicines[0].id, quantity: 1, instructions: '' }]); + } + }, [medicines, items.length]); + + const handleAddItem = () => { + if (medicines.length === 0) return; + setItems([...items, { itemId: medicines[0].id, quantity: 1, instructions: '' }]); + }; + + const handleUpdateItem = (index: number, field: string, value: any) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], [field]: value }; + setItems(newItems); + }; + + const handleRemoveItem = (index: number) => { + setItems(items.filter((_, i) => i !== index)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (items.length === 0) return; + + const payloadItems = items.map(item => ({ + itemId: item.itemId, + quantity: typeof item.quantity === 'string' ? parseInt(item.quantity) || 1 : item.quantity, + instructions: item.instructions + })); + + prescribeMutation.mutate({ patientId, patientName, items: payloadItems }, { + onSuccess: () => onClose() + }); + }; + + return ( +
+
+
+
+
+

Write Prescription

+
+ +
+ +
+
+

Patient Name

+

{patientName}

+
+
+ +
+ {items.map((item, index) => ( +
+
+
+ +
+
+ handleUpdateItem(index, 'quantity', e.target.value)} + className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-center font-bold focus:outline-none focus:ring-2 focus:ring-indigo-500" + required + placeholder="Qty" + /> +
+ +
+
+ handleUpdateItem(index, 'instructions', e.target.value)} + placeholder="Doctor instructions (e.g. 1-0-1 for 5 days after food)" + className="w-full bg-slate-50 border border-slate-200 rounded-lg px-4 py-3 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 transition" + /> +
+
+ ))} + + {medicines.length > 0 ? ( + + ) : ( +
No unassigned medicines available in the hospital inventory.
+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/features/pharmacy/hooks/usePharmacy.ts b/frontend/src/features/pharmacy/hooks/usePharmacy.ts new file mode 100644 index 0000000..eca4e43 --- /dev/null +++ b/frontend/src/features/pharmacy/hooks/usePharmacy.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPendingPrescriptions } from '../api/pharmacy.api'; + +export function usePendingPrescriptions() { + return useQuery({ + queryKey: ['pendingPrescriptions'], + queryFn: getPendingPrescriptions, + }); +} diff --git a/frontend/src/features/pharmacy/hooks/usePharmacyActions.ts b/frontend/src/features/pharmacy/hooks/usePharmacyActions.ts new file mode 100644 index 0000000..64f68d0 --- /dev/null +++ b/frontend/src/features/pharmacy/hooks/usePharmacyActions.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createPrescription, dispensePrescription } from '../api/pharmacy.api'; + +export function useCreatePrescription() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createPrescription, + onSuccess: () => { + // We don't necessarily need to invalidate here since Doctors don't see the Pharmacy list, but good practice + queryClient.invalidateQueries({ queryKey: ['pendingPrescriptions'] }); + }, + }); +} + +export function useDispensePrescription() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: dispensePrescription, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pendingPrescriptions'] }); + // Dispensing uses inventory stock, so we invalidate inventory too + queryClient.invalidateQueries({ queryKey: ['inventory'] }); + }, + }); +} diff --git a/frontend/src/features/staff/api/staff.api.ts b/frontend/src/features/staff/api/staff.api.ts new file mode 100644 index 0000000..ff9c371 --- /dev/null +++ b/frontend/src/features/staff/api/staff.api.ts @@ -0,0 +1,13 @@ +import { api } from '@/src/lib/api'; + +export async function getDepartments() { + const { data } = await api.get('/staff/departments'); + return data; +} + +export async function getDoctorsByDepartment(departmentId: string) { + const { data } = await api.get( + `/staff/doctors?department_id=${departmentId}` + ); + return data; +} diff --git a/frontend/src/features/staff/hooks/useStaffData.ts b/frontend/src/features/staff/hooks/useStaffData.ts new file mode 100644 index 0000000..e6e50e9 --- /dev/null +++ b/frontend/src/features/staff/hooks/useStaffData.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDepartments, getDoctorsByDepartment } from '../api/staff.api'; + +export function useDepartments() { + return useQuery({ + queryKey: ['departments'], + queryFn: getDepartments, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +export function useDoctorsByDepartment(departmentId: string) { + return useQuery({ + queryKey: ['doctors', departmentId], + queryFn: () => getDoctorsByDepartment(departmentId), + enabled: !!departmentId, + staleTime: 2 * 60 * 1000, // 2 minutes + }); +} diff --git a/frontend/src/staff/auth/staff.auth.types.ts b/frontend/src/staff/auth/staff.auth.types.ts index 35269b7..88f047e 100644 --- a/frontend/src/staff/auth/staff.auth.types.ts +++ b/frontend/src/staff/auth/staff.auth.types.ts @@ -1,3 +1,4 @@ +// import { strict } from 'assert'; import { z } from 'zod'; export const StaffSchema = z.object({ @@ -5,6 +6,7 @@ export const StaffSchema = z.object({ name: z.string(), email: z.string().email(), department: z.string(), + department_id: z.string(), role: z.string(), job_title: z.string(), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4001343..0ef7cbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@h-os/shared': specifier: workspace:* version: link:../shared + axios: + specifier: ^1.13.5 + version: 1.13.5 bcrypt: specifier: ^6.0.0 version: 6.0.0 diff --git a/services/patient-service/scripts/init-db.ts b/services/patient-service/scripts/init-db.ts index b8c77c6..bc61120 100644 --- a/services/patient-service/scripts/init-db.ts +++ b/services/patient-service/scripts/init-db.ts @@ -21,6 +21,23 @@ async function init() { ); `); + // Admissions table + + await pool.query(`CREATE TABLE IF NOT EXISTS admissions ( + id UUID PRIMARY KEY, + patient_id UUID NOT NULL, + doctor_id UUID NOT NULL, + department_id UUID NOT NULL, + + status TEXT NOT NULL DEFAULT 'REQUESTED', + + discharge_requested BOOLEAN DEFAULT false, + discharge_requested_at TIMESTAMP, + + created_at TIMESTAMP DEFAULT now(), + admitted_at TIMESTAMP, + discharged_at TIMESTAMP +);`); /** * PATIENT PROFILES TABLE * Extended medical + personal information diff --git a/services/patient-service/src/app.ts b/services/patient-service/src/app.ts index 4bc6ab3..f2fc315 100644 --- a/services/patient-service/src/app.ts +++ b/services/patient-service/src/app.ts @@ -6,6 +6,7 @@ import healthRoutes from './routes/health'; import authRoutes from './modules/auth/auth.routes'; import { errorMiddleware } from './middlewares/error.middleware'; import appointmentRoutes from './modules/appointments/appointment.routes'; +import admissionRoutes from './modules/admissions/admission.routes'; const app = express(); // Middleware @@ -35,6 +36,7 @@ app.use('/patients', patientRoutes); app.use('/appointments', appointmentRoutes); +app.use('/admissions', admissionRoutes); // Error handling app.use(errorMiddleware); diff --git a/services/patient-service/src/modules/admissions/admission.controller.ts b/services/patient-service/src/modules/admissions/admission.controller.ts new file mode 100644 index 0000000..b1a3082 --- /dev/null +++ b/services/patient-service/src/modules/admissions/admission.controller.ts @@ -0,0 +1,101 @@ +import { Request, Response } from 'express'; +import { admissionRepository } from './admission.repository'; +import { admissionService } from './admission.service'; + +export class AdmissionController { + async requestAdmission(req: Request, res: Response) { + const { patientId, doctorId, departmentId } = req.body; + + const admission = await admissionRepository.createAdmission({ + patientId, + doctorId, + departmentId, + }); + + res.json({ + message: 'Admission requested', + admission, + }); + } + + async getPending(req: Request, res: Response) { + const admissions = await admissionService.getPendingAdmissions(); + + res.json(admissions); + } + + async admitPatient(req: Request, res: Response) { + const admissionId = req.params.id as string; + + await admissionService.admitPatient(admissionId); + + res.json({ + message: 'Patient admitted', + }); + } + + async requestDischarge(req: Request, res: Response) { + const admissionId = req.params.id as string; + + await admissionService.requestDischarge(admissionId); + + res.json({ + message: 'Discharge requested', + }); + } + + async completeDischarge(req: Request, res: Response) { + const admissionId = req.params.id as string; + + await admissionService.completeDischarge(admissionId); + + res.json({ + message: 'Patient discharged', + }); + } + async getDoctorAdmissions(req: Request, res: Response) { + const doctorId = req.headers['x-user-id'] as string; + + if (!doctorId) { + return res.status(401).json({ + error: 'Unauthorized', + }); + } + + const admissions = await admissionService.getDoctorAdmissions(doctorId); + + res.json(admissions); + } + async getDischargeRequests(req: Request, res: Response) { + const requests = await admissionService.getDischargeRequests(); + + res.json(requests); + } + + async getCurrentAdmission(req: Request, res: Response) { + const patientId = req.headers['x-user-id'] as string; + + if (!patientId) { + return res.status(401).json({ + error: 'Unauthorized', + }); + } + + const admission = await admissionService.getCurrentAdmission(patientId); + + res.json(admission); + } + + async getBulkCurrent(req: Request, res: Response) { + try { + const { patientIds } = req.body; + if (!Array.isArray(patientIds)) { + return res.status(400).json({ error: 'patientIds array required' }); + } + const data = await admissionService.getBulkCurrent(patientIds); + return res.json(data); + } catch (e: any) { + return res.status(500).json({ error: e.message }); + } + } +} diff --git a/services/patient-service/src/modules/admissions/admission.repository.ts b/services/patient-service/src/modules/admissions/admission.repository.ts new file mode 100644 index 0000000..4af1959 --- /dev/null +++ b/services/patient-service/src/modules/admissions/admission.repository.ts @@ -0,0 +1,155 @@ +import { pool } from '../../db'; +import { randomUUID } from 'crypto'; + +class AdmissionRepository { + async createAdmission(data: { + patientId: string; + doctorId: string; + departmentId: string; + }) { + const existing = await pool.query( + `SELECT * FROM admissions WHERE patient_id = $1 AND status != 'DISCHARGED'`, + [data.patientId] + ); + if (existing.rows.length > 0) return existing.rows[0]; + + const result = await pool.query( + ` + INSERT INTO admissions + (id, patient_id, doctor_id, department_id, status) + VALUES ($1,$2,$3,$4,'REQUESTED') + RETURNING * + `, + [randomUUID(), data.patientId, data.doctorId, data.departmentId] + ); + + return result.rows[0]; + } + + async getPendingAdmissions() { + const result = await pool.query( + ` + SELECT a.*, p.name AS patient_name + FROM admissions a + LEFT JOIN patients p ON p.id = a.patient_id + WHERE a.status='REQUESTED' + ORDER BY a.created_at ASC + ` + ); + + return result.rows; + } + + async markAdmitted(id: string) { + await pool.query( + ` + UPDATE admissions + SET status='ADMITTED', + admitted_at = now() + WHERE id=$1 + `, + [id] + ); + } + async requestDischarge(id: string) { + await pool.query( + ` + UPDATE admissions + SET discharge_requested = true, + discharge_requested_at = now() + WHERE id = $1 + `, + [id] + ); + } + + async completeDischarge(id: string) { + await pool.query( + ` + UPDATE admissions + SET status = 'DISCHARGED', + discharge_requested = false + WHERE id = $1 + `, + [id] + ); + } + + async getDoctorAdmissions(doctorId: string) { + const result = await pool.query( + ` + SELECT + a.id, + a.patient_id, + p.name AS patient_name, + a.doctor_id, + a.department_id, + a.status, + a.created_at, + a.discharge_requested + FROM admissions a + LEFT JOIN patients p ON p.id = a.patient_id + WHERE a.doctor_id = $1 + AND a.status = 'ADMITTED' + ORDER BY a.created_at DESC + `, + [doctorId] + ); + + return result.rows; + } + async getDischargeRequests() { + const result = await pool.query( + ` + SELECT + a.id, + a.patient_id, + p.name AS patient_name, + a.doctor_id + FROM admissions a + LEFT JOIN patients p ON p.id = a.patient_id + WHERE a.discharge_requested = true + AND a.status = 'ADMITTED' + ORDER BY a.created_at ASC + ` + ); + + return result.rows; + } + + async getCurrentAdmission(patientId: string) { + const result = await pool.query( + ` + SELECT * FROM admissions + WHERE patient_id = $1 + AND status != 'DISCHARGED' + LIMIT 1 + `, + [patientId] + ); + + return result.rows[0] || null; + } + + async getAdmissionsByIds(ids: string[]) { + if (!ids || ids.length === 0) return []; + const result = await pool.query( + `SELECT id, patient_id, doctor_id FROM admissions WHERE id = ANY($1::uuid[])`, + [ids] + ); + return result.rows; + } + + async getBulkCurrent(patientIds: string[]) { + if (!patientIds || patientIds.length === 0) return []; + const result = await pool.query( + `SELECT id as admission_id, patient_id, doctor_id + FROM admissions + WHERE patient_id = ANY($1::uuid[]) AND status != 'DISCHARGED'`, + [patientIds] + ); + return result.rows; + } +} + +export const admissionRepository = new AdmissionRepository(); diff --git a/services/patient-service/src/modules/admissions/admission.routes.ts b/services/patient-service/src/modules/admissions/admission.routes.ts new file mode 100644 index 0000000..1a90908 --- /dev/null +++ b/services/patient-service/src/modules/admissions/admission.routes.ts @@ -0,0 +1,24 @@ +import { Router } from 'express'; +import { AdmissionController } from './admission.controller'; + +const router = Router(); +const controller = new AdmissionController(); + +router.post('/request', controller.requestAdmission.bind(controller)); +router.get('/pending', controller.getPending.bind(controller)); +router.post('/:id/admit', controller.admitPatient.bind(controller)); + +router.get('/doctor', controller.getDoctorAdmissions.bind(controller)); +router.get( + '/discharge-requests', + controller.getDischargeRequests.bind(controller) +); + +router.post( + '/:id/request-discharge', + controller.requestDischarge.bind(controller) +); +router.post('/:id/discharged', controller.completeDischarge.bind(controller)); +router.get('/current', controller.getCurrentAdmission.bind(controller)); +router.post('/bulk-current', controller.getBulkCurrent.bind(controller)); +export default router; diff --git a/services/patient-service/src/modules/admissions/admission.service.ts b/services/patient-service/src/modules/admissions/admission.service.ts new file mode 100644 index 0000000..b0b9d22 --- /dev/null +++ b/services/patient-service/src/modules/admissions/admission.service.ts @@ -0,0 +1,166 @@ +import { admissionRepository } from './admission.repository'; +import axios from 'axios'; + +class AdmissionService { + async requestAdmission(data: any) { + return admissionRepository.createAdmission(data); + } + + async getPendingAdmissions() { + const admissions = await admissionRepository.getPendingAdmissions(); + + if (!admissions || admissions.length === 0) return []; + + const doctorIds = admissions.map((a: any) => a.doctor_id); + + try { + const response = await axios.post( + `${process.env.STAFF_SERVICE_URL}/staff/bulk-basic-info`, + { + staffIds: doctorIds, + } + ); + const staffInfo = response.data || []; + + return admissions.map((admission: any) => { + const doc = staffInfo.find((s: any) => s.id === admission.doctor_id); + return { + ...admission, + doctor_name: doc?.doctor_name || 'Unknown Doctor', + department_name: doc?.department_name || 'Unknown Department', + }; + }); + } catch (err) { + console.error('Failed to fetch doctor names', err); + return admissions; + } + } + + async admitPatient(admissionId: string) { + return admissionRepository.markAdmitted(admissionId); + } + + async requestDischarge(admissionId: string) { + await admissionRepository.requestDischarge(admissionId); + } + + async completeDischarge(admissionId: string) { + await admissionRepository.completeDischarge(admissionId); + } + + async getDoctorAdmissions(doctorId: string) { + const admissions = await admissionRepository.getDoctorAdmissions(doctorId); + + if (!admissions || admissions.length === 0) return []; + + const patientIds = admissions.map((a: any) => a.patient_id); + + try { + const response = await axios.post( + `${process.env.STAFF_SERVICE_URL}/staff/beds/active-assignments`, + { + patientIds, + } + ); + const activeAssignments = response.data || []; + + return admissions.map((admission: any) => { + const assignment = activeAssignments.find( + (b: any) => b.patient_id === admission.patient_id + ); + return { + ...admission, + ward: assignment?.ward || 'Unassigned', + bed_number: assignment?.bed_number || 'Unassigned', + }; + }); + } catch (err) { + console.error('Failed to fetch bed assignments', err); + return admissions; + } + } + async getDischargeRequests() { + const requests = await admissionRepository.getDischargeRequests(); + + if (!requests || requests.length === 0) return []; + + const patientIds = requests.map((r: any) => r.patient_id); + + try { + const response = await axios.post( + `${process.env.STAFF_SERVICE_URL}/staff/beds/active-assignments`, + { + patientIds, + } + ); + const activeAssignments = response.data || []; + + return requests.map((request: any) => { + const assignment = activeAssignments.find( + (b: any) => b.patient_id === request.patient_id + ); + return { + ...request, + ward: assignment?.ward || 'Unassigned', + bed_number: assignment?.bed_number || 'Unassigned', + }; + }); + } catch (err) { + console.error('Failed to fetch bed assignments', err); + return requests; + } + } + + async getCurrentAdmission(patientId: string) { + const admission = await admissionRepository.getCurrentAdmission(patientId); + + if (!admission) return null; + + // Fetch doctor info + let doctor_name = 'Unknown Doctor'; + try { + const response = await axios.post( + `${process.env.STAFF_SERVICE_URL}/staff/bulk-basic-info`, + { + staffIds: [admission.doctor_id], + } + ); + const staffInfo = response.data || []; + doctor_name = staffInfo[0]?.doctor_name || 'Unknown Doctor'; + } catch (err) { + console.error('Failed to fetch doctor info', err); + } + + // Fetch bed assignment + let assignmentInfo = { ward: 'N/A', bed_number: 'N/A' }; + try { + const response = await axios.post( + `${process.env.STAFF_SERVICE_URL}/staff/beds/active-assignments`, + { + patientIds: [patientId], + } + ); + const activeAssignments = response.data || []; + if (activeAssignments.length > 0) { + assignmentInfo = { + ward: activeAssignments[0].ward, + bed_number: activeAssignments[0].bed_number, + }; + } + } catch (err) { + console.error('Failed to fetch bed assignment', err); + } + + return { + ...admission, + doctor_name, + ...assignmentInfo, + }; + } + + async getBulkCurrent(patientIds: string[]) { + return admissionRepository.getBulkCurrent(patientIds); + } +} + +export const admissionService = new AdmissionService(); diff --git a/services/patient-service/src/modules/appointments/appointment.controller.ts b/services/patient-service/src/modules/appointments/appointment.controller.ts index ffed9ac..2271c53 100644 --- a/services/patient-service/src/modules/appointments/appointment.controller.ts +++ b/services/patient-service/src/modules/appointments/appointment.controller.ts @@ -154,9 +154,21 @@ export class AppointmentController { const date = (req.query.date as string) || new Date().toISOString().split('T')[0]; + const statusesQuery = req.query.statuses; + let statuses: string[] | undefined; + + if (statusesQuery) { + if (Array.isArray(statusesQuery)) { + statuses = statusesQuery as string[]; + } else { + statuses = (statusesQuery as string).split(','); + } + } + const queue = await appointmentService.getDoctorQueueForDay( doctorId, - date + date, + statuses ); return res.json(queue); diff --git a/services/patient-service/src/modules/appointments/appointment.repository.ts b/services/patient-service/src/modules/appointments/appointment.repository.ts index e505269..ebae483 100644 --- a/services/patient-service/src/modules/appointments/appointment.repository.ts +++ b/services/patient-service/src/modules/appointments/appointment.repository.ts @@ -45,19 +45,31 @@ export class AppointmentRepository { } } - async getDoctorAppointmentsForDay(doctorId: string, date: string) { + async getDoctorAppointmentsForDay( + doctorId: string, + date: string, + statuses: string[] = ['SCHEDULED', 'CHECKED_IN', 'IN_PROGRESS'] + ) { const result = await pool.query( ` - SELECT * - FROM appointments - WHERE doctor_id = $1 - AND appointment_time::date = $2 - AND status IN ('SCHEDULED', 'CHECKED_IN', 'IN_PROGRESS') + SELECT + a.*, + p.name AS patient_name, + EXISTS ( + SELECT 1 FROM admissions adm + WHERE adm.patient_id = a.patient_id + AND adm.status != 'DISCHARGED' + ) AS admission_requested + FROM appointments a + LEFT JOIN patients p ON p.id = a.patient_id + WHERE a.doctor_id = $1 + AND a.appointment_time::date = $2 + AND a.status = ANY($3) ORDER BY - CASE WHEN priority = 'HIGH' THEN 0 ELSE 1 END, - appointment_time ASC + CASE WHEN a.priority = 'HIGH' THEN 0 ELSE 1 END, + a.appointment_time ASC `, - [doctorId, date] + [doctorId, date, statuses] ); return result.rows; @@ -146,6 +158,34 @@ export class AppointmentRepository { return `${hours}:${minutes}`; }); } + + async updateAppointmentTime( + appointmentId: string, + newTime: Date, + newDuration: number + ) { + const result = await pool.query( + ` + UPDATE appointments + SET appointment_time = $2, + duration_minutes = $3, + updated_at = now() + WHERE id = $1 + RETURNING *; + `, + [appointmentId, newTime, newDuration] + ); + + return result.rows[0]; + } + + async getAppointmentById(id: string) { + const result = await pool.query( + `SELECT * FROM appointments WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } } export const appointmentRepository = new AppointmentRepository(); diff --git a/services/patient-service/src/modules/appointments/appointment.service.ts b/services/patient-service/src/modules/appointments/appointment.service.ts index d0d5af1..2a21ccb 100644 --- a/services/patient-service/src/modules/appointments/appointment.service.ts +++ b/services/patient-service/src/modules/appointments/appointment.service.ts @@ -1,34 +1,31 @@ import axios from 'axios'; import { pool } from '../../db'; import { appointmentRepository } from './appointment.repository'; -import { error } from 'console'; export class AppointmentService { + async validateDoctor(doctorId: string) { + await axios.get(`${process.env.STAFF_SERVICE_URL}/staff/by-id/${doctorId}`); + } + async bookAppointment(data: { doctorId: string; patientId: string; appointmentTime: string; durationMinutes?: number; }) { - // Validate doctor exists try { - console.log('BOOK SERVICE HIT'); - await axios.get( - `${process.env.STAFF_SERVICE_URL}/staff/${data.doctorId}` - ); + await this.validateDoctor(data.doctorId); } catch { throw new Error('Doctor not found'); } - // 2️⃣ Validate booking is inside availability - const appointmentDate = new Date(data.appointmentTime); - const now = new Date(); if (appointmentDate <= now) { throw new Error('PAST_TIME_NOT_ALLOWED'); } + const dayOfWeek = appointmentDate.getDay(); const availabilityResponse = await axios.get( @@ -63,7 +60,6 @@ export class AppointmentService { throw new Error('OUTSIDE_WORKING_HOURS'); } - // validate slot alignment const diffMinutes = (appointmentDate.getTime() - start.getTime()) / 60000; if (diffMinutes % slot_duration !== 0) { @@ -123,9 +119,17 @@ export class AppointmentService { return updated.rows[0]; } - async getDoctorQueueForDay(doctorId: string, date: string) { + async getDoctorQueueForDay( + doctorId: string, + date: string, + statuses?: string[] + ) { const appointments = - await appointmentRepository.getDoctorAppointmentsForDay(doctorId, date); + await appointmentRepository.getDoctorAppointmentsForDay( + doctorId, + date, + statuses + ); const GRACE_MINUTES = 10; const now = new Date(); @@ -156,17 +160,13 @@ export class AppointmentService { currentTime = new Date(appt.actual_end_time); } else if (appt.status === 'IN_PROGRESS' && appt.actual_start_time) { const actualStart = new Date(appt.actual_start_time); + const plannedEnd = new Date( actualStart.getTime() + appt.duration_minutes * 60000 ); const now = new Date(); - console.log('Raw DB time:', appt.appointment_time); - console.log('JS Date:', new Date(appt.appointment_time)); - console.log('ISO:', new Date(appt.appointment_time).toISOString()); - - // If doctor exceeded duration, use real time currentTime = now > plannedEnd ? now : plannedEnd; estimatedStart = actualStart; @@ -194,6 +194,8 @@ export class AppointmentService { return { id: appt.id, patient_id: appt.patient_id, + patient_name: appt.patient_name, + admission_requested: appt.admission_requested, status: appt.status, priority: appt.priority, planned_time: appt.appointment_time, @@ -206,8 +208,6 @@ export class AppointmentService { }; }); - // 🧠 Doctor Delay Calculation - let doctorDelayMinutes = 0; if (queue.length > 0) { @@ -220,11 +220,9 @@ export class AppointmentService { ); } - // 🧠 Next Available Time const nextAvailableTime = queue.length > 0 ? queue[queue.length - 1].estimated_end_time : null; - // 🧠 Remaining Queue Minutes (from now) let remainingQueueMinutes = 0; if (nextAvailableTime) { @@ -247,100 +245,6 @@ export class AppointmentService { }; } - async setPriority(appointmentId: string, priority: 'NORMAL' | 'HIGH') { - const appointment = await appointmentRepository.updatePriority( - appointmentId, - priority - ); - - if (!appointment) { - throw new Error('NOT_FOUND'); - } - - return appointment; - } - - async getMyStatus(patientId: string) { - const today = new Date().toLocaleDateString('en-CA'); - - const myAppointment = - await appointmentRepository.getPatientActiveAppointment(patientId, today); - - if (!myAppointment) { - return null; - } - - // get full doctor queue - const doctorQueue = await this.getDoctorQueueForDay( - myAppointment.doctor_id, - today - ); - - const myQueueItem = doctorQueue.queue.find( - (q) => q.id === myAppointment.id - ); - - if (!myQueueItem) { - return null; - } - - return { - appointment_id: myAppointment.id, - doctor_id: myAppointment.doctor_id, - status: myQueueItem.status, - position: myQueueItem.position, - patients_ahead: myQueueItem.patients_ahead, - estimated_start_time: myQueueItem.estimated_start_time, - estimated_end_time: myQueueItem.estimated_end_time, - delay_minutes: myQueueItem.delay_minutes, - doctor_delay_minutes: doctorQueue.doctor_status.doctor_delay_minutes, - doctor_current_patient: doctorQueue.doctor_status.current_patient, - }; - } - - async cancelAppointment(appointmentId: string, patientId: string) { - const result = await pool.query( - `SELECT * FROM appointments WHERE id = $1`, - [appointmentId] - ); - - const appt = result.rows[0]; - - if (!appt) { - throw new Error('NOT_FOUND'); - } - - if (appt.patient_id !== patientId) { - throw new Error('FORBIDDEN'); - } - - if (appt.status !== 'SCHEDULED') { - throw new Error('CANNOT_CANCEL'); - } - - const now = new Date(); - const appointmentTime = new Date(appt.appointment_time); - - const diffMinutes = (appointmentTime.getTime() - now.getTime()) / 60000; - - if (diffMinutes < 60) { - throw new Error('TOO_LATE_TO_CANCEL'); - } - - const updated = await pool.query( - ` - UPDATE appointments - SET status = 'CANCELLED', - updated_at = now() - WHERE id = $1 - RETURNING * - `, - [appointmentId] - ); - - return updated.rows[0]; - } - async getPatientHistory(patientId: string) { const appointments = await appointmentRepository.getPatientHistory(patientId); @@ -349,7 +253,7 @@ export class AppointmentService { appointments.map(async (appt) => { try { const response = await axios.get( - `${process.env.STAFF_SERVICE_URL}/staff/${appt.doctor_id}` + `${process.env.STAFF_SERVICE_URL}/staff/by-id/${appt.doctor_id}` ); const doctor = response.data; @@ -372,66 +276,6 @@ export class AppointmentService { return enriched; } - async rescheduleAppointment( - appointmentId: string, - patientId: string, - newTime: string - ) { - const result = await pool.query( - `SELECT * FROM appointments WHERE id = $1`, - [appointmentId] - ); - - const appt = result.rows[0]; - - if (!appt) { - throw new Error('NOT_FOUND'); - } - - if (appt.patient_id !== patientId) { - throw new Error('FORBIDDEN'); - } - - if (appt.status !== 'SCHEDULED') { - throw new Error('CANNOT_RESCHEDULE'); - } - - const now = new Date(); - const oldTime = new Date(appt.appointment_time); - - const diffMinutes = (oldTime.getTime() - now.getTime()) / 60000; - - if (diffMinutes < 60) { - throw new Error('TOO_LATE_TO_RESCHEDULE'); - } - - // 1️⃣ Cancel old - await pool.query( - ` - UPDATE appointments - SET status = 'CANCELLED', - updated_at = now() - WHERE id = $1 - `, - [appointmentId] - ); - - // 2️⃣ Create new appointment - const newAppointment = await appointmentRepository.createAppointment({ - doctorId: appt.doctor_id, - patientId, - appointmentTime: new Date(newTime), - durationMinutes: appt.duration_minutes, - priority: 'NORMAL', - }); - - if (!newAppointment) { - throw new Error('SLOT_TAKEN'); - } - - return newAppointment; - } - async getAvailableSlots(doctorId: string, date: string) { const dayOfWeek = new Date(date).getDay(); @@ -447,7 +291,6 @@ export class AppointmentService { const { start_time, end_time, slot_duration } = availability; - // Generate slots const slots: string[] = []; const start = new Date(`${date}T${start_time}`); @@ -464,25 +307,20 @@ export class AppointmentService { current = new Date(current.getTime() + slot_duration * 60000); } - // Remove booked slots const booked = await appointmentRepository.getBookedSlots(doctorId, date); const available = slots.filter((slot) => !booked.includes(slot)); return available; } - async createEmergencyAppointment(doctorId: string, patientId: string) { - // Validate doctor exists try { - await axios.get(`${process.env.STAFF_SERVICE_URL}/staff/${doctorId}`); + await this.validateDoctor(doctorId); } catch { throw new Error('Doctor not found'); } const now = new Date(); - - // Get doctor availability to fetch slot duration const dayOfWeek = now.getDay(); const availabilityResponse = await axios.get( @@ -497,7 +335,6 @@ export class AppointmentService { const { slot_duration } = availability; - // Create emergency appointment immediately const appointment = await appointmentRepository.createAppointment({ doctorId, patientId, @@ -510,11 +347,162 @@ export class AppointmentService { throw new Error('SLOT_TAKEN'); } - // Immediately check in await this.updateStatus(appointment.id, 'CHECKED_IN'); return appointment; } + async getMyStatus(patientId: string) { + const today = new Date().toLocaleDateString('en-CA'); + + const myAppointment = + await appointmentRepository.getPatientActiveAppointment(patientId, today); + + if (!myAppointment) { + return null; + } + + // get full doctor queue + const doctorQueue = await this.getDoctorQueueForDay( + myAppointment.doctor_id, + today + ); + + const myQueueItem = doctorQueue.queue.find( + (q) => q.id === myAppointment.id + ); + + if (!myQueueItem) { + return null; + } + + return { + appointment_id: myAppointment.id, + doctor_id: myAppointment.doctor_id, + status: myQueueItem.status, + position: myQueueItem.position, + patients_ahead: myQueueItem.patients_ahead, + estimated_start_time: myQueueItem.estimated_start_time, + estimated_end_time: myQueueItem.estimated_end_time, + delay_minutes: myQueueItem.delay_minutes, + doctor_delay_minutes: doctorQueue.doctor_status.doctor_delay_minutes, + doctor_current_patient: doctorQueue.doctor_status.current_patient, + }; + } + + async setPriority(appointmentId: string, priority: 'NORMAL' | 'HIGH') { + const appointment = + await appointmentRepository.getAppointmentById(appointmentId); + + if (!appointment) throw new Error('NOT_FOUND'); + + return appointmentRepository.updatePriority(appointmentId, priority); + } + + async cancelAppointment(appointmentId: string, patientId: string) { + const appointment = + await appointmentRepository.getAppointmentById(appointmentId); + + if (!appointment) throw new Error('NOT_FOUND'); + + if (appointment.patient_id !== patientId) throw new Error('FORBIDDEN'); + + if (appointment.status !== 'SCHEDULED') throw new Error('CANNOT_CANCEL'); + + // Check if it's too late (e.g. less than 1 hour before) + const now = new Date(); + const apptTime = new Date(appointment.appointment_time); + const diffHours = (apptTime.getTime() - now.getTime()) / 3600000; + + if (diffHours < 1) throw new Error('TOO_LATE_TO_CANCEL'); + + return this.updateStatus(appointmentId, 'CANCELLED'); + } + + async rescheduleAppointment( + appointmentId: string, + patientId: string, + newTimeStr: string + ) { + const appointment = + await appointmentRepository.getAppointmentById(appointmentId); + + if (!appointment) throw new Error('NOT_FOUND'); + + if (appointment.patient_id !== patientId) throw new Error('FORBIDDEN'); + + if (appointment.status !== 'SCHEDULED') + throw new Error('CANNOT_RESCHEDULE'); + + const now = new Date(); + const newTime = new Date(newTimeStr); + + if (newTime <= now) { + throw new Error('PAST_TIME_NOT_ALLOWED'); + } + + // Check if too late to change current one + const apptTime = new Date(appointment.appointment_time); + const diffHours = (apptTime.getTime() - now.getTime()) / 3600000; + + if (diffHours < 1) throw new Error('TOO_LATE_TO_RESCHEDULE'); + + // Validate new slot + const dayOfWeek = newTime.getDay(); + const availabilityResponse = await axios.get( + `${process.env.STAFF_SERVICE_URL}/staff/availability/${appointment.doctor_id}/${dayOfWeek}` + ); + + const availability = availabilityResponse.data; + if (!availability) throw new Error('DOCTOR_NOT_AVAILABLE'); + + const { start_time, end_time, slot_duration } = availability; + + const start = new Date(newTime); + start.setHours( + Number(start_time.split(':')[0]), + Number(start_time.split(':')[1]), + 0, + 0 + ); + + const end = new Date(newTime); + end.setHours( + Number(end_time.split(':')[0]), + Number(end_time.split(':')[1]), + 0, + 0 + ); + + if (newTime < start || newTime >= end) { + throw new Error('OUTSIDE_WORKING_HOURS'); + } + + const diffMinutes = (newTime.getTime() - start.getTime()) / 60000; + if (diffMinutes % slot_duration !== 0) { + throw new Error('INVALID_SLOT_TIME'); + } + + // Check if slot taken + const dateStr = newTime.toISOString().split('T')[0]; + const booked = await appointmentRepository.getBookedSlots( + appointment.doctor_id, + dateStr + ); + + const hours = newTime.getHours().toString().padStart(2, '0'); + const minutes = newTime.getMinutes().toString().padStart(2, '0'); + const timeStr = `${hours}:${minutes}`; + + if (booked.includes(timeStr)) { + throw new Error('SLOT_TAKEN'); + } + + return appointmentRepository.updateAppointmentTime( + appointmentId, + newTime, + slot_duration + ); + } } export const appointmentService = new AppointmentService(); diff --git a/services/patient-service/src/modules/patients/patient.controller.ts b/services/patient-service/src/modules/patients/patient.controller.ts index a7e774e..6147f1b 100644 --- a/services/patient-service/src/modules/patients/patient.controller.ts +++ b/services/patient-service/src/modules/patients/patient.controller.ts @@ -163,9 +163,22 @@ class PatientController { const { file_key } = req.body; await deletePatientDocument(patientId, file_key); - return res.json({ success: true }); } + + async getBulkInfo(req: Request, res: Response) { + try { + const { ids } = req.body; + if (!Array.isArray(ids)) { + return res.status(400).json({ error: 'ids array required' }); + } + const patients = await patientService.getPatientsByIds(ids); + return res.json(patients); + } catch (error) { + console.error(error); + return res.status(500).json({ error: 'Failed' }); + } + } } export const patientController = new PatientController(); diff --git a/services/patient-service/src/modules/patients/patient.routes.ts b/services/patient-service/src/modules/patients/patient.routes.ts index 351fa88..df7ae01 100644 --- a/services/patient-service/src/modules/patients/patient.routes.ts +++ b/services/patient-service/src/modules/patients/patient.routes.ts @@ -34,4 +34,9 @@ router.delete('/:id', (req, res) => patientController.deactivatePatient(req, res) ); +router.post( + '/bulk-info', + patientController.getBulkInfo.bind(patientController) +); + export default router; diff --git a/services/patient-service/src/modules/patients/patient.service.ts b/services/patient-service/src/modules/patients/patient.service.ts index b12df86..9d462fd 100644 --- a/services/patient-service/src/modules/patients/patient.service.ts +++ b/services/patient-service/src/modules/patients/patient.service.ts @@ -265,6 +265,15 @@ class PatientService { return result.rows[0] || null; } + async getPatientsByIds(ids: string[]) { + if (!ids || ids.length === 0) return []; + const result = await pool.query( + `SELECT id, name FROM patients WHERE id = ANY($1::uuid[])`, + [ids] + ); + return result.rows; + } + /** * UPDATE PATIENT PROFILE */ diff --git a/services/staff-service/inventory_audit.json b/services/staff-service/inventory_audit.json new file mode 100644 index 0000000..b27077c --- /dev/null +++ b/services/staff-service/inventory_audit.json @@ -0,0 +1,47 @@ +[ + { + "name": "Dolo 250mg", + "category": "CONSUMABLE", + "quantity": 10 + }, + { + "name": "Paracetamol 500mg", + "category": "CONSUMABLE", + "quantity": 8 + }, + { + "name": "Ibuprofen 400mg", + "category": "MEDICINE", + "quantity": 300 + }, + { + "name": "Pantoprazole 40mg", + "category": "MEDICINE", + "quantity": 150 + }, + { + "name": "Syringes 5ml", + "category": "CONSUMABLE", + "quantity": 1000 + }, + { + "name": "Medical Gauze", + "category": "CONSUMABLE", + "quantity": 2000 + }, + { + "name": "Cetirizine 10mg", + "category": "MEDICINE", + "quantity": 99 + }, + { + "name": "Amoxicillin 250mg", + "category": "MEDICINE", + "quantity": 200 + }, + { + "name": "new", + "category": "CONSUMABLE", + "quantity": 300 + } +] diff --git a/services/staff-service/package.json b/services/staff-service/package.json index f454291..1183025 100644 --- a/services/staff-service/package.json +++ b/services/staff-service/package.json @@ -15,11 +15,12 @@ "license": "ISC", "packageManager": "pnpm@10.28.2", "dependencies": { + "@h-os/shared": "workspace:*", + "axios": "^1.13.5", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "express": "^5.2.1", - "jsonwebtoken": "^9.0.3", - "@h-os/shared": "workspace:*" + "jsonwebtoken": "^9.0.3" }, "devDependencies": { "@types/bcrypt": "^6.0.0", diff --git a/services/staff-service/scripts/init-db.ts b/services/staff-service/scripts/init-db.ts index bc5ca6a..50e4103 100644 --- a/services/staff-service/scripts/init-db.ts +++ b/services/staff-service/scripts/init-db.ts @@ -1,4 +1,4 @@ -import { pool } from '../src/db.ts'; +import { pool } from '../src/db'; import { randomUUID } from 'crypto'; async function init() { @@ -38,7 +38,7 @@ async function init() { } /** - * 2️⃣ Staff Table (FOREIGN KEY version) + * 2️⃣ Staff Table */ await pool.query(` CREATE TABLE IF NOT EXISTS staff ( @@ -71,7 +71,148 @@ async function init() { ); `); - console.log('✅ departments + staff + doctor_availability tables ready'); + /** + * 4️⃣ Wards Table + */ + await pool.query(` + CREATE TABLE IF NOT EXISTS wards ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT now() + ); + `); + + /** + * 5️⃣ Rooms Table + */ + await pool.query(` + CREATE TABLE IF NOT EXISTS rooms ( + id UUID PRIMARY KEY, + ward_id UUID REFERENCES wards(id) ON DELETE CASCADE, + room_number TEXT NOT NULL, + created_at TIMESTAMP DEFAULT now(), + UNIQUE (ward_id, room_number) + ); + `); + + /** + * 6️⃣ Beds Table + */ + await pool.query(` + CREATE TABLE IF NOT EXISTS beds ( + id UUID PRIMARY KEY, + room_id UUID REFERENCES rooms(id) ON DELETE CASCADE, + bed_number TEXT NOT NULL, + bed_type TEXT DEFAULT 'GENERAL', + status TEXT DEFAULT 'AVAILABLE', + created_at TIMESTAMP DEFAULT now(), + UNIQUE (room_id, bed_number) + ); + `); + + /** + * 7️⃣ Bed Assignments Table + */ + await pool.query(` + CREATE TABLE IF NOT EXISTS bed_assignments ( + id UUID PRIMARY KEY, + bed_id UUID REFERENCES beds(id) ON DELETE CASCADE, + patient_id UUID NOT NULL, + assigned_at TIMESTAMP DEFAULT now(), + discharged_at TIMESTAMP + ); + `); + + /** + * 8️⃣ Index for fast lookup of active assignments + */ + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_active_bed_assignments + ON bed_assignments (bed_id) + WHERE discharged_at IS NULL; + `); + + /** + * 9️⃣ Inventory Tables + */ + await pool.query(` + CREATE TABLE IF NOT EXISTS inventory_items ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + category TEXT NOT NULL, + quantity INT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT now() + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS inventory_transactions ( + id UUID PRIMARY KEY, + item_id UUID REFERENCES inventory_items(id) ON DELETE CASCADE, + type TEXT NOT NULL, + quantity INT NOT NULL, + staff_id UUID REFERENCES staff(id) ON DELETE SET NULL, + patient_id UUID, + timestamp TIMESTAMP DEFAULT now() + ); + `); + + /** + * 10️⃣ Pharmacy Tables + */ + await pool.query(` + CREATE TABLE IF NOT EXISTS prescriptions ( + id UUID PRIMARY KEY, + patient_id UUID NOT NULL, + patient_name TEXT, + doctor_id UUID REFERENCES staff(id) ON DELETE CASCADE, + status TEXT DEFAULT 'PENDING', + created_at TIMESTAMP DEFAULT now() + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS prescription_items ( + id UUID PRIMARY KEY, + prescription_id UUID REFERENCES prescriptions(id) ON DELETE CASCADE, + item_id UUID REFERENCES inventory_items(id) ON DELETE CASCADE, + quantity INT NOT NULL, + instructions TEXT + ); + `); + + // Ensure instructions column exists if table was created previously + await pool.query(` + ALTER TABLE prescription_items ADD COLUMN IF NOT EXISTS instructions TEXT; + `); + + /** + * Seed default medicines + */ + const defaultMedicines = [ + { name: 'Paracetamol 500mg', category: 'MEDICINE', quantity: 500 }, + { name: 'Amoxicillin 250mg', category: 'MEDICINE', quantity: 200 }, + { name: 'Ibuprofen 400mg', category: 'MEDICINE', quantity: 300 }, + { name: 'Cetirizine 10mg', category: 'MEDICINE', quantity: 100 }, + { name: 'Pantoprazole 40mg', category: 'MEDICINE', quantity: 150 }, + { name: 'Syringes 5ml', category: 'CONSUMABLE', quantity: 1000 }, + { name: 'Medical Gauze', category: 'CONSUMABLE', quantity: 2000 }, + ]; + + for (const med of defaultMedicines) { + await pool.query( + `INSERT INTO inventory_items (id, name, category, quantity) + VALUES ($1, $2, $3, $4) + ON CONFLICT (name) DO NOTHING`, + [randomUUID(), med.name, med.category, med.quantity] + ); + } + + console.log( + '✅ departments + staff + doctor_availability + bed management + inventory + pharmacy tables + seeds ready' + ); + process.exit(0); } diff --git a/services/staff-service/src/app.ts b/services/staff-service/src/app.ts index e3f3ef5..396bc7c 100644 --- a/services/staff-service/src/app.ts +++ b/services/staff-service/src/app.ts @@ -3,6 +3,10 @@ import cookieParser from 'cookie-parser'; import healthRouter from './routes/health'; import authRouter from './modules/auth/auth.routes'; import staffRouter from './modules/staff/staff.routes'; +import bedRoutes from './modules/beds/bed.routes'; +import adminBedRoutes from './modules/beds/admin.bed.routes'; +import inventoryRouter from './modules/inventory/inventory.routes'; +import pharmacyRouter from './modules/pharmacy/pharmacy.routes'; // import departmentRoutes from './modules/staff/department.routes' const app = express(); @@ -11,6 +15,11 @@ app.use(cookieParser()); app.use('/health', healthRouter); app.use('/auth', authRouter); +app.use('/staff/beds', bedRoutes); +app.use('/admin/beds', adminBedRoutes); + +app.use('/staff/inventory', inventoryRouter); +app.use('/staff/pharmacy', pharmacyRouter); app.use('/staff', staffRouter); // app.use('/departments',departmentRoutes); diff --git a/services/staff-service/src/modules/auth/staff.auth.controller.ts b/services/staff-service/src/modules/auth/staff.auth.controller.ts index d1ef41c..8ebc807 100644 --- a/services/staff-service/src/modules/auth/staff.auth.controller.ts +++ b/services/staff-service/src/modules/auth/staff.auth.controller.ts @@ -119,6 +119,7 @@ export class StaffAuthController { role: staff.role, job_title: staff.job_title, department: staff.department, + department_id: staff.department_id, }, }); } catch { @@ -170,10 +171,25 @@ export class StaffAuthController { /** * 🔐 NEW ACCESS TOKEN */ + // fetch staff info + const staffResult = await pool.query( + ` +SELECT role, job_title, department_id +FROM staff +WHERE id = $1 +`, + [stored.staff_id] + ); + + const staff = staffResult.rows[0]; + const newAccessToken = jwt.sign( { sub: stored.staff_id, type: 'STAFF', + role: staff.role, + job_title: staff.job_title, + department_id: staff.department_id, }, process.env.JWT_SECRET!, { expiresIn: ACCESS_TOKEN_EXPIRES_IN } @@ -279,11 +295,17 @@ export class StaffAuthController { } const result = await pool.query( - ` - SELECT id, name, email, department_id, role, job_title - FROM staff - WHERE id = $1 - `, + `SELECT + s.id, + s.name, + s.email, + s.role, + s.job_title, + d.id AS department_id, + d.name AS department +FROM staff s +JOIN departments d ON s.department_id = d.id +WHERE s.id = $1`, [payload.sub] ); diff --git a/services/staff-service/src/modules/beds/admin.bed.routes.ts b/services/staff-service/src/modules/beds/admin.bed.routes.ts new file mode 100644 index 0000000..0d3f488 --- /dev/null +++ b/services/staff-service/src/modules/beds/admin.bed.routes.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { BedsController } from './bed.controller'; + +const router = Router(); +const controller = new BedsController(); + +// ADMIN INFRASTRUCTURE MANAGEMENT + +router.get('/wards', controller.getWards.bind(controller)); + +router.get('/rooms/:wardId', controller.getRooms.bind(controller)); + +router.post('/wards', controller.createWard.bind(controller)); + +router.post('/rooms', controller.createRoom.bind(controller)); + +router.post('/beds', controller.createBed.bind(controller)); + +export default router; diff --git a/services/staff-service/src/modules/beds/bed.controller.ts b/services/staff-service/src/modules/beds/bed.controller.ts new file mode 100644 index 0000000..0d20970 --- /dev/null +++ b/services/staff-service/src/modules/beds/bed.controller.ts @@ -0,0 +1,91 @@ +import { Request, Response } from 'express'; +import { bedsService } from './bed.service'; + +export class BedsController { + async createWard(req: Request, res: Response) { + const { name, description } = req.body; + + const ward = await bedsService.createWard(name, description); + + res.json(ward); + } + + async createRoom(req: Request, res: Response) { + const { wardId, roomNumber } = req.body; + + const room = await bedsService.createRoom(wardId, roomNumber); + + res.json(room); + } + + async createBed(req: Request, res: Response) { + const { roomId, bedNumber } = req.body; + + const bed = await bedsService.createBed(roomId, bedNumber); + + res.json(bed); + } + + async getBeds(req: Request, res: Response) { + const beds = await bedsService.getBeds(); + + res.json(beds); + } + + async assignBed(req: Request, res: Response) { + const { bedId, patientId, admissionId } = req.body; + + const assignment = await bedsService.assignBed( + bedId, + patientId, + admissionId + ); + + res.json({ + message: 'Bed assigned successfully', + assignment, + }); + } + + async dischargePatient(req: Request, res: Response) { + const { admissionId } = req.body; + + await bedsService.dischargePatient(admissionId); + + res.json({ + message: 'Patient discharged and bed released', + }); + } + + async getActiveAssignments(req: Request, res: Response) { + const { patientIds } = req.body; + + if (!Array.isArray(patientIds) || patientIds.length === 0) { + res.json([]); + return; + } + + const assignments = await bedsService.getActiveAssignments(patientIds); + res.json(assignments); + } + + async getWards(req: Request, res: Response) { + const wards = await bedsService.getWards(); + res.json(wards); + } + async getBedsByRoom(req: Request, res: Response) { + const roomId = req.params.roomId as string; + + const beds = await bedsService.getBedsByRoom(roomId); + + res.json(beds); + } + + async getRooms(req: Request, res: Response) { + const wardId = req.params.wardId as string; + + const rooms = await bedsService.getRoomsByWard(wardId); + + res.json(rooms); + } +} diff --git a/services/staff-service/src/modules/beds/bed.routes.ts b/services/staff-service/src/modules/beds/bed.routes.ts new file mode 100644 index 0000000..29b50e9 --- /dev/null +++ b/services/staff-service/src/modules/beds/bed.routes.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { BedsController } from './bed.controller'; + +const router = Router(); +const controller = new BedsController(); + +// STAFF OPERATIONS ONLY +router.get('/', controller.getBeds.bind(controller)); + +router.get('/wards', controller.getWards.bind(controller)); + +router.get('/rooms/:wardId', controller.getRooms.bind(controller)); + +router.get('/beds/:roomId', controller.getBedsByRoom.bind(controller)); + +router.post('/assign', controller.assignBed.bind(controller)); + +router.post('/discharge', controller.dischargePatient.bind(controller)); + +router.post( + '/active-assignments', + controller.getActiveAssignments.bind(controller) +); + +export default router; diff --git a/services/staff-service/src/modules/beds/bed.service.ts b/services/staff-service/src/modules/beds/bed.service.ts new file mode 100644 index 0000000..7137563 --- /dev/null +++ b/services/staff-service/src/modules/beds/bed.service.ts @@ -0,0 +1,258 @@ +import { pool } from '../../db'; +import { randomUUID } from 'crypto'; +import axios from 'axios'; + +class BedsService { + async createWard(name: string, description?: string) { + const result = await pool.query( + ` + INSERT INTO wards (id, name, description) + VALUES ($1,$2,$3) + RETURNING * + `, + [randomUUID(), name, description] + ); + + return result.rows[0]; + } + + async createRoom(wardId: string, roomNumber: string) { + const result = await pool.query( + ` + INSERT INTO rooms (id, ward_id, room_number) + VALUES ($1,$2,$3) + RETURNING * + `, + [randomUUID(), wardId, roomNumber] + ); + + return result.rows[0]; + } + + async createBed(roomId: string, bedNumber: string) { + const result = await pool.query( + ` + INSERT INTO beds (id, room_id, bed_number) + VALUES ($1,$2,$3) + RETURNING * + `, + [randomUUID(), roomId, bedNumber] + ); + + return result.rows[0]; + } + + async getBeds() { + const result = await pool.query( + ` + SELECT + b.id, + b.bed_number, + b.status, + r.room_number, + w.name AS ward, + ba.patient_id + FROM beds b + JOIN rooms r ON b.room_id = r.id + JOIN wards w ON r.ward_id = w.id + LEFT JOIN bed_assignments ba ON b.id = ba.bed_id AND ba.discharged_at IS NULL + ` + ); + + const beds = result.rows; + const activePatientIds = beds + .filter((b) => b.status === 'OCCUPIED' && b.patient_id) + .map((b) => b.patient_id); + + if (activePatientIds.length === 0) return beds; + + try { + // 1. Fetch names and current admissions from patient-service + const [patientsRes, admissionsRes] = await Promise.all([ + axios.post(`${process.env.PATIENT_SERVICE_URL}/patients/bulk-info`, { + ids: activePatientIds, + }), + axios.post( + `${process.env.PATIENT_SERVICE_URL}/admissions/bulk-current`, + { + patientIds: activePatientIds, + } + ), + ]); + + const patientMap = new Map( + patientsRes.data.map((p: any) => [p.id, p.name]) + ); + const admissionMap = new Map( + admissionsRes.data.map((a: any) => [a.patient_id, a.doctor_id]) + ); + + const doctorIds = Array.from( + new Set(admissionsRes.data.map((a: any) => a.doctor_id)) as Set + ); + + // 2. Fetch doctor names from local staff table + let doctorMap = new Map(); + if (doctorIds.length > 0) { + const staffRes = await pool.query( + `SELECT id, name FROM staff WHERE id = ANY($1::uuid[])`, + [doctorIds] + ); + doctorMap = new Map(staffRes.rows.map((s: any) => [s.id, s.name])); + } + + // 3. Map back to beds + return beds.map((bed) => { + if (bed.status === 'OCCUPIED' && bed.patient_id) { + const doctorId = admissionMap.get(bed.patient_id); + return { + ...bed, + patient_name: patientMap.get(bed.patient_id) || 'Unknown Patient', + doctor_name: doctorId + ? doctorMap.get(doctorId) || 'Unknown Doctor' + : 'Unknown Doctor', + }; + } + return bed; + }); + } catch (e) { + console.error('Enrichment failed:', e); + return beds; + } + } + + async assignBed(bedId: string, patientId: string, admissionId: string) { + const check = await pool.query(`SELECT status FROM beds WHERE id = $1`, [ + bedId, + ]); + + if (check.rows[0]?.status !== 'AVAILABLE') { + throw new Error('Bed not available'); + } + + // Mark bed occupied + await pool.query(`UPDATE beds SET status = 'OCCUPIED' WHERE id = $1`, [ + bedId, + ]); + + // Create assignment + const assignment = await pool.query( + ` + INSERT INTO bed_assignments (id, bed_id, patient_id, admission_id) + VALUES ($1,$2,$3,$4) + RETURNING * + `, + [randomUUID(), bedId, patientId, admissionId] + ); + + // 🔁 Notify patient-service admission is complete + await axios.post( + `${process.env.PATIENT_SERVICE_URL}/admissions/${admissionId}/admit` + ); + + return assignment.rows[0]; + } + + async dischargePatient(admissionId: string) { + const result = await pool.query( + ` + SELECT bed_id + FROM bed_assignments + WHERE admission_id = $1 + AND discharged_at IS NULL + `, + [admissionId] + ); + + const bedId = result.rows[0]?.bed_id; + + if (!bedId) { + throw new Error('Active bed assignment not found'); + } + + await pool.query( + ` + UPDATE bed_assignments + SET discharged_at = now() + WHERE admission_id = $1 + `, + [admissionId] + ); + + await pool.query( + ` + UPDATE beds + SET status = 'AVAILABLE' + WHERE id = $1 + `, + [bedId] + ); + + await axios.post( + `${process.env.PATIENT_SERVICE_URL}/admissions/${admissionId}/discharged` + ); + } + + async getActiveAssignments(patientIds: string[]) { + if (!patientIds || patientIds.length === 0) return []; + + const result = await pool.query( + ` + SELECT + ba.patient_id, + b.bed_number, + r.room_number, + w.name AS ward + FROM bed_assignments ba + JOIN beds b ON ba.bed_id = b.id + JOIN rooms r ON b.room_id = r.id + JOIN wards w ON r.ward_id = w.id + WHERE ba.patient_id = ANY($1::uuid[]) + AND ba.discharged_at IS NULL + `, + [patientIds] + ); + + return result.rows; + } + + async getWards() { + const result = await pool.query(` + SELECT id, name + FROM wards + ORDER BY name + `); + + return result.rows; + } + + async getRoomsByWard(wardId: string) { + const result = await pool.query( + ` + SELECT id, room_number + FROM rooms + WHERE ward_id = $1 + ORDER BY room_number + `, + [wardId] + ); + + return result.rows; + } + async getBedsByRoom(roomId: string) { + const result = await pool.query( + ` + SELECT id, bed_number, status + FROM beds + WHERE room_id = $1 + AND status = 'AVAILABLE' + ORDER BY bed_number + `, + [roomId] + ); + + return result.rows; + } +} + +export const bedsService = new BedsService(); diff --git a/services/staff-service/src/modules/inventory/inventory.controller.ts b/services/staff-service/src/modules/inventory/inventory.controller.ts new file mode 100644 index 0000000..3441359 --- /dev/null +++ b/services/staff-service/src/modules/inventory/inventory.controller.ts @@ -0,0 +1,59 @@ +import { Request, Response } from 'express'; +import { inventoryService } from './inventory.service'; + +export class InventoryController { + async getItems(req: Request, res: Response) { + try { + const items = await inventoryService.getItems(); + res.json(items); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + } + + async createItem(req: Request, res: Response) { + try { + const staffId = req.headers['x-user-id'] as string; + const item = await inventoryService.createItem(req.body, staffId); + res.json(item); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + } + + async addStock(req: Request, res: Response) { + try { + const staffId = req.headers['x-user-id'] as string; + const { itemId, quantity } = req.body; + const item = await inventoryService.addStock(itemId, quantity, staffId); + res.json(item); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + } + + async useItem(req: Request, res: Response) { + try { + const staffId = req.headers['x-user-id'] as string; + const { itemId, quantity, patientId } = req.body; + const item = await inventoryService.useItem( + itemId, + quantity, + staffId, + patientId + ); + res.json(item); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + } + + async getTransactions(req: Request, res: Response) { + try { + const transactions = await inventoryService.getTransactions(); + res.json(transactions); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + } +} diff --git a/services/staff-service/src/modules/inventory/inventory.repository.ts b/services/staff-service/src/modules/inventory/inventory.repository.ts new file mode 100644 index 0000000..4da6794 --- /dev/null +++ b/services/staff-service/src/modules/inventory/inventory.repository.ts @@ -0,0 +1,77 @@ +import { pool } from '../../db'; +import { randomUUID } from 'crypto'; + +class InventoryRepository { + async getItems() { + const result = await pool.query( + `SELECT * FROM inventory_items ORDER BY name ASC` + ); + return result.rows; + } + + async createItem(data: { name: string; category: string; quantity: number }) { + const result = await pool.query( + ` + INSERT INTO inventory_items (id, name, category, quantity) + VALUES ($1, $2, $3, $4) + RETURNING * + `, + [randomUUID(), data.name, data.category, data.quantity] + ); + return result.rows[0]; + } + + async adjustQuantity(itemId: string, amount: number) { + const result = await pool.query( + ` + UPDATE inventory_items + SET quantity = quantity + $2 + WHERE id = $1 + RETURNING * + `, + [itemId, amount] + ); + return result.rows[0]; + } + + async logTransaction(data: { + itemId: string; + type: 'IN' | 'OUT'; + quantity: number; + staffId: string; + patientId?: string; + }) { + const result = await pool.query( + ` + INSERT INTO inventory_transactions (id, item_id, type, quantity, staff_id, patient_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, + [ + randomUUID(), + data.itemId, + data.type, + data.quantity, + data.staffId, + data.patientId || null, + ] + ); + return result.rows[0]; + } + + async getTransactions() { + const result = await pool.query( + ` + SELECT t.*, i.name as item_name, s.name as staff_name + FROM inventory_transactions t + JOIN inventory_items i ON t.item_id = i.id + LEFT JOIN staff s ON t.staff_id = s.id + ORDER BY t.timestamp DESC + LIMIT 100 + ` + ); + return result.rows; + } +} + +export const inventoryRepository = new InventoryRepository(); diff --git a/services/staff-service/src/modules/inventory/inventory.routes.ts b/services/staff-service/src/modules/inventory/inventory.routes.ts new file mode 100644 index 0000000..aea0402 --- /dev/null +++ b/services/staff-service/src/modules/inventory/inventory.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { InventoryController } from './inventory.controller'; + +const router = Router(); +const controller = new InventoryController(); + +router.get('/items', controller.getItems.bind(controller)); +router.post('/items', controller.createItem.bind(controller)); +router.post('/stock', controller.addStock.bind(controller)); +router.post('/use', controller.useItem.bind(controller)); +router.get('/transactions', controller.getTransactions.bind(controller)); + +export default router; diff --git a/services/staff-service/src/modules/inventory/inventory.service.ts b/services/staff-service/src/modules/inventory/inventory.service.ts new file mode 100644 index 0000000..5479abd --- /dev/null +++ b/services/staff-service/src/modules/inventory/inventory.service.ts @@ -0,0 +1,68 @@ +import { inventoryRepository } from './inventory.repository'; + +class InventoryService { + async getItems() { + return inventoryRepository.getItems(); + } + + async createItem( + data: { name: string; category: string; quantity: number }, + staffId: string + ) { + const item = await inventoryRepository.createItem(data); + if (data.quantity > 0) { + await inventoryRepository.logTransaction({ + itemId: item.id, + type: 'IN', + quantity: data.quantity, + staffId, + }); + } + return item; + } + + async addStock(itemId: string, quantity: number, staffId: string) { + if (quantity <= 0) throw new Error('Quantity must be greater than zero'); + + const updated = await inventoryRepository.adjustQuantity(itemId, quantity); + await inventoryRepository.logTransaction({ + itemId, + type: 'IN', + quantity, + staffId, + }); + + return updated; + } + + async useItem( + itemId: string, + quantity: number, + staffId: string, + patientId?: string + ) { + if (quantity <= 0) throw new Error('Quantity must be greater than zero'); + + const items = await inventoryRepository.getItems(); + const item = items.find((i: any) => i.id === itemId); + if (!item) throw new Error('Item not found'); + if (item.quantity < quantity) throw new Error('Insufficient stock'); + + const updated = await inventoryRepository.adjustQuantity(itemId, -quantity); + await inventoryRepository.logTransaction({ + itemId, + type: 'OUT', + quantity, + staffId, + patientId, + }); + + return updated; + } + + async getTransactions() { + return inventoryRepository.getTransactions(); + } +} + +export const inventoryService = new InventoryService(); diff --git a/services/staff-service/src/modules/pharmacy/pharmacy.controller.ts b/services/staff-service/src/modules/pharmacy/pharmacy.controller.ts new file mode 100644 index 0000000..dc24378 --- /dev/null +++ b/services/staff-service/src/modules/pharmacy/pharmacy.controller.ts @@ -0,0 +1,56 @@ +import { Request, Response } from 'express'; +import { pharmacyService } from './pharmacy.service'; + +export class PharmacyController { + async createPrescription(req: Request, res: Response) { + try { + const doctorId = String(req.headers['x-user-id']); + if (!doctorId || doctorId === 'undefined') { + return res.status(401).json({ error: 'User identity missing' }); + } + const { patientId, patientName, items } = req.body; + const result = await pharmacyService.createPrescription({ + patientId, + patientName, + doctorId, + items, + }); + res.json(result); + } catch (e: any) { + res.status(400).json({ error: e.message }); + } + } + + async getPending(req: Request, res: Response) { + try { + const result = await pharmacyService.getPendingPrescriptions(); + res.json(result); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } + } + + async dispense(req: Request, res: Response) { + try { + const staffId = String(req.headers['x-user-id']); + const id = String(req.params.id); + const result = await pharmacyService.dispense(id, staffId); + res.json(result); + } catch (e: any) { + res.status(400).json({ error: e.message }); + } + } + + async getMyPrescriptions(req: Request, res: Response) { + try { + const patientId = String(req.headers['x-user-id']); + if (!patientId || patientId === 'undefined') { + return res.status(401).json({ error: 'User identity missing' }); + } + const result = await pharmacyService.getPatientPrescriptions(patientId); + res.json(result); + } catch (e: any) { + res.status(400).json({ error: e.message }); + } + } +} diff --git a/services/staff-service/src/modules/pharmacy/pharmacy.repository.ts b/services/staff-service/src/modules/pharmacy/pharmacy.repository.ts new file mode 100644 index 0000000..09a4fb9 --- /dev/null +++ b/services/staff-service/src/modules/pharmacy/pharmacy.repository.ts @@ -0,0 +1,125 @@ +import { pool } from '../../db'; +import { randomUUID } from 'crypto'; + +class PharmacyRepository { + async createPrescription(data: { + patientId: string; + patientName: string; + doctorId: string; + items: { itemId: string; quantity: number; instructions?: string }[]; + }) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const prescriptionId = randomUUID(); + + const pRes = await client.query( + `INSERT INTO prescriptions (id, patient_id, patient_name, doctor_id, status) + VALUES ($1, $2, $3, $4, 'PENDING') RETURNING *`, + [prescriptionId, data.patientId, data.patientName, data.doctorId] + ); + + for (const item of data.items) { + await client.query( + `INSERT INTO prescription_items (id, prescription_id, item_id, quantity, instructions) + VALUES ($1, $2, $3, $4, $5)`, + [ + randomUUID(), + prescriptionId, + item.itemId, + item.quantity, + item.instructions || '', + ] + ); + } + + await client.query('COMMIT'); + return pRes.rows[0]; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + } + + async getPendingPrescriptions() { + const result = await pool.query( + ` + SELECT + p.id, + p.patient_id, + p.patient_name, + p.status, + p.created_at, + s.name as doctor_name, + json_agg(json_build_object( + 'id', pi.id, + 'item_id', pi.item_id, + 'quantity', pi.quantity, + 'instructions', pi.instructions, + 'item_name', i.name, + 'category', i.category, + 'stock_available', COALESCE(i.quantity, 0) + )) as items + FROM prescriptions p + LEFT JOIN staff s ON p.doctor_id = s.id + LEFT JOIN prescription_items pi ON p.id = pi.prescription_id + LEFT JOIN inventory_items i ON pi.item_id = i.id + WHERE p.status = 'PENDING' + GROUP BY p.id, s.name + ORDER BY p.created_at ASC + ` + ); + return result.rows; + } + + async markDispensed(prescriptionId: string) { + const result = await pool.query( + `UPDATE prescriptions SET status = 'DISPENSED' WHERE id = $1 RETURNING *`, + [prescriptionId] + ); + return result.rows[0]; + } + + async getPrescriptionItems(prescriptionId: string) { + const result = await pool.query( + `SELECT * FROM prescription_items WHERE prescription_id = $1`, + [prescriptionId] + ); + return result.rows; + } + + async getPatientPrescriptions(patientId: string) { + const result = await pool.query( + ` + SELECT + p.id, + p.patient_id, + p.patient_name, + p.status, + p.created_at, + s.name as doctor_name, + json_agg(json_build_object( + 'id', pi.id, + 'item_id', pi.item_id, + 'quantity', pi.quantity, + 'instructions', pi.instructions, + 'item_name', i.name, + 'category', i.category, + 'stock_available', COALESCE(i.quantity, 0) + )) as items + FROM prescriptions p + LEFT JOIN staff s ON p.doctor_id = s.id + LEFT JOIN prescription_items pi ON p.id = pi.prescription_id + LEFT JOIN inventory_items i ON pi.item_id = i.id + WHERE p.patient_id = $1 + GROUP BY p.id, s.name + ORDER BY p.created_at DESC + `, + [patientId] + ); + return result.rows; + } +} +export const pharmacyRepository = new PharmacyRepository(); diff --git a/services/staff-service/src/modules/pharmacy/pharmacy.routes.ts b/services/staff-service/src/modules/pharmacy/pharmacy.routes.ts new file mode 100644 index 0000000..2ffe1df --- /dev/null +++ b/services/staff-service/src/modules/pharmacy/pharmacy.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { PharmacyController } from './pharmacy.controller'; + +const router = Router(); +const controller = new PharmacyController(); + +router.post('/', controller.createPrescription.bind(controller)); +router.get('/pending', controller.getPending.bind(controller)); +router.post('/:id/dispense', controller.dispense.bind(controller)); +router.get('/me', controller.getMyPrescriptions.bind(controller)); + +export default router; diff --git a/services/staff-service/src/modules/pharmacy/pharmacy.service.ts b/services/staff-service/src/modules/pharmacy/pharmacy.service.ts new file mode 100644 index 0000000..68a8c98 --- /dev/null +++ b/services/staff-service/src/modules/pharmacy/pharmacy.service.ts @@ -0,0 +1,59 @@ +import { pharmacyRepository } from './pharmacy.repository'; +import { inventoryService } from '../inventory/inventory.service'; + +class PharmacyService { + async createPrescription(data: { + patientId: string; + patientName: string; + doctorId: string; + items: { itemId: string; quantity: number; instructions?: string }[]; + }) { + if (!data.items || data.items.length === 0) + throw new Error('Prescription must have at least one item'); + return pharmacyRepository.createPrescription(data); + } + + async getPendingPrescriptions() { + const prescriptions = await pharmacyRepository.getPendingPrescriptions(); + // Clean up any NULL entries from the aggregations if LEFT JOIN had no matches + return prescriptions.map((p: any) => ({ + ...p, + items: p.items.filter((item: any) => item.id !== null), + })); + } + + async dispense(prescriptionId: string, staffId: string) { + // 1. Validate the items + const items = await pharmacyRepository.getPrescriptionItems(prescriptionId); + if (!items.length) throw new Error('No items found on this prescription'); + + // We need patient mapping info, let's grab it directly + const pendingList = await pharmacyRepository.getPendingPrescriptions(); + const prescription = pendingList.find((p: any) => p.id === prescriptionId); + if (!prescription) + throw new Error('Prescription not found or already dispensed'); + + // 2. Iterate and useItem (this seamlessly checks constraints & reduces stock) + for (const item of items) { + await inventoryService.useItem( + item.item_id, + item.quantity, + staffId, + prescription.patient_id + ); + } + + // 3. Complete fulfillment + return pharmacyRepository.markDispensed(prescriptionId); + } + + async getPatientPrescriptions(patientId: string) { + const prescriptions = + await pharmacyRepository.getPatientPrescriptions(patientId); + return prescriptions.map((p: any) => ({ + ...p, + items: (p.items || []).filter((item: any) => item.id !== null), + })); + } +} +export const pharmacyService = new PharmacyService(); diff --git a/services/staff-service/src/modules/staff/staff.controller.ts b/services/staff-service/src/modules/staff/staff.controller.ts index 884f080..ee2165a 100644 --- a/services/staff-service/src/modules/staff/staff.controller.ts +++ b/services/staff-service/src/modules/staff/staff.controller.ts @@ -38,6 +38,22 @@ export class StaffController { } } + async getBulkBasicInfo(req: Request, res: Response) { + try { + const { staffIds } = req.body; + if (!Array.isArray(staffIds) || staffIds.length === 0) { + res.json([]); + return; + } + const data = await staffService.getBulkBasicInfo(staffIds); + res.json(data); + return; + } catch (err) { + res.status(500).json({ error: 'Failed to fetch bulk staff info' }); + return; + } + } + async updateStaff(req: Request, res: Response) { try { const id = getIdParam(req); diff --git a/services/staff-service/src/modules/staff/staff.routes.ts b/services/staff-service/src/modules/staff/staff.routes.ts index 775fed2..aa8a05e 100644 --- a/services/staff-service/src/modules/staff/staff.routes.ts +++ b/services/staff-service/src/modules/staff/staff.routes.ts @@ -5,16 +5,24 @@ const router = Router(); const controller = new StaffController(); router.get('/departments', controller.getDepartments.bind(controller)); + router.get('/doctors', controller.getDoctorsByDepartment.bind(controller)); + router.post('/availability', controller.setAvailability.bind(controller)); + router.get( '/availability/:doctorId/:dayOfWeek', controller.getAvailability.bind(controller) ); router.post('/', controller.createStaff.bind(controller)); -router.get('/:id', controller.getStaffById.bind(controller)); -router.put('/:id', controller.updateStaff.bind(controller)); -router.delete('/:id', controller.deactivateStaff.bind(controller)); + +router.get('/by-id/:id', controller.getStaffById.bind(controller)); + +router.post('/bulk-basic-info', controller.getBulkBasicInfo.bind(controller)); + +router.put('/by-id/:id', controller.updateStaff.bind(controller)); + +router.delete('/by-id/:id', controller.deactivateStaff.bind(controller)); export default router; diff --git a/services/staff-service/src/modules/staff/staff.service.ts b/services/staff-service/src/modules/staff/staff.service.ts index dbb984d..819ee87 100644 --- a/services/staff-service/src/modules/staff/staff.service.ts +++ b/services/staff-service/src/modules/staff/staff.service.ts @@ -106,6 +106,23 @@ class StaffService { return result.rows[0] || null; } + async getBulkBasicInfo(staffIds: string[]) { + if (!staffIds || staffIds.length === 0) return []; + const result = await pool.query( + ` + SELECT + s.id, + s.name AS doctor_name, + d.name AS department_name + FROM staff s + LEFT JOIN departments d ON s.department_id = d.id + WHERE s.id = ANY($1::uuid[]) + `, + [staffIds] + ); + return result.rows; + } + async updateStaff(id: string, data: any) { const result = await pool.query( `