init commit

This commit is contained in:
Yanfeng 2025-04-30 17:28:58 +01:00
commit 1312f21849
Signed by: yanfeng
GPG Key ID: 00610B08C1BF7BE9
68 changed files with 16526 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.idea
.vs
.vscode
__pycache__
/HMS_Backend/HMS_Backend.zip
/HMS_Frontend/HMS_Frontend.zip
/HMS_Frontend/HMS_Frontend_2.zip

View File

@ -0,0 +1 @@
from .hms import *

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,271 @@
from flask_sqlalchemy import SQLAlchemy
from flask import Flask
from flask_cors import CORS
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime as dt
from datetime import date as dt_date
from typing import Optional, Union
from enum import Enum
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///hospital.db'
CORS(app, supports_credentials=True)
db = SQLAlchemy(app)
class MIntEnum(int, Enum):
def __eq__(self, other):
if isinstance(other, Enum):
return self.value == other.value
if isinstance(other, int):
return self.value == other
return super().__eq__(other)
def __int__(self):
return int(self.value)
class MStrEnum(str, Enum):
def __eq__(self, other):
if isinstance(other, Enum):
return self.value == other.value
if isinstance(other, str):
return self.value == other
return super().__eq__(other)
def __str__(self):
return str(self.value)
class UserType(MIntEnum):
UNAUTHORIZED = -1
PATIENT = 0
DOCTOR = 1
class UserPermissionLevel(MIntEnum):
UNAUTHORIZED = -1
PATIENT = 0
DOCTOR = 1
# CONSULTANT = 2
ADMIN = 3
class DoctorTeamType(MIntEnum):
DOCTOR_TEAM = 0
RECEPTIONIST_TEAM = 2
ADMIN_TEAM = 4
class DoctorGrade(MIntEnum):
Unknown = 0
Junior = 2
Registrar = 4
Consultant = 6
class Departments(MStrEnum):
Internal_Medicine = "Internal Medicine" # 内科
Surgery = "Surgery" # 外科
Obstetrics_and_Gynecology = "Obstetrics and Gynecology" # 妇产科
Pediatrics = "Pediatrics" # 儿科
Ophthalmology = "Ophthalmology" # 眼科
Otolaryngology = "Otolaryngology (ENT)" # 耳鼻喉科
Dermatology = "Dermatology" # 皮肤科
Psychiatry = "Psychiatry" # 精神科
Neurology = "Neurology" # 神经科
Cardiology = "Cardiology" # 心内科
Radiology = "Radiology" # 放射科
Emergency_Department = "Emergency Department" # 急诊科
ICU = "Intensive Care Unit (ICU)" # 重症监护室
Oncology = "Oncology" # 肿瘤科
Orthopedics = "Orthopedics" # 骨科
Urology = "Urology" # 泌尿外科
Rehabilitation_Medicine = "Rehabilitation Medicine" # 康复医学科
Dentistry = "Dentistry / Oral and Maxillofacial Surgery" # 口腔科
Traditional_Chinese_Medicine = "Traditional Chinese Medicine" # 中医科
Reception = "Reception" # 接待
Admin = "Admin"
class WardTypes(MStrEnum):
GENERAL = "General Ward" # 普通病房
ICU = "Intensive Care Unit" # 重症监护室
ISOLATION = "Isolation" # 隔离病房
MATERNITY = "Maternity" # 产科病房
PEDIATRIC = "Pediatric" # 儿科病房
class BaseDefine:
categories = [
"consultation",
"follow_up",
"admission",
"diagnostic",
"therapy",
"surgery"
]
MAX_PER_PAGE = 50
DEFAULT_PER_PAGE = 50 # 16
MAX_PATIENTS_PER_WARD = 12
class BaseModel(db.Model):
__abstract__ = True
def to_dict(self, exclude: set = None):
exclude = exclude or set()
ret = {}
for c in self.__table__.columns:
if c.name not in exclude:
curr_item = getattr(self, c.name)
if isinstance(curr_item, dt):
ret[c.name] = int(curr_item.timestamp())
elif isinstance(curr_item, dt_date):
ret[c.name] = curr_item.strftime("%d/%m/%Y")
elif isinstance(curr_item, Enum):
ret[c.name] = curr_item.value
else:
ret[c.name] = curr_item
return ret
# 病房
class Ward(BaseModel):
__tablename__ = 'wards'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
name: str = db.Column(db.Text, nullable=False)
type: str = db.Column(db.Text, nullable=False)
total_capacity: int = db.Column(db.Integer, nullable=False)
current_occupancy: int = db.Column(db.Integer, nullable=False)
# 医生基础信息
class Doctor(BaseModel):
__tablename__ = 'doctors'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
name: str = db.Column(db.Text, nullable=False)
title: str = db.Column(db.Text, nullable=False)
birth_date: dt_date = db.Column(db.Date, nullable=False)
gender: str = db.Column(db.Text, nullable=False)
phone: str = db.Column(db.Text, nullable=False)
email: str = db.Column(db.Text, nullable=False)
grade: int = db.Column(db.Integer, nullable=False)
is_resigned: bool = db.Column(db.Boolean, default=0, nullable=False) # 是否离职
# 医生团队
class Team(BaseModel):
__tablename__ = 'teams'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
department: str = db.Column(db.Text, nullable=False) # Departments
consultant_id: int = db.Column(db.Integer, db.ForeignKey('doctors.id'), nullable=False)
is_admin_team: bool = db.Column(db.Boolean, default=0, nullable=False) # 是否为管理员团队
def get_team_type(self) -> DoctorTeamType:
if self.is_admin_team:
return DoctorTeamType.ADMIN_TEAM
elif self.department == 'Reception':
return DoctorTeamType.RECEPTIONIST_TEAM
else:
return DoctorTeamType.DOCTOR_TEAM
# 医生与团队的关联表
class DoctorTeamRole(BaseModel):
__tablename__ = 'doctor_team_roles'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
doctor_id: int = db.Column(db.Integer, db.ForeignKey('doctors.id'), nullable=False)
team_id: int = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
# 患者
class Patient(BaseModel):
__tablename__ = 'patients'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
name: str = db.Column(db.Text, nullable=False)
title: str = db.Column(db.Text, nullable=False)
birth_date: dt_date = db.Column(db.Date, nullable=False)
gender: str = db.Column(db.Text, nullable=False)
phone: str = db.Column(db.Text, nullable=False)
email: str = db.Column(db.Text, nullable=False)
postcode: str = db.Column(db.Text, nullable=False)
address: str = db.Column(db.Text, nullable=False)
# 患者住院信息
class Admission(BaseModel):
__tablename__ = 'admissions'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
appointment_id: int = db.Column(db.Integer, db.ForeignKey('appointments.id'), nullable=False)
patient_id: int = db.Column(db.Integer, db.ForeignKey('patients.id'), nullable=False)
ward_id: int = db.Column(db.Integer, db.ForeignKey('wards.id'), nullable=False)
team_id: int = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
consultant_id: int = db.Column(db.Integer, db.ForeignKey('doctors.id'), nullable=False)
admitted_at: dt = db.Column(db.DateTime, nullable=False)
# discharged_at: Optional[dt] = db.Column(db.DateTime)
# 治疗记录
class Treatment(BaseModel):
__tablename__ = 'treatments'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
appointment_id: int = db.Column(db.Integer, db.ForeignKey('appointments.id'), nullable=False)
doctor_id: int = db.Column(db.Integer, db.ForeignKey('doctors.id'), nullable=False)
patient_id: int = db.Column(db.Integer, db.ForeignKey('patients.id'), nullable=False)
treated_at: dt = db.Column(db.DateTime, nullable=False)
treat_info: str = db.Column(db.Text, nullable=False)
# 预约
class Appointment(BaseModel):
__tablename__ = 'appointments'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
patient_id: int = db.Column(db.Integer, db.ForeignKey('patients.id'), nullable=False)
category: str = db.Column(db.Text, nullable=False)
appointment_time: dt = db.Column(db.DateTime, nullable=False)
description: str = db.Column(db.Text, nullable=False)
approved: bool = db.Column(db.Boolean, default=False, nullable=False)
feedback: Optional[str] = db.Column(db.Text)
assigned_team: int = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
admitted: bool = db.Column(db.Boolean, default=False, nullable=False)
discharged: bool = db.Column(db.Boolean, default=False, nullable=False)
# 用户表(用于存储登录信息,关联患者或医生信息)
class User(BaseModel):
__tablename__ = 'users'
id: int = db.Column(db.Integer, primary_key=True, autoincrement=True)
username: str = db.Column(db.Text, unique=True, nullable=False)
password_hash: str = db.Column(db.Text, nullable=False)
role: int = db.Column(db.Integer, nullable=False) # 0: 患者, 1: 医生
patient_id: Optional[int] = db.Column(db.Integer, db.ForeignKey('patients.id'))
doctor_id: Optional[int] = db.Column(db.Integer, db.ForeignKey('doctors.id'))
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
# 登录凭据表
class Credentials(BaseModel):
__tablename__ = 'credentials'
token: str = db.Column(db.Text, primary_key=True)
expire_at: dt = db.Column(db.DateTime, nullable=False)
role: int = db.Column(db.Integer, nullable=False) # 0: 患者, 1: 医生
patient_id: Optional[int] = db.Column(db.Integer, db.ForeignKey('patients.id'))
doctor_id: Optional[int] = db.Column(db.Integer, db.ForeignKey('doctors.id'))
class UserLoginInfo:
def __init__(self, user_type: UserType, user_permission: UserPermissionLevel,
user_data: Optional[Union[Patient, Doctor]]):
self.user_type = user_type
self.user_permission = user_permission
self.user_data = user_data

View File

@ -0,0 +1,98 @@
import string
import random
import datetime
from typing import Optional, Union, List, Tuple
from . import models as m
def generate_randstring(num=8):
value = ''.join(random.sample(string.ascii_letters + string.digits, num))
return value
def get_doctor_is_resigned(doctor_id: int) -> bool:
doctor: Optional[m.Doctor] = m.Doctor.query.filter_by(id=doctor_id).first()
if not doctor:
return True
if doctor.is_resigned:
return True
return False
def get_doctor_teams(doctor_id: int) -> List[m.Team]:
if get_doctor_is_resigned(doctor_id):
return []
doctor_team: Optional[List[m.DoctorTeamRole]] = m.DoctorTeamRole.query.filter_by(doctor_id=doctor_id).all()
if not doctor_team:
return []
ret = []
for team in doctor_team:
curr_team: m.Team = m.Team.query.filter_by(id=team.team_id).first()
ret.append(curr_team)
return ret
def get_team_can_reception(team: m.Team) -> bool:
team_type = team.get_team_type()
if team_type == m.DoctorTeamType.RECEPTIONIST_TEAM:
return True
elif team_type == m.DoctorTeamType.ADMIN_TEAM:
return True
return False
def get_doctor_is_admin(doctor_id: int) -> bool:
if get_doctor_is_resigned(doctor_id):
return False
doctor_teams = get_doctor_teams(doctor_id)
for team in doctor_teams:
if team.is_admin_team:
return True
return False
def get_doctor_can_reception(doctor_id: int) -> bool:
if get_doctor_is_resigned(doctor_id):
return False
doctor_teams = get_doctor_teams(doctor_id)
if not doctor_teams:
return False
for team in doctor_teams:
if get_team_can_reception(team):
return True
return False
def get_user_info_from_token(token: str) -> Optional[Union[m.Patient, m.Doctor]]:
credentials = m.Credentials.query.filter_by(token=token).first()
if not credentials:
return None
if credentials.expire_at < datetime.datetime.now():
return None
if credentials.role == 0:
user: m.Patient = m.Patient.query.filter_by(id=credentials.patient_id).first()
else:
user: m.Doctor = m.Doctor.query.filter_by(id=credentials.doctor_id).first()
return user
def get_user_info_with_permission_from_token(token: str) -> m.UserLoginInfo:
user_info = get_user_info_from_token(token)
if not user_info:
return m.UserLoginInfo(m.UserType.UNAUTHORIZED, m.UserPermissionLevel.UNAUTHORIZED, None)
if isinstance(user_info, m.Patient): # 病人
return m.UserLoginInfo(m.UserType.PATIENT, m.UserPermissionLevel.PATIENT, user_info)
elif isinstance(user_info, m.Doctor):
if user_info.is_resigned: # 离职医生
return m.UserLoginInfo(m.UserType.UNAUTHORIZED, m.UserPermissionLevel.UNAUTHORIZED, None)
user_permission = m.UserPermissionLevel.DOCTOR # 医生
doctor_teams = get_doctor_teams(user_info.id)
if doctor_teams:
for team in doctor_teams:
if team.is_admin_team: # 管理员团队
user_permission = m.UserPermissionLevel.ADMIN
break
return m.UserLoginInfo(m.UserType.DOCTOR, user_permission, user_info)
else:
return m.UserLoginInfo(m.UserType.UNAUTHORIZED, m.UserPermissionLevel.UNAUTHORIZED, None)

Binary file not shown.

6
HMS_Backend/main.py Normal file
View File

@ -0,0 +1,6 @@
import HMS_Backend
if __name__ == "__main__":
# Initialize the backend
HMS_Backend.app.run("0.0.0.0", port=721)

View File

@ -0,0 +1,4 @@
Flask~=3.0.3
flask-cors~=5.0.1
Werkzeug~=3.1.1
flask-sqlalchemy~=3.1.1

View File

@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

2
HMS_Frontend/.env Normal file
View File

@ -0,0 +1,2 @@
# VITE_API_ENDPOINT = "http://localhost:721"
VITE_API_ENDPOINT = "https://hms_api.yanfeng.uk"

8
HMS_Frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
.idea
.vscode

22
HMS_Frontend/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

87
HMS_Frontend/README.md Normal file
View File

@ -0,0 +1,87 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
To build and run using Docker:
```bash
docker build -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

15
HMS_Frontend/app/app.css Normal file
View File

@ -0,0 +1,15 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
html,
body {
@apply bg-white dark:bg-gray-950;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}

View File

@ -0,0 +1,47 @@
import {Affix, Button, Group, rem, Stack} from "@mantine/core";
import ArrowUpIcon from "mdi-react/ArrowUpIcon";
import HomeIcon from "mdi-react/HomeIcon";
import {useNavigate} from "react-router";
import {useWindowScroll} from "@mantine/hooks";
import {ThemeToggle} from "~/components/subs/ThemeToggle.tsx";
import {roundThemeButton} from "~/styles.ts";
export default function () {
const navigate = useNavigate();
const [scroll, scrollTo] = useWindowScroll();
const buttonSize = 23;
const circleSize = 55;
return (
<>
<Affix position={{ bottom: 20, right: 20 }}>
<Group dir="reverse">
<Button
radius={circleSize}
w={circleSize}
h={circleSize}
p={0}
disabled={scroll.y <= 0}
onClick={() => scrollTo({ y: 0 })}
>
<ArrowUpIcon size={buttonSize} />
</Button>
<ThemeToggle extraStyle={roundThemeButton} iconStyle={{width: rem(buttonSize - 3), height: rem(buttonSize - 3)}}/>
<Button
radius={circleSize}
w={circleSize}
h={circleSize}
p={0}
onClick={() => navigate("/")}
>
<HomeIcon size={buttonSize}/>
</Button>
</Group>
</Affix>
</>
)
}

View File

@ -0,0 +1,62 @@
import {Burger, Group, Image, Text} from "@mantine/core";
import {ThemeToggle} from "./subs/ThemeToggle.tsx";
import React, {useEffect, useState} from "react";
export function PageHeader({opened, toggle, appendTitle}: {opened: boolean, toggle: () => void, appendTitle?: string}) {
const [width, setWidth] = useState(window.innerWidth)
// const [height, setHeight] = useState(window.innerHeight)
const [title, setTitle] = useState('HMS Dashboard');
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
// setHeight(window.innerHeight);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
// useEffect(() => {
// const titleElement = document.querySelector('title');
// const observer = new MutationObserver((mutations) => {
// mutations.forEach((mutation) => {
// mutation.target.textContent && setTitle(mutation.target.textContent);
// });
// });
//
// if (titleElement) {
// observer.observe(titleElement, { childList: true });
// }
//
// return () => {
// observer.disconnect();
// };
// }, []);
return (
<Group justify="space-between">
<Group>
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<Image src="/favicon.ico" w={30} h={30}/>
{width >= 290 && <Text size="lg" fw={700}>{title}</Text>}
{appendTitle && <Text size="sm">{appendTitle}</Text>}
</Group>
<Group>
<ThemeToggle/>
</Group>
</Group>
)
}

View File

@ -0,0 +1,98 @@
import {Button, Divider, Flex, Tabs} from "@mantine/core";
import {iconMStyle, maxWidth} from "../styles.ts";
import React from "react";
import {DashboardPageType, UserPermissionLevel} from "~/utils/hms_enums.ts";
import HomeIcon from "mdi-react/HomeIcon";
import InformationIcon from "mdi-react/InformationIcon";
import LogoutIcon from "mdi-react/LogoutIcon";
import AccountGroupIcon from "mdi-react/AccountGroupIcon";
import BedIcon from "mdi-react/BedIcon";
import AccountWrenchIcon from "mdi-react/AccountWrenchIcon";
import BookAccountIcon from "mdi-react/BookAccountIcon";
import HospitalBoxIcon from "mdi-react/HospitalBoxIcon";
export function PageNavbar({currentStat, changePageStat, userPermission, canReception}:
{currentStat: DashboardPageType, changePageStat: (p: DashboardPageType) => any,
userPermission: UserPermissionLevel, canReception: boolean}) {
const onClickLogout = () => {
localStorage.removeItem("hms_token")
changePageStat(DashboardPageType.LoginPage)
}
return (
<>
<Tabs variant="pills" orientation="vertical" defaultValue="gallery" value={currentStat} style={{flex: 1}}
onChange={(e) => {if (e) changePageStat(e as DashboardPageType)}}>
<Tabs.List style={maxWidth}>
<Tabs.Tab value={DashboardPageType.Home} leftSection={<HomeIcon style={iconMStyle}/>}>
Home
</Tabs.Tab>
{ userPermission == UserPermissionLevel.PATIENT && <>
<Divider my="0px" label="Patient" labelPosition="center" />
<Tabs.Tab value={DashboardPageType.PatientBooking} leftSection={<BookAccountIcon style={iconMStyle}/>}>
My Appointments
</Tabs.Tab>
<Tabs.Tab value={DashboardPageType.TreatmentRecord} leftSection={<BookAccountIcon style={iconMStyle}/>}>
My Treatments
</Tabs.Tab>
</>}
{ canReception && <>
<Divider my="0px" label="Reception" labelPosition="center" />
<Tabs.Tab value={DashboardPageType.AppointManagement} leftSection={<BookAccountIcon style={iconMStyle}/>}>
Appointments Management
</Tabs.Tab>
</>}
{ userPermission >= UserPermissionLevel.DOCTOR && <>
<Divider my="0px" label="Doctor" labelPosition="center" />
<Tabs.Tab value={DashboardPageType.DoctorTreatment} leftSection={<HospitalBoxIcon style={iconMStyle}/>}>
Patient Treatment
</Tabs.Tab>
</>}
{ userPermission >= UserPermissionLevel.ADMIN && <>
<Divider my="0px" label="Ward Management" labelPosition="center" />
<Tabs.Tab value={DashboardPageType.WardsManagement} leftSection={<BedIcon style={iconMStyle}/>}>
Ward Management
</Tabs.Tab>
<Divider my="0px" label="User Management" labelPosition="center" />
<Tabs.Tab value={DashboardPageType.MedicalTeamsManagement} leftSection={<AccountGroupIcon style={iconMStyle}/>}>
Medical Teams Management
</Tabs.Tab>
<Tabs.Tab value={DashboardPageType.StaffManagement} leftSection={<AccountWrenchIcon style={iconMStyle}/>}>
Staff Management
</Tabs.Tab>
<Tabs.Tab value={DashboardPageType.PatientsManagement} leftSection={<AccountWrenchIcon style={iconMStyle}/>}>
Patients Management
</Tabs.Tab>
</> }
{ userPermission >= UserPermissionLevel.DOCTOR && <>
<Divider my="0px" label="Records" labelPosition="center" />
<Tabs.Tab value={DashboardPageType.TreatmentRecord} leftSection={<BookAccountIcon style={iconMStyle}/>}>
Treatment Records
</Tabs.Tab>
</>}
</Tabs.List>
</Tabs>
<Flex gap="md" justify="flex-start" align="flex-end" direction="row" wrap="wrap" style={maxWidth}>
<Button variant="outline" color="blue" fullWidth justify="start" onClick={() => changePageStat(DashboardPageType.About)}
leftSection={<InformationIcon style={iconMStyle}/>}>
About
</Button>
<Button variant="outline" color="red" fullWidth justify="start" onClick={onClickLogout}
leftSection={<LogoutIcon style={iconMStyle}/>}>
Logout
</Button>
</Flex>
</>
)
}

View File

@ -0,0 +1,75 @@
import {useEffect, useMemo, useState} from "react";
import {Button, Checkbox, Group, MultiSelect, Select, Stack, Text} from "@mantine/core";
import {BookingCategory} from "~/utils/hms_enums.ts";
interface AppointmentManageFilterProps {
onChange: (categories: string[], status: number, discharged: boolean) => void;
}
export function AppointmentManageFilter({onChange}: AppointmentManageFilterProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [filterStatus, setFilterStatus] = useState<string>('-1');
const [includeDischarged, setIncludeDischarged] = useState<boolean>(false);
const categoryOptions = useMemo(() => {
return Object.entries(BookingCategory).map(([key, value]) => ({
value,
label: key.replace(/_/g, ' ')
}));
}, []);
useEffect(() => {
onChange(
selectedCategories,
parseInt(filterStatus),
includeDischarged
)
}, [selectedCategories, filterStatus, includeDischarged]);
const statusOptions = useMemo(() => [
{value: '-1', label: 'All'},
{value: '0', label: 'Pending'},
{value: '1', label: 'Approved'},
{value: '2', label: 'Rejected'},
], []);
const handleResetFilter = () => {
setSelectedCategories([]);
setFilterStatus('-1');
setIncludeDischarged(false);
onChange([], -1, false);
};
const handleStatusChange = (value: string | null) => {
setFilterStatus(value || '-1');
};
return (
<Stack>
<MultiSelect
label="Category"
data={categoryOptions}
value={selectedCategories}
onChange={setSelectedCategories}
clearable
searchable
nothingFoundMessage="No results found"
placeholder="Select categories"
/>
<Select
label="Status"
data={statusOptions}
value={filterStatus}
onChange={handleStatusChange}
placeholder="Select status"
/>
<Checkbox
label="Include discharged patients"
checked={includeDischarged}
onChange={(e) => setIncludeDischarged(e.currentTarget.checked)}
/>
</Stack>
);
}

View File

@ -0,0 +1,40 @@
import { useNavigate, useLocation } from 'react-router';
import {useEffect, useMemo, useState} from "react";
export default function BackButton() {
const navigate = useNavigate();
const location = useLocation();
const [canBack, setCanBack] = useState(window.history.length > 1);
useEffect(() => {
setCanBack(window.history.length > 1);
}, [location.key]);
const supportsNavigationAPI = useMemo(
() =>
typeof window !== "undefined" &&
"navigation" in window &&
"canGoBack" in (window as any).navigation,
[]
);
return (
<button
disabled={!canBack}
onClick={() => {
if (supportsNavigationAPI) {
// @ts-ignore
if (!window.navigation.canGoBack) return
}
navigate(-1)
}}
style={{
opacity: canBack ? 1 : 0.35,
cursor : canBack ? "pointer" : "default",
transition: "opacity .2s",
}}
>
</button>
);
}

View File

@ -0,0 +1,173 @@
import React, { useEffect, useState } from 'react';
import { ActionIcon, Button, Group, Modal, Stack, Text, Loader } from '@mantine/core';
import { showErrorMessage } from '~/utils/utils.ts';
import { apiGetDoctorsList } from '~/utils/hms_api.ts';
import type { DoctorData, DoctorDataWithPermission } from '~/utils/models.ts';
import InfoIcon from 'mdi-react/InfoIcon';
import { iconMStyle } from '~/styles.ts';
import { DoctorGrade } from '~/utils/hms_enums.ts';
// 缓存已加载的医生数据,避免重复请求
const doctorCache = new Map<number, DoctorDataWithPermission>();
interface DoctorInfoDisplayProps {
doctorId: number;
showIcon?: boolean;
}
export function DoctorInfoDisplay({ doctorId, showIcon = true }: DoctorInfoDisplayProps) {
const [doctorInfo, setDoctorInfo] = useState<DoctorDataWithPermission | null>(null);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
useEffect(() => {
// 如果已经在缓存中,直接使用缓存数据
if (doctorCache.has(doctorId)) {
setDoctorInfo(doctorCache.get(doctorId) || null);
setLoading(false);
setInitialLoad(false);
return;
}
// 否则加载医生数据
fetchDoctorInfo();
}, [doctorId]);
const fetchDoctorInfo = async () => {
try {
setLoading(true);
// 获取所有医生信息,传递-1表示不分页获取所有医生
const response = await apiGetDoctorsList(-1);
if (response.success) {
const doctors = response.data.doctors;
// 将所有医生添加到缓存
doctors.forEach(doctor => {
doctorCache.set(doctor.id, doctor);
});
// 查找当前需要的医生
const doctor = doctors.find(d => d.id === doctorId);
if (doctor) {
setDoctorInfo(doctor);
} else {
console.warn(`Doctor with ID ${doctorId} not found`);
}
} else {
showErrorMessage(response.message, "Failed to load doctor information");
}
} catch (error) {
showErrorMessage("Error loading doctor information", "Error");
console.error("Error fetching doctor information:", error);
} finally {
setLoading(false);
setInitialLoad(false);
}
};
const openModal = () => {
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
};
// 初始加载时显示加载状态
if (initialLoad) {
return <Loader size="xs" />;
}
// 如果无法找到医生信息,显示 ID
if (!doctorInfo) {
return (
<Group gap="xs">
<Text>ID: {doctorId}</Text>
{showIcon && (
<ActionIcon
size="sm"
onClick={fetchDoctorInfo}
loading={loading}
title="Refresh doctor information"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
)}
</Group>
);
}
return (
<>
<Group gap="xs">
<Text
style={{ cursor: 'pointer' }}
onClick={openModal}
title="Click to view details"
>
{`${doctorInfo.title} ${doctorInfo.name}`}
</Text>
{showIcon && (
<ActionIcon
size="sm"
onClick={openModal}
title="View doctor details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
)}
</Group>
<Modal
opened={modalOpen}
onClose={closeModal}
title="Doctor Information"
centered
size="md"
>
<Stack gap="md">
<Group>
<Text fw={500} w={100}>Name:</Text>
<Text>{`${doctorInfo.title} ${doctorInfo.name}`}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Gender:</Text>
<Text>{doctorInfo.gender}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Grade:</Text>
<Text>{DoctorGrade[doctorInfo.grade]}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Birth Date:</Text>
<Text>{doctorInfo.birth_date}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Email:</Text>
<Text>{doctorInfo.email}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Phone:</Text>
<Text>{doctorInfo.phone}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Admin:</Text>
<Text>{doctorInfo.is_admin ? 'Yes' : 'No'}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Status:</Text>
<Text color={doctorInfo.is_resigned ? 'red' : 'green'}>
{doctorInfo.is_resigned ? 'Resigned' : 'Active'}
</Text>
</Group>
<Group justify="flex-end" mt="md">
<Button onClick={closeModal}>Close</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@ -0,0 +1,73 @@
import type {DoctorTeamInfo, OutletContextType} from "~/utils/models.ts";
import {ActionIcon, Card, Group, Table, Text, Tooltip} from "@mantine/core";
import HistoryIcon from "mdi-react/HistoryIcon";
import {iconMStyle, marginRound} from "~/styles.ts";
import EyeIcon from "mdi-react/EyeIcon";
import {useOutletContext} from "react-router";
import {DashboardPageType} from "~/utils/hms_enums.ts";
import {confirmViewTeamMembers} from "~/components/subs/confirms.tsx";
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
export default function({doctorTeams}: {doctorTeams: DoctorTeamInfo[]}) {
const { changePage } = useOutletContext<OutletContextType>();
const handleViewTeamMembers = (team: DoctorTeamInfo) => {
confirmViewTeamMembers(team)
}
const teamRows = doctorTeams.map((team) => (
<Table.Tr key={team.id}>
<Table.Td>{team.id}</Table.Td>
<Table.Td>{team.department.replace(/_/g, ' ')}</Table.Td>
<Table.Td>
{team.members.find(m => m.id === team.consultant_id)
? `${team.members.find(m => m.id === team.consultant_id)?.title || ''} ${team.members.find(m => m.id === team.consultant_id)?.name || ''}`
: `ID: ${team.consultant_id}`}
</Table.Td>
<Table.Td>{team.members.length}</Table.Td>
<Table.Td>{team.is_admin_team ? 'Yes' : 'No'}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Treatment Record" withArrow>
<ActionIcon onClick={() => changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/t${team.id}`)}>
<HistoryIcon style={iconMStyle}/>
</ActionIcon>
</Tooltip>
<ActionIcon onClick={() => handleViewTeamMembers(team)}>
<EyeIcon style={iconMStyle}/>
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return(
<Card padding="lg" radius="md" withBorder>
<Text size="lg" fw={700} mb="md">My Medical Teams</Text>
<Card.Section withBorder>
<Group style={marginRound}>
{doctorTeams.length > 0 ? (
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Team ID</Table.Th>
<Table.Th>Department</Table.Th>
<Table.Th>Consultant</Table.Th>
<Table.Th>Members Count</Table.Th>
<Table.Th>Admin Team</Table.Th>
<Table.Th>Operations</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{teamRows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
) : (
<Text c="dimmed">You are not a member of any medical team.</Text>
)}
</Group>
</Card.Section>
</Card>
)
}

