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