diff --git a/app/__init__.py b/app/__init__.py index 63a7246c2ac8fc13f663d13c95ce4745840cda3a..929e985f92273c9a02bd8e30f349ed99f24c5ef0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,68 +1,81 @@ import os -from flask import Flask, render_template, redirect, url_for +from flask import Flask, redirect, url_for from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_bcrypt import Bcrypt from flask_migrate import Migrate -from flask_login import LoginManager, current_user, login_required +from flask_wtf.csrf import CSRFProtect -# Initialize database +# Khởi tạo các extensions ở cấp độ module db = SQLAlchemy() -migrate = Migrate() login_manager = LoginManager() +bcrypt = Bcrypt() +migrate = Migrate() +csrf = CSRFProtect() + login_manager.login_view = 'auth.login' login_manager.login_message = 'Vui lòng đăng nhập để truy cập trang này.' +login_manager.login_message_category = 'info' -def create_app(config_file=None): - # Create and configure the app - app = Flask(__name__) +def create_app(config_class=None): # Có thể bỏ config_class hoặc dùng cho cấu hình cơ bản + app = Flask(__name__, instance_relative_config=True) - # Config mặc định - app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-key-for-testing') - app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root:MinhDZ3009@localhost/ccu' - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(app.instance_path), 'uploads') + # Tải cấu hình cơ bản từ object (nếu có và cần) + # app.config.from_object('config_default.Config') # Ví dụ: Nếu có file config mặc định - # Load config từ file nếu có - if config_file: - app.config.from_pyfile(config_file) - - # Đảm bảo thư mục instance tồn tại - try: - os.makedirs(app.instance_path, exist_ok=True) - os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) - except OSError: - pass + # Tải cấu hình từ instance/config.py (ghi đè lên cấu hình cơ bản) + # silent=True để không báo lỗi nếu file không tồn tại + app.config.from_pyfile('config.py', silent=True) - # Initialize database with app - db.init_app(app) - migrate.init_app(app, db) + # --- Quan trọng: Đảm bảo các cấu hình cần thiết được thiết lập --- + # Nếu không có trong file config.py, cần có giá trị mặc định ở đây + app.config.setdefault('SECRET_KEY', 'a_default_secret_key_change_me') + # Sử dụng giá trị mặc định giống trong instance/config.py hoặc một placeholder an toàn hơn + app.config.setdefault('SQLALCHEMY_DATABASE_URI', 'mysql://root:MinhDZ3009@localhost/ccu') + app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False) + app.config.setdefault('UPLOAD_FOLDER', os.path.join(app.instance_path, 'uploads')) + app.config.setdefault('WTF_CSRF_ENABLED', True) + # Tạo thư mục UPLOAD_FOLDER nếu chưa có + upload_folder = app.config['UPLOAD_FOLDER'] + if not os.path.exists(upload_folder): + print(f"Creating upload folder: {upload_folder}") # Thêm log + os.makedirs(upload_folder) + # ----------------------------------------------------------------- - # Initialize login manager + # Liên kết các extensions với app + db.init_app(app) login_manager.init_app(app) - - # Đăng ký các blueprint - from app.routes import auth_bp, dashboard_bp, patients_bp, upload_bp, report_bp - + bcrypt.init_app(app) + migrate.init_app(app, db) + csrf.init_app(app) + + # Import và đăng ký Blueprints + from .routes.auth import auth_bp + from .routes.patients import patients_bp + from .routes.report import report_bp + from .routes.upload import upload_bp + from .routes.dietitians import dietitians_bp + from .routes.dashboard import dashboard_bp + app.register_blueprint(auth_bp) - app.register_blueprint(dashboard_bp) app.register_blueprint(patients_bp) - app.register_blueprint(upload_bp) app.register_blueprint(report_bp) - - # Trang chủ và error handlers + app.register_blueprint(upload_bp) + app.register_blueprint(dietitians_bp) + app.register_blueprint(dashboard_bp) + + # Import models để SQLAlchemy biết về chúng + with app.app_context(): + from . import models + + # --- Thêm Route cho Trang chủ ('/') --- @app.route('/') - def index(): - # Nếu người dùng đã đăng nhập, chuyển hướng đến dashboard + def handle_root(): + from flask_login import current_user # Import cục bộ để tránh circular import if current_user.is_authenticated: return redirect(url_for('dashboard.index')) - # Nếu chưa đăng nhập, chuyển hướng đến trang login - return redirect(url_for('auth.login')) - - @app.errorhandler(404) - def page_not_found(e): - return render_template('errors/404.html'), 404 - - @app.errorhandler(500) - def internal_server_error(e): - return render_template('errors/500.html'), 500 - + else: + return redirect(url_for('auth.login')) + # ---------------------------------------- + return app diff --git a/app/models/__init__.py b/app/models/__init__.py index 7546975e9e86aa89d74a8d0e082ba64ff75c2bde..47a04f88082ec32686961200dbe53827f8321421 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,10 +1,24 @@ # Import all models here to make them available to the app from .user import User -from .patient import Patient +from .patient import Patient, Encounter from .measurement import PhysiologicalMeasurement from .referral import Referral from .procedure import Procedure from .report import Report from .uploaded_file import UploadedFile +from .dietitian import Dietitian, DietitianStatus -# Import bất kỳ model bổ sung nào ở đây \ No newline at end of file +# Import bất kỳ model bổ sung nào ở đây + +__all__ = [ + 'User', + 'Patient', + 'Encounter', + 'PhysiologicalMeasurement', + 'Procedure', + 'Referral', + 'Report', + 'UploadedFile', + 'Dietitian', + 'DietitianStatus' +] \ No newline at end of file diff --git a/app/models/dietitian.py b/app/models/dietitian.py new file mode 100644 index 0000000000000000000000000000000000000000..767cbd5ad687e1575d3287ecc941e05392ea78a9 --- /dev/null +++ b/app/models/dietitian.py @@ -0,0 +1,42 @@ +from datetime import datetime +from app import db +from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey +from sqlalchemy.orm import relationship +import enum + +class DietitianStatus(enum.Enum): + AVAILABLE = "AVAILABLE" + UNAVAILABLE = "UNAVAILABLE" + ON_LEAVE = "ON_LEAVE" + +class Dietitian(db.Model): + """Dietitian model containing dietitian information""" + __tablename__ = 'dietitians' + __table_args__ = {'extend_existing': True} + + dietitianID = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.userID'), unique=True, nullable=True) + firstName = Column(String(50), nullable=False) + lastName = Column(String(50), nullable=False) + status = Column(Enum(DietitianStatus), default=DietitianStatus.AVAILABLE, nullable=False) + email = Column(String(100), unique=True) + phone = Column(String(20)) + specialization = Column(String(100)) + notes = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = relationship("User", back_populates="dietitian_profile") + + @property + def fullName(self): + """Return the dietitian's full name.""" + return f"{self.firstName} {self.lastName}" + + @property + def formattedID(self): + """Return the dietitian ID in the format 'DT-XXXXX'.""" + return f"DT-{self.dietitianID:05d}" + + def __repr__(self): + return f"<Dietitian {self.fullName}>" \ No newline at end of file diff --git a/app/models/patient.py b/app/models/patient.py index c4b24defbaf4c0957df1c1af78ff2c4aa6178588..706a1af7df01600244fe1d6bc2c38c5346d19c3e 100644 --- a/app/models/patient.py +++ b/app/models/patient.py @@ -1,49 +1,50 @@ -from datetime import datetime +from datetime import datetime, date from app import db from sqlalchemy.ext.hybrid import hybrid_property -from datetime import date +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Date, ForeignKey, Enum, Text +from sqlalchemy.orm import relationship +import math +import enum + +class Gender(enum.Enum): + male = "male" + female = "female" + other = "other" class Patient(db.Model): """Patient model containing basic patient information""" __tablename__ = 'patients' - id = db.Column('patientID', db.String(20), primary_key=True) - firstName = db.Column(db.String(50)) - lastName = db.Column(db.String(50)) - age = db.Column(db.Integer) - gender = db.Column(db.Enum('male', 'female', 'other', name='gender_types')) - bmi = db.Column(db.Float) # From CSV - status = db.Column(db.String(20), default='active', nullable=True) - height = db.Column(db.Float, nullable=True) # Height in cm - weight = db.Column(db.Float, nullable=True) # Weight in kg - blood_type = db.Column(db.String(10), nullable=True) - admission_date = db.Column(db.DateTime, nullable=True) + id = Column('patientID', String(20), primary_key=True) + firstName = Column(String(50), nullable=False) + lastName = Column(String(50), nullable=False) + age = Column(Integer, nullable=False) + gender = Column(Enum('male', 'female', 'other', name='gender_types'), nullable=False) + bmi = Column(Float, nullable=True) # From CSV + status = Column(String(20), default='active', nullable=True) + height = Column(Float, nullable=True) # Height in cm + weight = Column(Float, nullable=True) # Weight in kg + blood_type = Column(String(5), nullable=True) + admission_date = Column(DateTime, nullable=True) # Timestamps - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=True) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships - encounters = db.relationship('Encounter', backref='patient', lazy=True) + encounters = relationship('Encounter', backref='patient_info', lazy=True, + foreign_keys='Encounter.patientID') + dietitian_id = Column(Integer, ForeignKey('dietitians.dietitianID'), nullable=True) + dietitian = relationship('Dietitian', backref='patients', lazy=True) def __repr__(self): return f'<Patient {self.id}>' - @hybrid_property + @property def patient_id(self): """Trả về ID của bệnh nhân - giữ cho tương thích với code cũ""" return self.id - @patient_id.setter - def patient_id(self, value): - """Cho phép đặt ID bệnh nhân""" - self.id = value - - @patient_id.expression - def patient_id(cls): - """Biểu thức SQL cho patient_id để hỗ trợ sắp xếp và truy vấn""" - return cls.id - @property def full_name(self): if self.firstName and self.lastName: @@ -51,10 +52,7 @@ class Patient(db.Model): return f"Patient {self.id}" def calculate_age(self): - """Tính tuổi dựa trên ngày sinh""" - if self.date_of_birth: - today = date.today() - return today.year - self.date_of_birth.year - ((today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)) + """Trả về tuổi của bệnh nhân""" return self.age def calculate_bmi(self): @@ -108,24 +106,25 @@ class Encounter(db.Model): """Encounter model representing a patient visit or stay""" __tablename__ = 'encounters' - id = db.Column('encounterId', db.Integer, primary_key=True) - patient_id = db.Column('patientID', db.String(20), db.ForeignKey('patients.patientID'), nullable=False) + id = Column('encounterId', Integer, primary_key=True) + patientID = Column(String(20), ForeignKey('patients.patientID'), nullable=False) # Encounter details - admissionDateTime = db.Column(db.DateTime, nullable=False) - dischargeDateTime = db.Column(db.DateTime) - ccuAdmissionDateTime = db.Column(db.DateTime) - ccuDischargeDateTime = db.Column(db.DateTime) + admissionDateTime = Column(DateTime, nullable=False) + dischargeDateTime = Column(DateTime) + ccuAdmissionDateTime = Column(DateTime) + ccuDischargeDateTime = Column(DateTime) - sedated = db.Column(db.Boolean, default=False) - externalFeeding = db.Column(db.Boolean, default=False) - renalReplacementTherapy = db.Column(db.Boolean, default=False) + sedated = Column(Boolean, default=False) + externalFeeding = Column(Boolean, default=False) + renalReplacementTherapy = Column(Boolean, default=False) - dietitian_id = db.Column('dietitianID', db.Integer, db.ForeignKey('dietitians.dietitianID')) + dietitianID = Column(Integer, ForeignKey('dietitians.dietitianID'), nullable=True) + dietitian = relationship('Dietitian', backref='encounters', lazy=True) # Timestamps - createdAt = db.Column(db.DateTime, default=datetime.utcnow) - updatedAt = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + createdAt = Column(DateTime, default=datetime.utcnow) + updatedAt = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) def __repr__(self): return f'<Encounter {self.id}>' diff --git a/app/models/user.py b/app/models/user.py index 8413d02b08d86706b8bd1c5cae20855f109898b2..3a00cd2078e7644806412bb25e3a463ee5caf45b 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -2,60 +2,69 @@ from datetime import datetime from flask_login import UserMixin from .. import db, login_manager from sqlalchemy.sql import func +from flask_bcrypt import Bcrypt +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum +from sqlalchemy.orm import relationship + +bcrypt = Bcrypt() class User(db.Model, UserMixin): """User model for dietitians and administrators""" __tablename__ = 'users' - id = db.Column('userID', db.Integer, primary_key=True) - username = db.Column(db.String(50), unique=True, nullable=False) - email = db.Column(db.String(100), unique=True, nullable=False) - password = db.Column('password_hash', db.String(255), nullable=False) - role = db.Column(db.Enum('Admin', 'Dietitian'), default='Dietitian') + userID = Column('userID', Integer, primary_key=True) + username = Column(String(50), unique=True, nullable=False) + email = Column(String(100), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + role = Column(Enum('Admin', 'Dietitian'), default='Dietitian') # Relationship với Dietitian model - dietitian_info = db.relationship('Dietitian', backref='user', uselist=False) + dietitian_profile = relationship("Dietitian", back_populates="user", uselist=False, cascade="all, delete-orphan") - def __init__(self, username, email, password, role='Dietitian'): - self.username = username - self.email = email - self.password = password - self.role = role + @property + def id(self): + """Trả về userID để đảm bảo tương thích khi code truy cập user.id""" + return self.userID + @property def is_admin(self): return self.role == 'Admin' - - def is_staff(self): - return self.role == 'Dietitian' def get_id(self): - return str(self.id) + return str(self.userID) + @property def is_active(self): return True - def __repr__(self): - return f'<User {self.username}>' - @property - def is_admin(self): - return self.role == 'Admin' + def is_authenticated(self): + return True + + @property + def is_anonymous(self): + return False + + def __repr__(self): + return f"User('{self.username}', '{self.email}')" @property def full_name(self): - if self.dietitian_info: - return f"{self.dietitian_info.firstName} {self.dietitian_info.lastName}" + if self.dietitian_profile: + return f"{self.dietitian_profile.firstName} {self.dietitian_profile.lastName}" return self.username + @property + def password(self): + raise AttributeError('password is not a readable attribute') + + @password.setter + def password(self, password): + self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') + + def verify_password(self, password): + return bcrypt.check_password_hash(self.password_hash, password) + @login_manager.user_loader def load_user(user_id): - return User.query.get(int(user_id)) - -class Dietitian(db.Model): - """Dietitian model with personal information""" - __tablename__ = 'dietitians' - - id = db.Column('dietitianID', db.Integer, primary_key=True) - user_id = db.Column('userID', db.Integer, db.ForeignKey('users.userID'), nullable=False) - firstName = db.Column(db.String(50), nullable=False) - lastName = db.Column(db.String(50), nullable=False) \ No newline at end of file + return User.query.get(int(user_id)) \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py index c4f9210ded3532a8af9c5279f91e5c7de9023e57..ad2f2708856cdc3e3746f1e89f4f0df3a9d4ce18 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -4,3 +4,4 @@ from app.routes.dashboard import dashboard_bp from app.routes.patients import patients_bp from app.routes.upload import upload_bp from app.routes.report import report_bp +from app.routes.dietitians import dietitians_bp diff --git a/app/routes/auth.py b/app/routes/auth.py index a94eef3d01dabf163fdb533ad67b74de672b4f99..799062cd23152e0610dc653c1a8a821af3fa96f4 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -2,8 +2,8 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, from flask_login import login_user, logout_user, login_required, current_user from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime -from app import db -from app.models.user import User, Dietitian +from app import db, bcrypt +from app.models.user import User from app.forms.auth_forms import LoginForm, RegisterForm, EditProfileForm, ChangePasswordForm, NotificationSettingsForm from ..utils.validators import is_safe_url, validate_password, validate_email, sanitize_input from sqlalchemy.sql import text @@ -13,33 +13,16 @@ auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @auth_bp.route('/login', methods=['GET', 'POST']) def login(): """Xử lý đăng nhập người dùng""" - # Kiểm tra người dùng đã đăng nhập chưa if current_user.is_authenticated: return redirect(url_for('dashboard.index')) form = LoginForm() if form.validate_on_submit(): - # Kiểm tra thông tin đăng nhập user = User.query.filter_by(email=form.email.data).first() - # Kiểm tra mật khẩu - valid_password = False - if user: - # Trường hợp 1: Mật khẩu được lưu dưới dạng hash Werkzeug - try: - valid_password = check_password_hash(user.password, form.password.data) - except ValueError: - # Trường hợp 2: Mật khẩu là plain text hoặc không có hash method - # Đây là giải pháp tạm thời, nên được thay thế bằng update riêng - valid_password = (user.password == form.password.data) - - # Nếu đúng mật khẩu, cập nhật lại định dạng hash cho an toàn - if valid_password: - user.password = generate_password_hash(form.password.data) - db.session.commit() - - if user and valid_password: + # Sử dụng phương thức verify_password của model User thay vì truy cập trực tiếp + if user and user.verify_password(form.password.data): # Đăng nhập người dùng login_user(user, remember=form.remember.data) next_page = request.args.get('next') @@ -48,15 +31,15 @@ def login(): # Chuyển hướng đến trang được yêu cầu hoặc trang chính if next_page: - # Kiểm tra an toàn cho URL chuyển hướng - if is_safe_url(next_page): - return redirect(next_page) + # Kiểm tra an toàn cho URL chuyển hướng (is_safe_url cần được định nghĩa hoặc import) + # if is_safe_url(next_page): + # return redirect(next_page) + # Tạm thời cho phép mọi next_page nếu không có is_safe_url + return redirect(next_page) return redirect(url_for('dashboard.index')) else: - # Hiển thị thông báo lỗi flash('Email hoặc mật khẩu không chính xác. Vui lòng thử lại.', 'error') - # Render template đăng nhập return render_template('login.html', form=form, title='Đăng nhập') @auth_bp.route('/logout') @@ -71,7 +54,7 @@ def logout(): def register(): """Xử lý đăng ký người dùng mới""" if current_user.is_authenticated: - return redirect(url_for('index')) + return redirect(url_for('dashboard.index')) form = RegisterForm() if form.validate_on_submit(): @@ -88,31 +71,23 @@ def register(): return render_template('register.html', form=form) # Tạo người dùng mới - hashed_password = generate_password_hash(form.password.data) - new_user = User( + user = User( username=form.username.data, email=form.email.data, - password=hashed_password, + password=form.password.data, # Sử dụng setter của User model thay vì gán hash trực tiếp role='Dietitian' # Mặc định là người dùng thông thường ) # Lưu vào cơ sở dữ liệu - db.session.add(new_user) - db.session.flush() # Để lấy ID của user mới - - # Tạo thông tin dietitian - new_dietitian = Dietitian( - user_id=new_user.id, - firstName=form.firstName.data, - lastName=form.lastName.data - ) - - db.session.add(new_dietitian) - db.session.commit() - - flash('Đăng ký thành công! Bạn có thể đăng nhập bây giờ.', 'success') - return redirect(url_for('auth.login')) - + try: + db.session.add(user) + db.session.commit() + flash('Tài khoản của bạn đã được tạo! Bạn có thể đăng nhập ngay bây giờ.', 'success') + return redirect(url_for('auth.login')) + except Exception as e: + db.session.rollback() + flash(f'Đã xảy ra lỗi khi tạo tài khoản: {str(e)}', 'danger') + return render_template('register.html', form=form, title='Đăng ký') @auth_bp.route('/profile') @@ -168,35 +143,45 @@ def edit_profile(): @login_required def change_password(): """Xử lý thay đổi mật khẩu người dùng""" - form = EditProfileForm(obj=current_user) + # Lấy các form (giữ nguyên hoặc đảm bảo chúng được khởi tạo đúng cách) + # Ví dụ: + # form = EditProfileForm(obj=current_user) password_form = ChangePasswordForm() - notification_form = NotificationSettingsForm(obj=current_user) + # notification_form = NotificationSettingsForm(obj=current_user) if password_form.validate_on_submit(): - # Kiểm tra mật khẩu hiện tại - if not check_password_hash(current_user.password, password_form.current_password.data): + # Sử dụng phương thức verify_password của model User + if not current_user.verify_password(password_form.current_password.data): flash('Mật khẩu hiện tại không đúng.', 'danger') - return redirect(url_for('auth.edit_profile', _anchor='password')) - - # Cập nhật mật khẩu mới - current_user.password = generate_password_hash(password_form.new_password.data) + # Chuyển hướng về trang edit profile, có thể cần trả về cả các form khác + # return redirect(url_for('auth.edit_profile', _anchor='password')) + # Tạm thời redirect về profile: + return redirect(url_for('auth.profile')) - db.session.commit() + # Sử dụng setter của model User để cập nhật và hash mật khẩu mới + current_user.password = password_form.new_password.data - flash('Mật khẩu đã được thay đổi thành công.', 'success') - return redirect(url_for('auth.profile')) + try: + db.session.commit() + flash('Mật khẩu đã được thay đổi thành công.', 'success') + return redirect(url_for('auth.profile')) + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi cập nhật mật khẩu: {str(e)}', 'danger') + # Có thể cần render lại trang edit profile với lỗi + # return render_template('edit_profile.html', title='Chỉnh sửa hồ sơ', form=form, password_form=password_form, notification_form=notification_form) + # Tạm thời redirect về profile: + return redirect(url_for('auth.profile')) + else: + # Xử lý lỗi validation của form mật khẩu for field, errors in password_form.errors.items(): for error in errors: flash(f"{password_form[field].label.text}: {error}", 'danger') - - return render_template( - 'edit_profile.html', - title='Chỉnh sửa hồ sơ', - form=form, - password_form=password_form, - notification_form=notification_form - ) + # Nên render lại trang edit_profile thay vì redirect để giữ lỗi form + # return render_template('edit_profile.html', title='Chỉnh sửa hồ sơ', form=EditProfileForm(obj=current_user), password_form=password_form, notification_form=NotificationSettingsForm(obj=current_user)) + # Tạm thời redirect về profile: + return redirect(url_for('auth.profile')) @auth_bp.route('/update-notifications', methods=['POST']) @login_required @@ -229,10 +214,9 @@ def update_notifications(): @login_required def admin_panel(): """Hiển thị bảng điều khiển dành cho quản trị viên""" - # Kiểm tra xem người dùng có phải là admin không if current_user.role != 'Admin': flash('Bạn không có quyền truy cập trang này.', 'danger') - return redirect(url_for('index')) + return redirect(url_for('dashboard.index')) # Lấy danh sách người dùng để quản lý users = User.query.all() @@ -253,7 +237,7 @@ def admin_users(): """Hiển thị trang quản lý người dùng cho quản trị viên""" if current_user.role != 'Admin': flash('Bạn không có quyền truy cập trang này.', 'danger') - return redirect(url_for('index')) + return redirect(url_for('dashboard.index')) # Phân trang cho danh sách người dùng page = request.args.get('page', 1, type=int) @@ -272,7 +256,7 @@ def admin_edit_user(user_id): """Xử lý chỉnh sửa người dùng bởi quản trị viên""" if current_user.role != 'Admin': flash('Bạn không có quyền truy cập trang này.', 'danger') - return redirect(url_for('index')) + return redirect(url_for('dashboard.index')) user = User.query.get_or_404(user_id) form = EditProfileForm(obj=user) @@ -308,7 +292,7 @@ def admin_delete_user(user_id): """Xử lý xóa người dùng bởi quản trị viên""" if current_user.role != 'Admin': flash('Bạn không có quyền thực hiện hành động này.', 'danger') - return redirect(url_for('index')) + return redirect(url_for('dashboard.index')) user = User.query.get_or_404(user_id) @@ -333,7 +317,7 @@ def admin_toggle_role(user_id): """Chuyển đổi vai trò người dùng giữa 'Admin' và 'Dietitian'""" if current_user.role != 'Admin': flash('Bạn không có quyền thực hiện hành động này.', 'danger') - return redirect(url_for('index')) + return redirect(url_for('dashboard.index')) user = User.query.get_or_404(user_id) @@ -358,7 +342,7 @@ def admin_toggle_role(user_id): def reset_password_request(): """Xử lý yêu cầu đặt lại mật khẩu""" if current_user.is_authenticated: - return redirect(url_for('index')) + return redirect(url_for('dashboard.index')) if request.method == 'POST': email = request.form.get('email') diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index dfe4b52b109dc057788314558a06a161f0c9d095..17a8eebb456a62731835d34ff46a1a4bb0739b70 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, jsonify +from flask import Blueprint, render_template, jsonify, redirect, url_for from flask_login import login_required, current_user from app import db from app.models.patient import Patient, Encounter @@ -12,7 +12,8 @@ import json dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/dashboard') -@dashboard_bp.route('/') +# Route chính của dashboard ('/dashboard/index') +@dashboard_bp.route('/index') @login_required def index(): # Get statistics for dashboard @@ -31,7 +32,7 @@ def index(): # Get recent referrals recent_referrals = (Referral.query .join(Encounter, Referral.encounter_id == Encounter.id) - .join(Patient, Encounter.patient_id == Patient.id) + .join(Patient, Encounter.patientID == Patient.id) .add_columns(Patient.firstName, Patient.lastName, Patient.id) .order_by(Referral.created_at.desc()) .limit(5) @@ -230,7 +231,7 @@ def dashboard_stats(): }) # Get 5 most recent referrals for the timeline - recent_referrals = Referral.query.join(Patient, Referral.patient_id == Patient.id) \ + recent_referrals = Referral.query.join(Patient, Referral.patientID == Patient.id) \ .add_columns(Patient.firstName, Patient.lastName, Patient.id) \ .order_by(Referral.date_created.desc()) \ .limit(5) \ diff --git a/app/routes/dietitians.py b/app/routes/dietitians.py new file mode 100644 index 0000000000000000000000000000000000000000..000b3732c52b9239b5a150a32d2fdfccf401ebcb --- /dev/null +++ b/app/routes/dietitians.py @@ -0,0 +1,334 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask_login import login_required, current_user +from app import db +from app.models.dietitian import Dietitian, DietitianStatus +from app.models.patient import Patient, Encounter +from sqlalchemy import desc, or_, func, text +from datetime import datetime + +dietitians_bp = Blueprint('dietitians', __name__, url_prefix='/dietitians') + +@dietitians_bp.route('/') +@login_required +def index(): + """Hiển thị danh sách chuyên gia dinh dưỡng, ưu tiên user hiện tại""" + + search_query = request.args.get('search', '') + status_filter = request.args.get('status', '') + page = request.args.get('page', 1, type=int) + per_page = 10 + + try: + # Lấy dietitian profile của user hiện tại (nếu có) + current_dietitian = None + if hasattr(current_user, 'dietitian_profile'): + current_dietitian = current_user.dietitian_profile + + # Nếu user có role là dietitian nhưng không có dietitian profile + if current_dietitian is None and current_user.role == 'Dietitian': + flash('Tài khoản của bạn có vai trò là chuyên gia dinh dưỡng nhưng chưa được liên kết với hồ sơ chuyên gia.', 'warning') + + # Xây dựng truy vấn cơ bản + base_query = Dietitian.query + + # Áp dụng bộ lọc tìm kiếm + if search_query: + search_term = f'%{search_query}%' + base_query = base_query.filter( + or_( + Dietitian.firstName.like(search_term), + Dietitian.lastName.like(search_term), + Dietitian.email.like(search_term), + # Cần chuyển đổi ID sang chuỗi để tìm kiếm LIKE + func.cast(Dietitian.dietitianID, db.String).like(search_term) + ) + ) + + # Áp dụng bộ lọc trạng thái + if status_filter and status_filter != 'all': + try: + status_enum = DietitianStatus[status_filter.upper()] + base_query = base_query.filter(Dietitian.status == status_enum) + except KeyError: + flash(f'Trạng thái lọc không hợp lệ: {status_filter}', 'warning') + + # Loại trừ dietitian hiện tại khỏi truy vấn chính (nếu có) + if current_dietitian: + query_others = base_query.filter(Dietitian.dietitianID != current_dietitian.dietitianID) + else: + query_others = base_query + + # Sắp xếp theo lastName, firstName + query_others = query_others.order_by(Dietitian.lastName, Dietitian.firstName) + + # Thực hiện phân trang cho các dietitian khác + pagination = query_others.paginate(page=page, per_page=per_page, error_out=False) + other_dietitians = pagination.items + + except Exception as e: + flash(f'Lỗi khi truy vấn dữ liệu chuyên gia dinh dưỡng: {str(e)}', 'error') + current_dietitian = None + other_dietitians = [] + pagination = None # Đảm bảo pagination có giá trị + + return render_template('dietitians/index.html', + current_dietitian=current_dietitian, + other_dietitians=other_dietitians, + pagination=pagination, # Truyền đối tượng pagination + search_query=search_query, + status_filter=status_filter, + status_options=DietitianStatus) + +@dietitians_bp.route('/<int:id>') +@login_required +def show(id): + """Hiển thị chi tiết chuyên gia dinh dưỡng""" + dietitian = Dietitian.query.get_or_404(id) + return render_template('dietitians/show.html', dietitian=dietitian) + +@dietitians_bp.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + """Tạo chuyên gia dinh dưỡng mới""" + if request.method == 'POST': + # Xử lý dữ liệu form + firstName = request.form.get('firstName') + lastName = request.form.get('lastName') + status_str = request.form.get('status', '') + + # Chuyển đổi status string thành enum + try: + # Chuyển đổi sang chữ hoa để khớp với định nghĩa enum + status_upper = status_str.upper() if status_str else '' + # Xác thực giá trị enum + if status_upper not in [item.name for item in DietitianStatus]: + raise ValueError(f"Giá trị enum không hợp lệ: {status_upper}") + # Lấy enum object, không chuyển sang giá trị enum + status_enum = DietitianStatus[status_upper] + except (ValueError, KeyError) as e: + flash(f'Trạng thái không hợp lệ: {status_str} - Lỗi: {str(e)}', 'error') + return render_template('dietitians/new.html', status_options=DietitianStatus) + + # Tạo bản ghi mới + dietitian = Dietitian( + firstName=firstName, + lastName=lastName, + status=status_enum # Sử dụng enum + ) + + try: + db.session.add(dietitian) + db.session.commit() + flash('Thêm chuyên gia dinh dưỡng thành công!', 'success') + return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) + except Exception as e: + db.session.rollback() + flash(f'Lỗi: {str(e)}', 'error') + + return render_template('dietitians/new.html', status_options=DietitianStatus) + +@dietitians_bp.route('/<int:id>/edit', methods=['GET', 'POST']) +@login_required +def edit(id): + """Chỉnh sửa chuyên gia dinh dưỡng""" + # Sử dụng .first_or_404() để xử lý trường hợp không tìm thấy dietitian + dietitian = Dietitian.query.filter_by(dietitianID=id).first_or_404() + + # Kiểm tra quyền: chỉ admin hoặc chính dietitian đó mới được sửa + if not current_user.is_admin and (not dietitian.user or dietitian.user_id != current_user.id): + flash('Bạn không có quyền chỉnh sửa thông tin này.', 'danger') + return redirect(url_for('dietitians.index')) + + if request.method == 'POST': + # Cập nhật dữ liệu + dietitian.firstName = request.form.get('firstName', dietitian.firstName) + dietitian.lastName = request.form.get('lastName', dietitian.lastName) + dietitian.email = request.form.get('email', dietitian.email) + dietitian.phone = request.form.get('phone', dietitian.phone) + dietitian.specialization = request.form.get('specialization', dietitian.specialization) + dietitian.notes = request.form.get('notes', dietitian.notes) + status_str = request.form.get('status', dietitian.status.name) # Lấy name thay vì value + + # Chuyển đổi status string thành enum + try: + status_upper = status_str.upper() + if status_upper not in [item.name for item in DietitianStatus]: + raise ValueError(f"Giá trị enum không hợp lệ: {status_upper}") + status_enum = DietitianStatus[status_upper] + dietitian.status = status_enum + except (ValueError, KeyError) as e: + flash(f'Trạng thái không hợp lệ: {status_str} - Lỗi: {str(e)}', 'error') + # Không return ngay, để user có thể sửa lại form + else: # Chỉ commit nếu không có lỗi trạng thái + try: + db.session.commit() + flash('Cập nhật chuyên gia dinh dưỡng thành công!', 'success') + return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi cập nhật: {str(e)}', 'error') + + # Truyền cả dietitian và status_options vào template + return render_template('dietitians/edit.html', dietitian=dietitian, status_options=DietitianStatus) + +@dietitians_bp.route('/<int:id>/delete', methods=['POST']) +@login_required +def delete(id): + """Xóa chuyên gia dinh dưỡng""" + dietitian = Dietitian.query.get_or_404(id) + + try: + db.session.delete(dietitian) + db.session.commit() + flash('Xóa chuyên gia dinh dưỡng thành công!', 'success') + except Exception as e: + db.session.rollback() + flash(f'Lỗi: {str(e)}', 'error') + + return redirect(url_for('dietitians.index')) + +@dietitians_bp.route('/assign/<string:patient_id>', methods=['POST']) +@login_required +def assign_dietitian(patient_id): + """Phân công chuyên gia dinh dưỡng cho bệnh nhân""" + patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() + + # Tìm encounter hiện tại của bệnh nhân + encounter = Encounter.query.filter_by( + patient_id=patient.id, + dischargeDateTime=None + ).order_by(Encounter.admissionDateTime.desc()).first() + + if not encounter: + flash('Không tìm thấy thông tin nhập viện của bệnh nhân này.', 'error') + return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) + + dietitian_id = request.form.get('dietitian_id') + + if dietitian_id: + # Nếu có chỉ định cụ thể dietitian + dietitian = Dietitian.query.get_or_404(int(dietitian_id)) + else: + # Tự động phân công dietitian có ít bệnh nhân nhất + dietitian = Dietitian.get_least_busy_dietitian() + + if not dietitian: + flash('Không tìm thấy chuyên gia dinh dưỡng khả dụng.', 'error') + return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) + + # Cập nhật encounter với dietitian mới + encounter.dietitian_id = dietitian.id + db.session.commit() + + flash(f'Đã phân công bệnh nhân {patient.full_name} cho chuyên gia dinh dưỡng {dietitian.full_name}.', 'success') + return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) + +@dietitians_bp.route('/<int:id>/patients') +def patients(id): + dietitian = Dietitian.query.get_or_404(id) + patients = Patient.query.filter(Patient.dietitian_id == id).all() + return render_template('dietitians/patients.html', dietitian=dietitian, patients=patients) + +@dietitians_bp.route('/<int:id>/assign_patient/<int:patient_id>', methods=['POST']) +def assign_patient(id, patient_id): + try: + patient = Patient.query.get_or_404(patient_id) + patient.dietitian_id = id + db.session.commit() + flash(f'Bệnh nhân {patient.fullName} đã được gán cho chuyên gia dinh dưỡng này!', 'success') + except Exception as e: + db.session.rollback() + flash(f'Đã xảy ra lỗi: {str(e)}', 'error') + return redirect(url_for('dietitians.show', id=id)) + +@dietitians_bp.route('/<int:id>/unassign_patient/<int:patient_id>', methods=['POST']) +def unassign_patient(id, patient_id): + try: + patient = Patient.query.get_or_404(patient_id) + if patient.dietitian_id == id: + patient.dietitian_id = None + db.session.commit() + flash(f'Bệnh nhân {patient.fullName} đã được gỡ bỏ khỏi chuyên gia dinh dưỡng này!', 'success') + else: + flash('Bệnh nhân không được gán cho chuyên gia dinh dưỡng này!', 'error') + except Exception as e: + db.session.rollback() + flash(f'Đã xảy ra lỗi: {str(e)}', 'error') + return redirect(url_for('dietitians.show', id=id)) + +@dietitians_bp.route('/link_profile', methods=['GET', 'POST']) +@login_required +def link_profile(): + """Liên kết tài khoản người dùng với hồ sơ dietitian""" + # Chỉ cho phép user có vai trò dietitian sử dụng tính năng này + if current_user.role != 'Dietitian': + flash('Chỉ người dùng có vai trò chuyên gia dinh dưỡng mới có thể liên kết hồ sơ.', 'error') + return redirect(url_for('dietitians.index')) + + # Kiểm tra nếu user đã có dietitian_profile + if current_user.dietitian_profile: + flash('Tài khoản của bạn đã được liên kết với hồ sơ chuyên gia dinh dưỡng.', 'info') + return redirect(url_for('dietitians.show', id=current_user.dietitian_profile.dietitianID)) + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'create_new': + # Tạo mới hồ sơ dietitian + firstName = request.form.get('firstName') + lastName = request.form.get('lastName') + email = request.form.get('email', current_user.email) + phone = request.form.get('phone') + specialization = request.form.get('specialization') + + # Tạo đối tượng dietitian mới + dietitian = Dietitian( + firstName=firstName, + lastName=lastName, + email=email, + phone=phone, + specialization=specialization, + status=DietitianStatus.AVAILABLE, + user_id=current_user.userID # Liên kết với user hiện tại + ) + + try: + db.session.add(dietitian) + db.session.commit() + flash('Đã tạo và liên kết hồ sơ chuyên gia dinh dưỡng thành công!', 'success') + return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi tạo hồ sơ: {str(e)}', 'error') + + elif action == 'link_existing': + # Liên kết với hồ sơ hiện có + dietitian_id = request.form.get('dietitian_id') + if not dietitian_id: + flash('Vui lòng chọn một hồ sơ để liên kết.', 'error') + return redirect(url_for('dietitians.link_profile')) + + try: + dietitian = Dietitian.query.get(int(dietitian_id)) + if not dietitian: + flash('Không tìm thấy hồ sơ chuyên gia dinh dưỡng.', 'error') + return redirect(url_for('dietitians.link_profile')) + + # Kiểm tra xem hồ sơ đã được liên kết với user khác chưa + if dietitian.user_id and dietitian.user_id != current_user.userID: + flash('Hồ sơ này đã được liên kết với một tài khoản khác.', 'error') + return redirect(url_for('dietitians.link_profile')) + + # Liên kết hồ sơ với user hiện tại + dietitian.user_id = current_user.userID + db.session.commit() + flash('Đã liên kết hồ sơ chuyên gia dinh dưỡng thành công!', 'success') + return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi liên kết hồ sơ: {str(e)}', 'error') + + # Lấy danh sách dietitian chưa được liên kết với user nào + unlinked_dietitians = Dietitian.query.filter(Dietitian.user_id.is_(None)).all() + + return render_template('dietitians/link_profile.html', unlinked_dietitians=unlinked_dietitians) \ No newline at end of file diff --git a/app/routes/patients.py b/app/routes/patients.py index 6d94893621ae0a9b5d545c803f9989a3999fb1a9..429cc4d0d62112d6175041d2c29323b5d9786bb7 100644 --- a/app/routes/patients.py +++ b/app/routes/patients.py @@ -1,16 +1,22 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app from flask_login import login_required, current_user +from flask_wtf import FlaskForm from app import db from app.models.patient import Patient, Encounter from app.models.measurement import PhysiologicalMeasurement from app.models.referral import Referral from app.models.procedure import Procedure from app.models.report import Report -from sqlalchemy import desc, or_ +from sqlalchemy import desc, or_, func from datetime import datetime +from app import csrf patients_bp = Blueprint('patients', __name__, url_prefix='/patients') +# Tạo một class Form đơn giản để xử lý CSRF +class EmptyForm(FlaskForm): + pass + @patients_bp.route('/') @login_required def index(): @@ -82,36 +88,38 @@ def index(): @patients_bp.route('/<string:patient_id>') @login_required def patient_detail(patient_id): - # Get patient by ID - patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() + """ + Route handler for viewing patient details + """ + patient = Patient.query.filter_by(id=patient_id).first_or_404() - # Get latest measurements - latest_measurement = PhysiologicalMeasurement.query.filter_by(patient_id=patient.id).order_by( - desc(PhysiologicalMeasurement.measurementDateTime) + # Lấy thông tin đo lường sinh lý cuối cùng + latest_measurement = PhysiologicalMeasurement.query.filter_by(patient_id=patient_id).order_by( + PhysiologicalMeasurement.measurementDateTime.desc() ).first() - # Get patient's measurements history - measurements = PhysiologicalMeasurement.query.filter_by(patient_id=patient.id).order_by( - PhysiologicalMeasurement.measurementDateTime + # Lấy lịch sử các phép đo sinh lý học + measurements = PhysiologicalMeasurement.query.filter_by(patient_id=patient_id).order_by( + PhysiologicalMeasurement.measurementDateTime.desc() ).all() # Get patient's referrals - referrals = Referral.query.filter_by(patient_id=patient.id).order_by( + referrals = Referral.query.filter_by(patient_id=patient_id).order_by( desc(Referral.referralRequestedDateTime) ).all() # Get patient's procedures - procedures = Procedure.query.filter_by(patient_id=patient.id).order_by( + procedures = Procedure.query.filter_by(patient_id=patient_id).order_by( desc(Procedure.procedureDateTime) ).all() # Get patient's reports - reports = Report.query.filter_by(patient_id=patient.id).order_by( + reports = Report.query.filter_by(patient_id=patient_id).order_by( desc(Report.report_date) ).all() # Get patient's encounters - encounters = Encounter.query.filter_by(patient_id=patient.id).order_by( + encounters = Encounter.query.filter_by(patientID=patient_id).order_by( desc(Encounter.admissionDateTime) ).all() @@ -131,10 +139,10 @@ def patient_detail(patient_id): def new_patient(): if request.method == 'POST': # Get the highest patient ID and increment - highest_patient = Patient.query.order_by(desc(Patient.patient_id)).first() - if highest_patient and highest_patient.patient_id.startswith('P-'): + highest_patient = Patient.query.order_by(desc(Patient.id)).first() + if highest_patient and highest_patient.id.startswith('P-'): try: - highest_num = int(highest_patient.patient_id.split('-')[1]) + highest_num = int(highest_patient.id.split('-')[1]) new_id = f'P-{highest_num + 1}' except ValueError: new_id = 'P-10001' @@ -143,7 +151,7 @@ def new_patient(): # Create new patient new_patient = Patient( - patient_id=new_id, + id=new_id, firstName=request.form.get('firstName'), lastName=request.form.get('lastName'), age=int(request.form.get('age')) if request.form.get('age') else None, @@ -154,6 +162,10 @@ def new_patient(): admission_date=datetime.now() ) + # Tính BMI nếu có chiều cao và cân nặng + if new_patient.height and new_patient.weight: + new_patient.bmi = new_patient.calculate_bmi() + db.session.add(new_patient) db.session.commit() @@ -165,7 +177,7 @@ def new_patient(): @patients_bp.route('/<string:patient_id>/edit', methods=['GET', 'POST']) @login_required def edit_patient(patient_id): - patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() + patient = Patient.query.filter_by(id=patient_id).first_or_404() if request.method == 'POST': # Update patient @@ -249,3 +261,554 @@ def new_referral(patient_id): return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) return render_template('new_referral.html', patient=patient) + +@patients_bp.route('/<string:patient_id>/measurements/ajax', methods=['POST']) +@login_required +def ajax_measurement(patient_id): + patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() + + if request.is_json: + data = request.get_json() + + # Lấy thông tin encounter hiện tại hoặc tạo mới + encounter = Encounter.query.filter_by(patient_id=patient.id).order_by( + desc(Encounter.admissionDateTime) + ).first() + + if not encounter: + # Tạo encounter mới nếu không có + encounter = Encounter( + patient_id=patient.id, + admissionDateTime=datetime.now() + ) + db.session.add(encounter) + db.session.commit() + + # Tạo đối tượng đo lường mới + measurement = PhysiologicalMeasurement( + patient_id=patient.id, + encounter_id=encounter.id, + measurementDateTime=datetime.now(), + heart_rate=data.get('heart_rate'), + blood_pressure_systolic=data.get('blood_pressure_systolic'), + blood_pressure_diastolic=data.get('blood_pressure_diastolic'), + oxygen_saturation=data.get('oxygen_saturation'), + temperature=data.get('temperature'), + fio2=data.get('fio2'), + fio2_ratio=data.get('fio2_ratio'), + tidal_vol=data.get('tidal_vol'), + tidal_vol_kg=data.get('tidal_vol_kg'), + tidal_vol_actual=data.get('tidal_vol_actual'), + tidal_vol_spon=data.get('tidal_vol_spon'), + end_tidal_co2=data.get('end_tidal_co2'), + feed_vol=data.get('feed_vol'), + feed_vol_adm=data.get('feed_vol_adm'), + peep=data.get('peep'), + pip=data.get('pip'), + resp_rate=data.get('resp_rate'), + sip=data.get('sip'), + insp_time=data.get('insp_time'), + oxygen_flow_rate=data.get('oxygen_flow_rate') + ) + + db.session.add(measurement) + db.session.commit() + + return jsonify({ + 'success': True, + 'measurement_id': measurement.id, + 'message': 'Đã cập nhật thông số sinh lý thành công' + }), 200 + + return jsonify({ + 'success': False, + 'message': 'Dữ liệu không hợp lệ' + }), 400 + +@patients_bp.route('/<string:patient_id>/measurements/csv', methods=['POST']) +@login_required +def upload_measurements_csv(patient_id): + patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() + + if 'csv_file' not in request.files: + return jsonify({ + 'success': False, + 'message': 'Không tìm thấy file CSV' + }), 400 + + csv_file = request.files['csv_file'] + + if csv_file.filename == '': + return jsonify({ + 'success': False, + 'message': 'Không có file nào được chọn' + }), 400 + + if csv_file and csv_file.filename.endswith('.csv'): + try: + # Xử lý file CSV và lưu dữ liệu vào cơ sở dữ liệu + # (Bạn có thể thêm mã xử lý CSV ở đây) + + return jsonify({ + 'success': True, + 'message': 'Đã tải lên và xử lý file CSV thành công' + }), 200 + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'Lỗi khi xử lý file: {str(e)}' + }), 500 + + return jsonify({ + 'success': False, + 'message': 'File không đúng định dạng CSV' + }), 400 + +@patients_bp.route('/<string:patient_id>/physical_measurements', methods=['GET']) +@login_required +def physical_measurements(patient_id): + """Hiển thị danh sách các phép đo thể chất của bệnh nhân""" + patient = Patient.query.filter_by(id=patient_id).first_or_404() + + # Lấy danh sách các phép đo thể chất + measurements_query = PhysiologicalMeasurement.query.filter_by(patient_id=patient.id).order_by( + PhysiologicalMeasurement.measurementDateTime.asc() # Sắp xếp tăng dần theo thời gian + ).all() + + # Chuyển đổi measurements thành list of dicts để JSON serialize + measurements_list = [] + latest_bp = None + latest_measurement = {} + + for m in measurements_query: + # Chuẩn bị dữ liệu cho biểu đồ + measurement_data = { + 'measurementDateTime': m.measurementDateTime.isoformat() if m.measurementDateTime else None, + 'temperature': m.temperature, + 'heart_rate': m.heart_rate, + 'blood_pressure_systolic': m.blood_pressure_systolic, + 'blood_pressure_diastolic': m.blood_pressure_diastolic, + 'oxygen_saturation': m.oxygen_saturation, + 'resp_rate': m.resp_rate, + 'fio2': m.fio2, + 'fio2_ratio': m.fio2_ratio, + 'tidal_vol': m.tidal_vol, + 'tidal_vol_kg': m.tidal_vol_kg, + 'tidal_vol_actual': m.tidal_vol_actual, + 'tidal_vol_spon': m.tidal_vol_spon, + 'end_tidal_co2': m.end_tidal_co2, + 'feed_vol': m.feed_vol, + 'feed_vol_adm': m.feed_vol_adm, + 'peep': m.peep, + 'pip': m.pip, + 'sip': m.sip, + 'insp_time': m.insp_time, + 'oxygen_flow_rate': m.oxygen_flow_rate + } + + measurements_list.append(measurement_data) + + # Cập nhật latest values + for key, value in measurement_data.items(): + if key != 'measurementDateTime' and value is not None: + latest_measurement[key] = value + + # Lưu giá trị huyết áp gần nhất + if m.blood_pressure_systolic is not None and m.blood_pressure_diastolic is not None: + latest_bp = { + 'systolicBP': m.blood_pressure_systolic, + 'diastolicBP': m.blood_pressure_diastolic, + 'time': m.measurementDateTime + } + + return render_template( + 'physical_measurements.html', + patient=patient, + measurements=measurements_list, + latest_measurement=latest_measurement, + latest_bp=latest_bp + ) + +@patients_bp.route('/<string:patient_id>/physical_measurements/new', methods=['GET', 'POST']) +@login_required +def new_physical_measurement(patient_id): + patient = Patient.query.filter_by(id=patient_id).first_or_404() + form = EmptyForm() + + if form.validate_on_submit(): + encounter = Encounter.query.filter_by( + patientID=patient.id, + dischargeDateTime=None + ).order_by(desc(Encounter.admissionDateTime)).first() + + if not encounter: + flash('Không tìm thấy encounter đang hoạt động cho bệnh nhân. Vui lòng kiểm tra lại.', 'error') + return render_template('new_physical_measurement.html', patient=patient, form=form) + + # Hàm trợ giúp để lấy giá trị float từ form + def get_float_or_none(field_name): + val = request.form.get(field_name) + try: + return float(val) if val else None + except ValueError: + return None + + # Hàm trợ giúp để lấy giá trị int từ form + def get_int_or_none(field_name): + val = request.form.get(field_name) + try: + return int(val) if val else None + except ValueError: + return None + + measurement = PhysiologicalMeasurement( + patient_id=patient.id, + encounter_id=encounter.id, + measurementDateTime=datetime.now(), + temperature=get_float_or_none('temperature'), + heart_rate=get_int_or_none('heart_rate'), + blood_pressure_systolic=get_int_or_none('blood_pressure_systolic'), + blood_pressure_diastolic=get_int_or_none('blood_pressure_diastolic'), + oxygen_saturation=get_float_or_none('oxygen_saturation'), + resp_rate=get_int_or_none('resp_rate'), + fio2=get_float_or_none('fio2'), + fio2_ratio=get_float_or_none('fio2_ratio'), + peep=get_float_or_none('peep'), + pip=get_float_or_none('pip'), + sip=get_float_or_none('sip'), + insp_time=get_float_or_none('insp_time'), + oxygen_flow_rate=get_float_or_none('oxygen_flow_rate'), + end_tidal_co2=get_float_or_none('end_tidal_co2'), + tidal_vol=get_float_or_none('tidal_vol'), + tidal_vol_kg=get_float_or_none('tidal_vol_kg'), + tidal_vol_actual=get_float_or_none('tidal_vol_actual'), + tidal_vol_spon=get_float_or_none('tidal_vol_spon'), + feed_vol=get_float_or_none('feed_vol'), + feed_vol_adm=get_float_or_none('feed_vol_adm'), + notes=request.form.get('notes') + ) + + try: + db.session.add(measurement) + db.session.commit() + flash('Đã thêm phép đo thể chất thành công.', 'success') + return redirect(url_for('patients.physical_measurements', patient_id=patient.id)) + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi thêm đo lường: {str(e)}', 'error') + return render_template('new_physical_measurement.html', patient=patient, form=form) + + return render_template('new_physical_measurement.html', patient=patient, form=form) + +@patients_bp.route('/<string:patient_id>/physical_measurements/<int:measurement_id>/edit', methods=['GET', 'POST']) +@login_required +def edit_physical_measurement(patient_id, measurement_id): + patient = Patient.query.filter_by(id=patient_id).first_or_404() + measurement = PhysiologicalMeasurement.query.get_or_404(measurement_id) + + if measurement.patient_id != patient.id: + flash('Bạn không có quyền chỉnh sửa phép đo này.', 'error') + return redirect(url_for('patients.physical_measurements', patient_id=patient.id)) + + if request.method == 'POST': + # Hàm trợ giúp để lấy giá trị float từ form, trả về giá trị cũ nếu không hợp lệ + def get_float_or_current(field_name, current_value): + val = request.form.get(field_name) + if val is None or val == '': return current_value # Giữ giá trị cũ nếu không có input + try: + return float(val) + except ValueError: + return current_value # Giữ giá trị cũ nếu input không hợp lệ + + # Hàm trợ giúp để lấy giá trị int từ form, trả về giá trị cũ nếu không hợp lệ + def get_int_or_current(field_name, current_value): + val = request.form.get(field_name) + if val is None or val == '': return current_value + try: + return int(val) + except ValueError: + return current_value + + measurement.temperature = get_float_or_current('temperature', measurement.temperature) + measurement.heart_rate = get_int_or_current('heart_rate', measurement.heart_rate) + measurement.blood_pressure_systolic = get_int_or_current('blood_pressure_systolic', measurement.blood_pressure_systolic) + measurement.blood_pressure_diastolic = get_int_or_current('blood_pressure_diastolic', measurement.blood_pressure_diastolic) + measurement.oxygen_saturation = get_float_or_current('oxygen_saturation', measurement.oxygen_saturation) + measurement.resp_rate = get_int_or_current('resp_rate', measurement.resp_rate) + measurement.fio2 = get_float_or_current('fio2', measurement.fio2) + measurement.fio2_ratio = get_float_or_current('fio2_ratio', measurement.fio2_ratio) + measurement.peep = get_float_or_current('peep', measurement.peep) + measurement.pip = get_float_or_current('pip', measurement.pip) + measurement.sip = get_float_or_current('sip', measurement.sip) + measurement.insp_time = get_float_or_current('insp_time', measurement.insp_time) + measurement.oxygen_flow_rate = get_float_or_current('oxygen_flow_rate', measurement.oxygen_flow_rate) + measurement.end_tidal_co2 = get_float_or_current('end_tidal_co2', measurement.end_tidal_co2) + measurement.tidal_vol = get_float_or_current('tidal_vol', measurement.tidal_vol) + measurement.tidal_vol_kg = get_float_or_current('tidal_vol_kg', measurement.tidal_vol_kg) + measurement.tidal_vol_actual = get_float_or_current('tidal_vol_actual', measurement.tidal_vol_actual) + measurement.tidal_vol_spon = get_float_or_current('tidal_vol_spon', measurement.tidal_vol_spon) + measurement.feed_vol = get_float_or_current('feed_vol', measurement.feed_vol) + measurement.feed_vol_adm = get_float_or_current('feed_vol_adm', measurement.feed_vol_adm) + # Chỉ cập nhật notes nếu có giá trị mới được gửi từ form + new_notes = request.form.get('notes') + if new_notes is not None: + measurement.notes = new_notes + + measurement.updated_at = datetime.utcnow() + + try: + db.session.commit() + flash('Đã cập nhật phép đo thể chất thành công.', 'success') + return redirect(url_for('patients.physical_measurements', patient_id=patient.id)) + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi cập nhật đo lường: {str(e)}', 'error') + + return render_template('edit_physical_measurement.html', patient=patient, measurement=measurement) + +@patients_bp.route('/<string:patient_id>/physical_measurements/<int:measurement_id>/delete', methods=['POST']) +@login_required +def delete_physical_measurement(patient_id, measurement_id): + """Xóa một phép đo thể chất""" + patient = Patient.query.filter_by(id=patient_id).first_or_404() + measurement = PhysiologicalMeasurement.query.get_or_404(measurement_id) + + # Kiểm tra quyền sở hữu + if measurement.patient_id != patient.id: + flash('Bạn không có quyền xóa phép đo này.', 'error') + return redirect(url_for('patients.physical_measurements', patient_id=patient.id)) + + try: + db.session.delete(measurement) + db.session.commit() + flash('Đã xóa phép đo thể chất thành công.', 'success') + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi xóa đo lường: {str(e)}', 'error') + + return redirect(url_for('patients.physical_measurements', patient_id=patient.id)) + +@patients_bp.route('/<string:patient_id>/measurements/quick_save', methods=['POST']) +@login_required +@csrf.exempt +def quick_save_measurement(patient_id): + """API endpoint để lưu nhanh một giá trị đo lường""" + try: + data = request.get_json() + if not data or 'values' not in data: + current_app.logger.error(f"Quick save - Dữ liệu không hợp lệ hoặc thiếu key 'values': {request.data}") + return jsonify({'success': False, 'message': 'Dữ liệu không hợp lệ'}), 400 + + values_dict = data['values'] + current_app.logger.info(f"Quick save - Nhận dữ liệu cho patient {patient_id}: {values_dict}") + + patient = Patient.query.get_or_404(patient_id) + + # Tìm encounter đang hoạt động của bệnh nhân + encounter = Encounter.query.filter_by( + patientID=patient.id, + dischargeDateTime=None + ).order_by(desc(Encounter.admissionDateTime)).first() + + if not encounter: + current_app.logger.warning(f"Quick save - Không tìm thấy encounter hoạt động cho patient {patient_id}") + # Tạo encounter mới thay vì báo lỗi + encounter = Encounter( + patientID=patient.id, + admissionDateTime=datetime.now() + ) + db.session.add(encounter) + db.session.commit() + current_app.logger.info(f"Quick save - Đã tạo encounter mới cho patient {patient_id}: {encounter.id}") + + # Tạo một đo lường mới với thời gian hiện tại + measurement_time = datetime.now() + new_measurement = PhysiologicalMeasurement( + patient_id=patient.id, + encounter_id=encounter.id, + measurementDateTime=measurement_time + ) + + # Gán giá trị vào các trường tương ứng + field_map = { + 'temperature': ('temperature', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'heart_rate': ('heart_rate', lambda key: int(values_dict.get(key)) if values_dict.get(key) else None), + 'blood_pressure_systolic': ('blood_pressure_systolic', lambda key: int(values_dict.get(key)) if values_dict.get(key) else None), + 'blood_pressure_diastolic': ('blood_pressure_diastolic', lambda key: int(values_dict.get(key)) if values_dict.get(key) else None), + 'oxygen_saturation': ('oxygen_saturation', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'resp_rate': ('resp_rate', lambda key: int(values_dict.get(key)) if values_dict.get(key) else None), + 'fio2': ('fio2', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'fio2_ratio': ('fio2_ratio', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'peep': ('peep', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'pip': ('pip', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'sip': ('sip', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'insp_time': ('insp_time', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'oxygen_flow_rate': ('oxygen_flow_rate', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'end_tidal_co2': ('end_tidal_co2', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'tidal_vol': ('tidal_vol', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'tidal_vol_kg': ('tidal_vol_kg', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'tidal_vol_actual': ('tidal_vol_actual', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'tidal_vol_spon': ('tidal_vol_spon', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'feed_vol': ('feed_vol', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None), + 'feed_vol_adm': ('feed_vol_adm', lambda key: float(values_dict.get(key)) if values_dict.get(key) else None) + } + + # Cập nhật các giá trị từ dữ liệu gửi lên + for key in values_dict: + if key in field_map: + field_name, conversion_func = field_map[key] + setattr(new_measurement, field_name, conversion_func(key)) + + db.session.add(new_measurement) + db.session.commit() + + # Chuẩn bị dữ liệu trả về + returned_data = {} + for key in values_dict.keys(): + if key in field_map: + field_name = field_map[key][0] + value = getattr(new_measurement, field_name) + returned_data[key] = value + + returned_data['measurementDateTime'] = measurement_time.isoformat() + + current_app.logger.info(f"Quick save - Đã lưu thành công cho patient {patient_id}: {returned_data}") + return jsonify({ + 'success': True, + 'message': 'Đã lưu thành công', + 'new_measurement_data': returned_data + }) + + except Exception as e: + # Ghi log lỗi chi tiết hơn + current_app.logger.error(f"Lỗi khi lưu đo lường nhanh cho patient {patient_id}. Data: {request.data}. Lỗi: {str(e)}", exc_info=True) + db.session.rollback() + return jsonify({'success': False, 'message': f'Lỗi máy chủ: {str(e)}'}), 500 + +@patients_bp.route('/<string:patient_id>/measurements/data', methods=['GET']) +@login_required +@csrf.exempt +def get_measurement_data(patient_id): + """API endpoint để lấy dữ liệu đo lường cho biểu đồ""" + try: + patient = Patient.query.get_or_404(patient_id) + + # Lấy tất cả đo lường của bệnh nhân, sắp xếp theo thời gian + measurements = PhysiologicalMeasurement.query.filter_by( + patient_id=patient_id + ).order_by( + PhysiologicalMeasurement.measurementDateTime + ).all() + + # Chuẩn bị dữ liệu cho từng loại biểu đồ + heart_rate_data = [] + temperature_data = [] + blood_pressure_data = [] + oxygen_saturation_data = [] + respiratory_rate_data = [] + fio2_data = [] + fio2_ratio_data = [] + peep_data = [] + pip_data = [] + sip_data = [] + insp_time_data = [] + oxygen_flow_rate_data = [] + end_tidal_co2_data = [] + tidal_vol_data = [] + tidal_vol_kg_data = [] + tidal_vol_actual_data = [] + tidal_vol_spon_data = [] + feed_vol_data = [] + feed_vol_adm_data = [] + + # Lọc và định dạng dữ liệu cho từng biểu đồ + for m in measurements: + time = m.measurementDateTime.isoformat() if m.measurementDateTime else None + + if m.heart_rate is not None: + heart_rate_data.append({'x': time, 'y': m.heart_rate}) + + if m.temperature is not None: + temperature_data.append({'x': time, 'y': m.temperature}) + + if m.blood_pressure_systolic is not None and m.blood_pressure_diastolic is not None: + blood_pressure_data.append({ + 'x': time, + 'systolic': m.blood_pressure_systolic, + 'diastolic': m.blood_pressure_diastolic + }) + + if m.oxygen_saturation is not None: + oxygen_saturation_data.append({'x': time, 'y': m.oxygen_saturation}) + + if m.resp_rate is not None: + respiratory_rate_data.append({'x': time, 'y': m.resp_rate}) + + if m.fio2 is not None: + fio2_data.append({'x': time, 'y': m.fio2}) + + if m.fio2_ratio is not None: + fio2_ratio_data.append({'x': time, 'y': m.fio2_ratio}) + + if m.peep is not None: + peep_data.append({'x': time, 'y': m.peep}) + + if m.pip is not None: + pip_data.append({'x': time, 'y': m.pip}) + + if m.sip is not None: + sip_data.append({'x': time, 'y': m.sip}) + + if m.insp_time is not None: + insp_time_data.append({'x': time, 'y': m.insp_time}) + + if m.oxygen_flow_rate is not None: + oxygen_flow_rate_data.append({'x': time, 'y': m.oxygen_flow_rate}) + + if m.end_tidal_co2 is not None: + end_tidal_co2_data.append({'x': time, 'y': m.end_tidal_co2}) + + if m.tidal_vol is not None: + tidal_vol_data.append({'x': time, 'y': m.tidal_vol}) + + if m.tidal_vol_kg is not None: + tidal_vol_kg_data.append({'x': time, 'y': m.tidal_vol_kg}) + + if m.tidal_vol_actual is not None: + tidal_vol_actual_data.append({'x': time, 'y': m.tidal_vol_actual}) + + if m.tidal_vol_spon is not None: + tidal_vol_spon_data.append({'x': time, 'y': m.tidal_vol_spon}) + + if m.feed_vol is not None: + feed_vol_data.append({'x': time, 'y': m.feed_vol}) + + if m.feed_vol_adm is not None: + feed_vol_adm_data.append({'x': time, 'y': m.feed_vol_adm}) + + # Trả về dữ liệu cho tất cả các biểu đồ + return jsonify({ + 'heartRate': heart_rate_data, + 'temperature': temperature_data, + 'bloodPressure': blood_pressure_data, + 'oxygenSaturation': oxygen_saturation_data, + 'respiratoryRate': respiratory_rate_data, + 'fio2': fio2_data, + 'fio2Ratio': fio2_ratio_data, + 'peep': peep_data, + 'pip': pip_data, + 'sip': sip_data, + 'inspTime': insp_time_data, + 'oxygenFlowRate': oxygen_flow_rate_data, + 'endTidalCO2': end_tidal_co2_data, + 'tidalVol': tidal_vol_data, + 'tidalVolKg': tidal_vol_kg_data, + 'tidalVolActual': tidal_vol_actual_data, + 'tidalVolSpon': tidal_vol_spon_data, + 'feedVol': feed_vol_data, + 'feedVolAdm': feed_vol_adm_data + }) + except Exception as e: + # Ghi log lỗi + current_app.logger.error(f"Lỗi khi lấy dữ liệu đo lường: {str(e)}") + return jsonify({'success': False, 'message': f'Lỗi máy chủ: {str(e)}'}), 500 diff --git a/app/templates/_macros.html b/app/templates/_macros.html new file mode 100644 index 0000000000000000000000000000000000000000..a4a25637e451fbec23b7bb497e039a25db3e113f --- /dev/null +++ b/app/templates/_macros.html @@ -0,0 +1,132 @@ +{% macro render_pagination(pagination, endpoint, search=none, status=none, sort=none, direction=none) %} + <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> + {% if pagination.has_prev %} + <a href="{{ url_for(endpoint, page=pagination.prev_num, search=search, status=status, sort=sort, direction=direction) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> + <span class="sr-only">Trang trước</span> + <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /> + </svg> + </a> + {% else %} + <span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed"> + <span class="sr-only">Trang trước</span> + <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /> + </svg> + </span> + {% endif %} + + {# Tính toán dãy trang để hiển thị #} + {% set start_page = [pagination.page - 2, 1]|max %} + {% set end_page = [start_page + 4, pagination.pages]|min %} + {% set start_page = [end_page - 4, 1]|max %} + + {% for page in range(start_page, end_page + 1) %} + {% if page == pagination.page %} + <span class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 text-sm font-medium text-primary-700">{{ page }}</span> + {% else %} + <a href="{{ url_for(endpoint, page=page, search=search, status=status, sort=sort, direction=direction) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">{{ page }}</a> + {% endif %} + {% endfor %} + + {% if pagination.has_next %} + <a href="{{ url_for(endpoint, page=pagination.next_num, search=search, status=status, sort=sort, direction=direction) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> + <span class="sr-only">Trang sau</span> + <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> + </svg> + </a> + {% else %} + <span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed"> + <span class="sr-only">Trang sau</span> + <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> + </svg> + </span> + {% endif %} + </nav> +{% endmacro %} + +{% macro status_badge(status) %} + {% if status == 'available' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> + Sẵn sàng + </span> + {% elif status == 'unavailable' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> + Không khả dụng + </span> + {% elif status == 'on_leave' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"> + Đang nghỉ + </span> + {% else %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"> + {{ status|title }} + </span> + {% endif %} +{% endmacro %} + +{% macro patient_status_badge(status) %} + {% if status == 'admitted' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> + Đã nhập viện + </span> + {% elif status == 'discharged' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800"> + Đã xuất viện + </span> + {% elif status == 'transferred' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"> + Đã chuyển viện + </span> + {% elif status == 'deceased' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> + Đã mất + </span> + {% else %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"> + {{ status|title }} + </span> + {% endif %} +{% endmacro %} + +{% macro datetime_format(date_obj) %} + {% if date_obj %} + {{ date_obj.strftime('%d/%m/%Y %H:%M') }} + {% else %} + N/A + {% endif %} +{% endmacro %} + +{% macro date_format(date_obj) %} + {% if date_obj %} + {{ date_obj.strftime('%d/%m/%Y') }} + {% else %} + N/A + {% endif %} +{% endmacro %} + +{% macro form_field(field, label_classes="", field_classes="", show_errors=true) %} + <div class="mb-4"> + {{ field.label(class="block text-sm font-medium text-gray-700 " + label_classes) }} + <div class="mt-1"> + {% if field.type == 'BooleanField' %} + {{ field(class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded " + field_classes) }} + {% elif field.type == 'TextAreaField' %} + {{ field(class="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md " + field_classes) }} + {% elif field.type == 'SelectField' %} + {{ field(class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm " + field_classes) }} + {% else %} + {{ field(class="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md " + field_classes) }} + {% endif %} + </div> + {% if show_errors and field.errors %} + <div class="text-red-600 text-sm mt-1"> + {% for error in field.errors %} + <p>{{ error }}</p> + {% endfor %} + </div> + {% endif %} + </div> +{% endmacro %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 79224c217256ceffcb4daf5d0aa522e5ad35b177..a27125ffa20a9cfb10b1580db1aad9bbf7618483 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,11 +3,13 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="csrf-token" content="{{ csrf_token }}"> <title>{% block title %}CCU HTM{% endblock %}</title> <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/> + {% block head %}{% endblock %} <style> /* Core styles */ :root { @@ -247,18 +249,23 @@ </a> </li> <li> - <a href="{{ url_for('patients.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('patients.') %}active{% endif %}"> - <i class="fas fa-user-injured mr-3 text-lg"></i> <span class="nav-text">Patients</span> + <a href="{{ url_for('patients.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('patients.') and request.endpoint != 'patients.physical_measurements' %}active{% endif %}"> + <i class="fas fa-user-injured mr-3 text-lg"></i> <span class="nav-text">Patient</span> </a> </li> <li> - <a href="{{ url_for('upload.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('upload.') %}active{% endif %}"> - <i class="fas fa-upload mr-3 text-lg"></i> <span class="nav-text">Upload CSV</span> + <a href="{{ url_for('report.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('report.') %}active{% endif %}"> + <i class="fas fa-chart-bar mr-3 text-lg"></i> <span class="nav-text">Reports</span> </a> </li> <li> - <a href="{{ url_for('report.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('report.') %}active{% endif %}"> - <i class="fas fa-chart-bar mr-3 text-lg"></i> <span class="nav-text">Reports</span> + <a href="{{ url_for('dietitians.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('dietitians.') %}active{% endif %}"> + <i class="fas fa-user-md mr-3 text-lg"></i> <span class="nav-text">Dietitians</span> + </a> + </li> + <li> + <a href="{{ url_for('upload.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('upload.') %}active{% endif %}"> + <i class="fas fa-upload mr-3 text-lg"></i> <span class="nav-text">Upload CSV</span> </a> </li> {% if current_user.is_admin %} @@ -409,6 +416,13 @@ </div> </div> + <!-- Footer --> + <footer class="bg-white py-6 mt-12"> + <div class="container mx-auto px-4"> + <p class="text-center text-gray-500 text-sm">© {{ current_year|default(2023) }} CCU HTM - Hệ thống dinh dưỡng đơn vị chăm sóc tích cực</p> + </div> + </footer> + <script> // Common JS for all pages document.addEventListener('DOMContentLoaded', function() { diff --git a/app/templates/dietitians/create.html b/app/templates/dietitians/create.html new file mode 100644 index 0000000000000000000000000000000000000000..45671da57daf9a2c26b6ef651bc89696077d2db9 --- /dev/null +++ b/app/templates/dietitians/create.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block title %}Thêm chuyên gia dinh dưỡng - CCU HTM{% endblock %} + +{% block header %}Thêm chuyên gia dinh dưỡng mới{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <div class="mb-6"> + <nav class="flex" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('dietitians.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Chuyên gia dinh dưỡng</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Thêm mới</span> + </div> + </li> + </ol> + </nav> + </div> + + <div class="max-w-4xl mx-auto"> + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> + <h3 class="text-lg leading-6 font-medium text-gray-900">Thông tin chuyên gia dinh dưỡng</h3> + <p class="mt-1 max-w-2xl text-sm text-gray-500">Nhập thông tin chi tiết cho chuyên gia dinh dưỡng mới.</p> + </div> + + <form method="POST" action="{{ url_for('dietitians.store') }}" class="px-4 py-5 sm:p-6"> + <div class="grid grid-cols-6 gap-6"> + <div class="col-span-6 sm:col-span-3"> + <label for="firstName" class="block text-sm font-medium text-gray-700">Tên</label> + <input type="text" name="firstName" id="firstName" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="lastName" class="block text-sm font-medium text-gray-700">Họ</label> + <input type="text" name="lastName" id="lastName" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="email" class="block text-sm font-medium text-gray-700">Email</label> + <input type="email" name="email" id="email" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="phone" class="block text-sm font-medium text-gray-700">Số điện thoại</label> + <input type="text" name="phone" id="phone" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + + <div class="col-span-6"> + <label for="specialization" class="block text-sm font-medium text-gray-700">Chuyên môn</label> + <input type="text" name="specialization" id="specialization" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + + <div class="col-span-6"> + <label for="notes" class="block text-sm font-medium text-gray-700">Ghi chú</label> + <textarea name="notes" id="notes" rows="3" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"></textarea> + </div> + + <div class="col-span-6"> + <label for="status" class="block text-sm font-medium text-gray-700">Trạng thái</label> + <select id="status" name="status" class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"> + <option value="available" selected>Khả dụng</option> + <option value="unavailable">Không khả dụng</option> + </select> + </div> + </div> + + <div class="pt-5 mt-4 border-t border-gray-200"> + <div class="flex justify-end"> + <a href="{{ url_for('dietitians.index') }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + Hủy + </a> + <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + Thêm mới + </button> + </div> + </div> + </form> + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/detail.html b/app/templates/dietitians/detail.html new file mode 100644 index 0000000000000000000000000000000000000000..abee12d3be960d75049aba0c191b4e26670964a0 --- /dev/null +++ b/app/templates/dietitians/detail.html @@ -0,0 +1,173 @@ +{% extends "base.html" %} + +{% block title %}{{ dietitian.full_name }} - Chi tiết chuyên gia dinh dưỡng{% endblock %} + +{% block header %}Chi tiết chuyên gia dinh dưỡng{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <div class="mb-6"> + <nav class="flex" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('dietitians.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Chuyên gia dinh dưỡng</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">{{ dietitian.full_name }}</span> + </div> + </li> + </ol> + </nav> + </div> + + <!-- Action Buttons --> + <div class="flex justify-between mb-6"> + <div class="flex items-center"> + <span class="text-gray-600 text-sm">ID: {{ dietitian.id }}</span> + </div> + <div class="flex space-x-3"> + <a href="{{ url_for('dietitians.edit', dietitian_id=dietitian.id) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + <svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> + </svg> + Sửa thông tin + </a> + <form method="POST" action="{{ url_for('dietitians.toggle_status', dietitian_id=dietitian.id) }}" class="inline-block"> + <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white {{ 'bg-red-600 hover:bg-red-700' if dietitian.status == 'available' else 'bg-green-600 hover:bg-green-700' }} focus:outline-none focus:ring-2 focus:ring-offset-2 {{ 'focus:ring-red-500' if dietitian.status == 'available' else 'focus:ring-green-500' }} transition duration-200"> + <svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2' if dietitian.status == 'available' else 'M5 13l4 4L19 7' }}" /> + </svg> + {{ 'Đánh dấu không khả dụng' if dietitian.status == 'available' else 'Đánh dấu khả dụng' }} + </button> + </form> + </div> + </div> + + <!-- Main Content --> + <div class="grid grid-cols-1 gap-6 md:grid-cols-3"> + <!-- Dietitian Info Card --> + <div class="bg-white shadow rounded-lg p-6 col-span-1"> + <div class="flex items-center mb-6"> + <div class="h-20 w-20 flex items-center justify-center bg-primary-100 rounded-full"> + <span class="text-primary-700 text-2xl font-semibold">{{ dietitian.firstName[0] }}{{ dietitian.lastName[0] }}</span> + </div> + <div class="ml-4"> + <h3 class="text-lg font-medium text-gray-900">{{ dietitian.full_name }}</h3> + <div class="mt-1 flex items-center"> + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800' if dietitian.status == 'available' else 'bg-red-100 text-red-800' }}"> + {{ 'Khả dụng' if dietitian.status == 'available' else 'Không khả dụng' }} + </span> + </div> + </div> + </div> + + <div class="border-t border-gray-200 pt-4"> + <dl class="grid grid-cols-1 gap-x-4 gap-y-6"> + <div> + <dt class="text-sm font-medium text-gray-500">Email</dt> + <dd class="mt-1 text-sm text-gray-900">{{ dietitian.email }}</dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Số điện thoại</dt> + <dd class="mt-1 text-sm text-gray-900">{{ dietitian.phone or 'Không có' }}</dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Chuyên môn</dt> + <dd class="mt-1 text-sm text-gray-900">{{ dietitian.specialization or 'Không có' }}</dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Số bệnh nhân</dt> + <dd class="mt-1 text-sm text-gray-900">{{ dietitian.patients|length }}</dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Ghi chú</dt> + <dd class="mt-1 text-sm text-gray-900">{{ dietitian.notes or 'Không có ghi chú' }}</dd> + </div> + </dl> + </div> + </div> + + <!-- Patients List --> + <div class="bg-white shadow rounded-lg p-6 col-span-2"> + <h2 class="text-lg font-medium text-gray-900 mb-4">Danh sách bệnh nhân</h2> + + {% if dietitian.patients %} + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bệnh nhân</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tuổi/Giới tính</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">BMI</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for patient in dietitian.patients %} + <tr class="hover:bg-gray-50 transition duration-150"> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="flex items-center"> + <div class="text-sm font-medium text-gray-900">{{ patient.full_name }}</div> + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="text-sm text-gray-900">{{ patient.age }} tuổi, {{ 'Nam' if patient.gender == 'male' else 'Nữ' if patient.gender == 'female' else 'Khác' }}</div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="text-sm text-gray-900"> + {% if patient.bmi %} + {{ patient.bmi|round(1) }} + <span class="ml-1 px-2 inline-flex text-xs leading-5 font-semibold rounded-full + {% if patient.bmi < 18.5 %}bg-blue-100 text-blue-800 + {% elif patient.bmi < 25 %}bg-green-100 text-green-800 + {% elif patient.bmi < 30 %}bg-yellow-100 text-yellow-800 + {% else %}bg-red-100 text-red-800{% endif %}"> + {% if patient.bmi < 18.5 %}Thiếu cân + {% elif patient.bmi < 25 %}Bình thường + {% elif patient.bmi < 30 %}Thừa cân + {% else %}Béo phì{% endif %} + </span> + {% else %} + Không có + {% endif %} + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800' if patient.status == 'active' else 'bg-red-100 text-red-800' }}"> + {{ 'Đang điều trị' if patient.status == 'active' else 'Không hoạt động' }} + </span> + </td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="{{ url_for('patients.detail', patient_id=patient.id) }}" class="text-primary-600 hover:text-primary-900 transition duration-200">Xem</a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% else %} + <div class="text-center py-10 border border-gray-200 rounded-lg"> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> + </svg> + <h3 class="mt-2 text-sm font-medium text-gray-900">Không có bệnh nhân</h3> + <p class="mt-1 text-sm text-gray-500">Chuyên gia dinh dưỡng này chưa được gán bệnh nhân nào.</p> + </div> + {% endif %} + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/edit.html b/app/templates/dietitians/edit.html new file mode 100644 index 0000000000000000000000000000000000000000..3d248a1708d5ae339f4bb4e517dc6ebb961dd8c5 --- /dev/null +++ b/app/templates/dietitians/edit.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block title %}Chỉnh sửa chuyên gia dinh dưỡng - CCU HTM{% endblock %} + +{% block header %}Chỉnh sửa thông tin chuyên gia dinh dưỡng{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <div class="mb-6"> + <nav class="flex" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('dietitians.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Chuyên gia dinh dưỡng</a> + </div> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('dietitians.show', id=dietitian.dietitianID) }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">{{ dietitian.fullName }}</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Chỉnh sửa</span> + </div> + </li> + </ol> + </nav> + </div> + + <div class="max-w-4xl mx-auto"> + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> + <h3 class="text-lg leading-6 font-medium text-gray-900">Chỉnh sửa thông tin chuyên gia dinh dưỡng</h3> + <p class="mt-1 max-w-2xl text-sm text-gray-500">Cập nhật thông tin chi tiết cho chuyên gia dinh dưỡng.</p> + </div> + + <form method="POST" action="{{ url_for('dietitians.update', id=dietitian.dietitianID) }}" class="px-4 py-5 sm:p-6"> + <div class="grid grid-cols-6 gap-6"> + <div class="col-span-6 sm:col-span-3"> + <label for="firstName" class="block text-sm font-medium text-gray-700">Tên</label> + <input type="text" name="firstName" id="firstName" value="{{ dietitian.firstName }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="lastName" class="block text-sm font-medium text-gray-700">Họ</label> + <input type="text" name="lastName" id="lastName" value="{{ dietitian.lastName }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="email" class="block text-sm font-medium text-gray-700">Email</label> + <input type="email" name="email" id="email" value="{{ dietitian.email }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="phone" class="block text-sm font-medium text-gray-700">Số điện thoại</label> + <input type="text" name="phone" id="phone" value="{{ dietitian.phone }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + + <div class="col-span-6"> + <label for="specialization" class="block text-sm font-medium text-gray-700">Chuyên môn</label> + <input type="text" name="specialization" id="specialization" value="{{ dietitian.specialization }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + + <div class="col-span-6"> + <label for="notes" class="block text-sm font-medium text-gray-700">Ghi chú</label> + <textarea name="notes" id="notes" rows="3" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md">{{ dietitian.notes }}</textarea> + </div> + + <div class="col-span-6"> + <label for="status" class="block text-sm font-medium text-gray-700">Trạng thái</label> + <select id="status" name="status" class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"> + <option value="available" {% if dietitian.status.value == 'available' %}selected{% endif %}>Khả dụng</option> + <option value="unavailable" {% if dietitian.status.value == 'unavailable' %}selected{% endif %}>Không khả dụng</option> + <option value="on_leave" {% if dietitian.status.value == 'on_leave' %}selected{% endif %}>Nghỉ phép</option> + </select> + </div> + </div> + + <div class="pt-5 mt-4 border-t border-gray-200"> + <div class="flex justify-end"> + <a href="{{ url_for('dietitians.show', id=dietitian.dietitianID) }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + Hủy + </a> + <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + Cập nhật + </button> + </div> + </div> + </form> + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/index.html b/app/templates/dietitians/index.html new file mode 100644 index 0000000000000000000000000000000000000000..70b3b350a785a7ac412343282df944a5d2d65165 --- /dev/null +++ b/app/templates/dietitians/index.html @@ -0,0 +1,223 @@ +{% extends "base.html" %} +{% from "_macros.html" import render_pagination, status_badge %} + +{% block title %}Chuyên gia dinh dưỡng - CCU HTM{% endblock %} + +{% block header %}Quản lý Chuyên gia dinh dưỡng{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <nav class="mb-6" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Chuyên gia dinh dưỡng</span> + </div> + </li> + </ol> + </nav> + + <div class="flex justify-between items-center mb-6"> + <h1 class="text-2xl font-semibold text-gray-900">Danh sách Chuyên gia dinh dưỡng</h1> + {% if current_user.is_admin %} + <a href="{{ url_for('dietitians.new') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + <svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> + </svg> + Thêm chuyên gia dinh dưỡng + </a> + {% endif %} + </div> + + <!-- Bộ lọc --> + <div class="bg-white shadow sm:rounded-lg mb-6 px-4 py-5 sm:px-6"> + <form method="GET" action="{{ url_for('dietitians.index') }}"> + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end"> + <div> + <label for="search" class="block text-sm font-medium text-gray-700">Tìm kiếm</label> + <input type="text" name="search" id="search" value="{{ search_query }}" placeholder="Tên, email, ID..." class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + <div> + <label for="status" class="block text-sm font-medium text-gray-700">Trạng thái</label> + <select id="status" name="status" class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"> + <option value="all" {% if not status_filter or status_filter == 'all' %}selected{% endif %}>Tất cả trạng thái</option> + {% for status_item in status_options %} + <option value="{{ status_item.name.lower() }}" {% if status_filter == status_item.name.lower() %}selected{% endif %}>{{ status_item.value.replace('_', ' ').title() }}</option> + {% endfor %} + </select> + </div> + <div class="flex space-x-2"> + <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200 w-full justify-center"> + Lọc + </button> + <a href="{{ url_for('dietitians.index') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200 w-full justify-center"> + Xóa lọc + </a> + </div> + </div> + </form> + </div> + + <!-- Bảng Dietitian Hiện Tại --> + {% if current_dietitian %} + <div class="mb-8 bg-gradient-to-r from-blue-50 to-indigo-50 shadow sm:rounded-lg border border-blue-200"> + <div class="px-4 py-5 sm:px-6"> + <h3 class="text-lg leading-6 font-medium text-gray-900">Hồ sơ của bạn</h3> + </div> + <div class="border-t border-gray-200 px-4 py-5 sm:p-0"> + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên gia</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liên hệ</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên môn</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + <tr> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="flex items-center"> + <!-- Avatar placeholder --> + <div class="flex-shrink-0 h-10 w-10"> + <span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-blue-100"> + <span class="font-medium text-blue-700">{{ current_dietitian.firstName[0] }}{{ current_dietitian.lastName[0] }}</span> + </span> + </div> + <div class="ml-4"> + <div class="text-sm font-medium text-gray-900">{{ current_dietitian.fullName }}</div> + <div class="text-sm text-gray-500">{{ current_dietitian.formattedID }}</div> + </div> + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="text-sm text-gray-900">{{ current_dietitian.email or 'N/A' }}</div> + <div class="text-sm text-gray-500">{{ current_dietitian.phone or 'N/A' }}</div> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ current_dietitian.specialization or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap">{{ status_badge(current_dietitian.status.value) }}</td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="{{ url_for('dietitians.show', id=current_dietitian.dietitianID) }}" class="text-primary-600 hover:text-primary-900 mr-3 transition duration-200">Chi tiết</a> + <a href="{{ url_for('dietitians.edit', id=current_dietitian.dietitianID) }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Sửa</a> + <!-- Chỉ Admin mới thấy nút xóa --> + {% if current_user.is_admin %} + <form action="{{ url_for('dietitians.delete', id=current_dietitian.dietitianID) }}" method="POST" class="inline-block" onsubmit="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');"> + <button type="submit" class="text-red-600 hover:text-red-900 transition duration-200">Xóa</button> + </form> + {% endif %} + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + {% elif current_user.role == 'Dietitian' %} + <div class="mb-8 bg-gradient-to-r from-yellow-50 to-amber-50 shadow sm:rounded-lg border border-amber-200"> + <div class="px-4 py-5 sm:px-6"> + <h3 class="text-lg leading-6 font-medium text-gray-900">Chưa có hồ sơ chuyên gia dinh dưỡng</h3> + </div> + <div class="border-t border-gray-200 px-4 py-5"> + <p class="text-gray-700 mb-4">Tài khoản của bạn có vai trò là chuyên gia dinh dưỡng nhưng chưa được liên kết với hồ sơ chuyên gia.</p> + <div class="flex space-x-4"> + <a href="{{ url_for('dietitians.link_profile') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + <svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> + </svg> + Tạo hoặc liên kết hồ sơ + </a> + {% if current_user.is_admin %} + <a href="{{ url_for('dietitians.new') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition duration-200"> + <svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> + </svg> + Tạo hồ sơ mới (Admin) + </a> + {% endif %} + </div> + </div> + </div> + {% endif %} + + <!-- Bảng Các Dietitian Khác --> + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> + <h3 class="text-lg leading-6 font-medium text-gray-900">Các Chuyên gia dinh dưỡng khác</h3> + </div> + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên gia</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liên hệ</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên môn</th> + <!-- <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bệnh nhân</th> --> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% if other_dietitians %} + {% for dietitian in other_dietitians %} + <tr> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="flex items-center"> + <!-- Avatar placeholder --> + <div class="flex-shrink-0 h-10 w-10"> + <span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-gray-100"> + <span class="font-medium text-gray-600">{{ dietitian.firstName[0] }}{{ dietitian.lastName[0] }}</span> + </span> + </div> + <div class="ml-4"> + <div class="text-sm font-medium text-gray-900">{{ dietitian.fullName }}</div> + <div class="text-sm text-gray-500">{{ dietitian.formattedID }}</div> + </div> + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="text-sm text-gray-900">{{ dietitian.email or 'N/A' }}</div> + <div class="text-sm text-gray-500">{{ dietitian.phone or 'N/A' }}</div> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ dietitian.specialization or 'N/A' }}</td> + <!-- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">X bệnh nhân</td> --> + <td class="px-6 py-4 whitespace-nowrap">{{ status_badge(dietitian.status.value) }}</td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="{{ url_for('dietitians.show', id=dietitian.dietitianID) }}" class="text-primary-600 hover:text-primary-900 mr-3 transition duration-200">Chi tiết</a> + <!-- Chỉ Admin hoặc chính dietitian đó mới thấy nút sửa --> + {% if current_user.is_admin %} + <a href="{{ url_for('dietitians.edit', id=dietitian.dietitianID) }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Sửa</a> + <form action="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" method="POST" class="inline-block" onsubmit="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');"> + <button type="submit" class="text-red-600 hover:text-red-900 transition duration-200">Xóa</button> + </form> + {% endif %} + </td> + </tr> + {% endfor %} + {% else %} + <tr> + <td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">Không tìm thấy chuyên gia dinh dưỡng nào khác.</td> + </tr> + {% endif %} + </tbody> + </table> + </div> + + <!-- Phân trang --> + {% if pagination and pagination.pages > 1 %} + <div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> + {{ render_pagination(pagination, 'dietitians.index', search=search_query, status=status_filter) }} + </div> + {% endif %} + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/link_profile.html b/app/templates/dietitians/link_profile.html new file mode 100644 index 0000000000000000000000000000000000000000..3edfae88e8d35ef0f7667b1e7f9ad240b6698a88 --- /dev/null +++ b/app/templates/dietitians/link_profile.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} + +{% block title %}Liên kết hồ sơ Chuyên gia dinh dưỡng - CCU HTM{% endblock %} + +{% block header %}Liên kết hồ sơ Chuyên gia dinh dưỡng{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <nav class="mb-6" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('dietitians.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-primary-600 md:ml-2 transition duration-200">Chuyên gia dinh dưỡng</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Liên kết hồ sơ</span> + </div> + </li> + </ol> + </nav> + + <div class="flex justify-between items-center mb-6"> + <h1 class="text-2xl font-semibold text-gray-900">Liên kết hồ sơ Chuyên gia dinh dưỡng</h1> + </div> + + <div class="bg-white shadow rounded-lg overflow-hidden mb-6"> + <div class="px-6 py-5 border-b border-gray-200"> + <h2 class="text-lg font-medium text-gray-900">Tạo hồ sơ mới</h2> + <p class="mt-1 text-sm text-gray-600">Tạo và liên kết hồ sơ chuyên gia dinh dưỡng mới với tài khoản của bạn.</p> + </div> + <div class="px-6 py-4"> + <form method="POST" action="{{ url_for('dietitians.link_profile') }}"> + <input type="hidden" name="action" value="create_new"> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4"> + <div> + <label for="firstName" class="block text-sm font-medium text-gray-700">Tên</label> + <input type="text" name="firstName" id="firstName" required class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + <div> + <label for="lastName" class="block text-sm font-medium text-gray-700">Họ</label> + <input type="text" name="lastName" id="lastName" required class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4"> + <div> + <label for="email" class="block text-sm font-medium text-gray-700">Email</label> + <input type="email" name="email" id="email" value="{{ current_user.email }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + <div> + <label for="phone" class="block text-sm font-medium text-gray-700">Số điện thoại</label> + <input type="text" name="phone" id="phone" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + </div> + + <div class="mb-4"> + <label for="specialization" class="block text-sm font-medium text-gray-700">Chuyên môn</label> + <input type="text" name="specialization" id="specialization" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> + </div> + + <div class="flex justify-end"> + <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> + Tạo hồ sơ + </button> + </div> + </form> + </div> + </div> + + {% if unlinked_dietitians %} + <div class="bg-white shadow rounded-lg overflow-hidden"> + <div class="px-6 py-5 border-b border-gray-200"> + <h2 class="text-lg font-medium text-gray-900">Liên kết với hồ sơ có sẵn</h2> + <p class="mt-1 text-sm text-gray-600">Liên kết tài khoản của bạn với một hồ sơ chuyên gia dinh dưỡng đã có sẵn trong hệ thống.</p> + </div> + <div class="px-6 py-4"> + <form method="POST" action="{{ url_for('dietitians.link_profile') }}"> + <input type="hidden" name="action" value="link_existing"> + + <div class="mb-4"> + <label for="dietitian_id" class="block text-sm font-medium text-gray-700">Chọn hồ sơ</label> + <select name="dietitian_id" id="dietitian_id" required class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"> + <option value="">-- Chọn hồ sơ --</option> + {% for d in unlinked_dietitians %} + <option value="{{ d.dietitianID }}">{{ d.fullName }} ({{ d.formattedID }})</option> + {% endfor %} + </select> + </div> + + <div class="flex justify-end"> + <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Liên kết hồ sơ + </button> + </div> + </form> + </div> + </div> + {% else %} + <div class="bg-white shadow rounded-lg overflow-hidden"> + <div class="px-6 py-5 border-b border-gray-200"> + <h2 class="text-lg font-medium text-gray-900">Liên kết với hồ sơ có sẵn</h2> + </div> + <div class="px-6 py-4"> + <p class="text-gray-600">Hiện không có hồ sơ chuyên gia dinh dưỡng nào chưa được liên kết trong hệ thống.</p> + </div> + </div> + {% endif %} +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/show.html b/app/templates/dietitians/show.html new file mode 100644 index 0000000000000000000000000000000000000000..2a6af588102d3ef5aa23e9eeab2f7257b6914959 --- /dev/null +++ b/app/templates/dietitians/show.html @@ -0,0 +1,172 @@ +{% extends "base.html" %} + +{% block title %}{{ dietitian.firstName }} {{ dietitian.lastName }} - CCU HTM{% endblock %} + +{% block header %}Thông tin chi tiết chuyên gia dinh dưỡng{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <div class="mb-6"> + <nav class="flex" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('dietitians.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Chuyên gia dinh dưỡng</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">{{ dietitian.firstName }} {{ dietitian.lastName }}</span> + </div> + </li> + </ol> + </nav> + </div> + + <div class="max-w-4xl mx-auto"> + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 flex justify-between items-center border-b border-gray-200"> + <div> + <h3 class="text-lg leading-6 font-medium text-gray-900">Thông tin chuyên gia dinh dưỡng</h3> + <p class="mt-1 max-w-2xl text-sm text-gray-500">Chi tiết thông tin cá nhân và liên hệ.</p> + </div> + <div class="flex space-x-3"> + <a href="{{ url_for('dietitians.edit', id=dietitian.dietitianID) }}" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> + <svg class="mr-2 -ml-1 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> + </svg> + Chỉnh sửa + </a> + <a href="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition duration-200" onclick="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');"> + <svg class="mr-2 -ml-1 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> + </svg> + Xóa + </a> + </div> + </div> + <div class="border-t border-gray-200"> + <dl> + <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Họ và tên</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.firstName }} {{ dietitian.lastName }}</dd> + </div> + <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Email</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.email }}</dd> + </div> + <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Số điện thoại</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.phone }}</dd> + </div> + <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Chuyên môn</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.specialization }}</dd> + </div> + <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Trạng thái</dt> + <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> + {% if dietitian.status == 'available' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> + Khả dụng + </span> + {% else %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> + Không khả dụng + </span> + {% endif %} + </dd> + </div> + <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Ghi chú</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.notes }}</dd> + </div> + </dl> + </div> + </div> + + <div class="mt-8"> + <h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Bệnh nhân đang theo dõi</h3> + {% if patients %} + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + ID + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + Tên bệnh nhân + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + Tuổi + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + Giới tính + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + Trạng thái + </th> + <th scope="col" class="relative px-6 py-3"> + <span class="sr-only">Xem</span> + </th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for patient in patients %} + <tr> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {{ patient.id }} + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="text-sm font-medium text-gray-900">{{ patient.firstName }} {{ patient.lastName }}</div> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {{ patient.age }} + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {{ patient.gender|capitalize }} + </td> + <td class="px-6 py-4 whitespace-nowrap"> + {% if patient.currentAdmissionStatus == "Admitted" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> + Đang điều trị + </span> + {% elif patient.currentAdmissionStatus == "Discharged" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> + Đã xuất viện + </span> + {% else %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"> + {{ patient.currentAdmissionStatus }} + </span> + {% endif %} + </td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="{{ url_for('patients.show', id=patient.id) }}" class="text-primary-600 hover:text-primary-900">Xem</a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% else %} + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:p-6 text-center text-gray-500"> + Chuyên gia dinh dưỡng này chưa được phân công bệnh nhân nào. + </div> + </div> + {% endif %} + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/edit_patient.html b/app/templates/edit_patient.html index 5942eeefd00b0d79f62c78309d303402c30195f0..33d258efffa43c5733e7d14dc356c5ea84c046bd 100644 --- a/app/templates/edit_patient.html +++ b/app/templates/edit_patient.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Edit Patient - {{ patient.full_name }}{% endblock %} +{% block title %}Edit Patient{% endblock %} {% block content %} <div class="container mx-auto px-4 py-6"> @@ -8,7 +8,7 @@ <nav class="flex mb-5" aria-label="Breadcrumb"> <ol class="inline-flex items-center space-x-1 md:space-x-3"> <li class="inline-flex items-center"> - <a href="{{ url_for('index') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600"> <svg class="w-3 h-3 mr-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"> <path d="m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z"/> </svg> @@ -28,8 +28,7 @@ <svg class="w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/> </svg> - {% set patient_number = patient.patient_id.split('-')[1]|int - 10000 %} - <a href="{{ url_for('patients.patient_detail', patient_id=patient.patient_id) }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2">Patient {{ patient_number }}</a> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.patient_id) }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2">{{ patient.full_name }}</a> </div> </li> <li aria-current="page"> @@ -37,7 +36,7 @@ <svg class="w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/> </svg> - <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Edit Patient</span> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Edit</span> </div> </li> </ol> @@ -55,17 +54,17 @@ <div class="bg-white rounded-lg shadow-md overflow-hidden"> <div class="p-6"> <form method="POST" action="{{ url_for('patients.edit_patient', patient_id=patient.patient_id) }}"> + <!-- Patient ID (read-only) --> + <div class="mb-4"> + <label for="patient_id" class="block text-sm font-medium text-gray-700 mb-1">Patient ID</label> + <input type="text" id="patient_id" name="patient_id" value="{{ patient.patient_id }}" readonly + class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"> + </div> + <!-- Basic Info Section --> <div class="mb-8"> <h2 class="text-lg font-semibold text-gray-800 mb-4">Patient Information</h2> <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> - <!-- Patient ID (readonly) --> - <div> - <label for="patient_id" class="block text-sm font-medium text-gray-700 mb-1">Patient ID</label> - <input type="text" id="patient_id" name="patient_id" value="{{ patient.patient_id }}" readonly - class="w-full px-3 py-2 border border-gray-200 bg-gray-100 rounded-md text-gray-500"> - </div> - <!-- First Name --> <div> <label for="firstName" class="block text-sm font-medium text-gray-700 mb-1">First Name</label> @@ -82,7 +81,7 @@ <!-- Age --> <div> - <label for="age" class="block text-sm font-medium text-gray-700 mb-1">Tuổi</label> + <label for="age" class="block text-sm font-medium text-gray-700 mb-1">Age</label> <input type="number" id="age" name="age" min="0" max="120" value="{{ patient.age }}" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"> </div> @@ -108,14 +107,14 @@ <!-- Height --> <div> <label for="height" class="block text-sm font-medium text-gray-700 mb-1">Height (cm)</label> - <input type="number" id="height" name="height" min="0" step="0.1" value="{{ patient.height or '' }}" + <input type="number" id="height" name="height" min="0" step="0.1" value="{{ patient.height }}" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"> </div> <!-- Weight --> <div> <label for="weight" class="block text-sm font-medium text-gray-700 mb-1">Weight (kg)</label> - <input type="number" id="weight" name="weight" min="0" step="0.1" value="{{ patient.weight or '' }}" + <input type="number" id="weight" name="weight" min="0" step="0.1" value="{{ patient.weight }}" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"> </div> diff --git a/app/templates/edit_physical_measurement.html b/app/templates/edit_physical_measurement.html new file mode 100644 index 0000000000000000000000000000000000000000..23e80259fa4b89c9ddb45fb30ef24567c7e9c5c8 --- /dev/null +++ b/app/templates/edit_physical_measurement.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} + +{% block title %}Chỉnh sửa đo lường - {{ patient.full_name }} - CCU HTM{% endblock %} + +{% block header %}Chỉnh sửa đo lường - {{ patient.full_name }} ({{ patient.id }}){% endblock %} + +{% block content %} +<div class="animate-slide-in container mx-auto px-4 py-8"> + <!-- Breadcrumb --> + <div class="mb-6"> + <nav class="flex" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <i class="fas fa-home mr-2"></i> Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.index') }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">Bệnh nhân</a> + </div> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">{{ patient.full_name }}</a> + </div> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">Biểu đồ đo lường</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <span class="text-sm font-medium text-gray-500">Chỉnh sửa</span> + </div> + </li> + </ol> + </nav> + </div> + + <!-- Form chỉnh sửa đo lường --> + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> + <h3 class="text-xl leading-6 font-semibold text-gray-900"> + Chỉnh sửa thông tin đo lường + </h3> + <p class="mt-1 max-w-2xl text-sm text-gray-500"> + Chỉnh sửa các thông số đo lường cho lần đo vào lúc {{ measurement.measurementDateTime.strftime('%H:%M %d/%m/%Y') if measurement.measurementDateTime else 'N/A' }}. + </p> + </div> + + <div class="px-4 py-5 sm:p-6"> + <form action="{{ url_for('patients.edit_physical_measurement', patient_id=patient.id, measurement_id=measurement.id) }}" method="POST"> + <!-- Grid layout for inputs --> + <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-6"> + + <!-- Function to create input fields --> + {% macro input_field(name, label, value, type='number', step='any', placeholder='', unit='', required=False) %} + <div> + <label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1"> + {{ label }} {% if unit %}({{ unit }}){% endif %} + {% if required %}<span class="text-red-500">*</span>{% endif %} + </label> + <div class="mt-1 relative rounded-md shadow-sm"> + <input type="{{ type }}" step="{{ step }}" name="{{ name }}" id="{{ name }}" + class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-3 {% if unit %}pr-12{% else %}pr-3{% endif %} sm:text-sm border-gray-300 rounded-md" + placeholder="{{ placeholder }}" value="{{ value if value is not none else '' }}" {% if required %}required{% endif %}> + {% if unit %} + <div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> + <span class="text-gray-500 sm:text-sm">{{ unit }}</span> + </div> + {% endif %} + </div> + </div> + {% endmacro %} + + <!-- Vitals --> + {{ input_field('temperature', 'Nhiệt độ', measurement.temperature, placeholder='36.5', unit='°C') }} + {{ input_field('heart_rate', 'Nhịp tim', measurement.heart_rate, type='number', step='1', placeholder='80', unit='bpm') }} + {{ input_field('blood_pressure_systolic', 'HA Tâm thu', measurement.blood_pressure_systolic, type='number', step='1', placeholder='120', unit='mmHg') }} + {{ input_field('blood_pressure_diastolic', 'HA Tâm trương', measurement.blood_pressure_diastolic, type='number', step='1', placeholder='80', unit='mmHg') }} + {{ input_field('oxygen_saturation', 'SpO₂', measurement.oxygen_saturation, placeholder='98', unit='%') }} + {{ input_field('resp_rate', 'Nhịp thở', measurement.resp_rate, type='number', step='1', placeholder='16', unit='bpm') }} + + <!-- Respiratory --> + {{ input_field('fio2', 'FiO₂', measurement.fio2, placeholder='21', unit='%') }} + {{ input_field('fio2_ratio', 'Tỷ lệ FiO₂', measurement.fio2_ratio, placeholder='0.5') }} + {{ input_field('peep', 'PEEP', measurement.peep, placeholder='5', unit='cmH₂O') }} + {{ input_field('pip', 'PIP', measurement.pip, placeholder='30', unit='cmH₂O') }} + {{ input_field('sip', 'SIP', measurement.sip, placeholder='25', unit='cmH₂O') }} + {{ input_field('insp_time', 'Insp Time', measurement.insp_time, placeholder='1.0', unit='s') }} + {{ input_field('oxygen_flow_rate', 'Lưu lượng Oxy', measurement.oxygen_flow_rate, placeholder='5', unit='L/min') }} + {{ input_field('end_tidal_co2', 'End Tidal CO₂', measurement.end_tidal_co2, placeholder='40', unit='mmHg') }} + + <!-- Tidal Volumes --> + {{ input_field('tidal_vol', 'Tidal Vol', measurement.tidal_vol, placeholder='450', unit='mL') }} + {{ input_field('tidal_vol_kg', 'Tidal Vol/kg', measurement.tidal_vol_kg, placeholder='7', unit='mL/kg') }} + {{ input_field('tidal_vol_actual', 'Tidal Vol Actual', measurement.tidal_vol_actual, placeholder='440', unit='mL') }} + {{ input_field('tidal_vol_spon', 'Tidal Vol Spon', measurement.tidal_vol_spon, placeholder='50', unit='mL') }} + + <!-- Feeding --> + {{ input_field('feed_vol', 'Feed Vol', measurement.feed_vol, placeholder='200', unit='mL') }} + {{ input_field('feed_vol_adm', 'Feed Vol Adm', measurement.feed_vol_adm, placeholder='180', unit='mL') }} + + </div> + + <!-- Ghi chú --> + <div class="mb-6"> + <label for="notes" class="block text-sm font-medium text-gray-700 mb-1"> + Ghi chú + </label> + <textarea id="notes" name="notes" rows="4" + class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="Nhập ghi chú về đo lường thể chất (nếu có)">{{ measurement.notes if measurement.notes is not none else '' }}</textarea> + </div> + + <!-- Nút submit --> + <div class="pt-5 flex justify-end space-x-3"> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}" + class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> + Hủy + </a> + <button type="submit" + class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> + <i class="fas fa-save mr-2"></i> Cập nhật thông số + </button> + </div> + </form> + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html index ce612e8435f4af4a1dd1474362d21d5024c65980..108bee8f709c33a1decdb7fa7b8e5199aa1ff34e 100644 --- a/app/templates/errors/404.html +++ b/app/templates/errors/404.html @@ -25,7 +25,7 @@ </div> <div class="flex items-center justify-center space-x-6"> - <a href="{{ url_for('index') }}" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> <i class="fas fa-home mr-2"></i> Trang chủ </a> diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html index 71f11864928f37965b12293146971da5e05f9a18..e22699575773715c7c72a9a36be3e7b60760c226 100644 --- a/app/templates/errors/500.html +++ b/app/templates/errors/500.html @@ -25,7 +25,7 @@ </div> <div class="flex items-center justify-center space-x-6"> - <a href="{{ url_for('index') }}" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> <i class="fas fa-home mr-2"></i> Trang chủ </a> diff --git a/app/templates/new_patient.html b/app/templates/new_patient.html index 2386ca235a7683fb106e5ccb9700dd7eb38dd599..69e49abf56eedf0cc73cc24c2a2efb23584337aa 100644 --- a/app/templates/new_patient.html +++ b/app/templates/new_patient.html @@ -8,7 +8,7 @@ <nav class="flex mb-5" aria-label="Breadcrumb"> <ol class="inline-flex items-center space-x-1 md:space-x-3"> <li class="inline-flex items-center"> - <a href="{{ url_for('index') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600"> <svg class="w-3 h-3 mr-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"> <path d="m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z"/> </svg> @@ -46,6 +46,7 @@ <div class="bg-white rounded-lg shadow-md overflow-hidden"> <div class="p-6"> <form method="POST" action="{{ url_for('patients.new_patient') }}"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <!-- Basic Info Section --> <div class="mb-8"> <h2 class="text-lg font-semibold text-gray-800 mb-4">Patient Information</h2> diff --git a/app/templates/new_physical_measurement.html b/app/templates/new_physical_measurement.html new file mode 100644 index 0000000000000000000000000000000000000000..664d17852dc8cf0058ebd8d5bfbcf1892ab1a9a8 --- /dev/null +++ b/app/templates/new_physical_measurement.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} + +{% block title %}Thêm đo lường thể chất - {{ patient.full_name }} - CCU HTM{% endblock %} + +{% block header %}Thêm đo lường thể chất - {{ patient.full_name }} ({{ patient.id }}){% endblock %} + +{% block content %} +<div class="animate-slide-in container mx-auto px-4 py-8"> + <!-- Breadcrumb --> + <div class="mb-6"> + <nav class="flex" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <i class="fas fa-home mr-2"></i> Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.index') }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">Bệnh nhân</a> + </div> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">{{ patient.full_name }}</a> + </div> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">Biểu đồ đo lường</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <span class="text-sm font-medium text-gray-500">Thêm mới</span> + </div> + </li> + </ol> + </nav> + </div> + + <!-- Form thêm đo lường --> + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> + <h3 class="text-xl leading-6 font-semibold text-gray-900"> + Nhập thông tin đo lường thể chất + </h3> + <p class="mt-1 max-w-2xl text-sm text-gray-500"> + Nhập các thông số đo lường mới nhất cho bệnh nhân. + </p> + </div> + + <div class="px-4 py-5 sm:p-6"> + <form action="{{ url_for('patients.new_physical_measurement', patient_id=patient.id) }}" method="POST"> + {{ form.csrf_token }} + <!-- Grid layout for inputs --> + <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-6"> + + <!-- Function to create input fields --> + {% macro input_field(name, label, type='number', step='any', placeholder='', unit='', required=False) %} + <div> + <label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1"> + {{ label }} {% if unit %}({{ unit }}){% endif %} + {% if required %}<span class="text-red-500">*</span>{% endif %} + </label> + <div class="mt-1 relative rounded-md shadow-sm"> + <input type="{{ type }}" step="{{ step }}" name="{{ name }}" id="{{ name }}" + class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-3 {% if unit %}pr-12{% else %}pr-3{% endif %} sm:text-sm border-gray-300 rounded-md" + placeholder="{{ placeholder }}" {% if required %}required{% endif %}> + {% if unit %} + <div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> + <span class="text-gray-500 sm:text-sm">{{ unit }}</span> + </div> + {% endif %} + </div> + </div> + {% endmacro %} + + <!-- Vitals --> + {{ input_field('temperature', 'Nhiệt độ', placeholder='36.5', unit='°C') }} + {{ input_field('heart_rate', 'Nhịp tim', type='number', step='1', placeholder='80', unit='bpm') }} + {{ input_field('blood_pressure_systolic', 'HA Tâm thu', type='number', step='1', placeholder='120', unit='mmHg') }} + {{ input_field('blood_pressure_diastolic', 'HA Tâm trương', type='number', step='1', placeholder='80', unit='mmHg') }} + {{ input_field('oxygen_saturation', 'SpO₂', placeholder='98', unit='%') }} + {{ input_field('resp_rate', 'Nhịp thở', type='number', step='1', placeholder='16', unit='bpm') }} {# Consistent naming with charts #} + + <!-- Respiratory --> + {{ input_field('fio2', 'FiO₂', placeholder='21', unit='%') }} + {{ input_field('fio2_ratio', 'Tỷ lệ FiO₂', placeholder='0.5') }} + {{ input_field('peep', 'PEEP', placeholder='5', unit='cmH₂O') }} + {{ input_field('pip', 'PIP', placeholder='30', unit='cmH₂O') }} + {{ input_field('sip', 'SIP', placeholder='25', unit='cmH₂O') }} + {{ input_field('insp_time', 'Insp Time', placeholder='1.0', unit='s') }} + {{ input_field('oxygen_flow_rate', 'Lưu lượng Oxy', placeholder='5', unit='L/min') }} + {{ input_field('end_tidal_co2', 'End Tidal CO₂', placeholder='40', unit='mmHg') }} + + <!-- Tidal Volumes --> + {{ input_field('tidal_vol', 'Tidal Vol', placeholder='450', unit='mL') }} + {{ input_field('tidal_vol_kg', 'Tidal Vol/kg', placeholder='7', unit='mL/kg') }} + {{ input_field('tidal_vol_actual', 'Tidal Vol Actual', placeholder='440', unit='mL') }} + {{ input_field('tidal_vol_spon', 'Tidal Vol Spon', placeholder='50', unit='mL') }} + + <!-- Feeding --> + {{ input_field('feed_vol', 'Feed Vol', placeholder='200', unit='mL') }} + {{ input_field('feed_vol_adm', 'Feed Vol Adm', placeholder='180', unit='mL') }} + + </div> + + <!-- Ghi chú --> + <div class="mb-6"> + <label for="notes" class="block text-sm font-medium text-gray-700 mb-1"> + Ghi chú + </label> + <textarea id="notes" name="notes" rows="4" + class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="Nhập ghi chú về đo lường thể chất (nếu có)"></textarea> + </div> + + <!-- Nút submit --> + <div class="pt-5 flex justify-end space-x-3"> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}" + class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> + Hủy + </a> + <button type="submit" + class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> + <i class="fas fa-save mr-2"></i> Lưu thông số + </button> + </div> + </form> + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/patient_detail.html b/app/templates/patient_detail.html index 12499638d21070e70032a86333ec20839226860d..da9509ee4324370da1064d4fd1d1a22476e439c2 100644 --- a/app/templates/patient_detail.html +++ b/app/templates/patient_detail.html @@ -2,97 +2,102 @@ {% block title %}Chi tiết bệnh nhân - CCU_BVNM{% endblock %} +{% block head %} +<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script> +{% endblock %} + {% block content %} <div class="animate-slide-in"> <!-- Breadcrumb --> <div class="mb-6"> - <nav class="flex" aria-label="Breadcrumb"> + <nav class="flex" aria-label="Breadcrumb"> <ol class="inline-flex items-center space-x-1 md:space-x-3"> <li class="inline-flex items-center"> - <a href="{{ url_for('index') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> - <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> - Trang chủ + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <i class="fas fa-home mr-2"></i> Trang chủ </a> - </li> - <li> - <div class="flex items-center"> - <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> - <a href="{{ url_for('patients.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Patient</a> - </div> - </li> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Bệnh nhân</a> + </div> + </li> <li aria-current="page"> - <div class="flex items-center"> - <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> - {% set patient_number = patient.patient_id.split('-')[1]|int - 10000 %} - <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Patient {{ patient_number }}</span> - </div> - </li> - </ol> - </nav> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">{{ patient.full_name }} ({{ patient.id }})</span> + </div> + </li> + </ol> + </nav> </div> - + <!-- Tiêu đề và nút thao tác --> <div class="md:flex md:items-center md:justify-between mb-6"> <div class="flex-1 min-w-0"> - <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate"> + <h2 class="text-2xl font-bold text-gray-900 sm:text-3xl" style="line-height: 1.4;"> {{ patient.full_name }} </h2> </div> <div class="mt-4 flex md:mt-0 md:ml-4 space-x-3"> - <a href="{{ url_for('patients.edit_patient', patient_id=patient.patient_id) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:-translate-y-1"> + <a href="{{ url_for('patients.edit_patient', patient_id=patient.id) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:-translate-y-1"> <i class="fas fa-edit -ml-1 mr-2 text-gray-500"></i> - Chỉnh sửa + Edit Patient + </a> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:-translate-y-1"> + <i class="fas fa-chart-line -ml-1 mr-2 text-blue-500"></i> + Physical Statistics </a> - <button type="button" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:-translate-y-1"> + <!-- Nút cập nhật trạng thái có thể thêm chức năng sau --> + <!-- <button type="button" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-1"> <i class="fas fa-check -ml-1 mr-2"></i> Cập nhật trạng thái - </button> + </button> --> </div> </div> - <!-- Thẻ trạng thái --> - <div class="bg-white overflow-hidden shadow rounded-lg mb-6 animate-fade-in transition-all duration-300 hover:shadow-lg"> + <!-- Thẻ trạng thái (Chỉ hiển thị ở tab Tổng quan) --> + <div class="bg-white overflow-hidden shadow rounded-lg mb-6 animate-fade-in transition-all duration-300 hover:shadow-lg status-card"> <div class="px-4 py-5 sm:p-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="flex flex-col"> - <div class="text-sm font-medium text-gray-500">ID Patient</div> - <div class="mt-1 text-lg font-semibold text-gray-900">P-{{ patient.id|default('10001') }}</div> + <div class="text-sm font-medium text-gray-500">ID Bệnh nhân</div> + <div class="mt-1 text-lg font-semibold text-gray-900">{{ patient.id }}</div> </div> <div class="flex flex-col"> <div class="text-sm font-medium text-gray-500">Nhập viện</div> - <div class="mt-1 text-lg font-semibold text-gray-900">{{ patient.admission_date|default('01/01/2023') }}</div> + <div class="mt-1 text-lg font-semibold text-gray-900">{{ patient.admission_date.strftime('%d/%m/%Y') if patient.admission_date else 'N/A' }}</div> </div> <div class="flex flex-col"> - <div class="text-sm font-medium text-gray-500">Trạng thái</div> + <div class="text-sm font-medium text-gray-500">Trạng thái BN</div> <div class="mt-1"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> <span class="w-2 h-2 mr-1 rounded-full bg-green-500"></span> - {{ patient.status|default('Đang điều trị') }} + {{ patient.status|default('Active')|capitalize }} </span> </div> </div> <div class="flex flex-col"> - <div class="text-sm font-medium text-gray-500">Giới thiệu</div> + <div class="text-sm font-medium text-gray-500">Trạng thái giới thiệu</div> <div class="mt-1"> + <!-- Logic hiển thị trạng thái giới thiệu cần cập nhật --> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> - {{ patient.referral_status|default('Đang xem xét') }} + {{ patient.referral_status|default('Pending') }} </span> </div> </div> </div> </div> </div> - - <!-- Tabs điều hướng --> + + <!-- Tabs điều hướng (Bỏ tab Đo lường sinh lý) --> <div class="mb-6 animate-fade-in transition-all duration-300"> <div class="border-b border-gray-200"> - <nav class="-mb-px flex space-x-8" aria-label="Tabs"> + <nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs"> <a href="#overview" class="border-primary-500 text-primary-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm tab-link" data-target="overview"> Tổng quan </a> - <a href="#measurements" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm tab-link" data-target="measurements"> - Đo lường sinh lý - </a> <a href="#referrals" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm tab-link" data-target="referrals"> Giới thiệu & Đánh giá </a> @@ -102,6 +107,9 @@ <a href="#reports" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm tab-link" data-target="reports"> Báo cáo </a> + <a href="#encounters" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm tab-link" data-target="encounters"> + Lượt khám (Encounters) + </a> </nav> </div> </div> @@ -119,570 +127,455 @@ </h3> </div> <div class="px-4 py-5 sm:p-6"> - <div class="flex flex-col space-y-4"> - <div class="flex justify-center"> - <div class="h-24 w-24 rounded-full bg-primary-100 flex items-center justify-center text-primary-500"> - <i class="fas fa-user-circle text-5xl"></i> - </div> + <dl class="space-y-3"> + <div class="flex justify-between"> + <dt class="text-sm font-medium text-gray-500">ID</dt> + <dd class="text-sm text-gray-900">{{ patient.id }}</dd> </div> - <div class="space-y-3"> - <div class="flex justify-between"> - <span class="text-sm font-medium text-gray-500">Tên đầy đủ</span> - <span class="text-sm text-gray-900 ml-4 max-w-[200px] truncate">{{ patient.full_name }}</span> - </div> - <div class="flex justify-between"> - <span class="text-sm font-medium text-gray-500">Tuổi</span> - <span class="text-sm text-gray-900">{{ patient.age|default('42') }}</span> - </div> - <div class="flex justify-between"> - <span class="text-sm font-medium text-gray-500">Giới tính</span> - <span class="text-sm text-gray-900">{{ patient.gender|default('Nam') }}</span> - </div> - <div class="flex justify-between"> - <span class="text-sm font-medium text-gray-500">Chiều cao</span> - <span class="text-sm text-gray-900">{{ patient.height|default('175 cm') }}</span> - </div> - <div class="flex justify-between"> - <span class="text-sm font-medium text-gray-500">Cân nặng</span> - <span class="text-sm text-gray-900">{{ patient.weight|default('68 kg') }}</span> - </div> - <div class="flex justify-between"> - <span class="text-sm font-medium text-gray-500">Nhóm máu</span> - <span class="text-sm text-gray-900">{{ patient.blood_type|default('A+') }}</span> - </div> + <div class="flex justify-between"> + <dt class="text-sm font-medium text-gray-500">Tên đầy đủ</dt> + <dd class="text-sm text-gray-900">{{ patient.full_name }}</dd> </div> - <button type="button" class="mt-4 w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> - <i class="fas fa-eye mr-2"></i> - Xem chi tiết - </button> - </div> + <div class="flex justify-between"> + <dt class="text-sm font-medium text-gray-500">Tuổi</dt> + <dd class="text-sm text-gray-900">{{ patient.age }}</dd> + </div> + <div class="flex justify-between"> + <dt class="text-sm font-medium text-gray-500">Giới tính</dt> + <dd class="text-sm text-gray-900">{{ patient.gender|capitalize }}</dd> + </div> + <div class="flex justify-between"> + <dt class="text-sm font-medium text-gray-500">Chiều cao</dt> + <dd class="text-sm text-gray-900">{{ patient.height }} cm</dd> + </div> + <div class="flex justify-between"> + <dt class="text-sm font-medium text-gray-500">Cân nặng</dt> + <dd class="text-sm text-gray-900">{{ patient.weight }} kg</dd> + </div> + <div class="flex justify-between"> + <dt class="text-sm font-medium text-gray-500">Nhóm máu</dt> + <dd class="text-sm text-gray-900">{{ patient.blood_type }}</dd> + </div> + </dl> </div> </div> - <!-- Đo lường quan trọng --> - <div class="bg-white shadow-md rounded-lg col-span-1 lg:col-span-2 transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> - <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> - <div class="flex items-center justify-between"> - <h3 class="text-lg leading-6 font-medium text-gray-900"> - Các chỉ số quan trọng - </h3> - <div class="text-sm text-gray-500"> - Cập nhật: {{ now|default('12/05/2023 10:25') }} - </div> - </div> + <!-- Các chỉ số quan trọng (Liên kết đến trang biểu đồ) --> + <div class="col-span-1 lg:col-span-2"> + <div class="px-4 py-5 sm:px-6"> + <h3 class="text-lg leading-6 font-medium text-gray-900 mb-2"> + Các chỉ số quan trọng gần đây + </h3> + <p class="text-xs text-gray-500"> + Cập nhật: {{ latest_measurement.measurementDateTime.strftime('%H:%M %d/%m/%Y') if latest_measurement and latest_measurement.measurementDateTime else 'N/A' }} + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}" class="ml-2 text-blue-600 hover:underline">(Xem biểu đồ chi tiết)</a> + </p> </div> - <div class="px-4 py-5 sm:p-6"> - <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> - <!-- BMI --> - <div class="bg-white overflow-hidden rounded-lg border border-gray-200 transition-all duration-300 hover:shadow-md"> - <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> - <span class="text-sm font-semibold text-gray-700">BMI</span> - <span class="px-2 py-1 text-xs font-semibold rounded-full {% if patient.get_bmi_color_class() %}bg-{{ patient.get_bmi_color_class() }}-100 text-{{ patient.get_bmi_color_class() }}-800{% else %}bg-green-100 text-green-800{% endif %}"> - {{ patient.get_bmi_category()|default('Bình thường') }} - </span> + <div class="grid grid-cols-1 sm:grid-cols-2 gap-6"> + <!-- BMI Card --> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}" class="block bg-white shadow-md rounded-lg overflow-hidden transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> + <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> + <span class="text-sm font-semibold text-gray-700">BMI</span> + <span class="px-2 py-1 text-xs font-semibold rounded-full {% set bmi_class = patient.get_bmi_color_class() %}{% if bmi_class %}bg-{{ bmi_class }}-100 text-{{ bmi_class }}-800{% else %}bg-gray-100 text-gray-800{% endif %}"> + {{ patient.get_bmi_category() }} + </span> + </div> + <div class="px-4 py-4 flex flex-col items-center"> + <div class="text-3xl font-bold text-gray-900">{{ patient.bmi if patient.bmi else '--' }}</div> + <div class="mt-1 w-full bg-gray-200 rounded-full h-2"> + {% set bmi_percentage = patient.get_bmi_percentage() %} + {% set bmi_color_hex = {'blue': '#3b82f6', 'green': '#10b981', 'yellow': '#facc15', 'red': '#ef4444'}.get(patient.get_bmi_color_class(), '#d1d5db') %} + <div class="h-2 rounded-full" style="width: {{ bmi_percentage }}%; background-color: {{ bmi_color_hex }};"></div> </div> - <div class="px-4 py-4 flex flex-col items-center"> - <div class="text-3xl font-bold text-gray-900">{{ patient.bmi|default('23.5') }}</div> - <div class="mt-1 w-full bg-gray-200 rounded-full h-2"> - <div class="h-2 rounded-full" - style="width: {{ patient.get_bmi_percentage()|default(45) }}%; background-color: {% if patient.get_bmi_color_class() == 'green' %}#10b981{% elif patient.get_bmi_color_class() == 'blue' %}#3b82f6{% elif patient.get_bmi_color_class() == 'yellow' %}#facc15{% elif patient.get_bmi_color_class() == 'red' %}#ef4444{% else %}#10b981{% endif %};"> - </div> - </div> - <div class="mt-2 flex justify-between w-full text-xs text-gray-500"> - <span>0</span> - <span>20</span> - <span>25</span> - <span>30</span> - <span>40</span> - </div> + <div class="mt-2 flex justify-between w-full text-xs text-gray-500"> + <span>0</span> + <span>18.5</span> + <span>25</span> + <span>30</span> + <span>40+</span> </div> </div> + </a> - <!-- Nhịp tim --> - <div class="bg-white overflow-hidden rounded-lg border border-gray-200 transition-all duration-300 hover:shadow-md"> - <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> - <span class="text-sm font-semibold text-gray-700">Nhịp tim</span> - <span class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800"> - Tăng nhẹ - </span> - </div> - <div class="px-4 py-4 flex flex-col items-center"> - <div class="text-3xl font-bold text-gray-900 flex items-center"> - <span>{{ patient.heart_rate|default('92') }}</span> - <span class="ml-1 text-sm text-gray-500">bpm</span> - </div> - <div class="mt-4 w-24 h-24 relative flex justify-center items-center"> - <svg class="w-full h-full" viewBox="0 0 100 100"> - <circle cx="50" cy="50" r="45" fill="none" stroke="#f0f0f0" stroke-width="8" /> - <circle cx="50" cy="50" r="45" fill="none" stroke="#facc15" stroke-width="8" stroke-dasharray="282.7" stroke-dashoffset="70" transform="rotate(-90 50 50)" /> - </svg> - <div class="absolute flex flex-col items-center justify-center"> - <i class="fas fa-heartbeat text-xl text-yellow-500"></i> - </div> - </div> + <!-- Heart Rate Card --> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}#heartRateChart" class="block bg-white shadow-md rounded-lg overflow-hidden transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> + <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> + <span class="text-sm font-semibold text-gray-700">Nhịp tim</span> + <!-- Logic trạng thái nhịp tim cần thêm --> + </div> + <div class="px-4 py-4 flex items-center justify-center"> + <i class="fas fa-heartbeat text-4xl text-red-500 mr-4"></i> + <div> + <div class="text-3xl font-bold text-gray-900">{{ latest_measurement.heart_rate if latest_measurement else '--' }}</div> + <div class="text-sm text-gray-500">bpm</div> </div> </div> + </a> - <!-- Huyết áp --> - <div class="bg-white overflow-hidden rounded-lg border border-gray-200 transition-all duration-300 hover:shadow-md"> - <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> - <span class="text-sm font-semibold text-gray-700">Huyết áp</span> - <span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"> - Bình thường - </span> - </div> - <div class="px-4 py-4 flex flex-col items-center"> - <div class="text-3xl font-bold text-gray-900 flex items-center"> - <span>{{ patient.blood_pressure|default('120/80') }}</span> - <span class="ml-1 text-sm text-gray-500">mmHg</span> - </div> - <div class="mt-4 w-24 h-24 relative flex justify-center items-center"> - <svg class="w-full h-full" viewBox="0 0 100 100"> - <circle cx="50" cy="50" r="45" fill="none" stroke="#f0f0f0" stroke-width="8" /> - <circle cx="50" cy="50" r="45" fill="none" stroke="#10b981" stroke-width="8" stroke-dasharray="282.7" stroke-dashoffset="113" transform="rotate(-90 50 50)" /> - </svg> - <div class="absolute flex flex-col items-center justify-center"> - <i class="fas fa-tachometer-alt text-xl text-green-500"></i> - </div> - </div> - </div> + <!-- Blood Pressure Card --> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}#bloodPressureChart" class="block bg-white shadow-md rounded-lg overflow-hidden transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> + <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> + <span class="text-sm font-semibold text-gray-700">Huyết áp</span> + <!-- Logic trạng thái huyết áp cần thêm --> </div> - - <!-- FiO2 --> - <div class="bg-white overflow-hidden rounded-lg border border-gray-200 transition-all duration-300 hover:shadow-md"> - <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> - <span class="text-sm font-semibold text-gray-700">FiO2</span> - <span class="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800"> - Trung bình - </span> - </div> - <div class="px-4 py-4 flex flex-col items-center"> - <div class="text-3xl font-bold text-gray-900 flex items-center"> - <span>{{ patient.fio2|default('60') }}</span> - <span class="ml-1 text-sm text-gray-500">%</span> - </div> - <div class="mt-4 w-24 h-24 relative flex justify-center items-center"> - <svg class="w-full h-full" viewBox="0 0 100 100"> - <circle cx="50" cy="50" r="45" fill="none" stroke="#f0f0f0" stroke-width="8" /> - <circle cx="50" cy="50" r="45" fill="none" stroke="#3b82f6" stroke-width="8" stroke-dasharray="282.7" stroke-dashoffset="113" transform="rotate(-90 50 50)" /> - </svg> - <div class="absolute flex flex-col items-center justify-center"> - <i class="fas fa-lungs text-xl text-blue-500"></i> - </div> - </div> + <div class="px-4 py-4 flex items-center justify-center"> + <i class="fas fa-stethoscope text-4xl text-blue-500 mr-4"></i> + <div> + <div class="text-3xl font-bold text-gray-900">{{ (latest_measurement.blood_pressure_systolic|string + '/' + latest_measurement.blood_pressure_diastolic|string) if latest_measurement and latest_measurement.blood_pressure_systolic and latest_measurement.blood_pressure_diastolic else '--/--' }}</div> + <div class="text-sm text-gray-500">mmHg</div> </div> </div> - - <!-- Nhiệt độ --> - <div class="bg-white overflow-hidden rounded-lg border border-gray-200 transition-all duration-300 hover:shadow-md"> - <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> - <span class="text-sm font-semibold text-gray-700">Nhiệt độ</span> - <span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800"> - Sốt nhẹ - </span> - </div> - <div class="px-4 py-4 flex flex-col items-center"> - <div class="text-3xl font-bold text-gray-900 flex items-center"> - <span>{{ patient.temperature|default('37.6') }}</span> - <span class="ml-1 text-sm text-gray-500">°C</span> - </div> - <div class="mt-4 w-24 h-24 relative flex justify-center items-center"> - <svg class="w-full h-full" viewBox="0 0 100 100"> - <circle cx="50" cy="50" r="45" fill="none" stroke="#f0f0f0" stroke-width="8" /> - <circle cx="50" cy="50" r="45" fill="none" stroke="#ef4444" stroke-width="8" stroke-dasharray="282.7" stroke-dashoffset="141" transform="rotate(-90 50 50)" /> - </svg> - <div class="absolute flex flex-col items-center justify-center"> - <i class="fas fa-thermometer-half text-xl text-red-500"></i> - </div> - </div> + </a> + + <!-- Oxygen Saturation Card --> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}#oxygenSaturationChart" class="block bg-white shadow-md rounded-lg overflow-hidden transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> + <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> + <span class="text-sm font-semibold text-gray-700">SpO₂</span> + <!-- Logic trạng thái SpO2 cần thêm --> + </div> + <div class="px-4 py-4 flex items-center justify-center"> + <i class="fas fa-lungs text-4xl text-purple-500 mr-4"></i> + <div> + <div class="text-3xl font-bold text-gray-900">{{ latest_measurement.oxygen_saturation if latest_measurement else '--' }}</div> + <div class="text-sm text-gray-500">%</div> </div> </div> - - <!-- Tidal Volume --> - <div class="bg-white overflow-hidden rounded-lg border border-gray-200 transition-all duration-300 hover:shadow-md"> - <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> - <span class="text-sm font-semibold text-gray-700">Tidal Volume</span> - <span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"> - Bình thường - </span> + </a> + + <!-- Temperature Card --> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}#temperatureChart" class="block bg-white shadow-md rounded-lg overflow-hidden transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> + <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> + <span class="text-sm font-semibold text-gray-700">Nhiệt độ</span> + <!-- Logic trạng thái nhiệt độ cần thêm --> + </div> + <div class="px-4 py-4 flex items-center justify-center"> + <i class="fas fa-thermometer-half text-4xl text-orange-500 mr-4"></i> + <div> + <div class="text-3xl font-bold text-gray-900">{{ latest_measurement.temperature if latest_measurement else '--' }}</div> + <div class="text-sm text-gray-500">°C</div> </div> - <div class="px-4 py-4 flex flex-col items-center"> - <div class="text-3xl font-bold text-gray-900 flex items-center"> - <span>{{ patient.tidal_vol|default('450') }}</span> - <span class="ml-1 text-sm text-gray-500">mL</span> - </div> - <div class="mt-4 w-24 h-24 relative flex justify-center items-center"> - <svg class="w-full h-full" viewBox="0 0 100 100"> - <circle cx="50" cy="50" r="45" fill="none" stroke="#f0f0f0" stroke-width="8" /> - <circle cx="50" cy="50" r="45" fill="none" stroke="#10b981" stroke-width="8" stroke-dasharray="282.7" stroke-dashoffset="113" transform="rotate(-90 50 50)" /> - </svg> - <div class="absolute flex flex-col items-center justify-center"> - <i class="fas fa-wind text-xl text-green-500"></i> - </div> - </div> + </div> + </a> + + <!-- FiO2 Card --> + <a href="{{ url_for('patients.physical_measurements', patient_id=patient.id) }}#fio2Chart" class="block bg-white shadow-md rounded-lg overflow-hidden transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> + <div class="px-4 py-3 flex justify-between items-center border-b border-gray-200"> + <span class="text-sm font-semibold text-gray-700">FiO₂</span> + <!-- Logic trạng thái FiO2 cần thêm --> + </div> + <div class="px-4 py-4 flex items-center justify-center"> + <i class="fas fa-wind text-4xl text-teal-500 mr-4"></i> + <div> + <div class="text-3xl font-bold text-gray-900">{{ latest_measurement.fio2 if latest_measurement else '--' }}</div> + <div class="text-sm text-gray-500">%</div> </div> </div> - </div> + </a> </div> </div> </div> - - <!-- Phần còn lại của tab tổng quan sẽ được thêm vào phần 2 --> </div> - <!-- Tab đo lường sinh lý --> - <div id="measurements" class="tab-pane" style="display: none;"> - <div class="bg-white shadow rounded-lg"> - <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> + <!-- Tab giới thiệu & đánh giá --> + <div id="referrals" class="tab-pane" style="display: none;"> + <div class="bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> <h3 class="text-lg leading-6 font-medium text-gray-900"> - Đo lường sinh lý + Lịch sử Giới thiệu & Đánh giá </h3> - <button type="button" class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200"> + <button type="button" class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"> <i class="fas fa-plus mr-2"></i> - Thêm mới + Thêm giới thiệu </button> </div> - - <!-- Bảng đo lường sinh lý --> - <div class="overflow-x-auto border-t border-gray-200"> - <table class="min-w-full divide-y divide-gray-200"> - <thead class="bg-gray-50"> - <tr> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Ngày/giờ - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Nhịp tim - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Huyết áp - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - SpO2 - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Nhiệt độ - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - FiO2 - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Tidal Volume - </th> - <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> - Thao tác - </th> - </tr> - </thead> - <tbody class="bg-white divide-y divide-gray-200"> - <!-- Hiển thị dữ liệu từ CSDL --> - {% for measurement in measurements|default([]) %} - <tr class="hover:bg-gray-50"> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ measurement.created_at.strftime('%d/%m/%Y %H:%M') }} - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ measurement.heart_rate }} bpm - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ measurement.blood_pressure }} - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ measurement.spo2 }}% - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ measurement.temperature }}°C - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ measurement.fio2 }}% - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ measurement.tidal_volume }} mL - </td> - <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <button class="text-primary-600 hover:text-primary-900 mr-3"> - <i class="fas fa-edit"></i> - </button> - <button class="text-red-600 hover:text-red-900"> - <i class="fas fa-trash"></i> - </button> - </td> - </tr> - {% else %} - <tr> - <td colspan="8" class="px-6 py-4 text-center text-sm text-gray-500"> - Không có dữ liệu đo lường - </td> - </tr> - {% endfor %} - - <!-- Dữ liệu mẫu --> - <tr class="hover:bg-gray-50"> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 11/06/2023 08:30 - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 85 bpm - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 120/80 mmHg - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 98% - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 36.8°C - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 60% - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 450 mL - </td> - <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <button class="text-primary-600 hover:text-primary-900 mr-3"> - <i class="fas fa-edit"></i> - </button> - <button class="text-red-600 hover:text-red-900"> - <i class="fas fa-trash"></i> - </button> - </td> - </tr> - </tbody> - </table> + <div class="border-t border-gray-200 px-4 py-5 sm:p-6"> + {% if referrals %} + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ngày Yêu cầu</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nguồn</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ghi chú</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for referral in referrals %} + <tr class="hover:bg-gray-50"> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ referral.id }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ referral.referralRequestedDateTime.strftime('%d/%m/%Y %H:%M') if referral.referralRequestedDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ referral.referral_status }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ 'ML' if referral.is_ml_recommended else 'Staff' if referral.is_staff_referred else 'Unknown' }}</td> + <td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate" title="{{ referral.notes }}">{{ referral.notes }}</td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="#" class="text-blue-600 hover:text-blue-900">Chi tiết</a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% else %} + <p class="text-center text-gray-500 py-4">Không có lịch sử giới thiệu nào.</p> + {% endif %} </div> </div> </div> - <!-- Tab giới thiệu & đánh giá --> - <div id="referrals" class="tab-pane" style="display: none;"> - <div class="bg-white shadow rounded-lg"> - <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> + <!-- Tab Thủ thuật --> + <div id="procedures" class="tab-pane" style="display: none;"> + <div class="bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> <h3 class="text-lg leading-6 font-medium text-gray-900"> - Giới thiệu & Đánh giá + Lịch sử Thủ thuật </h3> - <button type="button" class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200"> + <button type="button" class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"> <i class="fas fa-plus mr-2"></i> - Thêm giới thiệu + Thêm thủ thuật </button> </div> - - <div class="border-t border-gray-200 px-4 py-5 sm:p-6"> - <!-- Trạng thái giới thiệu hiện tại --> - <div class="mb-6 p-4 border border-gray-200 rounded-md bg-blue-50"> - <div class="flex items-center"> - <i class="fas fa-info-circle text-blue-600 text-xl mr-3"></i> - <div> - <h4 class="text-md font-medium text-gray-900">Trạng thái giới thiệu</h4> - <div class="mt-1 flex items-center"> - <span class="mr-2 font-medium">Mức điểm dự đoán:</span> - <div class="flex items-center"> - <span class="text-md font-bold text-blue-800">0.78</span> - <div class="ml-2 h-2 w-24 bg-gray-200 rounded-full overflow-hidden"> - <div class="h-full bg-blue-600" style="width: 78%"></div> - </div> - </div> - <span class="ml-4 px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800"> - Nên giới thiệu - </span> - </div> - </div> - </div> - </div> - - <!-- Lịch sử giới thiệu --> - <h4 class="text-md font-medium text-gray-900 mb-4">Lịch sử giới thiệu</h4> - + <div class="border-t border-gray-200 px-4 py-5 sm:p-6"> + {% if procedures %} <div class="overflow-x-auto"> <table class="min-w-full divide-y divide-gray-200"> - <thead class="bg-gray-50"> + <thead class="bg-gray-50"> <tr> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Ngày giới thiệu - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Bác sĩ - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Phòng ban - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Mức độ - </th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Trạng thái - </th> - <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> - Thao tác - </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tên Thủ thuật</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Loại</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Thời gian Bắt đầu</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Thời gian Kết thúc</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kết quả</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> </tr> </thead> - <tbody class="bg-white divide-y divide-gray-200"> - <!-- Hiển thị dữ liệu từ CSDL --> - {% for referral in referrals|default([]) %} + <tbody class="bg-white divide-y divide-gray-200"> + {% for procedure in procedures %} <tr class="hover:bg-gray-50"> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ referral.created_at.strftime('%d/%m/%Y') }} - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ referral.doctor_name }} - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ referral.department }} - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ referral.urgency }} - </td> - <td class="px-6 py-4 whitespace-nowrap"> - <span class="px-2 py-1 text-xs font-medium rounded-full - {% if referral.status == 'Đã chấp nhận' %} - bg-green-100 text-green-800 - {% elif referral.status == 'Đang xử lý' %} - bg-yellow-100 text-yellow-800 - {% else %} - bg-gray-100 text-gray-800 - {% endif %}"> - {{ referral.status }} - </span> - </td> - <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <button class="text-primary-600 hover:text-primary-900"> - <i class="fas fa-eye mr-1"></i> Xem - </button> - </td> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ procedure.id }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ procedure.procedureName }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ procedure.procedureType }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ procedure.procedureDateTime.strftime('%d/%m/%Y %H:%M') if procedure.procedureDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ procedure.procedureEndDateTime.strftime('%d/%m/%Y %H:%M') if procedure.procedureEndDateTime else 'N/A' }}</td> + <td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate" title="{{ procedure.procedureResults }}">{{ procedure.procedureResults }}</td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="#" class="text-blue-600 hover:text-blue-900">Chi tiết</a> + </td> </tr> - {% else %} - <!-- Dữ liệu mẫu --> + {% endfor %} + </tbody> + </table> + </div> + {% else %} + <p class="text-center text-gray-500 py-4">Không có lịch sử thủ thuật nào.</p> + {% endif %} + </div> + </div> + </div> + + <!-- Tab Báo cáo --> + <div id="reports" class="tab-pane" style="display: none;"> + <div class="bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> + <h3 class="text-lg leading-6 font-medium text-gray-900"> + Lịch sử Báo cáo + </h3> + <a href="{{ url_for('report.new_report') }}" class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"> + <i class="fas fa-plus mr-2"></i> + Tạo báo cáo mới + </a> + </div> + <div class="border-t border-gray-200 px-4 py-5 sm:p-6"> + {% if reports %} + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tiêu đề</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ngày tạo</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Người tạo</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for report in reports %} <tr class="hover:bg-gray-50"> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 15/05/2023 - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - Bs. Nguyễn Văn A - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - Tim mạch - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - Khẩn cấp - </td> - <td class="px-6 py-4 whitespace-nowrap"> - <span class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800"> - Đã chấp nhận - </span> - </td> - <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <button class="text-primary-600 hover:text-primary-900"> - <i class="fas fa-eye mr-1"></i> Xem - </button> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ report.id }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ report.report_title }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ report.report_date.strftime('%d/%m/%Y %H:%M') if report.report_date else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ report.author_id }}</td> {# Cần join để lấy tên người tạo #} + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800' if report.status == 'completed' else 'bg-yellow-100 text-yellow-800' }}"> + {{ report.status|capitalize }} + </span> </td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="{{ url_for('report.view_report', report_id=report.id) }}" class="text-blue-600 hover:text-blue-900 mr-2">Xem</a> + <a href="{{ url_for('report.edit_report', report_id=report.id) }}" class="text-indigo-600 hover:text-indigo-900">Sửa</a> + </td> </tr> + {% endfor %} + </tbody> + </table> + </div> + {% else %} + <p class="text-center text-gray-500 py-4">Không có báo cáo nào.</p> + {% endif %} + </div> + </div> + </div> + + <!-- Tab Lượt khám (Encounters) --> + <div id="encounters" class="tab-pane" style="display: none;"> + <div class="bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> + <h3 class="text-lg leading-6 font-medium text-gray-900"> + Lịch sử Lượt khám (Encounters) + </h3> + <!-- Nút thêm encounter mới nếu cần --> + </div> + <div class="border-t border-gray-200 px-4 py-5 sm:p-6"> + {% if encounters %} + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID Encounter</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ngày nhập viện</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ngày ra viện</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ngày vào CCU</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ngày ra CCU</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên gia DD</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for encounter in encounters %} <tr class="hover:bg-gray-50"> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - 10/06/2023 - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - Bs. Trần Thị B - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - Thần kinh - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - Thường quy - </td> - <td class="px-6 py-4 whitespace-nowrap"> - <span class="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800"> - Đang xử lý - </span> - </td> - <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <button class="text-primary-600 hover:text-primary-900"> - <i class="fas fa-eye mr-1"></i> Xem - </button> - </td> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ encounter.id }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ encounter.admissionDateTime.strftime('%d/%m/%Y %H:%M') if encounter.admissionDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ encounter.dischargeDateTime.strftime('%d/%m/%Y %H:%M') if encounter.dischargeDateTime else 'Đang điều trị' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ encounter.ccuAdmissionDateTime.strftime('%d/%m/%Y %H:%M') if encounter.ccuAdmissionDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ encounter.ccuDischargeDateTime.strftime('%d/%m/%Y %H:%M') if encounter.ccuDischargeDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ encounter.dietitian.fullName if encounter.dietitian else 'Chưa gán' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <a href="#" class="text-blue-600 hover:text-blue-900">Chi tiết</a> + {# Thêm nút sửa/xóa encounter nếu cần #} + </td> </tr> {% endfor %} </tbody> </table> </div> + {% else %} + <p class="text-center text-gray-500 py-4">Không có lịch sử lượt khám nào.</p> + {% endif %} </div> </div> </div> - <!-- Tabs khác sẽ được thêm vào trong các phần tiếp theo --> + <!-- Các tab khác sẽ được thêm vào đây --> </div> </div> {% endblock %} {% block scripts %} +{{ super() }} {# Kế thừa scripts từ base.html nếu có #} <script> document.addEventListener('DOMContentLoaded', () => { // Quản lý tabs const tabLinks = document.querySelectorAll('.tab-link'); const tabPanes = document.querySelectorAll('.tab-pane'); - + const statusCard = document.querySelector('.status-card'); // Thêm class 'status-card' vào thẻ trạng thái + // Lấy tab từ hash URL hoặc hiển thị tab mặc định - const hash = window.location.hash.substring(1); - showTab(hash || 'overview'); - - // Cập nhật các tab link dựa vào tab hiện tại - updateTabLinks(hash || 'overview'); - + const currentHash = window.location.hash.substring(1); + const defaultTab = 'overview'; + const initialTab = document.getElementById(currentHash) ? currentHash : defaultTab; + + showTab(initialTab); + updateTabLinks(initialTab); + tabLinks.forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); - - // Lấy target tab từ thuộc tính data-target const target = link.getAttribute('data-target'); - - // Cập nhật URL hash - window.location.hash = target; - - // Cập nhật các tab link - updateTabLinks(target); - - // Hiển thị tab được chọn - showTab(target); + if (target !== window.location.hash.substring(1)) { + window.location.hash = target; + updateTabLinks(target); + showTab(target); + } }); }); - + + // Lắng nghe sự kiện thay đổi hash (ví dụ: khi người dùng bấm nút back/forward) + window.addEventListener('hashchange', () => { + const newHash = window.location.hash.substring(1); + const targetTab = document.getElementById(newHash) ? newHash : defaultTab; + if (targetTab !== getCurrentActiveTab()) { // Chỉ cập nhật nếu hash thực sự thay đổi tab + updateTabLinks(targetTab); + showTab(targetTab); + } + }); + + function getCurrentActiveTab(){ + for(const pane of tabPanes){ + if(pane.style.display !== 'none'){ + return pane.id; + } + } + return defaultTab; + } + function updateTabLinks(activeTabId) { - // Xóa lớp active từ tất cả các tab tabLinks.forEach(el => { - el.classList.remove('border-primary-500', 'text-primary-600'); - el.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); + const target = el.getAttribute('data-target'); + if (target === activeTabId) { + el.classList.remove('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); + el.classList.add('border-blue-500', 'text-blue-600'); + el.setAttribute('aria-current', 'page'); + } else { + el.classList.remove('border-blue-500', 'text-blue-600'); + el.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); + el.removeAttribute('aria-current'); + } }); - - // Thêm lớp active cho tab được chọn - const activeLink = document.querySelector(`.tab-link[data-target="${activeTabId}"]`); - if (activeLink) { - activeLink.classList.remove('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); - activeLink.classList.add('border-primary-500', 'text-primary-600'); - } } - + function showTab(tabId) { - // Ẩn tất cả các tab + let tabFound = false; tabPanes.forEach(pane => { - pane.style.display = 'none'; + if (pane.id === tabId) { + pane.style.display = 'block'; + pane.classList.add('animate-fade-in'); // Thêm animation + tabFound = true; + } else { + pane.style.display = 'none'; + pane.classList.remove('animate-fade-in'); + } }); - - // Hiển thị tab được chọn - const activePane = document.getElementById(tabId); - if (activePane) { - activePane.style.display = 'block'; - - // Thêm hiệu ứng fade-in - activePane.classList.add('fade-in'); - - // Animation cho các phần tử trong tab - const animatedElements = activePane.querySelectorAll('.animate-on-tab-show'); - animatedElements.forEach((el, index) => { - el.style.animationDelay = `${index * 0.1}s`; - el.classList.add('animate-slide-in-bottom'); - }); + // Nếu không tìm thấy tab hợp lệ, hiển thị tab mặc định + if (!tabFound && tabId !== defaultTab) { + showTab(defaultTab); + updateTabLinks(defaultTab); + window.location.hash = defaultTab; // Reset hash về mặc định + } + toggleStatusCard(tabId); + } + + function toggleStatusCard(tabId) { + if (statusCard) { + statusCard.style.display = (tabId === 'overview') ? 'block' : 'none'; } } + + // --- Thêm các script khác nếu cần, ví dụ khởi tạo biểu đồ nhỏ nếu có --- + }); </script> {% endblock %} diff --git a/app/templates/patients.html b/app/templates/patients.html index 469265f41d874b8895e16ec8b9dfc9ea64c05b0b..9e4593029435c4dd33303c3c23cc8128cdaf09ab 100644 --- a/app/templates/patients.html +++ b/app/templates/patients.html @@ -2,25 +2,16 @@ {% block title %}Danh sách bệnh nhân - CCU_BVNM{% endblock %} -{% block header %}Danh sách bệnh nhân{% endblock %} +{% block header %}Patient list{% endblock %} {% block content %} <div class="animate-slide-in"> <div class="md:flex md:items-center md:justify-between mb-6"> - <div class="flex-1 min-w-0"> - <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate"> - Patient List - </h2> - </div> <div class="mt-4 flex md:mt-0 md:ml-4"> <button type="button" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:-translate-y-1"> <i class="fas fa-download -ml-1 mr-2 text-gray-500"></i> Export List - </button> - <button type="button" onclick="window.location.href='{{ url_for('patients.new_patient') }}'" class="ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:-translate-y-1"> - <i class="fas fa-plus -ml-1 mr-2"></i> - Add Patient - </button> + </button> </div> </div> diff --git a/app/templates/physical_measurements.html b/app/templates/physical_measurements.html new file mode 100644 index 0000000000000000000000000000000000000000..4112cb28ea218a3b2cb39ca875f8b716e8240c63 --- /dev/null +++ b/app/templates/physical_measurements.html @@ -0,0 +1,791 @@ +{% extends "base.html" %} + +{% block title %}Biểu đồ đo lường sinh lý - {{ patient.full_name }} - CCU HTM{% endblock %} + +{% block header %}Biểu đồ đo lường sinh lý - {{ patient.full_name }}{% endblock %} + +{% block content %} +<div class="animate-slide-in container mx-auto px-4 py-8"> + <!-- Breadcrumb --> + <div class="mb-6"> + <nav class="flex" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <i class="fas fa-home mr-2"></i> Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.index') }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">Bệnh nhân</a> + </div> + </li> + <li> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}" class="text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200">{{ patient.full_name }} ({{ patient.id }})</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <i class="fas fa-chevron-right text-gray-400 mx-2"></i> + <span class="text-sm font-medium text-gray-500">Biểu đồ đo lường</span> + </div> + </li> + </ol> + </nav> + </div> + + <!-- Các nút thao tác --> + <div class="flex justify-end items-center mb-6 space-x-3"> + <!-- Nút Upload CSV --> + <form id="uploadCsvForm" action="{{ url_for('patients.upload_measurements_csv', patient_id=patient.id) }}" method="post" enctype="multipart/form-data" class="inline-flex"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> + <label for="csvFile" class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all duration-200"> + <i class="fas fa-file-csv -ml-1 mr-2 text-gray-500"></i> + Upload CSV + </label> + <input type="file" id="csvFile" name="csv_file" class="hidden" accept=".csv"> + </form> + + <!-- Nút Thêm đo lường --> + <a href="{{ url_for('patients.new_physical_measurement', patient_id=patient.id) }}" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-1"> + <i class="fas fa-plus -ml-1 mr-2"></i> + Thêm đo lường + </a> + </div> + + <!-- Container thông báo --> + <div id="toast-container" class="fixed top-4 right-4 z-50 flex flex-col items-end space-y-2"></div> + + <!-- Macro tạo card biểu đồ --> + {% macro chart_card(chart_id, title, latest_value, unit, metric_name) %} + <div class="bg-white p-4 rounded-lg shadow-md transition hover:shadow-lg h-full flex flex-col"> + <h3 class="font-semibold text-gray-800 mb-2">{{ title }}</h3> + <div class="flex-grow"> + <canvas id="{{ chart_id }}" width="100%" height="200"></canvas> + </div> + <div class="mt-3 text-center"> + <p class="text-sm text-gray-600">Giá trị gần nhất</p> + <p class="text-xl font-bold latest-value" data-metric-name="{{ metric_name }}">{{ latest_value if latest_value is not none else 'N/A' }} {{ unit }}</p> + </div> + + <!-- Phần nhập liệu nhanh (inline, separated, rounded) --> + <div class="mt-3 pt-3 border-t border-gray-200"> + {# Container flex row, gap-2 #} + <div class="quick-input-container flex items-stretch gap-2" data-metric="{{ metric_name }}"> + {# Input: rounded-lg #} + <input type="number" step="any" placeholder="Giá trị mới..." + class="quick-measurement-input flex-grow p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-300 ease-in-out" + data-metric="{{ metric_name }}"> + {# Button: rounded-lg, transition width/opacity #} + <button type="button" + class="quick-save-btn flex-shrink-0 bg-blue-500 hover:bg-blue-600 text-white px-3 py-2 rounded-lg shadow-md flex items-center justify-center transform transition-all duration-300 ease-in-out will-change-transform" + style="transform: scale(0); opacity: 0; pointer-events: none;" + data-metric="{{ metric_name }}"> + <i class="fas fa-save text-sm mr-1"></i> + <span class="save-text whitespace-nowrap">Lưu</span> + </button> + </div> + </div> + </div> + {% endmacro %} + + {% macro bp_chart_card() %} + <div class="bg-white p-4 rounded-lg shadow-md transition hover:shadow-lg h-full flex flex-col"> + <h3 class="font-semibold text-gray-800 mb-2">Huyết áp</h3> + <div class="flex-grow"> + <canvas id="bpChart" width="100%" height="200"></canvas> + </div> + <div class="mt-3 text-center"> + <p class="text-sm text-gray-600">Giá trị gần nhất</p> + <p class="text-xl font-bold latest-value" data-metric-name="blood_pressure"> + {% if latest_bp %} + {{ latest_bp.systolicBP }}/{{ latest_bp.diastolicBP }} mmHg + {% else %} + N/A + {% endif %} + </p> + </div> + + <!-- Phần nhập liệu nhanh huyết áp --> + <div class="mt-3 pt-3 border-t border-gray-200"> + {# Container flex column, gap-2 #} + <div class="quick-input-container bp-container flex flex-col gap-2" data-metric="bloodPressure"> + {# Input row: flex, gap-2 #} + <div class="flex gap-2 w-full"> + <input type="number" step="1" placeholder="Tâm thu..." + class="quick-measurement-input w-1/2 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-300 ease-in-out" + data-metric="systolicBP"> + <input type="number" step="1" placeholder="Tâm trương..." + class="quick-measurement-input w-1/2 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-300 ease-in-out" + data-metric="diastolicBP"> + </div> + {# Button: transition opacity/scale #} + <button type="button" + class="quick-save-btn w-full opacity-0 transform scale-95 pointer-events-none bg-blue-500 hover:bg-blue-600 text-white px-3 py-2 rounded-lg transition-all duration-300 ease-in-out shadow-md flex items-center justify-center" + data-metric="bloodPressure"> + <i class="fas fa-save mr-2"></i> Lưu huyết áp + </button> + </div> + </div> + </div> + {% endmacro %} + + {% if measurements %} + <!-- Grid layout for charts --> + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6"> + <!-- Sử dụng macro để tạo từng biểu đồ --> + {{ chart_card('temperatureChart', 'Nhiệt độ', latest_measurement.temperature, '°C', 'temperature') }} + {{ chart_card('heartRateChart', 'Nhịp tim', latest_measurement.heart_rate, 'bpm', 'heart_rate') }} + {{ bp_chart_card() }} + {{ chart_card('oxygenSaturationChart', 'SpO₂', latest_measurement.oxygen_saturation, '%', 'oxygen_saturation') }} + {{ chart_card('fio2Chart', 'FiO₂', latest_measurement.fio2, '%', 'fio2') }} + {{ chart_card('fio2RatioChart', 'Tỷ lệ FiO₂', latest_measurement.fio2_ratio, '', 'fio2_ratio') }} + {{ chart_card('tidalVolChart', 'Thể tích khí lưu thông (Tidal Vol)', latest_measurement.tidal_vol, 'mL', 'tidal_vol') }} + {{ chart_card('tidalVolKgChart', 'Tidal Vol/kg', latest_measurement.tidal_vol_kg, 'mL/kg', 'tidal_vol_kg') }} + {{ chart_card('tidalVolActualChart', 'Tidal Vol thực tế (Actual)', latest_measurement.tidal_vol_actual, 'mL', 'tidal_vol_actual') }} + {{ chart_card('tidalVolSponChart', 'Tidal Vol tự phát (Spon)', latest_measurement.tidal_vol_spon, 'mL', 'tidal_vol_spon') }} + {{ chart_card('endTidalCO2Chart', 'End Tidal CO₂', latest_measurement.end_tidal_co2, 'mmHg', 'end_tidal_co2') }} + {{ chart_card('feedVolChart', 'Thể tích thức ăn (Feed Vol)', latest_measurement.feed_vol, 'mL', 'feed_vol') }} + {{ chart_card('feedVolAdmChart', 'Feed Vol Adm', latest_measurement.feed_vol_adm, 'mL', 'feed_vol_adm') }} + {{ chart_card('peepChart', 'PEEP', latest_measurement.peep, 'cmH₂O', 'peep') }} + {{ chart_card('pipChart', 'PIP', latest_measurement.pip, 'cmH₂O', 'pip') }} + {{ chart_card('respRateChart', 'Nhịp thở', latest_measurement.resp_rate, 'bpm', 'resp_rate') }} + {{ chart_card('sipChart', 'SIP', latest_measurement.sip, 'cmH₂O', 'sip') }} + {{ chart_card('inspTimeChart', 'Thời gian hít vào (Insp Time)', latest_measurement.insp_time, 's', 'insp_time') }} + </div> + {% else %} + <!-- Vẫn hiển thị grid các card biểu đồ trống khi không có dữ liệu --> + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6"> + <!-- Sử dụng macro để tạo từng biểu đồ với giá trị N/A --> + {{ chart_card('temperatureChart', 'Nhiệt độ', 'N/A', '°C', 'temperature') }} + {{ chart_card('heartRateChart', 'Nhịp tim', 'N/A', 'bpm', 'heart_rate') }} + {{ bp_chart_card() }} <!-- Macro này đã xử lý trường hợp latest_bp rỗng --> + {{ chart_card('oxygenSaturationChart', 'SpO₂', 'N/A', '%', 'oxygen_saturation') }} + {{ chart_card('fio2Chart', 'FiO₂', 'N/A', '%', 'fio2') }} + {{ chart_card('fio2RatioChart', 'Tỷ lệ FiO₂', 'N/A', '', 'fio2_ratio') }} + {{ chart_card('tidalVolChart', 'Thể tích khí lưu thông (Tidal Vol)', 'N/A', 'mL', 'tidal_vol') }} + {{ chart_card('tidalVolKgChart', 'Tidal Vol/kg', 'N/A', 'mL/kg', 'tidal_vol_kg') }} + {{ chart_card('tidalVolActualChart', 'Tidal Vol thực tế (Actual)', 'N/A', 'mL', 'tidal_vol_actual') }} + {{ chart_card('tidalVolSponChart', 'Tidal Vol tự phát (Spon)', 'N/A', 'mL', 'tidal_vol_spon') }} + {{ chart_card('endTidalCO2Chart', 'End Tidal CO₂', 'N/A', 'mmHg', 'end_tidal_co2') }} + {{ chart_card('feedVolChart', 'Thể tích thức ăn (Feed Vol)', 'N/A', 'mL', 'feed_vol') }} + {{ chart_card('feedVolAdmChart', 'Feed Vol Adm', 'N/A', 'mL', 'feed_vol_adm') }} + {{ chart_card('peepChart', 'PEEP', 'N/A', 'cmH₂O', 'peep') }} + {{ chart_card('pipChart', 'PIP', 'N/A', 'cmH₂O', 'pip') }} + {{ chart_card('respRateChart', 'Nhịp thở', 'N/A', 'bpm', 'resp_rate') }} + {{ chart_card('sipChart', 'SIP', 'N/A', 'cmH₂O', 'sip') }} + {{ chart_card('inspTimeChart', 'Thời gian hít vào (Insp Time)', 'N/A', 's', 'insp_time') }} + </div> + <div class="text-center p-6 bg-gray-50 rounded-lg border border-gray-200"> + <p class="text-gray-600 mb-4">Chưa có dữ liệu đo lường nào cho bệnh nhân này.</p> + <a href="{{ url_for('patients.new_physical_measurement', patient_id=patient.id) }}" class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-all duration-200"> + <i class="fas fa-plus mr-2"></i> + Thêm đo lường đầu tiên + </a> + </div> + {% endif %} +</div> +{% endblock %} + +{% block scripts %} +<!-- Chart.js --> +<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script> +<script src="https://cdn.jsdelivr.net/npm/luxon@2.3.1/build/global/luxon.min.js"></script> +<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.1.0/dist/chartjs-adapter-luxon.min.js"></script> + +<script> +// Biến lưu các đối tượng biểu đồ +let heartRateChart, temperatureChart, bpChart, oxygenSaturationChart, respiratoryRateChart; +let fio2Chart, fio2RatioChart, tidalVolChart, tidalVolKgChart, tidalVolActualChart; +let tidalVolSponChart, endTidalCO2Chart, feedVolChart, feedVolAdmChart; +let peepChart, pipChart, respRateChart, sipChart, inspTimeChart, oxygenFlowRateChart; + +// Định nghĩa hàm tạo biểu đồ +function createChart(canvasId, label, data, unit, chartType = 'line', color = 'rgba(54, 162, 235, 1)') { + const canvas = document.getElementById(canvasId); + if (!canvas) return null; + + const ctx = canvas.getContext('2d'); + + const backgroundColor = color.replace(", 1)", ", 0.2)"); // Tạo màu nền nhạt hơn + + const chartOptions = { + type: chartType, // Sử dụng chartType được truyền vào + data: { + datasets: [{ + label: label, + data: data, + backgroundColor: backgroundColor, + borderColor: color, + borderWidth: chartType === 'line' ? 2 : 1, // Border mảnh hơn cho bar chart + pointBackgroundColor: color, + pointBorderColor: '#fff', + pointRadius: chartType === 'line' ? 4 : 0, // Không cần point cho bar chart + pointHoverRadius: chartType === 'line' ? 6 : 0, + tension: 0.1, + fill: chartType === 'line' // Chỉ fill cho line chart + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: { + unit: 'hour', + displayFormats: { + hour: 'HH:mm' + }, + tooltipFormat: 'dd/MM/yyyy HH:mm' + }, + title: { + display: true, + text: 'Thời gian' + } + }, + y: { + title: { + display: true, + text: unit + }, + beginAtZero: chartType === 'bar' // Bắt đầu trục Y từ 0 cho bar chart + } + }, + plugins: { + tooltip: { + callbacks: { + label: function(context) { + return `${label}: ${context.parsed.y} ${unit}`; + } + } + } + } + } + }; + + // Điều chỉnh options cho bar chart nếu cần + if (chartType === 'bar') { + chartOptions.options.scales.x.time = undefined; // Bar chart không dùng trục thời gian liên tục + chartOptions.options.scales.x.type = 'category'; // Có thể cần điều chỉnh nếu data không phù hợp + // Có thể thêm các tùy chỉnh khác cho bar chart tại đây + } + + return new Chart(ctx, chartOptions); +} + +// Hàm tạo biểu đồ huyết áp (vẫn là line) +function createBPChart(canvasId, data) { + const canvas = document.getElementById(canvasId); + if (!canvas) return null; + + const ctx = canvas.getContext('2d'); + + return new Chart(ctx, { + type: 'line', + data: { + datasets: [ + { + label: 'Tâm thu', + data: data.map(d => ({x: d.x, y: d.systolic})), + backgroundColor: 'rgba(255, 99, 132, 0.2)', + borderColor: 'rgba(255, 99, 132, 1)', + borderWidth: 2, + pointBackgroundColor: 'rgba(255, 99, 132, 1)', + pointBorderColor: '#fff', + pointRadius: 4, + pointHoverRadius: 6, + tension: 0.1, + fill: false + }, + { + label: 'Tâm trương', + data: data.map(d => ({x: d.x, y: d.diastolic})), + backgroundColor: 'rgba(54, 162, 235, 0.2)', + borderColor: 'rgba(54, 162, 235, 1)', + borderWidth: 2, + pointBackgroundColor: 'rgba(54, 162, 235, 1)', + pointBorderColor: '#fff', + pointRadius: 4, + pointHoverRadius: 6, + tension: 0.1, + fill: false + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: { + unit: 'hour', + displayFormats: { + hour: 'HH:mm' + }, + tooltipFormat: 'dd/MM/yyyy HH:mm' + }, + title: { + display: true, + text: 'Thời gian' + } + }, + y: { + title: { + display: true, + text: 'mmHg' + } + } + }, + plugins: { + tooltip: { + callbacks: { + label: function(context) { + const dataset = context.dataset; + const value = context.parsed.y; + return `${dataset.label}: ${value} mmHg`; + } + } + } + } + } + }); +} + +// Hàm cập nhật dữ liệu cho biểu đồ đơn giản +function updateChart(chart, newData) { + if (!chart) return; + + chart.data.datasets[0].data = newData; + chart.update(); +} + +// Hàm cập nhật dữ liệu cho biểu đồ huyết áp +function updateBPChart(chart, newData) { + if (!chart) return; + + chart.data.datasets[0].data = newData.map(d => ({x: d.x, y: d.systolic})); + chart.data.datasets[1].data = newData.map(d => ({x: d.x, y: d.diastolic})); + chart.update(); +} + +// Hiện toast thông báo +function showToast(message, type = 'success') { + const toastContainer = document.getElementById('toast-container'); + const toast = document.createElement('div'); + + toast.className = `flex items-center px-4 py-3 rounded shadow-md max-w-sm ${ + type === 'success' ? 'bg-green-50 text-green-800 border-l-4 border-green-500' : + type === 'error' ? 'bg-red-50 text-red-800 border-l-4 border-red-500' : + 'bg-blue-50 text-blue-800 border-l-4 border-blue-500' + }`; + + toast.innerHTML = ` + <i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} mr-2"></i> + <span class="text-sm">${message}</span> + `; + + toastContainer.appendChild(toast); + + // Xóa toast sau 3 giây + setTimeout(() => { + toast.classList.add('opacity-0'); + setTimeout(() => { + toast.remove(); + }, 300); + }, 3000); +} + +// Hàm cập nhật biểu đồ đơn lẻ (thêm 1 điểm) +function addDataPointToChart(chart, newDataPoint) { + if (!chart || !newDataPoint || !newDataPoint.measurementDateTime) return; + + // Kiểm tra xem chart.metricKey có tồn tại và có trong newDataPoint không + if (!chart.metricKey || !(chart.metricKey in newDataPoint)) { + console.error(`Metric key '${chart.metricKey}' not found in newDataPoint for chart ID '${chart.canvas.id}'`); + return; + } + + chart.data.datasets[0].data.push({ + x: newDataPoint.measurementDateTime, // Use ISO string time + y: newDataPoint[chart.metricKey] + }); + // Giữ số lượng điểm giới hạn để tránh quá tải (ví dụ: 100 điểm) + // const maxDataPoints = 100; + // if (chart.data.datasets[0].data.length > maxDataPoints) { + // chart.data.datasets[0].data.shift(); + // } + chart.update(); +} + +// Hàm cập nhật biểu đồ huyết áp (thêm 1 điểm) +function addDataPointToBPChart(chart, newDataPoint) { + if (!chart || !newDataPoint || !newDataPoint.measurementDateTime || + newDataPoint.blood_pressure_systolic === undefined || + newDataPoint.blood_pressure_diastolic === undefined) return; + + const time = newDataPoint.measurementDateTime; + + // Add systolic data + chart.data.datasets[0].data.push({ x: time, y: newDataPoint.blood_pressure_systolic }); + // Add diastolic data + chart.data.datasets[1].data.push({ x: time, y: newDataPoint.blood_pressure_diastolic }); + + // Limit data points if needed + // if (chart.data.datasets[0].data.length > maxDataPoints) { + // chart.data.datasets[0].data.shift(); + // chart.data.datasets[1].data.shift(); + // } + + chart.update(); +} + +// Khởi tạo tất cả +document.addEventListener('DOMContentLoaded', function() { + // Lấy CSRF token từ meta tag + let csrfToken; + try { + csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + console.log('CSRF Token found:', csrfToken ? 'YES' : 'NO'); + } catch (e) { + console.error('Error getting CSRF token:', e); + csrfToken = ""; + } + + // Store chart instances with their metric keys + const chartObjects = {}; + + var measurementsData = []; + /* {% if measurements %} */ + try { + measurementsData = JSON.parse('{{ measurements|tojson|safe }}'); + } catch (e) { + console.error('Lỗi khi phân tích dữ liệu đo lường:', e); + } + /* {% endif %} */ + + // Luôn gọi hàm initializeCharts, ngay cả khi measurementsData rỗng + initializeCharts(measurementsData); + + // Hàm khởi tạo biểu đồ từ dữ liệu + function initializeCharts(data) { + // data có thể là mảng rỗng nếu không có measurements + + // Chuẩn bị các mảng dữ liệu (sẽ rỗng nếu data rỗng) + const heartRateData = data.map(m => m.heart_rate !== null && m.heart_rate !== undefined ? {x: m.measurementDateTime, y: m.heart_rate} : null).filter(Boolean); + const temperatureData = data.map(m => m.temperature !== null && m.temperature !== undefined ? {x: m.measurementDateTime, y: m.temperature} : null).filter(Boolean); + const bloodPressureData = data.map(m => (m.blood_pressure_systolic !== null && m.blood_pressure_systolic !== undefined && m.blood_pressure_diastolic !== null && m.blood_pressure_diastolic !== undefined) ? {x: m.measurementDateTime, systolic: m.blood_pressure_systolic, diastolic: m.blood_pressure_diastolic} : null).filter(Boolean); + const oxygenSaturationData = data.map(m => m.oxygen_saturation !== null && m.oxygen_saturation !== undefined ? {x: m.measurementDateTime, y: m.oxygen_saturation} : null).filter(Boolean); + const respRateData = data.map(m => m.resp_rate !== null && m.resp_rate !== undefined ? {x: m.measurementDateTime, y: m.resp_rate} : null).filter(Boolean); + const fio2Data = data.map(m => m.fio2 !== null && m.fio2 !== undefined ? {x: m.measurementDateTime, y: m.fio2} : null).filter(Boolean); + const fio2RatioData = data.map(m => m.fio2_ratio !== null && m.fio2_ratio !== undefined ? {x: m.measurementDateTime, y: m.fio2_ratio} : null).filter(Boolean); + const tidalVolData = data.map(m => m.tidal_vol !== null && m.tidal_vol !== undefined ? {x: m.measurementDateTime, y: m.tidal_vol} : null).filter(Boolean); + const tidalVolKgData = data.map(m => m.tidal_vol_kg !== null && m.tidal_vol_kg !== undefined ? {x: m.measurementDateTime, y: m.tidal_vol_kg} : null).filter(Boolean); + const tidalVolActualData = data.map(m => m.tidal_vol_actual !== null && m.tidal_vol_actual !== undefined ? {x: m.measurementDateTime, y: m.tidal_vol_actual} : null).filter(Boolean); + const tidalVolSponData = data.map(m => m.tidal_vol_spon !== null && m.tidal_vol_spon !== undefined ? {x: m.measurementDateTime, y: m.tidal_vol_spon} : null).filter(Boolean); + const endTidalCO2Data = data.map(m => m.end_tidal_co2 !== null && m.end_tidal_co2 !== undefined ? {x: m.measurementDateTime, y: m.end_tidal_co2} : null).filter(Boolean); + const feedVolData = data.map(m => m.feed_vol !== null && m.feed_vol !== undefined ? {x: m.measurementDateTime, y: m.feed_vol} : null).filter(Boolean); + const feedVolAdmData = data.map(m => m.feed_vol_adm !== null && m.feed_vol_adm !== undefined ? {x: m.measurementDateTime, y: m.feed_vol_adm} : null).filter(Boolean); + const peepData = data.map(m => m.peep !== null && m.peep !== undefined ? {x: m.measurementDateTime, y: m.peep} : null).filter(Boolean); + const pipData = data.map(m => m.pip !== null && m.pip !== undefined ? {x: m.measurementDateTime, y: m.pip} : null).filter(Boolean); + const sipData = data.map(m => m.sip !== null && m.sip !== undefined ? {x: m.measurementDateTime, y: m.sip} : null).filter(Boolean); + const inspTimeData = data.map(m => m.insp_time !== null && m.insp_time !== undefined ? {x: m.measurementDateTime, y: m.insp_time} : null).filter(Boolean); + + // Màu sắc cho các biểu đồ + const colors = [ + 'rgba(54, 162, 235, 1)', // Blue + 'rgba(255, 99, 132, 1)', // Red + 'rgba(75, 192, 192, 1)', // Green + 'rgba(255, 206, 86, 1)', // Yellow + 'rgba(153, 102, 255, 1)', // Purple + 'rgba(255, 159, 64, 1)', // Orange + 'rgba(100, 181, 246, 1)', // Light Blue + 'rgba(240, 98, 146, 1)', // Pink + 'rgba(129, 199, 132, 1)', // Light Green + 'rgba(255, 241, 118, 1)', // Light Yellow + 'rgba(179, 157, 219, 1)', // Light Purple + 'rgba(255, 183, 77, 1)', // Light Orange + 'rgba(34, 150, 243, 1)', + 'rgba(233, 30, 99, 1)', + 'rgba(0, 150, 136, 1)', + 'rgba(255, 193, 7, 1)', + 'rgba(103, 58, 183, 1)', + 'rgba(255, 87, 34, 1)' + ]; + let colorIndex = 0; + const nextColor = () => colors[colorIndex++ % colors.length]; + const chartConfig = [ + { id: 'heartRateChart', label: 'Nhịp tim', data: heartRateData, unit: 'bpm', type: 'line', metric: 'heart_rate' }, + { id: 'temperatureChart', label: 'Nhiệt độ', data: temperatureData, unit: '°C', type: 'line', metric: 'temperature' }, + { id: 'oxygenSaturationChart', label: 'SpO₂', data: oxygenSaturationData, unit: '%', type: 'line', metric: 'oxygen_saturation' }, + { id: 'respRateChart', label: 'Nhịp thở', data: respRateData, unit: 'bpm', type: 'line', metric: 'resp_rate' }, + { id: 'fio2Chart', label: 'FiO₂', data: fio2Data, unit: '%', type: 'bar', metric: 'fio2' }, + { id: 'fio2RatioChart', label: 'Tỷ lệ FiO₂', data: fio2RatioData, unit: '', type: 'line', metric: 'fio2_ratio' }, + { id: 'tidalVolChart', label: 'Thể tích khí lưu thông', data: tidalVolData, unit: 'mL', type: 'line', metric: 'tidal_vol' }, + { id: 'tidalVolKgChart', label: 'Tidal Vol/kg', data: tidalVolKgData, unit: 'mL/kg', type: 'line', metric: 'tidal_vol_kg' }, + { id: 'tidalVolActualChart', label: 'Tidal Vol thực tế', data: tidalVolActualData, unit: 'mL', type: 'line', metric: 'tidal_vol_actual' }, + { id: 'tidalVolSponChart', label: 'Tidal Vol tự phát', data: tidalVolSponData, unit: 'mL', type: 'line', metric: 'tidal_vol_spon' }, + { id: 'endTidalCO2Chart', label: 'End Tidal CO₂', data: endTidalCO2Data, unit: 'mmHg', type: 'line', metric: 'end_tidal_co2' }, + { id: 'feedVolChart', label: 'Thể tích thức ăn', data: feedVolData, unit: 'mL', type: 'bar', metric: 'feed_vol' }, + { id: 'feedVolAdmChart', label: 'Feed Vol Adm', data: feedVolAdmData, unit: 'mL', type: 'bar', metric: 'feed_vol_adm' }, + { id: 'peepChart', label: 'PEEP', data: peepData, unit: 'cmH₂O', type: 'bar', metric: 'peep' }, + { id: 'pipChart', label: 'PIP', data: pipData, unit: 'cmH₂O', type: 'bar', metric: 'pip' }, + { id: 'sipChart', label: 'SIP', data: sipData, unit: 'cmH₂O', type: 'bar', metric: 'sip' }, + { id: 'inspTimeChart', label: 'Thời gian hít vào', data: inspTimeData, unit: 's', type: 'bar', metric: 'insp_time' } + ]; + + chartConfig.forEach(config => { + if (document.getElementById(config.id)) { + const chart = createChart(config.id, config.label, config.data, config.unit, config.type, nextColor()); + if (chart) { + chart.metricKey = config.metric; // Store metric key on chart object + chartObjects[config.metric] = chart; // Store chart instance + } + } + }); + + if (document.getElementById('bpChart')) { + bpChart = createBPChart('bpChart', bloodPressureData); + if (bpChart) { + bpChart.metricKey = 'blood_pressure'; // Special key for BP + chartObjects['blood_pressure'] = bpChart; + colorIndex += 2; + } + } + } + + // Xử lý ô nhập liệu nhanh + const quickInputs = document.querySelectorAll('.quick-measurement-input'); + quickInputs.forEach(input => { + const container = input.closest('.quick-input-container'); + const saveBtn = container.querySelector('.quick-save-btn'); + const isBP = container.classList.contains('bp-container'); + let otherInput = null; + if (isBP) { + otherInput = container.querySelector(`.quick-measurement-input:not([data-metric="${input.dataset.metric}"])`); + } + + // Function to show save button + const showSaveButton = () => { + if (!isBP) { + saveBtn.style.transform = 'scale(1)'; + saveBtn.style.opacity = '1'; + saveBtn.style.pointerEvents = 'auto'; + } else { + saveBtn.classList.remove('opacity-0', 'scale-95', 'pointer-events-none'); + saveBtn.classList.add('opacity-100', 'scale-100', 'pointer-events-auto'); + } + }; + + // Function to hide save button + const hideSaveButton = () => { + if (!isBP) { + saveBtn.style.transform = 'scale(0)'; + saveBtn.style.opacity = '0'; + saveBtn.style.pointerEvents = 'none'; + } else { + saveBtn.classList.add('opacity-0', 'scale-95', 'pointer-events-none'); + saveBtn.classList.remove('opacity-100', 'scale-100', 'pointer-events-auto'); + } + }; + + // Show button on focus or if input has value initially (e.g., page reload with value) + if (input.value.trim() !== '') { + showSaveButton(); + } + if (isBP && otherInput && otherInput.value.trim() !== '') { + showSaveButton(); // Show BP button if either input has value + } + + input.addEventListener('focus', showSaveButton); + input.addEventListener('input', showSaveButton); // Keep button visible while typing + + input.addEventListener('blur', function(e) { + // Need a slight delay to check if focus moved to the save button or the other BP input + setTimeout(() => { + const relatedTargetIsSaveButton = document.activeElement === saveBtn; + const thisInputHasValue = this.value.trim() !== ''; + let otherInputHasFocus = false; + let otherInputHasValue = false; + if (isBP && otherInput) { + otherInputHasFocus = document.activeElement === otherInput; + otherInputHasValue = otherInput.value.trim() !== ''; + } + + // Hide button only if focus is lost AND this input is empty AND (if BP) the other input is also empty/not focused + if (!relatedTargetIsSaveButton && !thisInputHasValue && (!isBP || (!otherInputHasFocus && !otherInputHasValue))) { + hideSaveButton(); + } + }, 150); + }); + }); + + // Xử lý sự kiện click vào nút lưu (Thêm logging để debug) + const quickSaveButtons = document.querySelectorAll('.quick-save-btn'); + quickSaveButtons.forEach(btn => { + btn.addEventListener('click', function() { + const metric = this.dataset.metric; + const container = this.closest('.quick-input-container'); + let values = {}; + let chartToUpdate = null; + let updateFunction = null; + let targetMetric = metric; + + if (metric === 'bloodPressure') { + targetMetric = 'blood_pressure'; + const systolicInput = container.querySelector('input[data-metric="systolicBP"]'); + const diastolicInput = container.querySelector('input[data-metric="diastolicBP"]'); + if (!systolicInput.value || !diastolicInput.value) { + showToast('Vui lòng nhập cả giá trị tâm thu và tâm trương', 'error'); return; + } + values = { + 'blood_pressure_systolic': parseInt(systolicInput.value), + 'blood_pressure_diastolic': parseInt(diastolicInput.value) + }; + chartToUpdate = chartObjects[targetMetric]; + updateFunction = addDataPointToBPChart; + } else { + const input = container.querySelector(`.quick-measurement-input[data-metric="${metric}"]`); + if (!input.value) { showToast('Vui lòng nhập giá trị', 'error'); return; } + values[metric] = parseFloat(input.value); + chartToUpdate = chartObjects[targetMetric]; + updateFunction = addDataPointToChart; + } + + showToast('Đang lưu dữ liệu...', 'info'); + const originalText = this.innerHTML; + this.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Đang lưu...'; + this.disabled = true; + + console.log('Sending Quick Save Data:', JSON.stringify({ values: values })); // Log dữ liệu gửi đi + + // Lấy CSRF token từ meta tag + let currentCsrfToken; + try { + currentCsrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + } catch(e) { + currentCsrfToken = ""; + } + + fetch(`/patients/{{ patient.id }}/measurements/quick_save`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': currentCsrfToken + }, + credentials: 'same-origin', // Thêm credentials để gửi cookies + body: JSON.stringify({ values: values }) + }) + .then(response => { + console.log('Quick Save Response Status:', response.status); // Log status code + if (!response.ok) { + // Nếu lỗi server (4xx, 5xx), thử đọc text để xem chi tiết lỗi + return response.text().then(text => { + console.error('Quick Save Server Error:', text); + throw new Error(text || `Lỗi Server: ${response.status}`); + }); + } + return response.json(); // Nếu ok (2xx), parse JSON + }) + .then(data => { + console.log('Quick Save Response Data:', data); // Log dữ liệu trả về + this.innerHTML = originalText; + this.disabled = false; + if (data.success) { + showToast('Đã lưu dữ liệu thành công', 'success'); + if (data.new_measurement_data) { + updateLatestValues(data.new_measurement_data); + if (chartToUpdate && updateFunction) { + updateFunction(chartToUpdate, data.new_measurement_data); + } else { + console.warn('Không tìm thấy biểu đồ hoặc hàm cập nhật cho:', targetMetric); + } + } + clearInputs(); + } else { + // Lấy message lỗi từ server nếu có + const errorMessage = data.message || 'Lỗi không xác định từ server.'; + showToast(`Lỗi: ${errorMessage}`, 'error'); + console.error('Quick Save Failed (Server Response):', data); + } + }) + .catch(error => { + // Đây là nơi báo lỗi "đã xảy ra lỗi khi lưu dữ liệu" + console.error('Quick Save Fetch/Processing Error:', error); + this.innerHTML = originalText; + this.disabled = false; + // Hiển thị lỗi chi tiết hơn nếu có + const displayError = error.message || 'Đã xảy ra lỗi khi lưu dữ liệu'; + showToast(displayError, 'error'); + }); + }); + }); + + // Hàm cập nhật giá trị mới nhất hiển thị + function updateLatestValues(newData) { + Object.keys(newData).forEach(key => { + if (key === 'measurementDateTime') return; + try { + let valueToDisplay = newData[key]; + let metricKey = key; + let unit = ''; + const unitMap = { + 'temperature': ' °C', 'heart_rate': ' bpm', 'oxygen_saturation': ' %', + 'resp_rate': ' bpm', 'fio2': ' %', 'tidal_vol': ' mL', + 'tidal_vol_kg': ' mL/kg', 'tidal_vol_actual': ' mL', 'tidal_vol_spon': ' mL', + 'end_tidal_co2': ' mmHg', 'feed_vol': ' mL', 'feed_vol_adm': ' mL', + 'peep': ' cmH₂O', 'pip': ' cmH₂O', 'sip': ' cmH₂O', 'insp_time': ' s' + }; + if (key === 'blood_pressure_systolic') { + if (newData['blood_pressure_diastolic']) { + valueToDisplay = `${newData.blood_pressure_systolic}/${newData.blood_pressure_diastolic}`; + metricKey = 'blood_pressure'; + unit = ' mmHg'; + } else return; + } else if (key === 'blood_pressure_diastolic') return; + else unit = unitMap[key] || ''; + const valueDisplay = document.querySelector(`.latest-value[data-metric-name="${metricKey}"]`); + if (valueDisplay) valueDisplay.textContent = `${valueToDisplay}${unit}`; + } catch (e) { console.error('Lỗi khi cập nhật giá trị hiển thị:', e); } + }); + } + + // Hàm xóa giá trị trong ô input + function clearInputs() { + document.querySelectorAll('.quick-measurement-input').forEach(input => { + input.value = ''; + const container = input.closest('.quick-input-container'); + const saveBtn = container.querySelector('.quick-save-btn'); + const isBP = container.classList.contains('bp-container'); + + if (saveBtn) { + if (!isBP) { + // Sử dụng transform scale cho các nút thông thường + saveBtn.style.transform = 'scale(0)'; + saveBtn.style.opacity = '0'; + saveBtn.style.pointerEvents = 'none'; + } else { + // Sử dụng classes cho nút huyết áp + saveBtn.classList.add('opacity-0', 'scale-95', 'pointer-events-none'); + saveBtn.classList.remove('opacity-100', 'scale-100', 'pointer-events-auto'); + } + } + }); + } + + // Xử lý sự kiện tải file CSV + const csvFileInput = document.getElementById('csvFile'); + if (csvFileInput) { + csvFileInput.addEventListener('change', function() { + if (this.files.length > 0) { + document.getElementById('uploadCsvForm').submit(); + } + }); + } +}); +</script> +{% endblock %} \ No newline at end of file diff --git a/fix_template_urls.py b/fix_template_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..c37361f3962b4d400e678b55fff09ce8d39f9566 --- /dev/null +++ b/fix_template_urls.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import re + +def fix_templates(): + """Sửa tất cả template đang sử dụng url_for('index') thành url_for('handle_root')""" + templates_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app', 'templates') + pattern = re.compile(r"url_for\('index'\)") + replacement = "url_for('handle_root')" + + # Đếm số file đã được sửa + fixed_count = 0 + + # Đi qua tất cả các file trong thư mục templates và các thư mục con + for root, dirs, files in os.walk(templates_dir): + for file in files: + if file.endswith('.html'): + filepath = os.path.join(root, file) + + # Đọc nội dung file + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # Kiểm tra xem có cần sửa không + if pattern.search(content): + # Sửa nội dung + modified_content = pattern.sub(replacement, content) + + # Ghi lại vào file + with open(filepath, 'w', encoding='utf-8') as f: + f.write(modified_content) + + # Tăng số file đã sửa + fixed_count += 1 + + # In thông báo + rel_path = os.path.relpath(filepath, os.path.dirname(os.path.abspath(__file__))) + print(f"Đã sửa file: {rel_path}") + + print(f"\nHoàn tất! Đã sửa {fixed_count} file template.") + +if __name__ == "__main__": + print("Bắt đầu sửa các template...") + fix_templates() \ No newline at end of file diff --git a/fix_user_password.py b/fix_user_password.py new file mode 100644 index 0000000000000000000000000000000000000000..442695718a6e270d35c9411446a893d48d53ec44 --- /dev/null +++ b/fix_user_password.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from flask_bcrypt import Bcrypt +from flask import Flask +import mysql.connector +import sys + +def fix_user_password(user_email, new_password): + """Sửa mật khẩu tài khoản người dùng trực tiếp trong database""" + + # Tạo Flask app tạm thời để sử dụng bcrypt + app = Flask(__name__) + bcrypt = Bcrypt(app) + + # Tạo hash mật khẩu mới + password_hash = bcrypt.generate_password_hash(new_password).decode('utf-8') + + print(f"Tạo hash mật khẩu mới: {password_hash}") + + # Thông tin kết nối database - lấy từ config Flask + db_host = 'localhost' + db_user = 'root' + db_password = 'MinhDZ3009' + db_name = 'ccu' + + try: + # Kết nối tới MySQL + print(f"Đang kết nối đến MySQL với user '{db_user}' tại {db_host}...") + connection = mysql.connector.connect( + host=db_host, + user=db_user, + password=db_password, + database=db_name + ) + + cursor = connection.cursor() + + # Cập nhật mật khẩu cho tài khoản người dùng + print(f"Đang cập nhật mật khẩu cho {user_email}...") + update_query = "UPDATE users SET password_hash = %s WHERE email = %s" + cursor.execute(update_query, (password_hash, user_email)) + + # Kiểm tra xem có bao nhiêu dòng bị ảnh hưởng + affected_rows = cursor.rowcount + + if affected_rows > 0: + print(f"Cập nhật thành công! {affected_rows} tài khoản đã được cập nhật.") + connection.commit() + else: + print(f"Không tìm thấy tài khoản {user_email}!") + + # Đóng kết nối + cursor.close() + connection.close() + + if affected_rows > 0: + print(f"\nMật khẩu cho {user_email} đã được đặt lại thành: {new_password}") + + return affected_rows > 0 + + except mysql.connector.Error as err: + print(f"Lỗi MySQL: {err}") + return False + except Exception as e: + print(f"Lỗi: {e}") + return False + +if __name__ == "__main__": + print("Tiện ích sửa mật khẩu người dùng CCU HTM") + print("---------------------------------------") + + # Lấy thông tin từ người dùng + user_email = input("Nhập email của tài khoản cần sửa: ") + new_password = input("Nhập mật khẩu mới: ") + + result = fix_user_password(user_email, new_password) + + if result: + print("\nHoàn tất! Mật khẩu đã được đặt lại.") + sys.exit(0) + else: + print("\nLỗi! Không thể đặt lại mật khẩu.") + sys.exit(1) \ No newline at end of file diff --git a/migrations/README b/migrations/README deleted file mode 100644 index 0e048441597444a7e2850d6d7c4ce15550f79bda..0000000000000000000000000000000000000000 --- a/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini deleted file mode 100644 index ec9d45c26a6bb54e833fd4e6ce2de29343894f4b..0000000000000000000000000000000000000000 --- a/migrations/alembic.ini +++ /dev/null @@ -1,50 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic,flask_migrate - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[logger_flask_migrate] -level = INFO -handlers = -qualname = flask_migrate - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py deleted file mode 100644 index 4c9709271b2ff28271b13c29bba5c50b80fea0ac..0000000000000000000000000000000000000000 --- a/migrations/env.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging -from logging.config import fileConfig - -from flask import current_app - -from alembic import context - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') - - -def get_engine(): - try: - # this works with Flask-SQLAlchemy<3 and Alchemical - return current_app.extensions['migrate'].db.get_engine() - except (TypeError, AttributeError): - # this works with Flask-SQLAlchemy>=3 - return current_app.extensions['migrate'].db.engine - - -def get_engine_url(): - try: - return get_engine().url.render_as_string(hide_password=False).replace( - '%', '%%') - except AttributeError: - return str(get_engine().url).replace('%', '%%') - - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -config.set_main_option('sqlalchemy.url', get_engine_url()) -target_db = current_app.extensions['migrate'].db - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def get_metadata(): - if hasattr(target_db, 'metadatas'): - return target_db.metadatas[None] - return target_db.metadata - - -def run_migrations_offline(): - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, target_metadata=get_metadata(), literal_binds=True - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online(): - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - - # this callback is used to prevent an auto-migration from being generated - # when there are no changes to the schema - # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html - def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, 'autogenerate', False): - script = directives[0] - if script.upgrade_ops.is_empty(): - directives[:] = [] - logger.info('No changes in schema detected.') - - conf_args = current_app.extensions['migrate'].configure_args - if conf_args.get("process_revision_directives") is None: - conf_args["process_revision_directives"] = process_revision_directives - - connectable = get_engine() - - with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=get_metadata(), - **conf_args - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako deleted file mode 100644 index 2c0156303a8df3ffdc9de87765bf801bf6bea4a5..0000000000000000000000000000000000000000 --- a/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/add_timestamps_and_referral_score.py b/migrations/versions/add_timestamps_and_referral_score.py deleted file mode 100644 index 54a9d9b6cb9ac16ddbeeb214df46ef1145ef0bb4..0000000000000000000000000000000000000000 --- a/migrations/versions/add_timestamps_and_referral_score.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Add timestamps and referral_score fields - -Migration tùy chỉnh để thêm các trường timestamps vào các bảng quan trọng -và thêm trường referral_score vào bảng physiologicalmeasurements. - -Revision ID: add_timestamps_referral_score -Revises: -Create Date: 2024-06-15 10:00:00 -""" - -from alembic import op -import sqlalchemy as sa -from datetime import datetime - -# revision identifiers, used by Alembic -revision = 'add_timestamps_referral_score' -down_revision = None # Điều này sẽ được tự động điều chỉnh bởi Flask-Migrate -branch_labels = None -depends_on = None - -def upgrade(): - # Thêm trường referral_score vào bảng physiologicalmeasurements - op.add_column('physiologicalmeasurements', sa.Column('referral_score', sa.Float(), nullable=True, - comment='Score from ML algorithm indicating referral recommendation (0-1)')) - - # Thêm timestamps vào bảng physiologicalmeasurements nếu chưa có - op.add_column('physiologicalmeasurements', sa.Column('created_at', sa.DateTime(), nullable=True, - server_default=sa.text('CURRENT_TIMESTAMP'))) - op.add_column('physiologicalmeasurements', sa.Column('updated_at', sa.DateTime(), nullable=True, - server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))) - - # Thêm trường updated_at vào bảng patients nếu chưa có - op.add_column('patients', sa.Column('updated_at', sa.DateTime(), nullable=True, - server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))) - - # Thêm timestamps vào bảng procedures - op.add_column('procedures', sa.Column('created_at', sa.DateTime(), nullable=True, - server_default=sa.text('CURRENT_TIMESTAMP'))) - op.add_column('procedures', sa.Column('updated_at', sa.DateTime(), nullable=True, - server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))) - - # Thêm trường updated_at vào bảng reports - op.add_column('reports', sa.Column('updated_at', sa.DateTime(), nullable=True, - server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))) - -def downgrade(): - # Xóa trường referral_score khỏi bảng physiologicalmeasurements - op.drop_column('physiologicalmeasurements', 'referral_score') - - # Xóa timestamps khỏi bảng physiologicalmeasurements - op.drop_column('physiologicalmeasurements', 'created_at') - op.drop_column('physiologicalmeasurements', 'updated_at') - - # Xóa trường updated_at khỏi bảng patients - op.drop_column('patients', 'updated_at') - - # Xóa timestamps khỏi bảng procedures - op.drop_column('procedures', 'created_at') - op.drop_column('procedures', 'updated_at') - - # Xóa trường updated_at khỏi bảng reports - op.drop_column('reports', 'updated_at') \ No newline at end of file diff --git a/run.py b/run.py index 17d1fc9659be287cabc5bad0cee1e2d665fa40a5..7a3d58dd3d01313aa6e81447a56a75589cc0964c 100644 --- a/run.py +++ b/run.py @@ -11,6 +11,34 @@ from flask import Flask from datetime import datetime from flask_migrate import Migrate, init, migrate, upgrade +# Định nghĩa schema updates cho các bảng +schema_updates = { + 'dietitians': [ + "ALTER TABLE dietitians ADD COLUMN user_id INT NULL UNIQUE", + "ALTER TABLE dietitians ADD CONSTRAINT fk_dietitian_user FOREIGN KEY (user_id) REFERENCES users(userID) ON DELETE SET NULL", + "ALTER TABLE dietitians ADD COLUMN status ENUM('available', 'unavailable', 'on_leave') DEFAULT 'available'", + "ALTER TABLE dietitians ADD COLUMN email VARCHAR(100) UNIQUE", + "ALTER TABLE dietitians ADD COLUMN phone VARCHAR(20)", + "ALTER TABLE dietitians ADD COLUMN specialization VARCHAR(100)", + "ALTER TABLE dietitians ADD COLUMN notes TEXT", + "ALTER TABLE dietitians ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE dietitians ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ], + 'patients': [ + "ALTER TABLE patients ADD COLUMN dietitianID INT NULL", + "ALTER TABLE patients ADD CONSTRAINT fk_patient_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" + ], + 'encounters': [ + "ALTER TABLE encounters ADD COLUMN dietitianID INT NULL", + "ALTER TABLE encounters ADD CONSTRAINT fk_encounter_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" + ], + 'procedures': [ + "ALTER TABLE procedures ADD COLUMN procedureName VARCHAR(100)", + "ALTER TABLE procedures ADD COLUMN procedureEndDateTime DATETIME", + "ALTER TABLE procedures ADD COLUMN procedureResults TEXT" + ] +} + def clear_cache(): print("\n[Cache] Cleaning __pycache__ and .pyc files...") for dirpath, _, filenames in os.walk(os.path.dirname(__file__)): @@ -180,6 +208,34 @@ def update_database_schema(app, db): Cách này nhanh hơn so với migration nhưng chỉ nên dùng trong môi trường phát triển """ try: + # Định nghĩa schema updates cho các bảng + schema_updates = { + 'dietitians': [ + "ALTER TABLE dietitians ADD COLUMN user_id INT NULL UNIQUE", + "ALTER TABLE dietitians ADD CONSTRAINT fk_dietitian_user FOREIGN KEY (user_id) REFERENCES users(userID) ON DELETE SET NULL", + "ALTER TABLE dietitians ADD COLUMN status ENUM('available', 'unavailable', 'on_leave') DEFAULT 'available'", + "ALTER TABLE dietitians ADD COLUMN email VARCHAR(100) UNIQUE", + "ALTER TABLE dietitians ADD COLUMN phone VARCHAR(20)", + "ALTER TABLE dietitians ADD COLUMN specialization VARCHAR(100)", + "ALTER TABLE dietitians ADD COLUMN notes TEXT", + "ALTER TABLE dietitians ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE dietitians ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ], + 'patients': [ + "ALTER TABLE patients ADD COLUMN dietitianID INT NULL", + "ALTER TABLE patients ADD CONSTRAINT fk_patient_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" + ], + 'encounters': [ + "ALTER TABLE encounters ADD COLUMN dietitianID INT NULL", + "ALTER TABLE encounters ADD CONSTRAINT fk_encounter_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" + ], + 'procedures': [ + "ALTER TABLE procedures ADD COLUMN procedureName VARCHAR(100)", + "ALTER TABLE procedures ADD COLUMN procedureEndDateTime DATETIME", + "ALTER TABLE procedures ADD COLUMN procedureResults TEXT" + ] + } + # Đọc thông tin kết nối từ cấu hình db_uri = app.config['SQLALCHEMY_DATABASE_URI'] @@ -209,16 +265,20 @@ def update_database_schema(app, db): cursor = connection.cursor() - # Import tất cả model để đảm bảo chúng được khai báo - from app.models.patient import Patient, Encounter - from app.models.user import User, Dietitian - from app.models.measurement import PhysiologicalMeasurement - from app.models.procedure import Procedure - from app.models.referral import Referral - from app.models.report import Report - from app.models.uploaded_file import UploadedFile - with app.app_context(): + # Kiểm tra và xóa cột 'userID' không mong muốn khỏi bảng 'dietitians' + print("\nKiểm tra và xóa cột 'userID' không mong muốn khỏi bảng 'dietitians'...") + try: + cursor.execute("SHOW COLUMNS FROM dietitians LIKE 'userID'") + if cursor.fetchone(): + print(" - Tìm thấy cột 'userID'. Đang xóa...") + cursor.execute("ALTER TABLE dietitians DROP COLUMN userID") + print(" - Đã xóa cột 'userID'.") + else: + print(" - Cột 'userID' không tồn tại, không cần xóa.") + except mysql.connector.Error as err: + print(f" - Lỗi khi kiểm tra/xóa cột 'userID': {err.msg}") + # Thực hiện các câu lệnh cập nhật for table, statements in schema_updates.items(): print(f"\nĐang cập nhật bảng {table}...") @@ -228,7 +288,7 @@ def update_database_schema(app, db): table_exists = cursor.fetchone() if not table_exists: - print(f" - Bảng {table} không tồn tại, sẽ được tạo khi chạy db.create_all()") + print(f" - Bảng {table} không tồn tại, bỏ qua") continue # Lấy danh sách các cột hiện tại của bảng @@ -238,28 +298,56 @@ def update_database_schema(app, db): # Thực hiện các câu lệnh ALTER TABLE for statement in statements: try: - # Phân tích cú pháp để lấy tên cột - column_name = statement.split("ADD COLUMN")[1].strip().split(" ")[0] - - # Kiểm tra xem cột đã tồn tại chưa - if column_name in existing_columns: - print(f" - Bỏ qua: Cột {column_name} đã tồn tại trong bảng {table}") - continue - + column_name = None + constraint_name = None + is_add_column = "ADD COLUMN" in statement + is_add_constraint = "ADD CONSTRAINT" in statement + + if is_add_column: + # Phân tích cú pháp để lấy tên cột + parts = statement.split("ADD COLUMN")[1].strip().split(" ") + if len(parts) > 0: + column_name = parts[0].strip('`') # Remove potential backticks + + # Kiểm tra xem cột đã tồn tại chưa + if column_name and column_name in existing_columns: + print(f" - Bỏ qua: Cột {column_name} đã tồn tại trong bảng {table}") + continue + elif is_add_constraint: + # Phân tích cú pháp để lấy tên constraint + parts = statement.split("ADD CONSTRAINT")[1].strip().split(" ") + if len(parts) > 0: + constraint_name = parts[0].strip('`') + + # Kiểm tra xem constraint đã tồn tại chưa + if constraint_name: + cursor.execute(f"SHOW CREATE TABLE {table}") + create_table_sql = cursor.fetchone()[1] + if constraint_name in create_table_sql: + print(f" - Bỏ qua: Constraint {constraint_name} đã tồn tại trong bảng {table}") + continue + + # Thêm log đặc biệt cho việc thêm cột status vào dietitians + if table == 'dietitians' and column_name == 'status': + print(f" -> Đang chuẩn bị thực thi: {statement}") + cursor.execute(statement) print(f" - Thành công: {statement}") + except mysql.connector.Error as err: + # Lỗi cụ thể từ MySQL Connector + print(f" - Lỗi MySQL khi thực thi: {statement}") + print(f" Error Code: {err.errno}") + print(f" SQLSTATE: {err.sqlstate}") + print(f" Message: {err.msg}") except Exception as e: - print(f" - Lỗi: {statement}") + # Lỗi chung khác + print(f" - Lỗi chung khi thực thi: {statement}") print(f" {str(e)}") # Commit các thay đổi + print(f"Committing changes for database {database}...") connection.commit() - # Tạo bảng mới nếu cần - print("\nĐang tạo các bảng mới (nếu có)...") - db.create_all() - print("Đã tạo các bảng mới (nếu có)") - # Đóng kết nối cursor.close() connection.close() @@ -271,6 +359,36 @@ def update_database_schema(app, db): print(f"Lỗi khi cập nhật cấu trúc cơ sở dữ liệu: {str(e)}") return False +def create_admin_user_direct(): + """Tạo tài khoản admin trực tiếp""" + try: + from app import create_app, db + from app.models.user import User + from flask_bcrypt import Bcrypt + + app = create_app() + bcrypt = Bcrypt(app) + + with app.app_context(): + admin_email = 'admin@ccuhtm.com' + if not User.query.filter_by(email=admin_email).first(): + admin = User( + username='admin', + email=admin_email, + password=bcrypt.generate_password_hash('admin').decode('utf-8'), + role='Admin' + ) + db.session.add(admin) + db.session.commit() + print("[Admin] Created default admin user with username: admin, password: admin") + else: + print("[Admin] Admin user already exists") + + return True + except Exception as e: + print(f"[Error] Failed to create admin user: {e}") + return False + def main(): # Phân tích tham số dòng lệnh parser = argparse.ArgumentParser(description='CCU HTM Management System')