View File

@ -0,0 +1,56 @@
import {useEffect, useMemo, useState} from "react";
import {Button, Checkbox, Group, MultiSelect, RangeSlider, Select, Stack, Text} from "@mantine/core";
import {Departments} from "~/utils/hms_enums.ts";
interface MedicalTeamTableFilterProps {
onChange: (departments: string[], adminTeam: number) => void;
}
export function MedicalTeamManageFilter({onChange}: MedicalTeamTableFilterProps) {
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
const [filterAdminTeam, setFilterAdminTeam] = useState<string>('-1');
useEffect(() => {
onChange(selectedDepartments, parseInt(filterAdminTeam));
}, [selectedDepartments, filterAdminTeam]);
const departmentOptions = useMemo(() => {
return Object.entries(Departments).map(([key, value]) => ({
value,
label: key.replace(/_/g, ' ')
}));
}, []);
const adminOptions = useMemo(() => [
{value: '-1', label: 'All'},
{value: '1', label: 'Yes'},
{value: '0', label: 'No'},
], []);
const handleAdminTeamChange = (value: string | null) => {
setFilterAdminTeam(value || '-1');
};
return (
<Stack>
<MultiSelect
label="Department"
data={departmentOptions}
value={selectedDepartments}
onChange={setSelectedDepartments}
clearable
searchable
nothingFoundMessage="No results found"
placeholder="Select departments"
/>
<Select
label="Admin Team"
data={adminOptions}
value={filterAdminTeam}
onChange={handleAdminTeamChange}
placeholder="Is admin team?"
/>
</Stack>
);
}

View File

@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import { ActionIcon, Button, Group, Modal, Stack, Text, Loader } from '@mantine/core';
import { showErrorMessage } from '~/utils/utils.ts';
import { apiGetPatientsList } from '~/utils/hms_api.ts';
import type { PatientData } from '~/utils/models.ts';
import InfoIcon from 'mdi-react/InfoIcon';
import { iconMStyle } from '~/styles.ts';
// 缓存已加载的患者数据,避免重复请求
const patientCache = new Map<number, PatientData>();
interface PatientInfoDisplayProps {
patientId: number;
showIcon?: boolean;
}
export function PatientInfoDisplay({ patientId, showIcon = true }: PatientInfoDisplayProps) {
const [patientInfo, setPatientInfo] = useState<PatientData | null>(null);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
useEffect(() => {
// 如果已经在缓存中,直接使用缓存数据
if (patientCache.has(patientId)) {
setPatientInfo(patientCache.get(patientId) || null);
setLoading(false);
setInitialLoad(false);
return;
}
// 否则加载患者数据
fetchPatientInfo();
}, [patientId]);
const fetchPatientInfo = async () => {
try {
setLoading(true);
// 获取所有患者信息,传递-1表示不分页获取所有患者
const response = await apiGetPatientsList(-1);
if (response.success) {
const patients = response.data.patients;
// 将所有患者添加到缓存
patients.forEach(patient => {
patientCache.set(patient.id, patient);
});
// 查找当前需要的患者
const patient = patients.find(p => p.id === patientId);
if (patient) {
setPatientInfo(patient);
} else {
console.warn(`Patient with ID ${patientId} not found`);
}
} else {
showErrorMessage(response.message, "Failed to load patient information");
}
} catch (error) {
showErrorMessage("Error loading patient information", "Error");
console.error("Error fetching patient information:", error);
} finally {
setLoading(false);
setInitialLoad(false);
}
};
const openModal = () => {
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
};
// 初始加载时显示加载状态
if (initialLoad) {
return <Loader size="xs" />;
}
// 如果无法找到患者信息,显示 ID
if (!patientInfo) {
return (
<Group gap="xs">
<Text>ID: {patientId}</Text>
{showIcon && (
<ActionIcon
size="sm"
onClick={fetchPatientInfo}
loading={loading}
title="Refresh patient information"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
)}
</Group>
);
}
return (
<>
<Group gap="xs">
<Text
style={{ cursor: 'pointer' }}
onClick={openModal}
title="Click to view details"
>
{`${patientInfo.title} ${patientInfo.name}`}
</Text>
{showIcon && (
<ActionIcon
size="sm"
onClick={openModal}
title="View patient details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
)}
</Group>
<Modal
opened={modalOpen}
onClose={closeModal}
title="Patient Information"
centered
size="md"
>
<Stack gap="md">
<Group>
<Text fw={500} w={100}>Name:</Text>
<Text>{`${patientInfo.title} ${patientInfo.name}`}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Gender:</Text>
<Text>{patientInfo.gender}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Birth Date:</Text>
<Text>{patientInfo.birth_date}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Email:</Text>
<Text>{patientInfo.email}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Phone:</Text>
<Text>{patientInfo.phone}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Address:</Text>
<Text>{patientInfo.address}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Postcode:</Text>
<Text>{patientInfo.postcode}</Text>
</Group>
<Group justify="flex-end" mt="md">
<Button onClick={closeModal}>Close</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@ -0,0 +1,124 @@
import {type ComboboxItem, Grid, Group, MultiSelect, type OptionsFilter, Radio, Text, TextInput} from "@mantine/core";
import React, {useEffect, useMemo, useState} from "react";
import SearchIcon from "mdi-react/SearchIcon";
import {iconMStyle} from "~/styles.ts";
export interface PatientFilterProps {
onChange: (nameSearch: string, genders: string[], hasTeam: string, isAdmitted: string, teams: number[], wards: number[]) => void;
teamOptions: { value: string, label: string }[];
wardOptions: { value: string, label: string }[];
}
export function PatientManagementFilter({ onChange, teamOptions, wardOptions }: PatientFilterProps) {
const [nameSearch, setNameSearch] = useState<string>("")
const [filterGenders, setFilterGenders] = useState<string[]>([])
const [filterHasTeam, setFilterHasTeam] = useState<string>("-1")
const [filterIsAdmitted, setFilterIsAdmitted] = useState<string>("-1")
const [filterTeams, setFilterTeams] = useState<string[]>([])
const [filterWards, setFilterWards] = useState<string[]>([])
useEffect(() => {
onChange(
nameSearch,
filterGenders,
filterHasTeam,
filterIsAdmitted,
filterTeams.map(t => parseInt(t)),
filterWards.map(w => parseInt(w))
)
}, [nameSearch, filterGenders, filterHasTeam, filterIsAdmitted, filterTeams, filterWards]);
const genderOptions = useMemo(
() => [
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
{ value: 'Intersex', label: 'Intersex' },
],
[],
);
const optionsFilter: OptionsFilter = ({ options, search }) => {
const filtered = (options as ComboboxItem[]).filter((option) =>
option.label.toLowerCase().trim().includes(search.toLowerCase().trim())
);
filtered.sort((a, b) => a.label.localeCompare(b.label));
return filtered;
};
return (
<Grid>
<Grid.Col span={12}>
<TextInput
label="Search Patient Name"
placeholder="Enter patient name to search"
value={nameSearch}
onChange={(e) => setNameSearch(e.currentTarget.value)}
leftSection={<SearchIcon style={iconMStyle} />}
/>
</Grid.Col>
<Grid.Col span={4}>
<MultiSelect
label="Gender"
placeholder="Select genders"
data={genderOptions}
value={filterGenders}
onChange={setFilterGenders}
clearable
searchable
filter={optionsFilter}
/>
</Grid.Col>
<Grid.Col span={4}>
<Text fw={500} size="sm">Medical Team Status</Text>
<Radio.Group
value={filterHasTeam}
onChange={setFilterHasTeam}
>
<Group mt="xs">
<Radio value="-1" label="All" />
<Radio value="1" label="Assigned" />
<Radio value="0" label="Not Assigned" />
</Group>
</Radio.Group>
</Grid.Col>
<Grid.Col span={4}>
<Text fw={500} size="sm">Admission Status</Text>
<Radio.Group
value={filterIsAdmitted}
onChange={setFilterIsAdmitted}
>
<Group mt="xs">
<Radio value="-1" label="All" />
<Radio value="1" label="Admitted" />
<Radio value="0" label="Not Admitted" />
</Group>
</Radio.Group>
</Grid.Col>
<Grid.Col span={6}>
<MultiSelect
label="Medical Teams"
placeholder="Filter by medical teams"
data={teamOptions}
value={filterTeams}
onChange={setFilterTeams}
clearable
searchable
filter={optionsFilter}
/>
</Grid.Col>
<Grid.Col span={6}>
<MultiSelect
label="Wards"
placeholder="Filter by wards"
data={wardOptions}
value={filterWards}
onChange={setFilterWards}
clearable
searchable
filter={optionsFilter}
/>
</Grid.Col>
</Grid>
)
}

View File

@ -0,0 +1,27 @@
import { type ReactNode, forwardRef } from 'react';
import { Table } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
type ResponsiveTableContainerProps = Omit<
React.ComponentProps<typeof Table.ScrollContainer>,
'children'
> & {
children: ReactNode;
};
export const ResponsiveTableContainer = forwardRef<
HTMLDivElement,
ResponsiveTableContainerProps
>(({ children, ...rest }, ref) => {
const isNarrow = useMediaQuery('(max-width: 700px)');
return isNarrow ? (
<Table.ScrollContainer ref={ref} {...rest}>
{children}
</Table.ScrollContainer>
) : (
<>{children}</>
);
});
ResponsiveTableContainer.displayName = 'ResponsiveTableContainer';

View File

@ -0,0 +1,99 @@
import {type ComboboxItem, Grid, Group, MultiSelect, type OptionsFilter, Radio, RangeSlider, Text} from "@mantine/core";
import React, {useEffect, useMemo, useState} from "react";
import {WardTypes} from "~/utils/models.ts";
import {DoctorGrade} from "~/utils/hms_enums.ts";
export function StaffTableFilter({onChange}: {onChange: (grades: string[], genders: string[], isAdmin: string, isTerminated: string) => void}) {
const [filterGrades, setFilterGrades] = useState<string[]>([])
const [filterGenders, setFilterGenders] = useState<string[]>([])
const [filterIsAdmin, setFilterIsAdmin] = useState<string>("-1")
const [filterIsTerminated, setFilterIsTerminated] = useState<string>("-1")
useEffect(() => {
onChange(filterGrades, filterGenders, filterIsAdmin, filterIsTerminated)
}, [filterGrades, filterGenders, filterIsAdmin, filterIsTerminated]);
const gradesTypesOptions = useMemo(() => {
return Object.entries(DoctorGrade)
.filter(([k]) => isNaN(Number(k)))
.map(([key, value]) => ({ value: value.toString(), label: key }))
}, [WardTypes])
const genderOptions = useMemo(
() => [
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
{ value: 'Intersex', label: 'Intersex' },
],
[],
);
const optionsFilter: OptionsFilter = ({ options, search }) => {
const filtered = (options as ComboboxItem[]).filter((option) =>
option.label.toLowerCase().trim().includes(search.toLowerCase().trim())
);
filtered.sort((a, b) => a.label.localeCompare(b.label));
return filtered;
}
return (
<Grid>
<Grid.Col span={12} mb="md">
<MultiSelect
data={gradesTypesOptions}
placeholder="Grade Type"
value={filterGrades}
filter={optionsFilter}
searchable
onChange={(value) => setFilterGrades(value)}
comboboxProps={{ transitionProps: { transition: 'fade', duration: 100, timingFunction: 'ease' } }}
/>
</Grid.Col>
<Grid.Col span={12} mb="md">
<MultiSelect
data={genderOptions}
placeholder="Gender"
value={filterGenders}
filter={optionsFilter}
searchable
onChange={(value) => setFilterGenders(value)}
comboboxProps={{ transitionProps: { transition: 'fade', duration: 100, timingFunction: 'ease' } }}
/>
</Grid.Col>
<Grid.Col span={6} mb="md">
<Radio.Group
name="User Type"
label="Select user type"
withAsterisk
value={filterIsAdmin}
onChange={(value) => setFilterIsAdmin(value)}
>
<Group>
<Radio value="1" label="Admin" />
<Radio value="0" label="Not Admin" />
<Radio value="-1" label="No filtering" />
</Group>
</Radio.Group>
</Grid.Col>
<Grid.Col span={6} mb="md">
<Radio.Group
name="Employment Status"
label="Select employment status"
withAsterisk
value={filterIsTerminated}
onChange={(value) => setFilterIsTerminated(value)}
>
<Group>
<Radio value="1" label="Terminated" />
<Radio value="0" label="Active" />
<Radio value="-1" label="No filtering" />
</Group>
</Radio.Group>
</Grid.Col>
</Grid>
)
}

View File

@ -0,0 +1,123 @@
import React, { useEffect, useState } from 'react';
import { ActionIcon, Group, Text, Loader } from '@mantine/core';
import { showErrorMessage } from '~/utils/utils.ts';
import { apiGetTeamList, get_team_info } from '~/utils/hms_api.ts';
import type { DoctorTeamInfo } from '~/utils/models.ts';
import InfoIcon from 'mdi-react/InfoIcon';
import { iconMStyle } from '~/styles.ts';
import { confirmViewTeamMembers } from '~/components/subs/confirms.tsx';
import { Departments } from '~/utils/hms_enums.ts';
const teamCache = new Map<number, DoctorTeamInfo>();
interface TeamInfoDisplayProps {
teamId: number;
showIcon?: boolean;
}
export function TeamInfoDisplay({ teamId, showIcon = true }: TeamInfoDisplayProps) {
const [teamInfo, setTeamInfo] = useState<DoctorTeamInfo | null>(null);
const [loading, setLoading] = useState(true);
const [initialLoad, setInitialLoad] = useState(true);
useEffect(() => {
if (teamCache.has(teamId)) {
setTeamInfo(teamCache.get(teamId) || null);
setLoading(false);
setInitialLoad(false);
return;
}
fetchTeamInfo();
}, [teamId]);
const fetchTeamInfo = async () => {
try {
setLoading(true);
const detailResponse = await get_team_info(teamId);
if (detailResponse.success) {
const team = detailResponse.data.team;
teamCache.set(teamId, team);
setTeamInfo(team);
} else {
const listResponse = await apiGetTeamList(-1);
if (listResponse.success) {
const teams = listResponse.data.teams;
teams.forEach(team => {
teamCache.set(team.id, team);
});
const team = teams.find(t => t.id === teamId);
if (team) {
setTeamInfo(team);
} else {
console.warn(`Team with ID ${teamId} not found`);
}
} else {
showErrorMessage(listResponse.message, "Failed to load team information");
}
}
} catch (error) {
showErrorMessage("Error loading team information", "Error");
console.error("Error fetching team information:", error);
} finally {
setLoading(false);
setInitialLoad(false);
}
};
const handleViewTeamDetails = () => {
if (!teamInfo) {
showErrorMessage("Team information not available", "Error");
return;
}
confirmViewTeamMembers(teamInfo);
};
if (initialLoad) {
return <Loader size="xs" />;
}
if (!teamInfo) {
return (
<Group gap="xs">
<Text>ID: {teamId}</Text>
{showIcon && (
<ActionIcon
size="sm"
onClick={fetchTeamInfo}
loading={loading}
title="Refresh team information"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
)}
</Group>
);
}
return (
<Group gap="xs">
<Text
style={{ cursor: 'pointer' }}
onClick={handleViewTeamDetails}
title="Click to view team details"
>
{teamInfo.department.replace(/_/g, ' ')}
</Text>
{showIcon && (
<ActionIcon
size="sm"
onClick={handleViewTeamDetails}
title="View team details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
)}
</Group>
);
}

View File

@ -0,0 +1,41 @@
import {
ActionIcon,
Group, rem,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
useMantineTheme
} from "@mantine/core";
import {iconMStyle} from "~/styles";
import ThemeLightDarkIcon from "mdi-react/ThemeLightDarkIcon";
import WeatherNightIcon from "mdi-react/WeatherNightIcon";
import WeatherSunnyIcon from "mdi-react/WeatherSunnyIcon";
import type {CSSProperties} from "react";
export function ThemeToggle({extraStyle, iconStyle}: {extraStyle?: CSSProperties, iconStyle?: CSSProperties}) {
const { colorScheme, setColorScheme } = useMantineColorScheme();
// const [colorSchemeState, toggleColorSchemeState] = useToggle(['auto', 'dark', 'light'] as const);
const computedColorScheme = useComputedColorScheme('light');
const theme = useMantineTheme();
const nextScheme = colorScheme === "auto" ? "dark" : colorScheme === "dark" ? "light" : "auto";
const onClickChangeColorScheme = () => {
setColorScheme(nextScheme)
}
return (
<Group justify="center">
<Tooltip label={colorScheme === 'auto' ? 'Auto' : colorScheme === 'dark' ? 'Dark' : 'Light'} position="left">
<ActionIcon variant="light" size="md" onClick={onClickChangeColorScheme} color={
colorScheme === 'auto' ? undefined : computedColorScheme === 'dark' ? theme.colors.blue[4] : theme.colors.yellow[6]
} style={ extraStyle ? {...extraStyle} : {} }>
{colorScheme === 'auto' ? <ThemeLightDarkIcon style={iconStyle || iconMStyle}/> :
colorScheme === 'dark' ? <WeatherNightIcon style={iconStyle || iconMStyle}/> : <WeatherSunnyIcon style={iconStyle || iconMStyle}/>}
</ActionIcon>
</Tooltip>
</Group>
);
}

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { ActionIcon, Group, Modal, Text, Tooltip } from '@mantine/core';
import InformationOutlineIcon from 'mdi-react/InformationOutlineIcon';
import { iconMStyle } from '~/styles.ts';
interface TruncatedTextProps {
text: string;
maxLength?: number;
maxTooltipLength?: number;
title?: string;
}
export function TruncatedText({
text,
maxLength = 20,
maxTooltipLength = 200,
title = 'Details'
}: TruncatedTextProps) {
const [opened, setOpened] = useState(false);
if (!text) return <Text>-</Text>;
if (text.length <= maxLength) {
return <Text>{text}</Text>;
}
const truncatedText = `${text.substring(0, maxLength)}...`;
const tooltipText = text.length > maxTooltipLength
? `${text.substring(0, maxTooltipLength)}...`
: text
return (
<>
<Tooltip
label={tooltipText}
multiline
maw={300}
position="top"
withArrow
transitionProps={{ duration: 200 }}
>
<Group gap="xs" wrap="nowrap">
<Text>{truncatedText}</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => setOpened(true)}
title="Show full content"
>
<InformationOutlineIcon style={iconMStyle} />
</ActionIcon>
</Group>
</Tooltip>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={title}
size="md"
centered
>
<Text style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{text}
</Text>
</Modal>
</>
);
}

View File

@ -0,0 +1,78 @@
import {type ComboboxItem, Grid, MultiSelect, type OptionsFilter, RangeSlider, Text} from "@mantine/core";
import React, {useEffect, useMemo, useState} from "react";
import {WardTypes} from "~/utils/models.ts";
export function WardManageTableFilter({onChange}: {onChange: (types: string[], occupancy: [number, number], capacity: [number, number]) => void}) {
const [filterTypes, setFilterTypes] = useState<string[]>([])
const [filterOccupancy, setFilterOccupancy] = useState<[number, number]>([0, 12])
const [filterCapacity, setFilterCapacity] = useState<[number, number]>([1, 12])
useEffect(() => {
onChange(filterTypes, filterOccupancy, filterCapacity)
}, [filterTypes, filterOccupancy, filterCapacity]);
const wardTypesOptions = useMemo(() => {
return Object.entries(WardTypes).map(([key, value]) => ({ value: key, label: value }))
}, [WardTypes])
const optionsFilter: OptionsFilter = ({ options, search }) => {
const filtered = (options as ComboboxItem[]).filter((option) =>
option.label.toLowerCase().trim().includes(search.toLowerCase().trim())
);
filtered.sort((a, b) => a.label.localeCompare(b.label));
return filtered;
}
return (
<Grid>
<Grid.Col span={12} mb="md">
<MultiSelect
data={wardTypesOptions}
placeholder="Ward Types"
value={filterTypes}
filter={optionsFilter}
searchable
onChange={(value) => setFilterTypes(value)}
comboboxProps={{ transitionProps: { transition: 'fade', duration: 100, timingFunction: 'ease' } }}
/>
</Grid.Col>
<Grid.Col span={12} mb="md">
<Text fz="xs" c="dimmed" mb={3}>Current Occupancy</Text>
<RangeSlider
min={0}
max={12}
step={1}
minRange={1}
precision={1}
value={filterOccupancy}
marks={Array.from({ length: 13 }, (_, index) => ({
value: index,
label: String(index),
}))}
onChange={setFilterOccupancy}
onChangeEnd={setFilterOccupancy}
/>
</Grid.Col>
<Grid.Col span={12} mb="md">
<Text fz="xs" c="dimmed" mb={3}>Capacity</Text>
<RangeSlider
min={1}
max={12}
step={1}
minRange={1}
precision={1}
value={filterCapacity}
marks={Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: String(index + 1),
}))}
onChange={setFilterCapacity}
onChangeEnd={setFilterCapacity}
/>
</Grid.Col>
</Grid>
)
}

