diff --git a/0.5 b/0.5 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/__init__.py b/app/__init__.py index a8ebbe53e4209e5611fb44971b5589cb3b8e6b97..b830505c688020d34ac68375ba07010c0c67a80a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -15,7 +15,7 @@ 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 = 'Please login to access this page.' login_manager.login_message_category = 'info' # --- Định nghĩa Jinja filter --- diff --git a/app/forms/auth_forms.py b/app/forms/auth_forms.py index 13b21f01f65977605f9e5e296f5cbc72db606de8..445746c172261bd7293863be3db3f4330247d6f5 100644 --- a/app/forms/auth_forms.py +++ b/app/forms/auth_forms.py @@ -6,31 +6,31 @@ from app.models.user import User class LoginForm(FlaskForm): """Form đăng nhập người dùng""" email = StringField('Email', validators=[ - DataRequired(message="Email không được để trống"), - Email(message="Vui lòng nhập địa chỉ email hợp lệ") + DataRequired(message="Email is required"), + Email(message="Please enter a valid email address") ]) dietitianID = StringField('Dietitian ID', validators=[Optional()]) - password = PasswordField('Mật khẩu', validators=[ - DataRequired(message="Mật khẩu không được để trống") + password = PasswordField('Password', validators=[ + DataRequired(message="Password is required") ]) - remember = BooleanField('Ghi nhớ đăng nhập') - submit = SubmitField('Đăng nhập') + remember = BooleanField('Remember me') + submit = SubmitField('Login') class EditProfileForm(FlaskForm): """Form for users to edit their profile""" firstName = StringField('First Name', validators=[DataRequired(), Length(min=2, max=50)]) lastName = StringField('Last Name', validators=[DataRequired(), Length(min=2, max=50)]) email = StringField('Email', validators=[DataRequired(), Email(), Length(max=100)]) - phone = StringField('Số điện thoại', validators=[Optional()]) + phone = StringField('Phone number', validators=[Optional()]) # Các trường cho dietitian - specialization = StringField('Chuyên môn', validators=[Optional()]) - notes = TextAreaField('Ghi chú', validators=[Optional(), Length(max=500, message="Ghi chú không được vượt quá 500 ký tự")]) + specialization = StringField('Specialization', validators=[Optional()]) + notes = TextAreaField('Note', validators=[Optional(), Length(max=500, message="Note cannot exceed 500 characters")]) - password = PasswordField('Mật khẩu mới', validators=[Optional(), Length(min=6, message="Mật khẩu phải có ít nhất 6 ký tự")]) - confirm_password = PasswordField('Xác nhận mật khẩu', validators=[Optional(), EqualTo('password', message="Xác nhận mật khẩu không khớp")]) + password = PasswordField('New password', validators=[Optional(), Length(min=6, message="Password must be at least 6 characters")]) + confirm_password = PasswordField('Confirm password', validators=[Optional(), EqualTo('password', message="Confirmation password does not match")]) - submit = SubmitField('Cập nhật') + submit = SubmitField('Update') def __init__(self, *args, **kwargs): super(EditProfileForm, self).__init__(*args, **kwargs) @@ -42,28 +42,28 @@ class EditProfileForm(FlaskForm): return user = User.query.filter_by(email=email.data).first() if user: - raise ValidationError('Email này đã được sử dụng. Vui lòng sử dụng email khác.') + raise ValidationError('Email is already in use. Please use a different email.') class ChangePasswordForm(FlaskForm): """Form thay đổi mật khẩu""" - current_password = PasswordField('Mật khẩu hiện tại', validators=[ - DataRequired(message="Vui lòng nhập mật khẩu hiện tại") + current_password = PasswordField('Current password', validators=[ + DataRequired(message="Please enter your current password") ]) - new_password = PasswordField('Mật khẩu mới', validators=[ - DataRequired(message="Mật khẩu mới không được để trống"), - Length(min=6, message="Mật khẩu phải có ít nhất 6 ký tự") + new_password = PasswordField('New password', validators=[ + DataRequired(message="New password is required"), + Length(min=6, message="Password must be at least 6 characters") ]) - confirm_password = PasswordField('Xác nhận mật khẩu mới', validators=[ - DataRequired(message="Vui lòng xác nhận mật khẩu mới"), - EqualTo('new_password', message="Mật khẩu không khớp") + confirm_password = PasswordField('Confirm new password', validators=[ + DataRequired(message="Please confirm your new password"), + EqualTo('new_password', message="Password does not match") ]) - submit = SubmitField('Thay đổi mật khẩu') + submit = SubmitField('Change password') class ResetPasswordRequestForm(FlaskForm): """Form yêu cầu đặt lại mật khẩu""" email = StringField('Email', validators=[ - DataRequired(message="Email không được để trống"), - Email(message="Vui lòng nhập địa chỉ email hợp lệ") + DataRequired(message="Email is required"), + Email(message="Please enter a valid email address") ]) submit = SubmitField('Gửi yêu cầu đặt lại mật khẩu') diff --git a/app/routes/auth.py b/app/routes/auth.py index aceb2ea24fdb213247b1a7f4b0fef151f4b64c66..d2a6969b2e78c939ba86d06e0a18bd642692713f 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -24,7 +24,7 @@ def login(): user = User.query.filter_by(email=form.email.data).first() if not user or not user.check_password(form.password.data): - flash('Email hoặc mật khẩu không chính xác.', 'error') + flash('Email or password is incorrect.', 'error') return render_template('login.html', form=form, title='Đăng nhập') # Kiểm tra role và dietitianID nếu là Dietitian @@ -32,25 +32,25 @@ def login(): # Lấy Dietitian ID từ form và chuẩn hóa (viết hoa) form_dietitian_id_raw = form.dietitianID.data if not form_dietitian_id_raw: - flash('Vui lòng nhập Dietitian ID.', 'error') + flash('Please enter Dietitian ID.', 'error') return render_template('login.html', form=form, title='Đăng nhập') form_dietitian_id = form_dietitian_id_raw.strip().upper() # Kiểm tra xem user có dietitian profile không if not user.dietitian: - flash('Tài khoản Dietitian này chưa có hồ sơ được liên kết.', 'error') + flash('This Dietitian account does not exist.', 'error') return render_template('login.html', form=form, title='Đăng nhập') # Kiểm tra khớp Dietitian ID (đã chuẩn hóa) if user.dietitian.formattedID.upper() != form_dietitian_id: - flash('Dietitian ID không khớp với tài khoản này.', 'error') + flash('Dietitian ID does not match this account.', 'error') return render_template('login.html', form=form, title='Đăng nhập') # Nếu là Admin hoặc Dietitian đã xác thực ID thành công login_user(user, remember=form.remember.data) next_page = request.args.get('next') - flash('Đăng nhập thành công!', 'success') + flash('Logged in successfully!', 'success') # Chuyển hướng đến trang được yêu cầu hoặc dashboard phù hợp với vai trò redirect_url = None @@ -73,14 +73,14 @@ def login(): return redirect(redirect_url) - return render_template('login.html', form=form, title='Đăng nhập') + return render_template('login.html', form=form, title='Login') @auth_bp.route('/logout') @login_required def logout(): """Xử lý đăng xuất người dùng""" logout_user() - flash('Bạn đã đăng xuất thành công.', 'info') + flash('Successfully logged out.', 'info') return redirect(url_for('auth.login')) @auth_bp.route('/profile') @@ -99,7 +99,7 @@ def profile(): return render_template( 'profile.html', - title='Hồ sơ người dùng', + title='User Profile', upload_count=upload_count ) @@ -113,7 +113,7 @@ def edit_profile(): elif user.role == 'Dietitian': template = 'edit_profile.html' # Template cho dietitian else: - flash('Bạn không có quyền chỉnh sửa hồ sơ này.', 'warning') + flash('You do not have permission to edit this profile.', 'warning') return redirect(url_for('main.handle_root')) # Lấy dietitian profile nếu là dietitian (sử dụng tên relationship đúng: dietitian) @@ -153,12 +153,12 @@ def edit_profile(): try: db.session.commit() - flash('Thông tin hồ sơ đã được cập nhật thành công.', 'success') + flash('Profile updated successfully.', 'success') return redirect(url_for('auth.profile')) except Exception as e: db.session.rollback() - current_app.logger.error(f"Lỗi khi cập nhật hồ sơ user {user.userID}: {e}") - flash('Đã xảy ra lỗi khi cập nhật hồ sơ.', 'danger') + current_app.logger.error(f"There was an error updating user {user.userID}: {e}") + flash('There was an error updating your profile.', 'danger') # Nếu form không hợp lệ hoặc là GET request, hiển thị lại form # Cần đảm bảo form.role_display được set lại cho GET request của Admin @@ -167,7 +167,7 @@ def edit_profile(): password_form = ChangePasswordForm() # Luôn cần form đổi mật khẩu - return render_template(template, title='Chỉnh sửa Hồ sơ', form=form, password_form=password_form) + return render_template(template, title='Edit Profile', form=form, password_form=password_form) @auth_bp.route('/change-password', methods=['POST']) @login_required @@ -182,7 +182,7 @@ def change_password(): if password_form.validate_on_submit(): # Sử dụng phương thức check_password của model User if not current_user.check_password(password_form.current_password.data): - flash('Mật khẩu hiện tại không đúng.', 'danger') + flash('Incorrect current password.', 'danger') # 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: @@ -193,11 +193,11 @@ def change_password(): try: db.session.commit() - flash('Mật khẩu đã được thay đổi thành công.', 'success') + flash('Password updated successfully.', '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') + flash(f'There was an error updating password: {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: @@ -229,7 +229,7 @@ def update_notifications(): db.session.commit() - flash('Cài đặt thông báo đã được cập nhật thành công.', 'success') + flash('Notification settings updated successfully.', 'success') return redirect(url_for('auth.profile')) return render_template( @@ -245,7 +245,7 @@ def update_notifications(): def admin_panel(): """Hiển thị bảng điều khiển dành 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') + flash('You do not have permission to access this page.', 'danger') return redirect(url_for('dashboard.index')) # Lấy danh sách người dùng để quản lý @@ -266,7 +266,7 @@ def admin_panel(): 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') + flash('You do not have permission to access this page.', 'danger') return redirect(url_for('dashboard.index')) # Phân trang cho danh sách người dùng @@ -285,7 +285,7 @@ def admin_users(): 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') + flash('You do not have permission to access this page.', 'danger') return redirect(url_for('dashboard.index')) user = User.query.get_or_404(user_id) @@ -296,7 +296,7 @@ def admin_edit_user(user_id): if form.email.data != user.email: existing_user = User.query.filter_by(email=form.email.data).first() if existing_user: - flash('Email này đã được sử dụng bởi tài khoản khác.', 'danger') + flash('This email is already in use.', 'danger') return redirect(url_for('auth.admin_edit_user', user_id=user.userID)) # Cập nhật thông tin người dùng @@ -307,12 +307,12 @@ def admin_edit_user(user_id): user.bio = form.bio.data db.session.commit() - flash(f'Thông tin của người dùng {user.firstName} {user.lastName} đã được cập nhật thành công.', 'success') + flash(f'User {user.firstName} {user.lastName} information updated successfully.', 'success') return redirect(url_for('auth.admin_users')) return render_template( 'admin/edit_user.html', - title='Chỉnh sửa người dùng', + title='Edit profile', form=form, user=user ) @@ -322,14 +322,14 @@ def admin_edit_user(user_id): 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') + flash('You do not have permission to perform this action.', 'danger') return redirect(url_for('dashboard.index')) user = User.query.get_or_404(user_id) # Ngăn chặn xóa chính mình if user.userID == current_user.userID: - flash('Bạn không thể xóa tài khoản của chính mình.', 'danger') + flash('You cannot delete your own account.', 'danger') return redirect(url_for('auth.admin_users')) # Lưu tên người dùng để hiển thị trong thông báo @@ -339,7 +339,7 @@ def admin_delete_user(user_id): db.session.delete(user) db.session.commit() - flash(f'Người dùng {username} đã được xóa thành công.', 'success') + flash(f'User {username} successfully deleted.', 'success') return redirect(url_for('auth.admin_users')) @auth_bp.route('/admin/users/<int:user_id>/toggle-role', methods=['POST']) @@ -347,14 +347,14 @@ def admin_delete_user(user_id): 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') + flash('You do not have permission to perform this action.', 'danger') return redirect(url_for('dashboard.index')) user = User.query.get_or_404(user_id) # Ngăn chặn hạ cấp chính mình if user.userID == current_user.userID: - flash('Bạn không thể thay đổi vai trò của chính mình.', 'danger') + flash('You cannot change your own role.', 'danger') return redirect(url_for('auth.admin_users')) # Chuyển đổi vai trò diff --git a/app/routes/patients.py b/app/routes/patients.py index d1aefe791611b448c18cc23192d943d0d528768f..bd4904bfc8a62292ce6d06f3c4be9d6d6eeb7bdc 100644 --- a/app/routes/patients.py +++ b/app/routes/patients.py @@ -478,11 +478,23 @@ def patient_detail(patient_id): current_app.logger.error(f"Failed to decode ml_limit_breaches_json for encounter {enc.encounterID}") # Tạo dict dữ liệu cho encounter này + # *** SỬA: Đảm bảo tổng số chỉ số (limit_breaches + top_features) không quá 3 *** + # Ưu tiên limit_breaches trước + limited_limit_breaches = limit_breaches[:3] if limit_breaches else [] + remaining_slots = 3 - len(limited_limit_breaches) + limited_top_features = [] + + # Chỉ thêm top_features nếu còn chỗ và chỉ thêm những features chưa có trong limit_breaches + if remaining_slots > 0 and top_features: + # Lọc ra những top_features không có trong limit_breaches + unique_top_features = [f for f in top_features if f not in limited_limit_breaches] + limited_top_features = unique_top_features[:remaining_slots] + encounters_data.append({ 'encounter': enc, # Dùng encounter đã query lại 'status': status_display, - 'top_features': top_features, # Thêm danh sách top features - 'limit_breaches': limit_breaches[:3] # *** SỬA: Giới hạn chỉ lấy tối đa 3 phần tử *** + 'top_features': limited_top_features, # Chỉ lấy số top_features còn lại sau khi đã hiển thị limit_breaches + 'limit_breaches': limited_limit_breaches # Giới hạn chỉ lấy tối đa 3 phần tử }) # Debug log lại số lượng encounters với assigned_dietitian @@ -1283,89 +1295,89 @@ def upload_encounter_measurements_csv(patient_id, encounter_id): def new_encounter(patient_id): patient = Patient.query.get_or_404(patient_id) form = EmptyForm() - if form.validate_on_submit(): - new_encounter_id = None # Khởi tạo để dùng trong except - try: # Khối try chính - # Check if there is an ON_GOING encounter - latest_encounter = Encounter.query.filter_by(patient_id=patient.id).order_by(desc(Encounter.start_time)).first() - if latest_encounter and latest_encounter.status == EncounterStatus.ON_GOING: - flash('Cannot create a new encounter while another is ongoing.', 'warning') - # *** SỬA: Return ở cuối nếu không có lỗi *** - # return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) - else: - # --- Generate Custom Encounter ID --- - patient_id_number = patient.id.split('-')[-1] if '-' in patient.id else patient.id - last_custom_encounter = Encounter.query.filter( - Encounter.patient_id == patient.id, - Encounter.custom_encounter_id.like(f'E-{patient_id_number}-%') - ).order_by(desc(Encounter.custom_encounter_id)).first() - - next_seq = 1 - if last_custom_encounter and last_custom_encounter.custom_encounter_id: - try: # Khối try nhỏ cho việc parse sequence - last_seq_str = last_custom_encounter.custom_encounter_id.split('-')[-1] - next_seq = int(last_seq_str) + 1 - except (IndexError, ValueError): - current_app.logger.warning(f"Could not parse sequence from last custom encounter ID '{last_custom_encounter.custom_encounter_id}'. Starting sequence at 1.") - next_seq = 1 - - new_custom_id = f"E-{patient_id_number}-{next_seq:02d}" - # --- End Generate Custom Encounter ID --- - # Create new Encounter with custom ID - new_encounter = Encounter( - patient_id=patient.id, - start_time=datetime.utcnow(), - custom_encounter_id=new_custom_id - ) - db.session.add(new_encounter) - db.session.flush() # Get encounterID for logs/links - new_encounter_id = new_encounter.encounterID # Lưu ID để dùng trong redirect + # 1️⃣ CSRF / form‑error guard + if not form.validate_on_submit(): + flash('Invalid request to create encounter.', 'danger') + return redirect(url_for('patients.patient_detail', + patient_id=patient.id, _anchor='encounters')) - # Log activity with custom ID - activity = ActivityLog( - user_id=current_user.userID, - action=f"Created new encounter {new_custom_id} (ID: {new_encounter_id}) for patient {patient.full_name}", - details=f"Patient ID: {patient.id}, Encounter ID: {new_encounter_id}, Custom ID: {new_custom_id}" - ) - db.session.add(activity) + try: + # 2️⃣ Đang có encounter mở? + latest = (Encounter.query + .filter_by(patient_id=patient.id) + .order_by(desc(Encounter.start_time)) + .first()) + + if latest and latest.status == EncounterStatus.ON_GOING: + flash('Cannot create a new encounter while another is ongoing.', 'warning') + return redirect(url_for('patients.patient_detail', + patient_id=patient.id, _anchor='encounters')) + + # 3️⃣ Tạo custom_encounter_id + pid_num = patient.id.split('-')[-1] if '-' in patient.id else patient.id + last_enc = (Encounter.query + .filter(Encounter.patient_id == patient.id, + Encounter.custom_encounter_id.like(f'E-{pid_num}-%')) + .order_by(desc(Encounter.custom_encounter_id)) + .first()) + + seq = 1 + if last_enc and last_enc.custom_encounter_id: + try: + seq = int(last_enc.custom_encounter_id.split('-')[-1]) + 1 + except (IndexError, ValueError): + current_app.logger.warning( + f"Cannot parse sequence from '{last_enc.custom_encounter_id}', fallback to 1") - # Update patient status - update_patient_status_from_encounters(patient) + custom_id = f"E-{pid_num}-{seq:02d}" - # Add Notification for New Encounter - encounter_display_id = new_encounter.custom_encounter_id - admin_message = f"New encounter ({encounter_display_id}) created for patient {patient.full_name} by {current_user.full_name}." - link_kwargs = {'patient_id': patient.id, 'encounter_id': new_encounter_id} - admin_link = ('patients.encounter_measurements', link_kwargs) - create_notification_for_admins(admin_message, admin_link, exclude_user_id=current_user.userID) - - # Notify assigned dietitian - if patient.assigned_dietitian_user_id and patient.assigned_dietitian_user_id != current_user.userID: - dietitian_message = f"New encounter ({encounter_display_id}) created for your patient {patient.full_name}." - create_notification(patient.assigned_dietitian_user_id, dietitian_message, admin_link) + # 4️⃣ Ghi encounter mới + activity log + new_enc = Encounter(patient_id=patient.id, + start_time=datetime.utcnow(), + custom_encounter_id=custom_id) + db.session.add(new_enc) + db.session.flush() # lấy encounterID + enc_id = new_enc.encounterID - # Commit everything at the end of the try block - db.session.commit() - flash('New encounter created successfully.', 'success') - # Redirect after successful commit inside try - return redirect(url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=new_encounter_id)) + activity = ActivityLog( + user_id=current_user.userID, + action=f"Created encounter {custom_id} (ID: {enc_id}) for patient {patient.full_name}", + details=f"Patient ID: {patient.id}, Encounter ID: {enc_id}, Custom ID: {custom_id}" + ) + db.session.add(activity) - # *** SỬA: Khối except phải cùng mức thụt lề với try *** - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error creating new encounter for patient {patient.id}: {str(e)}", exc_info=True) - flash(f'Error creating encounter: {str(e)}', 'danger') - # *** SỬA: Redirect về patient detail nếu có lỗi *** - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) - - # *** SỬA: Redirect về patient detail nếu không tạo mới (do đang ongoing) *** - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + # 5️⃣ Cập nhật patient + bắn thông báo + update_patient_status_from_encounters(patient) - else: - # Handle CSRF or other form validation errors - flash('Invalid request to create encounter.', 'danger') - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + link_kwargs = {'patient_id': patient.id, 'encounter_id': enc_id} + admin_link = ('patients.encounter_measurements', link_kwargs) + + create_notification_for_admins( + f"New encounter ({custom_id}) for patient {patient.full_name} by {current_user.full_name}.", + admin_link, + exclude_user_id=current_user.userID) + + if (patient.assigned_dietitian_user_id + and patient.assigned_dietitian_user_id != current_user.userID): + create_notification( + patient.assigned_dietitian_user_id, + f"New encounter ({custom_id}) created for your patient {patient.full_name}.", + admin_link) + + # 6️⃣ Commit & thoát + db.session.commit() + flash('New encounter created successfully.', 'success') + return redirect(url_for('patients.encounter_measurements', + patient_id=patient.id, encounter_id=enc_id)) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error creating new encounter for patient {patient.id}: {e}", + exc_info=True) + flash(f'Error creating encounter: {e}', 'danger') + return redirect(url_for('patients.patient_detail', + patient_id=patient.id, _anchor='encounters')) def update_patient_status_from_encounters(patient): """Update patient status based on encounters and referrals. Also handles status change notifications.""" @@ -1940,239 +1952,344 @@ def bulk_auto_assign(): def _get_ml_prediction_details(encounter, measurement): """ Hàm riêng để lấy chi tiết dự đoán ML cho encounter/measurement. + Trả về dict gồm: + - needs_intervention (bool) + - probability (float, 0‑1) + - shap_values (dict tên‑cột → giá trị SHAP) + - top_features (dict 5 đặc trưng đóng góp lớn nhất) + - error (str, thông báo lỗi – rỗng nếu OK) """ prediction_details = { - 'needs_intervention': None, - 'probability': None, - 'shap_values': {}, - 'top_features': {}, - 'error': '' # *** SỬA: Khởi tạo bằng chuỗi rỗng *** + "needs_intervention": None, + "probability": None, + "shap_values": {}, + "top_features": {}, + "error": "" # *** SỬA: Khởi tạo bằng chuỗi rỗng *** } - - if not measurement: - prediction_details['error'] = "No measurement data available for this encounter" + + # -------------------------------------------------------- + # Trường hợp không có measurement + # -------------------------------------------------------- + if measurement is None: + prediction_details["error"] = "No measurement data available for this encounter" return prediction_details - - # *** SỬA: Khối try chính bao quanh toàn bộ quá trình *** - try: + + # -------------------------------------------------------- + # Khối try chính bao quanh toàn bộ quá trình + # -------------------------------------------------------- + try: + # --- Load các thành phần đã lưu --- loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns = _load_ml_components() - # *** THÊM KIỂM TRA: Nếu không load được components thì báo lỗi *** + + # *** THÊM KIỂM TRA: Nếu load lỗi thì raise ngay *** if not all([loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns]): raise ValueError("Failed to load one or more ML components. Check previous logs.") + # --- Chuẩn bị data frame dự đoán --- features_dict = measurement.to_dict() df_predict = pd.DataFrame([features_dict]) - - # ... (Chuẩn bị df_predict, impute, scale) ... - df_predict = df_predict[[col for col in loaded_feature_columns if col in df_predict.columns]] - missing_features = [col for col in loaded_feature_columns if col not in df_predict.columns] + + # Giữ lại cột cần thiết, thêm cột thiếu dưới dạng NaN + df_predict = df_predict[[c for c in loaded_feature_columns if c in df_predict.columns]] + missing_features = [c for c in loaded_feature_columns if c not in df_predict.columns] if missing_features: - current_app.logger.warning(f"(Download Context) Missing features for ML prediction: {missing_features}") + current_app.logger.warning( + f"(Download Context) Missing features for ML prediction: {missing_features}" + ) for col in missing_features: df_predict[col] = np.nan + + # Bảo đảm đúng thứ tự cột df_predict = df_predict[loaded_feature_columns] + + # --- Impute & scale --- X_imputed = loaded_imputer.transform(df_predict) X_scaled = loaded_scaler.transform(X_imputed) - - # Dự đoán xác suất - prob = loaded_model.predict_proba(X_scaled)[0, 1] - prediction_details['probability'] = float(prob) - prediction_details['needs_intervention'] = bool(prob >= 0.5) - - # --- Tạo và sử dụng SHAP explainer --- - # *** SỬA: Khối try nhỏ hơn chỉ bao quanh phần SHAP *** - try: + + # --- Dự đoán xác suất --- + prob = loaded_model.predict_proba(X_scaled)[0, 1] + prediction_details["probability"] = float(prob) + prediction_details["needs_intervention"] = bool(prob >= 0.5) + + # ---------------------------------------------------- + # Khối try riêng cho phần SHAP + # ---------------------------------------------------- + try: explainer = shap.TreeExplainer(loaded_model) - # *** SỬA: Không cần kiểm tra if explainer nữa vì TreeExplainer luôn trả về object hoặc lỗi *** - # if explainer: - shap_values_all_classes = explainer.shap_values(X_scaled) - current_app.logger.info(f"[SHAP Debug] (Download Context) Encounter {encounter.encounterID} - SHAP output type: {type(shap_values_all_classes)}. Shape: {getattr(shap_values_all_classes, 'shape', 'N/A')}.") - - # --- START: Xử lý SHAP values --- + shap_values_all = explainer.shap_values(X_scaled) + shap_values_instance = None + # Trường hợp 1: SHAP là array 3D (1, n_features, 2) - if isinstance(shap_values_all_classes, np.ndarray) and shap_values_all_classes.ndim == 3: - # ... (logic xử lý mảng 3D giữ nguyên) ... - shap_values_instance = shap_values_all_classes[0, :, 1] - current_app.logger.info(f"(Download Context) Extracted SHAP values ... from 3D array.") - # Trường hợp 2: SHAP là list các arrays [shap_class_0, shap_class_1] - elif isinstance(shap_values_all_classes, list) and len(shap_values_all_classes) == 2: - # ... (logic xử lý list giữ nguyên) ... - shap_values_class1 = shap_values_all_classes[1] - if isinstance(shap_values_class1, np.ndarray): - if shap_values_class1.ndim == 2 and shap_values_class1.shape[0] == 1: - shap_values_instance = shap_values_class1[0] - current_app.logger.info(f"(Download Context) Extracted SHAP values ... from list output (2D).") - elif shap_values_class1.ndim == 1: - shap_values_instance = shap_values_class1 - current_app.logger.info(f"(Download Context) Using 1D SHAP values ... from list output (1D).") - else: - # *** SỬA: Báo lỗi đúng *** - raise ValueError(f"(Download Context) Unexpected shape {shap_values_class1.shape} in list-based SHAP values...") + if isinstance(shap_values_all, np.ndarray) and shap_values_all.ndim == 3: + shap_values_instance = shap_values_all[0, :, 1] + + # Trường hợp 2: SHAP là list 2 phần tử [class0, class1] + elif isinstance(shap_values_all, list) and len(shap_values_all) == 2: + shap_cls1 = shap_values_all[1] + if isinstance(shap_cls1, np.ndarray): + if shap_cls1.ndim == 2 and shap_cls1.shape[0] == 1: + shap_values_instance = shap_cls1[0] + elif shap_cls1.ndim == 1: + shap_values_instance = shap_cls1 + else: + raise ValueError( + f"(Download Context) Unexpected shape {shap_cls1.shape} in list‑based SHAP values" + ) else: - # *** SỬA: Báo lỗi đúng *** - raise ValueError("(Download Context) Positive class SHAP values in list are not a NumPy array.") - # Trường hợp 3: SHAP là array 2D - elif isinstance(shap_values_all_classes, np.ndarray) and shap_values_all_classes.ndim == 2: - # ... (logic xử lý mảng 2D giữ nguyên) ... - shap_values_instance = shap_values_all_classes[0] - current_app.logger.info(f"(Download Context) Using SHAP values from 2D array (shape: {shap_values_instance.shape})...") + raise ValueError( + "(Download Context) Positive class SHAP values in list are not a NumPy array." + ) + + # Trường hợp 3: SHAP là array 2D (1, n_features) + elif isinstance(shap_values_all, np.ndarray) and shap_values_all.ndim == 2: + shap_values_instance = shap_values_all[0] + # Trường hợp 4: Cấu trúc không mong đợi else: - # *** SỬA: Báo lỗi đúng *** - type_info = type(shap_values_all_classes) - structure_info = getattr(shap_values_all_classes, 'shape', len(shap_values_all_classes) if isinstance(shap_values_all_classes, list) else 'N/A') - raise ValueError(f"(Download Context) Unexpected SHAP value structure. Type: {type_info}, Structure: {structure_info}") - - # Kiểm tra cuối cùng + raise ValueError( + f"(Download Context) Unexpected SHAP value structure. " + f"Type: {type(shap_values_all)}, " + f"Structure: {getattr(shap_values_all, 'shape', 'N/A')}." + ) + + # --- Kiểm tra & lưu kết quả SHAP --- if shap_values_instance is None: - # *** SỬA: Báo lỗi đúng *** - raise ValueError("(Download Context) Failed to extract SHAP values from any known structure.") - if not isinstance(shap_values_instance, np.ndarray) or shap_values_instance.ndim != 1: - # *** SỬA: Báo lỗi đúng *** - raise ValueError(f"(Download Context) Extracted SHAP values are not a 1D NumPy array. Shape: {getattr(shap_values_instance, 'shape', 'N/A')}") + raise ValueError("(Download Context) Failed to extract SHAP values.") + if len(shap_values_instance) != len(loaded_feature_columns): - # *** SỬA: Báo lỗi đúng *** - raise ValueError(f"(Download Context) Mismatch between number of SHAP values ({len(shap_values_instance)}) and features ({len(loaded_feature_columns)}).") - # --- END: Xử lý SHAP values --- - - # Lưu SHAP values và top features - prediction_details['shap_values'] = dict(zip(loaded_feature_columns, shap_values_instance)) + raise ValueError( + f"(Download Context) Mismatch between number of SHAP values " + f"({len(shap_values_instance)}) and features ({len(loaded_feature_columns)})." + ) + + # Lưu SHAP values & top features + prediction_details["shap_values"] = dict(zip(loaded_feature_columns, shap_values_instance)) + N_TOP_FEATURES = 5 - # *** SỬA: Tính abs() an toàn hơn *** - abs_shap_dict = {k: abs(v) if isinstance(v, (int, float, np.number)) else 0 for k, v in prediction_details['shap_values'].items()} + abs_shap_dict = { + k: abs(v) if isinstance(v, (int, float, np.number)) else 0 + for k, v in prediction_details["shap_values"].items() + } sorted_features = sorted(abs_shap_dict.items(), key=lambda item: item[1], reverse=True) - prediction_details['top_features'] = dict(sorted_features[:N_TOP_FEATURES]) - current_app.logger.info(f"(Download Context) Top {N_TOP_FEATURES} features cho encounter {encounter.encounterID}: {prediction_details['top_features']}") - - # *** SỬA: Khối except tương ứng với try của SHAP *** - except Exception as shap_e: + prediction_details["top_features"] = dict(sorted_features[:N_TOP_FEATURES]) + + current_app.logger.info( + f"(Download Context) Top {N_TOP_FEATURES} features cho encounter " + f"{encounter.encounterID}: {prediction_details['top_features']}" + ) + + # ------------------ except SHAP ------------------ + except Exception as shap_e: error_msg = f"Failed to get SHAP details: {shap_e}" - # *** SỬA: Nối lỗi đúng cách *** - prediction_details['error'] = prediction_details['error'] + f"; {error_msg}" if prediction_details['error'] else error_msg - current_app.logger.error(f"Error getting SHAP details for download (Encounter {encounter.encounterID}): {shap_e}", exc_info=True) - prediction_details['shap_values'] = {} - prediction_details['top_features'] = {} - - # *** SỬA: Khối except tương ứng với try chính *** + prediction_details["error"] = ( + f"{prediction_details['error']}; {error_msg}" + if prediction_details["error"] + else error_msg + ) + current_app.logger.error( + f"Error getting SHAP details for download (Encounter {encounter.encounterID}): {shap_e}", + exc_info=True, + ) + prediction_details["shap_values"] = {} + prediction_details["top_features"] = {} + + # ------------------ except toàn hàm ------------------ except Exception as e: - error_msg = f"Failed to get ML prediction details: {e}" - # *** SỬA: Nối lỗi đúng cách *** - prediction_details['error'] = prediction_details['error'] + f"; {error_msg}" if prediction_details['error'] else error_msg - current_app.logger.error(f"Error getting ML prediction details (Encounter {encounter.encounterID}): {e}", exc_info=True) - prediction_details['probability'] = None - prediction_details['needs_intervention'] = None - prediction_details['shap_values'] = {} - prediction_details['top_features'] = {} - + error_msg = f"Prediction pipeline failed: {e}" + prediction_details["error"] = ( + f"{prediction_details['error']}; {error_msg}" if prediction_details["error"] else error_msg + ) + current_app.logger.error( + f"Prediction pipeline failed for download (Encounter {encounter.encounterID}): {e}", + exc_info=True, + ) + return prediction_details + # --- ROUTE MỚI ĐỂ TẢI KẾT QUẢ ML (Cập nhật) --- -@patients_bp.route('/<string:patient_id>/encounter/<int:encounter_id>/download_ml_results') +@patients_bp.route("/<string:patient_id>/encounter/<int:encounter_id>/download_ml_results") @login_required def download_ml_results(patient_id, encounter_id): - # ... (Lấy encounter, latest_measurement) ... - encounter = Encounter.query.options( - joinedload(Encounter.patient) # Tải sẵn thông tin bệnh nhân - ).filter_by( - patient_id=patient_id, - encounterID=encounter_id - ).first_or_404() - - if encounter.ml_needs_intervention is None: # Sử dụng trường mới - flash('ML prediction has not been run for this encounter yet.', 'warning') - return redirect(url_for('.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) + """ + Tải kết quả ML (bao gồm SHAP) của mọi phép đo trong encounter + về dưới dạng CSV. - latest_measurement = PhysiologicalMeasurement.query.filter_by( - encounter_id=encounter.encounterID - ).order_by( - desc(PhysiologicalMeasurement.measurementDateTime) - ).first() + CSV header: Measurement Time | Needs Intervention | <feature>_Value | <feature>_SHAP | … + """ + # -------------------------------------------------- + # 1. Truy vấn encounter & measurements + # -------------------------------------------------- + encounter = ( + Encounter.query.options(joinedload(Encounter.patient)) + .filter_by(patient_id=patient_id, encounterID=encounter_id) + .first_or_404() + ) - if not latest_measurement: - flash('Could not find the measurement used for the ML prediction.', 'error') - return redirect(url_for('.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) + all_measurements = ( + PhysiologicalMeasurement.query.filter_by(encounter_id=encounter.encounterID) + .order_by(PhysiologicalMeasurement.measurementDateTime.asc()) + .all() + ) - # Lấy chi tiết dự đoán, bao gồm shap_values - prediction_details = _get_ml_prediction_details(encounter, latest_measurement) + if not all_measurements: + flash("No measurements found for this encounter…", "warning") + return redirect( + url_for(".encounter_measurements", patient_id=patient_id, encounter_id=encounter_id) + ) - if not prediction_details or prediction_details.get('error'): - flash(f"Could not retrieve ML prediction details: {prediction_details.get('error', 'Unknown error')}", 'error') - return redirect(url_for('.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) + # -------------------------------------------------- + # 2. Load model / imputer / scaler / explainer + # -------------------------------------------------- + try: + loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns = _load_ml_components() + if not all([loaded_model, loaded_imputer, loaded_scaler, loaded_feature_columns]): + raise ValueError("Failed to load one or more ML components.") + explainer = shap.TreeExplainer(loaded_model) # Tạo một lần + except Exception as e: + current_app.logger.error(f"Error loading ML components/explainer: {e}", exc_info=True) + flash(f"Error loading ML components/explainer: {e}", "danger") + return redirect( + url_for(".encounter_measurements", patient_id=patient_id, encounter_id=encounter_id) + ) + # -------------------------------------------------- + # 3. Chuẩn bị CSV writer + # -------------------------------------------------- output = io.StringIO() writer = csv.writer(output) - # --- Header --- - writer.writerow(['Patient ID', encounter.patient_id]) - writer.writerow(['Patient Name', encounter.patient.full_name]) - writer.writerow(['Encounter ID', encounter.custom_encounter_id or encounter.encounterID]) - writer.writerow(['Measurement Time', latest_measurement.measurementDateTime.strftime('%Y-%m-%d %H:%M:%S') if latest_measurement.measurementDateTime else 'N/A']) - writer.writerow(['Prediction Result', 'Needs Intervention' if prediction_details['needs_intervention'] else 'No Intervention Needed']) - writer.writerow([]) - writer.writerow(['Feature', 'Value', 'SHAP Value (Impact on Prediction)']) - - # --- Feature Details --- - shap_values = prediction_details.get('shap_values', {}) - - # *** SỬA: Lấy feature names từ shap_values keys *** - feature_names = list(shap_values.keys()) - - # Hàm helper để lấy giá trị tuyệt đối của SHAP - def get_abs_shap(feature_name): - val = shap_values.get(feature_name, 0) - if isinstance(val, (int, float, np.number)): - return abs(val) - return 0 - - # *** SỬA: Sắp xếp feature_names dựa trên get_abs_shap *** - sorted_feature_names = sorted(feature_names, key=get_abs_shap, reverse=True) - - # *** SỬA: Lặp qua sorted_feature_names và lấy value từ latest_measurement *** - for feature in sorted_feature_names: - # Lấy giá trị thực tế từ measurement object - value = getattr(latest_measurement, feature, None) - shap_value = shap_values.get(feature) - - # Định dạng SHAP value - shap_display = 'N/A' - if isinstance(shap_value, (float, np.float64)): - shap_display = f'{shap_value:.4f}' - elif isinstance(shap_value, (int, np.integer)): - shap_display = str(shap_value) - elif shap_value is not None: - shap_display = str(shap_value) - - # Định dạng Value - value_display = 'N/A' - if value is not None: - if isinstance(value, (float, np.float64)): - value_display = f'{value:.2f}' - else: - value_display = str(value) - - writer.writerow([ - feature, - value_display, # Sử dụng giá trị đã định dạng - shap_display # Sử dụng giá trị đã định dạng - ]) + header = ["Measurement Time", "Needs Intervention (Predicted)"] + for feature in loaded_feature_columns: + header.extend([f"{feature}_Value", f"{feature}_SHAP"]) + writer.writerow(header) + + # -------------------------------------------------- + # 4. Lặp qua từng measurement + # -------------------------------------------------- + prediction_error_count = 0 + shap_error_count = 0 + + for m in all_measurements: + row_data = [ + m.measurementDateTime.strftime("%Y-%m-%d %H:%M:%S") if m.measurementDateTime else "N/A" + ] + shap_values_dict: dict[str, float] = {} + needs_intervention_str = "ERROR" + + try: + # 4.1 Chuẩn bị data + df_predict = pd.DataFrame([{**m.to_dict()}]) + missing_cols = set(loaded_feature_columns) - set(df_predict.columns) + for col in missing_cols: + df_predict[col] = np.nan + df_predict = df_predict[loaded_feature_columns] + + X_imputed = loaded_imputer.transform(df_predict) + X_scaled = loaded_scaler.transform(X_imputed) + + # 4.2 Predict + prob = loaded_model.predict_proba(X_scaled)[0, 1] + needs_intervention_str = "Yes" if prob >= 0.5 else "No" + + # 4.3 SHAP + try: + shap_all = explainer.shap_values(X_scaled) + shap_instance: np.ndarray | None = None + + if isinstance(shap_all, np.ndarray) and shap_all.ndim == 3: + shap_instance = shap_all[0, :, 1] # (1, n_feat, 2) + elif isinstance(shap_all, list) and len(shap_all) == 2: + cls1 = shap_all[1] + if isinstance(cls1, np.ndarray): + shap_instance = cls1[0] if cls1.ndim == 2 else cls1 + elif isinstance(shap_all, np.ndarray) and shap_all.ndim == 2: + shap_instance = shap_all[0] + + if ( + shap_instance is not None + and isinstance(shap_instance, np.ndarray) + and shap_instance.ndim == 1 + and len(shap_instance) == len(loaded_feature_columns) + ): + shap_values_dict = dict(zip(loaded_feature_columns, shap_instance)) + else: + shap_error_count += 1 + current_app.logger.warning( + f"Invalid SHAP shape for measurement {m.id}: " + f"type={type(shap_all)}, shape={getattr(shap_all, 'shape', 'N/A')}" + ) + except Exception as shap_e: + shap_error_count += 1 + current_app.logger.error( + f"Error calculating SHAP for measurement {m.id}: {shap_e}", exc_info=False + ) + + except Exception as row_e: + prediction_error_count += 1 + current_app.logger.error( + f"Error processing measurement {m.id} in CSV export: {row_e}", exc_info=True + ) + + # 4.4 Viết dòng CSV + row_data.append(needs_intervention_str) + for feature in loaded_feature_columns: + # value + raw_val = getattr(m, feature, None) + val_disp = f"{raw_val:.2f}" if isinstance(raw_val, (float, np.floating)) else str(raw_val or "N/A") + # shap + shap_val = shap_values_dict.get(feature) + shap_disp = ( + f"{shap_val:.4f}" + if isinstance(shap_val, (float, np.floating)) + else str(shap_val) if shap_val is not None else "N/A" + ) + row_data.extend([val_disp, shap_disp]) + + writer.writerow(row_data) + + # -------------------------------------------------- + # 5. Xuất file CSV + # -------------------------------------------------- + if prediction_error_count or shap_error_count: + flash( + f"Export finished with {prediction_error_count} prediction error(s) and " + f"{shap_error_count} SHAP error(s). Check logs for details.", + "warning" if prediction_error_count + shap_error_count < len(all_measurements) else "danger", + ) + + output.seek(0) + return send_file( + io.BytesIO(output.getvalue().encode("utf-8")), + mimetype="text/csv", + download_name=f"encounter_{encounter_id}_detailed_ml_results_pat_{patient_id}.csv", + as_attachment=True, + ) + + # --- TẠO RESPONSE --- + if prediction_error_count > 0: + flash(f"Warning: Could not process {prediction_error_count} measurement records. Check logs.", 'warning') + if shap_error_count > 0: + flash(f"Warning: SHAP values could not be calculated for {shap_error_count} records. Check logs.", 'warning') output.seek(0) response = make_response(output.getvalue()) - response.headers["Content-Disposition"] = f"attachment; filename=ml_results_enc_{encounter.custom_encounter_id or encounter.encounterID}_pat_{patient_id}.csv" + # Đổi tên file để phản ánh nội dung chi tiết + response.headers["Content-Disposition"] = f"attachment; filename=ml_results_full_detailed_enc_{encounter.custom_encounter_id or encounter.encounterID}_pat_{patient_id}.csv" response.headers["Content-type"] = "text/csv" - # Log download activity + # --- GHI LOG HOẠT ĐỘNG --- try: log_entry = ActivityLog( user_id=current_user.userID, - action=f'Downloaded ML results for Encounter {encounter.custom_encounter_id or encounter.encounterID} (Patient {patient_id})' + action=f'Downloaded FULL DETAILED ML results for Encounter {encounter.custom_encounter_id or encounter.encounterID} (Patient {patient_id})' ) db.session.add(log_entry) db.session.commit() except Exception as e: db.session.rollback() - current_app.logger.error(f"Error logging ML results download: {e}") + current_app.logger.error(f"Error logging FULL DETAILED ML results download: {e}") return response # --- KẾT THÚC ROUTE TẢI KẾT QUẢ ML --- diff --git a/app/templates/admin/profile.html b/app/templates/admin/profile.html index 8eb4d45b6735584cdb2f87acbd915072c8379ca4..8bbc8213f060e9c1a527e43a943331d57df954cb 100644 --- a/app/templates/admin/profile.html +++ b/app/templates/admin/profile.html @@ -66,19 +66,15 @@ <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <dt class="text-sm font-medium text-gray-500">Account Created</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.created_at.strftime('%d %b %Y, %H:%M') if current_user.created_at else 'N/A' }}</dd> - </div> - <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> - <dt class="text-sm font-medium text-gray-500">Last Login</dt> - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.last_login_at.strftime('%d %b %Y, %H:%M') if current_user.last_login_at else 'Never' }}</dd> </div> <!-- Admin specific sections --> <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <dt class="text-sm font-medium text-gray-500">Admin Actions</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> <div class="space-y-2"> - <a href="{{ url_for('auth.admin_users') }}" class="text-indigo-600 hover:text-indigo-900 text-sm font-medium flex items-center"> + <a href="{{ url_for('dietitians.index') }}" class="text-indigo-600 hover:text-indigo-900 text-sm font-medium flex items-center"> <svg class="w-4 h-4 mr-1" 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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg> - User Management + Dietitian Management </a> {# Placeholder links - update when these routes exist #} <a href="#" class="text-gray-400 text-sm font-medium flex items-center cursor-not-allowed" title="System Logs (Not Implemented)"> diff --git a/app/templates/auth_base.html b/app/templates/auth_base.html index d61f3124880bd4c0afdc0b6053c95f98e0b7c071..d25d782db2e1c393cc20242873b52c7ed7206426 100644 --- a/app/templates/auth_base.html +++ b/app/templates/auth_base.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% block title %}CCU HTM - Hệ thống quản lý chăm sóc dinh dưỡng{% endblock %}</title> + <title>{% block title %}CCU HTM - HuanTrungMinh's Crititica Care Unit{% 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"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> @@ -90,14 +90,14 @@ <div class="flex items-center justify-center min-h-screen p-4"> <div class="auth-container w-full animate-fadeIn"> <h1 class="auth-title">CCU HTM</h1> - <p class="auth-subtitle">Hệ thống quản lý chăm sóc dinh dưỡng</p> + <p class="auth-subtitle">Critical Care Unit</p> <div class="auth-content"> {% block content %}{% endblock %} </div> <div class="mt-6 text-center text-sm text-gray-500 animate-fadeIn delay-150"> - <p>© 2023 CCU HTM. Đã đăng ký bản quyền.</p> + <p>© 2025 CCU HTM. Copyright reserved.</p> </div> </div> </div> diff --git a/app/templates/base.html b/app/templates/base.html index a4055fbf0bd9be1a5920a7b601a912823d800948..00792b72d0b77333803785ab834fc64d44fd0cf8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -458,7 +458,7 @@ <div class="p-6"> <!-- Page Header --> <div class="page-header mb-8"> - <h1 class="text-3xl font-bold text-gray-800 animate-fade-in">{% block header %}Dashboard{% endblock %}</h1> + <h1 class="text-5xl font-bold text-gray-800 animate-fade-in">{% block header %}{% endblock %}</h1> </div> <!-- Flash Messages --> diff --git a/app/templates/dietitian_dashboard.html b/app/templates/dietitian_dashboard.html index 0192e40d2404b32aa1adcfdec0b2d8dbc4524203..2b5370967ec836a175cdfede2f451bd10635fda2 100644 --- a/app/templates/dietitian_dashboard.html +++ b/app/templates/dietitian_dashboard.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block title %}Dietitian Dashboard - CCU_BVNM{% endblock %} +{% block header %}Dietitian Dashboard{% endblock %} {% block head %} {{ super() }} @@ -11,7 +12,7 @@ {% block content %} <div class="container mx-auto px-4 py-6 animate-slide-in"> - <h1 class="text-3xl font-bold text-gray-800 mb-6">Dietitian Dashboard</h1> + <h1 class="text-2xl font-bold text-gray-800 mb-6">My Dashboard</h1> <!-- Statistics Cards --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> diff --git a/app/templates/dietitian_procedures.html b/app/templates/dietitian_procedures.html index cacbc7fcce601dd78aed4d61266db11cf6405523..89189adfa22651169ca9a853feda92c2aac7db8a 100644 --- a/app/templates/dietitian_procedures.html +++ b/app/templates/dietitian_procedures.html @@ -2,13 +2,14 @@ {% from '_macros.html' import render_pagination, flash_messages %} {% block title %}Procedures - {{ super() }}{% endblock %} +{% block header %}Procedures management{% endblock %} {% block content %} <div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8"> {{ flash_messages() }} <div class="mb-6 flex justify-between items-center"> - <h1 class="text-3xl font-bold text-gray-800">Manage Procedures</h1> + <h1 class="text-2xl font-bold text-gray-800">Add procedures</h1> {# Hiển thị nút Add Procedure chỉ khi đã chọn bệnh nhân cụ thể #} {% if selected_patient_id %} <a href="{{ url_for('dietitian.new_procedure', patient_id=selected_patient_id) }}" diff --git a/app/templates/dietitians/index.html b/app/templates/dietitians/index.html index 5008e91fc368a68f99d6b0ffebf128f75020095a..ecb4fbf208d223deb9f2d3cbfd6e39bdfd5c6ab4 100644 --- a/app/templates/dietitians/index.html +++ b/app/templates/dietitians/index.html @@ -3,7 +3,7 @@ {% block title %}Chuyên gia dinh dưỡng - CCU HTM{% endblock %} -{% block header %}Quản lý Chuyên gia dinh dưỡng{% endblock %} +{% block header %}Dietitians{% endblock %} {% block content %} <div class="container mx-auto px-4 py-6"> @@ -13,26 +13,26 @@ <li class="inline-flex items-center"> <a href="{{ url_for('main.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ủ + Home </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> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Dietitians</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> + <h1 class="text-2xl font-semibold text-gray-900">Dietitians List</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-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-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 + Add New Dietitian </a> {% endif %} </div> @@ -42,13 +42,13 @@ <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"> + <label for="search" class="block text-sm font-medium text-gray-700">Search</label> + <input type="text" name="search" id="search" value="{{ search_query }}" placeholder="Name, 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> + <label for="status" class="block text-sm font-medium text-gray-700">Dietitian Status</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> + <option value="all" {% if not status_filter or status_filter == 'all' %}selected{% endif %}>All Status</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 %} @@ -56,10 +56,10 @@ </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-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-200 w-full justify-center"> - Lọc + Filter </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 + Reset </a> </div> </div> @@ -70,18 +70,18 @@ {% 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> + <h3 class="text-lg leading-6 font-medium text-gray-900">Your Profile</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-100"> <tr> - <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Chuyên gia</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Liên hệ</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Dietitian</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Contact</th> <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Taking care</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Trạng thái</th> - <th scope="col" class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Thao tác</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Status</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">More</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -108,8 +108,8 @@ </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-blue-600 hover:text-blue-800 mr-3 transition duration-200">Chi tiết</a> - <a href="{{ url_for('auth.edit_profile') }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Sửa</a> + <a href="{{ url_for('dietitians.show', id=current_dietitian.dietitianID) }}" class="text-blue-600 hover:text-blue-800 mr-3 transition duration-200">Detail</a> + <a href="{{ url_for('auth.edit_profile') }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Edit</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> @@ -127,17 +127,17 @@ <!-- 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> + <h3 class="text-lg leading-6 font-medium text-gray-900">Other Dietitians</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">Dietitians</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Taking care</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">Status</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">More</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -167,11 +167,11 @@ </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> + <a href="{{ url_for('dietitians.show', id=dietitian.dietitianID) }}" class="text-primary-600 hover:text-primary-900 mr-3 transition duration-200">Detail</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> + <a href="{{ url_for('dietitians.edit', id=dietitian.dietitianID) }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Edit</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> + <button type="submit" class="text-red-600 hover:text-red-900 transition duration-200">Delete</button> </form> {% endif %} </td> @@ -179,7 +179,7 @@ {% 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> + <td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">Cannot find any other dietitians.</td> </tr> {% endif %} </tbody> diff --git a/app/templates/dietitians/show.html b/app/templates/dietitians/show.html index 4a0fe67804a2eebc63924441505ae4b1fba54266..01cf33bb65c3d233db70409c1dcb7e9dcf6c3cbf 100644 --- a/app/templates/dietitians/show.html +++ b/app/templates/dietitians/show.html @@ -3,7 +3,7 @@ {% block title %}{{ dietitian.user.firstName if dietitian.user else dietitian.firstName }} {{ dietitian.user.lastName if dietitian.user else dietitian.lastName }} - CCU HTM{% endblock %} -{% block header %}Thông tin chi tiết chuyên gia dinh dưỡng{% endblock %} +{% block header %}Dietitian Detail{% endblock %} {% block content %} <div class="container mx-auto px-4 py-6"> @@ -14,13 +14,13 @@ <li class="inline-flex items-center"> <a href="{{ url_for('main.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ủ + Home </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> + <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">Dietitians</a> </div> </li> <li aria-current="page"> @@ -37,8 +37,8 @@ <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> + <h3 class="text-lg leading-6 font-medium text-gray-900">Dietitian Detail</h3> + <p class="mt-1 max-w-2xl text-sm text-gray-500">Personal information and contact details.</p> </div> <div class="flex space-x-3"> {% if current_user.is_admin or (dietitian.user and dietitian.user.userID == current_user.userID) %} @@ -46,16 +46,16 @@ <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 + Edit </a> {% endif %} {% if current_user.is_admin %} - <form action="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" method="POST" onsubmit="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');" class="inline-block"> + <form action="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this dietitian?');" class="inline-block"> <button type="submit" 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"> <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 + Delete </button> </form> {% endif %} @@ -64,7 +64,7 @@ <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> + <dt class="text-sm font-medium text-gray-500">Full Name</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.firstName if dietitian.user else dietitian.firstName }} {{ dietitian.user.lastName if dietitian.user else dietitian.lastName }}</dd> </div> <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> @@ -72,21 +72,21 @@ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.email if dietitian.user else 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> + <dt class="text-sm font-medium text-gray-500">Phone Number</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.phone if dietitian.user and dietitian.user.phone else dietitian.phone or 'None' }}</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> + <dt class="text-sm font-medium text-gray-500">Specialization</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.specialization or 'None' }}</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> + <dt class="text-sm font-medium text-gray-500">Status</dt> <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> {{ status_badge(dietitian.status.value) }} </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> + <dt class="text-sm font-medium text-gray-500">Note</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.notes or 'None' }}</dd> </div> <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> @@ -94,7 +94,7 @@ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.formattedID }}</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">User ID liên kết</dt> + <dt class="text-sm font-medium text-gray-500">Linked User ID</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.formattedID if dietitian.user else 'Chưa liên kết' }}</dd> </div> </dl> @@ -102,7 +102,7 @@ </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> + <h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Assigned Patients</h3> {% if patients %} <div class="overflow-x-auto"> <table class="min-w-full divide-y divide-gray-200"> @@ -112,20 +112,20 @@ 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 + Patient Name </th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Tuổi + Age </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 + Gender </th> {% if current_user.is_admin %} <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Trạng thái + Patient Status </th> <th scope="col" class="relative px-6 py-3"> - <span class="sr-only">Xem</span> + <span class="sr-only">View</span> </th> {% endif %} </tr> @@ -186,7 +186,7 @@ {% 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. + This dietitian is not assigned to any patients. </div> </div> {% endif %} diff --git a/app/templates/edit_patient.html b/app/templates/edit_patient.html index 726a30602c22b569ea0bf6cfc297cef2ef36735f..b5fbc492b1776cb1a55d2a74f8fed1c42d081c9f 100644 --- a/app/templates/edit_patient.html +++ b/app/templates/edit_patient.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block title %}Edit Patient{% endblock %} +{% block header %}Edit Patient{% endblock %} {% block content %} <div class="container mx-auto px-4 py-6"> @@ -36,7 +37,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</span> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Edit Patient</span> </div> </li> </ol> @@ -47,7 +48,7 @@ <svg class="w-6 h-6 text-gray-700 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <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"></path> </svg> - Edit Patient: {{ patient.full_name }} + Editing Patient: {{ patient.full_name }} </h1> <!-- Form Card --> diff --git a/app/templates/edit_profile.html b/app/templates/edit_profile.html index 9ae7bacbac1f6ff3e4eefce3037fbcdcf324dd08..cd970f5465a222ba4ba9ccfe6c7c2d985946a6ae 100644 --- a/app/templates/edit_profile.html +++ b/app/templates/edit_profile.html @@ -3,7 +3,7 @@ {% block title %}Chỉnh sửa hồ sơ - {{ super() }}{% endblock %} -{% block header %}Chỉnh sửa thông tin cá nhân{% endblock %} +{% block header %}Edit Profile{% endblock %} {% block content %} <div class="container mx-auto px-4 py-6"> @@ -13,19 +13,19 @@ <li class="inline-flex items-center"> <a href="{{ url_for('main.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ủ + Home </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('auth.profile') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Hồ sơ của tôi</a> + <a href="{{ url_for('auth.profile') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Profile</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 hồ sơ</span> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Edit profile</span> </div> </li> </ol> @@ -36,10 +36,10 @@ <div class="border-b border-gray-200"> <nav class="-mb-px flex space-x-8 px-6" aria-label="Tabs"> <button id="profile-tab" type="button" class="border-indigo-500 text-indigo-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" aria-current="page"> - Thông tin cá nhân + Personal Information </button> <button id="password-tab" type="button" 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"> - Thay đổi mật khẩu + Change Password </button> </nav> </div> @@ -52,11 +52,11 @@ <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> <div class="sm:col-span-3"> - {{ render_field(form.firstName, label_text="Tên") }} + {{ render_field(form.firstName, label_text="First Name") }} </div> <div class="sm:col-span-3"> - {{ render_field(form.lastName, label_text="Họ") }} + {{ render_field(form.lastName, label_text="Last Name") }} </div> <div class="sm:col-span-4"> @@ -64,12 +64,12 @@ </div> <div class="sm:col-span-4"> - {{ render_field(form.phone, label_text="Số điện thoại") }} + {{ render_field(form.phone, label_text="Phone Number") }} </div> {% if current_user.role == 'Dietitian' and current_user.dietitian %} <div class="sm:col-span-6 border-t border-gray-200 pt-6 mt-6"> - <h3 class="text-base font-semibold leading-6 text-gray-900">Thông tin Chuyên gia Dinh dưỡng</h3> + <h3 class="text-base font-semibold leading-6 text-gray-900">Dietitian Information</h3> </div> <div class="sm:col-span-4"> @@ -79,18 +79,18 @@ </div> <div class="sm:col-span-4"> - <label class="block text-sm font-medium text-gray-700">Trạng thái</label> + <label class="block text-sm font-medium text-gray-700">Dietitian Status</label> <input type="text" value="{{ current_user.dietitian.status.value.replace('_', ' ').title() }}" readonly class="mt-1 block w-full bg-gray-50 shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm border-gray-300 rounded-md"> - <p class="mt-1 text-xs text-gray-500">Trạng thái được cập nhật tự động dựa trên số lượng bệnh nhân.</p> + <p class="mt-1 text-xs text-gray-500">Dietitian's status is automatically updated based on the number of patients.</p> </div> <div class="sm:col-span-6"> - {{ render_field(form.specialization, label_text="Chuyên môn") }} + {{ render_field(form.specialization, label_text="Specialization") }} </div> <div class="sm:col-span-6"> - <label for="notes" class="block text-sm font-medium text-gray-700">Ghi chú</label> + <label for="notes" class="block text-sm font-medium text-gray-700">Note</label> <div class="mt-1"> {{ form.notes(id="notes", rows="4", class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md") }} </div> @@ -102,10 +102,10 @@ <div class="pt-5 border-t border-gray-200 mt-6"> <div class="flex justify-end"> <a href="{{ url_for('auth.profile') }}" 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-indigo-500"> - Hủy + Cancel </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-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> - Lưu thay đổi + Update Password </button> </div> </div> @@ -117,13 +117,13 @@ <form action="{{ url_for('auth.change_password') }}" method="POST" class="space-y-6"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <div> - <h3 class="text-base font-semibold leading-6 text-gray-900">Thay đổi mật khẩu</h3> - <p class="mt-1 text-sm text-gray-500">Cập nhật mật khẩu của bạn để bảo mật tài khoản.</p> + <h3 class="text-base font-semibold leading-6 text-gray-900">Change Password</h3> + <p class="mt-1 text-sm text-gray-500">Update your password to secure your account.</p> </div> <div class="grid grid-cols-1 gap-y-6 sm:grid-cols-6"> <div class="sm:col-span-4"> - <label for="current_password" class="block text-sm font-medium text-gray-700">Mật khẩu hiện tại</label> + <label for="current_password" class="block text-sm font-medium text-gray-700">Current Password</label> <div class="mt-1"> <input type="password" name="current_password" id="current_password" class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> @@ -132,7 +132,7 @@ </div> <div class="sm:col-span-4"> - <label for="new_password" class="block text-sm font-medium text-gray-700">Mật khẩu mới</label> + <label for="new_password" class="block text-sm font-medium text-gray-700">New Password</label> <div class="mt-1"> <input type="password" name="new_password" id="new_password" class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> @@ -141,7 +141,7 @@ </div> <div class="sm:col-span-4"> - <label for="confirm_password" class="block text-sm font-medium text-gray-700">Xác nhận mật khẩu mới</label> + <label for="confirm_password" class="block text-sm font-medium text-gray-700">Confirm Password</label> <div class="mt-1"> <input type="password" name="confirm_password" id="confirm_password" class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> @@ -153,10 +153,10 @@ <div class="pt-5 border-t border-gray-200 mt-6"> <div class="flex justify-end"> <button type="button" 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-indigo-500" onclick="showProfileTab()"> - Hủy + Canel </button> <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-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> - Cập nhật mật khẩu + Update Password </button> </div> </div> diff --git a/app/templates/login.html b/app/templates/login.html index ce62c09868201387ece0bde02cdea4ac01b632cb..5de5d5a9583d931f07fd7b42482bc6cf2b1cbfbc 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,6 +1,6 @@ {% extends "auth_base.html" %} -{% block title %}Đăng nhập - CCU HTM{% endblock %} +{% block title %}Login - CCU HTM{% endblock %} {% block extra_css %} <style> @@ -78,7 +78,7 @@ {% block content %} <div class="bg-white rounded-lg shadow-lg p-8 login-form"> - <h2 class="text-3xl font-bold text-center text-gray-800 mb-6">Đăng nhập</h2> + <h2 class="text-3xl font-bold text-center text-gray-800 mb-6">Login</h2> <!-- Error message container - Only takes space when messages exist --> <div class="error-container {% if not get_flashed_messages() %}hidden{% endif %}"> @@ -109,7 +109,7 @@ <div class="mb-5"> <label for="email" class="block form-label font-medium text-gray-700">Email</label> - {{ form.email(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập email của bạn") }} + {{ form.email(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Enter your email") }} {% if form.email.errors %} <div class="text-red-600 text-sm mt-1"> {% for error in form.email.errors %} @@ -120,8 +120,8 @@ </div> <div class="mb-5"> - <label for="dietitianID" class="block form-label font-medium text-gray-700">Dietitian ID (nếu có)</label> - {{ form.dietitianID(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Ví dụ: DT-00001") }} + <label for="dietitianID" class="block form-label font-medium text-gray-700">Dietitian ID</label> + {{ form.dietitianID(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="For example: DT-00069") }} {% if form.dietitianID.errors %} <div class="text-red-600 text-sm mt-1"> {% for error in form.dietitianID.errors %} @@ -132,8 +132,8 @@ </div> <div class="mb-5"> - <label for="password" class="block form-label font-medium text-gray-700">Mật khẩu</label> - {{ form.password(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập mật khẩu của bạn") }} + <label for="password" class="block form-label font-medium text-gray-700">Password</label> + {{ form.password(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Enter your password") }} {% if form.password.errors %} <div class="text-red-600 text-sm mt-1"> {% for error in form.password.errors %} @@ -147,13 +147,13 @@ <div class="flex items-center"> {{ form.remember(class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded") }} <label for="remember" class="ml-2 block text-sm md:text-base text-gray-700"> - Ghi nhớ đăng nhập + Remember me </label> </div> </div> <button type="submit" class="btn-login w-full border border-transparent rounded-md shadow-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> - Đăng nhập + Login </button> </form> </div> diff --git a/app/templates/patient_detail.html b/app/templates/patient_detail.html index 830cb77c5cd8638dc2a2ba8f62a8e07158e8ffe6..f8e5c6d8ecf5ee7f81ba7769f0a0f35196de9653 100644 --- a/app/templates/patient_detail.html +++ b/app/templates/patient_detail.html @@ -1,6 +1,8 @@ {% extends "base.html" %} -{% block title %}Chi tiết bệnh nhân - CCU_BVNM{% endblock %} +{% block title %}Patient Detail - CCU_BVNM{% endblock %} + +{% block header %}Patient Details{% endblock %} {% block head %} <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script> @@ -14,13 +16,13 @@ <ol class="inline-flex items-center space-x-1 md:space-x-3"> <li class="inline-flex items-center"> <a href="{{ url_for('main.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ủ + <i class="fas fa-home mr-2"></i> Home </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="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Bệnh nhân</a> + <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 aria-current="page"> @@ -36,7 +38,7 @@ <!-- 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 text-gray-900 sm:text-3xl" style="line-height: 1.4;"> + <h2 class="text-2xl font-bold text-gray-900 sm:text-2xl" style="line-height: 1.4;"> {{ patient.full_name }} </h2> </div> @@ -83,15 +85,15 @@ <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 Bệnh nhân</div> + <div class="text-sm font-medium text-gray-500">Patient ID</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="text-sm font-medium text-gray-500">Join Date</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 BN</div> + <div class="text-sm font-medium text-gray-500">Patient Status</div> <div class="mt-1"> {# Cập nhật hiển thị và màu sắc dựa trên PatientStatus Enum #} {% set p_status = patient.status %} @@ -109,7 +111,7 @@ </div> </div> <div class="flex flex-col"> - <div class="text-sm font-medium text-gray-500">Trạng thái Giới thiệu/Đánh giá</div> + <div class="text-sm font-medium text-gray-500">Referral Status</div> <div class="mt-1"> {# Hiển thị trạng thái Referral liên kết với Encounter mới nhất #} {% set latest_enc_referral = latest_encounter.referrals|first if latest_encounter and latest_encounter.referrals else None %} @@ -134,25 +136,25 @@ <div class="border-b border-gray-200"> <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 + Overview </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) + Encounters </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á + Referrals </a> {# Hide Procedures tab link for admin #} {% if not current_user.is_admin %} <a href="#procedures" 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="procedures"> - Thủ thuật + Procedures </a> {% endif %} {# Hide Reports tab link for admin #} {% if not current_user.is_admin %} <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 + Reports </a> {% endif %} </nav> @@ -168,7 +170,7 @@ <div class="bg-white shadow-md rounded-lg col-span-1 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"> <h3 class="text-lg leading-6 font-medium text-gray-900"> - Thông tin cá nhân + Patient's Overview Stats </h3> </div> <div class="px-4 py-5 sm:p-6"> @@ -178,33 +180,33 @@ <dd class="text-sm text-gray-900">{{ patient.id }}</dd> </div> <div class="flex justify-between"> - <dt class="text-sm font-medium text-gray-500">Tên đầy đủ</dt> + <dt class="text-sm font-medium text-gray-500">Full name</dt> <dd class="text-sm text-gray-900">{{ patient.full_name }}</dd> </div> <div class="flex justify-between"> - <dt class="text-sm font-medium text-gray-500">Tuổi</dt> + <dt class="text-sm font-medium text-gray-500">Age</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> + <dt class="text-sm font-medium text-gray-500">Gender</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> + <dt class="text-sm font-medium text-gray-500">Height</dt> {# Định dạng lại chiều cao #} <dd class="text-sm text-gray-900">{{ "%.1f cm"|format(patient.height) if patient.height else 'N/A' }}</dd> </div> <div class="flex justify-between"> - <dt class="text-sm font-medium text-gray-500">Cân nặng</dt> + <dt class="text-sm font-medium text-gray-500">Weight</dt> {# Định dạng lại cân nặng #} <dd class="text-sm text-gray-900">{{ "%.1f kg"|format(patient.weight) if patient.weight else 'N/A' }}</dd> </div> <div class="flex justify-between"> - <dt class="text-sm font-medium text-gray-500">Nhóm máu</dt> + <dt class="text-sm font-medium text-gray-500">Blood type</dt> <dd class="text-sm text-gray-900">{{ patient.blood_type or 'N/A' }}</dd> {# Sửa lại cách hiển thị N/A #} </div> <div class="flex justify-between"> - <dt class="text-sm font-medium text-gray-500">Chuyên gia DD</dt> + <dt class="text-sm font-medium text-gray-500">In-charge dietitian</dt> {# Cập nhật để dùng assigned_dietitian từ Patient #} <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ patient.assigned_dietitian.full_name if patient.assigned_dietitian else 'Chưa gán' }}</dd> </div> @@ -217,12 +219,12 @@ <div class="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-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 + Recent key health indicators </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' }} + Last updatedt: {{ latest_measurement.measurementDateTime.strftime('%H:%M %d/%m/%Y') if latest_measurement and latest_measurement.measurementDateTime else 'N/A' }} {# Sửa url_for để trỏ đến encounter mới nhất #} - <a href="{{ url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=latest_encounter.encounterID) if latest_encounter else '#' }}" class="ml-2 text-blue-600 hover:underline">(Xem biểu đồ chi tiết)</a> + <a href="{{ url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=latest_encounter.encounterID) if latest_encounter else '#' }}" class="ml-2 text-blue-600 hover:underline">(View detailed charts)</a> </p> </div> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 p-6"> @@ -272,7 +274,7 @@ {# Sửa url_for để trỏ đến encounter mới nhất #} <a href="{{ url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=latest_encounter.encounterID) if latest_encounter else '#' }}#heartRateChart" class="block bg-gray-50 shadow-inner rounded-lg overflow-hidden transition-all duration-300 hover:shadow-md transform hover:scale-105"> <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="text-sm font-semibold text-gray-700">Heart Rate</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"> @@ -289,7 +291,7 @@ {# Sửa url_for để trỏ đến encounter mới nhất #} <a href="{{ url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=latest_encounter.encounterID) if latest_encounter else '#' }}#bloodPressureChart" class="block bg-gray-50 shadow-inner rounded-lg overflow-hidden transition-all duration-300 hover:shadow-md transform hover:scale-105"> <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="text-sm font-semibold text-gray-700">Blood Pressure</span> <!-- Logic trạng thái huyết áp cần thêm --> </div> <div class="px-4 py-4 flex items-center justify-center"> @@ -323,7 +325,7 @@ {# Sửa url_for để trỏ đến encounter mới nhất #} <a href="{{ url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=latest_encounter.encounterID) if latest_encounter else '#' }}#temperatureChart" class="block bg-gray-50 shadow-inner rounded-lg overflow-hidden transition-all duration-300 hover:shadow-md transform hover:scale-105"> <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="text-sm font-semibold text-gray-700">Temperature</span> <!-- Logic trạng thái nhiệt độ cần thêm --> </div> <div class="px-4 py-4 flex items-center justify-center"> @@ -363,7 +365,7 @@ <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) + Encounter History (Encounters) </h3> {# Thay thế button bằng form - bỏ onsubmit và chỉ dùng JavaScript #} <form id="new-encounter-form" action="{{ url_for('patients.new_encounter', patient_id=patient.id) }}" method="POST" class="inline-block"> @@ -378,7 +380,7 @@ data-latest-id="{{ latest_encounter.encounterID if latest_encounter else 'null' }}" data-latest-custom-id="{{ latest_encounter.custom_encounter_id if latest_encounter and latest_encounter.custom_encounter_id else 'null' }}"> <i class="fas fa-plus mr-2"></i> - Thêm lượt khám + Add Encounter </button> </form> </div> @@ -389,11 +391,11 @@ <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">Thời gian khám</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chỉ số bất ổn (Max 3)</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">Time</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dietitian</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Critical Indicators (Max 3)</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">ACTIONS</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -413,7 +415,7 @@ </td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ encounter.start_time.strftime('%d/%m/%Y %H:%M') if encounter.start_time else 'N/A' }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ encounter.assigned_dietitian.full_name if encounter.assigned_dietitian else 'Chưa gán' }} + {{ encounter.assigned_dietitian.full_name if encounter.assigned_dietitian else 'Not assigned' }} </td> {# --- SỬA CỘT CHỈ SỐ BẤT ỔN --- #} <td class="px-6 py-4 whitespace-nowrap text-sm text-red-600 font-medium"> @@ -422,32 +424,27 @@ {% if enc_data.limit_breaches %} {% for feature in enc_data.limit_breaches %} {% if displayed_count < 3 %} - <span class="inline-block mr-1 px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-xs" title="Vượt ngưỡng giới hạn"> + <span class="inline-block mr-1 px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-xs" title="Exceeding threshold limits"> <i class="fas fa-exclamation-circle mr-1"></i>{{ feature }} </span> {% set displayed_count = displayed_count + 1 %} {% endif %} {% endfor %} {% endif %} - {# Hiển thị top features nếu còn chỗ #} + {# Hiển thị top features nếu còn chỗ - backend đã lọc nên không cần kiểm tra trùng lặp #} {% if displayed_count < 3 and enc_data.top_features %} {% for feature in enc_data.top_features %} - {# Chỉ hiển thị nếu feature này chưa được hiển thị ở phần vượt ngưỡng #} - {% if feature not in enc_data.limit_breaches %} - {% if displayed_count < 3 %} - <span class="inline-block mr-1 px-1.5 py-0.5 bg-yellow-100 text-yellow-800 rounded text-xs" title="Ảnh hưởng cao nhất"> - <i class="fas fa-star mr-1"></i>{{ feature }} - </span> - {% set displayed_count = displayed_count + 1 %} - {% endif %} - {% endif %} + {% if displayed_count < 3 %} + <span class="inline-block mr-1 px-1.5 py-0.5 bg-yellow-100 text-yellow-800 rounded text-xs" title="Highest influence"> + <i class="fas fa-star mr-1"></i>{{ feature }} + </span> + {% set displayed_count = displayed_count + 1 %} + {% endif %} {% endfor %} {% endif %} {# Nếu không có gì để hiển thị #} - {% if displayed_count == 0 and enc_data.encounter.ml_needs_intervention is not none %} - <span class="text-gray-500 italic text-xs">None significant</span> - {% elif displayed_count == 0 %} - <span class="text-gray-400">-</span> {# Chưa chạy ML hoặc không có dữ liệu #} + {% if displayed_count == 0 %} + <span class="text-gray-400">-</span> {# No ML data or no data to display #} {% endif %} </td> {# --- KẾT THÚC SỬA --- #} @@ -479,7 +476,7 @@ </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> + <p class="text-center text-gray-500 py-4">No encounter history.</p> {% endif %} </div> </div> @@ -491,7 +488,7 @@ <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ử Giới thiệu & Đánh giá Dinh dưỡng + Nutrition Assessment & Referral History </h3> </div> <div class="border-t border-gray-200 px-4 py-5 sm:p-6"> @@ -501,12 +498,12 @@ <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">Request Date</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Encounter</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lý do</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">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> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reason</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dietitian</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">ACTIONS</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -542,8 +539,8 @@ ReferralStatus.WAITING_FOR_REPORT: 'orange', ReferralStatus.COMPLETED: 'emerald' } %} - {% set ref_color = ref_color_map.get(ref_status, 'gray') %} - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ ref_color }}-100 text-{{ ref_color }}-800"> + {% set ref_color = ref_color_map.get(ref_status, 'gray') %} + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ ref_color }}-100 text-{{ ref_color }}-800"> {{ ref_status.value if ref_status else 'Unknown' }} </span> {# Thêm tên dietitian nếu đã completed #} @@ -552,7 +549,7 @@ {% endif %} </td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> - {{ ref.assigned_dietitian.full_name if ref.assigned_dietitian else 'Chưa gán' }} + {{ ref.assigned_dietitian.full_name if ref.assigned_dietitian else 'Not assigned' }} </td> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2"> {# Căn chỉnh lại icon và thêm link #} @@ -572,7 +569,7 @@ </table> </div> {% else %} - <p class="text-center text-gray-500 py-4">Không có giới thiệu nào.</p> + <p class="text-center text-gray-500 py-4">No referrals found.</p> {% endif %} </div> </div> @@ -586,7 +583,7 @@ {# Conditional Header for Procedures Tab #} <div class="px-4 py-5 sm:px-6 flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-gray-200"> <h3 class="text-lg leading-6 font-medium text-gray-900 mb-2 sm:mb-0"> - Danh sách Thủ thuật + Procedures List </h3> {# Check if procedures list exists and is not empty #} {% if procedures and procedures|length > 0 %} @@ -596,7 +593,7 @@ class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-150 {% if has_ongoing_encounter %}bg-blue-600 hover:bg-blue-700{% else %}bg-gray-400 cursor-not-allowed{% endif %}" {% if not has_ongoing_encounter %}aria-disabled="true" title="No active encounter to add procedure to." tabindex="-1"{% endif %}> - <i class="fas fa-plus mr-1"></i> Thêm Thủ thuật + <i class="fas fa-plus mr-1"></i> Add Procedure </a> {% else %} {# Display text link if no procedures exist #} @@ -615,14 +612,14 @@ <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">Loại</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tên</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Thời gian</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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">Mô tả</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start Time</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">End Time</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Results</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Encounter ID</th> - <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Hành động</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -682,7 +679,7 @@ <div class="bg-white shadow-md rounded-lg overflow-hidden"> <div class="px-4 py-5 sm:px-6 border-b border-gray-200 flex justify-between items-center"> <h3 class="text-lg leading-6 font-medium text-gray-900"> - Danh sách Báo cáo Dinh dưỡng + Nutrition Reports List </h3> </div> <div class="px-4 py-5 sm:p-6"> @@ -695,7 +692,7 @@ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IN-CHARGE DIETITIAN</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">STATUS</th> {# Change MORE back to THAO TAC and align right #} - <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-right text-xs font-medium text-gray-500 uppercase tracking-wider">ACTIONS</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -730,13 +727,13 @@ {# Bọc các actions vào div để căn chỉnh #} <div class="flex items-center justify-end space-x-2"> {# Nút Xem #} - <a href="{{ url_for('report.view_report', report_id=report.id) }}" class="text-blue-600 hover:text-blue-900" title="Xem chi tiết"><i class="fas fa-eye"></i></a> + <a href="{{ url_for('report.view_report', report_id=report.id) }}" class="text-blue-600 hover:text-blue-900" title="View details"><i class="fas fa-eye"></i></a> {# SỬA: Nút Sửa (chỉ khi Pending/Draft và có quyền) #} {# Điều kiện: report status là Draft hoặc Pending VÀ (user là admin HOẶC user là author HOẶC user là dietitian được gán) #} {% set report_status = report.status.value if report.status else None %} {% if report_status in ['Draft', 'Pending'] and (current_user.is_admin or (report.author_id == current_user.userID) or (report.dietitian_id == current_user.userID)) %} - <a href="{{ url_for('report.edit_report', report_id=report.id) }}" class="text-indigo-600 hover:text-indigo-900" title="Chỉnh sửa"><i class="fas fa-edit"></i></a> + <a href="{{ url_for('report.edit_report', report_id=report.id) }}" class="text-indigo-600 hover:text-indigo-900" title="Edit report"><i class="fas fa-edit"></i></a> {% endif %} </div> </td> @@ -745,7 +742,7 @@ </tbody> </table> {% else %} - <p class="text-center text-gray-500 py-6">Chưa có báo cáo nào cho bệnh nhân này.</p> + <p class="text-center text-gray-500 py-6">No reports available for this patient.</p> {% endif %} </div> </div> diff --git a/app/templates/profile.html b/app/templates/profile.html index 56bbb6ee58ddf1999f48a121706d872c528612b6..61ca33f7d0e23745f3b39aa1614944a482cba6b7 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -1,16 +1,17 @@ {% extends "base.html" %} {% from "_macros.html" import status_badge %} -{% block title %}Hồ sơ người dùng{% endblock %} +{% block title %}User Profile{% endblock %} +{% block header %}Profile{% endblock %} {% block content %} <div class="container mx-auto mt-10 px-4"> <div class="flex justify-between items-center mb-6"> - <h1 class="text-3xl font-semibold text-gray-800">Hồ sơ người dùng</h1> + <h1 class="text-2xl font-semibold text-gray-800">Your Profile</h1> {# Display Dietitian Status (Read-only) #} {% if current_user.role == 'Dietitian' and current_user.dietitian %} <div class="flex items-center space-x-2"> - <span class="text-sm font-medium text-gray-600">Trạng thái:</span> + <span class="text-sm font-medium text-gray-600">Status:</span> {{ status_badge(current_user.dietitian.status.value) }} </div> {% endif %} @@ -30,7 +31,7 @@ <div class="border-t border-gray-200 pt-4"> <dl> <div class="bg-gray-50 px-4 py-3 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> + <dt class="text-sm font-medium text-gray-500">Full Name</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.full_name }}</dd> </div> <div class="bg-white px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> @@ -38,11 +39,11 @@ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.email }}</dd> </div> <div class="bg-gray-50 px-4 py-3 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">{{ current_user.phone or 'Chưa cập nhật' }}</dd> + <dt class="text-sm font-medium text-gray-500">Phone Number</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.phone or 'Not updated' }}</dd> </div> <div class="bg-white px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> - <dt class="text-sm font-medium text-gray-500">Vai trò</dt> + <dt class="text-sm font-medium text-gray-500">Role</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.role }}</dd> </div> {# Dietitian Specific Info #} @@ -52,20 +53,20 @@ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.dietitian.formattedID }}</dd> </div> <div class="bg-white px-4 py-3 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">{{ current_user.dietitian.specialization or 'Chưa cập nhật' }}</dd> + <dt class="text-sm font-medium text-gray-500">Specialization</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.dietitian.specialization or 'Not updated' }}</dd> </div> <div class="bg-gray-50 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> - <dt class="text-sm font-medium text-gray-500">Số bệnh nhân đang theo dõi</dt> + <dt class="text-sm font-medium text-gray-500">Number of Patients</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.dietitian.patient_count }}</dd> </div> <div class="bg-white px-4 py-3 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 whitespace-pre-wrap">{{ current_user.dietitian.notes or 'Không có ghi chú' }}</dd> + <dt class="text-sm font-medium text-gray-500">Notes</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 whitespace-pre-wrap">{{ current_user.dietitian.notes or 'No notes' }}</dd> </div> {% endif %} <div class="bg-gray-50 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> - <dt class="text-sm font-medium text-gray-500">Ngày tham gia</dt> + <dt class="text-sm font-medium text-gray-500">Join Date</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.created_at.strftime('%d/%m/%Y') if current_user.created_at else 'N/A' }}</dd> </div> </dl> @@ -73,7 +74,7 @@ </div> <div class="px-6 py-3 bg-gray-50 text-right"> <a href="{{ url_for('auth.edit_profile') }}" 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"> - Chỉnh sửa hồ sơ + Edit Profile </a> </div> </div> diff --git a/app/templates/report.html b/app/templates/report.html index e7e27a547c47fe7dcccfa629bf82800554a82234..0186e21e0ba017d711a2852395547d49a9dcc913 100644 --- a/app/templates/report.html +++ b/app/templates/report.html @@ -2,11 +2,13 @@ {% block title %}Reports - CCU HTM{% endblock %} +{% block header %}Reports{% endblock %} + {% block content %} <div class="px-4 py-6 sm:px-6 lg:px-8 max-w-7xl mx-auto"> <div class="mb-6 md:flex md:items-center md:justify-between fade-in"> <div class="min-w-0 flex-1"> - <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight"> + <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-2xl sm:tracking-tight"> Reports and Analysis {% if current_user.role == 'Dietitian' %} (My Reports){% endif %} </h2> </div> @@ -358,68 +360,92 @@ </div> {% endif %} +{# Chuẩn bị biến JavaScript để sử dụng trong script block #} +{% set report_type_data_json = report_type_chart_data|default({})|tojson|safe %} + +{% if current_user.is_admin and dietitian_contribution_chart_data %} + {% set dietitian_contribution_data_json = dietitian_contribution_chart_data|tojson|safe %} +{% else %} + {% set dietitian_contribution_data_json = '{}'|safe %} +{% endif %} {# Import thư viện Chart.js nếu chưa có trong base #} <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script> <script> document.addEventListener('DOMContentLoaded', function() { - // Truyền vai trò người dùng vào biến JavaScript + // Pass user role and stats data to JavaScript const currentUserRole = {{ current_user.role|tojson }}; - // Truyền dữ liệu stats vào JavaScript - const reportStats = {{ stats|tojson|safe }}; - - // --- Auto-submit filter form for Dietitian -> REMOVED --- + // Ensure stats exists and is a dict before trying to access keys, default to empty obj + const reportStats = {{ stats|default({})|tojson|safe }}; - // --- Dropdown cho Generate Statistics (Chỉ Admin) --- - const dropdownButton = document.getElementById('reportDropdownButton'); - const dropdownMenu = document.getElementById('reportDropdown'); - if (dropdownButton && dropdownMenu) { - dropdownButton.addEventListener('click', function(event) { - event.stopPropagation(); // Ngăn sự kiện click lan ra ngoài - dropdownMenu.classList.toggle('hidden'); + // --- Dropdown for Generate Statistics (Admin only, in filter bar) --- + const statsDropdownButton = document.getElementById('reportStatsDropdownButton_filter'); + const statsDropdownMenu = document.getElementById('reportStatsDropdown_filter'); + if (statsDropdownButton && statsDropdownMenu) { + statsDropdownButton.addEventListener('click', function(event) { + event.stopPropagation(); // Prevent event bubbling + statsDropdownMenu.classList.toggle('hidden'); }); - // Đóng dropdown khi click ra ngoài + // Close dropdown when clicking outside document.addEventListener('click', function(event) { - if (!dropdownButton.contains(event.target) && !dropdownMenu.contains(event.target)) { - dropdownMenu.classList.add('hidden'); + // Check if the click is outside the button AND outside the menu + if (!statsDropdownButton.contains(event.target) && !statsDropdownMenu.contains(event.target)) { + statsDropdownMenu.classList.add('hidden'); } }); } - - // --- Hàm tạo màu ngẫu nhiên cho biểu đồ cột --- + + // --- Random color generator for bar charts --- function getRandomColor() { - const r = Math.floor(Math.random() * 200); // Giảm giá trị max để màu đậm hơn + const r = Math.floor(Math.random() * 200); // Lower max value for darker colors const g = Math.floor(Math.random() * 200); const b = Math.floor(Math.random() * 200); return `rgba(${r}, ${g}, ${b}, 0.7)`; } - - // --- Màu cố định cho status chart --- + + // --- Fixed colors for status charts --- const statusColors = { 'Draft': 'rgba(107, 114, 128, 0.7)', // gray 'Pending': 'rgba(245, 158, 11, 0.7)', // amber/yellow 'Completed': 'rgba(16, 185, 129, 0.7)' // emerald/green }; - // --- Biểu đồ Report Type Distribution (Chung) --- + // --- Helper function to render fallback text on canvas --- + function renderCanvasFallback(ctx, message) { + if (!ctx) return; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // Clear previous drawings + ctx.font = "16px Arial"; + ctx.fillStyle = "gray"; + ctx.textAlign = "center"; + ctx.fillText(message, ctx.canvas.width / 2, ctx.canvas.height / 2); + } + + // --- Report Type Distribution Chart (Common) --- const reportTypeCtx = document.getElementById('reportTypeChart')?.getContext('2d'); - // Sử dụng try-catch để xử lý lỗi parse JSON tiềm ẩn let reportTypeData = null; + + // Sử dụng biến đã được chuẩn bị bởi Jinja trước đó try { - reportTypeData = JSON.parse('{{ report_type_chart_data|tojson|safe }}'); + const reportTypeDataStr = '{{ report_type_data_json }}'; + if (reportTypeDataStr && reportTypeDataStr !== '{}' && reportTypeDataStr !== 'null') { + reportTypeData = JSON.parse(reportTypeDataStr); + } } catch (e) { console.error("Error parsing report type chart data:", e); } - if (reportTypeCtx && reportTypeData && reportTypeData.labels && reportTypeData.data && reportTypeData.labels.length === reportTypeData.data.length) { + // Validate data structure before creating chart + if (reportTypeCtx && reportTypeData && Array.isArray(reportTypeData.labels) && + Array.isArray(reportTypeData.data) && reportTypeData.labels.length === reportTypeData.data.length && + reportTypeData.labels.length > 0) { new Chart(reportTypeCtx, { - type: 'pie', // Hoặc 'doughnut' + type: 'pie', // Or 'doughnut' data: { labels: reportTypeData.labels, datasets: [{ label: 'Number of Reports', data: reportTypeData.data, - backgroundColor: [ // Cung cấp đủ màu cho số lượng labels có thể có + backgroundColor: [ // Provide enough base colors 'rgba(59, 130, 246, 0.7)', // blue 'rgba(16, 185, 129, 0.7)', // emerald 'rgba(245, 158, 11, 0.7)', // amber @@ -430,7 +456,8 @@ document.addEventListener('DOMContentLoaded', function() { 'rgba(34, 197, 94, 0.7)', // green 'rgba(249, 115, 22, 0.7)', // orange 'rgba(107, 114, 128, 0.7)' // gray - ], + // Add more colors if more types are expected + ].slice(0, reportTypeData.labels.length), // Use only needed colors borderColor: 'rgba(255, 255, 255, 0.8)', // White border borderWidth: 1 }] @@ -445,7 +472,9 @@ document.addEventListener('DOMContentLoaded', function() { label: function(context) { const label = context.label || ''; const value = context.parsed || 0; - return `${label}: ${value}`; + const total = context.chart.data.datasets[0].data.reduce((a, b) => a + b, 0); + const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0; + return `${label}: ${value} (${percentage}%)`; } } } @@ -453,38 +482,39 @@ document.addEventListener('DOMContentLoaded', function() { } }); } else if(reportTypeCtx) { - console.warn("Report type chart data is invalid or missing."); - // Có thể hiển thị thông báo lỗi trên canvas - reportTypeCtx.font = "16px Arial"; - reportTypeCtx.fillStyle = "gray"; - reportTypeCtx.textAlign = "center"; - reportTypeCtx.fillText("No data available for report types.", reportTypeCtx.canvas.width / 2, reportTypeCtx.canvas.height / 2); + console.warn("Report type chart data is invalid, missing, or empty."); + renderCanvasFallback(reportTypeCtx, "No data available for report types."); } - // --- Biểu đồ Dietitian Contribution (Chỉ Admin) --- + // --- Admin-Specific Charts --- if (currentUserRole === 'Admin') { + // --- Dietitian Contribution Chart (Admin) --- const contributionCtx = document.getElementById('dietitianContributionChart')?.getContext('2d'); let contributionData = null; - - // Chỉ cố gắng parse JSON nếu là Admin và có dữ liệu được truyền từ backend - {% if current_user.is_admin and dietitian_contribution_chart_data %} - try { - contributionData = JSON.parse('{{ dietitian_contribution_chart_data|tojson|safe }}'); - } catch (e) { - console.error("Error parsing dietitian contribution chart data:", e); - contributionData = null; // Đảm bảo là null nếu parse lỗi + + // Sử dụng biến đã được chuẩn bị bởi Jinja trước đó + try { + const contributionDataStr = '{{ dietitian_contribution_data_json }}'; + if (contributionDataStr && contributionDataStr !== '{}' && contributionDataStr !== 'null') { + contributionData = JSON.parse(contributionDataStr); } - {% endif %} + } catch (e) { + console.error("Error parsing dietitian contribution chart data:", e); + contributionData = null; // Ensure it's null on error + } - if (contributionCtx && contributionData && contributionData.labels && contributionData.data && contributionData.labels.length === contributionData.data.length && contributionData.labels.length > 0) { + // Validate data structure + if (contributionCtx && contributionData && Array.isArray(contributionData.labels) && + Array.isArray(contributionData.data) && contributionData.labels.length === contributionData.data.length && + contributionData.labels.length > 0) { new Chart(contributionCtx, { - type: 'bar', // Biểu đồ cột + type: 'bar', // Bar chart data: { labels: contributionData.labels, datasets: [{ label: 'Reports Created', data: contributionData.data, - backgroundColor: contributionData.labels.map(() => getRandomColor()), // Màu ngẫu nhiên cho mỗi cột + backgroundColor: contributionData.labels.map(() => getRandomColor()), // Random color for each bar borderColor: 'rgba(255, 255, 255, 0.5)', borderWidth: 1 }] @@ -492,7 +522,7 @@ document.addEventListener('DOMContentLoaded', function() { options: { responsive: true, maintainAspectRatio: false, - indexAxis: 'y', // Hiển thị tên dietitian dọc (dễ đọc hơn nếu nhiều) + indexAxis: 'y', // Display dietitian names vertically scales: { x: { beginAtZero: true, @@ -503,10 +533,11 @@ document.addEventListener('DOMContentLoaded', function() { } }, plugins: { - legend: { display: false }, // Ẩn legend vì chỉ có 1 dataset + legend: { display: false }, // Hide legend for single dataset tooltip: { callbacks: { label: function(context) { + // For horizontal bars, value is on the x-axis const value = context.parsed.x || 0; return ` Reports: ${value}`; } @@ -516,36 +547,41 @@ document.addEventListener('DOMContentLoaded', function() { } }); } else if (contributionCtx) { - // Chỉ hiển thị cảnh báo và text nếu là Admin và không có data hợp lệ + // Only show warning and fallback text if Admin and no valid data console.warn("Dietitian contribution chart data is invalid, missing, or empty."); - contributionCtx.font = "16px Arial"; - contributionCtx.fillStyle = "gray"; - contributionCtx.textAlign = "center"; - contributionCtx.fillText("No contribution data available.", contributionCtx.canvas.width / 2, contributionCtx.canvas.height / 2); + renderCanvasFallback(contributionCtx, "No contribution data available."); } - - // --- Biểu đồ Report Status Distribution (Admin - Full Width) --- + + // --- Report Status Distribution Chart (Admin - Full Width) --- const reportStatusAdminCtx = document.getElementById('reportStatusChartAdmin')?.getContext('2d'); - if (reportStatusAdminCtx && reportStats) { + if (reportStatusAdminCtx && reportStats && typeof reportStats === 'object') { const statusLabelsAdmin = ['Draft', 'Pending', 'Completed']; const statusDataAdmin = [ reportStats.draft || 0, reportStats.pending || 0, reportStats.completed || 0 ]; - // Lọc bỏ status không có report - const filteredLabelsAdmin = statusLabelsAdmin.filter((_, index) => statusDataAdmin[index] > 0); - const filteredDataAdmin = statusDataAdmin.filter(count => count > 0); + // Filter out statuses with zero reports for a cleaner chart + const filteredLabelsAdmin = []; + const filteredDataAdmin = []; + const filteredColorsAdmin = []; + statusLabelsAdmin.forEach((label, index) => { + if (statusDataAdmin[index] > 0) { + filteredLabelsAdmin.push(label); + filteredDataAdmin.push(statusDataAdmin[index]); + filteredColorsAdmin.push(statusColors[label]); + } + }); if (filteredLabelsAdmin.length > 0) { new Chart(reportStatusAdminCtx, { - type: 'bar', // Có thể đổi thành 'pie' hoặc 'doughnut' + type: 'bar', // Can change to 'pie' or 'doughnut' data: { labels: filteredLabelsAdmin, datasets: [{ label: 'Number of Reports', data: filteredDataAdmin, - backgroundColor: filteredLabelsAdmin.map(label => statusColors[label]), + backgroundColor: filteredColorsAdmin, // Use filtered colors borderColor: 'rgba(255, 255, 255, 0.8)', borderWidth: 1 }] @@ -553,7 +589,7 @@ document.addEventListener('DOMContentLoaded', function() { options: { responsive: true, maintainAspectRatio: false, - indexAxis: 'x', // Trục x cho status + indexAxis: 'x', // Status on x-axis scales: { y: { beginAtZero: true, @@ -578,36 +614,45 @@ document.addEventListener('DOMContentLoaded', function() { } }); } else { - reportStatusAdminCtx.font = "16px Arial"; - reportStatusAdminCtx.fillStyle = "gray"; - reportStatusAdminCtx.textAlign = "center"; - reportStatusAdminCtx.fillText("No report status data available.", reportStatusAdminCtx.canvas.width / 2, reportStatusAdminCtx.canvas.height / 2); + renderCanvasFallback(reportStatusAdminCtx, "No report status data available."); } + } else if (reportStatusAdminCtx) { + console.warn("Admin report status chart: Context exists but reportStats is invalid or missing."); + renderCanvasFallback(reportStatusAdminCtx, "Report status data unavailable."); } } - // --- Biểu đồ Report Status Distribution (Dietitian - Side) --- + // --- Dietitian-Specific Chart --- else if (currentUserRole === 'Dietitian') { + // --- Report Status Distribution Chart (Dietitian - Side) --- const reportStatusDietitianCtx = document.getElementById('reportStatusChartDietitian')?.getContext('2d'); - if (reportStatusDietitianCtx && reportStats) { + if (reportStatusDietitianCtx && reportStats && typeof reportStats === 'object') { const statusLabelsDietitian = ['Draft', 'Pending', 'Completed']; const statusDataDietitian = [ reportStats.draft || 0, reportStats.pending || 0, reportStats.completed || 0 ]; - // Lọc bỏ status không có report - const filteredLabelsDietitian = statusLabelsDietitian.filter((_, index) => statusDataDietitian[index] > 0); - const filteredDataDietitian = statusDataDietitian.filter(count => count > 0); - + // Filter out statuses with zero reports + const filteredLabelsDietitian = []; + const filteredDataDietitian = []; + const filteredColorsDietitian = []; + statusLabelsDietitian.forEach((label, index) => { + if (statusDataDietitian[index] > 0) { + filteredLabelsDietitian.push(label); + filteredDataDietitian.push(statusDataDietitian[index]); + filteredColorsDietitian.push(statusColors[label]); + } + }); + if (filteredLabelsDietitian.length > 0) { new Chart(reportStatusDietitianCtx, { - type: 'pie', // Pie chart cho Dietitian + type: 'pie', // Pie chart for Dietitian data: { labels: filteredLabelsDietitian, datasets: [{ label: 'Number of Reports', data: filteredDataDietitian, - backgroundColor: filteredLabelsDietitian.map(label => statusColors[label]), + backgroundColor: filteredColorsDietitian, // Use filtered colors borderColor: 'rgba(255, 255, 255, 0.8)', borderWidth: 1 }] @@ -622,7 +667,10 @@ document.addEventListener('DOMContentLoaded', function() { label: function(context) { const label = context.label || ''; const value = context.parsed || 0; - return `${label}: ${value}`; + // Calculate percentage for pie chart + const total = context.chart.data.datasets[0].data.reduce((a, b) => a + b, 0); + const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0; + return `${label}: ${value} (${percentage}%)`; } } } @@ -630,20 +678,20 @@ document.addEventListener('DOMContentLoaded', function() { } }); } else { - reportStatusDietitianCtx.font = "16px Arial"; - reportStatusDietitianCtx.fillStyle = "gray"; - reportStatusDietitianCtx.textAlign = "center"; - reportStatusDietitianCtx.fillText("No report status data available.", reportStatusDietitianCtx.canvas.width / 2, reportStatusDietitianCtx.canvas.height / 2); + renderCanvasFallback(reportStatusDietitianCtx, "No report status data available."); } + } else if (reportStatusDietitianCtx) { + console.warn("Dietitian report status chart: Context exists but reportStats is invalid or missing."); + renderCanvasFallback(reportStatusDietitianCtx, "Report status data unavailable."); } } - // Confirmation modal logic (dùng confirm() đơn giản) + // Confirmation modal logic (using simple confirm()) document.querySelectorAll('.needs-confirmation').forEach(form => { form.addEventListener('submit', function(event) { const message = this.getAttribute('data-confirmation-message') || 'Are you sure?'; if (!confirm(message)) { - event.preventDefault(); // Ngăn chặn submit nếu người dùng chọn Cancel + event.preventDefault(); // Prevent form submission if user cancels } }); }); diff --git a/app/templates/report_form.html b/app/templates/report_form.html index 93f1406df28f06c796a397d0a05a08ac4fdc12e0..2c106efd27f40c999319223aed6c482c23d430ef 100644 --- a/app/templates/report_form.html +++ b/app/templates/report_form.html @@ -134,9 +134,8 @@ {# Add link to create new procedure if the report's encounter is ongoing #} {% if report and report.encounter and report.encounter.status == EncounterStatus.ON_GOING %} <a href="{{ url_for('dietitian.new_procedure', patient_id=report.patient_id, encounter_id=report.encounter_id, redirect_to_report=report.id) }}" class="btn btn-link btn-sm p-0 mt-1" target="_blank"> - <i class="fas fa-plus me-1"></i>Thêm Thủ thuật Mới + <i class="fas fa-plus me-1"></i> Add New Procedure </a> - <small class="form-text text-muted d-block">Mở trong tab mới. Chọn lại thủ thuật từ dropdown sau khi thêm.</small> {% endif %} </div>