init commit
This commit is contained in:
commit
1312f21849
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal 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
|
||||||
1
HMS_Backend/HMS_Backend/__init__.py
Normal file
1
HMS_Backend/HMS_Backend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .hms import *
|
||||||
1252
HMS_Backend/HMS_Backend/hms.py
Normal file
1252
HMS_Backend/HMS_Backend/hms.py
Normal file
File diff suppressed because it is too large
Load Diff
271
HMS_Backend/HMS_Backend/models.py
Normal file
271
HMS_Backend/HMS_Backend/models.py
Normal 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
|
||||||
98
HMS_Backend/HMS_Backend/utils.py
Normal file
98
HMS_Backend/HMS_Backend/utils.py
Normal 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)
|
||||||
BIN
HMS_Backend/instance/hospital.db
Normal file
BIN
HMS_Backend/instance/hospital.db
Normal file
Binary file not shown.
6
HMS_Backend/main.py
Normal file
6
HMS_Backend/main.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import HMS_Backend
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Initialize the backend
|
||||||
|
HMS_Backend.app.run("0.0.0.0", port=721)
|
||||||
4
HMS_Backend/requirements.txt
Normal file
4
HMS_Backend/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Flask~=3.0.3
|
||||||
|
flask-cors~=5.0.1
|
||||||
|
Werkzeug~=3.1.1
|
||||||
|
flask-sqlalchemy~=3.1.1
|
||||||
4
HMS_Frontend/.dockerignore
Normal file
4
HMS_Frontend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.react-router
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
README.md
|
||||||
2
HMS_Frontend/.env
Normal file
2
HMS_Frontend/.env
Normal 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
8
HMS_Frontend/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# React Router
|
||||||
|
/.react-router/
|
||||||
|
/build/
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
22
HMS_Frontend/Dockerfile
Normal file
22
HMS_Frontend/Dockerfile
Normal 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
87
HMS_Frontend/README.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Welcome to React Router!
|
||||||
|
|
||||||
|
A modern, production-ready template for building full-stack React applications using React Router.
|
||||||
|
|
||||||
|
[](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
15
HMS_Frontend/app/app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
HMS_Frontend/app/components/GlobalAffix.tsx
Normal file
47
HMS_Frontend/app/components/GlobalAffix.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
HMS_Frontend/app/components/PageHeader.tsx
Normal file
62
HMS_Frontend/app/components/PageHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
HMS_Frontend/app/components/PageNavbar.tsx
Normal file
98
HMS_Frontend/app/components/PageNavbar.tsx
Normal 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>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
HMS_Frontend/app/components/subs/AppointmentManageFilter.tsx
Normal file
75
HMS_Frontend/app/components/subs/AppointmentManageFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
HMS_Frontend/app/components/subs/BackButton.tsx
Normal file
40
HMS_Frontend/app/components/subs/BackButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
HMS_Frontend/app/components/subs/DoctorInfoDisplay.tsx
Normal file
173
HMS_Frontend/app/components/subs/DoctorInfoDisplay.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
HMS_Frontend/app/components/subs/DoctorTeamsSimple.tsx
Normal file
73
HMS_Frontend/app/components/subs/DoctorTeamsSimple.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
HMS_Frontend/app/components/subs/MedicalTeamManageFilter.tsx
Normal file
56
HMS_Frontend/app/components/subs/MedicalTeamManageFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
HMS_Frontend/app/components/subs/PatientInfoDisplay.tsx
Normal file
166
HMS_Frontend/app/components/subs/PatientInfoDisplay.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
HMS_Frontend/app/components/subs/PatientManagementFilter.tsx
Normal file
124
HMS_Frontend/app/components/subs/PatientManagementFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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';
|
||||||
99
HMS_Frontend/app/components/subs/StaffManageTableFilter.tsx
Normal file
99
HMS_Frontend/app/components/subs/StaffManageTableFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
HMS_Frontend/app/components/subs/TeamInfoDisplay.tsx
Normal file
123
HMS_Frontend/app/components/subs/TeamInfoDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
HMS_Frontend/app/components/subs/ThemeToggle.tsx
Normal file
41
HMS_Frontend/app/components/subs/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
HMS_Frontend/app/components/subs/TruncatedText.tsx
Normal file
69
HMS_Frontend/app/components/subs/TruncatedText.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
HMS_Frontend/app/components/subs/WardManageTableFilter.tsx
Normal file
78
HMS_Frontend/app/components/subs/WardManageTableFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
HMS_Frontend/app/components/subs/WardPatients.tsx
Normal file
206
HMS_Frontend/app/components/subs/WardPatients.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
1598
HMS_Frontend/app/components/subs/confirms.tsx
Normal file
1598
HMS_Frontend/app/components/subs/confirms.tsx
Normal file
File diff suppressed because it is too large
Load Diff
103
HMS_Frontend/app/pages/Dashboard.tsx
Normal file
103
HMS_Frontend/app/pages/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
HMS_Frontend/app/pages/HomePage.tsx
Normal file
53
HMS_Frontend/app/pages/HomePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
HMS_Frontend/app/pages/Page2.tsx
Normal file
12
HMS_Frontend/app/pages/Page2.tsx
Normal 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>;
|
||||||
|
}
|
||||||
416
HMS_Frontend/app/pages/dashboard/AppointmentManagement.tsx
Normal file
416
HMS_Frontend/app/pages/dashboard/AppointmentManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
HMS_Frontend/app/pages/dashboard/DoctorTreatment.tsx
Normal file
410
HMS_Frontend/app/pages/dashboard/DoctorTreatment.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
684
HMS_Frontend/app/pages/dashboard/Home.tsx
Normal file
684
HMS_Frontend/app/pages/dashboard/Home.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
HMS_Frontend/app/pages/dashboard/MedicalTeamsManagement.tsx
Normal file
252
HMS_Frontend/app/pages/dashboard/MedicalTeamsManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
HMS_Frontend/app/pages/dashboard/Page2.tsx
Normal file
23
HMS_Frontend/app/pages/dashboard/Page2.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
HMS_Frontend/app/pages/dashboard/PatientBooking.tsx
Normal file
345
HMS_Frontend/app/pages/dashboard/PatientBooking.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
HMS_Frontend/app/pages/dashboard/PatientsManagement.tsx
Normal file
432
HMS_Frontend/app/pages/dashboard/PatientsManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
HMS_Frontend/app/pages/dashboard/StaffManagement.tsx
Normal file
250
HMS_Frontend/app/pages/dashboard/StaffManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
HMS_Frontend/app/pages/dashboard/TreatmentRecord.tsx
Normal file
34
HMS_Frontend/app/pages/dashboard/TreatmentRecord.tsx
Normal 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 }} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
HMS_Frontend/app/pages/dashboard/TreatmentRecordDefault.tsx
Normal file
47
HMS_Frontend/app/pages/dashboard/TreatmentRecordDefault.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
524
HMS_Frontend/app/pages/dashboard/TreatmentRecordSub.tsx
Normal file
524
HMS_Frontend/app/pages/dashboard/TreatmentRecordSub.tsx
Normal 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: <PatientInfoDisplay patientId={parseInt(patientId || "-1")} /></Group>}
|
||||||
|
{querySlot == 1 && <Group style={noColumnGap}>Doctor: <DoctorInfoDisplay doctorId={parseInt(patientId?.substring(1) || "-1")} /></Group>}
|
||||||
|
{querySlot == 2 && <Group style={noColumnGap}>Team: <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
HMS_Frontend/app/pages/dashboard/WardManagement.tsx
Normal file
205
HMS_Frontend/app/pages/dashboard/WardManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
HMS_Frontend/app/pages/dashboard_sub/DashboardAccount.tsx
Normal file
28
HMS_Frontend/app/pages/dashboard_sub/DashboardAccount.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
HMS_Frontend/app/pages/dashboard_sub/DashboardLogin.tsx
Normal file
92
HMS_Frontend/app/pages/dashboard_sub/DashboardLogin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
234
HMS_Frontend/app/pages/dashboard_sub/DashboardRegister.tsx
Normal file
234
HMS_Frontend/app/pages/dashboard_sub/DashboardRegister.tsx
Normal 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
95
HMS_Frontend/app/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
HMS_Frontend/app/routes.ts
Normal file
37
HMS_Frontend/app/routes.ts
Normal 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;
|
||||||
53
HMS_Frontend/app/styles.ts
Normal file
53
HMS_Frontend/app/styles.ts
Normal 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"
|
||||||
|
}
|
||||||
5
HMS_Frontend/app/theme.ts
Normal file
5
HMS_Frontend/app/theme.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createTheme } from "@mantine/core";
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
|
||||||
|
});
|
||||||
355
HMS_Frontend/app/utils/hms_api.ts
Normal file
355
HMS_Frontend/app/utils/hms_api.ts
Normal 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()
|
||||||
|
}
|
||||||
83
HMS_Frontend/app/utils/hms_enums.ts
Normal file
83
HMS_Frontend/app/utils/hms_enums.ts
Normal 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"
|
||||||
|
}
|
||||||
|
|
||||||
230
HMS_Frontend/app/utils/models.ts
Normal file
230
HMS_Frontend/app/utils/models.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
102
HMS_Frontend/app/utils/utils.ts
Normal file
102
HMS_Frontend/app/utils/utils.ts
Normal 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); // 1‑12
|
||||||
|
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
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
40
HMS_Frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
HMS_Frontend/public/favicon.ico
Normal file
BIN
HMS_Frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
HMS_Frontend/public/icon.png
Normal file
BIN
HMS_Frontend/public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
BIN
HMS_Frontend/public/logo.png
Normal file
BIN
HMS_Frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
7
HMS_Frontend/react-router.config.ts
Normal file
7
HMS_Frontend/react-router.config.ts
Normal 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;
|
||||||
33
HMS_Frontend/tsconfig.json
Normal file
33
HMS_Frontend/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
8
HMS_Frontend/vite.config.ts
Normal file
8
HMS_Frontend/vite.config.ts
Normal 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()],
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user