View File

@ -0,0 +1,206 @@
import {type PatientData, SORT_SYMBOLS, type WardInfo, WardTypes} from "~/utils/models.ts";
import {ActionIcon, Group, Table, Text} from "@mantine/core";
import {useEffect, useMemo, useState} from "react";
import {apiGetWardPatients, apiTransferWard, apiGetWardList} from "~/utils/hms_api.ts";
import {getUserDisplayFullName, showErrorMessage, showInfoMessage} from "~/utils/utils.ts";
import {confirmCheckWardPatients, confirmDeleteWard, confirmEditOrCreateWard} from "~/components/subs/confirms.tsx";
import EyeIcon from "mdi-react/EyeIcon";
import {iconMStyle} from "~/styles.ts";
import PencilIcon from "mdi-react/PencilIcon";
import DeleteIcon from "mdi-react/DeleteIcon";
import SwapHorizontalIcon from "mdi-react/SwapHorizontalIcon";
import {modals} from "@mantine/modals";
import {Button, Select, Stack} from "@mantine/core";
import {useForm} from "@mantine/form";
import { ResponsiveTableContainer } from "./ResponsiveTableContainer";
function ConfirmTransferWard({patientId, currentWardId, onSuccess}: {patientId: number, currentWardId: number, onSuccess: () => void}) {
const [loading, setLoading] = useState(false);
const [wards, setWards] = useState<WardInfo[]>([]);
const [loadingWards, setLoadingWards] = useState(true);
const form = useForm({
initialValues: {
patient_id: patientId,
target_ward_id: '',
},
validate: {
target_ward_id: (value) => (!value ? 'Please select a ward' : null),
},
});
useEffect(() => {
// Load wards for selection
apiGetWardList(1, true).then(res => {
if (res.success) {
setWards(res.data.wards);
} else {
showErrorMessage(res.message, "Failed to load wards");
}
})
.catch(err => {
showErrorMessage("Failed to load wards", "Error");
})
.finally(() => {
setLoadingWards(false);
});
}, []);
const wardOptions = useMemo(() => {
return wards
.filter(ward => ward.id !== currentWardId && ward.current_occupancy < ward.total_capacity) // Exclude current ward and show only those with available capacity
.map(ward => ({
value: ward.id.toString(),
label: `${ward.name} (${ward.current_occupancy}/${ward.total_capacity}) - ${WardTypes[ward.type]}`
}));
}, [wards, currentWardId]);
const handleFormSubmit = (values: typeof form.values) => {
setLoading(true);
apiTransferWard(
values.patient_id,
parseInt(values.target_ward_id)
).then(res => {
if (res.success) {
showInfoMessage("Patient transferred successfully", "Success");
modals.closeAll();
onSuccess();
} else {
showErrorMessage(res.message, "Failed to transfer patient");
}
})
.catch(err => {
showErrorMessage("Failed to transfer patient", "Error");
})
.finally(() => {
setLoading(false);
});
};
return (
<form onSubmit={form.onSubmit(handleFormSubmit)}>
<Stack>
<Select
withAsterisk
label="Select Target Ward"
placeholder="Choose a ward to transfer patient to"
data={wardOptions}
searchable
nothingFoundMessage={wardOptions.length === 0 ? "No available wards with capacity" : "No matching wards found"}
disabled={loadingWards || wardOptions.length === 0}
{...form.getInputProps('target_ward_id')}
onChange={(value) => {
if (!value) return;
form.setFieldValue('target_ward_id', value);
}}
/>
{wardOptions.length === 0 && !loadingWards && (
<Text c="red" size="sm">
No available wards with capacity. Please free up space in a ward first.
</Text>
)}
<Group mt="md" justify="flex-end">
<Button variant="outline" onClick={() => modals.closeAll()}>
Cancel
</Button>
<Button
type="submit"
loading={loading || loadingWards}
disabled={wardOptions.length === 0}
color="blue"
>
Transfer Patient
</Button>
</Group>
</Stack>
</form>
);
}
export default function WardPatients ({ward_id, onChanged}: {ward_id: number, onChanged: () => void}) {
const [wardPatients, setWardPatients] = useState<PatientData[]>([])
useEffect(() => {
refreshWardPatients()
}, []);
const refreshWardPatients = () => {
apiGetWardPatients(ward_id).then((result) => {
if (result.success) {
setWardPatients(result.data.patients)
}
else {
showErrorMessage(result.message, "Failed to get ward patients")
}
}).catch((err => { showErrorMessage(err.toString(), "Failed to get ward patients") }) )
}
const handleTransferWard = (patientId: number) => {
modals.open({
title: "Transfer Patient",
centered: true,
size: "md",
children: (
<ConfirmTransferWard
patientId={patientId}
currentWardId={ward_id}
onSuccess={() => {
refreshWardPatients()
onChanged()
}}
/>
)
});
};
const rows = wardPatients.map((patient) => (
<Table.Tr key={patient.id}>
<Table.Td>{`${patient.title} ${patient.name}`}</Table.Td>
<Table.Td>{patient.gender}</Table.Td>
<Table.Td>{patient.birth_date}</Table.Td>
<Table.Td>{patient.email}</Table.Td>
<Table.Td>{patient.phone}</Table.Td>
<Table.Td>
<Group>
<ActionIcon onClick={() => handleTransferWard(patient.id)} title="Transfer to another ward">
<SwapHorizontalIcon style={iconMStyle}/>
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))
return (
<>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>
Name
</Table.Th>
<Table.Th>
Gender
</Table.Th>
<Table.Th>
Date of Birth
</Table.Th>
<Table.Th>
Email
</Table.Th>
<Table.Th>
Phone
</Table.Th>
<Table.Th>
Operations
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,103 @@
import type {Route} from "../../.react-router/types/app/pages/+types/Dashboard";
import {useDisclosure, useWindowScroll} from "@mantine/hooks";
import {Affix, AppShell, Button, Space, Stack, Transition} from "@mantine/core";
import {PageHeader} from "~/components/PageHeader.tsx";
import {PageNavbar} from "~/components/PageNavbar.tsx";
import {useEffect, useState} from "react";
import {DashboardPageType, Departments, UserPermissionLevel} from "~/utils/hms_enums.ts";
import {Outlet, useLocation, useNavigate} from "react-router";
import type {LoginUserInfo} from "~/utils/models.ts";
import {apiGetMyInfo} from "~/utils/hms_api.ts";
import {getEnumKeyByValue, getUserDisplayFullName} from "~/utils/utils.ts";
import ArrowUpIcon from "mdi-react/ArrowUpIcon";
import HomeIcon from "mdi-react/HomeIcon";
import GlobalAffix from "~/components/GlobalAffix.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Dashboard" },
{ name: "description", content: "Dashboard Main" },
];
}
export default function Component() {
const navigate = useNavigate();
const [currentStat, setCurrentStat] = useState(DashboardPageType.Home)
const [loginUserInfo, setLoginUserInfo] = useState<LoginUserInfo | null>(null)
const [canReception, setCanReception] = useState<boolean>(false)
const [opened, { toggle }] = useDisclosure()
const location = useLocation();
const changePage = (pageType: DashboardPageType, navigateTo?: string) => {
// if (currentStat == pageType) return
setCurrentStat(pageType)
navigate(navigateTo || pageType)
}
const refreshCanReception = (data: LoginUserInfo) => {
if (!data.doctor_teams) {
setCanReception(false)
return
}
for (const team of data.doctor_teams) {
if ((team.department == Departments.Reception) || team.is_admin_team) {
setCanReception(true)
return
}
}
}
const refreshMyInfo = () => {
apiGetMyInfo()
.then((res => {
if (res.success && res.data) {
setLoginUserInfo(res.data)
refreshCanReception(res.data)
}
else {
navigate(DashboardPageType.LoginPage)
}
}))
.catch(err => {
console.log("apiGetMyInfo failed", err)
navigate(DashboardPageType.LoginPage)
})
}
useEffect(() => {
refreshMyInfo()
let currPage = getEnumKeyByValue(DashboardPageType, location.pathname)
if (currPage) {
setCurrentStat(DashboardPageType[currPage])
}
else {
const pathParts = location.pathname.split("/")
const path = pathParts.slice(0, pathParts.length - 1).join("/")
currPage = getEnumKeyByValue(DashboardPageType, path + "/")
if (currPage) {
setCurrentStat(DashboardPageType[currPage])
}
}
}, []);
return (
<AppShell header={{ height: 60 }} navbar={{width: 300, breakpoint: `sm`, collapsed: {mobile: !opened}}} padding="md">
<AppShell.Header p="md">
<PageHeader opened={opened} toggle={toggle} appendTitle={getUserDisplayFullName(loginUserInfo)}/>
</AppShell.Header>
<AppShell.Navbar p="md">
<PageNavbar currentStat={currentStat} changePageStat={changePage}
userPermission={loginUserInfo ? loginUserInfo.user_permission : UserPermissionLevel.UNAUTHORIZED}
canReception={canReception} />
</AppShell.Navbar>
<AppShell.Main>
<Outlet context={{ loginUserInfo, refreshMyInfo, changePage }} />
<Space h={50} />
<GlobalAffix />
</AppShell.Main>
</AppShell>
);
}

View File

@ -0,0 +1,53 @@
import type {Route} from "../../.react-router/types/app/pages/+types/HomePage";
import {Box, Button, Flex, Group, Image, Text, Select, MantineProvider, em} from '@mantine/core';
import {Link, useNavigate} from "react-router";
import {marginRound, marginTopBottom, maxWidth} from "~/styles";
import {Notifications} from "@mantine/notifications";
import GlobalAffix from "~/components/GlobalAffix.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "HomePage" },
{ name: "description", content: "Welcome!" },
];
}
export default function Component() {
const navigate = useNavigate()
return (
<Flex gap="md" align="center" direction="column" p={em(20)} h="100vh">
<Group justify="flex-end" style={maxWidth}>
<Select
size="md"
placeholder="Search for services"
data={['Book Appointment', 'Find a Doctor', 'Contact Us', 'Emergency Services', 'Pharmacy Services', 'Health Checkup', 'Vaccination Services']}
value={null}
searchable
nothingFoundMessage="Nothing found..."
onChange={(_) => {
navigate("/dashboard")
}}
/>
</Group>
<Image
radius="md"
h="auto"
w={600}
src="logo.png"
/>
<Group>
<Button><Link to="/">Home</Link></Button>
<Button><Link to="/dashboard">Book Appointment</Link></Button>
<Button><Link to="/dashboard">Find a Doctor</Link></Button>
<Button><Link to="/">Contact Us</Link></Button>
<Button><Link to="/dashboard">Services</Link></Button>
<Button><Link to="/dashboard">Staff login</Link></Button>
</Group>
<GlobalAffix />
</Flex>
)
}

View File

@ -0,0 +1,12 @@
import type {Route} from "../../.react-router/types/app/pages/+types/Page2";
export function meta({}: Route.MetaArgs) {
return [
{ title: "ExamplePage2" },
{ name: "description", content: "Welcome Example2!" },
];
}
export default function Component() {
return <h1>Example Page</h1>;
}

View File

