From 1312f21849477cdf1029d6c0f623edce23ed8b9c Mon Sep 17 00:00:00 2001 From: chinosk <2248589280@qq.com> Date: Wed, 30 Apr 2025 17:28:58 +0100 Subject: [PATCH] init commit --- .gitignore | 7 + HMS_Backend/HMS_Backend/__init__.py | 1 + HMS_Backend/HMS_Backend/hms.py | 1252 ++++ HMS_Backend/HMS_Backend/models.py | 271 + HMS_Backend/HMS_Backend/utils.py | 98 + HMS_Backend/instance/hospital.db | Bin 0 -> 65536 bytes HMS_Backend/main.py | 6 + HMS_Backend/requirements.txt | 4 + HMS_Frontend/.dockerignore | 4 + HMS_Frontend/.env | 2 + HMS_Frontend/.gitignore | 8 + HMS_Frontend/Dockerfile | 22 + HMS_Frontend/README.md | 87 + HMS_Frontend/app/app.css | 15 + HMS_Frontend/app/components/GlobalAffix.tsx | 47 + HMS_Frontend/app/components/PageHeader.tsx | 62 + HMS_Frontend/app/components/PageNavbar.tsx | 98 + .../subs/AppointmentManageFilter.tsx | 75 + .../app/components/subs/BackButton.tsx | 40 + .../app/components/subs/DoctorInfoDisplay.tsx | 173 + .../app/components/subs/DoctorTeamsSimple.tsx | 73 + .../subs/MedicalTeamManageFilter.tsx | 56 + .../components/subs/PatientInfoDisplay.tsx | 166 + .../subs/PatientManagementFilter.tsx | 124 + .../subs/ResponsiveTableContainer.tsx | 27 + .../subs/StaffManageTableFilter.tsx | 99 + .../app/components/subs/TeamInfoDisplay.tsx | 123 + .../app/components/subs/ThemeToggle.tsx | 41 + .../app/components/subs/TruncatedText.tsx | 69 + .../components/subs/WardManageTableFilter.tsx | 78 + .../app/components/subs/WardPatients.tsx | 206 + HMS_Frontend/app/components/subs/confirms.tsx | 1598 +++++ HMS_Frontend/app/pages/Dashboard.tsx | 103 + HMS_Frontend/app/pages/HomePage.tsx | 53 + HMS_Frontend/app/pages/Page2.tsx | 12 + .../pages/dashboard/AppointmentManagement.tsx | 416 ++ .../app/pages/dashboard/DoctorTreatment.tsx | 410 ++ HMS_Frontend/app/pages/dashboard/Home.tsx | 684 ++ .../dashboard/MedicalTeamsManagement.tsx | 252 + HMS_Frontend/app/pages/dashboard/Page2.tsx | 23 + .../app/pages/dashboard/PatientBooking.tsx | 345 + .../pages/dashboard/PatientsManagement.tsx | 432 ++ .../app/pages/dashboard/StaffManagement.tsx | 250 + .../app/pages/dashboard/TreatmentRecord.tsx | 34 + .../dashboard/TreatmentRecordDefault.tsx | 47 + .../pages/dashboard/TreatmentRecordSub.tsx | 524 ++ .../app/pages/dashboard/WardManagement.tsx | 205 + .../pages/dashboard_sub/DashboardAccount.tsx | 28 + .../dashboard_sub/DashboardAccountDefault.tsx | 28 + .../pages/dashboard_sub/DashboardLogin.tsx | 92 + .../pages/dashboard_sub/DashboardRegister.tsx | 234 + HMS_Frontend/app/root.tsx | 95 + HMS_Frontend/app/routes.ts | 37 + HMS_Frontend/app/styles.ts | 53 + HMS_Frontend/app/theme.ts | 5 + HMS_Frontend/app/utils/hms_api.ts | 355 + HMS_Frontend/app/utils/hms_enums.ts | 83 + HMS_Frontend/app/utils/models.ts | 230 + HMS_Frontend/app/utils/utils.ts | 102 + HMS_Frontend/package-lock.json | 6371 +++++++++++++++++ HMS_Frontend/package.json | 40 + HMS_Frontend/public/favicon.ico | Bin 0 -> 4286 bytes HMS_Frontend/public/icon.png | Bin 0 -> 127963 bytes HMS_Frontend/public/logo.png | Bin 0 -> 232211 bytes HMS_Frontend/react-router.config.ts | 7 + HMS_Frontend/tsconfig.json | 33 + HMS_Frontend/vite.config.ts | 8 + README.md | 3 + 68 files changed, 16526 insertions(+) create mode 100644 .gitignore create mode 100644 HMS_Backend/HMS_Backend/__init__.py create mode 100644 HMS_Backend/HMS_Backend/hms.py create mode 100644 HMS_Backend/HMS_Backend/models.py create mode 100644 HMS_Backend/HMS_Backend/utils.py create mode 100644 HMS_Backend/instance/hospital.db create mode 100644 HMS_Backend/main.py create mode 100644 HMS_Backend/requirements.txt create mode 100644 HMS_Frontend/.dockerignore create mode 100644 HMS_Frontend/.env create mode 100644 HMS_Frontend/.gitignore create mode 100644 HMS_Frontend/Dockerfile create mode 100644 HMS_Frontend/README.md create mode 100644 HMS_Frontend/app/app.css create mode 100644 HMS_Frontend/app/components/GlobalAffix.tsx create mode 100644 HMS_Frontend/app/components/PageHeader.tsx create mode 100644 HMS_Frontend/app/components/PageNavbar.tsx create mode 100644 HMS_Frontend/app/components/subs/AppointmentManageFilter.tsx create mode 100644 HMS_Frontend/app/components/subs/BackButton.tsx create mode 100644 HMS_Frontend/app/components/subs/DoctorInfoDisplay.tsx create mode 100644 HMS_Frontend/app/components/subs/DoctorTeamsSimple.tsx create mode 100644 HMS_Frontend/app/components/subs/MedicalTeamManageFilter.tsx create mode 100644 HMS_Frontend/app/components/subs/PatientInfoDisplay.tsx create mode 100644 HMS_Frontend/app/components/subs/PatientManagementFilter.tsx create mode 100644 HMS_Frontend/app/components/subs/ResponsiveTableContainer.tsx create mode 100644 HMS_Frontend/app/components/subs/StaffManageTableFilter.tsx create mode 100644 HMS_Frontend/app/components/subs/TeamInfoDisplay.tsx create mode 100644 HMS_Frontend/app/components/subs/ThemeToggle.tsx create mode 100644 HMS_Frontend/app/components/subs/TruncatedText.tsx create mode 100644 HMS_Frontend/app/components/subs/WardManageTableFilter.tsx create mode 100644 HMS_Frontend/app/components/subs/WardPatients.tsx create mode 100644 HMS_Frontend/app/components/subs/confirms.tsx create mode 100644 HMS_Frontend/app/pages/Dashboard.tsx create mode 100644 HMS_Frontend/app/pages/HomePage.tsx create mode 100644 HMS_Frontend/app/pages/Page2.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/AppointmentManagement.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/DoctorTreatment.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/Home.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/MedicalTeamsManagement.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/Page2.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/PatientBooking.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/PatientsManagement.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/StaffManagement.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/TreatmentRecord.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/TreatmentRecordDefault.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/TreatmentRecordSub.tsx create mode 100644 HMS_Frontend/app/pages/dashboard/WardManagement.tsx create mode 100644 HMS_Frontend/app/pages/dashboard_sub/DashboardAccount.tsx create mode 100644 HMS_Frontend/app/pages/dashboard_sub/DashboardAccountDefault.tsx create mode 100644 HMS_Frontend/app/pages/dashboard_sub/DashboardLogin.tsx create mode 100644 HMS_Frontend/app/pages/dashboard_sub/DashboardRegister.tsx create mode 100644 HMS_Frontend/app/root.tsx create mode 100644 HMS_Frontend/app/routes.ts create mode 100644 HMS_Frontend/app/styles.ts create mode 100644 HMS_Frontend/app/theme.ts create mode 100644 HMS_Frontend/app/utils/hms_api.ts create mode 100644 HMS_Frontend/app/utils/hms_enums.ts create mode 100644 HMS_Frontend/app/utils/models.ts create mode 100644 HMS_Frontend/app/utils/utils.ts create mode 100644 HMS_Frontend/package-lock.json create mode 100644 HMS_Frontend/package.json create mode 100644 HMS_Frontend/public/favicon.ico create mode 100644 HMS_Frontend/public/icon.png create mode 100644 HMS_Frontend/public/logo.png create mode 100644 HMS_Frontend/react-router.config.ts create mode 100644 HMS_Frontend/tsconfig.json create mode 100644 HMS_Frontend/vite.config.ts create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b85dc31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +.vs +.vscode +__pycache__ +/HMS_Backend/HMS_Backend.zip +/HMS_Frontend/HMS_Frontend.zip +/HMS_Frontend/HMS_Frontend_2.zip diff --git a/HMS_Backend/HMS_Backend/__init__.py b/HMS_Backend/HMS_Backend/__init__.py new file mode 100644 index 0000000..e505ffe --- /dev/null +++ b/HMS_Backend/HMS_Backend/__init__.py @@ -0,0 +1 @@ +from .hms import * diff --git a/HMS_Backend/HMS_Backend/hms.py b/HMS_Backend/HMS_Backend/hms.py new file mode 100644 index 0000000..c4d4b09 --- /dev/null +++ b/HMS_Backend/HMS_Backend/hms.py @@ -0,0 +1,1252 @@ +import datetime +from typing import Callable, Optional, Dict +import inspect +from flask import Flask, request, jsonify +from .models import * +from . import utils + + +def create_init_data(): + if Doctor.query.count() == 0: + admin = Doctor(id=1, name="Admin", title="", birth_date=datetime.datetime(2000, 1, 1), gender="M", + phone="1234567890", email="Admin@example.com", grade=DoctorGrade.Unknown.value) + receptionist = Doctor(id=2, name="Receptionist", title="", birth_date=datetime.datetime(2000, 1, 1), gender="M", + phone="1234567891", email="Receptionist@example.com", grade=DoctorGrade.Unknown.value) + db.session.add(admin) + db.session.add(receptionist) + db.session.commit() + + if Team.query.count() == 0: + admin_team = Team(id=1, department="Admin", consultant_id=1, is_admin_team=True) + reception_team = Team(id=2, department="Reception", consultant_id=2, is_admin_team=False) + db.session.add(admin_team) + db.session.add(reception_team) + db.session.commit() + + if DoctorTeamRole.query.count() == 0: + admin_team_role = DoctorTeamRole(doctor_id=1, team_id=1) + reception_team_role = DoctorTeamRole(doctor_id=2, team_id=2) + db.session.add(admin_team_role) + db.session.add(reception_team_role) + db.session.commit() + + if User.query.count() == 0: + admin_user = User(username="admin", role=UserType.DOCTOR.value, doctor_id=1) + admin_user.set_password("admin") + receptionist_user = User(username="receptionist", role=UserType.DOCTOR.value, doctor_id=2) + receptionist_user.set_password("receptionist") + db.session.add(admin_user) + db.session.add(receptionist_user) + db.session.commit() + + +with app.app_context(): + db.create_all() + create_init_data() + + +def return_json(success=True, message: str = "", status_code: int = 200, data: dict = None): + return jsonify({ + 'success': success, + 'message': message, + 'data': {} if data is None else data + }), status_code + + +def permission_check(min_permission: UserPermissionLevel, receptionist_only: bool = False): + def outer(func: Callable): + def inner(*args, **kwargs): + token = request.headers.get('Authorization') + if not token: + return return_json(success=False, message="Unauthorized", status_code=401) + + login_info = utils.get_user_info_with_permission_from_token(token) + if receptionist_only and ( + login_info.user_type.value == UserType.DOCTOR.value) and utils.get_doctor_can_reception( + login_info.user_data.id): + if "pm_user_data" in inspect.signature(func).parameters: + return func(*args, pm_user_data=login_info, **kwargs) + else: + return func(*args, **kwargs) + + elif login_info.user_permission.value >= min_permission.value: + if "pm_user_data" in inspect.signature(func).parameters: + return func(*args, pm_user_data=login_info, **kwargs) + else: + return func(*args, **kwargs) + + return return_json(success=False, message="Permission denied", status_code=403) + + inner.__name__ = func.__name__ + return inner + + return outer + +def _get_team_members(team_id: int) -> Optional[Dict[str, any]]: + team: Team = Team.query.filter_by(id=team_id).first() + if not team: + return None + + members = DoctorTeamRole.query.filter_by(team_id=team.id).all() + if not members: + return {"team": team.to_dict(), "members": []} + + member_list = [] + for i in members: + doctor: Doctor = Doctor.query.filter_by(id=i.doctor_id).first() + if doctor: + member_list.append(doctor.to_dict()) + return { + "team": team.to_dict(), + "members": member_list + } + +def get_formatted_user_info_data(pm_user_data: UserLoginInfo): + ret_data = { + "user_type": pm_user_data.user_type, + "user_permission": pm_user_data.user_permission, + "patient_data": None, + "doctor_data": None, + "doctor_teams": None + } + if pm_user_data.user_type == UserType.PATIENT.value: + ret_data["patient_data"] = pm_user_data.user_data.to_dict() + elif pm_user_data.user_type == UserType.DOCTOR.value: + ret_data["doctor_data"] = pm_user_data.user_data.to_dict() + # ret_data["doctor_teams"] = [i.to_dict() for i in utils.get_doctor_teams(pm_user_data.user_data.id)] + ret_data["doctor_teams"] = [] + for i in utils.get_doctor_teams(pm_user_data.user_data.id): + team_data = i.to_dict() + team_data["members"] = _get_team_members(i.id)["members"] + ret_data["doctor_teams"].append(team_data) + else: + # return return_json(success=False, message="Unknown user type", status_code=500) + return None + + return ret_data + + +# Login +@app.route('/login', methods=['POST']) +def login(): + data = request.get_json() + username = data.get('username') + password = data.get('password') + + if not username or not password: + return return_json(success=False, message="Username and password are required", status_code=400) + + user: User = User.query.filter_by(username=username).first() + if not user: + return return_json(success=False, message="User not found", status_code=404) + if not user.check_password(password): + return return_json(success=False, message="Invalid password", status_code=401) + + token = utils.generate_randstring(62) + expire_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=14) + credentials = Credentials(token=token, expire_at=expire_at, role=user.role) + if user.role == UserType.DOCTOR.value: + credentials.doctor_id = user.doctor_id + else: + credentials.patient_id = user.patient_id + db.session.add(credentials) + db.session.commit() + + user_login_info = utils.get_user_info_with_permission_from_token(token) + + return return_json(success=True, message="Login successful", + data={"token": token, "expire_at": int(expire_at.timestamp()), + "user_info": get_formatted_user_info_data(user_login_info)}) + + +@app.route('/get_my_info', methods=['GET']) +@permission_check(UserPermissionLevel.PATIENT) +def get_user_info(pm_user_data: UserLoginInfo): + user_formatted_info = get_formatted_user_info_data(pm_user_data) + + if user_formatted_info is None: + return return_json(success=False, message="Unknown user type", status_code=500) + + if pm_user_data.user_type == UserType.DOCTOR: + user_formatted_info["doctor_data"]["is_admin"] = user_formatted_info.get("user_permission", UserPermissionLevel.UNAUTHORIZED.value) >= UserPermissionLevel.ADMIN.value + return return_json(success=True, message="User info retrieved successfully", data=user_formatted_info) + + +@app.route('/change_password', methods=['POST']) +@permission_check(UserPermissionLevel.PATIENT) +def change_password(pm_user_data: UserLoginInfo): + data = request.get_json() + old_password = data.get('old_password') + new_password = data.get('new_password') + + if not new_password: + return return_json(success=False, message="New passwords required", status_code=400) + if not old_password: + return return_json(success=False, message="Old passwords required", status_code=400) + + if pm_user_data.user_type == UserType.PATIENT.value: + user: User = User.query.filter_by(patient_id=pm_user_data.user_data.id).first() + elif pm_user_data.user_type == UserType.DOCTOR.value: + user: User = User.query.filter_by(doctor_id=pm_user_data.user_data.id).first() + else: + return return_json(success=False, message="Unknown user type", status_code=500) + + if not user: + return return_json(success=False, message="User not found", status_code=500) + + if not user.check_password(old_password): + return return_json(success=False, message="Old password is incorrect", status_code=401) + + user.set_password(new_password) + + if pm_user_data.user_type == UserType.PATIENT.value: + Credentials.query.filter_by(patient_id=user.patient_id).delete() + elif pm_user_data.user_type == UserType.DOCTOR.value: + Credentials.query.filter_by(doctor_id=user.doctor_id).delete() + + db.session.commit() + + return return_json(success=True, message="Password changed successfully") + + +# Register patient +@app.route('/register_patient', methods=['POST']) +def register(): + data = request.get_json() + username = data.get('username') + password = data.get('password') + + name = data.get('name') + title = data.get('title') + birth_date = datetime.datetime.fromtimestamp(data.get('birth_date'), datetime.timezone.utc) + gender = data.get('gender') + phone = data.get('phone') + email = data.get('email') + address = data.get('address') + postcode = data.get('postcode') + + # Validate input + if not all([username, password, name, title, birth_date, gender, phone, email, address, postcode]): + return return_json(success=False, message="Username and password are required", status_code=400) + + if User.query.filter_by(username=username).first(): + return return_json(success=False, message="Username already exists", status_code=409) + + patient = Patient(name=name, title=title, birth_date=birth_date, gender=gender, phone=phone, email=email, + postcode=postcode, + address=address) + db.session.add(patient) + + db.session.flush() + if patient.id is None: + return return_json(success=False, message="Failed to create patient record", status_code=500) + + user = User(username=username, role=UserType.PATIENT.value, patient_id=patient.id) + user.set_password(password) + db.session.add(user) + + db.session.commit() + + return return_json(success=True, message="User registered successfully") + + +@app.route("/get_doctor_list", methods=["GET"]) +# @permission_check(UserPermissionLevel.PATIENT) +def get_doctor_list(): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + + if page == -1: + doctors = Doctor.query.paginate(per_page=999999, max_per_page=None, error_out=False) + else: + doctors = Doctor.query.paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + if not doctors.items: + return return_json(success=True, message="No doctors found", data={"doctors": [], "total_pages": 1}) + doctor_list = [] + for i in doctors.items: + doctor_data = i.to_dict() + doctor_data["is_admin"] = utils.get_doctor_is_admin(i.id) + doctor_list.append(doctor_data) + + return return_json(success=True, message="Doctors retrieved successfully", + data={"doctors": doctor_list, "total_pages": doctors.pages}) + + +@app.route("/get_ward_list", methods=["GET"]) +@permission_check(UserPermissionLevel.PATIENT) # TODO 确认此 API 的权限 +def get_ward_list(): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + if page < 1: + page = 1 + no_limit = request.args.get("no_limit", "false").lower() == "true" + + wards = Ward.query.paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, error_out=False) \ + if no_limit else Ward.query.paginate(max_per_page=None) + if not wards.items: + return return_json(success=True, message="No wards found", data={"wards": [], "total_pages": 1}) + ward_list = [i.to_dict() for i in wards.items] + return return_json(success=True, message="Wards retrieved successfully", + data={"wards": ward_list, "total_pages": wards.pages}) + + +@app.route("/get_team_info", methods=["GET"]) +# @permission_check(UserPermissionLevel.DOCTOR) +def get_team_members(): + team_id = request.args.get("team_id", type=int) + team = _get_team_members(team_id) + if not team: + return return_json(success=False, message="Team not found", status_code=404) + team["team"]["members"] = team["members"] + + return return_json(success=True, message="Members retrieved successfully", data=team) + + +@app.route('/get_team_list', methods=['GET']) +# @permission_check(UserPermissionLevel.DOCTOR) +def get_team_list(): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + + if page == -1: + teams = Team.query.paginate(per_page=999999, max_per_page=None, error_out=False) + else: + teams = Team.query.paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + if not teams.items: + return return_json(success=True, message="No teams found", data={"teams": [], "total_pages": 1}) + + team_list = [] + for i in teams.items: + team_data = i.to_dict() + team_data["members"] = _get_team_members(i.id)["members"] + team_list.append(team_data) + + return return_json(success=True, message="Teams retrieved successfully", + data={"teams": team_list, "total_pages": teams.pages}) + + +@app.route('/get_patient_info', methods=['GET']) +@permission_check(UserPermissionLevel.DOCTOR) +def get_patient_info(): + patient_id = request.args.get("patient_id", type=int) + if not patient_id: + return return_json(success=False, message="Patient ID is required", status_code=400) + + patient: Patient = Patient.query.filter_by(id=patient_id).first() + if not patient: + return return_json(success=False, message="Patient not found", status_code=404) + + return return_json(success=True, message="Patient info retrieved successfully", + data={"patient": patient.to_dict()}) + + +@app.route('/patient/book', methods=['POST']) +@permission_check(UserPermissionLevel.PATIENT) +def book_appointment(pm_user_data: UserLoginInfo): + data = request.get_json() + appointment_time = datetime.datetime.fromtimestamp(data.get('appointment_time')) + description = data.get('description') + category = data.get('category') + + if category not in BaseDefine.categories: + return return_json(success=False, message="Invalid category", status_code=400) + + if not all([appointment_time, description, category]): + return return_json(success=False, message="Appointment time, description and category are required", + status_code=400) + + appointment = Appointment(patient_id=pm_user_data.user_data.id, appointment_time=appointment_time, + description=description, category=category) + db.session.add(appointment) + db.session.commit() + + return return_json(success=True, message="Appointment booked successfully", data={"appointment_id": appointment.id}) + + +@app.route('/patient/appointments', methods=['GET']) +@permission_check(UserPermissionLevel.PATIENT) +def get_appointments(pm_user_data: UserLoginInfo): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + + appointments = (Appointment.query.order_by(Appointment.id.desc()).filter_by(patient_id=pm_user_data.user_data.id) + .paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, error_out=False)) + # appointments = Appointment.query.order_by(Appointment.id.desc()).filter_by(patient_id=pm_user_data.user_data.id).all() + if not appointments.items: + return return_json(success=True, message="Appointments retrieved successfully", + data={"appointments": [], "total_pages": 1}) + + appointment_list = [appointment.to_dict() for appointment in appointments.items] + return return_json(success=True, message="Appointments retrieved successfully", + data={"appointments": appointment_list, "total_pages": appointments.pages}) + + +@app.route('/patient/edit_appointment', methods=['POST']) +@permission_check(UserPermissionLevel.PATIENT) +def edit_appointment(pm_user_data: UserLoginInfo): + data = request.get_json() + appointment_id = data.get("appointment_id") + appointment_time = datetime.datetime.fromtimestamp(data.get('appointment_time')) + description = data.get('description') + category = data.get('category') + + if not all([appointment_id, appointment_time, description, category]): + return return_json(success=False, message="Appointment ID, time, description and category are required", + status_code=400) + + appointment: Appointment = Appointment.query.order_by(Appointment.id.desc()).filter_by(id=appointment_id).first() + if not appointment: + return return_json(success=False, message="Appointment not found", status_code=404) + if appointment.patient_id != pm_user_data.user_data.id: + return return_json(success=False, message="You are not authorized to edit this appointment", status_code=403) + if appointment.approved: + return return_json(success=False, message="Appointment already approved", status_code=403) + + appointment.appointment_time = appointment_time + appointment.description = description + appointment.category = category + db.session.commit() + + return return_json(success=True, message="Appointment edited successfully") + + +@app.route('/patient/cancel_appointment', methods=['DELETE']) +@permission_check(UserPermissionLevel.PATIENT) +def cancel_appointment(pm_user_data: UserLoginInfo): + data = request.get_json() + appointment_id = data.get("appointment_id") + + if not appointment_id: + return return_json(success=False, message="Appointment ID is required", status_code=400) + + appointment: Appointment = Appointment.query.order_by(Appointment.id.desc()).filter_by(id=appointment_id).first() + if not appointment: + return return_json(success=False, message="Appointment not found", status_code=404) + if appointment.patient_id != pm_user_data.user_data.id: + return return_json(success=False, message="You are not authorized to cancel this appointment", status_code=403) + if appointment.approved: + return return_json(success=False, message="Appointment already approved", status_code=403) + + db.session.delete(appointment) + db.session.commit() + + return return_json(success=True, message="Appointment cancelled successfully") + +@app.route("/patient/get_my_ward", methods=["GET"]) +@permission_check(UserPermissionLevel.PATIENT) +def get_my_ward(pm_user_data: UserLoginInfo): + admission: Admission = Admission.query.filter_by(patient_id=pm_user_data.user_data.id).first() + if not admission: + return return_json(success=True, message="Patient not admitted", status_code=404) + + ward: Ward = Ward.query.filter_by(id=admission.ward_id).first() + if not ward: + return return_json(success=True, message="Ward not found", status_code=404) + + return return_json(success=True, message="Ward retrieved successfully", data={"ward": ward.to_dict()}) + + +@app.route("/receptionist/appointments", methods=["GET"]) +@permission_check(UserPermissionLevel.DOCTOR, receptionist_only=True) +def get_receptionist_appointments(): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + include_discharged = request.args.get("include_discharged", "false").lower() == "true" + + appointments_query = Appointment.query.order_by(Appointment.id.desc()).filter_by(discharged=False) if not include_discharged else Appointment.query.order_by(Appointment.id.desc()) + appointments = appointments_query.paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + + if not appointments.items: + return return_json(success=True, message="Appointments retrieved successfully", + data={"appointments": [], "total_pages": 1}) + + appointment_list = [appointment.to_dict() for appointment in appointments.items] + return return_json(success=True, message="Appointments retrieved successfully", + data={"appointments": appointment_list, "total_pages": appointments.pages}) + + +@app.route("/receptionist/process_appointment", methods=["POST"]) +@permission_check(UserPermissionLevel.DOCTOR, receptionist_only=True) +def process_appointment(): + data = request.get_json() + appointment_id = data.get("appointment_id") + approved = data.get("approved", False) + feedback = data.get("feedback") + assigned_team = data.get("assigned_team") + + appointment: Appointment = Appointment.query.order_by(Appointment.id.desc()).filter_by(id=appointment_id).first() + if not appointment: + return return_json(success=False, message="Appointment not found", status_code=404) + + appointment.approved = approved + appointment.feedback = feedback + if approved: + appointment.assigned_team = assigned_team + else: + appointment.assigned_team = None + db.session.commit() + + return return_json(success=True, message="Appointment processed successfully") + + +@app.route("/receptionist/check_in", methods=["POST"]) +@permission_check(UserPermissionLevel.DOCTOR, receptionist_only=True) +def check_in_appointment(): + data = request.get_json() + appointment_id = data.get("appointment_id") + ward_id = data.get("ward_id") + + appointment: Appointment = Appointment.query.order_by(Appointment.id.desc()).filter_by(id=appointment_id).first() + if not appointment: + return return_json(success=False, message="Appointment not found", status_code=500) + if appointment.admitted: + return return_json(success=False, message="Patient already admitted", status_code=403) + if Admission.query.filter_by(patient_id=appointment.patient_id).first(): + return return_json(success=False, message="Patient already admitted", status_code=403) + + team: Team = Team.query.filter_by(id=appointment.assigned_team).first() + if not team: + return return_json(success=False, message="Team not found", status_code=500) + + ward: Ward = Ward.query.filter_by(id=ward_id).first() + if not ward: + return return_json(success=False, message="Ward not found", status_code=500) + + if ward.total_capacity <= ward.current_occupancy: + return return_json(success=False, message="Ward is full", status_code=403) + + ward.current_occupancy += 1 + appointment.admitted = True + admission = Admission(appointment_id=appointment_id, patient_id=appointment.patient_id, ward_id=ward_id, + team_id=appointment.assigned_team, consultant_id=team.consultant_id, + admitted_at=datetime.datetime.now()) + db.session.add(admission) + db.session.commit() + + return return_json(success=True, message="Appointment checked in successfully") + + +@app.route("/doctor/appointments", methods=["GET"]) +@permission_check(UserPermissionLevel.DOCTOR) +def get_doctor_appointments(pm_user_data: UserLoginInfo): + target_team = request.args.get("target_team", type=int) + + if target_team is not None: + if pm_user_data.user_permission < UserPermissionLevel.ADMIN: + return return_json(success=False, message="Permission denied", status_code=403) + if target_team == -1: + doctor_teams = Team.query.all() + else: + team = Team.query.filter_by(id=target_team).first() + doctor_teams = [team] if team else [] + else: + doctor_teams = utils.get_doctor_teams(pm_user_data.user_data.id) + if not doctor_teams: + return return_json(success=False, message="Doctor teams not found", status_code=404) + + appointments = [] + for i in doctor_teams: + team_appointments = Appointment.query.order_by(Appointment.id.desc()).filter_by(assigned_team=i.id, admitted=True, + discharged=False + ).all() + if team_appointments: + appointments.extend(team_appointments) + + if not appointments: + return return_json(success=True, message="Appointments retrieved successfully", + data={"appointments": []}) + + appointment_list = [] + for appointment in appointments: + appointment_data = appointment.to_dict() + admission: Admission = Admission.query.filter_by(appointment_id=appointment.id).first() + if admission: + appointment_data["admission"] = admission.to_dict() + else: + appointment_data["admission"] = None + appointment_list.append(appointment_data) + + return return_json(success=True, message="Appointments retrieved successfully", + data={"appointments": appointment_list}) + + +@app.route("/doctor/treat", methods=["POST"]) +@permission_check(UserPermissionLevel.DOCTOR) +def treat_appointment(pm_user_data: UserLoginInfo): + data = request.get_json() + appointment_id = data.get("appointment_id") + treat_info = data.get("treat_info") + treated_at = datetime.datetime.fromtimestamp(data.get("treated_at", datetime.datetime.now().timestamp())) + + if not all([appointment_id, treat_info]): + return return_json(success=False, message="Appointment ID and treatment info are required", status_code=400) + + appointment: Appointment = Appointment.query.order_by(Appointment.id.desc()).filter_by(id=appointment_id).first() + if not appointment: + return return_json(success=False, message="Appointment not found", status_code=404) + + treatment = Treatment(appointment_id=appointment.id, doctor_id=pm_user_data.user_data.id, + patient_id=appointment.patient_id, treated_at=treated_at, treat_info=treat_info) + db.session.add(treatment) + db.session.commit() + + return return_json(success=True, message="Treatment recorded successfully") + + +@app.route("/admin/transfer_ward", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def transfer_ward(): + data = request.get_json() + patient_id = data.get("patient_id") + target_ward_id = data.get("target_ward_id") + + ward: Ward = Ward.query.filter_by(id=target_ward_id).first() + if not ward: + return return_json(success=False, message="Ward not found", status_code=404) + if ward.total_capacity <= ward.current_occupancy: + return return_json(success=False, message="Ward is full", status_code=403) + + admission: Admission = Admission.query.filter_by(patient_id=patient_id).first() + if not admission: + return return_json(success=False, message="Admission not found", status_code=404) + orig_ward: Ward = Ward.query.filter_by(id=admission.ward_id).first() + + if orig_ward: + orig_ward.current_occupancy -= 1 + admission.ward_id = target_ward_id + ward.current_occupancy += 1 + db.session.commit() + + return return_json(success=True, message="Ward transferred successfully") + + +@app.route("/admin/patient_discharge", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def discharge_patient(): + data = request.get_json() + admission_id = data.get("admission_id") + + admission: Admission = Admission.query.filter_by(id=admission_id).first() + if not admission: + return return_json(success=False, message="Admission not found", status_code=404) + + ward: Ward = Ward.query.filter_by(id=admission.ward_id).first() + if not ward: + return return_json(success=False, message="Ward not found", status_code=404) + + appointment = Appointment.query.order_by(Appointment.id.desc()).filter_by(id=admission.appointment_id, admitted=True).all() + if not appointment: + return return_json(success=False, message="Appointment not found", status_code=404) + + for n, _ in enumerate(appointment): + appointment[n].discharged = True + + # appointment.discharged = True + ward.current_occupancy -= 1 + db.session.delete(admission) + db.session.commit() + + return return_json(success=True, message="Patient discharged successfully") + + +@app.route("/admin/create_ward", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def create_wards(): + data = request.get_json() + ward_name = data.get("ward_name") + total_capacity = data.get("total_capacity") + ward_type = data.get("type") + + if ward_type not in WardTypes._member_names_: + return return_json(success=False, message="Invalid ward type", status_code=400) + + if not all([ward_name, total_capacity]): + return return_json(success=False, message="Ward name and total capacity are required", status_code=400) + + if total_capacity > BaseDefine.MAX_PATIENTS_PER_WARD: + return return_json(success=False, message=f"Ward capacity exceeds maximum limit: " + f"{BaseDefine.MAX_PATIENTS_PER_WARD}", status_code=400) + + ward = Ward(name=ward_name, type=ward_type, total_capacity=total_capacity, current_occupancy=0) + db.session.add(ward) + db.session.commit() + + return return_json(success=True, message="Ward created successfully", data={"ward_id": ward.id}) + + +@app.route("/admin/edit_ward", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def edit_wards(): + data = request.get_json() + ward_id = data.get("ward_id") + ward_name = data.get("ward_name") + total_capacity = data.get("total_capacity") + ward_type = data.get("type") + + if ward_type not in WardTypes._member_names_: + return return_json(success=False, message="Invalid ward type", status_code=400) + + if not all([ward_id, ward_name, total_capacity]): + return return_json(success=False, message="Ward ID, name and total capacity are required", status_code=400) + + if total_capacity > BaseDefine.MAX_PATIENTS_PER_WARD: + return return_json(success=False, message=f"Ward capacity exceeds maximum limit: " + f"{BaseDefine.MAX_PATIENTS_PER_WARD}", status_code=400) + + ward: Ward = Ward.query.filter_by(id=ward_id).first() + if not ward: + return return_json(success=False, message="Ward not found", status_code=404) + + ward.name = ward_name + ward.total_capacity = total_capacity + ward.type = ward_type + + db.session.commit() + + return return_json(success=True, message="Ward edited successfully") + + +@app.route("/admin/delete_ward", methods=["DELETE"]) +@permission_check(UserPermissionLevel.ADMIN) +def delete_wards(): + data = request.get_json() + ward_id = data.get("ward_id") + + if not ward_id: + return return_json(success=False, message="Ward ID is required", status_code=400) + + ward: Ward = Ward.query.filter_by(id=ward_id).first() + if not ward: + return return_json(success=False, message="Ward not found", status_code=404) + + db.session.delete(ward) + db.session.commit() + + return return_json(success=True, message="Ward deleted successfully") + + +@app.route("/admin/create_doctor_team", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def create_doctor_team(): + data = request.get_json() + department = data.get("department") + consultant_id = data.get("consultant_id") + is_admin_team = data.get("is_admin_team", False) + team_members = data.get("team_members", []) + + if team_members is None: + team_members = [] + + if not all([department, consultant_id]): + return return_json(success=False, message="Department and consultant ID are required", status_code=400) + + if consultant_id not in team_members: + return return_json(success=False, message="Consultant must be in team members", status_code=400) + + if department not in Departments._member_names_: + return return_json(success=False, message="Invalid department", status_code=400) + + if not Doctor.query.filter_by(id=consultant_id).first(): + return return_json(success=False, message="Invalid consultant", status_code=400) + + team = Team(department=department, consultant_id=consultant_id, is_admin_team=is_admin_team) + db.session.add(team) + db.session.flush() + + if team.id is None: + return return_json(success=False, message="Failed to create team record", status_code=500) + + for i in team_members: + doctor_team_role = DoctorTeamRole(doctor_id=i, team_id=team.id) + db.session.add(doctor_team_role) + + db.session.commit() + + return return_json(success=True, message="Doctor team created successfully", data={"team_id": team.id}) + + +@app.route("/admin/delete_doctor_team", methods=["DELETE"]) +@permission_check(UserPermissionLevel.ADMIN) +def delete_doctor_team(): + data = request.get_json() + team_id = data.get("team_id") + + team: Team = Team.query.filter_by(id=team_id).first() + if not team: + return return_json(success=False, message="Team not found", status_code=404) + + # Check if there are any admissions with the same team_id + admissions = Admission.query.filter_by(team_id=team.id).all() + if admissions: + return return_json(success=False, message="Cannot delete team with existing admissions", status_code=403) + + DoctorTeamRole.query.filter_by(team_id=team.id).delete() + db.session.delete(team) + db.session.commit() + + return return_json(success=True, message="Doctor team deleted successfully") + + +@app.route("/admin/register", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def admin_register(): + data = request.get_json() + reg_type = data.get('reg_type') # Patient: 0, Doctor: 1, Receptionist: 99 + + username = data.get('username') + password = utils.generate_randstring(6) + + name = data.get('name') + title = data.get('title') + birth_date = datetime.datetime.fromtimestamp(data.get('birth_date'), datetime.timezone.utc) + gender = data.get('gender') + phone = data.get('phone') + email = data.get('email') + + address = data.get('address') # patient + postcode = data.get('postcode') # patient + + grade = data.get('grade') # doctor + team_id = data.get('team_id') # doctor, 留 None 则不添加团队, Receptionist 不生效 + is_admin = data.get('is_admin', False) # doctor + + if User.query.filter_by(username=username).first(): + return return_json(success=False, message="Username already exists", status_code=409) + + if reg_type == 0: # Patient + patient = Patient(name=name, title=title, birth_date=birth_date, gender=gender, phone=phone, email=email, + postcode=postcode, address=address) + db.session.add(patient) + + db.session.flush() + if patient.id is None: + return return_json(success=False, message="Failed to create patient record", status_code=500) + + user = User(username=username, role=UserType.PATIENT.value, patient_id=patient.id) + user.set_password(password) + db.session.add(user) + elif (reg_type == 1) or (reg_type == 99): # Doctor or Receptionist + if grade not in DoctorGrade._value2member_map_: + return return_json(success=False, message="Invalid doctor grade", status_code=400) + + doctor = Doctor(name=name, title=title, birth_date=birth_date, gender=gender, phone=phone, email=email, + grade=grade) + db.session.add(doctor) + + db.session.flush() + if doctor.id is None: + return return_json(success=False, message="Failed to create doctor record", status_code=500) + + user = User(username=username, role=UserType.DOCTOR.value, doctor_id=doctor.id) + user.set_password(password) + + if is_admin: + admin_team = Team.query.filter_by(id=1).first() # Admin team must be No.1 + if not admin_team: + return return_json(success=False, message="Admin team not found", status_code=500) + admin_team_role = DoctorTeamRole(doctor_id=doctor.id, team_id=admin_team.id) + db.session.add(admin_team_role) + + if reg_type == 99: + team = Team.query.filter_by(department="Reception").first() + if not team: + return return_json(success=False, message="Reception team not found", status_code=500) + doctor_team_role = DoctorTeamRole(doctor_id=doctor.id, team_id=team.id) + db.session.add(doctor_team_role) + else: + if team_id is not None: + team = Team.query.filter_by(id=team_id).first() + if not team: + return return_json(success=False, message="Team not found", status_code=500) + doctor_team_role = DoctorTeamRole(doctor_id=doctor.id, team_id=team.id) + db.session.add(doctor_team_role) + + db.session.add(user) + db.session.commit() + + return return_json(success=True, message="User registered successfully", data={"username": username, + "password": password}) + + +@app.route("/admin/edit_team_info", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def edit_team_info(): + data = request.get_json() + team_id = data.get("team_id") + department = data.get("department") + consultant_id = data.get("consultant_id") + is_admin_team = data.get("is_admin_team", False) + team_members = data.get("team_members", None) + + if not all([team_id, department, consultant_id]): + return return_json(success=False, message="Team ID, department and consultant ID are required", status_code=400) + + if team_members is not None: + if consultant_id not in team_members: + return return_json(success=False, message="Consultant must be in team members", status_code=400) + + if department not in Departments._member_names_: + return return_json(success=False, message="Invalid department", status_code=400) + + if not Doctor.query.filter_by(id=consultant_id).first(): + return return_json(success=False, message="Invalid consultant", status_code=400) + + team: Team = Team.query.filter_by(id=team_id).first() + if not team: + return return_json(success=False, message="Team not found", status_code=404) + + if team_members is not None: + DoctorTeamRole.query.filter_by(team_id=team.id).delete() + + for i in team_members: + doctor_team_role = DoctorTeamRole(doctor_id=i, team_id=team.id) + db.session.add(doctor_team_role) + + team.department = department + team.consultant_id = consultant_id + team.is_admin_team = is_admin_team + + db.session.commit() + + return return_json(success=True, message="Doctor team info updated successfully") + + +@app.route("/admin/edit_doctor_info", methods=["POST"]) +@permission_check(UserPermissionLevel.PATIENT) +def edit_doctor_info(pm_user_data: UserLoginInfo): + data = request.get_json() + doctor_id = data.get("doctor_id") + + name = data.get('name') + title = data.get('title') + birth_date = datetime.datetime.fromtimestamp(data.get('birth_date'), datetime.timezone.utc) + gender = data.get('gender') + phone = data.get('phone') + email = data.get('email') + grade = data.get('grade', DoctorGrade.Unknown.value) + + is_admin = data.get('is_admin', False) + + if pm_user_data.user_permission < UserPermissionLevel.ADMIN: + if pm_user_data.user_type == UserType.DOCTOR: + if pm_user_data.user_data.id != doctor_id: + return return_json(success=False, message="You are not authorized to edit this doctor", status_code=403) + else: + return return_json(success=False, message="You are not authorized to edit this doctor", status_code=403) + + doctor: Doctor = Doctor.query.filter_by(id=doctor_id).first() + if not doctor: + return return_json(success=False, message="Doctor not found", status_code=404) + if grade not in DoctorGrade._value2member_map_: + return return_json(success=False, message="Invalid doctor grade", status_code=400) + doctor.name = name + doctor.title = title + doctor.birth_date = birth_date + doctor.gender = gender + doctor.phone = phone + doctor.email = email + doctor.grade = grade + + doctor_team_roles = DoctorTeamRole.query.filter_by(doctor_id=doctor_id, team_id=1).all() + if is_admin and not doctor_team_roles: + admin_team_role = DoctorTeamRole(doctor_id=doctor.id, team_id=1) + db.session.add(admin_team_role) + elif not is_admin and doctor_team_roles: + for team_role in doctor_team_roles: + db.session.delete(team_role) + + db.session.commit() + return return_json(success=True, message="Doctor info updated successfully") + + +@app.route("/admin/edit_patient_info", methods=["POST"]) +@permission_check(UserPermissionLevel.PATIENT) +def edit_patient_info(pm_user_data: UserLoginInfo): + data = request.get_json() + patient_id = data.get("patient_id") + + name = data.get('name') + title = data.get('title') + birth_date = datetime.datetime.fromtimestamp(data.get('birth_date'), datetime.timezone.utc) + gender = data.get("gender") + phone = data.get('phone') + email = data.get('email') + postcode = data.get('postcode') + address = data.get('address') + + if pm_user_data.user_permission < UserPermissionLevel.ADMIN: + if pm_user_data.user_type.value == UserType.PATIENT.value: + if pm_user_data.user_data.id != patient_id: + return return_json(success=False, message="You are not authorized to edit this patient", status_code=403) + else: + return return_json(success=False, message="You are not authorized to edit this patient", status_code=403) + + patient: Patient = Patient.query.filter_by(id=patient_id).first() + if not patient: + return return_json(success=False, message="Patient not found", status_code=404) + + patient.name = name + patient.title = title + patient.birth_date = birth_date + patient.gender = gender + patient.phone = phone + patient.email = email + patient.postcode = postcode + patient.address = address + + db.session.commit() + return return_json(success=True, message="Patient info updated successfully") + + + +@app.route("/admin/set_resign_doctor", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def resign_doctor(): + data = request.get_json() + doctor_id = data.get("doctor_id") + is_resigned = data.get("is_resigned") + + doctor: Doctor = Doctor.query.filter_by(id=doctor_id).first() + if not doctor: + return return_json(success=False, message="Doctor not found", status_code=404) + + doctor.is_resigned = is_resigned + db.session.commit() + + return return_json(success=True, message="Doctor resigned successfully") + + +@app.route("/admin/reset_user_password", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def reset_user_password(): + data = request.get_json() + username = data.get("username") + password = utils.generate_randstring(6) + + user: User = User.query.filter_by(username=username).first() + if not user: + return return_json(success=False, message="User not found", status_code=404) + + if user.role == UserType.PATIENT.value: + Credentials.query.filter_by(patient_id=user.patient_id).delete() + elif user.role == UserType.DOCTOR.value: + Credentials.query.filter_by(doctor_id=user.doctor_id).delete() + + user.set_password(password) + db.session.commit() + + return return_json(success=True, message="User password reset successfully", data={ + "username": user.username, + "password": password + }) + + +@app.route("/admin/reset_user_password_from_role_id", methods=["POST"]) +@permission_check(UserPermissionLevel.ADMIN) +def reset_user_password_from_id(): + data = request.get_json() + user_id = data.get("user_id") + password = utils.generate_randstring(6) + user_type = data.get("user_type") + + if user_type == UserType.PATIENT.value: + user: User = User.query.filter_by(patient_id=user_id).first() + elif user_type == UserType.DOCTOR.value: + user: User = User.query.filter_by(doctor_id=user_id).first() + else: + return return_json(success=False, message="Invalid user type", status_code=400) + + if not user: + return return_json(success=False, message="User not found", status_code=404) + + if user.role == UserType.PATIENT.value: + Credentials.query.filter_by(patient_id=user.patient_id).delete() + elif user.role == UserType.DOCTOR.value: + Credentials.query.filter_by(doctor_id=user.doctor_id).delete() + + user.set_password(password) + db.session.commit() + + return return_json(success=True, message="User password reset successfully", data={ + "username": user.username, + "password": password + }) + + +@app.route("/admin/get_patient_list", methods=["GET"]) +@permission_check(UserPermissionLevel.PATIENT) +def get_user_list(): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + + if page == -1: + patients = Patient.query.paginate(per_page=9999999, max_per_page=None, error_out=False) + else: + patients = Patient.query.paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + if not patients.items: + return return_json(success=True, message="No patients found", data={"patients": [], "total_pages": 1}) + patient_list = [] + for i in patients.items: + admission: Admission = Admission.query.filter_by(patient_id=i.id).first() + ward: Ward = Ward.query.filter_by(id=admission.ward_id).first() if admission else None + + add_data = i.to_dict() + add_data["admission"] = admission.to_dict() if admission else None + add_data["ward"] = ward.to_dict() if ward else None + + patient_list.append(add_data) + + return return_json(success=True, message="Patients retrieved successfully", + data={"patients": patient_list, "total_pages": patients.pages}) + + +@app.route("/data/ward_patients", methods=["GET"]) +@permission_check(UserPermissionLevel.DOCTOR) +def get_ward_patients(): + ward_id = request.args.get("ward_id", type=int) + + if not ward_id: + return return_json(success=False, message="Ward ID is required", status_code=400) + ward: Ward = Ward.query.filter_by(id=ward_id).first() + if not ward: + return return_json(success=False, message="Ward not found", status_code=404) + admissions = Admission.query.filter_by(ward_id=ward_id).all() + if not admissions: + return return_json(success=True, message="No patients found", + data={"ward": ward.to_dict(), "patients": []}) + + patient_list = [] + for i in admissions: + patient: Patient = Patient.query.filter_by(id=i.patient_id).first() + if patient: + patient_list.append(patient.to_dict()) + + return return_json(success=True, message="Patients retrieved successfully", + data={"ward": ward.to_dict(), "patients": patient_list}) + + +@app.route("/data/team_patients", methods=["GET"]) +@permission_check(UserPermissionLevel.DOCTOR) +def get_team_patients(): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + if page < 1: + page = 1 + team_id = request.args.get("team_id", type=int) + + if not team_id: + return return_json(success=False, message="Team ID is required", status_code=400) + team: Team = Team.query.filter_by(id=team_id).first() + if not team: + return return_json(success=False, message="Team not found", status_code=404) + admissions = Admission.query.filter_by(team_id=team_id).paginate(page=page, per_page=count_limit, + max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + if not admissions.items: + return return_json(success=True, message="No patients found", + data={"team": team.to_dict(), "patients": [], "total_pages": admissions.pages}) + + patient_list = [] + for i in admissions.items: + patient: Patient = Patient.query.filter_by(id=i.patient_id).first() + ward: Ward = Ward.query.filter_by(id=i.ward_id).first() + if patient: + add_dict = patient.to_dict() + add_dict["admission"] = i.to_dict() + add_dict["ward"] = ward.to_dict() if ward else {} + patient_list.append(add_dict) + + return return_json(success=True, message="Patients retrieved successfully", + data={"team": team.to_dict(), "patients": patient_list, "total_pages": admissions.pages}) + + +@app.route("/data/treatment_doctors", methods=["GET"]) +@permission_check(UserPermissionLevel.PATIENT) +def get_treatment_doctors(pm_user_data: UserLoginInfo): + count_limit = request.args.get("limit", BaseDefine.DEFAULT_PER_PAGE, type=int) + page = request.args.get("page", 1, type=int) + if page < 1: + page = 1 + patient_id = request.args.get("patient_id", type=int) + doctor_id = request.args.get("doctor_id", type=int) + team_id = request.args.get("team_id", type=int) + display_all = request.args.get("all", type=str).lower() == "true" + + if pm_user_data.user_type.value == UserType.PATIENT.value: + if patient_id is not None: + if pm_user_data.user_data.id != patient_id: + return return_json(success=False, message="You are not authorized to view this patient's treatments", + status_code=403) + else: + return return_json(success=False, message="Permission denied", status_code=403) + + if display_all: + if pm_user_data.user_permission < UserPermissionLevel.ADMIN: + return return_json(success=False, message="Permission denied", status_code=403) + + if display_all: + treatments = Treatment.query.paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + + elif patient_id is not None: + patient: Patient = Patient.query.filter_by(id=patient_id).first() + if not patient: + return return_json(success=False, message="Patient not found", status_code=404) + + treatments = Treatment.query.filter_by(patient_id=patient_id).paginate(page=page, per_page=count_limit, + max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + elif doctor_id is not None: + doctor: Doctor = Doctor.query.filter_by(id=doctor_id).first() + if not doctor: + return return_json(success=False, message="Doctor not found", status_code=404) + treatments = Treatment.query.filter_by(doctor_id=doctor_id).paginate(page=page, per_page=count_limit, + max_per_page=BaseDefine.MAX_PER_PAGE, + error_out=False) + elif team_id is not None: + appointments = Appointment.query.order_by(Appointment.id.desc()).filter_by(assigned_team=team_id + # , discharged=False + ).all() + if not appointments: + return return_json(success=True, message="No patients found", status_code=404, data={"treatments": [], + "total_pages": 1}) + treatment_ids = [i.id for i in appointments] + treatments = (Treatment.query.filter(Treatment.appointment_id.in_(treatment_ids)) + .paginate(page=page, per_page=count_limit, max_per_page=BaseDefine.MAX_PER_PAGE, error_out=False)) + else: + return return_json(success=False, message="Patient ID or Doctor ID or Team ID is required", status_code=400) + + if not treatments.items: + return return_json(success=True, message="No treatment found", + data={ + # "patient": patient.to_dict(), + "treatments": [], + "total_pages": treatments.pages + }) + + treatment_data = [] + for i in treatments.items: + doctor: Doctor = Doctor.query.filter_by(id=i.doctor_id).first() + add_dict = i.to_dict() + add_dict["doctor_info"] = doctor.to_dict() if doctor else None + + appointment: Appointment = Appointment.query.order_by(Appointment.id.desc()).filter_by(id=i.appointment_id).first() + if appointment and (appointment.assigned_team is not None): + doctor_team: Optional[Team] = Team.query.filter_by(id=appointment.assigned_team).first() + else: + doctor_team = None + + add_dict["team"] = doctor_team.to_dict() if doctor_team else None + + treatment_data.append(add_dict) + + return return_json(success=True, message="Doctors retrieved successfully", + data={ + # "patient": patient.to_dict(), + "treatments": treatment_data, + "total_pages": treatments.pages + }) diff --git a/HMS_Backend/HMS_Backend/models.py b/HMS_Backend/HMS_Backend/models.py new file mode 100644 index 0000000..4cfa248 --- /dev/null +++ b/HMS_Backend/HMS_Backend/models.py @@ -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 diff --git a/HMS_Backend/HMS_Backend/utils.py b/HMS_Backend/HMS_Backend/utils.py new file mode 100644 index 0000000..7897604 --- /dev/null +++ b/HMS_Backend/HMS_Backend/utils.py @@ -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) diff --git a/HMS_Backend/instance/hospital.db b/HMS_Backend/instance/hospital.db new file mode 100644 index 0000000000000000000000000000000000000000..50b30dcfb5d65db08913dd89bd9d2712659b387f GIT binary patch literal 65536 zcmeI5U2G%UUBJg5No?n1_I7(4meqD=zq)s6*E`=IsV-V4aS|tvlQ_;N57{$w&cu`W zD>LIbP9;#bR|OvWfW#YZc>tllT!9c3LMqUQ3bhg-5Ilg2geoK+`hut`6@ifW&)C_- zYp2_i+e>vhv+Im~KK|!_e&>Hi^Z(EB@%mcc@d;P zPy&PPy&2~vm0*>5tbvG*Bvp8Zw!+Y{fI_}s+f z@xL2yjsH9((t{G91SkPYfD)htC;>`<5}*Vqf&VK4hpAB}ayQdXPDT>RxLvPQ>aD$I zL*zx}DK9^jcuvr!cxg&d(|mZ8%7nvp%dfkfOOBe3OFRz2V&|aZ(`JJsC#dSwknhxM zoKu6W79oe}m8F0C`q{`YC84oLnWxEjBgtgks@J?`#Se>~wH@xD*JNg(*qs3inav zkV9s@>2uXKmrkcwJCZT+EY~ri3Ha<*+QpHO(ozzfy=1J9QlPamB?2hpmDVlfab?F3 z^#f#Of-q;cfrp7Im6fG?!^PoH=98ob`d%qho|5@=}&nH zY=YyBDFFsZ>Z*m2htV-2m=hJvCfGx#HimB62z#6@3p%GD6$2nSOzE6CTR}GV;6F;) zARvWp6udkXhztMzVg$6fn|Ty4PxK>K26v@ZcPscFNQizm3iTglZiQnu(ALyIgrZ!~ z5G4UL5=1Em({U)N#6ZHm%q<8FBof#``?b2~JJu^(6}u>;b6X8jc;iqxKJnWO`-klA z#DBmWdQbwC03|>PPy&4pyv%hQccvFAa62Ldo@h`vm(i=2?jOuLTf*DEh# zDdqtPzMHu%Eub2Nfw*;uDlB`icXo;ZXO*rNsSXXqbcTbp!$P5&*qv3j*qydx?iu6P8&uLu@d#5jSJn8X+4Km8eSk+ z<%Es(KFRq=NA82icW!s(9$u0wJQc*Jk}$_>y3}O?r|0`sPPy&aX9L(H>CB3JW?TSJw-Wr7{c zsgYPRHpIYM1X$u9W22$eBC$v#Sm_c?MTVHkWHN(c?MvYQCsG>>TbTIG@$Zg*YRnz| z#%Kx>=s^ik0+awHKnYL+lmI0_3H-PO&csB5x&3l2$wWG0!UtWqEF14c;2vaVqXo4QJ{#A8u36r0BuFIu`yGz%Ip#*d5l5zCuoG)ai zbGliOPTZ{~CbfsQrkJ8ahzP1mU?PU4B1Pg2V5Vq>t>_}MWoXH?6)ea^R}@3yEgQ^6 zxr zJPs+_okzKyN3MOUH4G(z`QMiGCGzmP=MzZ0#>R?NFll< zTapIKAW5|aQ;=0l)&x^CO#-}$U`VQfkSSSMm!N_rK_7Wd)re&AiUmDGU?;qakpTik z1vJMh*gL_ZZ1ZCDjE{x56G^6{dY{gD-fqmUWo19>w-$`b@VDAhID~o zq+_`F$d+!v$Mxs;Q|aC`4dxAsAR*A~5*Ski{F4 zB?%ffu}%=o0b3Sz6)RZbd90cyk|mWFESLcrWMQHpUIqNNX-K9@U|40{Bw+lKqR1(@ zrYG1hG3*c7KV!edeuw=w`&IT|*{}V$mwH-`5}*Vq0ZM=ppaduZN`Mle1SkPYfD)ht zu18=rcKbYjniz}SiJXTeM-s8OBSExpI2F6qEiv>~>~W+gHWd#tJ1Manu}6{1p_FJM zHra_#jF0^WGdh}zvhPkzj(=_J`(w{0{%NAker2>Wni~D<)K^D?00BKH0ZM=ppaduZ zN`Mle1So+InZS#WjhrPju%MuIT-z3Ba??s(J>0U7_)KMUVeNnv&GL@i+;uiqXO~Y` zN=t6Lim-323zpuI{&v&-Ng5Bb@U=uXin3C1Bs>5SqDgNR{w}nDZpZ6R@ z*0)!dv&VLGOTzBLt|khn#oD32SXHNI>Id!ePRl%M@N#CuP^`JqNq%i=>?`o%R5zmy4HdYVX zYGd)FRo>)t==hMB^{tGapWg0m0d|NJ<+LH|lA_1=-s{fpI$X%7>MSXL* zpw@PGTJyfKK~}aGmK<>gb}3t1FJ?@<-)N)bYU$KE%)v5&gEBwY+kzrZNorcq;EAO2 zBGv7Jy_S>KO@F>9Dz)W;wY76Fqn)VR`=oT-YI>)$g3;iY@@QqVGN;Rn8<`_qT{v8A zyVFj2H(T#C5boFq+x5Y&X3zyu*WsaTbiBl%V4#yr`D3BISVPkCR&fD0s;w1y_vk=h zH!96?eW&D}c&oOl%!t0apINi!`1$N~uHbBM5^Z+l@U*W5K}^Hq3s@rZBGK)FXW-M? z^h_33Ivg&Uv%7VE`)F=Gx0o+C)it@g(aIDwM6Bad(P?k)*sG1og1+x=EzcjU_`=C+ z4aif9n1*E&h9pK|YIM4wlcr-g4o-Jw=9enPl7F-;Yu?6gel5FrxQdNgcfER|s5NnO zZokl8v06E_FulE1FK2joPJP_h0&LwT$O3%F{l##%3)3^Ax3#uS@~WRL z?L=Rg2hXp_@5_x9r!JkEi-NYaak##_Q)p9UJQ0-RP?m08IrC<_r}8(U{%8L>`8U~aGxw^8+k$9IBnGzS7z2+X~Ca{ zu@*}i-rKb1i$~^MdkO893kO;bJ3HITs$P?~IxU0~8urH%jbMK~LsMm4kFtAX-6m$l za$^?dj#rzdbvxr|ma&vAEY_D7j@+GT<)nDZ*N8rU;IF7ceq*zBxa&!{vbMi1x3{Y1 z#cY5r_*9TT#mjKb&|u-tUbNeQQStLf>zE)@n3tW}%RTbpHVW%0DCSPPB0 z!)be8++J(CJIgJ7wczok%*pQ3fs{L^BpRG3f*nms5=>}O5Y-nG-C;NsXE)X=hB>!i zukEa!*qeps@{Fr`(|Ud@i%C&X{FU5#YiT#Lz)S6e#-ekip{jhkZ5@|OJy$(>3NCyw zp8*oAI*6qv9x`<5}*Wr_6f|y&yo?_o!N)ThL28g$ymveBVF4CS942jZ`5Y<-uiK|x?MlW zPB%@pa5(3zoh~k|3K_n!FfUoH%^RVN>jg*HP{#G#rHN3+^};}%zFaR#k_z>>UMT0S zP{#E_9SQbNnb~Uw-aZ!UalLrOa46$?5s9Ht#`R(j`pWn2C z@Bfdo?=$RwvfpRF&c4t75&J#Z3Gh4Y-?M+oev|#ZpZyCD)sPaP1SkPYfD)htC;>`< z5}*Vq0ZM=p_^1#Vnus&W$nelua2gsOk28@-YG#ZaUa`K}>i1vdLpaduZN`Mle1SkPYfD)htC;>`<5}*XG zCJ;RTkFj51;J5#NpZx->v-;I#X$~bo2~Yx*03|>PPy&0 z1SkPYfD)htC;>`<5}*Vq0ZM=ppaiZ%KpGhd{MXEaD* z#3{j;5~Xg@*-NB2Qt?PS(y8>QzY?zus1*4BaW=}Zf5HATy9{sWK?zU-lmI0_2~Yx* z03|>PPy&|Y@J}t`z zJXnXP=Az(3u`~{gJr6<%jS5Ly_3H4mScTlUVh3;PkAK}Ac%^t z8xpFvpQEtCTr|Zz8o$Rrem9XQ5{oo~NQC40Pz~A@;Qw-1O_CM(*#|?oT;_7idL+e6 z26CPy5*Z93jVlE3VdmwUf&ZUKev)CuiHY&MqrWrugOoB_Nq#bVYxsvlYq9^1zV(qD zblRym6BrqKa~WEK5}*Vq0ZM=p_y`hs`SIk)4irnd z_S=(fo}3(WN(I?dZHwTCvy)Xj8ntp#5<}u zK6JI|*Tc_ymk8|%mp&w47KTP9CnqECeJ-Tk^oZ+)e};PqheqVEhc*sRhKDELoxExsLXLYvcp5pY#6$L<*FyH; zBnbZu^|0?wj;kj(X&~3H!X)X(Yt?IBv*M$gf607%%--?#P~g;s7aneUWhI}@6naYZ z81;ZlKu;SzM%}U7R}=eQY-IAzoydD{huuB5`15nTr_<-DKf>k)+newBFpv8tzv;Ms zc@HC>gtNb=gZl*LjN4zQQ4XHj_lY7^Ucl}gfXlW@cOH~PB!)AY$+0& z#LH&LsD)hYg@2+wbi$;c98J41Ks&$gqspFz8pv||_P{Hk)pT7juf4iuH5;gA4KCH+ zoACL6;&aUSKaNSMe}*^opaduZN`Mle1SkPY;HO96Jv1>g`RGyP*N4IzDb#3yCluZ! zdzatGdTy)kU(UMEy)&NTxcd&i-+xmS=5+5-;m-Q15*FNV?$_P+{nzgO!s?eA+w+|& zxj(=!4GZFMk!kq&<|;vM{g{9jeYfvneVY($BI~eI`7@oyJ2-4!nf&|(1mjo4t2-98CWy)a0cdtPR_ubeHUTSurJIM zwguiKxH=)}ertTAZ2;5HKNuUCymv41g||YczDrPN)?U0D?;(4U{v-UM%Tw(ljB~;1 zjUloN6YcLfR1TGZ$#hk0Da_w+-X4R9$Y + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/PageHeader.tsx b/HMS_Frontend/app/components/PageHeader.tsx new file mode 100644 index 0000000..d5fae30 --- /dev/null +++ b/HMS_Frontend/app/components/PageHeader.tsx @@ -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 ( + + + + + {width >= 290 && {title}} + {appendTitle && {appendTitle}} + + + + + + + + ) +} diff --git a/HMS_Frontend/app/components/PageNavbar.tsx b/HMS_Frontend/app/components/PageNavbar.tsx new file mode 100644 index 0000000..f683757 --- /dev/null +++ b/HMS_Frontend/app/components/PageNavbar.tsx @@ -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 ( + <> + {if (e) changePageStat(e as DashboardPageType)}}> + + }> + Home + + + { userPermission == UserPermissionLevel.PATIENT && <> + + }> + My Appointments + + }> + My Treatments + + } + + { canReception && <> + + }> + Appointments Management + + } + + { userPermission >= UserPermissionLevel.DOCTOR && <> + + }> + Patient Treatment + + } + + { userPermission >= UserPermissionLevel.ADMIN && <> + + }> + Ward Management + + + + }> + Medical Teams Management + + }> + Staff Management + + }> + Patients Management + + } + + { userPermission >= UserPermissionLevel.DOCTOR && <> + + }> + Treatment Records + + } + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/AppointmentManageFilter.tsx b/HMS_Frontend/app/components/subs/AppointmentManageFilter.tsx new file mode 100644 index 0000000..5aebf6c --- /dev/null +++ b/HMS_Frontend/app/components/subs/AppointmentManageFilter.tsx @@ -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([]); + const [filterStatus, setFilterStatus] = useState('-1'); + const [includeDischarged, setIncludeDischarged] = useState(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 ( + + + + + + ); +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/PatientInfoDisplay.tsx b/HMS_Frontend/app/components/subs/PatientInfoDisplay.tsx new file mode 100644 index 0000000..e5cf6d4 --- /dev/null +++ b/HMS_Frontend/app/components/subs/PatientInfoDisplay.tsx @@ -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(); + +interface PatientInfoDisplayProps { + patientId: number; + showIcon?: boolean; +} + +export function PatientInfoDisplay({ patientId, showIcon = true }: PatientInfoDisplayProps) { + const [patientInfo, setPatientInfo] = useState(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 ; + } + + // 如果无法找到患者信息,显示 ID + if (!patientInfo) { + return ( + + ID: {patientId} + {showIcon && ( + + + + )} + + ); + } + + return ( + <> + + + {`${patientInfo.title} ${patientInfo.name}`} + + {showIcon && ( + + + + )} + + + + + + Name: + {`${patientInfo.title} ${patientInfo.name}`} + + + Gender: + {patientInfo.gender} + + + Birth Date: + {patientInfo.birth_date} + + + Email: + {patientInfo.email} + + + Phone: + {patientInfo.phone} + + + Address: + {patientInfo.address} + + + Postcode: + {patientInfo.postcode} + + + + + + + + + ); +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/PatientManagementFilter.tsx b/HMS_Frontend/app/components/subs/PatientManagementFilter.tsx new file mode 100644 index 0000000..995ca58 --- /dev/null +++ b/HMS_Frontend/app/components/subs/PatientManagementFilter.tsx @@ -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("") + const [filterGenders, setFilterGenders] = useState([]) + const [filterHasTeam, setFilterHasTeam] = useState("-1") + const [filterIsAdmitted, setFilterIsAdmitted] = useState("-1") + const [filterTeams, setFilterTeams] = useState([]) + const [filterWards, setFilterWards] = useState([]) + + 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 ( + + + setNameSearch(e.currentTarget.value)} + leftSection={} + /> + + + + + + Medical Team Status + + + + + + + + + + Admission Status + + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/ResponsiveTableContainer.tsx b/HMS_Frontend/app/components/subs/ResponsiveTableContainer.tsx new file mode 100644 index 0000000..ad0c4cb --- /dev/null +++ b/HMS_Frontend/app/components/subs/ResponsiveTableContainer.tsx @@ -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, + 'children' +> & { + children: ReactNode; +}; + +export const ResponsiveTableContainer = forwardRef< + HTMLDivElement, + ResponsiveTableContainerProps +>(({ children, ...rest }, ref) => { + const isNarrow = useMediaQuery('(max-width: 700px)'); + + return isNarrow ? ( + + {children} + + ) : ( + <>{children} + ); +}); + +ResponsiveTableContainer.displayName = 'ResponsiveTableContainer'; diff --git a/HMS_Frontend/app/components/subs/StaffManageTableFilter.tsx b/HMS_Frontend/app/components/subs/StaffManageTableFilter.tsx new file mode 100644 index 0000000..b2abed1 --- /dev/null +++ b/HMS_Frontend/app/components/subs/StaffManageTableFilter.tsx @@ -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([]) + const [filterGenders, setFilterGenders] = useState([]) + const [filterIsAdmin, setFilterIsAdmin] = useState("-1") + const [filterIsTerminated, setFilterIsTerminated] = useState("-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 ( + + + setFilterGrades(value)} + comboboxProps={{ transitionProps: { transition: 'fade', duration: 100, timingFunction: 'ease' } }} + /> + + + + setFilterGenders(value)} + comboboxProps={{ transitionProps: { transition: 'fade', duration: 100, timingFunction: 'ease' } }} + /> + + + + setFilterIsAdmin(value)} + > + + + + + + + + + + setFilterIsTerminated(value)} + > + + + + + + + + + ) +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/TeamInfoDisplay.tsx b/HMS_Frontend/app/components/subs/TeamInfoDisplay.tsx new file mode 100644 index 0000000..46fad02 --- /dev/null +++ b/HMS_Frontend/app/components/subs/TeamInfoDisplay.tsx @@ -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(); + +interface TeamInfoDisplayProps { + teamId: number; + showIcon?: boolean; +} + +export function TeamInfoDisplay({ teamId, showIcon = true }: TeamInfoDisplayProps) { + const [teamInfo, setTeamInfo] = useState(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 ; + } + + if (!teamInfo) { + return ( + + ID: {teamId} + {showIcon && ( + + + + )} + + ); + } + + return ( + + + {teamInfo.department.replace(/_/g, ' ')} + + {showIcon && ( + + + + )} + + ); +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/ThemeToggle.tsx b/HMS_Frontend/app/components/subs/ThemeToggle.tsx new file mode 100644 index 0000000..4f05965 --- /dev/null +++ b/HMS_Frontend/app/components/subs/ThemeToggle.tsx @@ -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 ( + + + + {colorScheme === 'auto' ? : + colorScheme === 'dark' ? : } + + + + ); +} diff --git a/HMS_Frontend/app/components/subs/TruncatedText.tsx b/HMS_Frontend/app/components/subs/TruncatedText.tsx new file mode 100644 index 0000000..9bf230e --- /dev/null +++ b/HMS_Frontend/app/components/subs/TruncatedText.tsx @@ -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 -; + + if (text.length <= maxLength) { + return {text}; + } + + const truncatedText = `${text.substring(0, maxLength)}...`; + + const tooltipText = text.length > maxTooltipLength + ? `${text.substring(0, maxTooltipLength)}...` + : text + + return ( + <> + + + {truncatedText} + setOpened(true)} + title="Show full content" + > + + + + + + setOpened(false)} + title={title} + size="md" + centered + > + + {text} + + + + ); +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/WardManageTableFilter.tsx b/HMS_Frontend/app/components/subs/WardManageTableFilter.tsx new file mode 100644 index 0000000..043aa07 --- /dev/null +++ b/HMS_Frontend/app/components/subs/WardManageTableFilter.tsx @@ -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([]) + 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 ( + + + setFilterTypes(value)} + comboboxProps={{ transitionProps: { transition: 'fade', duration: 100, timingFunction: 'ease' } }} + /> + + + + Current Occupancy + ({ + value: index, + label: String(index), + }))} + onChange={setFilterOccupancy} + onChangeEnd={setFilterOccupancy} + /> + + + + Capacity + ({ + value: index + 1, + label: String(index + 1), + }))} + onChange={setFilterCapacity} + onChangeEnd={setFilterCapacity} + /> + + + ) +} \ No newline at end of file diff --git a/HMS_Frontend/app/components/subs/WardPatients.tsx b/HMS_Frontend/app/components/subs/WardPatients.tsx new file mode 100644 index 0000000..45603f1 --- /dev/null +++ b/HMS_Frontend/app/components/subs/WardPatients.tsx @@ -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([]); + 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 ( +
+ + { + if (!e) e = form.values.type + form.getInputProps("type").onChange(e) + }} + /> + + + + + + +
+ ) + } + + modals.open({ + title: origWardInfo ? "Edit Ward" : "Create Ward", + centered: true, + children: ( + + ) + }) +} + +export function confirmCheckWardPatients(origWardId: number, onChanged: () => void = () => {}) { + modals.open({ + title: "Ward Patients", + centered: true, + size: "70%", + children: ( + + ) + }) +} + +export function confirmEditDoctor( + origDoctorInfo: DoctorDataWithPermission, + onSucceed: () => void = () => {}, +) { + function ConfirmEditOrCreateDoctor({ + doctorInfo, + }: { + doctorInfo: DoctorDataWithPermission; + }) { + const form = useForm({ + initialValues: { + id: doctorInfo ? doctorInfo.id : -1, + name: doctorInfo ? doctorInfo.name : '', + email: doctorInfo ? doctorInfo.email : '', + phone: doctorInfo ? doctorInfo.phone : '', + gender: doctorInfo ? doctorInfo.gender : 'M', + birth_date: doctorInfo ? parseDmyToDate(doctorInfo.birth_date) : new Date(2000, 1, 1), + title: doctorInfo ? doctorInfo.title : '', + grade: doctorInfo ? doctorInfo.grade.toString() : DoctorGrade.Unknown.toString(), + is_admin: doctorInfo ? doctorInfo.is_admin : false + }, + + validate: { + name: (v) => (v.length === 0 ? 'Input doctor name.' : null), + email: (v) => + /^\S+@\S+\.\S+$/.test(v) ? null : 'Incorrect email address.', + phone: (v) => + v.length === 0 || /^\+?\d{6,15}$/.test(v) + ? null + : 'Invalid phone number.', + birth_date: (v) => (v > new Date() ? 'Date of birth cannot be in the future' : null), + gender: (v) => (v.length === 0 ? "Select a gender" : null) + }, + }); + + const genderOptions = useMemo( + () => [ + { value: 'M', label: 'Male' }, + { value: 'F', label: 'Female' }, + { value: 'Intersex', label: 'Intersex' }, + ], + [], + ); + + const gradeOptions = useMemo( + () => + Object.entries(DoctorGrade) + .filter(([k]) => isNaN(Number(k))) + .map(([k, v]) => ({ + value: v.toString(), + label: k, + })), + [], + ); + + const optionsFilter: OptionsFilter = ({ options, search }) => { + const res = (options as ComboboxItem[]).filter((o) => + o.label.toLowerCase().includes(search.toLowerCase().trim()), + ); + res.sort((a, b) => a.label.localeCompare(b.label)); + return res; + }; + + const onClickConfirmEditDoctor = (values: typeof form.values) => { + apiEditDoctorInfo(values).then((res) => { + if (res.success) { + modals.closeAll(); + showInfoMessage('', 'Doctor edited successfully', 3000); + onSucceed(); + } else showErrorMessage(res.message, 'Edit Doctor Failed'); + }).catch((err) => + showErrorMessage(err.toString(), 'Edit Doctor Failed'), + ); + }; + + return ( +
+ + + } + {...form.getInputProps('name')} + /> + + } + {...form.getInputProps('title')} + /> + + + + } + {...form.getInputProps('grade')} + onChange={(v) => { + if (!v) v = form.values.grade.toString(); + form.getInputProps('grade').onChange(v); + }} + /> + + } + {...form.getInputProps('email')} + /> + + + + } + {...form.getInputProps('phone')} + /> + + + + + + + + + +
+ ); + } + + modals.open({ + title: origDoctorInfo ? 'Edit Doctor' : 'Create Doctor', + centered: true, + size: "xl", + children: , + }); +} + +export function confirmSetResignedDoctor(doctor_id: number, doctor_name: string, is_resigned: boolean, onFinished: () => void = () => {}) { + const onClickConfirmSetResigned = (doctor: number, resigned: boolean) => { + apiSetDoctorResigned(doctor, resigned).then(res => { + if (res.success) { + showInfoMessage("", "The operation has been completed successfully.", 3000) + } + else { + showErrorMessage(res.message, "The doctor's resignation failed.") + } + }).catch(err => { + showErrorMessage(err.toString(), "The doctor's resignation failed.") + }).finally(() => onFinished()) + modals.closeAll() + } + + modals.open({ + title: is_resigned ? "Termination" : "Reinstatement", + centered: true, + children: ( + + { is_resigned ? + Are you certain you want to proceed with the termination of {doctor_name}? + : + Are you certain you want to proceed with the reinstatement of {doctor_name}? + } + + + + + + + ) + }) +} + + +export function confirmAdminAddUser( + initRegType: number = 1, // Patient: 0, Doctor: 1, Receptionist: 99 + onSucceed: () => void = () => {}, +) { + function ConfirmAddUser() { + const form = useForm({ + initialValues: { + reg_type: initRegType.toString(), + username: '', + name: '', + email: '', + phone: '', + gender: '', + birth_date: new Date(2000, 1, 1), + title: '', + + address: '', + postcode: '', + + grade: DoctorGrade.Unknown.toString(), + is_admin: false + }, + + validate: { + name: (v) => (v.length === 0 ? 'Input name.' : null), + email: (v) => + /^\S+@\S+\.\S+$/.test(v) ? null : 'Incorrect email address.', + phone: (v) => + v.length === 0 || /^\+?\d{6,15}$/.test(v) + ? null + : 'Invalid phone number.', + birth_date: (v) => (v > new Date() ? 'Date of birth cannot be in the future' : null), + gender: (v) => (v.length === 0 ? "Select a gender" : null) + }, + }); + + const genderOptions = useMemo( + () => [ + { value: 'M', label: 'Male' }, + { value: 'F', label: 'Female' }, + { value: 'Intersex', label: 'Intersex' }, + ], + [], + ); + + const gradeOptions = useMemo( + () => + Object.entries(DoctorGrade) + .filter(([k]) => isNaN(Number(k))) + .map(([k, v]) => ({ + value: v.toString(), + label: k, + })), + [], + ); + + const optionsFilter: OptionsFilter = ({ options, search }) => { + const res = (options as ComboboxItem[]).filter((o) => + o.label.toLowerCase().includes(search.toLowerCase().trim()), + ); + res.sort((a, b) => a.label.localeCompare(b.label)); + return res; + }; + + const onClickRegSubmit = (values: typeof form.values) => { + apiAdminRegister(values).then((res) => { + if (res.success) { + // modals.closeAll(); + showRegAccountPassword("Added User Succeed", res.data?.username, res.data?.password) + onSucceed() + } else showErrorMessage(res.message, 'Add User Failed'); + }).catch((err) => + showErrorMessage(err.toString(), 'Add User Failed'), + ); + }; + + return ( +
+ + + + + + + + + + } + {...form.getInputProps('username')} + /> + + } + {...form.getInputProps('name')} + /> + + } + {...form.getInputProps('title')} + /> + + } + {...form.getInputProps('grade')} + onChange={(v) => { + if (!v) v = form.values.grade.toString(); + form.getInputProps('grade').onChange(v); + }} + /> + + + + } + + + + + + +
+ ); + } + + modals.open({ + title: "Add User", + centered: true, + size: "70%", + children: , + }); +} + +export function showRegAccountPassword(title: string, username?: string, password?: string) { + modals.open({ + title: title, + centered: true, + closeOnEscape: false, + closeOnClickOutside: false, + withCloseButton: false, + children: ( + + + Username: {username} + { username && + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + } + + + Password: {password} + { password && + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + } + + + + {({ copied, copy }) => ( + + )} + + + + + + ), + }); +} + +export function confirmResetPassword(user_id: number, user_type: number) { + function ConfirmResetPWD() { + const [resetting, setResetting] = useState(false) + + return( + + Are you sure you want to reset the password? + + + + + + ) + } + + modals.open({ + title: "Reset Password", + centered: true, + children: + }); +} + +export function confirmDeleteTeam(teamId: number, department: String, onFinished: () => void = () => {}) { + const onClickConfirmDeleteTeam = (teamId: number) => { + apiDeleteTeam(teamId).then(res => { + if (res.success) { + showInfoMessage("", "Medical team deleted successfully", 3000) + } else { + showErrorMessage(res.message, "Failed to delete medical team") + } + }).catch(err => { + showErrorMessage(err.toString(), "Failed to delete medical team") + }).finally(() => onFinished()) + modals.closeAll() + } + + modals.open({ + title: "Delete Medical Team", + centered: true, + children: ( + + Are you sure you want to delete this medical team: {department}? + + + + + + ) + }) +} + +export function confirmEditOrCreateTeam( + origTeamInfo: DoctorTeamInfo | null, + doctorsList: DoctorDataWithPermission[] = [], + onSucceed: () => void = () => {} +) { + function ConfirmEditOrCreateTeam({ + teamInfo, + doctorsList + }: { + teamInfo: DoctorTeamInfo | null; + doctorsList: DoctorDataWithPermission[]; + }) { + const [loading, setLoading] = useState(false); + const [availableDoctors, setAvailableDoctors] = useState([]); + + useEffect(() => { + // Filter out resigned doctors + const activeDoctors = doctorsList.filter(d => !d.is_resigned); + setAvailableDoctors(activeDoctors); + }, [doctorsList]); + + const form = useForm({ + initialValues: { + team_id: teamInfo ? teamInfo.id : -1, + department: teamInfo ? teamInfo.department : '', + consultant_id: teamInfo ? teamInfo.consultant_id.toString() : '-1', + is_admin_team: teamInfo ? teamInfo.is_admin_team : false, + team_members: teamInfo + ? teamInfo.members.map(member => member.id) + : [] + }, + validate: { + department: (value) => (!value ? 'Please select a department' : null), + consultant_id: (value) => (value === '-1' || !value ? 'Please select a consultant' : null), + }, + }); + + const departmentOptions = useMemo(() => { + return Object.entries(Departments).map(([key, value]) => ({ + value: value as string, + label: key.replace(/_/g, ' ') + })); + }, []); + + const consultantOptions = useMemo(() => { + return availableDoctors + .map(d => ({ + value: d.id.toString(), + label: `${d.title} ${d.name}` + })); + }, [availableDoctors]); + + const teamMemberOptions = useMemo(() => { + return availableDoctors.map(d => ({ + value: d.id.toString(), + label: `${d.title} ${d.name} (${DoctorGrade[d.grade]})` + })); + }, [availableDoctors]); + + const handleFormSubmit = (values: typeof form.values) => { + setLoading(true); + + const teamData = { + ...values, + consultant_id: Number(values.consultant_id), + team_members: values.team_members.map(Number) + }; + + const apiCall = teamInfo + ? apiEditTeam(teamData) + : apiCreateTeam(teamData); + + apiCall.then(res => { + if (res.success) { + modals.closeAll(); + showInfoMessage("", teamInfo ? "Medical team updated successfully" : "Medical team created successfully", 3000); + onSucceed(); + } else { + showErrorMessage(res.message, teamInfo ? "Failed to update medical team" : "Failed to create medical team"); + } + }).catch(err => { + showErrorMessage(err.toString(), teamInfo ? "Failed to update medical team" : "Failed to create medical team"); + }).finally(() => { + setLoading(false); + }); + }; + + return ( +
+ + { + if (!value) return; + form.setFieldValue('consultant_id', value); + }} + /> + + id.toString())} + onChange={(values) => { + form.setFieldValue('team_members', values.map(Number)); + }} + /> + + + + + + + + +
+ ); + } + + modals.open({ + title: origTeamInfo ? "Edit Medical Team" : "Create Medical Team", + centered: true, + size: "lg", + children: ( + + ) + }); +} + +export function confirmViewTeamMembers(teamInfo: DoctorTeamInfo) { + modals.open({ + title: `Medical Team: ${teamInfo.department.replace(/_/g, ' ')}`, + centered: true, + size: "lg", + children: ( + + Team Information + Department: {teamInfo.department.replace(/_/g, ' ')} + + Consultant: { + teamInfo.members.find(m => m.id === teamInfo.consultant_id) + ? `${teamInfo.members.find(m => m.id === teamInfo.consultant_id)?.title || ''} ${teamInfo.members.find(m => m.id === teamInfo.consultant_id)?.name || ''}` + : `ID: ${teamInfo.consultant_id}` + } + + Admin Team: {teamInfo.is_admin_team ? 'Yes' : 'No'} + + Team Members ({teamInfo.members.length}) + {teamInfo.members.length > 0 ? ( + + + + ID + Name + Grade + Gender + Contact + + + + {teamInfo.members.map(member => ( + + {member.id} + {`${member.title} ${member.name}`} + {DoctorGrade[member.grade]} + {member.gender} + {member.email} + + ))} + +
+ ) : ( + No team members + )} + + + + +
+ ) + }); +} + +export function confirmDeleteBooking(appointmentId: number, category: string, onFinished: () => void = () => {}) { + const onClickConfirmDeleteBooking = (appointmentId: number) => { + apiDeletePatientBooking(appointmentId).then(res => { + if (res.success) { + showInfoMessage("", "Appointment deleted successfully", 3000) + } else { + showErrorMessage(res.message, "Failed to delete appointment") + } + }).catch(err => { + showErrorMessage(err.toString(), "Failed to delete appointment") + }).finally(() => onFinished()) + modals.closeAll() + } + + modals.open({ + title: "Delete Appointment", + centered: true, + children: ( + + Are you sure you want to delete this appointment: {category}? + + + + + + ) + }) +} + +export function confirmEditOrCreateBooking( + origBookingInfo: PatientBookingInfo | null, + onSucceed: () => void = () => {} +) { + function ConfirmEditOrCreateBooking({ + bookingInfo + }: { + bookingInfo: PatientBookingInfo | null; + }) { + const [loading, setLoading] = useState(false); + + const form = useForm({ + initialValues: { + appointment_id: bookingInfo ? bookingInfo.id : -1, + appointment_time: bookingInfo ? new Date(bookingInfo.appointment_time * 1000) : new Date(), + category: bookingInfo ? bookingInfo.category : BookingCategory.Consultation, + description: bookingInfo ? bookingInfo.description : '', + }, + validate: { + appointment_time: (value) => (!value ? 'Please select appointment time' : null), + category: (value) => (!value ? 'Please select category' : null), + description: (value) => (!value || value.length < 5 ? 'Description must be at least 5 characters' : null), + }, + }); + + const categoryOptions = useMemo(() => { + return Object.entries(BookingCategory).map(([key, value]) => ({ + value: value, + label: key.replace(/_/g, ' ') + })); + }, []); + + const handleFormSubmit = (values: typeof form.values) => { + setLoading(true); + + const appointmentTime = Math.floor(values.appointment_time.getTime() / 1000); + + const bookingData = { + ...values, + appointment_time: appointmentTime, + }; + + const apiCall = bookingInfo + ? apiEditPatientBooking(bookingData) + : apiPatientBooking(bookingData); + + apiCall.then(res => { + if (res.success) { + modals.closeAll(); + showInfoMessage("", bookingInfo ? "Appointment updated successfully" : "Appointment created successfully", 3000); + onSucceed(); + } else { + showErrorMessage(res.message, bookingInfo ? "Failed to update appointment" : "Failed to create appointment"); + } + }).catch(err => { + showErrorMessage(err.toString(), bookingInfo ? "Failed to update appointment" : "Failed to create appointment"); + }).finally(() => { + setLoading(false); + }); + }; + + return ( +
+ +