@ -0,0 +1,416 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/AppointmentManagement";
import {useDisclosure} from "@mantine/hooks";
import {useEffect, useMemo, useState} from "react";
import {BookingCategory, DashboardPageType} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import {
type OutletContextType,
type PatientBookingInfo,
type DoctorTeamInfo,
SORT_SYMBOLS
} from "~/utils/models.ts";
import {Accordion, ActionIcon, Badge, Button, Card, Group, Pagination, Stack, Table, Text} from "@mantine/core";
import {apiGetAllAppointments, get_team_info} from "~/utils/hms_api.ts";
import {showErrorMessage, timestampToDate} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRightBottom, marginRound, marginTopBottom, textCenter} from "~/styles.ts";
import CheckCircleIcon from "mdi-react/CheckCircleIcon";
import CancelIcon from "mdi-react/CancelIcon";
import HospitalIcon from "mdi-react/HospitalIcon";
import {
confirmApproveAppointment,
confirmRejectAppointment,
confirmPatientAdmission,
confirmViewTeamMembers
} from "~/components/subs/confirms.tsx";
import {AppointmentManageFilter} from "~/components/subs/AppointmentManageFilter.tsx";
import HomePlusIcon from "mdi-react/HomePlusIcon";
import {TruncatedText} from "~/components/subs/TruncatedText.tsx";
import InfoIcon from "mdi-react/InfoIcon";
import {PatientInfoDisplay} from "~/components/subs/PatientInfoDisplay.tsx";
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Appointment Management" },
{ name: "description", content: "Appointment Management" },
];
}
export default function Component() {
const navigate = useNavigate();
const [refreshingAppointmentList, setRefreshingAppointmentList] = useState<boolean>(false);
const [appointmentInfo, setAppointmentInfo] = useState<{appointments: PatientBookingInfo[], total_pages: number}>({appointments: [], total_pages: 1});
const [teamInfoMap, setTeamInfoMap] = useState<Map<number, DoctorTeamInfo>>(new Map());
const [loadingTeams, setLoadingTeams] = useState<Set<number>>(new Set());
const [sortKey, setSortKey] = useState<string>("id");
const [sortDesc, setSortDesc] = useState<boolean>(true);
const [currPage, setCurrPage] = useState<number>(1);
const [filterCategories, setFilterCategories] = useState<string[]>([]);
const [filterStatus, setFilterStatus] = useState<number>(-1);
const [includeDischarged, setIncludeDischarged] = useState<boolean>(false);
const { loginUserInfo, refreshMyInfo } = useOutletContext<OutletContextType>();
useEffect(() => {
refreshAppointmentList();
}, []);
useEffect(() => {
refreshAppointmentList();
}, [currPage, includeDischarged]);
const updateFilter = (categories: string[], status: number, discharged: boolean) => {
setFilterCategories(categories);
setFilterStatus(status);
setIncludeDischarged(discharged);
};
const refreshAppointmentList = () => {
setRefreshingAppointmentList(true);
apiGetAllAppointments(currPage, includeDischarged).then(res => {
if (!res.success) {
showErrorMessage(res.message, "Failed to get appointments list");
return;
}
setAppointmentInfo(res.data);
// Fetch team info for all appointments with assigned teams
const teamsToFetch = res.data.appointments
.filter(app => app.assigned_team !== null)
.map(app => app.assigned_team as number);
// Remove duplicates
const uniqueTeams = [...new Set(teamsToFetch)];
// Fetch team info for each unique team ID
uniqueTeams.forEach(teamId => {
fetchTeamInfo(teamId);
});
})
.catch(err => {})
.finally(() => { setRefreshingAppointmentList(false); });
};
const fetchTeamInfo = (teamId: number) => {
if (teamId === null || teamInfoMap.has(teamId) || loadingTeams.has(teamId)) {
return;
}
setLoadingTeams(prev => new Set([...prev, teamId]));
get_team_info(teamId).then(res => {
if (res.success) {
setTeamInfoMap(prev => {
const newMap = new Map(prev);
newMap.set(teamId, res.data.team);
return newMap;
});
}
})
.catch(err => {})
.finally(() => {
setLoadingTeams(prev => {
const newSet = new Set(prev);
newSet.delete(teamId);
return newSet;
});
});
};
const handleViewTeam = (teamId: number) => {
const teamInfo = teamInfoMap.get(teamId);
if (teamInfo) {
confirmViewTeamMembers(teamInfo);
} else {
// If team info is not fetched yet, fetch it and then show
get_team_info(teamId).then(res => {
if (res.success) {
setTeamInfoMap(prev => {
const newMap = new Map(prev);
newMap.set(teamId, res.data.team);
return newMap;
});
confirmViewTeamMembers(res.data.team);
} else {
showErrorMessage(res.message, "Failed to fetch team information");
}
}).catch(err => {
showErrorMessage("Failed to fetch team information", "Error");
});
}
};
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc);
} else {
setSortKey(key);
setSortDesc(false); // Default ascending
}
};
const sortedAppointments = useMemo(() => {
let data = [...appointmentInfo.appointments];
// Apply filters
data = data.filter((appointment) => {
// Filter by category
const categoryMatch = filterCategories.length === 0 || filterCategories.includes(appointment.category);
// Filter by status
let statusMatch = true;
if (filterStatus !== -1) {
if (filterStatus === 0) { // Pending
statusMatch = !appointment.approved;
} else if (filterStatus === 1) { // Approved
statusMatch = appointment.approved && appointment.assigned_team !== null;
} else if (filterStatus === 2) { // Rejected
statusMatch = appointment.approved && appointment.assigned_team === null;
}
}
return categoryMatch && statusMatch;
});
// Apply sorting
if (!sortKey) return data;
data.sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB);
return sortDesc ? -cmp : cmp;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
if (typeof valA === 'boolean' && typeof valB === 'boolean') {
return sortDesc ? (valB ? 1 : -1) : (valA ? 1 : -1);
}
return 0;
});
return data;
}, [appointmentInfo.appointments, sortKey, sortDesc, filterCategories, filterStatus]);
const handleApproveAppointment = (appointmentId: number) => {
confirmApproveAppointment(appointmentId, refreshAppointmentList);
};
const handleRejectAppointment = (appointmentId: number) => {
confirmRejectAppointment(appointmentId, refreshAppointmentList);
};
const handlePatientAdmission = (appointmentId: number) => {
confirmPatientAdmission(appointmentId, refreshAppointmentList);
};
const getCategoryDisplay = (category: string) => {
const key = Object.entries(BookingCategory).find(([_, value]) => value === category)?.[0];
return key ? key.replace(/_/g, ' ') : category;
};
const getStatusBadge = (appointment: PatientBookingInfo) => {
if (appointment.discharged) {
return <Badge color="purple">Discharged</Badge>;
} else if (appointment.admitted) {
return <Badge color="blue">Admitted</Badge>;
} else if (appointment.approved) {
if (appointment.assigned_team === null) {
return <Badge color="red">Rejected</Badge>;
} else {
return <Badge color="green">Approved</Badge>;
}
} else {
return <Badge color="yellow">Pending</Badge>;
}
};
const getTeamDisplay = (appointment: PatientBookingInfo) => {
if (!appointment.approved) {
return <Text size="sm" c="dimmed">Pending approval</Text>;
}
if (appointment.assigned_team === null) {
return <Text size="sm" c="dimmed">Not assigned</Text>;
}
const teamInfo = teamInfoMap.get(appointment.assigned_team);
if (!teamInfo) {
return (
<Group gap="xs">
<Text>ID: {appointment.assigned_team}</Text>
<ActionIcon
size="sm"
onClick={() => handleViewTeam(appointment.assigned_team as number)}
title="View team details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
</Group>
);
}
return (
<Group gap="xs">
<Text>{teamInfo.department.replace(/_/g, ' ')}</Text>
<ActionIcon
size="sm"
onClick={() => handleViewTeam(appointment.assigned_team as number)}
title="View team details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
</Group>
);
};
const rows = sortedAppointments.map((appointment) => (
<Table.Tr key={appointment.id}>
<Table.Td>{appointment.id}</Table.Td>
<Table.Td>
<PatientInfoDisplay patientId={appointment.patient_id} />
</Table.Td>
<Table.Td>{getCategoryDisplay(appointment.category)}</Table.Td>
<Table.Td>{new Date(appointment.appointment_time * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
<Table.Td>
<TruncatedText
text={appointment.description}
title={`Description - Appointment #${appointment.id}`}
/>
</Table.Td>
<Table.Td>{getStatusBadge(appointment)}</Table.Td>
<Table.Td>
{appointment.feedback ?
<TruncatedText
text={appointment.feedback}
title={`Feedback - Appointment #${appointment.id}`}
/> :
'-'
}
</Table.Td>
<Table.Td>{getTeamDisplay(appointment)}</Table.Td>
<Table.Td>
<Group>
<ActionIcon
color="green"
onClick={() => handleApproveAppointment(appointment.id)}
title="Approve"
disabled={appointment.admitted || appointment.discharged}
>
<CheckCircleIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon
color="red"
onClick={() => handleRejectAppointment(appointment.id)}
title="Reject"
disabled={appointment.admitted || appointment.discharged}
>
<CancelIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon
color={appointment.approved && appointment.assigned_team !== null && !appointment.admitted && !appointment.discharged ? "blue" : "gray"}
onClick={() => {
if (appointment.approved && appointment.assigned_team !== null && !appointment.admitted && !appointment.discharged) {
handlePatientAdmission(appointment.id);
}
}}
disabled={!appointment.approved || appointment.assigned_team === null || appointment.admitted || appointment.discharged}
title={
appointment.admitted ? "Already admitted" :
appointment.discharged ? "Already discharged" :
!appointment.approved ? "Not approved yet" :
appointment.assigned_team === null ? "No team assigned" :
"Admit Patient"
}
>
<HomePlusIcon style={iconMStyle}/>
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Text size="1.5em" fw={700}>Appointment Management</Text>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>Filter</Accordion.Control>
<Accordion.Panel>
<AppointmentManageFilter onChange={updateFilter}/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("patient_id")}>
Patient ID{" "}
{sortKey === "patient_id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("category")}>
Category{" "}
{sortKey === "category" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("appointment_time")}>
Appointment Time{" "}
{sortKey === "appointment_time" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Description
</Table.Th>
<Table.Th onClick={() => handleSort("approved")}>
Status{" "}
{sortKey === "approved" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Feedback
</Table.Th>
<Table.Th onClick={() => handleSort("assigned_team")}>
Team{" "}
{sortKey === "assigned_team" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Actions
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
<Pagination
withEdges
total={appointmentInfo.total_pages}
value={currPage}
onChange={setCurrPage}
mt="sm"
style={{justifyItems: "flex-end", ...marginRightBottom}}
/>
</Card.Section>
</Card>
</Stack>
);
}

View File

@ -0,0 +1,410 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/DoctorTreatment";
import {useEffect, useMemo, useState} from "react";
import {BookingCategory, UserPermissionLevel} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import {
type DoctorTeamInfo,
type OutletContextType,
type PatientBookingInfoWithAdmission,
SORT_SYMBOLS,
WardTypes
} from "~/utils/models.ts";
import {Accordion, ActionIcon, Badge, Button, Card, Group, Modal, Select, Stack, Table, Text, Textarea} from "@mantine/core";
import {apiDoctorTreat, apiGetDoctorAppointments, apiGetTeamList, apiGetWardList} from "~/utils/hms_api.ts";
import {showErrorMessage, showInfoMessage} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginTopBottom} from "~/styles.ts";
import {confirmCheckWardPatients} from "~/components/subs/confirms.tsx";
import {TruncatedText} from "~/components/subs/TruncatedText.tsx";
import InfoIcon from "mdi-react/InfoIcon";
import NoteEditIcon from "mdi-react/NoteEditIcon";
import {useForm} from "@mantine/form";
import {PatientInfoDisplay} from "~/components/subs/PatientInfoDisplay.tsx";
import FilterIcon from "mdi-react/FilterIcon";
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
import {TeamInfoDisplay} from "~/components/subs/TeamInfoDisplay.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Doctor Treatment" },
{ name: "description", content: "Record treatment information for patients" },
];
}
function TreatmentModal({
opened,
onClose,
appointmentId,
patientName,
patientId,
onSuccess
}: {
opened: boolean;
onClose: () => void;
appointmentId: number;
patientName: string;
patientId: number;
onSuccess: () => void;
}) {
const [loading, setLoading] = useState(false);
const form = useForm({
initialValues: {
treatment_info: ''
},
validate: {
treatment_info: (value) => (!value ? 'Treatment information is required' : null)
}
});
const handleSubmit = (values: typeof form.values) => {
setLoading(true);
// Current timestamp in seconds
const currentTime = Math.floor(Date.now() / 1000);
apiDoctorTreat(
appointmentId,
values.treatment_info,
currentTime
).then(res => {
if (res.success) {
showInfoMessage("Treatment record saved successfully", "Success");
onSuccess();
onClose();
} else {
showErrorMessage(res.message, "Failed to save treatment record");
}
})
.catch(err => {
showErrorMessage("Failed to save treatment record", "Error");
})
.finally(() => {
setLoading(false);
});
};
return (
<Modal
opened={opened}
onClose={onClose}
title="Record Treatment"
size="lg"
centered
>
<Stack gap="md">
<Group>
<Text fw={500}>Patient:</Text>
<PatientInfoDisplay patientId={patientId} showIcon={false} />
</Group>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Textarea
label="Treatment Information"
description="Please provide detailed information about the treatment provided"
placeholder="Enter treatment details, medications, observations, etc."
required
minRows={5}
maxRows={10}
{...form.getInputProps('treatment_info')}
/>
<Group justify="flex-end" mt="md">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={loading}>Save Treatment Record</Button>
</Group>
</Stack>
</form>
</Stack>
</Modal>
);
}
export default function Component() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [appointments, setAppointments] = useState<PatientBookingInfoWithAdmission[]>([]);
const [wardMap, setWardMap] = useState<Map<number, string>>(new Map());
// Treatment modal state
const [treatmentModalOpen, setTreatmentModalOpen] = useState(false);
const [selectedAppointment, setSelectedAppointment] = useState<PatientBookingInfoWithAdmission | null>(null);
const [sortKey, setSortKey] = useState<string>("appointment_time");
const [sortDesc, setSortDesc] = useState<boolean>(true);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [teamList, setTeamList] = useState<DoctorTeamInfo[]>([]);
const { loginUserInfo } = useOutletContext<OutletContextType>();
useEffect(() => {
fetchAppointments();
fetchWards();
refreshTeamList()
}, []);
const refreshTeamList = () => {
apiGetTeamList(-1).then(res => {
if (res.success) {
setTeamList(res.data.teams)
}
else {
showErrorMessage(res.message, "Failed to fetch teams");
}
}).catch(err => showErrorMessage(err.toString(), "Failed to fetch teams"));
}
const getMemberNameFromTeam = (team: DoctorTeamInfo, doctorId: number) => {
const member = team.members.find(member => member.id === doctorId);
return member ? `${member.title} ${member.name}`.trim() : `Doctor ID: ${doctorId}`;
}
const fetchAppointments = (target_team_id?: number) => {
setLoading(true);
apiGetDoctorAppointments(target_team_id)
.then(res => {
if (res.success) {
setAppointments(res.data.appointments);
} else {
showErrorMessage(res.message, "Failed to fetch appointments");
}
})
.catch(err => {
showErrorMessage("Failed to fetch appointments", "Error");
})
.finally(() => {
setLoading(false);
});
};
const fetchWards = () => {
apiGetWardList(1, true)
.then(res => {
if (res.success) {
const wardNameMap = new Map<number, string>();
res.data.wards.forEach(ward => {
wardNameMap.set(ward.id, `${ward.name} (${WardTypes[ward.type]})`);
});
setWardMap(wardNameMap);
}
})
.catch(err => {
showErrorMessage("Failed to fetch wards information", "Error");
});
};
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc);
} else {
setSortKey(key);
setSortDesc(false); // Default ascending
}
};
const openTreatmentModal = (appointment: PatientBookingInfoWithAdmission) => {
setSelectedAppointment(appointment);
setTreatmentModalOpen(true);
};
const closeTreatmentModal = () => {
setTreatmentModalOpen(false);
setSelectedAppointment(null);
};
const handleViewWard = (wardId: number) => {
confirmCheckWardPatients(wardId, fetchAppointments);
};
const getCategoryDisplay = (category: string) => {
const key = Object.entries(BookingCategory).find(([_, value]) => value === category)?.[0];
return key ? key.replace(/_/g, ' ') : category;
};
const getStatusBadge = (appointment: PatientBookingInfoWithAdmission) => {
if (appointment.discharged) {
return <Badge color="purple">Discharged</Badge>;
} else if (appointment.admitted) {
return <Badge color="blue">Admitted</Badge>;
} else {
return <Badge color="yellow">Pending</Badge>;
}
};
const sortedAppointments = useMemo(() => {
let data = [...appointments];
// Apply sorting
if (!sortKey) return data;
data.sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB);
return sortDesc ? -cmp : cmp;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
return 0;
});
return data;
}, [appointments, sortKey, sortDesc]);
const rows = sortedAppointments.map((appointment) => (
<Table.Tr key={appointment.id}>
<Table.Td>{appointment.id}</Table.Td>
<Table.Td>
<PatientInfoDisplay patientId={appointment.patient_id} />
</Table.Td>
<Table.Td>{getCategoryDisplay(appointment.category)}</Table.Td>
<Table.Td>{new Date(appointment.appointment_time * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
<Table.Td>
<TruncatedText
text={appointment.description}
title={`Description - Appointment #${appointment.id}`}
/>
</Table.Td>
<Table.Td>{getStatusBadge(appointment)}</Table.Td>
<Table.Td>
<Group gap="xs">
<Text>{wardMap.get(appointment.admission.ward_id) || `Ward ID: ${appointment.admission.ward_id}`}</Text>
<ActionIcon
size="sm"
onClick={() => handleViewWard(appointment.admission.ward_id)}
title="View ward details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
</Group>
</Table.Td>
<Table.Td>
<TeamInfoDisplay teamId={appointment.assigned_team || -1} />
</Table.Td>
<Table.Td>
<Group>
<ActionIcon
color="blue"
onClick={() => openTreatmentModal(appointment)}
title="Record treatment"
>
<NoteEditIcon style={iconMStyle}/>
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
const handleTeamChange = (value: string | null) => {
setSelectedTeamId(value);
const teamId = value === null ? undefined : parseInt(value);
fetchAppointments(teamId);
};
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Text size="1.5em" fw={700}>Patient Treatment</Text>
<Button
onClick={() => fetchAppointments(selectedTeamId ? parseInt(selectedTeamId) : undefined)}
loading={loading}
variant="outline"
>Refresh</Button>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
{ loginUserInfo && loginUserInfo.user_permission >= UserPermissionLevel.ADMIN &&
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>
<Group>
<Text>Select a Team</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Group grow>
<Select
label="Select Team"
placeholder="Select a medical team"
data={[
{ value: "-1", label: "All Teams" },
...(teamList.map(team => ({
value: team.id.toString(),
label: `Team ${team.id}: ${team.department.replace(/_/g, ' ')} (${getMemberNameFromTeam(team, team.consultant_id)})`
})) || [])
]}
value={selectedTeamId}
onChange={handleTeamChange}
searchable
clearable
/>
</Group>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section> }
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("patient_id")}>
Patient{" "}
{sortKey === "patient_id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("category")}>
Category{" "}
{sortKey === "category" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("appointment_time")}>
Appointment Time{" "}
{sortKey === "appointment_time" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Description
</Table.Th>
<Table.Th onClick={() => handleSort("admitted")}>
Status{" "}
{sortKey === "admitted" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Ward
</Table.Th>
<Table.Th>
Medical Team
</Table.Th>
<Table.Th>
Actions
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
</Card.Section>
</Card>
{selectedAppointment && (
<TreatmentModal
opened={treatmentModalOpen}
onClose={closeTreatmentModal}
appointmentId={selectedAppointment.id}
patientName={`Patient ID: ${selectedAppointment.patient_id}`}
patientId={selectedAppointment.patient_id}
onSuccess={fetchAppointments}
/>
)}
</Stack>
);
}

View File

@ -0,0 +1,684 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/Home";
import {useEffect, useState} from "react";
import {useNavigate, useOutletContext} from "react-router";
import {
type DoctorDataWithPermission,
type DoctorTeamInfo,
type OutletContextType,
type PatientBookingInfo,
type PatientData,
type TreatmentInfoWithDoctorInfo,
type WardInfo
} from "~/utils/models.ts";
import {Badge, Button, Card, Grid, Group, Modal, PasswordInput, Stack, Table, Text} from "@mantine/core";
import {
apiChangeMyPassword,
apiGetPatientBookingList,
apiGetTreatmentRecords,
apiPatientGetCurrentWard
} from "~/utils/hms_api.ts";
import {showErrorMessage, showInfoMessage} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRound, marginTop} from "~/styles.ts";
import {useForm} from "@mantine/form";
import {BookingCategory, DashboardPageType, DoctorGrade, UserType} from "~/utils/hms_enums.ts";
import {confirmEditDoctor, confirmEditPatient} from "~/components/subs/confirms.tsx";
import EditIcon from "mdi-react/EditIcon";
import LockResetIcon from "mdi-react/LockResetIcon";
import ArrowRightIcon from "mdi-react/ArrowRightIcon";
import {TruncatedText} from "~/components/subs/TruncatedText.tsx";
import {PatientInfoDisplay} from "~/components/subs/PatientInfoDisplay.tsx";
import {TeamInfoDisplay} from "~/components/subs/TeamInfoDisplay.tsx";
import {DoctorInfoDisplay} from "~/components/subs/DoctorInfoDisplay.tsx";
import DoctorTeamsSimple from "~/components/subs/DoctorTeamsSimple.tsx";
import { ResponsiveTableContainer } from "~/components/subs/ResponsiveTableContainer";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Home" },
{ name: "description", content: "Dashboard Home" },
];
}
interface ChangePasswordModalProps {
opened: boolean;
onClose: () => void;
onSuccess: () => void;
}
function ChangePasswordModal({ opened, onClose, onSuccess }: ChangePasswordModalProps) {
const form = useForm({
initialValues: {
old_password: '',
new_password: '',
confirm_password: '',
},
validate: {
old_password: (value) => (!value ? 'Current password is required' : null),
new_password: (value) => (!value ? 'New password is required' : value.length < 6 ? 'Password must be at least 6 characters' : null),
confirm_password: (value, values) => (value !== values.new_password ? 'Passwords do not match' : null),
},
});
const handleSubmit = (values: typeof form.values) => {
apiChangeMyPassword(values.old_password, values.new_password)
.then(res => {
if (res.success) {
showInfoMessage('', 'Password changed successfully', 3000);
form.reset();
onClose();
onSuccess();
} else {
showErrorMessage(res.message, 'Failed to change password');
}
})
.catch(err => {
showErrorMessage(err.toString(), 'Failed to change password');
});
};
return (
<Modal opened={opened} onClose={onClose} title="Change Password" centered>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
withAsterisk
{...form.getInputProps('old_password')}
/>
<PasswordInput
label="New Password"
placeholder="Enter new password"
withAsterisk
{...form.getInputProps('new_password')}
/>
<PasswordInput
label="Confirm New Password"
placeholder="Confirm new password"
withAsterisk
{...form.getInputProps('confirm_password')}
/>
<Group justify="flex-end" mt="md">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit">Change Password</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
interface PatientHomeProps {
patientData: PatientData;
refreshUserInfo: () => void;
}
function PatientHome({ patientData, refreshUserInfo }: PatientHomeProps) {
const navigate = useNavigate();
const [changePasswordOpened, setChangePasswordOpened] = useState(false);
const [currentWard, setCurrentWard] = useState<WardInfo | null>(null);
const [loadingWard, setLoadingWard] = useState(false);
const [bookings, setBookings] = useState<PatientBookingInfo[]>([]);
const [loadingBookings, setLoadingBookings] = useState(false);
const [treatments, setTreatments] = useState<TreatmentInfoWithDoctorInfo[]>([]);
const [loadingTreatments, setLoadingTreatments] = useState(false);
const { changePage } = useOutletContext<OutletContextType>();
useEffect(() => {
fetchCurrentWard();
fetchRecentBookings();
fetchRecentTreatments();
}, []);
const fetchCurrentWard = () => {
setLoadingWard(true);
apiPatientGetCurrentWard()
.then(res => {
if (res.success && res.data.ward) {
setCurrentWard(res.data.ward);
} else {
setCurrentWard(null);
}
})
.catch(err => {
showErrorMessage(err.toString(), 'Error');
})
.finally(() => {
setLoadingWard(false);
});
};
const fetchRecentBookings = () => {
setLoadingBookings(true);
apiGetPatientBookingList(1)
.then(res => {
if (res.success) {
const recentBookings = [...res.data.appointments]
.sort((a, b) => b.id - a.id)
.slice(0, 5);
setBookings(recentBookings);
} else {
showErrorMessage(res.message, 'Failed to load appointments');
}
})
.catch(err => {
showErrorMessage(err.toString(), 'Error');
})
.finally(() => {
setLoadingBookings(false);
});
};
const fetchRecentTreatments = () => {
setLoadingTreatments(true);
apiGetTreatmentRecords(1, patientData.id, null, null)
.then(res => {
if (res.success) {
// Get only the 10 most recent treatments
const recentTreatments = [...res.data.treatments]
.sort((a, b) => b.treated_at - a.treated_at)
.slice(0, 10);
setTreatments(recentTreatments);
} else {
showErrorMessage(res.message, 'Failed to load treatments');
}
})
.catch(err => {
showErrorMessage(err.toString(), 'Error');
})
.finally(() => {
setLoadingTreatments(false);
});
};
const handleEditInfo = () => {
confirmEditPatient(patientData, () => {
refreshUserInfo();
});
};
const getStatusBadge = (booking: PatientBookingInfo) => {
if (booking.discharged) {
return <Badge color="purple">Discharged</Badge>;
} else if (booking.admitted) {
return <Badge color="blue">Admitted</Badge>;
} else if (booking.approved) {
if (booking.assigned_team === null) {
return <Badge color="red">Rejected</Badge>;
} else {
return <Badge color="green">Approved</Badge>;
}
} else {
return <Badge color="yellow">Pending</Badge>;
}
};
const getCategoryDisplay = (category: string) => {
const key = Object.entries(BookingCategory).find(([_, value]) => value === category)?.[0];
return key ? key.replace(/_/g, ' ') : category;
};
const bookingRows = bookings.map((booking) => (
<Table.Tr key={booking.id}>
<Table.Td>{booking.id}</Table.Td>
<Table.Td>{getCategoryDisplay(booking.category)}</Table.Td>
<Table.Td>{new Date(booking.appointment_time * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
<Table.Td>
<TruncatedText
text={booking.description}
title={`Description - Appointment #${booking.id}`}
/>
</Table.Td>
<Table.Td>{getStatusBadge(booking)}</Table.Td>
<Table.Td>
{booking.assigned_team ? (
<TeamInfoDisplay teamId={booking.assigned_team} />
) : (
<Text size="sm" c="dimmed">Not Assigned</Text>
)}
</Table.Td>
<Table.Td>
{booking.feedback ? (
<TruncatedText
text={booking.feedback}
title={`Feedback - Appointment #${booking.id}`}
/>
) : (
<Text>-</Text>
)}
</Table.Td>
</Table.Tr>
));
const treatmentRows = treatments.map((treatment) => (
<Table.Tr key={treatment.id}>
<Table.Td>{treatment.id}</Table.Td>
<Table.Td>
<DoctorInfoDisplay doctorId={treatment.doctor_id} />
</Table.Td>
<Table.Td>
{treatment.team ? (
<TeamInfoDisplay teamId={treatment.team.id} />
) : (
<Text size="sm" c="dimmed">Not Assigned</Text>
)}
</Table.Td>
<Table.Td>
<TruncatedText
text={treatment.treat_info}
title={`Treatment Info - Record #${treatment.id}`}
/>
</Table.Td>
<Table.Td>{new Date(treatment.treated_at * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Card padding="lg" radius="md" withBorder>
<Card.Section withBorder>
<Group justify="space-between" style={marginRound}>
<Stack gap="xs">
<Text size="xl" fw={700}>Hi, {patientData.title} {patientData.name}</Text>
<Text size="sm" c="dimmed">Welcome to the Hospital Management System</Text>
</Stack>
<Group>
<Button
leftSection={<EditIcon style={iconMStyle} />}
onClick={handleEditInfo}
>
Edit Profile
</Button>
<Button
leftSection={<LockResetIcon style={iconMStyle} />}
onClick={() => setChangePasswordOpened(true)}
>
Change Password
</Button>
</Group>
</Group>
</Card.Section>
<Card.Section>
<Grid style={marginRound}>
<Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="sm">
<Group>
<Text fw={500} w={100}>Full Name:</Text>
<Text>{patientData.title} {patientData.name}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Gender:</Text>
<Text>{patientData.gender}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Birth Date:</Text>
<Text>{patientData.birth_date}</Text>
</Group>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="sm">
<Group>
<Text fw={500} w={100}>Email:</Text>
<Text>{patientData.email}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Phone:</Text>
<Text>{patientData.phone}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Address:</Text>
<Text>{patientData.address}, {patientData.postcode}</Text>
</Group>
</Stack>
</Grid.Col>
</Grid>
</Card.Section>
</Card>
<Card padding="lg" radius="md" withBorder>
<Card.Section withBorder>
<Group style={{...marginLeftRight, ...marginTop}}>
<Text size="lg" fw={700} mb="md">Current Ward</Text>
</Group>
</Card.Section>
<Card.Section>
{loadingWard ? (
<Group style={marginRound}>
<Text>Loading...</Text>
</Group>
) : currentWard ? (
<Grid style={marginRound}>
<Grid.Col span={6}>
<Stack gap="sm">
<Group>
<Text fw={500} w={120}>Ward ID:</Text>
<Text>{currentWard.id}</Text>
</Group>
<Group>
<Text fw={500} w={120}>Ward Name:</Text>
<Text>{currentWard.name}</Text>
</Group>
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<Stack gap="sm">
<Group>
<Text fw={500} w={120}>Ward Type:</Text>
<Text>{currentWard.type.replace(/_/g, ' ')}</Text>
</Group>
<Group>
<Text fw={500} w={120}>Occupancy:</Text>
<Text>{currentWard.current_occupancy} / {currentWard.total_capacity}</Text>
</Group>
</Stack>
</Grid.Col>
</Grid>
) : (
<Group style={marginRound}>
<Text c="dimmed">You are not currently admitted to any ward.</Text>
</Group>
)}
</Card.Section>
</Card>
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" mb="md">
<Text size="lg" fw={700}>Recent Appointments</Text>
<Button
rightSection={<ArrowRightIcon style={iconMStyle} />}
variant="subtle"
onClick={() => changePage(DashboardPageType.PatientBooking)}
>
View All
</Button>
</Group>
<Card.Section withBorder>
<Group style={marginRound}>
{loadingBookings ? (
<Text>Loading...</Text>
) : bookings.length > 0 ? (
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Appointment Time</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Team</Table.Th>
<Table.Th>Feedback</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{bookingRows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
) : (
<Text c="dimmed">No appointments found.</Text>
)}
</Group>
</Card.Section>
</Card>
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" mb="md">
<Text size="lg" fw={700}>Recent Treatments</Text>
<Button
rightSection={<ArrowRightIcon style={iconMStyle} />}
variant="subtle"
onClick={() => changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/${patientData.id}`)}
>
View All
</Button>
</Group>
<Card.Section withBorder>
<Group style={marginRound}>
{loadingTreatments ? (
<Text>Loading...</Text>
) : treatments.length > 0 ? (
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Doctor</Table.Th>
<Table.Th>Medical Team</Table.Th>
<Table.Th>Treatment Info</Table.Th>
<Table.Th>Treatment Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{treatmentRows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
) : (
<Text c="dimmed">No treatment records found.</Text>
)}
</Group>
</Card.Section>
</Card>
<ChangePasswordModal
opened={changePasswordOpened}
onClose={() => setChangePasswordOpened(false)}
onSuccess={() => {navigate("/dashboard/account/login")}}
/>
</Stack>
);
}
interface DoctorHomeProps {
doctorData: DoctorDataWithPermission;
doctorTeams: DoctorTeamInfo[];
refreshUserInfo: () => void;
}
function DoctorHome({ doctorData, doctorTeams, refreshUserInfo }: DoctorHomeProps) {
const [changePasswordOpened, setChangePasswordOpened] = useState(false);
const [assignedPatients, setAssignedPatients] = useState<any[]>([]);
const [loadingPatients, setLoadingPatients] = useState(false);
const { changePage } = useOutletContext<OutletContextType>();
const navigate = useNavigate();
useEffect(() => {
fetchAssignedPatients();
}, []);
const fetchAssignedPatients = () => {
setLoadingPatients(true);
apiGetTreatmentRecords(1, null, doctorData.id, null)
.then(res => {
if (res.success) {
const uniquePatientIds = new Set();
const patientTreatments = res.data.treatments
.filter(treatment => {
if (!uniquePatientIds.has(treatment.patient_id)) {
uniquePatientIds.add(treatment.patient_id);
return true;
}
return false;
})
.slice(0, 15); // Get only the first 15 unique patients
setAssignedPatients(patientTreatments);
} else {
showErrorMessage(res.message, 'Failed to load patient assignments');
}
})
.catch(err => {
showErrorMessage(err.toString(), 'Error');
})
.finally(() => {
setLoadingPatients(false);
});
};
const handleEditInfo = () => {
confirmEditDoctor(doctorData, () => {
refreshUserInfo();
});
};
const patientRows = assignedPatients.map((treatment) => (
<Table.Tr key={treatment.id}>
<Table.Td>
<PatientInfoDisplay patientId={treatment.patient_id} />
</Table.Td>
<Table.Td>
<TruncatedText
text={treatment.treat_info}
title={`Treatment Info - Record #${treatment.id}`}
/>
</Table.Td>
<Table.Td>{new Date(treatment.treated_at * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Card padding="lg" radius="md" withBorder>
<Card.Section withBorder>
<Group justify="space-between" style={marginRound}>
<Stack gap="xs">
<Text size="xl" fw={700}>Hi, {doctorData.title} {doctorData.name}</Text>
<Text size="sm" c="dimmed">Welcome to the Hospital Management System</Text>
</Stack>
<Group>
<Button
leftSection={<EditIcon style={iconMStyle} />}
onClick={handleEditInfo}
>
Edit Profile
</Button>
<Button
leftSection={<LockResetIcon style={iconMStyle} />}
onClick={() => setChangePasswordOpened(true)}
>
Change Password
</Button>
</Group>
</Group>
</Card.Section>
<Card.Section>
<Grid style={marginRound}>
<Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="sm">
<Group>
<Text fw={500} w={100}>Full Name:</Text>
<Text>{doctorData.title} {doctorData.name}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Grade:</Text>
<Text>{DoctorGrade[doctorData.grade]}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Gender:</Text>
<Text>{doctorData.gender}</Text>
</Group>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="sm">
<Group>
<Text fw={500} w={100}>Birth Date:</Text>
<Text>{doctorData.birth_date}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Email:</Text>
<Text>{doctorData.email}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Phone:</Text>
<Text>{doctorData.phone}</Text>
</Group>
</Stack>
</Grid.Col>
</Grid>
</Card.Section>
</Card>
<DoctorTeamsSimple doctorTeams={doctorTeams} />
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" mb="md">
<Text size="lg" fw={700}>Recent Patients</Text>
<Button
rightSection={<ArrowRightIcon style={iconMStyle} />}
variant="subtle"
onClick={() => changePage(DashboardPageType.DoctorTreatment)}
>
View All
</Button>
</Group>
<Card.Section withBorder>
<Group style={marginRound}>
{loadingPatients ? (
<Text>Loading...</Text>
) : assignedPatients.length > 0 ? (
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Patient</Table.Th>
<Table.Th>Last Treatment</Table.Th>
<Table.Th>Treatment Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{patientRows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
) : (
<Text c="dimmed">No patients assigned yet.</Text>
)}
</Group>
</Card.Section>
</Card>
<ChangePasswordModal
opened={changePasswordOpened}
onClose={() => setChangePasswordOpened(false)}
onSuccess={() => {navigate("/dashboard/account/login")}}
/>
</Stack>
);
}
export default function Component() {
const { loginUserInfo, refreshMyInfo } = useOutletContext<OutletContextType>();
if (!loginUserInfo) {
return (
<Stack>
<Text size="1.5em" fw={700} style={marginLeftRight}>Dashboard</Text>
<Text>Loading user information...</Text>
</Stack>
);
}
const isDoctor = loginUserInfo.user_type === UserType.DOCTOR;
return (
<Stack>
{isDoctor && loginUserInfo.doctor_data ? (
<DoctorHome
doctorData={loginUserInfo.doctor_data}
doctorTeams={loginUserInfo.doctor_teams || []}
refreshUserInfo={refreshMyInfo}
/>
) : loginUserInfo.patient_data ? (
<PatientHome
patientData={loginUserInfo.patient_data}
refreshUserInfo={refreshMyInfo}
/>
) : (
<Text>Unable to load user profile information.</Text>
)}
</Stack>
);
}

View File

@ -0,0 +1,252 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/MedicalTeamsManagement";
import {useEffect, useMemo, useState} from "react";
import {DashboardPageType} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import {
type DoctorDataWithPermission,
type DoctorTeamInfo,
type OutletContextType,
SORT_SYMBOLS
} from "~/utils/models.ts";
import {Accordion, ActionIcon, Button, Card, Group, Pagination, Stack, Table, Text, Tooltip} from "@mantine/core";
import {apiGetDoctorsList, apiGetTeamList} from "~/utils/hms_api.ts";
import {showErrorMessage} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRightBottom, marginTopBottom} from "~/styles.ts";
import PencilIcon from "mdi-react/PencilIcon";
import DeleteIcon from "mdi-react/DeleteIcon";
import {confirmDeleteTeam, confirmEditOrCreateTeam, confirmViewTeamMembers} from "~/components/subs/confirms.tsx";
import AddIcon from "mdi-react/AddIcon";
import EyeIcon from "mdi-react/EyeIcon";
import {MedicalTeamManageFilter} from "~/components/subs/MedicalTeamManageFilter.tsx";
import HistoryIcon from "mdi-react/HistoryIcon";
import FilterIcon from "mdi-react/FilterIcon";
import { ResponsiveTableContainer } from "~/components/subs/ResponsiveTableContainer";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Medical Teams Management" },
{ name: "description", content: "Medical Teams Management" },
];
}
export default function Component() {
const navigate = useNavigate();
const [refreshingTeamList, setRefreshingTeamList] = useState<boolean>(false);
const [teamInfo, setTeamInfo] = useState<{teams: DoctorTeamInfo[], total_pages: number}>({teams: [], total_pages: 1});
const [doctorsList, setDoctorsList] = useState<DoctorDataWithPermission[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState<boolean>(false);
const [sortKey, setSortKey] = useState<string>("");
const [sortDesc, setSortDesc] = useState<boolean>(true);
const [currPage, setCurrPage] = useState<number>(1);
const [filterDepartments, setFilterDepartments] = useState<string[]>([]);
const [filterAdminTeam, setFilterAdminTeam] = useState<number>(-1);
const { changePage } = useOutletContext<OutletContextType>();
useEffect(() => {
refreshTeamList();
loadDoctorsList();
}, []);
useEffect(() => {
refreshTeamList();
}, [currPage]);
const updateFilter = (departments: string[], adminTeam: number) => {
setFilterDepartments(departments);
setFilterAdminTeam(adminTeam);
};
const loadDoctorsList = () => {
setLoadingDoctors(true);
apiGetDoctorsList(1).then(res => {
if (!res.success) {
showErrorMessage(res.message, "Failed to get doctors list");
return;
}
setDoctorsList(res.data.doctors);
})
.catch(err => {})
.finally(() => { setLoadingDoctors(false); });
};
const refreshTeamList = () => {
setRefreshingTeamList(true);
apiGetTeamList(currPage).then(res => {
if (!res.success) {
showErrorMessage(res.message, "Failed to get medical teams list");
return;
}
setTeamInfo(res.data);
})
.catch(err => {})
.finally(() => { setRefreshingTeamList(false); });
};
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc);
} else {
setSortKey(key);
setSortDesc(false); // Default ascending
}
};
const sortedTeams = useMemo(() => {
let data = [...teamInfo.teams];
data = data.filter((team) => {
const okDepartment = filterDepartments.length === 0 || filterDepartments.includes(team.department);
const okAdmin = filterAdminTeam === -1 || (filterAdminTeam === 1 ? team.is_admin_team : !team.is_admin_team);
return okDepartment && okAdmin;
});
if (!sortKey) return data;
data.sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB);
return sortDesc ? -cmp : cmp;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
if (typeof valA === 'boolean' && typeof valB === 'boolean') {
return sortDesc ? (valB ? 1 : -1) : (valA ? 1 : -1);
}
return 0;
});
return data;
}, [teamInfo.teams, sortKey, sortDesc, filterDepartments, filterAdminTeam]);
const handleCreateTeam = () => {
confirmEditOrCreateTeam(null, doctorsList, refreshTeamList);
};
const handleEditTeam = (team: DoctorTeamInfo) => {
confirmEditOrCreateTeam(team, doctorsList, refreshTeamList);
};
const handleDeleteTeam = (teamId: number, department: string) => {
confirmDeleteTeam(teamId, department, refreshTeamList);
};
const handleViewTeamMembers = (team: DoctorTeamInfo) => {
confirmViewTeamMembers(team);
};
const rows = sortedTeams.map((team) => (
<Table.Tr key={team.id}>
<Table.Td>{team.id}</Table.Td>
<Table.Td>{team.department.replace(/_/g, ' ')}</Table.Td>
<Table.Td>
{team.members.find(m => m.id === team.consultant_id)
? `${team.members.find(m => m.id === team.consultant_id)?.title || ''} ${team.members.find(m => m.id === team.consultant_id)?.name || ''}`
: `ID: ${team.consultant_id}`}
</Table.Td>
<Table.Td>{team.members.length}</Table.Td>
<Table.Td>{team.is_admin_team ? 'Yes' : 'No'}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Treatment Record" withArrow>
<ActionIcon onClick={() => { changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/t${team.id}`) }}>
<HistoryIcon style={iconMStyle}/>
</ActionIcon>
</Tooltip>
<ActionIcon onClick={() => handleViewTeamMembers(team)}>
<EyeIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon onClick={() => handleEditTeam(team)}>
<PencilIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon color="red" onClick={() => handleDeleteTeam(team.id, team.department.replace(/_/g, ' '))}>
<DeleteIcon style={iconMStyle}/>
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Text size="1.5em" fw={700}>Medical Teams Management</Text>
<Button
leftSection={<AddIcon style={iconMStyle}/>}
onClick={handleCreateTeam}
loading={loadingDoctors}
>Add Medical Team</Button>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>
<Group>
<FilterIcon style={iconMStyle} />
<Text>Filters</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<MedicalTeamManageFilter onChange={updateFilter}/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
Team ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("department")}>
Department{" "}
{sortKey === "department" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("consultant_id")}>
Consultant{" "}
{sortKey === "consultant_id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Members Count
</Table.Th>
<Table.Th onClick={() => handleSort("is_admin_team")}>
Admin Team{" "}
{sortKey === "is_admin_team" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Operations
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
<Pagination
withEdges
total={teamInfo.total_pages}
value={currPage}
onChange={setCurrPage}
mt="sm"
style={{justifyItems: "flex-end", ...marginRightBottom}}
/>
</Card.Section>
</Card>
</Stack>
);
}

View File

@ -0,0 +1,23 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/Page2";
import {useDisclosure} from "@mantine/hooks";
import {useState} from "react";
import {DashboardPageType} from "~/utils/hms_enums.ts";
import {useNavigate} from "react-router";
import { useOutletContext } from 'react-router';
import type {OutletContextType} from "~/utils/models.ts";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Dashboard P2" },
{ name: "description", content: "Dashboard P2" },
];
}
export default function Component() {
const navigate = useNavigate();
const { loginUserInfo, refreshMyInfo } = useOutletContext<OutletContextType>();
return (
<h1>Dashboard Page2</h1>
);
}

View File

@ -0,0 +1,345 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/PatientBooking";
import {useDisclosure} from "@mantine/hooks";
import {useEffect, useMemo, useState} from "react";
import {BookingCategory, DashboardPageType} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import {
type OutletContextType,
type PatientBookingInfo,
type DoctorTeamInfo,
SORT_SYMBOLS
} from "~/utils/models.ts";
import {Accordion, ActionIcon, Button, Card, Group, Pagination, Stack, Table, Text, Badge} from "@mantine/core";
import {apiGetPatientBookingList, get_team_info} from "~/utils/hms_api.ts";
import {showErrorMessage, timestampToDate} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRightBottom, marginRound, marginTopBottom, textCenter} from "~/styles.ts";
import PencilIcon from "mdi-react/PencilIcon";
import DeleteIcon from "mdi-react/DeleteIcon";
import InfoIcon from "mdi-react/InfoIcon";
import {
confirmDeleteBooking,
confirmEditOrCreateBooking,
confirmViewTeamMembers
} from "~/components/subs/confirms.tsx";
import AddIcon from "mdi-react/AddIcon";
import {TruncatedText} from "~/components/subs/TruncatedText.tsx";
import { ResponsiveTableContainer } from "~/components/subs/ResponsiveTableContainer";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Patient Booking" },
{ name: "description", content: "Dashboard Patient Booking" },
];
}
export default function Component() {
const navigate = useNavigate();
const [refreshingBookingList, setRefreshingBookingList] = useState<boolean>(false);
const [bookingInfo, setBookingInfo] = useState<{appointments: PatientBookingInfo[], total_pages: number}>({appointments: [], total_pages: 1});
const [teamInfoMap, setTeamInfoMap] = useState<Map<number, DoctorTeamInfo>>(new Map());
const [loadingTeams, setLoadingTeams] = useState<Set<number>>(new Set());
const [sortKey, setSortKey] = useState<string>("");
const [sortDesc, setSortDesc] = useState<boolean>(true);
const [currPage, setCurrPage] = useState<number>(1);
const { loginUserInfo, refreshMyInfo } = useOutletContext<OutletContextType>();
useEffect(() => {
refreshBookingList();
}, []);
useEffect(() => {
refreshBookingList();
}, [currPage]);
const refreshBookingList = () => {
setRefreshingBookingList(true);
apiGetPatientBookingList(currPage).then(res => {
if (!res.success) {
showErrorMessage(res.message, "Failed to get appointments list");
return;
}
setBookingInfo(res.data);
// Fetch team info for all appointments with assigned teams
const teamsToFetch = res.data.appointments
.filter(app => app.assigned_team !== null)
.map(app => app.assigned_team as number);
// Remove duplicates
const uniqueTeams = [...new Set(teamsToFetch)];
// Fetch team info for each unique team ID
uniqueTeams.forEach(teamId => {
fetchTeamInfo(teamId);
});
})
.catch(err => {})
.finally(() => { setRefreshingBookingList(false); });
};
const fetchTeamInfo = (teamId: number) => {
if (teamId === null || teamInfoMap.has(teamId) || loadingTeams.has(teamId)) {
return;
}
setLoadingTeams(prev => new Set([...prev, teamId]));
get_team_info(teamId).then(res => {
if (res.success) {
setTeamInfoMap(prev => {
const newMap = new Map(prev);
newMap.set(teamId, res.data.team);
return newMap;
});
}
})
.catch(err => {})
.finally(() => {
setLoadingTeams(prev => {
const newSet = new Set(prev);
newSet.delete(teamId);
return newSet;
});
});
};
const handleViewTeam = (teamId: number) => {
const teamInfo = teamInfoMap.get(teamId);
if (teamInfo) {
confirmViewTeamMembers(teamInfo);
} else {
// If team info is not fetched yet, fetch it and then show
get_team_info(teamId).then(res => {
if (res.success) {
setTeamInfoMap(prev => {
const newMap = new Map(prev);
newMap.set(teamId, res.data.team);
return newMap;
});
confirmViewTeamMembers({
...res.data.team,
members: res.data.members
});
} else {
showErrorMessage(res.message, "Failed to fetch team information");
}
}).catch(err => {
showErrorMessage("Failed to fetch team information", "Error");
});
}
};
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc);
} else {
setSortKey(key);
setSortDesc(false); // Default ascending
}
};
const sortedBookings = useMemo(() => {
let data = [...bookingInfo.appointments];
if (!sortKey) return data;
data.sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB);
return sortDesc ? -cmp : cmp;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
if (typeof valA === 'boolean' && typeof valB === 'boolean') {
return sortDesc ? (valB ? 1 : -1) : (valA ? 1 : -1);
}
return 0;
});
return data;
}, [bookingInfo.appointments, sortKey, sortDesc]);
const handleCreateBooking = () => {
confirmEditOrCreateBooking(null, refreshBookingList);
};
const handleEditBooking = (booking: PatientBookingInfo) => {
confirmEditOrCreateBooking(booking, refreshBookingList);
};
const handleDeleteBooking = (bookingId: number, category: string) => {
confirmDeleteBooking(bookingId, category, refreshBookingList);
};
const getCategoryDisplay = (category: string) => {
const key = Object.entries(BookingCategory).find(([_, value]) => value === category)?.[0];
return key ? key.replace(/_/g, ' ') : category;
};
const getStatusBadge = (appointment: PatientBookingInfo) => {
if (appointment.discharged) {
return <Badge color="purple">Discharged</Badge>;
} else if (appointment.admitted) {
return <Badge color="blue">Admitted</Badge>;
} else if (appointment.approved) {
if (appointment.assigned_team === null) {
return <Badge color="red">Rejected</Badge>;
} else {
return <Badge color="green">Approved</Badge>;
}
} else {
return <Badge color="yellow">Pending</Badge>;
}
};
const getTeamDisplay = (booking: PatientBookingInfo) => {
if (!booking.approved) {
return <Text size="sm" c="dimmed">Pending approval</Text>;
}
if (booking.assigned_team === null) {
return <Text size="sm" c="dimmed">Not assigned</Text>;
}
const teamInfo = teamInfoMap.get(booking.assigned_team);
if (!teamInfo) {
return (
<Group>
<Text>Team ID: {booking.assigned_team}</Text>
<ActionIcon
onClick={() => handleViewTeam(booking.assigned_team as number)}
title="View team details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
</Group>
);
}
return (
<Group>
<Text>{teamInfo.department.replace(/_/g, ' ')}</Text>
<ActionIcon
onClick={() => handleViewTeam(booking.assigned_team as number)}
title="View team details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
</Group>
);
};
const rows = sortedBookings.map((booking) => (
<Table.Tr key={booking.id}>
<Table.Td>{booking.id}</Table.Td>
<Table.Td>{getCategoryDisplay(booking.category)}</Table.Td>
<Table.Td>{new Date(booking.appointment_time * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
<Table.Td>
<TruncatedText
text={booking.description}
title={`Description - Booking #${booking.id}`}
/>
</Table.Td>
<Table.Td>{getStatusBadge(booking)}</Table.Td>
<Table.Td>
{booking.feedback ?
<TruncatedText
text={booking.feedback}
title={`Feedback - Booking #${booking.id}`}
/> :
'-'
}
</Table.Td>
<Table.Td>{getTeamDisplay(booking)}</Table.Td>
<Table.Td>
<Group>
{!booking.approved && (
<>
<ActionIcon onClick={() => handleEditBooking(booking)}>
<PencilIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon color="red" onClick={() => handleDeleteBooking(booking.id, booking.category)}>
<DeleteIcon style={iconMStyle}/>
</ActionIcon>
</>
)}
{booking.approved && (
<Text size="sm" c="dimmed">No actions available</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Text size="1.5em" fw={700}>My Appointments</Text>
<Button
leftSection={<AddIcon style={iconMStyle}/>}
onClick={handleCreateBooking}
>Book Appointment</Button>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("category")}>
Category{" "}
{sortKey === "category" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("appointment_time")}>
Appointment Time{" "}
{sortKey === "appointment_time" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Description
</Table.Th>
<Table.Th onClick={() => handleSort("approved")}>
Status{" "}
{sortKey === "approved" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Feedback
</Table.Th>
<Table.Th>
Assigned Team
</Table.Th>
<Table.Th>
Actions
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
<Pagination
withEdges
total={bookingInfo.total_pages}
value={currPage}
onChange={setCurrPage}
mt="sm"
style={{justifyItems: "flex-end", ...marginRightBottom}}
/>
</Card.Section>
</Card>
</Stack>
);
}

View File

@ -0,0 +1,432 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/PatientsManagement";
import {useEffect, useMemo, useState} from "react";
import {DashboardPageType, UserType} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import {
type DoctorTeamInfo,
type OutletContextType,
type PatientDataWithWardAndAdmission,
SORT_SYMBOLS,
type WardInfo
} from "~/utils/models.ts";
import {Accordion, ActionIcon, Button, Card, Group, Pagination, Stack, Table, Text, Tooltip} from "@mantine/core";
import {apiGetPatientsList, apiGetTeamList, apiGetWardList} from "~/utils/hms_api.ts";
import {showErrorMessage} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRightBottom, marginTopBottom} from "~/styles.ts";
import PencilIcon from "mdi-react/PencilIcon";
import LogoutVariantIcon from "mdi-react/LogoutVariantIcon";
import AddIcon from "mdi-react/AddIcon";
import InfoIcon from "mdi-react/InfoIcon";
import {
confirmAdminAddUser,
confirmCheckWardPatients,
confirmEditPatient,
confirmPatientDischarge,
confirmResetPassword
} from "~/components/subs/confirms.tsx";
import {PatientManagementFilter} from "~/components/subs/PatientManagementFilter.tsx";
import {PatientInfoDisplay} from "~/components/subs/PatientInfoDisplay.tsx";
import {TeamInfoDisplay} from "~/components/subs/TeamInfoDisplay.tsx";
import LockResetIcon from "mdi-react/LockResetIcon";
import {modals} from "@mantine/modals";
import HistoryIcon from "mdi-react/HistoryIcon";
import FilterIcon from "mdi-react/FilterIcon";
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Patients Management" },
{ name: "description", content: "Manage hospital patients" },
];
}
export default function Component() {
const navigate = useNavigate();
const [refreshingPatientList, setRefreshingPatientList] = useState<boolean>(false);
const [patientInfo, setPatientInfo] = useState<{patients: PatientDataWithWardAndAdmission[], total_pages: number}>({patients: [], total_pages: 1});
// Lists for filters
const [teams, setTeams] = useState<DoctorTeamInfo[]>([]);
const [wards, setWards] = useState<WardInfo[]>([]);
const [loadingTeams, setLoadingTeams] = useState(false);
const [loadingWards, setLoadingWards] = useState(false);
const [sortKey, setSortKey] = useState<string>("");
const [sortDesc, setSortDesc] = useState<boolean>(true);
const [currPage, setCurrPage] = useState<number>(1);
// Filter states
const [nameSearch, setNameSearch] = useState<string>("");
const [filterGenders, setFilterGenders] = useState<string[]>([]);
const [filterHasTeam, setFilterHasTeam] = useState<number>(-1);
const [filterIsAdmitted, setFilterIsAdmitted] = useState<number>(-1);
const [filterTeams, setFilterTeams] = useState<number[]>([]);
const [filterWards, setFilterWards] = useState<number[]>([]);
const { changePage } = useOutletContext<OutletContextType>();
useEffect(() => {
refreshPatientList();
loadTeamsList();
loadWardsList();
}, []);
useEffect(() => {
refreshPatientList();
}, [currPage]);
const loadTeamsList = () => {
setLoadingTeams(true);
apiGetTeamList(-1) // Get all teams
.then(res => {
if (res.success) {
setTeams(res.data.teams);
} else {
showErrorMessage(res.message, "Failed to load teams");
}
})
.catch(err => {})
.finally(() => setLoadingTeams(false));
};
const loadWardsList = () => {
setLoadingWards(true);
apiGetWardList(-1, true) // Get all wards
.then(res => {
if (res.success) {
setWards(res.data.wards);
} else {
showErrorMessage(res.message, "Failed to load wards");
}
})
.catch(err => {})
.finally(() => setLoadingWards(false));
};
const updateFilter = (
name: string,
genders: string[],
hasTeam: string,
isAdmitted: string,
teams: number[],
wards: number[]
) => {
setNameSearch(name);
setFilterGenders(genders);
setFilterHasTeam(Number(hasTeam));
setFilterIsAdmitted(Number(isAdmitted));
setFilterTeams(teams);
setFilterWards(wards);
};
const refreshPatientList = () => {
setRefreshingPatientList(true);
apiGetPatientsList(currPage).then(res => {
if (!res.success) {
showErrorMessage(res.message, "Failed to get patient list");
return;
}
setPatientInfo(res.data);
})
.catch(err => {})
.finally(() => { setRefreshingPatientList(false); });
};
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc);
} else {
setSortKey(key);
setSortDesc(false); // Default ascending
}
};
const sortedPatients = useMemo<PatientDataWithWardAndAdmission[]>(() => {
let data = [...patientInfo.patients];
// Apply all filters
data = data.filter((p) => {
// Name search
const matchesName = !nameSearch || (
(p.name?.toLowerCase().includes(nameSearch.toLowerCase()) ||
p.title?.toLowerCase().includes(nameSearch.toLowerCase()))
);
// Gender filter
const okGender = filterGenders.length === 0 || filterGenders.includes(p.gender);
// Team assignment status filter
const okTeam = filterHasTeam === -1 ||
(filterHasTeam === 1 ? p.admission?.team_id != null : p.admission?.team_id == null);
// Admission status filter
const okAdmitted = filterIsAdmitted === -1 ||
(filterIsAdmitted === 1 ? p.admission != null : p.admission == null);
// Specific teams filter
const okSpecificTeams = filterTeams.length === 0 ||
(p.admission?.team_id != null && filterTeams.includes(p.admission.team_id));
// Specific wards filter
const okSpecificWards = filterWards.length === 0 ||
(p.ward?.id != null && filterWards.includes(p.ward.id));
return matchesName && okGender && okTeam && okAdmitted && okSpecificTeams && okSpecificWards;
});
if (!sortKey) return data;
data.sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB);
return sortDesc ? -cmp : cmp;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
return 0;
});
return data;
}, [
patientInfo.patients,
sortKey,
sortDesc,
nameSearch,
filterGenders,
filterHasTeam,
filterIsAdmitted,
filterTeams,
filterWards
]);
// Convert teams and wards to options format for MultiSelect
const teamOptions = useMemo(() => {
return teams.map(team => ({
value: team.id.toString(),
label: team.department.replace(/_/g, ' ')
}));
}, [teams]);
const wardOptions = useMemo(() => {
return wards.map(ward => ({
value: ward.id.toString(),
label: `${ward.name} (${ward.current_occupancy}/${ward.total_capacity})`
}));
}, [wards]);
const handleViewPatientInfo = (patient: PatientDataWithWardAndAdmission) => {
modals.open({
title: "Patient Information",
centered: true,
size: "md",
children: (
<Stack>
<Group>
<Text fw={500} w={100}>Name:</Text>
<Text>{`${patient.title} ${patient.name}`}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Gender:</Text>
<Text>{patient.gender}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Birth Date:</Text>
<Text>{patient.birth_date}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Email:</Text>
<Text>{patient.email}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Phone:</Text>
<Text>{patient.phone}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Address:</Text>
<Text>{patient.address}</Text>
</Group>
<Group>
<Text fw={500} w={100}>Postcode:</Text>
<Text>{patient.postcode}</Text>
</Group>
<Group justify="flex-end" mt="md">
<Button onClick={() => modals.closeAll()}>Close</Button>
</Group>
</Stack>
)
});
};
const handleEditPatient = (patient: PatientDataWithWardAndAdmission) => {
confirmEditPatient(patient, refreshPatientList);
};
const handleViewWard = (wardId: number) => {
confirmCheckWardPatients(wardId, refreshPatientList);
};
const handlePatientDischarge = (admissionId: number, patientName: string) => {
confirmPatientDischarge(admissionId, patientName, refreshPatientList);
};
const rows = sortedPatients.map((patient) => (
<Table.Tr key={patient.id}>
<Table.Td>{patient.id}</Table.Td>
<Table.Td>
<PatientInfoDisplay patientId={patient.id} />
</Table.Td>
<Table.Td>{patient.gender}</Table.Td>
<Table.Td>{patient.birth_date}</Table.Td>
<Table.Td>{patient.email}</Table.Td>
<Table.Td>{patient.phone}</Table.Td>
<Table.Td>
{patient.admission?.team_id ? (
<TeamInfoDisplay teamId={patient.admission.team_id} />
) : (
<Text size="sm" c="dimmed">Not Assigned</Text>
)}
</Table.Td>
<Table.Td>
{patient.ward ? (
<Group gap="xs">
<Text>{patient.ward.name}</Text>
<ActionIcon
size="sm"
onClick={() => handleViewWard(patient.ward!.id)}
title="View ward details"
>
<InfoIcon style={iconMStyle} />
</ActionIcon>
</Group>
) : (
<Text size="sm" c="dimmed">Not Admitted</Text>
)}
</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Treatment Record" withArrow>
<ActionIcon onClick={() => { changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/${patient.id}`) }}>
<HistoryIcon style={iconMStyle}/>
</ActionIcon>
</Tooltip>
<ActionIcon onClick={() => handleViewPatientInfo(patient)} title="View patient info">
<InfoIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon onClick={() => handleEditPatient(patient)} title="Edit patient info">
<PencilIcon style={iconMStyle}/>
</ActionIcon>
<Tooltip label="Reset Password" withArrow>
<ActionIcon onClick={() => { confirmResetPassword(patient.id, UserType.PATIENT) }}>
<LockResetIcon style={iconMStyle}/>
</ActionIcon>
</Tooltip>
<Tooltip label={patient.admission ? "Discharge patient" : "Not admitted"}>
<ActionIcon
color="red"
onClick={() => patient.admission && handlePatientDischarge(patient.admission.id, `${patient.title} ${patient.name}`)}
disabled={!patient.admission}
title="Discharge patient"
>
<LogoutVariantIcon style={iconMStyle}/>
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Text size="1.5em" fw={700}>Patients Management</Text>
<Button
leftSection={<AddIcon style={iconMStyle}/>}
onClick={() => { confirmAdminAddUser(0, refreshPatientList); }}
>Add Patient</Button>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>
<Group>
<FilterIcon style={iconMStyle} />
<Text>Filters</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<PatientManagementFilter
onChange={updateFilter}
teamOptions={teamOptions}
wardOptions={wardOptions}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
Patient ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("name")}>
Name{" "}
{sortKey === "name" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("gender")}>
Gender{" "}
{sortKey === "gender" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("birth_date")}>
Birth Date{" "}
{sortKey === "birth_date" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("email")}>
Email{" "}
{sortKey === "email" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("phone")}>
Phone{" "}
{sortKey === "phone" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Medical Team
</Table.Th>
<Table.Th>
Ward
</Table.Th>
<Table.Th>
Operations
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
<Pagination
withEdges
total={patientInfo.total_pages}
value={currPage}
onChange={setCurrPage}
mt="sm"
style={{justifyItems: "flex-end", ...marginRightBottom}}
/>
</Card.Section>
</Card>
</Stack>
);
}

View File

@ -0,0 +1,250 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/StaffManagement";
import {useEffect, useMemo, useState} from "react";
import {DashboardPageType, DoctorGrade, UserType} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import {
type DoctorDataWithPermission,
type DoctorListInfo,
type OutletContextType,
SORT_SYMBOLS
} from "~/utils/models.ts";
import {Accordion, ActionIcon, Button, Card, Group, Pagination, Stack, Table, Text, Tooltip} from "@mantine/core";
import {apiGetDoctorsList} from "~/utils/hms_api.ts";
import {showErrorMessage} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRightBottom, marginTopBottom} from "~/styles.ts";
import PencilIcon from "mdi-react/PencilIcon";
import {
confirmAdminAddUser,
confirmEditDoctor,
confirmResetPassword,
confirmSetResignedDoctor
} from "~/components/subs/confirms.tsx";
import AddIcon from "mdi-react/AddIcon";
import LogoutIcon from "mdi-react/LogoutIcon";
import LoginIcon from "mdi-react/LoginIcon";
import LockResetIcon from "mdi-react/LockResetIcon";
import {StaffTableFilter} from "~/components/subs/StaffManageTableFilter.tsx";
import HistoryIcon from "mdi-react/HistoryIcon";
import FilterIcon from "mdi-react/FilterIcon";
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Medical Teams Management" },
{ name: "description", content: "Medical Teams Management" },
];
}
export default function Component() {
const navigate = useNavigate();
const [refreshingStaffList, setRefreshingStaffList] = useState<boolean>(false)
const [staffInfo, setStaffInfo] = useState<DoctorListInfo>({doctors: [], total_pages: 1})
const [sortKey, setSortKey] = useState<string>("")
const [sortDesc, setSortDesc] = useState<boolean>(true)
const [currPage, setCurrPage] = useState<number>(1)
const [filterGrades, setFilterGrades] = useState<string[]>([])
const [filterGenders, setFilterGenders] = useState<string[]>([])
const [filterIsAdmin, setFilterIsAdmin] = useState<number>(-1)
const [filterIsTerminated, setFilterIsTerminated] = useState<number>(-1)
const { changePage } = useOutletContext<OutletContextType>();
useEffect(() => {
refreshStaffList()
}, []);
useEffect(() => {
refreshStaffList()
}, [currPage]);
const updateFilter = (grades: string[], genders: string[], isAdmin: string, isTerminated: string) => {
setFilterGrades(grades)
setFilterGenders(genders)
setFilterIsAdmin(Number(isAdmin))
setFilterIsTerminated(Number(isTerminated))
}
const refreshStaffList = () => {
setRefreshingStaffList(true)
apiGetDoctorsList(currPage).then(res => {
if (!res.success) {
showErrorMessage(res.message, "Get Ward List Failed")
return
}
setStaffInfo(res.data)
})
.catch(err => {})
.finally(() => { setRefreshingStaffList(false) })
}
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc)
} else {
setSortKey(key)
setSortDesc(false) // 默认升序
}
}
const sortedDoctors = useMemo<DoctorDataWithPermission[]>(() => {
let data = [...staffInfo.doctors]
data = data.filter((w) => {
const okType = filterGrades.length === 0 || filterGrades.includes(w.grade.toString())
const okGender = filterGenders.length === 0 || filterGenders.includes(w.gender)
const okAdmin = filterIsAdmin === -1 || (filterIsAdmin === 1 ? w.is_admin : !w.is_admin)
const okTerminated = filterIsTerminated === -1 || (filterIsTerminated === 1 ? w.is_resigned : !w.is_resigned)
return okType && okGender && okAdmin && okTerminated
})
if (!sortKey) return data
data.sort((a, b) => {
const valA = a[sortKey]
const valB = b[sortKey]
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB)
return sortDesc ? -cmp : cmp
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB
}
if (typeof valA === 'boolean' && typeof valB === 'boolean') {
return sortDesc ? (valB ? 1 : -1) : (valA ? 1 : -1)
}
return 0
})
return data
}, [staffInfo.doctors, sortKey, sortDesc, filterGrades, filterGenders, filterIsAdmin, filterIsTerminated])
const rows = sortedDoctors.map((doctor) => (
<Table.Tr key={doctor.id}>
<Table.Td>{doctor.id}</Table.Td>
<Table.Td>{`${doctor.title} ${doctor.name}`}</Table.Td>
<Table.Td>{DoctorGrade[doctor.grade]}</Table.Td>
<Table.Td>{doctor.gender}</Table.Td>
<Table.Td>{doctor.birth_date}</Table.Td>
<Table.Td>{doctor.email}</Table.Td>
<Table.Td>{doctor.phone}</Table.Td>
<Table.Td>{doctor.is_admin ? "Yes" : "No"}</Table.Td>
<Table.Td>{doctor.is_resigned ? "Yes" : "No"}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Treatment Record" withArrow>
<ActionIcon onClick={() => { changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/d${doctor.id}`) }}>
<HistoryIcon style={iconMStyle}/>
</ActionIcon>
</Tooltip>
<ActionIcon onClick={() => { confirmEditDoctor(doctor, refreshStaffList) }}>
<PencilIcon style={iconMStyle}/>
</ActionIcon>
<Tooltip label="Reset Password" withArrow>
<ActionIcon onClick={() => { confirmResetPassword(doctor.id, UserType.DOCTOR) }}>
<LockResetIcon style={iconMStyle}/>
</ActionIcon>
</Tooltip>
{doctor.is_resigned ?
<ActionIcon onClick={() => { confirmSetResignedDoctor(doctor.id, `${doctor.title} ${doctor.name}`, false, refreshStaffList) }}>
<LoginIcon style={iconMStyle}/>
</ActionIcon>
:
<ActionIcon color="red" onClick={() => { confirmSetResignedDoctor(doctor.id, `${doctor.title} ${doctor.name}`, true, refreshStaffList) }}>
<LogoutIcon style={iconMStyle}/>
</ActionIcon>
}
</Group>
</Table.Td>
</Table.Tr>
))
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Text size="1.5em" fw={700}>Staff Management</Text>
<Button
leftSection={<AddIcon style={iconMStyle}/>}
onClick={() => { confirmAdminAddUser(1, refreshStaffList) }}
>Add New Staff</Button>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>
<Group>
<FilterIcon style={iconMStyle} />
<Text>Filters</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<StaffTableFilter onChange={updateFilter}/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
User ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("name")}>
Name{" "}
{sortKey === "name" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("grade")}>
Grade{" "}
{sortKey === "grade" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("gender")}>
Gender{" "}
{sortKey === "gender" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("birth_date")}>
Birth Date{" "}
{sortKey === "birth_date" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("email")}>
Email{" "}
{sortKey === "email" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("phone")}>
Phone{" "}
{sortKey === "phone" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("is_admin")}>
Admin{" "}
{sortKey === "is_admin" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("is_resigned")}>
Terminated{" "}
{sortKey === "is_resigned" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Operations
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
<Pagination withEdges total={staffInfo.total_pages} value={currPage} onChange={setCurrPage} mt="sm"
style={{justifyItems: "flex-end", ...marginRightBottom}}/>
</Card.Section>
</Card>
</Stack>
);
}

View File

@ -0,0 +1,34 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/Page2";
import {useEffect} from "react";
import {DashboardPageType, UserType} from "~/utils/hms_enums.ts";
import {Outlet, useNavigate, useOutletContext} from "react-router";
import type {OutletContextType} from "~/utils/models.ts";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Treatment Records" },
{ name: "description", content: "Dashboard Treatment Records" },
];
}
export default function Component() {
const navigate = useNavigate();
const { loginUserInfo, refreshMyInfo, changePage } = useOutletContext<OutletContextType>();
useEffect(() => {
toPatientRecords()
}, [loginUserInfo]);
const toPatientRecords = () => {
if (!loginUserInfo) return
if (loginUserInfo.user_type === UserType.PATIENT) {
changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/${loginUserInfo.patient_data?.id}`)
}
}
return (
<>
{loginUserInfo && <Outlet context={{ loginUserInfo, refreshMyInfo, changePage }} />}
</>
);
}

View File

@ -0,0 +1,47 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/TreatmentRecordDefault";
import {useEffect, useState} from "react";
import {DashboardPageType, UserPermissionLevel, UserType} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import type {OutletContextType} from "~/utils/models.ts";
import {Button, Group, Stack} from "@mantine/core";
import DoctorTeamsSimple from "~/components/subs/DoctorTeamsSimple.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Treatment Records" },
{ name: "description", content: "Dashboard Treatment Records" },
];
}
export default function Component() {
const { loginUserInfo, changePage } = useOutletContext<OutletContextType>();
const onClickMyRecords = () => {
if (!loginUserInfo) return
if (loginUserInfo.user_type === UserType.PATIENT) {
changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/${loginUserInfo.patient_data?.id}`)
}
else {
changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/d${loginUserInfo.doctor_data?.id}`)
}
}
const onClickAllRecords = () => {
changePage(DashboardPageType.TreatmentRecord, `/dashboard/treatment_record/all`)
}
return (
<Stack>
<Group>
<Button onClick={onClickMyRecords}>My Treatment Records</Button>
{ loginUserInfo && loginUserInfo.user_permission >= UserPermissionLevel.ADMIN && <>
<Button onClick={onClickAllRecords}>All Treatment Records</Button>
</>}
</Group>
{ loginUserInfo && loginUserInfo.user_type === UserType.DOCTOR && <>
<DoctorTeamsSimple doctorTeams={loginUserInfo.doctor_teams || []} />
</>}
</Stack>
);
}

View File

@ -0,0 +1,524 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/TreatmentRecordSub";
import {useEffect, useMemo, useState} from "react";
import {useNavigate, useOutletContext, useParams} from "react-router";
import {
type DoctorTeamInfo,
type GetTreatmentRecords,
type OutletContextType,
SORT_SYMBOLS,
type TreatmentInfoWithDoctorInfo
} from "~/utils/models.ts";
import {
Accordion,
Button,
Card,
Grid,
Group,
MultiSelect,
Pagination,
Stack,
Table,
Text,
TextInput
} from "@mantine/core";
import {apiGetDoctorsList, apiGetTeamList, apiGetTreatmentRecords} from "~/utils/hms_api.ts";
import {showErrorMessage} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRightBottom, marginTopBottom, noColumnGap} from "~/styles.ts";
import {PatientInfoDisplay} from "~/components/subs/PatientInfoDisplay.tsx";
import {DoctorInfoDisplay} from "~/components/subs/DoctorInfoDisplay.tsx";
import {TeamInfoDisplay} from "~/components/subs/TeamInfoDisplay.tsx";
import {TruncatedText} from "~/components/subs/TruncatedText.tsx";
import SearchIcon from "mdi-react/SearchIcon";
import FilterIcon from "mdi-react/FilterIcon";
import {DateInput} from "@mantine/dates";
import CalendarIcon from "mdi-react/CalendarIcon";
import BackButton from "~/components/subs/BackButton.tsx";
import {DashboardPageType} from "~/utils/hms_enums.ts";
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Treatment Records" },
{ name: "description", content: "View patient treatment records" },
];
}
interface TreatmentFilterProps {
onFilterChange: (search: string, fromDate: Date | null, toDate: Date | null, doctorIds: number[], teamIds: number[]) => void;
doctorOptions: { value: string, label: string }[];
teamOptions: { value: string, label: string }[];
}
function TreatmentFilter({ onFilterChange, doctorOptions, teamOptions }: TreatmentFilterProps) {
const [searchText, setSearchText] = useState("");
const [fromDate, setFromDate] = useState<Date | null>(null);
const [toDate, setToDate] = useState<Date | null>(null);
const [selectedDoctors, setSelectedDoctors] = useState<string[]>([]);
const [selectedTeams, setSelectedTeams] = useState<string[]>([]);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.currentTarget.value;
setSearchText(newValue);
applyFilters(newValue, fromDate, toDate, selectedDoctors, selectedTeams);
};
const handleFromDateChange = (date: Date | null) => {
setFromDate(date);
applyFilters(searchText, date, toDate, selectedDoctors, selectedTeams);
};
const handleToDateChange = (date: Date | null) => {
setToDate(date);
applyFilters(searchText, fromDate, date, selectedDoctors, selectedTeams);
};
const handleDoctorsChange = (values: string[]) => {
setSelectedDoctors(values);
applyFilters(searchText, fromDate, toDate, values, selectedTeams);
};
const handleTeamsChange = (values: string[]) => {
setSelectedTeams(values);
applyFilters(searchText, fromDate, toDate, selectedDoctors, values);
};
const applyFilters = (
search: string,
fromDate: Date | null,
toDate: Date | null,
doctors: string[],
teams: string[]
) => {
onFilterChange(
search,
fromDate,
toDate,
doctors.map(d => parseInt(d)),
teams.map(t => parseInt(t))
);
};
return (
<Grid>
<Grid.Col span={12}>
<TextInput
placeholder="Search in treatment information..."
value={searchText}
onChange={handleSearchChange}
leftSection={<SearchIcon style={iconMStyle} />}
/>
</Grid.Col>
<Grid.Col span={6}>
<DateInput
label="Start Date"
placeholder="Select start date"
value={fromDate}
onChange={handleFromDateChange}
leftSection={<CalendarIcon style={iconMStyle} />}
clearable
/>
</Grid.Col>
<Grid.Col span={6}>
<DateInput
label="End Date"
placeholder="Select end date"
value={toDate}
onChange={handleToDateChange}
leftSection={<CalendarIcon style={iconMStyle} />}
clearable
/>
</Grid.Col>
<Grid.Col span={6}>
<MultiSelect
label="Doctors"
placeholder="Filter by doctors"
data={doctorOptions}
value={selectedDoctors}
onChange={handleDoctorsChange}
clearable
searchable
/>
</Grid.Col>
<Grid.Col span={6}>
<MultiSelect
label="Medical Teams"
placeholder="Filter by medical teams"
data={teamOptions}
value={selectedTeams}
onChange={handleTeamsChange}
clearable
searchable
/>
</Grid.Col>
</Grid>
);
}
function getQuerySlot(queryId?: string) {
if (!queryId) return -1;
if (queryId.startsWith("d")) {
return 1
}
else if (queryId.startsWith("t")) {
return 2
}
else if (queryId === "all") {
return 3
}
else if (!(/^\d+$/.test(queryId))) {
return -1
}
else {
return 0
}
}
export default function Component() {
const navigate = useNavigate();
const params = useParams();
const patientId = params.patientId;
const [querySlot, setQuerySlot] = useState(getQuerySlot(patientId));
const [loading, setLoading] = useState(true);
const [treatments, setTreatments] = useState<TreatmentInfoWithDoctorInfo[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
// Filter states
const [searchText, setSearchText] = useState("");
const [fromDate, setFromDate] = useState<Date | null>(null);
const [toDate, setToDate] = useState<Date | null>(null);
const [filterDoctorIds, setFilterDoctorIds] = useState<number[]>([]);
const [filterTeamIds, setFilterTeamIds] = useState<number[]>([]);
// Data for filter options
const [doctors, setDoctors] = useState<any[]>([]);
const [teams, setTeams] = useState<DoctorTeamInfo[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(false);
const [loadingTeams, setLoadingTeams] = useState(false);
const [sortKey, setSortKey] = useState<string>("treated_at");
const [sortDesc, setSortDesc] = useState<boolean>(true);
const { changePage } = useOutletContext<OutletContextType>();
useEffect(() => {
const currQuerySlot = getQuerySlot(patientId)
setQuerySlot(currQuerySlot)
if (currQuerySlot < 0) {
showErrorMessage("Invalid Query ID", "Error");
changePage(DashboardPageType.Home);
return;
}
fetchTreatmentRecords();
loadDoctorsList();
loadTeamsList();
}, [patientId, currentPage]);
const loadDoctorsList = () => {
setLoadingDoctors(true);
apiGetDoctorsList(-1) // Get all doctors
.then(res => {
if (res.success) {
setDoctors(res.data.doctors);
} else {
showErrorMessage(res.message, "Failed to load doctors");
}
})
.catch(err => {})
.finally(() => setLoadingDoctors(false));
};
const loadTeamsList = () => {
setLoadingTeams(true);
apiGetTeamList(-1) // Get all teams
.then(res => {
if (res.success) {
setTeams(res.data.teams);
} else {
showErrorMessage(res.message, "Failed to load teams");
}
})
.catch(err => {})
.finally(() => setLoadingTeams(false));
};
const fetchTreatmentRecords = (currQuerySlot: number = querySlot) => {
setLoading(true);
console.log("currQuerySlot", currQuerySlot)
let requestPromise: Promise<GetTreatmentRecords>;
if (currQuerySlot === 0) {
requestPromise = apiGetTreatmentRecords(currentPage, parseInt(patientId || "0"), null, null)
}
else if (currQuerySlot === 1) {
requestPromise = apiGetTreatmentRecords(currentPage, null, parseInt(patientId?.substring(1) || "0"), null)
}
else if (currQuerySlot === 2) {
requestPromise = apiGetTreatmentRecords(currentPage, null, null, parseInt(patientId?.substring(1) || "0"))
}
else if (querySlot === 3) {
requestPromise = apiGetTreatmentRecords(currentPage, null, null, null, true)
}
else {
showErrorMessage("Invalid Query ID", "Error");
return
}
requestPromise
.then(res => {
if (res.success) {
setTreatments(res.data.treatments);
setTotalPages(res.data.total_pages);
} else {
showErrorMessage(res.message, "Failed to fetch treatment records");
}
})
.catch(err => {
showErrorMessage(err.toString(), "Error");
})
.finally(() => {
setLoading(false);
});
};
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc);
} else {
setSortKey(key);
setSortDesc(false); // Default ascending
}
};
const handleFilterChange = (
search: string,
fromDate: Date | null,
toDate: Date | null,
doctorIds: number[],
teamIds: number[]
) => {
setSearchText(search);
setFromDate(fromDate);
setToDate(toDate);
setFilterDoctorIds(doctorIds);
setFilterTeamIds(teamIds);
};
// Convert doctors and teams to options format for MultiSelect
const doctorOptions = useMemo(() => {
return doctors.map(doctor => ({
value: doctor.id.toString(),
label: `${doctor.title} ${doctor.name}`
}));
}, [doctors]);
const teamOptions = useMemo(() => {
return teams.map(team => ({
value: team.id.toString(),
label: team.department.replace(/_/g, ' ')
}));
}, [teams]);
const filteredTreatments = useMemo(() => {
let filtered = [...treatments];
// Apply search filter
if (searchText.trim()) {
const searchLower = searchText.toLowerCase();
filtered = filtered.filter(treatment =>
treatment.treat_info.toLowerCase().includes(searchLower)
);
}
// Apply date filters
if (fromDate) {
const fromTimestamp = Math.floor(fromDate.getTime() / 1000);
filtered = filtered.filter(treatment => treatment.treated_at >= fromTimestamp);
}
if (toDate) {
const toTimestamp = Math.floor(toDate.getTime() / 1000);
filtered = filtered.filter(treatment => treatment.treated_at <= toTimestamp);
}
// Apply doctor filter
if (filterDoctorIds.length > 0) {
filtered = filtered.filter(treatment =>
filterDoctorIds.includes(treatment.doctor_id)
);
}
// Apply team filter
if (filterTeamIds.length > 0) {
filtered = filtered.filter(treatment =>
treatment.team && filterTeamIds.includes(treatment.team.id)
);
}
return filtered;
}, [treatments, searchText, fromDate, toDate, filterDoctorIds, filterTeamIds]);
const sortedTreatments = useMemo(() => {
let data = [...filteredTreatments];
// Apply sorting
if (!sortKey) return data;
data.sort((a, b) => {
// Special handling for ID-based columns to sort by name instead
if (sortKey === "patient_id") {
// For patient_id, sort by patient name
// Assuming there's a way to get patient name from cache or some lookup
const patientNameA = `${a.patient_title || ''} ${a.patient_name || ''}`.trim();
const patientNameB = `${b.patient_title || ''} ${b.patient_name || ''}`.trim();
const cmp = patientNameA.localeCompare(patientNameB);
return sortDesc ? -cmp : cmp;
}
if (sortKey === "doctor_id") {
// For doctor_id, sort by doctor name
const doctorNameA = `${a.doctor_title || ''} ${a.doctor_name || ''}`.trim();
const doctorNameB = `${b.doctor_title || ''} ${b.doctor_name || ''}`.trim();
const cmp = doctorNameA.localeCompare(doctorNameB);
return sortDesc ? -cmp : cmp;
}
if (sortKey === "team_id") {
// For team_id, sort by team department name
const teamNameA = a.team ? a.team.department.replace(/_/g, ' ') : '';
const teamNameB = b.team ? b.team.department.replace(/_/g, ' ') : '';
const cmp = teamNameA.localeCompare(teamNameB);
return sortDesc ? -cmp : cmp;
}
// Default sorting logic for other columns
const valA = a[sortKey];
const valB = b[sortKey];
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB);
return sortDesc ? -cmp : cmp;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
return 0;
});
return data;
}, [filteredTreatments, sortKey, sortDesc]);
const rows = sortedTreatments.map((treatment) => (
<Table.Tr key={treatment.id}>
<Table.Td>{treatment.id}</Table.Td>
<Table.Td>
<PatientInfoDisplay patientId={treatment.patient_id} />
</Table.Td>
<Table.Td>
<DoctorInfoDisplay doctorId={treatment.doctor_id} />
</Table.Td>
<Table.Td>
{treatment.team ? (
<TeamInfoDisplay teamId={treatment.team.id} />
) : (
<Text size="sm" c="dimmed">Not Assigned</Text>
)}
</Table.Td>
<Table.Td>
<TruncatedText
text={treatment.treat_info}
title={`Treatment Info - Record #${treatment.id}`}
/>
</Table.Td>
<Table.Td>{new Date(treatment.treated_at * 1000).toLocaleString('en-GB').replace(',', '')}</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Group align="center">
{/*<BackButton/>*/}
<Text size="1.5em" fw={700}>Treatment Records</Text>
{querySlot == 0 && <Group style={noColumnGap}>Patient:&nbsp;<PatientInfoDisplay patientId={parseInt(patientId || "-1")} /></Group>}
{querySlot == 1 && <Group style={noColumnGap}>Doctor:&nbsp;<DoctorInfoDisplay doctorId={parseInt(patientId?.substring(1) || "-1")} /></Group>}
{querySlot == 2 && <Group style={noColumnGap}>Team:&nbsp;<TeamInfoDisplay teamId={parseInt(patientId?.substring(1) || "-1")} /></Group>}
</Group>
<Button
onClick={() => fetchTreatmentRecords()}
loading={loading}
variant="outline"
>Refresh</Button>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>
<Group>
<FilterIcon style={iconMStyle} />
<Text>Filters</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<TreatmentFilter
onFilterChange={handleFilterChange}
doctorOptions={doctorOptions}
teamOptions={teamOptions}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("patient_id")}>
Patient (Sort by Name){" "}
{sortKey === "patient_id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("doctor_id")}>
Doctor (Sort by Name){" "}
{sortKey === "doctor_id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("team_id")}>
Medical Team (Sort by Name){" "}
{sortKey === "team_id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Treatment Info
</Table.Th>
<Table.Th onClick={() => handleSort("treated_at")}>
Treatment Date{" "}
{sortKey === "treated_at" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
<Pagination
withEdges
total={totalPages}
value={currentPage}
onChange={setCurrentPage}
mt="sm"
style={{justifyItems: "flex-end", ...marginRightBottom}}
/>
</Card.Section>
</Card>
</Stack>
);
}

View File

@ -0,0 +1,205 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/WardManagement";
import {useEffect, useMemo, useState} from "react";
import {DashboardPageType} from "~/utils/hms_enums.ts";
import {useNavigate, useOutletContext} from "react-router";
import {type OutletContextType, SORT_SYMBOLS, type WardInfo, type WardListInfo, WardTypes} from "~/utils/models.ts";
import {Accordion, ActionIcon, Button, Card, Group, Pagination, Stack, Table, Tabs, Text} from "@mantine/core";
import {apiGetWardList} from "~/utils/hms_api.ts";
import {showErrorMessage} from "~/utils/utils.ts";
import {iconMStyle, marginLeftRight, marginRightBottom, marginRound, marginTopBottom, textCenter} from "~/styles.ts";
import PencilIcon from "mdi-react/PencilIcon";
import DeleteIcon from "mdi-react/DeleteIcon";
import {confirmCheckWardPatients, confirmDeleteWard, confirmEditOrCreateWard} from "~/components/subs/confirms.tsx";
import AddIcon from "mdi-react/AddIcon";
import {WardManageTableFilter} from "~/components/subs/WardManageTableFilter.tsx";
import EyeIcon from "mdi-react/EyeIcon";
import FilterIcon from "mdi-react/FilterIcon";
import {ResponsiveTableContainer} from "~/components/subs/ResponsiveTableContainer.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Medical Teams Management" },
{ name: "description", content: "Medical Teams Management" },
];
}
export default function Component() {
const navigate = useNavigate();
const [refreshingWardList, setRefreshingWardList] = useState<boolean>(false)
const [wardInfo, setWardInfo] = useState<WardListInfo>({wards: [], total_pages: 1})
const [sortKey, setSortKey] = useState<string>("")
const [sortDesc, setSortDesc] = useState<boolean>(true)
const [currPage, setCurrPage] = useState<number>(1)
const [filterTypes, setFilterTypes] = useState<string[]>([])
const [filterOccupancy, setFilterOccupancy] = useState<[number, number]>([0, 12])
const [filterCapacity, setFilterCapacity] = useState<[number, number]>([1, 12])
const { loginUserInfo, refreshMyInfo } = useOutletContext<OutletContextType>();
const updateFilter = (types: string[], occupancy: [number, number], capacity: [number, number]) => {
setFilterTypes(types)
setFilterOccupancy(occupancy)
setFilterCapacity(capacity)
}
useEffect(() => {
refreshWardList()
}, []);
useEffect(() => {
refreshWardList()
}, [currPage]);
const refreshWardList = () => {
setRefreshingWardList(true)
apiGetWardList(currPage).then(res => {
if (!res.success) {
showErrorMessage(res.message, "Get Ward List Failed")
return
}
setWardInfo(res.data)
})
.catch(err => {})
.finally(() => { setRefreshingWardList(false) })
}
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDesc(!sortDesc)
} else {
setSortKey(key)
setSortDesc(false) // 默认升序
}
}
const sortedWards = useMemo<WardInfo[]>(() => {
let data = [...wardInfo.wards]
data = data.filter((w) => {
// 类型过滤:如果 filterTypes 为空数组则不过滤
const okType = filterTypes.length === 0 || filterTypes.includes(w.type)
// occupancy 和 capacity 的区间过滤
const okOcc =
w.current_occupancy >= filterOccupancy[0] &&
w.current_occupancy <= filterOccupancy[1]
const okCap =
w.total_capacity >= filterCapacity[0] &&
w.total_capacity <= filterCapacity[1]
return okType && okOcc && okCap
})
if (!sortKey) return data
data.sort((a, b) => {
const valA = a[sortKey]
const valB = b[sortKey]
if (typeof valA === 'string' && typeof valB === 'string') {
const cmp = valA.localeCompare(valB)
return sortDesc ? -cmp : cmp
}
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB
}
return 0
})
return data
}, [wardInfo.wards, sortKey, sortDesc, filterTypes, filterOccupancy[0], filterOccupancy[1], filterCapacity[0],
filterCapacity[1]])
const rows = sortedWards.map((ward) => (
<Table.Tr key={ward.id}>
<Table.Td>{ward.id}</Table.Td>
<Table.Td>{ward.name}</Table.Td>
<Table.Td>{WardTypes[ward.type]}</Table.Td>
<Table.Td>{ward.current_occupancy}</Table.Td>
<Table.Td>{ward.total_capacity}</Table.Td>
<Table.Td>
<Group>
<ActionIcon onClick={() => { confirmCheckWardPatients(ward.id, refreshWardList) }}>
<EyeIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon onClick={() => { confirmEditOrCreateWard(ward, refreshWardList) }}>
<PencilIcon style={iconMStyle}/>
</ActionIcon>
<ActionIcon color="red" onClick={() => { confirmDeleteWard(ward.id, ward.name, refreshWardList) }}>
<DeleteIcon style={iconMStyle}/>
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))
return (
<Stack>
<Group justify="space-between" align="center" style={marginLeftRight}>
<Text size="1.5em" fw={700}>Wards Management</Text>
<Button
leftSection={<AddIcon style={iconMStyle}/>}
onClick={() => confirmEditOrCreateWard(null, refreshWardList)}
>Add Ward</Button>
</Group>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>
<Group>
<FilterIcon style={iconMStyle} />
<Text>Filters</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<WardManageTableFilter onChange={updateFilter}/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th onClick={() => handleSort("id")}>
Ward ID{" "}
{sortKey === "id" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("name")}>
Ward Name{" "}
{sortKey === "name" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("type")}>
Ward Type{" "}
{sortKey === "type" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("current_occupancy")}>
Current Occupancy{" "}
{sortKey === "current_occupancy" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th onClick={() => handleSort("total_capacity")}>
Capacity{" "}
{sortKey === "total_capacity" && SORT_SYMBOLS[sortDesc ? "desc" : "asc"]}
</Table.Th>
<Table.Th>
Operations
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ResponsiveTableContainer>
<Pagination withEdges total={wardInfo.total_pages} value={currPage} onChange={setCurrPage} mt="sm"
style={{justifyItems: "flex-end", ...marginRightBottom}}/>
</Card.Section>
</Card>
</Stack>
);
}

View File

@ -0,0 +1,28 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard_sub/+types/DashboardAccount";
import {marginRound} from "~/styles";
import {Flex, Image} from "@mantine/core";
import {Outlet} from "react-router";
import GlobalAffix from "~/components/GlobalAffix.tsx";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Login" },
{ name: "description", content: "Login page" },
];
}
export default function Component() {
return (
<Flex gap="md" justify="center" align="center" direction="row" wrap="wrap" p={10} h="100vh">
<Outlet />
<Image
src="/logo.png"
h="95vh"
/>
<GlobalAffix />
</Flex>
)
}

View File

@ -0,0 +1,28 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard_sub/+types/DashboardAccountDefault";
import {flexLeftWeight} from "~/styles";
import {Button, Stack} from "@mantine/core";
import { useNavigate } from "react-router";
import {DashboardPageType} from "~/utils/hms_enums.ts";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Account" },
{ name: "description", content: "Account page" },
];
}
export default function Component() {
const navigate = useNavigate();
return (
<Stack align="stretch" justify="center" gap="md" style={{...flexLeftWeight, "maxWidth": "400px"}}>
<Button onClick={() => navigate(DashboardPageType.LoginPage)}>Admin</Button>
<Button onClick={() => navigate(DashboardPageType.LoginPage)}>Receptionist</Button>
<Button onClick={() => navigate(DashboardPageType.LoginPage)}>Doctor</Button>
<Button onClick={() => navigate(DashboardPageType.LoginPage)}>Login</Button>
<Button onClick={() => navigate(DashboardPageType.RegisterPage)}>Register</Button>
</Stack>
)
}

View File

@ -0,0 +1,92 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard_sub/+types/DashboardLogin";
import {flexLeftWeight, iconMStyle, marginRound, maxWidth} from "~/styles";
import {Button, Flex, Group, Image, Input, MantineProvider, PasswordInput, Stack, Text, TextInput} from "@mantine/core";
import {useForm} from "@mantine/form";
import {useEffect, useState} from "react";
import AccountIcon from "mdi-react/AccountIcon";
import LockIcon from "mdi-react/LockIcon";
import {Link} from "react-router";
import { useNavigate } from "react-router";
import {apiLogin} from "~/utils/hms_api";
import {getBaseUserInfo, showErrorMessage, showInfoMessage} from "~/utils/utils";
import {DashboardPageType} from "~/utils/hms_enums.ts";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Login" },
{ name: "description", content: "Login page" },
];
}
export default function Component() {
const form = useForm({
initialValues: {
userName: '',
password: ''
},
validate: {
userName: (value) => (value.length == 0 ? "Input your username." : null),
password: (value) => (value.length == 0 ? "Input your password." : null),
},
});
const [isLoggingIn, setIsLoggingIn] = useState(false);
const navigate = useNavigate();
const onClickLogin = (values: {userName: string, password: string}) => {
setIsLoggingIn(true)
apiLogin(values.userName, values.password)
.then((res) => {
if (!res.success) {
showErrorMessage(res.message, "Login failed")
return
}
res.data?.token && localStorage.setItem("hms_token", res.data?.token)
const userInfo = getBaseUserInfo(res.data?.user_info)
showInfoMessage(`Welcome ${userInfo?.title} ${userInfo?.name}`, "Login successful", 3000)
navigate(DashboardPageType.Home)
})
.catch((e) => showErrorMessage(e.toString(), "Login error"))
.finally(() => setIsLoggingIn(false))
}
return (
<Stack align="stretch" justify="center" gap="md" style={{...flexLeftWeight, "maxWidth": "400px"}}>
<Text size="1.5em" fw={700}>Log in</Text>
<form onSubmit={form.onSubmit((values) => onClickLogin(values))}>
<Stack gap="xs">
<TextInput
withAsterisk
label="Username"
placeholder="Username"
leftSection={<AccountIcon style={iconMStyle}/>}
{...form.getInputProps('userName')}
/>
<PasswordInput
withAsterisk
label="Password"
placeholder="Password"
leftSection={<LockIcon style={iconMStyle}/>}
{...form.getInputProps('password')}
/>
<Text size="sm" c="dimmed" ta="right">
Forgot Password?
</Text>
<Group justify="space-between">
<Button onClick={() => navigate(DashboardPageType.RegisterPage)}>Register</Button>
<Button type="submit" disabled={isLoggingIn}>Login</Button>
</Group>
</Stack>
</form>
</Stack>
)
}

View File

@ -0,0 +1,234 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard_sub/+types/DashboardRegister";
import {flexLeftWeight, iconMStyle, marginRound, maxWidth} from "~/styles";
import {
Button,
Flex,
Group,
Image,
Input,
Divider,
PasswordInput,
Select,
Stack,
Text,
TextInput, Space
} from "@mantine/core";
import {useForm} from "@mantine/form";
import {useState} from "react";
import AccountIcon from "mdi-react/AccountIcon";
import LockIcon from "mdi-react/LockIcon";
import { useNavigate } from "react-router";
import {apiRegisterPatient} from "~/utils/hms_api";
import {dateToTimestamp, showErrorMessage, showInfoMessage} from "~/utils/utils";
import {DateInput} from "@mantine/dates";
import {DashboardPageType} from "~/utils/hms_enums.ts";
import CalendarRangeIcon from "mdi-react/CalendarRangeIcon";
import GenderMaleFemaleIcon from "mdi-react/GenderMaleFemaleIcon";
import CellphoneIcon from "mdi-react/CellphoneIcon";
import EmailIcon from "mdi-react/EmailIcon";
import HomeIcon from "mdi-react/HomeIcon";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Patient Registration" },
{ name: "description", content: "Patient registration page." },
];
}
export default function Component() {
const form = useForm({
initialValues: {
userName: '',
password: '',
password2: '',
name: '',
title: '',
birth_date: new Date(2000, 1, 1),
gender: '',
phone: '+44 ',
email: '',
address: '',
postcode: ''
},
validate: {
userName: (value) => {
if (value.length < 6) return "Username must be at least 6 characters long.";
if (/^\d+$/.test(value)) return "The username cannot be a pure number";
return null;
},
password: (value) => (/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(value) ? null :
"The password must be at least 8 characters long and contain both letters and numbers."),
password2: (value, others) => (value !== others.password ? "The two passwords do not match" : null),
name: (value) => (value.length < 2 ? "Name is too short" : null),
title: (value) => (value.length < 2 ? "Title is too short" : null),
birth_date: (value) => (value > new Date() ? "Date of birth cannot be in the future" : null),
gender: (value) => (value.length <= 0 ? "Please select your biological sex" : null),
phone: (value) => (/^\+\d{1,3} \d{7,}$/.test(value) ? null : "Invalid phone number format"),
email: (value) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : "Invalid email format"),
address: (value) => (value.length < 5 ? "Address is too short" : null),
postcode: (value) => (/^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/.test(value) ? null : "Invalid postcode format"),
},
});
const [isLoggingIn, setIsLoggingIn] = useState(false);
const navigate = useNavigate();
const onClickRegister = (values: {
userName: string,
password: string,
password2: string,
name: string,
title: string,
birth_date: Date,
gender: string,
phone: string,
email: string,
address: string,
postcode: string,
}) => {
setIsLoggingIn(true)
const regData = {
username: values.userName,
password: values.password,
name: values.name,
title: values.title.endsWith(".") ? values.title : values.title + ".",
birth_date: dateToTimestamp(values.birth_date),
gender: values.gender,
phone: values.phone,
email: values.email,
address: values.address,
postcode: values.postcode,
}
// console.log(regData)
apiRegisterPatient(regData)
.then((res) => {
if (!res.success) {
showErrorMessage(res.message, "Registration failed")
return
}
showInfoMessage("", "Registration successful", 3000)
navigate(DashboardPageType.LoginPage)
})
.catch((e) => showErrorMessage(e.toString(), "Registration error"))
.finally(() => setIsLoggingIn(false))
}
return (
<Stack align="stretch" justify="center" gap="md" style={{...flexLeftWeight, "maxWidth": "400px"}}>
<Text size="1.5em" fw={700}>Patient Register</Text>
<form onSubmit={form.onSubmit((values) => onClickRegister(values))}>
<Divider my="0px" label="Account Info" labelPosition="center" />
<Stack gap="xs">
<TextInput
withAsterisk
label="Username"
placeholder="Username"
leftSection={<AccountIcon style={iconMStyle}/>}
{...form.getInputProps('userName')}
/>
<PasswordInput
withAsterisk
label="Password"
placeholder="At least 8 characters long and contain both letters and numbers."
leftSection={<LockIcon style={iconMStyle}/>}
{...form.getInputProps('password')}
/>
<PasswordInput
withAsterisk
label="Input Your Password Again"
placeholder="Input your password again"
leftSection={<LockIcon style={iconMStyle}/>}
{...form.getInputProps('password2')}
/>
</Stack>
<Space h="xs" />
<Divider my="0px" label="Patient Info" labelPosition="center" />
<Stack gap="xs">
<TextInput
withAsterisk
label="Name"
placeholder="Your full name"
leftSection={<AccountIcon style={iconMStyle}/>}
{...form.getInputProps('name')}
/>
<TextInput
withAsterisk
label="Title"
placeholder="eg. Mr./Mrs./Ms./Dr."
leftSection={<AccountIcon style={iconMStyle}/>}
{...form.getInputProps('title')}
/>
<DateInput
withAsterisk
label="Date of Birth"
placeholder="Date of Birth"
valueFormat="DD/MM/YYYY"
leftSection={<CalendarRangeIcon style={iconMStyle}/>}
{...form.getInputProps('birth_date')}
/>
<Select
withAsterisk
label="Biological Sex"
data={["M", "F", "Intersex"]}
defaultValue="F"
placeholder="Your biological sex"
leftSection={<GenderMaleFemaleIcon style={iconMStyle}/>}
{...form.getInputProps('gender')}
/>
<TextInput
withAsterisk
label="Phone number"
placeholder="+44 01234567"
leftSection={<CellphoneIcon style={iconMStyle}/>}
{...form.getInputProps('phone')}
/>
<TextInput
withAsterisk
label="Email"
placeholder="abc@example.com"
leftSection={<EmailIcon style={iconMStyle}/>}
{...form.getInputProps('email')}
/>
<TextInput
withAsterisk
label="Address"
placeholder="Your home address"
leftSection={<HomeIcon style={iconMStyle}/>}
{...form.getInputProps('address')}
/>
<TextInput
withAsterisk
label="Postcode"
placeholder="Your postcode. eg. BS16 1QY"
leftSection={<HomeIcon style={iconMStyle}/>}
{...form.getInputProps('postcode')}
/>
<Space h="0px" />
<Group justify="space-between">
<Button onClick={() => navigate(DashboardPageType.LoginPage)}>Back to Login</Button>
<Button type="submit" disabled={isLoggingIn}>Register</Button>
</Group>
</Stack>
</form>
</Stack>
)
}

95
HMS_Frontend/app/root.tsx Normal file
View File

@ -0,0 +1,95 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core';
import {Notifications} from "@mantine/notifications";
import {ModalsProvider} from "@mantine/modals";
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import {theme} from "~/theme.ts";
dayjs.extend(customParseFormat);
// export const links: Route.LinksFunction = () => [
// { rel: "preconnect", href: "https://fonts.googleapis.com" },
// {
// rel: "preconnect",
// href: "https://fonts.gstatic.com",
// crossOrigin: "anonymous",
// },
// {
// rel: "stylesheet",
// href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
// },
// ];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<ColorSchemeScript />
<Meta />
<Links />
</head>
<body>
<MantineProvider theme={theme}>
<ModalsProvider>
<Notifications position="top-center" zIndex={1000} />
{children}
</ModalsProvider>
</MantineProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@ -0,0 +1,37 @@
import { type RouteConfig, index, route, prefix } from "@react-router/dev/routes";
export default [
index("pages/HomePage.tsx"),
// route("page2", "pages/Page2.tsx"),
...prefix("dashboard", [
route("account", "pages/dashboard_sub/DashboardAccount.tsx", [
index("pages/dashboard_sub/DashboardAccountDefault.tsx"),
route("login", "pages/dashboard_sub/DashboardLogin.tsx"),
route("register", "pages/dashboard_sub/DashboardRegister.tsx")
])
]),
route("dashboard", "pages/Dashboard.tsx", [
index("pages/dashboard/Home.tsx"),
route("treatment_record", "pages/dashboard/TreatmentRecord.tsx", [
index("pages/dashboard/TreatmentRecordDefault.tsx"),
route(":patientId", "pages/dashboard/TreatmentRecordSub.tsx")
]),
route("patient_booking", "pages/dashboard/PatientBooking.tsx"),
route("appoint_management", "pages/dashboard/AppointmentManagement.tsx"),
route("doctor_treatment", "pages/dashboard/DoctorTreatment.tsx"),
route("ward_management", "pages/dashboard/WardManagement.tsx"),
route("medical_teams_management", "pages/dashboard/MedicalTeamsManagement.tsx"),
route("patients_management", "pages/dashboard/PatientsManagement.tsx"),
route("staff_management", "pages/dashboard/StaffManagement.tsx"),
route("page2", "pages/dashboard/Page2.tsx"),
]),
] satisfies RouteConfig;

View File

@ -0,0 +1,53 @@
import type {CSSProperties} from "react";
import {rem} from "@mantine/core";
export const maxWidth: CSSProperties = {
width: "100%"
}
export const marginTopBottom: CSSProperties = {
marginTop: "1em",
marginBottom: "1em"
}
export const marginTop: CSSProperties = {
marginTop: "1em",
}
export const marginRightBottom: CSSProperties = {
marginRight: "1em",
marginBottom: "1em"
}
export const marginRound: CSSProperties = {
margin: "1em"
}
export const marginLeftRight: CSSProperties = {
marginLeft: "1em",
marginRight: "1em"
}
export const iconMStyle: CSSProperties = {
width: rem(18),
height: rem(18)
}
export const flexLeftWeight: CSSProperties = {
flex: 1
}
export const textCenter: CSSProperties = {
textAlign: 'center'
}
export const noColumnGap: CSSProperties = {
columnGap: "0"
}
export const roundThemeButton: CSSProperties = {
width: "55px",
height: "55px",
borderRadius: "55px",
padding: "0"
}

View File

@ -0,0 +1,5 @@
import { createTheme } from "@mantine/core";
export const theme = createTheme({
});

View File

@ -0,0 +1,355 @@
import type {
UserLoginNameAndPwd,
BaseRetData,
GetDoctorList,
GetWardList,
GetWardPatients,
Login,
UserInfo,
GetPatientList,
GetTeamList,
GetPatientBookingList,
GetTeamInfo,
GetAppointments,
GetDoctorAppointments,
GetTreatmentRecords
} from "./models"
import {dateToTimestamp} from "~/utils/utils.ts";
export const apiEndpoint = import.meta.env.VITE_API_ENDPOINT
export async function fetchAPI(path: string, method: string, body?: any, headers?: any, contentType: string | null = "application/json"): Promise<Response> {
const baseHeaders: { [key: string]: any } = {
"Authorization": `${localStorage.getItem("hms_token")}`,
}
if (contentType) {
baseHeaders["Content-Type"] = contentType
}
let reqHeaders: any
if (headers) {
reqHeaders = {...baseHeaders, ...headers}
}
else {
reqHeaders = baseHeaders
}
return new Promise((resolve, reject) => {
fetch(`${apiEndpoint}/${path}`, {
method,
credentials: "include",
headers: reqHeaders,
body: contentType === "application/json" ? (body ? JSON.stringify(body) : undefined) : body,
})
.then((v) => resolve(v))
.catch((e) => reject(e))
})
}
export async function apiLogin(userName: string, password: string): Promise<Login> {
const resp = await fetchAPI("login", "POST", {
username: userName,
password: password
})
return resp.json()
}
export async function apiChangeMyPassword(old_password: string, newPassword: string): Promise<BaseRetData> {
const resp = await fetchAPI("change_password", "POST", {
old_password: old_password,
new_password: newPassword
})
return resp.json()
}
export async function apiRegisterPatient(regData: {
username: string,
password: string,
name: string,
title: string,
birth_date: number,
gender: string,
phone: string,
email: string,
address: string,
postcode: string,
}): Promise<BaseRetData> {
const resp = await fetchAPI("register_patient", "POST", regData)
return resp.json()
}
export async function apiGetMyInfo(): Promise<UserInfo> {
const resp = await fetchAPI("get_my_info", "GET")
return resp.json()
}
export async function apiGetWardList(page: number, no_limit: boolean = false): Promise<GetWardList> {
const resp = await fetchAPI(`get_ward_list?page=${page}&no_limit=${no_limit}`, "GET")
return resp.json()
}
export async function apiEditWard(ward_id: number, ward_name: string, total_capacity: number, type: string): Promise<BaseRetData> {
const resp = await fetchAPI("admin/edit_ward", "POST", {
"ward_id": ward_id,
"ward_name": ward_name,
"total_capacity": total_capacity,
"type": type
})
return resp.json()
}
export async function apiDeleteWard(ward_id: number): Promise<BaseRetData> {
const resp = await fetchAPI("admin/delete_ward", "DELETE", {
"ward_id": ward_id,
})
return resp.json()
}
export async function apiCreateWard(ward_name: string, total_capacity: number, type: string): Promise<BaseRetData> {
const resp = await fetchAPI("admin/create_ward", "POST", {
"ward_name": ward_name,
"total_capacity": total_capacity,
"type": type
})
return resp.json()
}
export async function apiGetWardPatients(ward_id: number): Promise<GetWardPatients> {
const resp = await fetchAPI(`data/ward_patients?ward_id=${ward_id}`, "GET")
return resp.json()
}
export async function apiGetDoctorsList(page: number): Promise<GetDoctorList> {
// set page to -1 to get all doctors
const resp = await fetchAPI(`get_doctor_list?page=${page}`, "GET")
return resp.json()
}
export async function apiEditDoctorInfo(data: {
gender: string, phone: string, birth_date: number | Date, grade: number | string, name: string, title: string, email: string, is_admin: boolean,
doctor_id?: number, id?: number
}): Promise<BaseRetData> {
const pushData = structuredClone(data)
if (pushData.birth_date instanceof Date) {
pushData.birth_date = dateToTimestamp(pushData.birth_date)
}
if (typeof pushData.grade === "string") {
pushData.grade = Number(pushData.grade)
}
if (pushData.doctor_id === undefined) {
pushData.doctor_id = pushData.id
}
const resp = await fetchAPI("admin/edit_doctor_info", "POST", pushData)
return resp.json()
}
export async function apiSetDoctorResigned(doctor_id: number, is_resigned: boolean): Promise<BaseRetData> {
const resp = await fetchAPI(`admin/set_resign_doctor?`, "POST", {
"doctor_id": doctor_id,
"is_resigned": is_resigned
})
return resp.json()
}
export async function apiAdminRegister(data: {
reg_type: number | string, // 0: patient, 1: doctor, 99: receptionist
username: string,
gender: string, phone: string, birth_date: number | Date, name: string, title: string, email: string,
// team_id?: number, // doctor
grade?: number | string, // doctor or receptionist
is_admin?: boolean, // doctor or receptionist
address?: string, postcode?: string // patient
}): Promise<UserLoginNameAndPwd> {
const pushData = structuredClone(data)
if (pushData.birth_date instanceof Date) {
pushData.birth_date = dateToTimestamp(pushData.birth_date)
}
if (pushData.grade && typeof pushData.grade === "string") {
pushData.grade = Number(pushData.grade)
}
if (typeof pushData.reg_type === "string") {
pushData.reg_type = Number(pushData.reg_type)
}
const resp = await fetchAPI("admin/register", "POST", pushData)
return resp.json()
}
export async function apiResetPasswordFromRoleID(user_id: number, user_type: number): Promise<UserLoginNameAndPwd> {
const resp = await fetchAPI("admin/reset_user_password_from_role_id", "POST", {
"user_id": user_id,
"user_type": user_type
})
return resp.json()
}
export async function apiGetTeamList(page: number): Promise<GetTeamList> {
// set page to -1 to get all teams
const resp = await fetchAPI(`get_team_list?page=${page}`, "GET")
return resp.json()
}
export async function apiEditTeam(data: {
team_id: number,
department: string,
consultant_id: number,
is_admin_team: boolean,
team_members: number[] | null, // no change team members if null
}): Promise<BaseRetData> {
const resp = await fetchAPI("admin/edit_team_info", "POST", data)
return resp.json()
}
export async function apiCreateTeam(data: {
department: string,
consultant_id: number,
is_admin_team: boolean,
team_members: number[] | null,
}): Promise<BaseRetData> {
const resp = await fetchAPI("admin/create_doctor_team", "POST", data)
return resp.json()
}
export async function apiDeleteTeam(team_id: number): Promise<BaseRetData> {
const resp = await fetchAPI("admin/delete_doctor_team", "DELETE", {
"team_id": team_id,
})
return resp.json()
}
export async function get_team_info(team_id: number): Promise<GetTeamInfo> {
const resp = await fetchAPI(`get_team_info?team_id=${team_id}`, "GET")
return resp.json()
}
export async function apiGetPatientsList(page: number): Promise<GetPatientList> {
// set page to -1 to get all patients
const resp = await fetchAPI(`admin/get_patient_list?page=${page}`, "GET")
return resp.json()
}
export async function apiPatientBooking(data: {
appointment_time: number, // timestamp
category: string, // BookingCategory
description: string
}): Promise<BaseRetData> {
const resp = await fetchAPI("patient/book", "POST", data)
return resp.json()
}
export async function apiGetPatientBookingList(page: number): Promise<GetPatientBookingList> {
const resp = await fetchAPI(`patient/appointments?page=${page}`, "GET")
return resp.json()
}
export async function apiEditPatientBooking(data: {
appointment_id: number,
appointment_time: number, // timestamp
category: string, // BookingCategory
description: string,
}): Promise<BaseRetData> {
const resp = await fetchAPI("patient/edit_appointment", "POST", data)
return resp.json()
}
export async function apiDeletePatientBooking(appointment_id: number): Promise<BaseRetData> {
const resp = await fetchAPI("patient/cancel_appointment", "DELETE", {
"appointment_id": appointment_id,
})
return resp.json()
}
export async function apiGetAllAppointments(page: number, include_discharged: boolean): Promise<GetAppointments> {
const resp = await fetchAPI(`receptionist/appointments?page=${page}&include_discharged=${include_discharged}`, "GET")
return resp.json()
}
export async function apiProcessAppointment(appointment_id: number, approved: boolean, feedback: string, assigned_team: number | null): Promise<BaseRetData> {
const resp = await fetchAPI("receptionist/process_appointment", "POST", {
"appointment_id": appointment_id,
"approved": approved,
"feedback": feedback,
"assigned_team": assigned_team
})
return resp.json()
}
export async function apiPatientAdmission(appointment_id: number, ward_id: number): Promise<BaseRetData> {
const resp = await fetchAPI("receptionist/check_in", "POST", {
"appointment_id": appointment_id,
"ward_id": ward_id
})
return resp.json()
}
// export async function apiTransferWard(admission_id: number, target_ward_id: number): Promise<BaseRetData> {
// const resp = await fetchAPI("admin/transfer_ward", "POST", {
// "admission_id": admission_id,
// "target_ward_id": target_ward_id
// })
// return resp.json()
// }
export async function apiTransferWard(patient_id: number, target_ward_id: number): Promise<BaseRetData> {
const resp = await fetchAPI("admin/transfer_ward", "POST", {
"patient_id": patient_id,
"target_ward_id": target_ward_id
})
return resp.json()
}
export async function apiGetDoctorAppointments(target_team_id?: number): Promise<GetDoctorAppointments> {
const resp = await fetchAPI(`doctor/appointments?target_team=${target_team_id}`, "GET")
return resp.json()
}
export async function apiDoctorTreat(appointment_id: number, treat_info: string, treated_at: number): Promise<BaseRetData> {
const resp = await fetchAPI("doctor/treat", "POST", {
"appointment_id": appointment_id,
"treat_info": treat_info,
"treated_at": treated_at // timestamp
})
return resp.json()
}
export async function apiGetTreatmentRecords(page: number, patient_id: number | null, doctor_id: number | null,
team_id: number | null, all: boolean = false): Promise<GetTreatmentRecords> {
const resp = await fetchAPI(`data/treatment_doctors?patient_id=${patient_id}&doctor_id=${doctor_id}&team_id=${team_id}&page=${page}&all=${all}`, "GET")
return resp.json()
}
export async function apiEditPatientInfo(data: {
gender: string, phone: string, birth_date: number | Date, address: string, name: string, title: string, email: string, postcode: string,
patient_id?: number, id?: number
}): Promise<BaseRetData> {
const pushData = structuredClone(data)
if (pushData.birth_date instanceof Date) {
pushData.birth_date = dateToTimestamp(pushData.birth_date)
}
if (pushData.patient_id === undefined) {
pushData.patient_id = pushData.id
}
const resp = await fetchAPI("admin/edit_patient_info", "POST", pushData)
return resp.json()
}
export async function apiPatientDischarge(admission_id: number): Promise<BaseRetData> {
const resp = await fetchAPI("admin/patient_discharge", "POST", {
"admission_id": admission_id
})
return resp.json()
}
export async function apiPatientGetCurrentWard(): Promise<BaseRetData> {
const resp = await fetchAPI("patient/get_my_ward", "GET")
return resp.json()
}

View File

@ -0,0 +1,83 @@
export enum DashboardPageType {
LoginPage = "/dashboard/account/login",
RegisterPage = "/dashboard/account/register",
Home = "/dashboard",
PatientBooking = "/dashboard/patient_booking",
TreatmentRecord = "/dashboard/treatment_record/",
AppointManagement = "/dashboard/appoint_management",
DoctorTreatment = "/dashboard/doctor_treatment",
WardsManagement = "/dashboard/ward_management",
MedicalTeamsManagement = "/dashboard/medical_teams_management",
PatientsManagement = "/dashboard/patients_management",
StaffManagement = "/dashboard/staff_management",
Page2 = "/dashboard/page2",
About = "/",
}
export enum UserType {
UNAUTHORIZED = -1,
PATIENT = 0,
DOCTOR = 1
}
export enum UserPermissionLevel {
UNAUTHORIZED = -1,
PATIENT = 0,
DOCTOR = 1,
// CONSULTANT = 2
ADMIN = 3
}
export enum DoctorTeamType {
DOCTOR_TEAM = 0,
RECEPTIONIST_TEAM = 2,
ADMIN_TEAM = 4
}
export enum DoctorGrade {
Unknown = 0,
Junior = 2,
Registrar = 4,
Consultant = 6
}
export enum Departments {
Internal_Medicine = "Internal_Medicine", // 内科
Surgery = "Surgery", // 外科
Obstetrics_and_Gynecology = "Obstetrics_and_Gynecology", // 妇产科
Pediatrics = "Pediatrics", // 儿科
Ophthalmology = "Ophthalmology", // 眼科
Otolaryngology = "Otolaryngology", // 耳鼻喉科
Dermatology = "Dermatology", // 皮肤科
Psychiatry = "Psychiatry", // 精神科
Neurology = "Neurology", // 神经科
Cardiology = "Cardiology", // 心内科
Radiology = "Radiology", // 放射科
Emergency_Department = "Emergency_Department", // 急诊科
ICU = "ICU", // 重症监护室
Oncology = "Oncology", // 肿瘤科
Orthopedics = "Orthopedics", // 骨科
Urology = "Urology", // 泌尿外科
Rehabilitation_Medicine = "Rehabilitation_Medicine", // 康复医学科
Dentistry = "Dentistry", // 口腔科
Traditional_Chinese_Medicine = "Traditional_Chinese_Medicine", // 中医科
Reception = "Reception", // 接待
Admin = "Admin"
}
export enum BookingCategory {
Consultation = "consultation",
FollowUp = "follow_up",
Admission = "admission",
Diagnostic = "diagnostic",
Therapy = "therapy",
Surgery = "surgery"
}

View File

@ -0,0 +1,230 @@
import {DashboardPageType, Departments, DoctorGrade, UserPermissionLevel, UserType} from "./hms_enums";
export const SORT_SYMBOLS = {
asc: "▲",
desc: "▼",
}
export const WardTypes = {
"GENERAL": "General Ward", // 普通病房
"ICU": "Intensive Care Unit", // 重症监护室
"ISOLATION": "Isolation", // 隔离病房
"MATERNITY": "Maternity", // 产科病房
"PEDIATRIC": "Pediatric" // 儿科病房
}
export interface OutletContextType {
loginUserInfo: LoginUserInfo | null;
refreshMyInfo: () => void;
changePage: (pageType: DashboardPageType, navigateTo?: string) => void;
}
interface IKeyString {
[key: string]: any;
}
export interface BaseRetData extends IKeyString {
success: boolean,
message: string,
data?: any
}
export interface BaseUserData extends IKeyString {
birth_date: string,
email: string,
gender: string,
id: number,
name: string,
phone: string,
title: string,
}
export interface PatientData extends BaseUserData {
address: string
postcode: string
}
export interface DoctorData extends BaseUserData {
grade: DoctorGrade,
is_resigned: boolean
}
export interface DoctorTeam extends IKeyString {
consultant_id: number,
department: Departments,
id: number,
is_admin_team: boolean,
}
export interface LoginUserInfo extends IKeyString {
user_type: UserType,
user_permission: UserPermissionLevel,
patient_data?: PatientData
doctor_data?: DoctorDataWithPermission
doctor_teams?: DoctorTeamInfo[]
}
export interface Login extends BaseRetData {
data?: {
token: string,
expire_at: number,
user_info: LoginUserInfo
}
}
export interface UserInfo extends BaseRetData {
data?: LoginUserInfo
}
export interface WardInfo extends IKeyString {
current_occupancy: number,
id: number,
name: string,
total_capacity: number,
type: keyof typeof WardTypes,
}
export interface WardListInfo extends IKeyString {
wards: WardInfo[],
total_pages: number
}
export interface GetWardList extends BaseRetData {
data: WardListInfo
}
export interface GetWardPatients extends BaseRetData {
data: {
patients: PatientData[],
total_pages: number,
ward: WardInfo
}
}
export interface DoctorDataWithPermission extends DoctorData {
is_admin: boolean
}
export interface DoctorListInfo extends IKeyString {
doctors: DoctorDataWithPermission[],
total_pages: number
}
export interface GetDoctorList extends BaseRetData {
data: DoctorListInfo
}
export interface UserLoginNameAndPwd extends BaseRetData {
data?: {
username: string,
password: string
}
}
export interface PatientDataWithWard extends PatientData {
ward?: WardInfo
}
export interface PatientDataWithWardAndAdmission extends PatientDataWithWard {
admission?: AdmissionInfo
}
export interface GetPatientList extends BaseRetData {
data: {
patients: PatientDataWithWardAndAdmission[],
total_pages: number
}
}
export interface DoctorTeamInfo extends DoctorTeam {
members: DoctorData[]
}
export interface GetTeamList extends BaseRetData {
data: {
teams: DoctorTeamInfo[],
total_pages: number
}
}
export interface GetTeamInfo extends BaseRetData {
data: {
members: DoctorData[],
team: DoctorTeamInfo,
}
}
export interface PatientBookingInfo extends IKeyString {
id: number,
patient_id: number,
category: string,
appointment_time: number, // timestamp
description: string,
approved: boolean,
feedback: string | null,
assigned_team: number | null,
admitted: boolean,
discharged: boolean,
}
export interface AdmissionInfo extends IKeyString {
admitted_at: number, // timestamp
appointment_id: number,
consultant_id: number,
id: number,
patient_id: number,
team_id: number,
ward_id: number
}
export interface PatientBookingInfoWithAdmission extends PatientBookingInfo {
admission: AdmissionInfo
}
export interface GetPatientBookingList extends BaseRetData {
data: {
appointments: PatientBookingInfo[],
total_pages: number
}
}
export interface GetAppointments extends BaseRetData {
data: {
appointments: PatientBookingInfo[],
"total_pages": number
}
}
export interface GetDoctorAppointments extends BaseRetData {
data: {
appointments: PatientBookingInfoWithAdmission[]
}
}
export interface TreatmentInfo extends IKeyString {
appointment_id: number,
doctor_id: number,
id: number,
patient_id: number,
treat_info: string,
treated_at: number, // timestamp
}
export interface TreatmentInfoWithDoctorInfo extends TreatmentInfo {
doctor_info: DoctorData,
team: DoctorTeamInfo
}
export interface GetTreatmentRecords extends BaseRetData {
data: {
// patient: PatientData,
treatments: TreatmentInfoWithDoctorInfo[],
total_pages: number
}
}
export interface GetMyWard extends BaseRetData {
data: {
ward?: WardInfo
}
}

View File

@ -0,0 +1,102 @@
import {notifications} from "@mantine/notifications";
import type {BaseUserData, LoginUserInfo} from "~/utils/models";
import {UserType} from "~/utils/hms_enums";
export function showErrorMessage(msg: string, title: string = "Error", autoClose: boolean | number = 10000) {
console.log("ErrorMessage:", title, msg)
notifications.show({
title: title,
message: msg,
color: 'red',
autoClose: autoClose
})
}
export function showWarningMessage(msg: string, title: string = "注意", autoClose: boolean | number = 10000) {
console.log("WarningMessage:", title, msg)
notifications.show({
title: title,
message: msg,
color: 'yellow',
autoClose: autoClose,
})
}
export function showInfoMessage(msg: string, title: string = "Info", autoClose: boolean | number = 10000) {
notifications.show({
title: title,
message: msg,
color: 'blue',
autoClose: autoClose,
})
}
export function getBaseUserInfo(loginUserInfo?: LoginUserInfo): BaseUserData | undefined {
if (!loginUserInfo) {
return undefined
}
if (loginUserInfo.user_type == UserType.PATIENT) {
return loginUserInfo.patient_data
}
else if (loginUserInfo.user_type == UserType.DOCTOR) {
return loginUserInfo.doctor_data
}
else {
return undefined
}
}
export function getUserDisplayFullName(loginUserInfo?: LoginUserInfo | null): string {
if (!loginUserInfo) {
return ""
}
if (loginUserInfo.user_type == UserType.PATIENT) {
return `${loginUserInfo.patient_data?.title} ${loginUserInfo.patient_data?.name} (Patient)`
}
else if (loginUserInfo.user_type == UserType.DOCTOR) {
return `${loginUserInfo.doctor_data?.title} ${loginUserInfo.doctor_data?.name} (Staff)`
}
else {
return ""
}
}
export function dateToTimestamp(date: Date): number {
const utcDate = new Date(date.getTime() + date.getTimezoneOffset() * 60 * 1000);
return Math.floor(utcDate.getTime() / 1000)
}
export function timestampToDate(timestamp: number): Date {
const date = new Date(timestamp * 1000);
return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000)
}
export function parseDmyToDate(input: string): Date {
const match = input.match(
/^(?<day>\d{1,2})\/(?<month>\d{1,2})\/(?<year>\d{4})$/
);
if (!match || !match.groups) return new Date(2000, 1, 1); // Invalid date;
const day = Number(match.groups.day);
const month = Number(match.groups.month); // 112
const year = Number(match.groups.year);
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return new Date(2000, 1, 1); // Invalid date
}
return date;
}
export function getEnumKeyByValue<T extends Record<string, string>>(
enumObj: T,
value: string
): keyof T | undefined {
return (Object.keys(enumObj) as Array<keyof T>)
.find(k => enumObj[k] === value);
}

6371
HMS_Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
HMS_Frontend/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "hms-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@mantine/core": "^7.17.4",
"@mantine/dates": "^7.17.4",
"@mantine/form": "^7.17.4",
"@mantine/hooks": "^7.17.4",
"@mantine/modals": "^7.17.4",
"@mantine/notifications": "^7.17.4",
"@react-router/node": "^7.5.0",
"@react-router/serve": "^7.5.0",
"@vitejs/plugin-react": "^4.4.0",
"dayjs": "^1.11.13",
"isbot": "^5.1.17",
"mdi-react": "^9.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.5.0"
},
"devDependencies": {
"@react-router/dev": "^7.5.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"react-router-devtools": "^1.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View File

@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
} satisfies Config;

View File

@ -0,0 +1,33 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["node", "vite/client"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"allowImportingTsExtensions": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
}
}

View File

@ -0,0 +1,8 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# HMS
- HMS source code - Group 5