diff --git a/app/__init__.py b/app/__init__.py index 22290e19e57b26f0101719d87c954646cd25d376..a8ebbe53e4209e5611fb44971b5589cb3b8e6b97 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -37,6 +37,16 @@ def format_datetime_filter(value, format='%d/%m/%Y %H:%M:%S'): return value # Trả về giá trị gốc nếu không phải datetime return value.strftime(format) +def pluralize_filter(count, singular, plural=None): + """Trả về dạng số ít hoặc số nhiều dựa trên giá trị đếm.""" + if plural is None: + plural = singular + 's' + + if count == 0 or count == 1: + return singular + else: + return plural + # --- Thêm hàm helper cho report status badge --- def get_report_status_badge(status): status_lower = status.lower() if status else '' @@ -89,35 +99,36 @@ def create_app(config_class=None): # Có thể bỏ config_class hoặc dùng ch # Đăng ký Jinja filter và global function app.jinja_env.filters['format_datetime'] = format_datetime_filter app.jinja_env.globals.update(get_report_status_badge=get_report_status_badge) # Đăng ký global function + app.jinja_env.filters['pluralize'] = pluralize_filter # -------------------- # Import và đăng ký Blueprints from .routes.auth import auth_bp from .routes.patients import patients_bp from .routes.report import report_bp - from .routes.upload import upload_bp from .routes.dietitians import dietitians_bp + from .routes.notifications import notifications_bp + from .routes.main import main_bp + from .routes.dietitian import dietitian_bp + from .routes.support import support_bp from .routes.dashboard import dashboard_bp - from .routes.notifications import notifications_bp as notifications_blueprint - from .routes.main import main_bp # Import main_bp + from .routes.upload import upload_bp # from .routes.errors import errors_bp # Đã comment out / xóa # from .api import api_bp # Xóa hoặc comment out dòng này app.register_blueprint(auth_bp) app.register_blueprint(patients_bp) app.register_blueprint(report_bp) - app.register_blueprint(upload_bp) app.register_blueprint(dietitians_bp) + app.register_blueprint(notifications_bp, url_prefix='/notifications') + app.register_blueprint(main_bp) + app.register_blueprint(dietitian_bp) + app.register_blueprint(support_bp) app.register_blueprint(dashboard_bp) - app.register_blueprint(notifications_blueprint, url_prefix='/notifications') # Sử dụng tên đã đổi - app.register_blueprint(main_bp) # Register main_bp + app.register_blueprint(upload_bp) # app.register_blueprint(errors_bp) # Đã comment out / xóa # app.register_blueprint(api_bp) # Xóa hoặc comment out dòng này - # Register dietitian blueprint - from .routes.dietitian import dietitian_bp - app.register_blueprint(dietitian_bp) - # Import models để SQLAlchemy biết về chúng with app.app_context(): from . import models diff --git a/app/forms/__init__.py b/app/forms/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..52bbca673a2d5c1cc53535781dc44fb69d1631fc --- /dev/null +++ b/app/forms/__init__.py @@ -0,0 +1,8 @@ +from flask_wtf import FlaskForm +from .procedure import ProcedureForm +from wtforms import StringField, PasswordField, SubmitField, BooleanField, SelectField, TextAreaField, DateTimeField +from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError, Optional + +class SupportMessageForm(FlaskForm): + content = TextAreaField('Message', validators=[DataRequired(), Length(min=1, max=1000)]) + submit = SubmitField('Send Message') \ No newline at end of file diff --git a/app/forms/procedure.py b/app/forms/procedure.py index 306399f127c24a867e23255f3031cd057b40f6a3..7e6220de60d15550b6d88f133662f5e71028bb19 100644 --- a/app/forms/procedure.py +++ b/app/forms/procedure.py @@ -1,8 +1,9 @@ from flask_wtf import FlaskForm from wtforms import StringField, TextAreaField, SelectField, DateTimeField, SubmitField -from wtforms.validators import DataRequired, Optional, ValidationError -from app.models.encounter import Encounter +from wtforms.validators import DataRequired, Optional, ValidationError, Length +from app.models.encounter import Encounter, EncounterStatus from datetime import datetime +from sqlalchemy import desc # Danh sách các loại procedure phổ biến PROCEDURE_TYPES = [ @@ -17,45 +18,47 @@ PROCEDURE_TYPES = [ ] class ProcedureForm(FlaskForm): - """Form for adding or editing nutritional procedures.""" - - # Liên kết với Encounter (bắt buộc) - encounter_id = SelectField('Associated Encounter*', coerce=int, validators=[DataRequired(message="Please select the encounter this procedure belongs to.")]) - - # Loại procedure (bắt buộc) - procedureType = SelectField('Procedure Type*', choices=PROCEDURE_TYPES, validators=[DataRequired(message="Please select a procedure type.")]) - - # Tên cụ thể (tùy chọn, ví dụ: tên loại ống sonde) - procedureName = StringField('Specific Name/Detail (Optional)') - - # Thời gian thực hiện (bắt buộc) - procedureDateTime = DateTimeField('Date and Time Performed*', format='%Y-%m-%dT%H:%M', default=datetime.utcnow, validators=[DataRequired(message="Please enter the date and time.")]) - - # Mô tả (tùy chọn) - description = TextAreaField('Description/Notes (Optional)', render_kw={"rows": 4}) - - # Kết quả (tùy chọn) - procedureResults = TextAreaField('Results/Outcome (Optional)', render_kw={"rows": 4}) - - # Thời gian kết thúc (tùy chọn) + """Form for creating and editing procedures.""" + encounter_id = SelectField('Encounter', coerce=lambda x: int(x) if x and x != '' else None, validators=[DataRequired(message="Please select an encounter.")]) + procedureType = SelectField('Procedure Type', choices=PROCEDURE_TYPES, validators=[DataRequired()]) + procedureName = StringField('Procedure Name', validators=[DataRequired(), Length(max=100)]) + procedureDateTime = DateTimeField('Procedure Date and Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) procedureEndDateTime = DateTimeField('End Date and Time (Optional)', format='%Y-%m-%dT%H:%M', validators=[Optional()]) + description = TextAreaField('Description', validators=[Optional(), Length(max=1000)]) + procedureResults = TextAreaField('Procedure Results', validators=[Optional(), Length(max=2000)]) + submit = SubmitField('Submit') - submit = SubmitField('Save Procedure') - - def __init__(self, patient_id=None, *args, **kwargs): - """Khởi tạo form, đặc biệt là populate Encounter choices.""" + def __init__(self, patient_id=None, preselected_encounter_id=None, *args, **kwargs): super(ProcedureForm, self).__init__(*args, **kwargs) - self.encounter_id.choices = [('', '-- Select Encounter --')] if patient_id: - # Truy vấn các encounters của bệnh nhân để làm choices - # Sắp xếp theo thời gian giảm dần để encounter mới nhất lên đầu - encounters = Encounter.query.filter_by(patient_id=patient_id).order_by(Encounter.start_time.desc()).all() - self.encounter_id.choices.extend([ - (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M') if enc.start_time else 'N/A'})") - for enc in encounters - ]) + # Lấy tất cả các encounter cho bệnh nhân nếu có preselected_encounter_id + # hoặc chỉ lấy ON_GOING nếu không có preselected_encounter_id + if preselected_encounter_id: + encounters = Encounter.query.filter_by(patient_id=patient_id).order_by(desc(Encounter.start_time)).all() + self.encounter_id.choices = [ + (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") + for enc in encounters + ] + # Chọn sẵn và khóa field + self.encounter_id.data = preselected_encounter_id + self.encounter_id.render_kw = {'disabled': 'disabled'} + else: + # Chỉ lấy ON_GOING nếu không preselect + on_going_encounters = Encounter.query.filter( + Encounter.patient_id == patient_id, + Encounter.status == EncounterStatus.ON_GOING + ).order_by(desc(Encounter.start_time)).all() + self.encounter_id.choices = [ + (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") + for enc in on_going_encounters + ] + + # Đảm bảo DateTimeField hoạt động đúng (Removed second/microsecond reset as it's less critical) + # if self.procedureDateTime.data: + # self.procedureDateTime.data = self.procedureDateTime.data.replace(second=0, microsecond=0) + # if self.procedureEndDateTime.data: + # self.procedureEndDateTime.data = self.procedureEndDateTime.data.replace(second=0, microsecond=0) def validate_procedureEndDateTime(self, field): - """Đảm bảo thời gian kết thúc không trước thời gian bắt đầu.""" if field.data and self.procedureDateTime.data and field.data < self.procedureDateTime.data: raise ValidationError('End time cannot be earlier than start time.') \ No newline at end of file diff --git a/app/forms/report.py b/app/forms/report.py index f3f8d2a783e3b590856328f79257c0a9e906af7f..699cd36348b98c8e725b12a92098f98f6101ec85 100644 --- a/app/forms/report.py +++ b/app/forms/report.py @@ -8,8 +8,9 @@ from app.models.patient import Patient class ReportForm(FlaskForm): patient_id = StringField('Patient ID', validators=[DataRequired()]) referral_id = HiddenField('Referral ID') + encounter_id = HiddenField('Encounter ID') # Thêm trường chọn Procedure liên quan - related_procedure_id = SelectField('Related Procedure (Optional)', coerce=int, validators=[Optional()]) + related_procedure_id = SelectField('Related Procedure (Optional)', coerce=lambda x: int(x) if x != '' else None, validators=[Optional()]) report_type = SelectField('Report Type', choices=[ diff --git a/app/models/__init__.py b/app/models/__init__.py index 7852de55131c4c3edfa3c2a7eadf115feded0449..7de59ffb51e23f4c8667ef2caab0e39524d43b2a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,29 +1,31 @@ # Import all models here to make them available to the app from .user import User -from .patient import Patient +from .patient import Patient, PatientStatus from .encounter import Encounter from .measurement import PhysiologicalMeasurement -from .referral import Referral +from .referral import Referral, ReferralStatus from .procedure import Procedure from .report import Report from .uploaded_file import UploadedFile from .dietitian import Dietitian, DietitianStatus from .activity_log import ActivityLog from .notification import Notification +from .support import SupportMessage, SupportMessageReadStatus # Import bất kỳ model bổ sung nào ở đây __all__ = [ - 'User', - 'Patient', - 'Encounter', - 'PhysiologicalMeasurement', - 'Procedure', - 'Referral', - 'Report', + 'User', + 'Patient', 'PatientStatus', + 'Encounter', + 'PhysiologicalMeasurement', + 'Procedure', + 'Referral', 'ReferralStatus', + 'Report', 'UploadedFile', - 'Dietitian', - 'DietitianStatus', + 'Dietitian', 'DietitianStatus', 'ActivityLog', - 'Notification' + 'Notification', + 'DietitianProfile', + 'SupportMessage', 'SupportMessageReadStatus' ] \ No newline at end of file diff --git a/app/models/dietitian.py b/app/models/dietitian.py index 88c255559c2642754092d62615123a14ed3f28da..f5afef264ea823b6f5f6f9b646f41ba56e36da70 100644 --- a/app/models/dietitian.py +++ b/app/models/dietitian.py @@ -42,7 +42,9 @@ class Dietitian(db.Model): def patient_count(self): """Trả về số lượng bệnh nhân mà dietitian đang chăm sóc.""" from app.models.patient import Patient - return Patient.query.filter_by(dietitian_id=self.dietitianID).count() + if self.user_id: + return Patient.query.filter_by(assigned_dietitian_user_id=self.user_id).count() + return 0 def update_status_based_on_patient_count(self): """Cập nhật trạng thái dựa trên số lượng bệnh nhân.""" diff --git a/app/models/patient.py b/app/models/patient.py index db40ff3def1d4a7566718f952e00e251a3f2f45e..8debfc512245fcbdb92c91c7e55cb7a33518af72 100644 --- a/app/models/patient.py +++ b/app/models/patient.py @@ -45,7 +45,7 @@ class Patient(db.Model): # Thêm trường mới để lưu userID của dietitian được gán assigned_dietitian_user_id = Column(Integer, ForeignKey('users.userID'), nullable=True) - assigned_dietitian = relationship('User', foreign_keys=[assigned_dietitian_user_id], backref='assigned_patients') + assigned_dietitian = relationship('User', foreign_keys=[assigned_dietitian_user_id], back_populates='assigned_patients') # Thêm cột ngày gán assignment_date = Column(DateTime, nullable=True) diff --git a/app/models/support.py b/app/models/support.py new file mode 100644 index 0000000000000000000000000000000000000000..30af6313d2c5c6ff028b77bb4964eb4875f348fc --- /dev/null +++ b/app/models/support.py @@ -0,0 +1,32 @@ +from .. import db +from datetime import datetime +from sqlalchemy.orm import relationship +from .user import User + +class SupportMessage(db.Model): + __tablename__ = 'support_messages' + id = db.Column(db.Integer, primary_key=True) + sender_id = db.Column(db.Integer, db.ForeignKey('users.userID'), nullable=False) + content = db.Column(db.Text, nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + + sender = relationship('User', backref='sent_support_messages') + read_statuses = relationship('SupportMessageReadStatus', back_populates='message', cascade='all, delete-orphan') + + def __repr__(self): + return f'<SupportMessage {self.id} from User {self.sender_id}>' + +class SupportMessageReadStatus(db.Model): + __tablename__ = 'support_message_read_status' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.userID'), nullable=False) + message_id = db.Column(db.Integer, db.ForeignKey('support_messages.id'), nullable=False) + read_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = relationship('User') + message = relationship('SupportMessage') + + __table_args__ = (db.UniqueConstraint('user_id', 'message_id', name='uq_user_message_read'),) + + def __repr__(self): + return f'<SupportMessageReadStatus User {self.user_id} read Message {self.message_id}>' \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 2b7b4f57aeac9a1c5488bd2de5e090fb26619fa9..33ef868273d7b537f24fa96f6edc286292232c8d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,6 +25,7 @@ class User(UserMixin, db.Model): # Relationships dietitian = relationship("Dietitian", back_populates="user", uselist=False, cascade="all, delete-orphan") activities = relationship("ActivityLog", back_populates="user", lazy='dynamic') + assigned_patients = relationship('Patient', back_populates='assigned_dietitian', foreign_keys='[Patient.assigned_dietitian_user_id]') def set_password(self, password): self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') diff --git a/app/routes/__init__.py b/app/routes/__init__.py index ad2f2708856cdc3e3746f1e89f4f0df3a9d4ce18..5a5378ce00e273419336ff2060b8b59366dea94b 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,7 +1,29 @@ # Import all routes here to make them available to the app from app.routes.auth import auth_bp -from app.routes.dashboard import dashboard_bp +from app.routes.dashboard import dashboard_bp # Uncomment to restore dashboard from app.routes.patients import patients_bp -from app.routes.upload import upload_bp +from app.routes.upload import upload_bp # Uncomment upload_bp from app.routes.report import report_bp from app.routes.dietitians import dietitians_bp +from app.routes.dietitian import dietitian_bp +# from app.routes.admin import admin_bp # Xóa import này +from app.routes.support import support_bp +# Import main_bp nếu cần +from app.routes.main import main_bp +# Import notification_bp nếu cần +from app.routes.notifications import notifications_bp + +def register_blueprints(app): + app.register_blueprint(main_bp) # Đăng ký main_bp + app.register_blueprint(auth_bp) + app.register_blueprint(patients_bp) + app.register_blueprint(report_bp) + app.register_blueprint(dietitians_bp) + app.register_blueprint(dietitian_bp) + app.register_blueprint(dashboard_bp) # Restore dashboard blueprint + app.register_blueprint(upload_bp) # Đăng ký upload_bp + # app.register_blueprint(admin_bp) # Xóa đăng ký này + app.register_blueprint(support_bp) + app.register_blueprint(notifications_bp) # Đăng ký notification_bp + # Đăng ký các blueprint khác nếu cần (upload, dashboard?) + # Ví dụ: from app.routes.upload import upload_bp; app.register_blueprint(upload_bp) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index bb8822f33fb62664968c3507992a05a9a9e1695e..d27ac42d6061bbca4849f00d9ce0989c4f763f0b 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -3,7 +3,7 @@ from flask_login import login_required, current_user from app import db from app.models.patient import Patient, PatientStatus from app.models.encounter import Encounter -from app.models.referral import Referral +from app.models.referral import Referral, ReferralStatus from app.models.procedure import Procedure from app.models.report import Report from app.models.measurement import PhysiologicalMeasurement @@ -24,7 +24,7 @@ def index(): # Get statistics for dashboard stats = { 'total_patients': Patient.query.count(), - 'new_referrals': Referral.query.filter_by(referral_status='new').count(), + 'new_referrals': Referral.query.filter_by(referral_status=ReferralStatus.DIETITIAN_UNASSIGNED).count(), 'procedures_today': Procedure.query.filter( func.date(Procedure.procedureDateTime) == func.date(datetime.now()) ).count(), @@ -100,7 +100,7 @@ def index(): # Alert for overdue referrals overdue_referrals = Referral.query.filter( - Referral.referral_status.in_(['new', 'in_review']), + Referral.referral_status.in_([ReferralStatus.DIETITIAN_UNASSIGNED, ReferralStatus.WAITING_FOR_REPORT]), Referral.referralRequestedDateTime < (datetime.now() - timedelta(days=2)) ).count() @@ -169,15 +169,19 @@ def dashboard_stats(): # Get counts for patients, referrals, and procedures # Get active patients and those admitted in the last 7 days - active_patients = Patient.query.filter_by(status='active').count() + active_patients = Patient.query.filter(Patient.status != PatientStatus.COMPLETED).count() # Get count of patients admitted in the last 7 days seven_days_ago = datetime.utcnow() - timedelta(days=7) recent_patients = Patient.query.filter(Patient.created_at >= seven_days_ago).count() # Get pending and completed referrals - pending_referrals = Referral.query.filter_by(status='pending').count() - completed_referrals = Referral.query.filter_by(status='completed').count() + pending_referrals = Referral.query.filter(Referral.referral_status.in_([ + ReferralStatus.DIETITIAN_UNASSIGNED, + ReferralStatus.WAITING_FOR_REPORT + ])).count() + + completed_referrals = Referral.query.filter_by(referral_status=ReferralStatus.COMPLETED).count() # Total procedures performed total_procedures = Procedure.query.count() @@ -204,8 +208,11 @@ def dashboard_stats(): seven_days_ago = datetime.utcnow() - timedelta(days=7) overdue_referrals = Referral.query.filter( and_( - Referral.date_created <= seven_days_ago, - Referral.status == 'pending' + Referral.created_at <= seven_days_ago, + Referral.referral_status.in_([ + ReferralStatus.DIETITIAN_UNASSIGNED, + ReferralStatus.WAITING_FOR_REPORT + ]) ) ).count() @@ -237,9 +244,10 @@ def dashboard_stats(): }) # Get 5 most recent referrals for the timeline - recent_referrals = Referral.query.join(Patient, Referral.patientID == Patient.id) \ + recent_referrals = Referral.query.join(Encounter, Referral.encounter_id == Encounter.encounterID) \ + .join(Patient, Encounter.patient_id == Patient.id) \ .add_columns(Patient.firstName, Patient.lastName, Patient.id) \ - .order_by(Referral.date_created.desc()) \ + .order_by(Referral.created_at.desc()) \ .limit(5) \ .all() @@ -249,9 +257,9 @@ def dashboard_stats(): 'type': 'referral', 'patient_name': f"{first_name} {last_name}", 'patient_id': patient_id, - 'time': ref.date_created.strftime('%Y-%m-%d %H:%M'), - 'status': ref.status, - 'message': f"{ref.referral_type} referral {ref.status}" + 'time': ref.created_at.strftime('%Y-%m-%d %H:%M'), + 'status': ref.referral_status.value if ref.referral_status else "Unknown", + 'message': f"Nutrition referral {ref.referral_status.value if ref.referral_status else 'Unknown'}" }) return jsonify({ diff --git a/app/routes/dietitian.py b/app/routes/dietitian.py index 0b5912dc3d9fd19f10361d941a149abb79d53885..a38bb6a781d44fde3e5c3350b74acc76ddaf2ff6 100644 --- a/app/routes/dietitian.py +++ b/app/routes/dietitian.py @@ -12,6 +12,7 @@ from app.models.procedure import Procedure # Import Procedure model from app.forms.procedure import ProcedureForm # Import ProcedureForm from sqlalchemy import func, case, desc from datetime import datetime, timedelta +from flask_wtf import FlaskForm # Đảm bảo import ở đầu file dietitian_bp = Blueprint('dietitian', __name__, url_prefix='/dietitian') @@ -25,6 +26,10 @@ def dietitian_required(f): return f(*args, **kwargs) return decorated_function +# Thêm class EmptyForm nếu chưa có +class EmptyForm(FlaskForm): + pass + @dietitian_bp.route('/dashboard') @dietitian_required # Sử dụng decorator mới def dashboard(): @@ -136,65 +141,179 @@ def list_procedures(): """Hiển thị danh sách các procedures, có thể lọc theo bệnh nhân.""" page = request.args.get('page', 1, type=int) per_page = 15 # Số lượng procedure mỗi trang - patient_filter_id = request.args.get('patient_id', type=int) + patient_filter_id = request.args.get('patient_id') # Giữ lại để lọc procedure + # --- Query chính lấy procedures --- query = Procedure.query.join(Patient, Procedure.patient_id == Patient.id)\ .join(Encounter, Procedure.encounter_id == Encounter.encounterID)\ .options(db.joinedload(Procedure.patient), db.joinedload(Procedure.encounter)) - # Lọc theo bệnh nhân nếu có ID + # Nếu người dùng là Dietitian, chỉ hiển thị procedures của bệnh nhân họ quản lý + if not current_user.is_admin: + query = query.filter(Patient.assigned_dietitian_user_id == current_user.userID) + + # Lọc theo bệnh nhân được chọn từ dropdown if patient_filter_id: query = query.filter(Procedure.patient_id == patient_filter_id) - # Chỉ hiển thị các procedure liên quan đến bệnh nhân được gán cho dietitian này? - # Cân nhắc: Có thể dietitian cần xem procedure của BN khác? Hiện tại cho xem hết. - # query = query.filter(Patient.assigned_dietitian_user_id == current_user.userID) - query = query.order_by(Procedure.procedureDateTime.desc()) pagination = query.paginate(page=page, per_page=per_page, error_out=False) procedures = pagination.items - # Lấy danh sách bệnh nhân cho dropdown filter - patients_for_filter = Patient.query.order_by(Patient.lastName, Patient.firstName).all() + # --- Lấy danh sách bệnh nhân cho dropdown filter --- + patients_query = Patient.query + # Nếu là Dietitian, chỉ lấy bệnh nhân được gán + if not current_user.is_admin: + patients_query = patients_query.filter(Patient.assigned_dietitian_user_id == current_user.userID) + + patients_for_filter = patients_query.order_by(Patient.lastName, Patient.firstName).all() + + # Tạo instance của EmptyForm + empty_form = EmptyForm() return render_template('dietitian_procedures.html', procedures=procedures, pagination=pagination, patients_for_filter=patients_for_filter, - selected_patient_id=patient_filter_id) + selected_patient_id=patient_filter_id, + empty_form=empty_form) # Truyền empty_form vào context @dietitian_bp.route('/procedure/new/for_patient/<string:patient_id>', methods=['GET', 'POST']) @dietitian_required def new_procedure(patient_id): """Hiển thị form và xử lý tạo Procedure mới cho bệnh nhân cụ thể.""" patient = Patient.query.get_or_404(patient_id) - form = ProcedureForm(patient_id=patient.id) # Truyền patient_id vào form + + # Lấy encounter_id từ URL nếu có (để chọn sẵn và khóa) + preselected_encounter_id = request.args.get('encounter_id', type=int) + redirect_to_report_id = request.args.get('redirect_to_report', type=int) + + # Kiểm tra xem có encounter nào đang ON_GOING không + on_going_encounters = Encounter.query.filter( + Encounter.patient_id == patient.id, + Encounter.status == EncounterStatus.ON_GOING + ).order_by(desc(Encounter.start_time)).all() + + # Nếu không có encounter_id được truyền và không có encounter ON_GOING nào + if not preselected_encounter_id and not on_going_encounters: + flash("Encounter is either not started or completed, please check again!", 'warning') + # Chuyển hướng về trang danh sách procedure của bệnh nhân đó + return redirect(url_for('.list_procedures', patient_id=patient.id)) + + # Khởi tạo form, truyền patient_id và encounter_id (nếu có) + # Truyền encounter_id vào để form biết cần khóa và chọn sẵn + form = ProcedureForm(patient_id=patient.id, preselected_encounter_id=preselected_encounter_id) + + # Nếu không có preselected_encounter_id, chỉ cho phép chọn từ các encounter ON_GOING + if not preselected_encounter_id: + form.encounter_id.choices = [ + (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") + for enc in on_going_encounters + ] + form.encounter_id.choices.insert(0, ('', '-- Select Encounter --')) + # Nếu có preselected_encounter_id, đảm bảo nó nằm trong danh sách choices (dù bị disable) + else: + # Lấy tất cả encounters để đảm bảo preselected_id có trong list + all_encounters = Encounter.query.filter_by(patient_id=patient.id).order_by(desc(Encounter.start_time)).all() + form.encounter_id.choices = [ + (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") + for enc in all_encounters + ] + # Không cần insert lựa chọn rỗng vì nó sẽ bị disable và chọn sẵn if form.validate_on_submit(): try: new_proc = Procedure( patient_id=patient.id, - encounter_id=form.encounter_id.data, + encounter_id=form.encounter_id.data, # Lấy từ form (sẽ là giá trị đã chọn sẵn nếu bị disable) procedureType=form.procedureType.data, procedureName=form.procedureName.data, procedureDateTime=form.procedureDateTime.data, procedureEndDateTime=form.procedureEndDateTime.data, description=form.description.data, procedureResults=form.procedureResults.data - # created_at, updated_at tự động ) db.session.add(new_proc) + db.session.flush() # Lấy ID của procedure mới + new_procedure_id = new_proc.id db.session.commit() flash(f'Procedure "{new_proc.procedureType}" created successfully for patient {patient.full_name}.', 'success') - # Redirect về danh sách procedure, lọc theo bệnh nhân vừa tạo - return redirect(url_for('.list_procedures', patient_id=patient.id)) + + # Kiểm tra xem có cần redirect về trang report không + if redirect_to_report_id: + return redirect(url_for('report.edit_report', report_id=redirect_to_report_id, new_procedure_id=new_procedure_id)) + else: + # Redirect về danh sách procedure, lọc theo bệnh nhân vừa tạo + return redirect(url_for('.list_procedures', patient_id=patient.id)) except Exception as e: db.session.rollback() flash(f'Error creating procedure: {str(e)}', 'danger') - current_app.logger.error(f"Error creating procedure for patient {patient.id}: {str(e)}", exc_info=True) - # Nếu là GET hoặc POST không hợp lệ, render form - return render_template('procedure_form.html', form=form, patient=patient, edit_mode=False) + # Render form nếu là GET request hoặc validation thất bại + return render_template('procedure_form.html', form=form, patient=patient, edit_mode=False, preselected_encounter_id=preselected_encounter_id) + +@dietitian_bp.route('/procedure/<int:procedure_id>/edit', methods=['GET', 'POST']) +@dietitian_required +def edit_procedure(procedure_id): + """Hiển thị form và xử lý cập nhật Procedure.""" + proc = Procedure.query.options(db.joinedload(Procedure.patient)).get_or_404(procedure_id) + patient = proc.patient + + # Permission check (ví dụ: chỉ dietitian được gán cho patient mới được sửa) + if not current_user.is_admin and patient.assigned_dietitian_user_id != current_user.userID: + flash('You do not have permission to edit this procedure.', 'danger') + return redirect(url_for('.list_procedures', patient_id=patient.id)) + + # Khởi tạo form với dữ liệu hiện có (obj=proc) + # Truyền patient_id để load encounter choices, preselect encounter hiện tại + form = ProcedureForm(obj=proc, patient_id=patient.id, preselected_encounter_id=proc.encounter_id) + + if form.validate_on_submit(): + try: + proc.procedureType = form.procedureType.data + proc.procedureName = form.procedureName.data + proc.procedureDateTime = form.procedureDateTime.data + proc.procedureEndDateTime = form.procedureEndDateTime.data + proc.description = form.description.data + proc.procedureResults = form.procedureResults.data + # Không cho sửa encounter_id khi edit? + # proc.encounter_id = form.encounter_id.data + proc.updated_at = datetime.utcnow() # Giả sử có cột updated_at + + db.session.commit() + flash(f'Procedure "{proc.procedureType}" updated successfully.', 'success') + # Redirect về danh sách procedure, lọc theo bệnh nhân + return redirect(url_for('.list_procedures', patient_id=patient.id)) + except Exception as e: + db.session.rollback() + flash(f'Error updating procedure: {str(e)}', 'danger') + + # Hiển thị form với dữ liệu hiện tại nếu là GET hoặc validation lỗi + return render_template('procedure_form.html', form=form, patient=patient, edit_mode=True, procedure=proc, preselected_encounter_id=proc.encounter_id) + +@dietitian_bp.route('/procedure/<int:procedure_id>/delete', methods=['POST']) +@dietitian_required +def delete_procedure(procedure_id): + """Xử lý yêu cầu xóa Procedure.""" + proc = Procedure.query.options(db.joinedload(Procedure.patient)).get_or_404(procedure_id) + patient_id_redirect = proc.patient_id # Lưu lại patient_id để redirect + + # Permission check + if not current_user.is_admin and proc.patient.assigned_dietitian_user_id != current_user.userID: + flash('You do not have permission to delete this procedure.', 'danger') + return redirect(url_for('.list_procedures', patient_id=patient_id_redirect)) + + try: + procedure_info = f'{proc.procedureType} (ID: {proc.id})' + db.session.delete(proc) + db.session.commit() + flash(f'Procedure {procedure_info} deleted successfully.', 'success') + except Exception as e: + db.session.rollback() + flash(f'Error deleting procedure: {str(e)}', 'danger') + + # Redirect về danh sách procedure của bệnh nhân đó + return redirect(url_for('.list_procedures', patient_id=patient_id_redirect)) -# (TODO later: Routes for viewing, editing, deleting procedures) \ No newline at end of file +# (TODO later: Routes for viewing procedures if needed separately) \ No newline at end of file diff --git a/app/routes/dietitians.py b/app/routes/dietitians.py index b77346d148595313972560079faa7be1545b0821..1ed3f0814b7338c87814cd01e1e25faca5a55eb6 100644 --- a/app/routes/dietitians.py +++ b/app/routes/dietitians.py @@ -85,7 +85,13 @@ def index(): def show(id): """Hiển thị chi tiết chuyên gia dinh dưỡng""" dietitian = Dietitian.query.get_or_404(id) - return render_template('dietitians/show.html', dietitian=dietitian) + + # Lấy danh sách bệnh nhân được gán cho dietitian này + patients = [] + if dietitian.user_id: + patients = Patient.query.filter_by(assigned_dietitian_user_id=dietitian.user_id).all() + + return render_template('dietitians/show.html', dietitian=dietitian, patients=patients) @dietitians_bp.route('/new', methods=['GET', 'POST']) @login_required diff --git a/app/routes/patients.py b/app/routes/patients.py index f10747678b844695cd7fb597a3cd39c1b4fa10dc..07796b4bb8f59100e901588a4e7bc840f600e71d 100644 --- a/app/routes/patients.py +++ b/app/routes/patients.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, abort, send_file +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, abort, send_file, send_from_directory from flask_login import login_required, current_user from flask_wtf import FlaskForm from app import db @@ -43,14 +43,13 @@ def _run_ml_prediction(encounter): encounter: Đối tượng Encounter. Returns: - tuple: (needs_intervention (bool), message (str)) - Trả về (None, error_message) nếu có lỗi. + bool: True nếu cần can thiệp, False nếu không cần, None nếu có lỗi """ loaded_model = None loaded_imputer = None loaded_scaler = None loaded_feature_columns = None - model_load_error = None + try: MODEL_DIR = os.path.join(current_app.root_path, '..', 'model', 'train_model') MODEL_PATH = os.path.join(MODEL_DIR, 'referral_model.pkl') @@ -66,13 +65,13 @@ def _run_ml_prediction(encounter): current_app.logger.info(f"ML components loaded successfully for prediction on encounter {encounter.encounterID}") except FileNotFoundError as e: - model_load_error = f"Lỗi tải file model: {e}." - current_app.logger.error(model_load_error) - return None, model_load_error + " Dự đoán ML bị vô hiệu hóa." + error_msg = f"Lỗi tải file model: {e}." + current_app.logger.error(error_msg) + return None except Exception as e: - model_load_error = f"Lỗi không xác định khi tải model: {e}." - current_app.logger.error(model_load_error, exc_info=True) - return None, model_load_error + " Dự đoán ML bị vô hiệu hóa." + error_msg = f"Lỗi không xác định khi tải model: {e}." + current_app.logger.error(error_msg, exc_info=True) + return None # Nếu model tải thành công, tiếp tục dự đoán try: @@ -84,7 +83,8 @@ def _run_ml_prediction(encounter): ).first() if not latest_measurement: - return None, "Không có dữ liệu đo lường nào để chạy dự đoán." + current_app.logger.warning(f"No measurements found for encounter {encounter.encounterID}") + return None # Chuyển thành DataFrame (chỉ một dòng) df_predict = pd.DataFrame([latest_measurement.to_dict()]) @@ -103,25 +103,15 @@ def _run_ml_prediction(encounter): prediction = loaded_model.predict(X_scaled)[0] # Lấy phần tử đầu tiên needs_intervention = bool(prediction == 1) - # Cập nhật encounter - if hasattr(encounter, 'needs_intervention'): - encounter.needs_intervention = needs_intervention - # Không commit ở đây, commit sẽ được thực hiện ở route gọi hàm này - # db.session.commit() - message = f"Model dự đoán: {'Cần' if needs_intervention else 'Chưa cần'} can thiệp dinh dưỡng." - current_app.logger.info(f"Encounter {encounter.encounterID} needs_intervention set to: {needs_intervention}") - return needs_intervention, message - else: - error_msg = "Lỗi: Cột 'needs_intervention' chưa có trong model Encounter." - current_app.logger.error(error_msg) - return None, error_msg + # Log kết quả dự đoán + current_app.logger.info(f"ML prediction for encounter {encounter.encounterID}: needs_intervention={needs_intervention}") + + return needs_intervention except Exception as e: error_msg = f"Lỗi trong quá trình dự đoán ML cho encounter {encounter.encounterID}: {e}" current_app.logger.error(error_msg, exc_info=True) - # Không nên rollback ở đây vì có thể ảnh hưởng đến commit của route - # db.session.rollback() - return None, error_msg + return None # --- KẾT THÚC HÀM HELPER --- def get_encounter_status_display(status: EncounterStatus): @@ -210,13 +200,31 @@ def index(): @patients_bp.route('/<string:patient_id>') @login_required def patient_detail(patient_id): - patient = Patient.query.get_or_404(patient_id) - - # Lấy thông tin đo lường sinh lý cuối cùng (của tất cả encounter) - latest_measurement = PhysiologicalMeasurement.query.filter_by(patient_id=patient_id).order_by( - PhysiologicalMeasurement.measurementDateTime.desc() + patient = Patient.query.options(joinedload(Patient.assigned_dietitian)).get_or_404(patient_id) + # Lấy phép đo mới nhất (có thể không cần thiết ở đây nữa nếu logic status dựa vào encounter) + latest_measurement = PhysiologicalMeasurement.query.filter_by( + patient_id=patient_id + ).order_by( + desc(PhysiologicalMeasurement.measurementDateTime) ).first() - + + # --- Quan trọng: Cập nhật trạng thái bệnh nhân trước khi lấy dữ liệu khác --- + update_patient_status_from_encounters(patient) + # Cần commit thay đổi trạng thái nếu hàm update có thể thay đổi DB + # Tuy nhiên, nếu hàm chỉ tính toán và đặt giá trị trên object patient + # thì không cần commit ở đây, commit sẽ diễn ra khi có hành động khác thay đổi DB. + # Để an toàn, có thể commit nhẹ: + try: + db.session.commit() + current_app.logger.info(f"Committed potential status update for patient {patient_id} before rendering detail page.") # Thêm log + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error committing updated patient status for {patient_id} in detail view: {e}", exc_info=True) + # Có thể flash lỗi hoặc bỏ qua tùy theo mức độ nghiêm trọng + + # Log trạng thái trước khi render + current_app.logger.info(f"[PatientDetail] Rendering for Patient {patient_id}. Status: {patient.status.value if patient.status else 'None'}, Assigned Dietitian ID: {patient.assigned_dietitian_user_id}") + # Lấy referrals (sử dụng status mới), procedures, reports # Cần cập nhật cách lấy/hiển thị referrals theo trạng thái mới referrals = Referral.query.filter_by(patient_id=patient_id).options( @@ -228,7 +236,8 @@ def patient_detail(patient_id): desc(Procedure.procedureDateTime) ).all() reports = Report.query.filter_by(patient_id=patient_id).options( - joinedload(Report.author) # Tải sẵn thông tin người tạo + joinedload(Report.author), # Tải sẵn thông tin người tạo + joinedload(Report.encounter) # Thêm joinedload cho encounter ).order_by( desc(Report.report_date) ).all() @@ -276,6 +285,9 @@ def patient_detail(patient_id): # Sắp xếp danh sách dietitian theo số lượng bệnh nhân tăng dần dietitians.sort(key=lambda dt: dt.assigned_patients_count) + # Tạo instance của EmptyForm để sử dụng trong template + empty_form = EmptyForm() + return render_template( 'patient_detail.html', patient=patient, @@ -288,7 +300,9 @@ def patient_detail(patient_id): EmptyForm=EmptyForm, dietitians=dietitians, PatientStatus=PatientStatus, # Truyền Enum PatientStatus - ReferralStatus=ReferralStatus # Truyền Enum ReferralStatus + ReferralStatus=ReferralStatus, # Truyền Enum ReferralStatus + EncounterStatus=EncounterStatus, # Add EncounterStatus to context + empty_form=empty_form ) @patients_bp.route('/new', methods=['GET', 'POST']) @@ -1013,106 +1027,282 @@ def upload_encounter_measurements_csv(patient_id, encounter_id): @patients_bp.route('/<string:patient_id>/encounters/new', methods=['POST']) @login_required def new_encounter(patient_id): - """Creates a new encounter for the patient.""" + """Creates a new encounter for the patient, handling interruptions.""" patient = Patient.query.get_or_404(patient_id) - # form = EmptyForm() # Không cần form ở đây nữa + + # Kiểm tra xem có encounter đang ON_GOING không + ongoing_encounter = Encounter.query.filter_by( + patient_id=patient.id, + status=EncounterStatus.ON_GOING + ).first() + + # Kiểm tra xem có encounter đang NOT_STARTED không + latest_encounter = Encounter.query.filter_by( + patient_id=patient.id + ).order_by(Encounter.start_time.desc()).first() + + # Không cho phép tạo encounter mới nếu encounter gần nhất đang NOT_STARTED + if latest_encounter and latest_encounter.status == EncounterStatus.NOT_STARTED: + flash(f"Cannot create a new encounter while the latest encounter ({latest_encounter.custom_encounter_id or latest_encounter.encounterID}) is still not started.", "warning") + return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + + interrupted_report_details = None # Biến để lưu chi tiết report bị interrupt + + if ongoing_encounter: + current_app.logger.warning(f"Attempt to create new encounter for patient {patient_id} while encounter {ongoing_encounter.custom_encounter_id or ongoing_encounter.encounterID} is ON_GOING.") + if current_user.is_admin: + # Admin đang cố interrupt -> Tiến hành interrupt encounter cũ + try: + ongoing_encounter.status = EncounterStatus.FINISHED + ongoing_encounter.end_time = datetime.utcnow() + interrupted_report_details = "Interrupted by admin." + current_app.logger.info(f"Admin {current_user.userID} interrupted encounter {ongoing_encounter.custom_encounter_id or ongoing_encounter.encounterID}. Setting status to FINISHED.") + + # Tìm report tương ứng để cập nhật + report_to_interrupt = Report.query.filter_by(encounter_id=ongoing_encounter.encounterID).first() + if report_to_interrupt: + report_to_interrupt.status = 'Completed' # Đánh dấu report là hoàn thành + report_to_interrupt.completed_date = datetime.utcnow() + # Ghi chú vào assessment_details + report_to_interrupt.assessment_details += "\n\n" + interrupted_report_details if report_to_interrupt.assessment_details else interrupted_report_details + current_app.logger.info(f"Report {report_to_interrupt.id} for interrupted encounter marked as Completed.") + else: + current_app.logger.warning(f"Could not find report associated with interrupted encounter {ongoing_encounter.encounterID} to mark as completed.") + + # --- Thêm: Tìm và cập nhật Referral liên quan --- + referral_to_complete = Referral.query.filter_by(encounter_id=ongoing_encounter.encounterID).first() + if referral_to_complete: + referral_to_complete.referral_status = ReferralStatus.COMPLETED + referral_to_complete.referralCompletedDateTime = datetime.utcnow() + current_app.logger.info(f"Referral {referral_to_complete.id} for interrupted encounter marked as COMPLETED.") + else: + current_app.logger.warning(f"Could not find referral associated with interrupted encounter {ongoing_encounter.encounterID} to mark as completed.") + # ----------------------------------------------- + + # Commit thay đổi trạng thái encounter/report/referral cũ NGAY LẬP TỨC + db.session.commit() + flash(f"Interrupted previous encounter ({ongoing_encounter.custom_encounter_id or ongoing_encounter.encounterID}). Marked as Finished.", "warning") + except Exception as interrupt_err: + db.session.rollback() + current_app.logger.error(f"Error interrupting encounter {ongoing_encounter.encounterID}: {interrupt_err}", exc_info=True) + flash(f"Error interrupting previous encounter: {interrupt_err}", "danger") + return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + else: + # Dietitian đang cố tạo mới -> Ngăn chặn (JS đã xử lý redirect) + # Vẫn nên có check ở backend để an toàn + flash("Please complete the report for the current encounter before creating a new one.", "warning") + # Tìm report ID để redirect (nếu có) + report_id_to_edit = db.session.query(Report.id).filter_by(encounter_id=ongoing_encounter.encounterID).scalar() + if report_id_to_edit: + return redirect(url_for('report.edit_report', report_id=report_id_to_edit)) + else: + return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + # --- Tạo encounter mới (nếu không có lỗi hoặc sau khi interrupt) --- try: # 1. Đếm số encounter hiện có của bệnh nhân này để xác định số thứ tự tiếp theo existing_encounter_count = Encounter.query.filter_by(patient_id=patient.id).count() next_sequence_num = existing_encounter_count + 1 # 2. Tạo custom_encounter_id - # Lấy 5 số cuối từ patient.id (giả sử format là P-xxxxx) patient_id_part = patient.id[-5:] if patient.id and patient.id.startswith('P-') and len(patient.id) >= 7 else patient.id - custom_id = f"E-{patient_id_part}-{next_sequence_num:02d}" # Format với 2 chữ số + custom_id = f"E-{patient_id_part}-{next_sequence_num:02d}" - # Kiểm tra nếu ID này đã tồn tại (trường hợp hiếm) while Encounter.query.filter_by(custom_encounter_id=custom_id).first(): current_app.logger.warning(f"Generated custom encounter ID {custom_id} already exists. Incrementing sequence.") next_sequence_num += 1 custom_id = f"E-{patient_id_part}-{next_sequence_num:02d}" # 3. Create a new encounter instance - encounter = Encounter( + new_encounter_obj = Encounter( patient_id=patient.id, start_time=datetime.utcnow(), - status=EncounterStatus.NOT_STARTED, # Thay đổi từ ACTIVE (không tồn tại) thành NOT_STARTED - custom_encounter_id=custom_id # Gán ID tùy chỉnh + status=EncounterStatus.NOT_STARTED, # Bắt đầu là NOT_STARTED + custom_encounter_id=custom_id ) - db.session.add(encounter) + db.session.add(new_encounter_obj) + # Commit để lấy ID cho encounter mới + db.session.flush() + new_encounter_id = new_encounter_obj.encounterID + new_custom_id = new_encounter_obj.custom_encounter_id + current_app.logger.info(f"New encounter created: {new_custom_id} (PK: {new_encounter_id}) for patient {patient_id}") + + # 4. Cập nhật trạng thái patient dựa trên encounter mới nhất và các referral + update_patient_status_from_encounters(patient) + + # Commit encounter mới và thay đổi trạng thái patient db.session.commit() - # flash(f'New encounter (ID: {encounter.encounterID}) created successfully for {patient.full_name}.', 'success') # Xóa dòng flash này + flash(f'New encounter {new_custom_id} created successfully.', 'success') # Redirect to the new encounter's measurement page - return redirect(url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=encounter.encounterID)) - # Option 2: Redirect back to patient detail, encounters tab - # return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + return redirect(url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=new_encounter_id)) 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) + current_app.logger.error(f"Error creating new encounter for patient {patient.id} after check/interrupt: {str(e)}", exc_info=True) flash(f'Error creating new encounter: {str(e)}', 'error') return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) +def update_patient_status_from_encounters(patient): + # ... (docstring và khởi tạo biến như cũ) ... + old_status_val = patient.status.value if patient.status else "None" + old_dietitian_id = patient.assigned_dietitian_user_id # Lưu lại dietitian cũ để log + new_status = None + current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Starting update. Current status: {old_status_val}, Assigned Dietitian: {old_dietitian_id}") + + # 1. Lấy encounter mới nhất + latest_encounter = Encounter.query.filter_by( + patient_id=patient.id + ).order_by(Encounter.start_time.desc()).first() + + # 2. Kiểm tra referral đang chờ (DIETITIAN_UNASSIGNED) cho encounter mới nhất (nếu có) + pending_referral_for_latest = None + if latest_encounter: + pending_referral_for_latest = Referral.query.filter_by( + encounter_id=latest_encounter.encounterID, + referral_status=ReferralStatus.DIETITIAN_UNASSIGNED + ).first() + + # 3. Logic xác định trạng thái mới + if latest_encounter: + # a. Encounter FINISHED -> Patient COMPLETED + if latest_encounter.status == EncounterStatus.FINISHED: + new_status = PatientStatus.COMPLETED + current_app.logger.debug(f"Patient {patient.id}: Setting status to COMPLETED (latest encounter {latest_encounter.encounterID} FINISHED).") + # b. Encounter ON_GOING + elif latest_encounter.status == EncounterStatus.ON_GOING: + # *SỬA*: Nếu có referral đang chờ gán -> NEEDS_ASSESSMENT + if pending_referral_for_latest: + new_status = PatientStatus.NEEDS_ASSESSMENT + current_app.logger.debug(f"Patient {patient.id}: Setting status to NEEDS_ASSESSMENT (latest encounter {latest_encounter.encounterID} ON_GOING with pending referral {pending_referral_for_latest.id}).") + # Nếu không có referral chờ -> ASSESSMENT_IN_PROGRESS (giả định đã có dietitian gán từ trước đó hoặc luồng khác) + else: + new_status = PatientStatus.ASSESSMENT_IN_PROGRESS + current_app.logger.debug(f"Patient {patient.id}: Setting status to ASSESSMENT_IN_PROGRESS (latest encounter {latest_encounter.encounterID} ON_GOING, no pending referral found).") + # c. Encounter NOT_STARTED -> Patient NOT_ASSESSED + elif latest_encounter.status == EncounterStatus.NOT_STARTED: + new_status = PatientStatus.NOT_ASSESSED + current_app.logger.debug(f"Patient {patient.id}: Setting status to NOT_ASSESSED (latest encounter {latest_encounter.encounterID} NOT_STARTED).") + else: # Trạng thái encounter không xác định + new_status = PatientStatus.NOT_ASSESSED # Mặc định an toàn + current_app.logger.warning(f"Patient {patient.id}: Latest encounter {latest_encounter.encounterID} has unexpected status {latest_encounter.status}. Setting patient status to NOT_ASSESSED.") + # 4. Không có encounter nào + else: + # Kiểm tra có referral nào đang chờ gán không (không cần liên kết với encounter cụ thể nữa) + any_pending_referral = Referral.query.filter_by( + patient_id=patient.id, + referral_status=ReferralStatus.DIETITIAN_UNASSIGNED + ).first() + if any_pending_referral: + new_status = PatientStatus.NEEDS_ASSESSMENT + current_app.logger.debug(f"Patient {patient.id}: Setting status to NEEDS_ASSESSMENT (no encounters, but found pending referral {any_pending_referral.id}).") + else: + new_status = PatientStatus.NOT_ASSESSED + current_app.logger.debug(f"Patient {patient.id}: Setting status to NOT_ASSESSED (no encounters, no pending referrals).") + + # 5. Cập nhật trạng thái bệnh nhân và dietitian nếu cần + status_changed = False + dietitian_removed = False + + if new_status and (not patient.status or patient.status != new_status): + patient.status = new_status + status_changed = True + current_app.logger.info(f"[UpdateStatus] Patient {patient.id} status WILL BE updated to {patient.status.value}") + elif not new_status: + current_app.logger.error(f"[UpdateStatus] Patient {patient.id}: Could not determine new status.") + + # *** Quan trọng: Reset Dietitian nếu trạng thái KHÔNG phải là ASSESSMENT_IN_PROGRESS hoặc COMPLETED *** + # Giữ lại dietitian khi bệnh nhân đang được đánh giá hoặc đã hoàn thành + if new_status not in [PatientStatus.ASSESSMENT_IN_PROGRESS, PatientStatus.COMPLETED] and patient.assigned_dietitian_user_id is not None: + patient.assigned_dietitian_user_id = None + patient.assignment_date = None # Reset cả ngày gán + dietitian_removed = True + current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Dietitian assignment WILL BE removed because new status is {new_status.value if new_status else 'None'}.") + else: + # Ghi log nếu dietitian được giữ lại + if patient.assigned_dietitian_user_id is not None and (new_status == PatientStatus.ASSESSMENT_IN_PROGRESS or new_status == PatientStatus.COMPLETED): + current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Dietitian assignment ({patient.assigned_dietitian_user_id}) RETAINED for status {new_status.value}") + + if not status_changed and not dietitian_removed: + current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: No changes to status ({old_status_val}) or dietitian assignment ({old_dietitian_id}).") + + current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Finished update logic. Final status decided: {new_status.value if new_status else 'None'}. Dietitian ID decided: {patient.assigned_dietitian_user_id}") + return patient.status # Trả về trạng thái mới (object patient đã được thay đổi trực tiếp) + # Thêm route xóa encounter @patients_bp.route('/<string:patient_id>/encounters/<int:encounter_pk>/delete', methods=['POST']) @login_required def delete_encounter(patient_id, encounter_pk): - """Deletes an encounter and its associated measurements.""" - # Dùng encounter_pk (khóa chính integer) để tìm encounter + """Deletes an encounter and its associated measurements/report/referral, updating patient status and dietitian assignment if necessary.""" encounter = Encounter.query.get_or_404(encounter_pk) patient = Patient.query.get_or_404(patient_id) - # Kiểm tra xem encounter có thuộc về bệnh nhân không if encounter.patient_id != patient.id: flash('Invalid encounter for this patient.', 'error') return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) - # Xác thực CSRF token - form = EmptyForm() # Sử dụng form rỗng để validate CSRF - if not form.validate_on_submit(): # validate_on_submit sẽ tự kiểm tra CSRF + form = EmptyForm() + if not form.validate_on_submit(): flash('Invalid CSRF token. Please try again.', 'error') return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) try: - # Lấy custom ID để hiển thị thông báo (nếu có) custom_id_for_flash = encounter.custom_encounter_id if encounter.custom_encounter_id else f"(PK: {encounter.encounterID})" + + # --- Hủy liên kết Dietitian nếu cần --- + # Lấy danh sách referrals của encounter này từ bảng Referral + encounter_referrals = Referral.query.filter_by(encounter_id=encounter.encounterID).all() + + # Kiểm tra xem việc gán dietitian hiện tại có phải là do referral của encounter này không + referral_linked_to_assignment = None + if patient.assigned_dietitian_user_id and encounter_referrals: + # Giả định chỉ có một referral / encounter + # Và việc gán dietitian được lưu trong patient.assigned_dietitian_user_id + # Kiểm tra xem dietitian được gán cho bệnh nhân có phải là dietitian của referral này không + encounter_referral = encounter_referrals[0] # Lấy referral đầu tiên + if encounter_referral.assigned_dietitian_user_id == patient.assigned_dietitian_user_id: + # Nếu dietitian gán cho patient trùng với dietitian của referral sắp bị xóa + referral_linked_to_assignment = encounter_referral # Lưu lại để biết cần hủy gán + + # --- Xóa các đối tượng liên quan --- + # Tìm và xóa Report liên quan + report_to_delete = Report.query.filter_by(encounter_id=encounter.encounterID).first() + if report_to_delete: + db.session.delete(report_to_delete) + current_app.logger.info(f"Deleted report {report_to_delete.id} associated with encounter {encounter_pk}.") + + # Tìm và xóa Referral liên quan (nếu có) + # Sử dụng encounter_referrals đã query ở trên + if encounter_referrals: + for ref in encounter_referrals: + db.session.delete(ref) + current_app.logger.info(f"Deleted referral {ref.id} associated with encounter {encounter_pk}.") # Xóa encounter (và measurements do cascade) db.session.delete(encounter) + current_app.logger.info(f"Deleted encounter {encounter_pk}.") + + # --- Cập nhật Patient --- + # Hủy gán dietitian NẾU referral liên quan bị xóa (Thực hiện TRƯỚC khi cập nhật status) + if referral_linked_to_assignment: + patient.assigned_dietitian_user_id = None + patient.assessment_date = None # Reset ngày đánh giá + current_app.logger.info(f"Unassigned dietitian from patient {patient.id} because associated referral {referral_linked_to_assignment.id} was deleted.") + # Flash message này nên hiển thị sau khi commit thành công + # flash('Dietitian assignment removed as the related encounter/referral was deleted.', 'warning') + + # Cập nhật trạng thái của bệnh nhân dựa trên encounter còn lại và referrals + new_status = update_patient_status_from_encounters(patient) - # Sau khi xóa encounter, cập nhật lại trạng thái bệnh nhân - # Kiểm tra có còn encounter nào cần assessment không - needs_assessment_encounter = Encounter.query.filter_by( - patient_id=patient.id, - needs_intervention=True - ).first() - - # Kiểm tra có referral nào đang chờ xử lý không - pending_referral = Referral.query.filter_by( - patient_id=patient.id, - referral_status=ReferralStatus.DIETITIAN_UNASSIGNED - ).first() - - if needs_assessment_encounter or pending_referral: - # Vẫn còn encounter cần assessment hoặc referral đang chờ - if patient.status != PatientStatus.NEEDS_ASSESSMENT: - patient.status = PatientStatus.NEEDS_ASSESSMENT - flash('Patient status updated to NEEDS_ASSESSMENT based on remaining encounters/referrals.', 'info') - else: - # Kiểm tra nếu có dietitian được gán - if patient.assigned_dietitian_user_id: - if patient.status != PatientStatus.ASSESSMENT_IN_PROGRESS: - patient.status = PatientStatus.ASSESSMENT_IN_PROGRESS - flash('Patient status updated to ASSESSMENT_IN_PROGRESS as dietitian is assigned.', 'info') - else: - # Không còn encounter nào cần assessment và không có dietitian - if patient.status != PatientStatus.NOT_ASSESSED: - patient.status = PatientStatus.NOT_ASSESSED - flash('Patient status updated to NOT_ASSESSED as no assessment needed.', 'info') - + # Commit tất cả thay đổi vào DB db.session.commit() + + # Flash messages sau khi commit thành công flash(f'Encounter {custom_id_for_flash} deleted successfully.', 'success') + if referral_linked_to_assignment: # Flash message hủy gán dietitian ở đây + flash('Dietitian assignment removed as the related encounter/referral was deleted.', 'warning') + if hasattr(patient, 'status') and patient.status == new_status: # Kiểm tra xem status đã được gán chưa + flash(f'Patient status is now {new_status.value}.', 'info') + except Exception as e: db.session.rollback() current_app.logger.error(f"Error deleting encounter {encounter_pk} for patient {patient.id}: {str(e)}", exc_info=True) @@ -1120,7 +1310,6 @@ def delete_encounter(patient_id, encounter_pk): return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) -# --- Route mới để chạy ML Prediction --- @patients_bp.route('/<string:patient_id>/encounter/<int:encounter_id>/run_ml', methods=['POST']) @login_required def run_encounter_ml(patient_id, encounter_id): @@ -1134,95 +1323,76 @@ def run_encounter_ml(patient_id, encounter_id): return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) try: - # Gọi hàm helper để chạy dự đoán - ml_needs_intervention, ml_message = _run_ml_prediction(encounter) - - if ml_needs_intervention is not None: - # Cập nhật trạng thái ML của encounter - encounter.needs_intervention = ml_needs_intervention - flash(f"Kết quả dự đoán ML: {ml_message}", 'info') # Thông báo kết quả ML - - if ml_needs_intervention: - # --- Xử lý khi ML dự đoán CẦN can thiệp --- - patient.status = PatientStatus.NEEDS_ASSESSMENT - encounter.status = EncounterStatus.NOT_STARTED - flash(f"Trạng thái bệnh nhân cập nhật: {PatientStatus.NEEDS_ASSESSMENT.value}", 'warning') - flash(f"Trạng thái lượt khám cập nhật: {EncounterStatus.NOT_STARTED.value}", 'warning') - - # Tìm hoặc tạo Referral mới - existing_referral = Referral.query.filter( - Referral.encounter_id == encounter.encounterID - # Referral.referral_status != ReferralStatus.COMPLETED # Chỉ tìm referral chưa hoàn thành? + # *** THÊM: Kiểm tra xem encounter có measurement chưa *** + measurement_count = PhysiologicalMeasurement.query.filter_by(encounter_id=encounter.encounterID).count() + if measurement_count == 0: + current_app.logger.warning(f"ML prediction skipped for encounter {encounter_id}: No measurements found.") + flash("No measurements found for this encounter. Please upload data before running the ML prediction.", "warning") + return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) + # *** KẾT THÚC KIỂM TRA *** + + # Cập nhật trạng thái encounter thành ON_GOING khi chạy ML + if encounter.status == EncounterStatus.NOT_STARTED: + encounter.status = EncounterStatus.ON_GOING + current_app.logger.info(f"Encounter {encounter_id} status updated to ON_GOING after running ML") + + # Chạy ML prediction và cập nhật encounter + needs_intervention_result = _run_ml_prediction(encounter) + + # Chỉ xử lý nếu kết quả ML không phải None (không có lỗi) + if needs_intervention_result is not None: + # Cập nhật encounter.needs_intervention + encounter.needs_intervention = needs_intervention_result + + if needs_intervention_result: + # Nếu cần can thiệp, tạo referral nếu chưa có + existing_referral = Referral.query.filter_by( + patient_id=patient.id, + encounter_id=encounter.encounterID ).first() - + if not existing_referral: - try: - if not current_user.is_authenticated: - raise ValueError("User not authenticated, cannot create referral.") - - new_referral = Referral( - patient_id=patient.id, - encounter_id=encounter.encounterID, - referralRequestedDateTime=datetime.utcnow(), - notes='Automated referral based on ML prediction.', - referral_status=ReferralStatus.DIETITIAN_UNASSIGNED, - is_ml_recommended=True, - ) - db.session.add(new_referral) - # Không commit ở đây, commit chung ở cuối - flash("Đã tự động tạo yêu cầu đánh giá mới (Referral).", 'success') - current_app.logger.info(f"Created new ML-based referral (DIETITIAN_UNASSIGNED) for encounter {encounter_id}") - except Exception as ref_e: - db.session.rollback() # Rollback nếu tạo referral lỗi - error_message = f"Lỗi khi tự động tạo Referral cho encounter {encounter_id}: {str(ref_e)}" - current_app.logger.error(error_message, exc_info=True) - flash(f"Lỗi khi tạo Referral: {str(ref_e)} ({type(ref_e).__name__})", 'danger') # Thêm type lỗi - # Vẫn tiếp tục commit trạng thái patient/encounter nếu không tạo được referral? - # Hoặc return redirect ở đây để tránh commit trạng thái sai? - # Quyết định: return redirect để tránh trạng thái không nhất quán - return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) - elif existing_referral.referral_status != ReferralStatus.DIETITIAN_UNASSIGNED: - # Nếu referral đã tồn tại nhưng không phải trạng thái chờ gán -> cập nhật - existing_referral.referral_status = ReferralStatus.DIETITIAN_UNASSIGNED - existing_referral.is_ml_recommended = True # Đánh dấu lại là ML rec - existing_referral.notes = f"(ML Re-evaluation) {existing_referral.notes or ''}".strip() - flash("Đã cập nhật trạng thái yêu cầu đánh giá hiện có thành 'Chờ gán chuyên gia'.", 'warning') - current_app.logger.info(f"Updated existing referral for encounter {encounter_id} to DIETITIAN_UNASSIGNED due to ML re-evaluation.") + new_referral = Referral( + patient_id=patient.id, + encounter_id=encounter.encounterID, + # Sử dụng notes thay vì referral_reason + notes="Algorithm detected need for assessment based on physiological measurements.", + referralRequestedDateTime=datetime.utcnow(), + referral_status=ReferralStatus.DIETITIAN_UNASSIGNED, + is_ml_recommended=True # Đánh dấu là do ML tạo + ) + db.session.add(new_referral) + current_app.logger.info(f"Created new ML-based referral for patient {patient_id}, encounter {encounter_id}") + flash("ML prediction indicates intervention needed. A new referral has been created.", "info") else: - # Referral đã tồn tại và đang ở trạng thái chờ gán - flash("Yêu cầu đánh giá (Referral) đang chờ gán chuyên gia.", 'info') - + flash("ML prediction indicates intervention needed. An existing referral is already in place.", "info") else: - # --- Xử lý khi ML dự đoán KHÔNG CẦN can thiệp --- - patient.status = PatientStatus.COMPLETED - encounter.status = EncounterStatus.FINISHED - flash(f"Trạng thái bệnh nhân cập nhật: {PatientStatus.COMPLETED.value}", 'info') - flash(f"Trạng thái lượt khám cập nhật: {EncounterStatus.FINISHED.value}", 'info') - - # Tùy chọn: Cập nhật/xóa Referral liên quan nếu có? - # Ví dụ: Tìm referral đang chờ và đánh dấu là không cần nữa - # existing_referral = Referral.query.filter(...).first() - # if existing_referral: - # existing_referral.referral_status = None # Hoặc một trạng thái mới 'NOT_REQUIRED' - # flash("Đã cập nhật yêu cầu đánh giá thành không cần thiết.", 'info') - - # Commit tất cả thay đổi vào DB + # Nếu không cần can thiệp + flash("ML prediction indicates no intervention needed at this time.", "success") + # *** THÊM LOGIC CẬP NHẬT TRẠNG THÁI ENCOUNTER *** + if encounter.status == EncounterStatus.ON_GOING: + encounter.status = EncounterStatus.FINISHED + encounter.end_time = datetime.utcnow() # Ghi lại thời gian kết thúc + current_app.logger.info(f"Encounter {encounter_id} status updated to FINISHED as ML indicated no intervention needed.") + # *** KẾT THÚC THÊM LOGIC *** + + # Quan trọng: Cập nhật trạng thái bệnh nhân SAU KHI xử lý ML và Referral + update_patient_status_from_encounters(patient) + db.session.commit() - else: - # Nếu _run_ml_prediction trả về lỗi - db.session.rollback() # Rollback nếu có lỗi trong quá trình dự đoán - flash(f"Lỗi khi chạy dự đoán ML: {ml_message}", 'danger') + # Có lỗi xảy ra trong quá trình chạy ML (_run_ml_prediction trả về None) + flash("An error occurred during ML prediction. Please check logs.", "danger") + # Không commit và redirect về trang encounter + return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) + except Exception as e: db.session.rollback() - current_app.logger.error(f"Lỗi không xác định khi chạy ML cho encounter {encounter_id}: {e}", exc_info=True) - flash(f"Đã xảy ra lỗi không mong muốn khi chạy ML: {str(e)}", 'danger') - - return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) -# --- Kết thúc Route mới --- + current_app.logger.error(f"Error running ML for encounter {encounter_id}: {str(e)}", exc_info=True) + flash(f"Error running ML prediction: {str(e)}", "danger") + return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) -# Route mới để xóa bệnh nhân @patients_bp.route('/<string:patient_id>/delete', methods=['POST']) @login_required def delete_patient(patient_id): @@ -1261,7 +1431,6 @@ def delete_patient(patient_id): flash('Invalid request. Could not delete patient.', 'danger') return redirect(url_for('patients.index')) -# Route mới để gán Dietitian @patients_bp.route('/<string:patient_id>/assign_dietitian', methods=['POST']) @login_required def assign_dietitian(patient_id): @@ -1311,30 +1480,36 @@ def assign_dietitian(patient_id): if patient.assigned_dietitian_user_id != dietitian.userID: try: - # Tìm encounter gần nhất đang ở trạng thái NOT_STARTED - encounter_to_update = Encounter.query.filter_by( - patient_id=patient.id, - status=EncounterStatus.NOT_STARTED - ).order_by(desc(Encounter.start_time)).first() - - if not encounter_to_update: - flash(f'Không tìm thấy lượt khám (encounter) phù hợp ở trạng thái "{EncounterStatus.NOT_STARTED.value}".', 'error') - return redirect(url_for('patients.patient_detail', patient_id=patient_id)) - - # Tìm referral gần nhất đang ở trạng thái DIETITIAN_UNASSIGNED (nên thuộc encounter trên) + # *** SỬA LOGIC TÌM ENCOUNTER/REFERRAL *** + # 1. Tìm referral mới nhất đang chờ gán cho bệnh nhân này referral_to_update = Referral.query.filter_by( patient_id=patient.id, - encounter_id=encounter_to_update.encounterID, # Chỉ tìm referral của encounter này referral_status=ReferralStatus.DIETITIAN_UNASSIGNED ).order_by(desc(Referral.referralRequestedDateTime)).first() if not referral_to_update: - # Nếu không tìm thấy referral chờ gán cho encounter này, có thể tạo mới? - # Hoặc báo lỗi vì quy trình không đúng? - # Hiện tại: Báo lỗi để đảm bảo quy trình ML -> Referral -> Assign - flash(f'Không tìm thấy yêu cầu đánh giá (referral) đang chờ gán cho lượt khám {encounter_to_update.custom_encounter_id or encounter_to_update.encounterID}.', 'error') + flash(f'Không tìm thấy yêu cầu đánh giá (referral) nào đang chờ được gán cho bệnh nhân này.', 'error') + return redirect(url_for('patients.patient_detail', patient_id=patient_id)) + + # 2. Lấy encounter liên kết với referral đó + if not referral_to_update.encounter_id: + flash(f'Referral đang chờ (ID: {referral_to_update.id}) không liên kết với lượt khám nào.', 'error') return redirect(url_for('patients.patient_detail', patient_id=patient_id)) + encounter_to_update = Encounter.query.get(referral_to_update.encounter_id) + + if not encounter_to_update: + flash(f'Không tìm thấy lượt khám (encounter) liên kết với referral ID {referral_to_update.id}.', 'error') + return redirect(url_for('patients.patient_detail', patient_id=patient_id)) + + # Optional: Kiểm tra trạng thái encounter có hợp lệ không (ví dụ: không phải FINISHED) + if encounter_to_update.status == EncounterStatus.FINISHED: + flash(f'Lượt khám (ID: {encounter_to_update.custom_encounter_id or encounter_to_update.encounterID}) liên kết với referral đã hoàn thành.', 'warning') + # Có thể vẫn cho phép gán nhưng logic sau đó cần xem xét? + # Tạm thời cho phép tiếp tục, nhưng log lại + current_app.logger.warning(f"Assigning dietitian to patient {patient_id} although associated encounter {encounter_to_update.encounterID} is FINISHED.") + # *** KẾT THÚC SỬA LOGIC TÌM KIẾM *** + # --- Bắt đầu cập nhật --- old_dietitian_id = patient.assigned_dietitian_user_id diff --git a/app/routes/report.py b/app/routes/report.py index 30c692e39dbb03500ddd60c110977a6ad9829bd6..69d0fc6ba92cbe6874520bc8e287453a156b6fd7 100644 --- a/app/routes/report.py +++ b/app/routes/report.py @@ -50,10 +50,11 @@ def index(): # Lọc theo dietitian hiện tại nếu user là Dietitian if current_user.role == 'Dietitian': - query = query.filter(Report.author_id == current_user.userID) + # Lọc theo dietitian được gán cho BỆNH NHÂN của report + query = query.filter(Patient.assigned_dietitian_user_id == current_user.userID) # Lọc theo dietitian được chọn nếu user là Admin và có filter elif current_user.is_admin and dietitian_filter_id: - query = query.filter(Report.author_id == dietitian_filter_id) + query = query.filter(Report.author_id == dietitian_filter_id) # Filter theo author nếu là Admin # Apply other filters if report_type_filter: @@ -79,10 +80,12 @@ def index(): # --- Dữ liệu cho Stats và Charts --- # Tạo query cơ sở cho stats, áp dụng các filter tương tự query chính - base_stats_query = Report.query + base_stats_query = Report.query.join(Patient, Report.patient_id == Patient.id) # Join với Patient để filter dietitian if current_user.role == 'Dietitian': - base_stats_query = base_stats_query.filter(Report.author_id == current_user.userID) + # Lọc theo dietitian được gán cho BỆNH NHÂN + base_stats_query = base_stats_query.filter(Patient.assigned_dietitian_user_id == current_user.userID) elif current_user.is_admin and dietitian_filter_id: + # Admin filter theo author (như query chính) base_stats_query = base_stats_query.filter(Report.author_id == dietitian_filter_id) if report_type_filter: @@ -115,26 +118,33 @@ def index(): # Dữ liệu biểu đồ đóng góp của Dietitian (chỉ cho Admin) dietitian_contribution_chart_data = None if current_user.is_admin: - # Query này cần áp dụng filter type/status, nhưng KHÔNG filter dietitian + # Query này cần áp dụng filter type/status, nhưng KHÔNG filter dietitian ID cụ thể admin_base_stats_query = Report.query if report_type_filter: admin_base_stats_query = admin_base_stats_query.filter(Report.report_type == report_type_filter) if status_filter: admin_base_stats_query = admin_base_stats_query.filter(Report.status == status_filter) - dietitian_contributions = admin_base_stats_query \ - .join(Author, Report.author_id == Author.userID) \ - .filter(Author.role == 'Dietitian') \ - .with_entities(Author.userID, Author.lastName, Author.firstName, func.count(Report.id)) \ - .group_by(Author.userID, Author.lastName, Author.firstName) \ - .order_by(func.count(Report.id).desc()) \ + # Sửa lại query: join và group theo AssignedDietitian (report.dietitian_id) + # Viết lại query không dùng \ để tránh lỗi linter + dietitian_contributions = (admin_base_stats_query + .join(AssignedDietitian, Report.dietitian_id == AssignedDietitian.userID) + .filter(Report.dietitian_id.isnot(None)) # Chỉ tính các report đã được gán dietitian + .filter(AssignedDietitian.role == 'Dietitian') # Đảm bảo user được join là dietitian + .with_entities(AssignedDietitian.userID, AssignedDietitian.lastName, AssignedDietitian.firstName, func.count(Report.id)) + .group_by(AssignedDietitian.userID, AssignedDietitian.lastName, AssignedDietitian.firstName) + .order_by(func.count(Report.id).desc()) .all() + ) dietitian_contribution_chart_data = { 'labels': [f"{d.lastName}, {d.firstName}" for d in dietitian_contributions], 'data': [d[3] for d in dietitian_contributions] } + # Pass delete_form vào context + delete_form = EmptyForm() + return render_template( 'report.html', reports=reports, @@ -146,7 +156,8 @@ def index(): selected_dietitian_id=dietitian_filter_id, # Cho Admin stats=stats, report_type_chart_data=report_type_chart_data, - dietitian_contribution_chart_data=dietitian_contribution_chart_data # Cho Admin + dietitian_contribution_chart_data=dietitian_contribution_chart_data, # Cho Admin + delete_form=delete_form # Thêm delete_form ) @report_bp.route('/new', methods=['GET', 'POST']) @@ -168,6 +179,8 @@ def new_report(): # Cập nhật lại procedure choices nếu patient đã được chọn sẵn form.__init__(patient_id=prefill_patient_id) # Gọi lại init để load procedure + first_error_field_id = None # Initialize for template context + if form.validate_on_submit(): print("--- Report form validation PASSED ---") # Lấy patient_id từ form đã validate @@ -208,6 +221,14 @@ def new_report(): related_procedure_id=form.related_procedure_id.data or None # Lưu procedure ID ) + # Force status to Draft if "Save Draft" button was clicked + action = request.form.get('action') + if action == 'save_draft': + report.status = 'Draft' + print("--- Action: Save Draft detected. Forcing status to Draft. ---") + else: + print(f"--- Action: {action}. Using status from form: {report.status} ---") + if form.referral_id.data: report.referral_id = form.referral_id.data @@ -229,7 +250,11 @@ def new_report(): form.patient_id.choices.insert(0, ('', '-- Select Patient --')) # Truyền lại patient_id ban đầu khi render lại sau lỗi DB current_patient_id = patient_id_from_form or prefill_patient_id - return render_template('report_form.html', form=form, report=None, edit_mode=False, prefill_patient_id=current_patient_id) + # Find first error for scrolling + if form.errors: + first_error_field = next(iter(form.errors)) + first_error_field_id = form[first_error_field].id if first_error_field in form else None + return render_template('report_form.html', form=form, report=None, edit_mode=False, prefill_patient_id=current_patient_id, first_error_field_id=first_error_field_id) # Xử lý khi validation thất bại (POST) hoặc là GET request else: @@ -238,7 +263,11 @@ def new_report(): if request.method == 'POST': # Validation failed on POST print("--- Report form validation FAILED ---") print(f"Validation Errors: {form.errors}") - flash('Form validation failed. Please check the highlighted fields.', 'error') + # Find the ID of the first field with an error + if form.errors: + first_error_field = next(iter(form.errors)) + first_error_field_id = form[first_error_field].id if first_error_field in form else None # Get ID from form field object + print(f"--- First error field ID for scroll: {first_error_field_id} ---") # Khi POST lỗi, lấy ID từ form đã submit (nếu user có chọn) # Hoặc thử lấy từ prefill_patient_id ban đầu (cần lấy lại từ đâu đó, ví dụ hidden field) # Cách đơn giản nhất là lấy từ form.patient_id.data vì nó chứa giá trị user đã chọn (dù có thể rỗng) @@ -252,7 +281,8 @@ def new_report(): # Render form tạo mới (hoặc render lại nếu validation thất bại) print(f"--- Rendering report form template (Prefill ID: {form.patient_id.data}) ---") - return render_template('report_form.html', form=form, report=None, edit_mode=False) + # Pass the ID of the first error field to the template + return render_template('report_form.html', form=form, report=None, edit_mode=False, first_error_field_id=first_error_field_id) @report_bp.route('/<int:report_id>') @login_required @@ -266,7 +296,8 @@ def view_report(report_id): @report_bp.route('/<int:report_id>/edit', methods=['GET', 'POST']) @login_required def edit_report(report_id): - report = Report.query.options(joinedload(Report.patient), joinedload(Report.procedures)).get_or_404(report_id) + # Sửa lại joinedload thành procedure (số ít) + report = Report.query.options(joinedload(Report.patient), joinedload(Report.procedure)).get_or_404(report_id) patient = report.patient # Load patient từ report # --- Start Permission Check --- @@ -287,6 +318,7 @@ def edit_report(report_id): # --- End Permission Check --- # Truyền patient_id vào form để load procedure choices + # Khởi tạo form với obj=report để điền dữ liệu hiện có form = ReportForm(patient_id=report.patient_id, obj=report) # Populate patient choices (dù bị disable, cần để hiển thị đúng tên) @@ -294,31 +326,114 @@ def edit_report(report_id): form.patient_id.choices.insert(0, ('', '-- Select Patient --')) # Đặt lại giá trị đã chọn cho patient_id (form init với obj có thể đã làm) form.patient_id.data = report.patient_id - # Procedure choices đã được populate trong form.__init__ - # Đặt lại giá trị procedure đã chọn nếu có - form.related_procedure_id.data = report.related_procedure_id + # Đặt lại giá trị procedure đã chọn nếu có (obj=report trong form init đã làm) + form.related_procedure_id.data = report.related_procedure_id # Lấy danh sách referrals (nếu cần) # form.referral_id.choices = ... # form.referral_id.data = report.referral_id + first_error_field_id = None # Initialize for template context + + # Debugging: Check report data BEFORE form initialization + print(f"--- [Edit Report GET/POST Start] Report ID: {report.id}, Status: {report.status}, Data before form init: {report.intervention_summary[:50] if report.intervention_summary else 'None'}... ---") + if form.validate_on_submit(): - # Cập nhật report từ form data - form.populate_obj(report) # Tự động cập nhật các trường khớp tên - # Đảm bảo related_procedure_id được cập nhật - report.related_procedure_id = form.related_procedure_id.data or None + print(f"--- Report form validation PASSED for report ID: {report_id} ---") - db.session.commit() + # Debugging: Check form data BEFORE populate_obj + print(f"--- [Edit Report Validate OK] Form data before populate: {form.intervention_summary.data[:50] if form.intervention_summary.data else 'None'}... Status: {form.status.data} ---") - # TODO: Xử lý file attachments mới và xóa file cũ nếu cần + # Cập nhật report từ form data bằng populate_obj + form.populate_obj(report) # Update report object with form data + + # Explicitly set referral_id to None if form data is empty string + if report.referral_id == '': + report.referral_id = None + print("--- Corrected empty string referral_id to None ---") + + # Debugging: Check report data AFTER populate_obj (before status override) + print(f"--- [Edit Report Validate OK] Report data after populate: Status: {report.status}, Referral ID: {report.referral_id} ---") + + # Force status to Draft if "Save Draft" button was clicked *AFTER* populate_obj + action = request.form.get('action') + # **** ADDED DEBUG LOG **** + print(f"--- [Edit Report Validate OK] Value of request.form.get('action'): {action} ---") + # **** END DEBUG LOG **** + if action == 'save_draft': + report.status = 'Draft' + print(f"--- Action: Save Draft detected. OVERRIDING status to Draft. ---") + elif action == 'complete': # Xử lý action complete ở đây + # Lưu trước báo cáo + report.updated_at = datetime.utcnow() + try: + db.session.commit() + print(f"--- Action: Complete detected. Saved report and redirecting to complete_report route... ---") + # Chuyển hướng đến complete_report route với báo cáo đã lưu + return redirect(url_for('report.complete_report', report_id=report.id)) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Database error updating report before complete {report_id}: {e}", exc_info=True) + flash(f'Lỗi khi lưu báo cáo trước khi hoàn thành: {e}', 'error') + # Reload choices và render lại form khi gặp lỗi DB + form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] + form.patient_id.choices.insert(0, ('', '-- Select Patient --')) + if form.errors: + first_error_field = next(iter(form.errors)) + first_error_field_id = form[first_error_field].id if first_error_field in form else None + return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=first_error_field_id) + else: + print(f"--- Action: {action}. Using status from populate_obj: {report.status} ---") - flash('Report updated successfully.', 'success') - return redirect(url_for('report.view_report', report_id=report.id)) + report.updated_at = datetime.utcnow() - # Render form chỉnh sửa - # TODO: Lấy danh sách attachments nếu có - attachments = [] # Tạm thời - return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=attachments) + try: + # Debugging: Log before commit + print(f"--- Committing report update. ID: {report.id}, Final Status: {report.status}, Summary: {report.intervention_summary[:50] if report.intervention_summary else 'None'}... ---") + db.session.commit() + # TODO: Xử lý file attachments mới và xóa file cũ nếu cần + flash('Report updated successfully.', 'success') + # Redirect to view report after successful save/update + return redirect(url_for('report.view_report', report_id=report.id)) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Database error updating report {report_id}: {e}", exc_info=True) + flash(f'Database error occurred while updating report: {e}', 'error') + # Find first error for scrolling on DB error + if form.errors: # Check form errors again? Might not be relevant here. + first_error_field = next(iter(form.errors)) + first_error_field_id = form[first_error_field].id if first_error_field in form else None + # Reload choices before rendering again after DB error + form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] + form.patient_id.choices.insert(0, ('', '-- Select Patient --')) + patient_procedures = Procedure.query.filter_by(patient_id=report.patient_id).order_by(Procedure.procedureDateTime.desc()).all() + form.related_procedure_id.choices = [('', '-- Select Procedure (Optional) --')] + \ + [(proc.id, f"#{proc.id} - {proc.procedureType} ({proc.procedureDateTime.strftime('%d/%m/%y %H:%M') if proc.procedureDateTime else 'N/A'})") + for proc in patient_procedures] + return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=first_error_field_id) # Pass attachments + + elif request.method == 'POST': # Validation failed on POST + current_app.logger.warning(f"Report form validation failed. Errors: {form.errors}") + # Find the ID of the first field with an error + if form.errors: + first_error_field = next(iter(form.errors)) # Get the name + first_error_field_id = form[first_error_field].id if first_error_field in form else None # Get ID from form field object + print(f"--- First error field ID for scroll: {first_error_field_id} ---") + + # ... (existing code to reload choices) ... + + # Render form chỉnh sửa (GET request or POST validation failed) + attachments = [] # TODO: Lấy danh sách attachments nếu có + # Pass the ID of the first error field to the template + return render_template( + 'report_form.html', + form=form, + report=report, + edit_mode=True, + attachments=attachments, + first_error_field_id=first_error_field_id, + EncounterStatus=EncounterStatus + ) @report_bp.route('/<int:report_id>/finalize', methods=['POST']) @login_required @@ -724,62 +839,59 @@ def create_stats_report(report_type): ) # Route mới để hoàn thành Report và cập nhật trạng thái liên quan -@report_bp.route('/<int:report_id>/complete', methods=['POST']) +@report_bp.route('/<int:report_id>/complete', methods=['POST', 'GET']) @login_required def complete_report(report_id): """Hoàn thành report, cập nhật trạng thái Patient, Encounter, Referral.""" - form = EmptyForm() # Sử dụng form rỗng để validate CSRF report = Report.query.get_or_404(report_id) - # Xác thực CSRF - if not form.validate_on_submit(): - flash('Invalid CSRF token. Please try again.', 'danger') - return redirect(url_for('report.view_report', report_id=report_id)) - # Kiểm tra quyền: Chỉ dietitian được gán cho report hoặc admin - # Giả định report.dietitian_id lưu ID của dietitian chịu trách nhiệm - if not current_user.is_admin and current_user.userID != report.dietitian_id: + if not current_user.is_admin and ( + current_user.userID != report.author_id and + (report.patient and current_user.userID != report.patient.assigned_dietitian_user_id)): flash('Bạn không có quyền hoàn thành báo cáo này.', 'error') return redirect(url_for('report.view_report', report_id=report_id)) - # Kiểm tra trạng thái report có phải là 'Pending' hoặc trạng thái phù hợp khác không? - if report.status != 'Pending': # Giả sử trạng thái chờ hoàn thành là 'Pending' - flash(f'Báo cáo này không ở trạng thái "Pending" để hoàn thành (hiện tại: {report.status}).', 'warning') + # Kiểm tra trạng thái report có phải là 'Pending' hoặc trạng thái phù hợp không? + if report.status not in ['Draft', 'Pending']: # Cho phép hoàn thành từ Draft hoặc Pending + flash(f'Báo cáo này không ở trạng thái "Draft" hoặc "Pending" để hoàn thành (hiện tại: {report.status}).', 'warning') return redirect(url_for('report.view_report', report_id=report_id)) try: # Tìm các đối tượng liên quan patient = Patient.query.get(report.patient_id) - encounter = Encounter.query.get(report.encounter_id) + encounter = Encounter.query.get(report.encounter_id) if report.encounter_id else None # Tìm referral tương ứng (dựa trên encounter_id) - referral = Referral.query.filter_by(encounter_id=report.encounter_id).first() + referral = Referral.query.filter_by(encounter_id=report.encounter_id).first() if report.encounter_id else None - if not patient or not encounter: - flash('Không tìm thấy Bệnh nhân hoặc Lượt khám liên quan.', 'error') + if not patient: + flash('Không tìm thấy Bệnh nhân liên quan.', 'error') return redirect(url_for('report.view_report', report_id=report_id)) # --- Bắt đầu cập nhật trạng thái --- # 1. Cập nhật Report report.status = 'Completed' # Hoặc 'Finalized' tùy theo Enum của bạn - report.completed_date = datetime.utcnow() # Thêm cột completed_date vào Report model? + report.completed_date = datetime.utcnow() # Thêm cột completed_date vào Report model # 2. Cập nhật Patient patient.status = PatientStatus.COMPLETED - # 3. Cập nhật Encounter - encounter.status = EncounterStatus.FINISHED - encounter.end_time = datetime.utcnow() # Đặt thời gian kết thúc encounter + # 3. Cập nhật Encounter (nếu có) + if encounter: + encounter.status = EncounterStatus.FINISHED + encounter.end_time = datetime.utcnow() # Đặt thời gian kết thúc encounter # 4. Cập nhật Referral (nếu tìm thấy) if referral: referral.referral_status = ReferralStatus.COMPLETED referral.referralCompletedDateTime = datetime.utcnow() else: - current_app.logger.warning(f"Không tìm thấy Referral tương ứng cho encounter {report.encounter_id} khi hoàn thành report {report.id}") + if report.encounter_id: + current_app.logger.warning(f"Không tìm thấy Referral tương ứng cho encounter {report.encounter_id} khi hoàn thành report {report.id}") # 5. Commit thay đổi db.session.commit() - flash('Báo cáo đã hoàn thành. Trạng thái Bệnh nhân, Lượt khám và Yêu cầu đánh giá đã được cập nhật.', 'success') + flash('Báo cáo đã được hoàn thành thành công. Trạng thái Bệnh nhân, Lượt khám và Yêu cầu đánh giá đã được cập nhật.', 'success') current_app.logger.info(f"Report {report.id} completed by User {current_user.userID}. Associated Patient/Encounter/Referral statuses updated.") # Redirect về trang chi tiết bệnh nhân diff --git a/app/routes/support.py b/app/routes/support.py new file mode 100644 index 0000000000000000000000000000000000000000..31f8d9174aaeb6445e15d8d2879184dd55b86561 --- /dev/null +++ b/app/routes/support.py @@ -0,0 +1,93 @@ +from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app +from flask_login import login_required, current_user +from .. import db +from ..models import SupportMessage, SupportMessageReadStatus, User +from ..forms import SupportMessageForm # Sẽ tạo form này sau +from sqlalchemy import desc, or_, func # Import func +from datetime import datetime + +support_bp = Blueprint('support', __name__, url_prefix='/support') + +@support_bp.route('/', methods=['GET', 'POST']) +@login_required +def index(): + """Hiển thị trang support và xử lý gửi tin nhắn.""" + if current_user.role not in ['Admin', 'Dietitian']: + flash('Bạn không có quyền truy cập trang này.', 'danger') + return redirect(url_for('main.handle_root')) + + # Sẽ tạo SupportMessageForm trong app/forms/__init__.py hoặc file riêng + # Tạm thời giả định nó đã tồn tại và có trường 'content' + form = SupportMessageForm() + + if form.validate_on_submit(): + try: + new_message = SupportMessage( + sender_id=current_user.userID, + content=form.content.data, + timestamp=datetime.utcnow() # Đảm bảo timestamp được set + ) + db.session.add(new_message) + db.session.commit() + flash('Tin nhắn đã được gửi.', 'success') + return redirect(url_for('.index')) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Lỗi khi gửi tin nhắn support: {e}") + flash('Đã xảy ra lỗi khi gửi tin nhắn.', 'danger') + + # Lấy tất cả tin nhắn, sắp xếp mới nhất lên đầu + messages = SupportMessage.query.options( + db.joinedload(SupportMessage.sender) # Tải sẵn thông tin người gửi + ).order_by(desc(SupportMessage.timestamp)).all() + + # Đánh dấu các tin nhắn là đã đọc cho user hiện tại + unread_message_ids = db.session.query(SupportMessage.id).\ + outerjoin(SupportMessageReadStatus, + (SupportMessage.id == SupportMessageReadStatus.message_id) & + (SupportMessageReadStatus.user_id == current_user.userID)).\ + filter(SupportMessageReadStatus.id == None).\ + filter(SupportMessage.sender_id != current_user.userID).\ + all() + + unread_message_ids = [m[0] for m in unread_message_ids] + + if unread_message_ids: + now = datetime.utcnow() + new_read_statuses = [] + for msg_id in unread_message_ids: + read_status = SupportMessageReadStatus( + user_id=current_user.userID, + message_id=msg_id, + read_at=now + ) + new_read_statuses.append(read_status) + + if new_read_statuses: + try: + db.session.add_all(new_read_statuses) + db.session.commit() + current_app.logger.info(f"User {current_user.userID} marked {len(new_read_statuses)} support messages as read.") + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Lỗi khi đánh dấu tin nhắn support đã đọc cho user {current_user.userID}: {e}") + + return render_template('support.html', messages=messages, form=form) + + +@support_bp.route('/unread_count') +@login_required +def unread_count(): + """API endpoint trả về số lượng tin nhắn support chưa đọc.""" + if current_user.role not in ['Admin', 'Dietitian']: + return jsonify(count=0) + + count = db.session.query(func.count(SupportMessage.id)).\ + outerjoin(SupportMessageReadStatus, + (SupportMessage.id == SupportMessageReadStatus.message_id) & + (SupportMessageReadStatus.user_id == current_user.userID)).\ + filter(SupportMessageReadStatus.id == None).\ + filter(SupportMessage.sender_id != current_user.userID).\ + scalar() + + return jsonify(count=count or 0) \ No newline at end of file diff --git a/app/templates/_macros.html b/app/templates/_macros.html index a4a25637e451fbec23b7bb497e039a25db3e113f..33fb5493e74c4e4af19b56f5a3387423b7605b47 100644 --- a/app/templates/_macros.html +++ b/app/templates/_macros.html @@ -1,49 +1,30 @@ -{% macro render_pagination(pagination, endpoint, search=none, status=none, sort=none, direction=none) %} +{% macro render_pagination(pagination, endpoint, filter_args=None) %} <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> - {% if pagination.has_prev %} - <a href="{{ url_for(endpoint, page=pagination.prev_num, search=search, status=status, sort=sort, direction=direction) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> + <a href="{{ url_for(endpoint, page=pagination.prev_num, **(filter_args or {})) if pagination.has_prev else '#' }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 {{ 'opacity-50 cursor-not-allowed' if not pagination.has_prev }}"> <span class="sr-only">Trang trước</span> <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /> </svg> - </a> - {% else %} - <span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed"> - <span class="sr-only">Trang trước</span> - <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> - <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /> - </svg> - </span> - {% endif %} - - {# Tính toán dãy trang để hiển thị #} - {% set start_page = [pagination.page - 2, 1]|max %} - {% set end_page = [start_page + 4, pagination.pages]|min %} - {% set start_page = [end_page - 4, 1]|max %} - - {% for page in range(start_page, end_page + 1) %} - {% if page == pagination.page %} - <span class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 text-sm font-medium text-primary-700">{{ page }}</span> - {% else %} - <a href="{{ url_for(endpoint, page=page, search=search, status=status, sort=sort, direction=direction) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">{{ page }}</a> - {% endif %} + </a> + + {% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %} + {% if page_num %} + <a href="{{ url_for(endpoint, page=page_num, **(filter_args or {})) }}" + class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium + {% if page_num == pagination.page %}bg-primary-100 text-primary-700{% else %}bg-white text-gray-700 hover:bg-gray-50{% endif %}"> + {{ page_num }} + </a> + {% else %} + <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span> + {% endif %} {% endfor %} - - {% if pagination.has_next %} - <a href="{{ url_for(endpoint, page=pagination.next_num, search=search, status=status, sort=sort, direction=direction) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> - <span class="sr-only">Trang sau</span> - <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> - <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> - </svg> - </a> - {% else %} - <span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed"> + + <a href="{{ url_for(endpoint, page=pagination.next_num, **(filter_args or {})) if pagination.has_next else '#' }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 {{ 'opacity-50 cursor-not-allowed' if not pagination.has_next }}"> <span class="sr-only">Trang sau</span> <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> </svg> - </span> - {% endif %} + </a> </nav> {% endmacro %} @@ -129,4 +110,28 @@ </div> {% endif %} </div> +{% endmacro %} + +{% macro flash_messages() %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + <div class="mb-4 space-y-2"> + {% for category, message in messages %} + {% set alert_color = { + 'success': 'green', + 'error': 'red', + 'danger': 'red', + 'warning': 'yellow', + 'info': 'blue' + }.get(category, 'gray') %} + <div class="bg-{{ alert_color }}-100 border-l-4 border-{{ alert_color }}-500 text-{{ alert_color }}-700 p-4 rounded-md shadow-sm flex justify-between items-center fade-in" role="alert"> + <p class="font-medium">{{ message }}</p> + <button type="button" class="text-{{ alert_color }}-500 hover:text-{{ alert_color }}-700 focus:outline-none" onclick="this.parentElement.style.display='none';"> + <i class="fas fa-times"></i> + </button> + </div> + {% endfor %} + </div> + {% endif %} + {% endwith %} {% endmacro %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 97d88e8bebbadf17e3ef613fefdf41e264de1d6f..236e46a6d930432ec10cd1bb08678afe7579c50d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -309,7 +309,7 @@ <span class="nav-text">Dietitians</span> </a> </li> - {# Hide Upload CSV for Dietitians #} + {% if current_user.role != 'Dietitian' %} <li class="nav-item {% if request.endpoint and request.endpoint.startswith('upload.') %}active{% endif %}"> <a href="{{ url_for('upload.index') }}" class="text-white hover:text-primary-light flex items-center w-full"> @@ -318,13 +318,28 @@ </a> </li> {% endif %} - {% if current_user.is_admin %} - <li class="nav-item {% if request.endpoint == 'auth.admin_users' %}active{% endif %}"> - <a href="{{ url_for('auth.admin_users') }}" class="text-white hover:text-primary-light flex items-center w-full"> - <i class="fas fa-users-cog mr-3 text-lg"></i> <span class="nav-text">User Management</span> + {% if current_user.role == 'Admin' %} + {# ... Admin items (Admin Panel, Upload Data đã bị xóa) ... #} + {% endif %} + {# Mục mới: Support (Cho cả Admin và Dietitian) #} + {% if current_user.role in ['Admin', 'Dietitian'] %} + <li class="nav-item {% if request.endpoint == 'support.index' %}active{% endif %}"> + <a href="{{ url_for('support.index') }}" class="relative flex items-center w-full"> + <i class="fas fa-headset"></i> + <span class="nav-text">Support</span> + <span id="support-link-badge" class="absolute top-2 right-2 notification-badge hidden">0</span> </a> </li> {% endif %} + {# Mục Help giữ nguyên -> Comment out vì không có route #} + {# + <li class="nav-item {% if request.endpoint == 'main.help' %}active{% endif %}"> + <a href="{{ url_for('main.help') }}" class="flex items-center w-full"> + <i class="fas fa-question-circle"></i> + <span class="nav-text">Help</span> + </a> + </li> + #} </ul> </nav> </div> @@ -706,9 +721,112 @@ }); </script> + {# Generic Modal Handling Script #} + <script> + document.addEventListener('DOMContentLoaded', function() { + // Handle general confirmation modals (Bootstrap version) + const confirmationForms = document.querySelectorAll('form.needs-confirmation'); + const genericConfirmModalEl = document.getElementById('deleteConfirmationModal'); // Use the ID from the macro + let genericConfirmModalInstance = null; + if (genericConfirmModalEl && typeof bootstrap !== 'undefined') { + genericConfirmModalInstance = new bootstrap.Modal(genericConfirmModalEl); + } else { + console.warn("Generic confirmation modal or Bootstrap JS not found."); + } + + confirmationForms.forEach(form => { + form.addEventListener('submit', function(event) { + event.preventDefault(); + if (!genericConfirmModalInstance) return; + + const message = this.dataset.confirmationMessage || 'Are you sure?'; + const modalBody = genericConfirmModalEl.querySelector('.modal-body'); + const confirmBtn = genericConfirmModalEl.querySelector('#confirmDeleteBtn'); // Ensure this ID matches the button in your macro + + if (modalBody) modalBody.textContent = message; + + // Clone and replace the confirm button to remove old listeners + if (confirmBtn) { + let newConfirmBtn = confirmBtn.cloneNode(true); + confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); + + newConfirmBtn.addEventListener('click', () => { + genericConfirmModalInstance.hide(); + this.submit(); // Submit the original form + }); + } + genericConfirmModalInstance.show(); + }); + }); + + // Handle Tailwind CSS modal close buttons (like delete procedure) + const allTailwindModals = document.querySelectorAll(".fixed.z-50"); // Selector for Tailwind modals + allTailwindModals.forEach(modalEl => { + const closeButtons = modalEl.querySelectorAll('.modal-close-btn'); + const backdrop = modalEl.querySelector('.modal-backdrop'); + + function hideTailwindModal() { + const modalContent = modalEl.querySelector('.modal-content'); + if (backdrop) backdrop.classList.remove('opacity-100'); + if (modalContent) { + modalContent.classList.remove('opacity-100', 'scale-100'); + modalContent.classList.add('scale-95'); + } + setTimeout(() => { + modalEl.classList.add('hidden'); + }, 300); // Match transition duration + } + + closeButtons.forEach(button => { + button.addEventListener('click', hideTailwindModal); + }); + + if (backdrop) { + backdrop.addEventListener('click', hideTailwindModal); + } + }); + }); + </script> + {% block scripts %}{% endblock %} <!-- Thêm Alpine.js --> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> + + <!-- Support Message Unread Count Script --> + <script> + document.addEventListener('DOMContentLoaded', function() { + // --- Support Message Unread Count --- + const supportLinkBadge = document.getElementById('support-link-badge'); + function fetchSupportUnreadCount() { + fetch('{{ url_for("support.unread_count") }}') // API endpoint for unread count + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (supportLinkBadge) { + const count = data.count || 0; + supportLinkBadge.textContent = count; + if (count > 0) { + supportLinkBadge.classList.remove('hidden'); + } else { + supportLinkBadge.classList.add('hidden'); + } + } + }) + .catch(error => console.error('Error fetching support unread count:', error)); + } + + // Fetch support count initially and periodically if the badge exists + if (supportLinkBadge) { + fetchSupportUnreadCount(); + setInterval(fetchSupportUnreadCount, 45000); // Check every 45 seconds + } + // --- End Support Message Unread Count --- + }); + </script> </body> </html> diff --git a/app/templates/dietitian_procedures.html b/app/templates/dietitian_procedures.html index f950ebeba90c89db17be9b00a9ddc3717ded8976..3af96d7f14bfb8049c94a55966fb24d2c91afc7f 100644 --- a/app/templates/dietitian_procedures.html +++ b/app/templates/dietitian_procedures.html @@ -1,141 +1,221 @@ {% extends 'base.html' %} +{% from '_macros.html' import render_pagination, flash_messages %} -{% block title %}Procedures - Dietitian Area{% endblock %} +{% block title %}Procedures - {{ super() }}{% endblock %} {% block content %} -<div class="container mx-auto px-4 py-6 animate-slide-in"> +<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8"> + {{ flash_messages() }} - <h1 class="text-3xl font-bold text-gray-800 mb-6">Nutritional Procedures</h1> + <div class="mb-6 flex justify-between items-center"> + <h1 class="text-3xl font-bold text-gray-800">Manage Procedures</h1> + {# Có thể thêm nút Add Procedure ở đây nếu muốn #} + {# <a href="#" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Add New Procedure</a> #} + </div> - <!-- Filters --> - <div class="mb-6 p-4 bg-white rounded-lg shadow"> - <form method="GET" action="{{ url_for('.list_procedures') }}" class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end"> - <div> - <label for="patient_id" class="block text-sm font-medium text-gray-700">Filter by Patient</label> - <select id="patient_id" name="patient_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"> + <!-- Filter Form --> + <form method="GET" action="{{ url_for('.list_procedures') }}" class="mb-6 bg-white shadow-md rounded px-8 pt-6 pb-8"> + <div class="flex flex-wrap gap-4 items-end"> + <div class="flex-grow"> + <label for="patient_id" class="block text-gray-700 text-sm font-bold mb-2">Filter by Patient:</label> + <select id="patient_id" name="patient_id" class="shadow border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"> <option value="">All Patients</option> - {% for p in patients_for_filter %} - <option value="{{ p.id }}" {% if p.id == selected_patient_id %}selected{% endif %}>{{ p.full_name }} ({{ p.id }})</option> + {% for patient in patients_for_filter %} + <option value="{{ patient.id }}" {% if patient.id == selected_patient_id|string %}selected{% endif %}>{{ patient.full_name }} (ID: {{ patient.id }})</option> {% endfor %} </select> </div> - <div class="md:col-span-1"> - <button type="submit" class="w-full px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> - <i class="fas fa-filter mr-2"></i>Filter - </button> - </div> - {# Nút Add Procedure chỉ hiển thị khi đã chọn bệnh nhân #} - <div class="md:col-span-1 text-right"> - {% if selected_patient_id %} - <a href="{{ url_for('.new_procedure', patient_id=selected_patient_id) }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"> - <i class="fas fa-plus mr-2"></i>Add Procedure - </a> - {% else %} - <button type="button" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed" title="Select a patient to add a procedure"> - <i class="fas fa-plus mr-2"></i>Add Procedure - </button> - {% endif %} + <div class="flex items-end space-x-2"> + <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Filter</button> + <a href="{{ url_for('.list_procedures') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Reset</a> </div> - </form> - </div> + </div> + </form> - <!-- Procedures List Table --> - <div class="bg-white shadow overflow-hidden sm:rounded-lg"> - <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">Date/Time</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Patient</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">Procedure Type</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details/Name</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Related Report</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> - </tr> - </thead> - <tbody class="bg-white divide-y divide-gray-200"> + <!-- Procedures Table --> + <div class="bg-white shadow-md rounded my-6 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">Patient</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">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">Date/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-right text-xs font-medium text-gray-500 uppercase tracking-wider"> + Actions + </th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% if procedures %} {% for proc in procedures %} - <tr class="hover:bg-gray-50"> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureDateTime.strftime('%d/%m/%Y %H:%M') if proc.procedureDateTime else 'N/A' }}</td> - <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> - <a href="{{ url_for('patients.patient_detail', patient_id=proc.patient.id) }}" class="text-blue-600 hover:underline">{{ proc.patient.full_name }}</a> - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> - <a href="{{ url_for('patients.encounter_measurements', patient_id=proc.patient.id, encounter_id=proc.encounter.encounterID) }}" class="text-blue-600 hover:underline"> - {{ proc.encounter.custom_encounter_id or proc.encounter.encounterID }} - </a> - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ proc.procedureType }}</td> - <td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate" title="{{ proc.procedureName or proc.description or '' }}"> - {{ proc.procedureName or proc.description or 'N/A' }} - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> - {% set related_report = proc.related_reports.first() %} - {% if related_report %} - <a href="{{ url_for('report.view_report', report_id=related_report.id) }}" class="text-blue-600 hover:underline" title="View Report #{{ related_report.id }}"> - <i class="fas fa-file-alt mr-1"></i> Report #{{ related_report.id }} - </a> - {% else %} - - - {% endif %} - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> - <a href="#" class="text-indigo-600 hover:text-indigo-900" title="Edit Procedure (Future)"><i class="fas fa-edit"></i></a> - <a href="#" class="text-red-600 hover:text-red-900" title="Delete Procedure (Future)"><i class="fas fa-trash-alt"></i></a> - </td> - </tr> - {% else %} + <tr> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="text-sm font-medium text-gray-900">{{ proc.patient.full_name }}</div> + <div class="text-sm text-gray-500">ID: {{ proc.patient_id }}</div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + {% if proc.encounter %} + <a href="{{ url_for('patients.encounter_measurements', patient_id=proc.patient_id, encounter_id=proc.encounter_id) }}" class="text-blue-600 hover:text-blue-800"> + {{ proc.encounter.custom_encounter_id if proc.encounter.custom_encounter_id else proc.encounter.encounterID }} + </a> + {% else %} + <span class="text-gray-400">N/A</span> + {% endif %} + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ proc.procedureType }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureName or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureDateTime.strftime('%d/%m/%Y %H:%M') if proc.procedureDateTime else 'N/A' }}</td> + <td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate" title="{{ proc.description }}">{{ proc.description|truncate(50, True) if proc.description else 'N/A' }}</td> + <td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate" title="{{ proc.procedureResults }}">{{ proc.procedureResults|truncate(50, True) if proc.procedureResults else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2"> + {# Edit Button #} + <a href="{{ url_for('dietitian.edit_procedure', procedure_id=proc.id) }}" class="text-indigo-600 hover:text-indigo-900" title="Edit Procedure"><i class="fas fa-edit"></i></a> + {# Delete Button (Triggers Modal) #} + <button type="button" class="text-red-600 hover:text-red-900 delete-procedure-btn" title="Delete Procedure" data-proc-id="{{ proc.id }}" data-proc-info="{{ proc.procedureType }} - {{ proc.patient.full_name }} (ID: {{ proc.id }})"> + <i class="fas fa-trash-alt"></i> + </button> + </td> + </tr> + {% endfor %} + {% else %} <tr> - <td colspan="7" class="px-6 py-10 text-center text-gray-500 text-sm"> - No procedures found matching the criteria. - {% if not selected_patient_id %}<br>Select a patient to add a new procedure.{% endif %} - </td> + <td colspan="8" class="px-6 py-4 text-center text-sm text-gray-500">No procedures found matching your criteria.</td> </tr> - {% endfor %} - </tbody> - </table> - </div> + {% endif %} + </tbody> + </table> + </div> + + <!-- Pagination --> + <div class="mt-6"> + {{ render_pagination(pagination, '.list_procedures', filter_args={'patient_id': selected_patient_id}) }} + </div> +</div> - <!-- Pagination --> - {% if pagination and pagination.total > pagination.per_page %} - <div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> - <div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> - <div> - <p class="text-sm text-gray-700"> - Showing <span class="font-medium">{{ (pagination.page - 1) * pagination.per_page + 1 }}</span> - to <span class="font-medium">{{ pagination.page * pagination.per_page if pagination.page * pagination.per_page <= pagination.total else pagination.total }}</span> - of <span class="font-medium">{{ pagination.total }}</span> results - </p> +<!-- Delete Procedure Confirmation Modal --> +<div id="deleteProcedureConfirmModal" + class="fixed inset-0 z-50 hidden flex items-center justify-center transition-opacity duration-300 ease-out" + aria-labelledby="deleteProcedureConfirmModalLabel" + aria-modal="true" + role="dialog"> + <!-- Backdrop --> + <div class="fixed inset-0 bg-gray-900 bg-opacity-60 backdrop-blur-sm modal-backdrop transition-opacity duration-300 ease-out" aria-hidden="true"></div> + + <!-- Modal Content --> + <div class="relative bg-white rounded-lg shadow-xl w-full max-w-md transform transition-all duration-300 ease-out scale-95 opacity-0 modal-content"> + <div class="p-6"> + <div class="flex items-start"> + <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"> + <svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> + </svg> </div> - <div> - <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> - <a href="{{ url_for('.list_procedures', page=pagination.prev_num, patient_id=selected_patient_id) if pagination.has_prev else '#' }}" - class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 {{ 'opacity-50 cursor-not-allowed' if not pagination.has_prev else '' }}"> - <span class="sr-only">Previous</span> - <i class="fas fa-chevron-left h-5 w-5"></i> - </a> - {% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %} - {% if page_num %} - <a href="{{ url_for('.list_procedures', page=page_num, patient_id=selected_patient_id) }}" - class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium - {% if page_num == pagination.page %}bg-blue-50 border-blue-500 text-blue-600 z-10{% else %}bg-white text-gray-500 hover:bg-gray-50{% endif %}"> - {{ page_num }} - </a> - {% else %} - <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span> - {% endif %} - {% endfor %} - <a href="{{ url_for('.list_procedures', page=pagination.next_num, patient_id=selected_patient_id) if pagination.has_next else '#' }}" - class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 {{ 'opacity-50 cursor-not-allowed' if not pagination.has_next else '' }}"> - <span class="sr-only">Next</span> - <i class="fas fa-chevron-right h-5 w-5"></i> - </a> - </nav> + <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-grow"> + <h3 class="text-lg font-semibold leading-6 text-gray-900" id="deleteProcedureConfirmModalLabel"> + Xác nhận Xóa Thủ thuật + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-600"> + Bạn có chắc chắn muốn xóa thủ thuật <strong id="procedure-info-placeholder" class="font-medium">[Procedure Info]</strong>? +Hành động này không thể hoàn tác. + </p> + </div> </div> + <button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center modal-close-btn"> + <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> + <span class="sr-only">Close modal</span> + </button> </div> </div> - {% endif %} + <div class="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg"> + <form id="delete-procedure-form" action="#" method="POST" class="inline-block"> + {{ empty_form.csrf_token }} + <button id="confirm-delete-procedure-btn" type="submit" class="inline-flex w-full justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"> + Xóa + </button> + </form> + <button type="button" class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto modal-close-btn"> + Hủy bỏ + </button> + </div> </div> </div> + +{% endblock %} + +{% block scripts %} +{{ super() }} +<script> +document.addEventListener('DOMContentLoaded', function() { + // *** Logic for Delete Procedure Modal (Tailwind version) *** + const deleteProcedureModalEl = document.getElementById('deleteProcedureConfirmModal'); + const modalBackdrop = deleteProcedureModalEl.querySelector('.modal-backdrop'); + const modalContent = deleteProcedureModalEl.querySelector('.modal-content'); + const deleteProcedureButtons = document.querySelectorAll('.delete-procedure-btn'); + const procedureInfoPlaceholder = document.getElementById('procedure-info-placeholder'); + const confirmDeleteProcedureBtn = document.getElementById('confirm-delete-procedure-btn'); + const deleteProcedureForm = document.getElementById('delete-procedure-form'); + const closeButtons = deleteProcedureModalEl.querySelectorAll('.modal-close-btn'); + let currentProcedureIdToDelete = null; + + function openModal() { + deleteProcedureModalEl.classList.remove('hidden'); + // Trigger transitions + setTimeout(() => { + modalBackdrop.classList.add('opacity-100'); + modalContent.classList.add('opacity-100', 'scale-100'); + modalContent.classList.remove('scale-95'); // Start slightly scaled down + }, 10); // Small delay for transitions + } + + function closeModal() { + modalBackdrop.classList.remove('opacity-100'); + modalContent.classList.remove('opacity-100', 'scale-100'); + modalContent.classList.add('scale-95'); + // Wait for transitions to finish before hiding + setTimeout(() => { + deleteProcedureModalEl.classList.add('hidden'); + }, 300); // Match transition duration + currentProcedureIdToDelete = null; // Reset ID when closing + } + + deleteProcedureButtons.forEach(button => { + button.addEventListener('click', function() { + currentProcedureIdToDelete = this.getAttribute('data-proc-id'); + const procedureInfo = this.getAttribute('data-proc-info'); + + if (procedureInfoPlaceholder) { + procedureInfoPlaceholder.textContent = procedureInfo; // Update modal body + } + + // Update form action + if (deleteProcedureForm && currentProcedureIdToDelete) { + // IMPORTANT: Need the correct base URL structure for the delete route + const deleteUrlTemplate = "{{ url_for('dietitian.delete_procedure', procedure_id=0) }}"; + const deleteUrl = deleteUrlTemplate.replace('/0', '/' + currentProcedureIdToDelete); + deleteProcedureForm.action = deleteUrl; + console.log("Delete form action set to:", deleteUrl); + } else { + console.error("Could not set delete form action. Form or ID missing?"); + } + + openModal(); + }); + }); + + // Add listeners to close buttons + closeButtons.forEach(button => { + button.addEventListener('click', closeModal); + }); + + // Close modal on backdrop click + modalBackdrop.addEventListener('click', closeModal); + + // No special listener needed for confirm button (type="submit" handles it) +}); +</script> {% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/detail.html b/app/templates/dietitians/detail.html index abee12d3be960d75049aba0c191b4e26670964a0..d52211313f64d9e9a776da95125e5e20706099bc 100644 --- a/app/templates/dietitians/detail.html +++ b/app/templates/dietitians/detail.html @@ -11,7 +11,7 @@ <nav class="flex" aria-label="Breadcrumb"> <ol class="inline-flex items-center space-x-1 md:space-x-3"> <li class="inline-flex items-center"> - <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <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ủ </a> @@ -101,9 +101,24 @@ <!-- Patients List --> <div class="bg-white shadow rounded-lg p-6 col-span-2"> - <h2 class="text-lg font-medium text-gray-900 mb-4">Danh sách bệnh nhân</h2> + <h2 class="text-lg font-medium text-gray-900 mb-4">Bệnh nhân đang theo dõi</h2> - {% if dietitian.patients %} + {# --- DEBUGGING START --- #} + <div class="text-xs p-2 mb-4 bg-yellow-100 border border-yellow-300 rounded"> + <p><strong>Debug Info:</strong></p> + <p>Dietitian ID: {{ dietitian.dietitianID }}</p> + <p>Linked User ID: {{ dietitian.user_id if dietitian.user_id else 'None' }}</p> + <p>Dietitian User Object Exists: {{ dietitian.user is not none }}</p> + {% if dietitian.user %} + <p>Assigned Patients (Count): {{ dietitian.user.assigned_patients | length }}</p> + <p>Assigned Patients List: {{ dietitian.user.assigned_patients }}</p> + {% else %} + <p>Cannot access assigned_patients because dietitian.user is None.</p> + {% endif %} + </div> + {# --- DEBUGGING END --- #} + + {% if dietitian.user and dietitian.user.assigned_patients %} <div class="overflow-x-auto"> <table class="min-w-full divide-y divide-gray-200"> <thead class="bg-gray-50"> @@ -116,7 +131,7 @@ </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> - {% for patient in dietitian.patients %} + {% for patient in dietitian.user.assigned_patients %} <tr class="hover:bg-gray-50 transition duration-150"> <td class="px-6 py-4 whitespace-nowrap"> <div class="flex items-center"> @@ -151,7 +166,7 @@ </span> </td> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <a href="{{ url_for('patients.detail', patient_id=patient.id) }}" class="text-primary-600 hover:text-primary-900 transition duration-200">Xem</a> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}" class="text-primary-600 hover:text-primary-900 transition duration-200">Xem</a> </td> </tr> {% endfor %} @@ -164,7 +179,7 @@ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> </svg> <h3 class="mt-2 text-sm font-medium text-gray-900">Không có bệnh nhân</h3> - <p class="mt-1 text-sm text-gray-500">Chuyên gia dinh dưỡng này chưa được gán bệnh nhân nào.</p> + <p class="mt-1 text-sm text-gray-500">Chuyên gia dinh dưỡng này chưa được phân công bệnh nhân nào.</p> </div> {% endif %} </div> diff --git a/app/templates/dietitians/index.html b/app/templates/dietitians/index.html index c32b98148ddad5ac46dc2d1283475755c50ff1fc..5008e91fc368a68f99d6b0ffebf128f75020095a 100644 --- a/app/templates/dietitians/index.html +++ b/app/templates/dietitians/index.html @@ -79,7 +79,7 @@ <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">Chuyên môn</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> </tr> @@ -103,7 +103,9 @@ <div class="text-sm font-medium text-gray-800">{{ current_user.email or 'N/A' }}</div> <div class="text-sm text-gray-600">{{ current_user.phone or 'N/A' }}</div> </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{{ current_dietitian.specialization or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700"> + {{ current_dietitian.patient_count }} {{ current_dietitian.patient_count | pluralize('patient') }} + </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> @@ -133,8 +135,7 @@ <tr> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên gia</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liên hệ</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên môn</th> - <!-- <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bệnh nhân</th> --> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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> </tr> @@ -142,10 +143,10 @@ <tbody class="bg-white divide-y divide-gray-200"> {% if other_dietitians %} {% for dietitian in other_dietitians %} + {% set patient_count = dietitian.patient_count %} <tr> <td class="px-6 py-4 whitespace-nowrap"> <div class="flex items-center"> - <!-- Avatar placeholder --> <div class="flex-shrink-0 h-10 w-10"> <span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-gray-100"> <span class="font-medium text-gray-600">{{ dietitian.firstName[0] }}{{ dietitian.lastName[0] }}</span> @@ -161,12 +162,12 @@ <div class="text-sm text-gray-900">{{ dietitian.email or 'N/A' }}</div> <div class="text-sm text-gray-500">{{ dietitian.phone or 'N/A' }}</div> </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ dietitian.specialization or 'N/A' }}</td> - <!-- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">X bệnh nhân</td> --> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {{ patient_count }} {{ patient_count | pluralize('patient') }} + </td> <td class="px-6 py-4 whitespace-nowrap">{{ status_badge(dietitian.status.value) }}</td> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <a href="{{ url_for('dietitians.show', id=dietitian.dietitianID) }}" class="text-primary-600 hover:text-primary-900 mr-3 transition duration-200">Chi tiết</a> - <!-- Chỉ Admin hoặc chính dietitian đó mới thấy nút sửa --> {% if current_user.is_admin %} <a href="{{ url_for('dietitians.edit', id=dietitian.dietitianID) }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Sửa</a> <form action="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" method="POST" class="inline-block" onsubmit="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');"> @@ -188,7 +189,7 @@ <!-- Phân trang --> {% if dietitians and dietitians.pages > 1 %} <div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> - {{ render_pagination(dietitians, 'dietitians.index', search=search_query, status=status_filter) }} + {{ render_pagination(dietitians, 'dietitians.index', filter_args={'search': search_query, 'status': status_filter}) }} </div> {% endif %} </div> diff --git a/app/templates/dietitians/show.html b/app/templates/dietitians/show.html index 24d80cd3b3c4e3dfd7fc1d0c5a5e1ab9524066ca..4a0fe67804a2eebc63924441505ae4b1fba54266 100644 --- a/app/templates/dietitians/show.html +++ b/app/templates/dietitians/show.html @@ -12,7 +12,7 @@ <nav class="flex" aria-label="Breadcrumb"> <ol class="inline-flex items-center space-x-1 md:space-x-3"> <li class="inline-flex items-center"> - <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <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ủ </a> @@ -120,12 +120,14 @@ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Giới tính </th> + {% 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 </th> <th scope="col" class="relative px-6 py-3"> <span class="sr-only">Xem</span> </th> + {% endif %} </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -143,24 +145,39 @@ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> {{ patient.gender|capitalize }} </td> + {% if current_user.is_admin %} <td class="px-6 py-4 whitespace-nowrap"> - {% if patient.currentAdmissionStatus == "Admitted" %} + {% if patient.status.name == "ACTIVE" %} <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> - Đang điều trị + Active </span> - {% elif patient.currentAdmissionStatus == "Discharged" %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> - Đã xuất viện + {% elif patient.status.name == "NEEDS_ASSESSMENT" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"> + Needs Assessment + </span> + {% elif patient.status.name == "ASSESSMENT_IN_PROGRESS" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800"> + Assessment In Progress + </span> + {% elif patient.status.name == "COMPLETED" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-purple-100 text-purple-800"> + Completed </span> {% else %} <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"> - {{ patient.currentAdmissionStatus }} + {{ patient.status.name.replace("_", " ").title() }} </span> {% endif %} </td> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <a href="{{ url_for('patients.show', id=patient.id) }}" class="text-primary-600 hover:text-primary-900">Xem</a> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}" class="text-primary-600 hover:text-primary-900"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> + </svg> + </a> </td> + {% endif %} </tr> {% endfor %} </tbody> diff --git a/app/templates/encounter_measurements.html b/app/templates/encounter_measurements.html index de0d872f1ddcf2a55e61347c01a9d1e28f5385a1..2fc04ae51c112c0c94164d0095f2110243a317a4 100644 --- a/app/templates/encounter_measurements.html +++ b/app/templates/encounter_measurements.html @@ -44,7 +44,12 @@ <!-- Thông tin Encounter --> <div class="bg-white shadow rounded-lg p-4 mb-6"> {# Hiển thị custom_encounter_id trong tiêu đề section #} - <h3 class="text-lg font-semibold mb-2">Encounter Details (ID: {{ display_encounter_id }})</h3> + <div class="flex justify-between items-center mb-2"> + <h3 class="text-lg font-semibold">Encounter Details (ID: {{ display_encounter_id }})</h3> + + <!-- Nút Back với anchor để quay lại tab encounters --> + {# Removed Back Button From Here #} + </div> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <div><strong>Start Time:</strong> {{ encounter.start_time.strftime('%d/%m/%Y %H:%M') if encounter.start_time else 'N/A' }}</div> {# Sửa cách hiển thị status #} @@ -82,27 +87,36 @@ </div> <!-- Các nút thao tác (Upload CSV cho encounter này) --> - <div class="flex justify-end items-center mb-6 space-x-3"> - <!-- Nút Upload CSV --> - <form id="uploadCsvForm" action="{{ url_for('patients.upload_encounter_measurements_csv', patient_id=patient.id, encounter_id=encounter.encounterID) }}" method="post" enctype="multipart/form-data" class="inline-flex"> - {# Sử dụng form rỗng để lấy CSRF token #} - {% set form = EmptyForm() %} - {{ form.csrf_token }} - {# Thêm input ẩn nếu cần gửi delimiter/encoding từ client #} - {# <input type="hidden" name="delimiter" value=","> #} - {# <input type="hidden" name="encoding" value="utf-8"> #} - <label for="csvFile" class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all duration-200"> - <i class="fas fa-file-csv -ml-1 mr-2 text-gray-500"></i> - Upload Measurements CSV - </label> - <input type="file" id="csvFile" name="csv_file" class="hidden" accept=".csv"> - </form> - - <!-- Nút Thêm đo lường thủ công (nếu có) --> - <!-- <a href="#" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-1"> - <i class="fas fa-plus -ml-1 mr-2"></i> - Add Measurement Manually - </a> --> + <div class="flex justify-between items-center mb-6 space-x-3"> + <!-- Nút Back được di chuyển đến đây --> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}#encounters" + class="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200"> + <i class="fas fa-arrow-left mr-1.5"></i> + Quay lại danh sách lượt khám + </a> + + <div class="flex items-center space-x-3"> {# Wrap remaining buttons #} + <!-- Nút Upload CSV --> + <form id="uploadCsvForm" action="{{ url_for('patients.upload_encounter_measurements_csv', patient_id=patient.id, encounter_id=encounter.encounterID) }}" method="post" enctype="multipart/form-data" class="inline-flex"> + {# Sử dụng form rỗng để lấy CSRF token #} + {% set form = EmptyForm() %} + {{ form.csrf_token }} + {# Thêm input ẩn nếu cần gửi delimiter/encoding từ client #} + {# <input type="hidden" name="delimiter" value=","> #} + {# <input type="hidden" name="encoding" value="utf-8"> #} + <label for="csvFile" class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all duration-200"> + <i class="fas fa-file-csv -ml-1 mr-2 text-gray-500"></i> + Upload Measurements CSV + </label> + <input type="file" id="csvFile" name="csv_file" class="hidden" accept=".csv"> + </form> + + <!-- Nút Thêm đo lường thủ công (nếu có) --> + <!-- <a href="#" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-1"> + <i class="fas fa-plus -ml-1 mr-2"></i> + Add Measurement Manually + </a> --> + </div> {# End wrapper for right-aligned buttons #} </div> <!-- Container thông báo --> diff --git a/app/templates/macros/modals.html b/app/templates/macros/modals.html index 382766d7159f2d6c17b9a1c7eacb546c4525144d..08feb8375c7afca9f1385b2888e396da54054710 100644 --- a/app/templates/macros/modals.html +++ b/app/templates/macros/modals.html @@ -38,29 +38,4 @@ </div> </div> </div> - -<script> -document.addEventListener('DOMContentLoaded', function() { - // Thêm event listener cho nút Cancel trong modal - const cancelBtns = document.querySelectorAll('#{{ modal_id }} .modal-cancel-btn'); - cancelBtns.forEach(btn => { - btn.addEventListener('click', function() { - const modal = document.getElementById('{{ modal_id }}'); - if (modal) { - modal.classList.add('hidden'); - } - }); - }); - - // Đóng modal khi click ngoài vùng modal - const modal = document.getElementById('{{ modal_id }}'); - if (modal) { - modal.addEventListener('click', function(event) { - if (event.target === modal) { - modal.classList.add('hidden'); - } - }); - } -}); -</script> {% endmacro %} \ No newline at end of file diff --git a/app/templates/patient_detail.html b/app/templates/patient_detail.html index 4563fa97bb408b781a18959e51d77d8009175b6f..d81f1a5e23df0bcd77f7930d86dff88fc1e53b6c 100644 --- a/app/templates/patient_detail.html +++ b/app/templates/patient_detail.html @@ -111,9 +111,9 @@ <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="mt-1"> - {# Hiển thị trạng thái Referral mới nhất #} - {% set latest_ref = referrals|sort(attribute='referralRequestedDateTime', reverse=True)|first %} - {% set r_status = latest_ref.referral_status if latest_ref else None %} + {# 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 %} + {% set r_status = latest_enc_referral.referral_status if latest_enc_referral else None %} {% set r_color_map = { ReferralStatus.DIETITIAN_UNASSIGNED: 'yellow', ReferralStatus.WAITING_FOR_REPORT: 'orange', @@ -142,12 +142,19 @@ <a href="#referrals" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm tab-link" data-target="referrals"> Giới thiệu & Đánh giá </a> + {# 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 </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 </a> + {% endif %} </nav> </div> </div> @@ -155,7 +162,7 @@ <!-- Nội dung tab --> <div class="animate-fade-in" id="tabContent"> <!-- Tab tổng quan --> - <div id="overview" class="tab-pane"> + <div id="overview" class="tab-pane active"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> <!-- Thông tin cá nhân --> <div class="bg-white shadow-md rounded-lg col-span-1 transition-all duration-300 hover:shadow-lg transform hover:-translate-y-1"> @@ -233,8 +240,10 @@ <div class="text-3xl font-bold text-gray-900">{{ "%.1f"|format(patient.bmi) if patient.bmi else '--' }}</div> <div class="mt-1 w-full bg-gray-200 rounded-full h-2"> {% set bmi_percentage = patient.get_bmi_percentage() %} - {% set bmi_color_hex = {'blue': '#3b82f6', 'green': '#10b981', 'yellow': '#facc15', 'red': '#ef4444'}.get(patient.get_bmi_color_class(), '#d1d5db') %} - <div class="h-2 rounded-full" style="width: {{ bmi_percentage }}%; background-color: {{ bmi_color_hex }};"></div> + {# Lấy tên màu (ví dụ: 'blue', 'green') từ hàm helper #} + {% set bmi_color_class_name = patient.get_bmi_color_class() or 'gray' %} + {# Sử dụng class động cho màu nền và style inline cho width, thêm dấu ; #} + <div class="h-2 rounded-full bg-{{ bmi_color_class_name }}-500" style="width: {{ bmi_percentage }}%;"></div> </div> <div class="mt-2 flex justify-between w-full text-xs text-gray-500"> <span>0</span> @@ -337,17 +346,24 @@ </div> <!-- Tab Lượt khám (Encounters) --> - <div id="encounters" class="tab-pane hidden"> + <div id="encounters" class="tab-pane"> <div class="bg-white shadow rounded-lg"> <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> <h3 class="text-lg leading-6 font-medium text-gray-900"> Lịch sử Lượt khám (Encounters) </h3> - {# Thay thế button bằng form #} - <form action="{{ url_for('patients.new_encounter', patient_id=patient.id) }}" method="POST" class="inline-block"> + {# 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"> {# Thêm CSRF token từ EmptyForm #} {{ EmptyForm().csrf_token }} - <button type="submit" class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"> + {# Thêm các thuộc tính data-* vào nút #} + <button id="add-encounter-button" type="submit" class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200" + data-latest-status="{{ latest_encounter.status.value if latest_encounter and latest_encounter.status else 'null' }}" + data-latest-dietitian="{{ latest_encounter.assigned_dietitian.full_name if latest_encounter and latest_encounter.assigned_dietitian else 'null' }}" + data-edit-url="{{ url_for('report.edit_report', report_id=latest_encounter.reports[0].id) if latest_encounter and latest_encounter.reports else '' }}" + data-is-admin="{{ 'true' if current_user.is_admin else 'false' }}" + 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 </button> @@ -403,9 +419,12 @@ <form action="{{ url_for('patients.delete_encounter', patient_id=patient.id, encounter_pk=encounter.encounterID) }}" method="POST" class="inline-block needs-confirmation" data-confirmation-message="Are you sure you want to delete encounter {{ encounter.custom_encounter_id if encounter.custom_encounter_id else encounter.encounterID }}? This will also delete all associated measurements."> {# Sử dụng EmptyForm để tạo CSRF token #} {{ EmptyForm().csrf_token }} + {# Add admin check #} + {% if current_user.is_admin %} <button type="submit" class="text-red-600 hover:text-red-800" title="Delete Encounter"> <i class="fas fa-trash-alt"></i> </button> + {% endif %} </form> </td> </tr> @@ -421,7 +440,7 @@ </div> <!-- Tab giới thiệu & đánh giá --> - <div id="referrals" class="tab-pane hidden"> + <div id="referrals" class="tab-pane"> <!-- Nội dung tab Giới thiệu & Đánh giá --> <div class="bg-white shadow rounded-lg"> <div class="px-4 py-5 sm:px-6 flex justify-between items-center"> @@ -514,99 +533,211 @@ </div> <!-- Tab Thủ thuật --> - <div id="procedures" class="tab-pane hidden"> - <div class="bg-white shadow rounded-lg p-6"> - <h3 class="text-lg font-medium text-gray-900 mb-4">Thông tin Thủ thuật</h3> - <p class="text-sm text-gray-500">Chức năng này đang được phát triển hoặc chưa có dữ liệu thủ thuật cho bệnh nhân này.</p> - </div> - </div> - - <!-- Tab Báo cáo --> - <div id="reports" class="tab-pane hidden"> - <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"> + {% if not current_user.is_admin %} + <div id="procedures" class="tab-pane"> + {# Apply Tailwind classes to the procedure content #} + <div class="bg-white shadow rounded-lg overflow-hidden"> + <div class="px-4 py-5 sm:px-6 flex justify-between items-center border-b border-gray-200"> <h3 class="text-lg leading-6 font-medium text-gray-900"> - Danh sách Báo cáo Dinh dưỡng + Danh sách Thủ thuật </h3> - <a href="{{ url_for('report.new_report', patient_id=patient.id) }}" class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs 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-colors duration-200"> - <i class="fas fa-plus mr-1"></i> Tạo báo cáo mới + {# Check for ongoing encounter to enable button #} + {% set has_ongoing_encounter = latest_encounter and latest_encounter.status == EncounterStatus.ON_GOING %} + <a href="{{ url_for('dietitian.new_procedure', patient_id=patient.id) }}" + 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."{% endif %}> + <i class="fas fa-plus mr-1"></i> Thêm Thủ thuật </a> </div> <div class="px-4 py-5 sm:p-6"> - {% if reports %} - <table class="min-w-full divide-y divide-gray-200"> - <thead class="bg-gray-50"> - <tr> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID Báo cáo</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ngày tạo</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Người tạo</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> - <th scope="col" class="relative px-6 py-3"> - <span class="sr-only">Actions</span> - </th> - </tr> - </thead> - <tbody class="bg-white divide-y divide-gray-200"> - {% for report in reports|sort(attribute='report_date', reverse=True) %} {# Sắp xếp báo cáo theo ngày, mới nhất đầu tiên #} - <tr class="hover:bg-gray-50 transition-colors duration-150"> - <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> - {# Sửa lại url_for và id #} - <a href="{{ url_for('report.view_report', report_id=report.id) }}" class="text-primary-600 hover:text-primary-900 hover:underline">#{{ report.id }}</a> - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ report.report_date.strftime('%d/%m/%Y %H:%M') if report.report_date else 'N/A' }}</td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ report.author.username if report.author else 'N/A' }}</td> - <td class="px-6 py-4 whitespace-nowrap"> - {% set status_color = 'green' if report.status == 'Completed' else 'yellow' if report.status == 'Pending' else 'gray' if report.status == 'Draft' else 'gray' %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-{{ status_color }}-100 text-{{ status_color }}-800"> - {{ report.status }} - </span> + {% if procedures and procedures|length > 0 %} + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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">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> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for proc in procedures %} + <tr class="hover:bg-gray-50"> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ proc.procedureType }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureName or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureDateTime.strftime('%d/%m/%Y %H:%M') if proc.procedureDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureEndDateTime.strftime('%d/%m/%Y %H:%M') if proc.procedureEndDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 max-w-xs truncate" title="{{ proc.description or 'N/A' }}">{{ proc.description or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 max-w-xs truncate" title="{{ proc.procedureResults or 'N/A' }}">{{ proc.procedureResults or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {% if proc.encounter %} + <a href="{{ url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=proc.encounter.encounterID) }}" class="text-blue-600 hover:underline"> + {{ proc.encounter.custom_encounter_id or proc.encounter.encounterID }} + </a> + {% else %} + N/A + {% endif %} </td> - <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - {# 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> - - {# Nút Sửa (chỉ khi Draft/Pending và có quyền) #} - {% if report.status in ['Draft', 'Pending'] and (current_user.is_admin or current_user.userID == report.author_id or current_user.userID == patient.assigned_dietitian_user_id) %} - <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> - {% endif %} - - {# Nút Hoàn thành (chỉ khi Pending và có quyền) #} - {% if report.status == 'Pending' and (current_user.is_admin or current_user.userID == patient.assigned_dietitian_user_id) %} - <form action="{{ url_for('report.complete_report', report_id=report.id) }}" method="post" class="inline-flex items-center needs-confirmation" data-confirmation-message="Bạn có chắc chắn muốn hoàn thành báo cáo này? Hành động này sẽ cập nhật trạng thái của bệnh nhân, lượt khám và giấy giới thiệu."> - {{ EmptyForm().csrf_token }} - <button type="submit" class="text-green-600 hover:text-green-900" title="Hoàn thành báo cáo"><i class="fas fa-check-circle"></i></button> - </form> - {% endif %} - - {# Nút Tải PDF (khi Completed) #} - {% if report.status == 'Completed' %} - <a href="{{ url_for('report.download_report', report_id=report.id) }}" class="text-green-600 hover:text-green-900" title="Xuất PDF"><i class="fas fa-file-pdf"></i></a> - {% endif %} - - {# Nút Xóa (chỉ Admin) #} - {% if current_user.role == 'Admin' %} - <form action="{{ url_for('report.delete_report', report_id=report.id) }}" method="post" class="inline-flex items-center needs-confirmation" data-confirmation-message="Bạn có chắc chắn muốn xóa báo cáo #{{ report.id }}? Hành động này không thể khôi phục."> - {{ EmptyForm().csrf_token }} - <button type="submit" class="text-red-600 hover:text-red-900" title="Xóa báo cáo"><i class="fas fa-trash-alt"></i></button> - </form> - {% endif %} - </div> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2"> + {# Nút Edit #} + <a href="{{ url_for('dietitian.edit_procedure', procedure_id=proc.id) }}" + class="text-indigo-600 hover:text-indigo-900 text-lg p-1 rounded-md hover:bg-indigo-100 transition duration-150 ease-in-out" + title="Edit Procedure"> + <i class="fas fa-edit"></i> + </a> + {# Nút Delete (mở modal) #} + <button type="button" + class="text-red-600 hover:text-red-900 text-lg p-1 rounded-md hover:bg-red-100 transition duration-150 ease-in-out delete-procedure-btn" + title="Delete Procedure" + data-proc-id="{{ proc.id }}" + data-proc-info="{{ proc.procedureType }} #{{ proc.id }} on {{ proc.procedureDateTime.strftime('%d/%m/%Y') if proc.procedureDateTime else 'N/A' }}"> + <i class="fas fa-trash-alt"></i> + </button> </td> </tr> - {% endfor %} - </tbody> - </table> + {% endfor %} + </tbody> + </table> + </div> {% 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-gray-500 italic">No procedures recorded for this encounter.</p> {% endif %} </div> </div> + {# End Procedures Section #} + + {# ... other sections ... #} + + </div> {# End grid #} + </div> {# End container #} + + <!-- Delete Procedure Confirmation Modal --> + <div id="deleteProcedureConfirmModal" + class="fixed inset-0 z-50 hidden flex items-center justify-center transition-opacity duration-300 ease-out" + aria-labelledby="deleteProcedureConfirmModalLabel" + aria-modal="true" + role="dialog"> + <!-- Backdrop --> + <div class="fixed inset-0 bg-gray-900 bg-opacity-60 backdrop-blur-sm modal-backdrop transition-opacity duration-300 ease-out" aria-hidden="true"></div> + + <!-- Modal Content --> + <div class="relative bg-white rounded-lg shadow-xl w-full max-w-md transform transition-all duration-300 ease-out scale-95 opacity-0 modal-content"> + <div class="p-6"> + <div class="flex items-start"> + <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"> + <!-- Heroicon name: outline/exclamation-triangle --> + <svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> + </svg> + </div> + <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-grow"> + <h3 class="text-lg font-semibold leading-6 text-gray-900" id="deleteProcedureConfirmModalLabel"> + Xác nhận Xóa Thủ thuật + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-600"> + Bạn có chắc chắn muốn xóa thủ thuật <strong id="procedure-info-placeholder" class="font-medium">[Procedure Info]</strong>? +Hành động này không thể hoàn tác. + </p> + </div> + </div> + <button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center modal-close-btn"> + <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> + <span class="sr-only">Close modal</span> + </button> + </div> + </div> + <div class="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg"> + {# This form's action will be set by JavaScript #} + <form id="delete-procedure-form" action="#" method="POST" class="inline-block"> + {{ empty_form.csrf_token }} {# Use csrf_token from the passed form object #} + <button id="confirm-delete-procedure-btn" type="submit" class="inline-flex w-full justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"> + Xóa + </button> + </form> + <button type="button" class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto modal-close-btn"> + Hủy bỏ + </button> + </div> </div> + </div> +{% endif %} {# Đóng khối if not current_user.is_admin cho tab Thủ thuật #} - <!-- Các tab khác sẽ được thêm vào đây --> + <!-- Tab Báo cáo --> + {% if not current_user.is_admin %} + <div id="reports" class="tab-pane"> + <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 + </h3> + </div> + <div class="px-4 py-5 sm:p-6"> + {% if reports %} + <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">ENCOUNTER ID</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CREATED DATE</th> + <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> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for report in reports|sort(attribute='report_date', reverse=True) %} {# Sắp xếp báo cáo theo ngày, mới nhất đầu tiên #} + <tr class="hover:bg-gray-50 transition-colors duration-150"> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> + {# Hiển thị encounter ID, link tới report view #} + <a href="{{ url_for('report.view_report', report_id=report.id) }}" class="text-primary-600 hover:text-primary-900 hover:underline"> + #{{ report.encounter.custom_encounter_id if report.encounter and report.encounter.custom_encounter_id else report.encounter_id }} + </a> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ report.report_date.strftime('%d/%m/%Y %H:%M') if report.report_date else 'N/A' }}</td> + {# Hiển thị tên người tạo #} + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {# Ưu tiên hiển thị dietitian được gán cho report #} + {{ report.dietitian.full_name if report.dietitian else (report.author.full_name if report.author else 'N/A') }} + </td> + <td class="px-6 py-4 whitespace-nowrap"> + {% set status_color = 'green' if report.status == 'Completed' else 'yellow' if report.status == 'Pending' else 'gray' if report.status == 'Draft' else 'gray' %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-{{ status_color }}-100 text-{{ status_color }}-800"> + {{ report.status }} + </span> + </td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + {# 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> + + {# Nút Sửa (chỉ khi Pending/Draft và có quyền) #} + {% if report.status in ['Pending', 'Draft'] and (current_user.is_admin 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> + {% endif %} + </div> + </td> + </tr> + {% endfor %} + </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> + {% endif %} + </div> + </div> </div> + {% endif %} + + <!-- Các tab khác sẽ được thêm vào đây --> +</div> </div> {# Include confirmation modal macro #} @@ -640,7 +771,7 @@ {# FORM Wrapper Starts Here #} <form id="assignDietitianForm" action="{{ url_for('patients.assign_dietitian', patient_id=patient.id) }}" method="POST"> {# Add CSRF token from EmptyForm #} - {{ EmptyForm().csrf_token }} + {{ empty_form.csrf_token }} {# Hidden input to store assignment type #} <input type="hidden" id="assignmentTypeInput" name="assignment_type" value=""> @@ -709,26 +840,376 @@ </div> </div> -{% endblock %} +{# === BEGIN NEW MODAL === #} +{# Confirmation Modal for Adding Encounter #} +<div id="addEncounterConfirmationModal" class="fixed z-20 inset-0 overflow-y-auto hidden" aria-labelledby="add-encounter-modal-title" role="dialog" aria-modal="true"> + <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> + <!-- Background overlay --> + <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> + <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> + + <!-- Modal panel --> + <div class="relative inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"> + <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> + <div class="sm:flex sm:items-start"> + <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100 sm:mx-0 sm:h-10 sm:w-10"> + <!-- Heroicon name: outline/exclamation --> + <svg class="h-6 w-6 text-yellow-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> + </svg> + </div> + <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> + <h3 class="text-lg leading-6 font-medium text-gray-900" id="add-encounter-modal-title"> + Xác nhận Thêm Lượt khám + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500" id="addEncounterConfirmationMessage"> + <!-- Confirmation message will be set here by JavaScript --> + </p> + </div> + </div> + </div> + </div> + <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> + <button type="button" id="confirmAddEncounterBtn" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"> + <!-- Button text might change (e.g., 'Confirm', 'Go to Report') --> + Xác nhận + </button> + <button type="button" id="cancelAddEncounterBtn" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm modal-cancel-btn"> + Hủy bỏ + </button> + </div> + </div> + </div> +</div> +{# === END NEW MODAL === #} + +<!-- CSS cho các nút trong trang --> +<style> + .btn-white-outline { + background-color: white; + color: #212529; + border: 1px solid #212529; + transition: transform 0.2s, box-shadow 0.2s; + } + + .btn-white-outline:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + } + + .btn-orange { + background-color: #fd7e14; + color: white; + border: none; + transition: transform 0.3s, box-shadow 0.3s; + } + + .btn-orange:hover { + background-color: #e67211; + transform: scale(1.05); + box-shadow: 0 5px 15px rgba(253, 126, 20, 0.4); + } + + /* Chuyển đổi các nút không nổi bật thành nút viền trắng */ + .convert-to-white-outline { + background-color: white !important; + color: #212529 !important; + border: 1px solid #212529 !important; + transition: transform 0.2s, box-shadow 0.2s !important; + } + + .convert-to-white-outline:hover { + transform: translateY(-2px) !important; + box-shadow: 0 4px 8px rgba(0,0,0,0.1) !important; + } + + .floating-animation { + animation: float 3s ease-in-out infinite; + } + @keyframes float { + 0% { transform: translateY(0px); } + 50% { transform: translateY(-5px); } + 100% { transform: translateY(0px); } + } + + .bounce-on-hover:hover { + animation: bounce 0.5s ease; + } + @keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } + } -{% block scripts %} -{{ super() }} {# Kế thừa scripts từ base.html nếu có #} + /* Tăng chiều cao tối đa cho modal */ + .modal-content-height { + max-height: 80vh; /* Hoặc một giá trị phù hợp khác */ + overflow-y: auto; /* Thêm thanh cuộn nếu nội dung vẫn vượt quá */ + } + + /* CSS cho các tab-pane */ + .tab-pane { + display: none; + } + + /* Không dùng class hidden nữa mà dùng style display */ + .tab-pane.active { + display: block; + } + + /* Animation cho tab-pane khi hiển thị */ + .animate-fade-in-fast { + animation: fadeIn 0.3s ease-in-out; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } +</style> -<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script> + // Áp dụng CSS mới cho các nút - Sử dụng JavaScript thuần thay vì jQuery document.addEventListener('DOMContentLoaded', function() { + // Thay thế $(document).ready(function() { ... }); + + // Thay thế $("#editPatientBtn").addClass("btn-white-outline").removeClass("btn-primary"); + const editPatientBtn = document.getElementById('editPatientBtn'); + if (editPatientBtn) { + editPatientBtn.classList.add('btn-white-outline'); + editPatientBtn.classList.remove('btn-primary'); + } + + // Thay thế $("#latestStatsBtn").addClass("btn-white-outline").removeClass("btn-info"); + const latestStatsBtn = document.getElementById('latestStatsBtn'); + if (latestStatsBtn) { + latestStatsBtn.classList.add('btn-white-outline'); + latestStatsBtn.classList.remove('btn-info'); + } + + // Thay thế $("#assignDietitianBtn").addClass("btn-orange").removeClass("btn-primary"); + const assignDietitianBtn = document.getElementById('assignDietitianBtn'); + if (assignDietitianBtn) { + assignDietitianBtn.classList.add('btn-orange'); + assignDietitianBtn.classList.remove('btn-primary'); + } + + // Thay thế $(".btn-secondary").addClass("convert-to-white-outline"); + const secondaryBtns = document.querySelectorAll('.btn-secondary'); + secondaryBtns.forEach(btn => { + btn.classList.add('convert-to-white-outline'); + }); + + // Logic for tabs, modals, etc. + const newEncounterForm = document.getElementById('new-encounter-form'); // Define form here + const addEncounterBtn = document.getElementById('add-encounter-button'); // Define button here + + // --- Start: Logic moved inside DOMContentLoaded --- + if (newEncounterForm && addEncounterBtn) { + console.log("[New Encounter Form Logic] Form and button found, adding submit listener"); + + // Lấy thông tin từ data attributes (lấy một lần khi tải trang) + const statusOnLoad = addEncounterBtn.dataset.latestStatus; + const isAdmin = addEncounterBtn.dataset.isAdmin === 'true'; + const editReportUrl = addEncounterBtn.dataset.editUrl; + const displayId = addEncounterBtn.dataset.latestCustomId !== 'null' ? + addEncounterBtn.dataset.latestCustomId : + addEncounterBtn.dataset.latestId; + const dietitianName = addEncounterBtn.dataset.latestDietitian !== 'null' ? + addEncounterBtn.dataset.latestDietitian : + 'assigned'; + + // Lấy các element của modal xác nhận thêm encounter MỚI + const addEncounterConfirmModalEl = document.getElementById('addEncounterConfirmationModal'); + const addEncounterConfirmMsgEl = document.getElementById('addEncounterConfirmationMessage'); + // Thay đổi const thành let ở đây + let confirmAddEncounterBtn = document.getElementById('confirmAddEncounterBtn'); + let cancelAddEncounterBtn = document.getElementById('cancelAddEncounterBtn'); + let addEncounterModalInstance = null; // Để lưu instance của modal Bootstrap + if (addEncounterConfirmModalEl && typeof bootstrap !== 'undefined') { // Check if bootstrap is loaded + // Khởi tạo modal instance nếu element tồn tại + addEncounterModalInstance = new bootstrap.Modal(addEncounterConfirmModalEl); + } else if (!addEncounterConfirmModalEl) { + console.error("Add Encounter confirmation modal element (#addEncounterConfirmationModal) not found!"); + } else { + console.error("Bootstrap Modal component not found. Make sure Bootstrap JS is loaded."); + } + + // --- Định nghĩa các hàm xử lý click cho modal --- + // Hàm này sẽ được gán cho nút xác nhận + let confirmClickHandler = null; + + // Hàm xử lý khi admin xác nhận + const handleAdminConfirm = (form, modalInstance) => { + console.log("[Modal Logic] Admin confirmed via modal, submitting form"); + if (modalInstance) modalInstance.hide(); + form.submit(); // Submit the form + }; + + // Hàm xử lý khi dietitian được chuyển hướng + const handleDietitianRedirect = (modalInstance, url) => { + console.log("[Modal Logic] Dietitian confirmed redirect via modal"); + if (modalInstance) modalInstance.hide(); + window.location.href = url; + }; + + // Hàm xử lý nút Hủy bỏ (luôn giống nhau) + const handleCancelClick = () => { + console.log("[Modal Logic] Cancel button clicked, attempting to hide modal."); // DEBUG + if (addEncounterModalInstance) { + try { + addEncounterModalInstance.hide(); + } catch (e) { + console.error("[Modal Logic] Error during Bootstrap modalInstance.hide():", e); // DEBUG + } + } else { + console.error("[Modal Logic] Cancel button clicked, but modal instance is null!"); + } + // *** THÊM: Luôn thử ẩn thủ công bằng cách thay đổi class và style *** + if (addEncounterConfirmModalEl) { + console.log("[Modal Logic] Manually forcing display: none !important and setting aria-hidden."); // DEBUG + addEncounterConfirmModalEl.style.display = 'none'; // Set display none first + addEncounterConfirmModalEl.setAttribute('aria-hidden', 'true'); + addEncounterConfirmModalEl.classList.remove('show', 'block', 'flex'); // Remove display classes + // Đảm bảo loại bỏ hoàn toàn style display inline nếu có, để !important có tác dụng + // Note: Setting display = 'none' directly might be sufficient, but using !important via style.cssText can be stronger + // addEncounterConfirmModalEl.style.cssText += 'display: none !important;'; // Thử cách mạnh hơn nếu cần + + const backdrop = document.querySelector('.modal-backdrop'); + if (backdrop) { + backdrop.remove(); // Vẫn giữ lại việc xóa backdrop + console.log("[Modal Logic] Manually removed backdrop."); + } + // Reset body style if bootstrap adds overflow: hidden + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; // Reset padding added by bootstrap + } else { + console.error("[Modal Logic] Cannot manually hide modal, element not found."); + } + // --- Kết thúc thêm --- + }; + // --- Kết thúc định nghĩa hàm xử lý --- + + // Set initial button appearance based on status + if (statusOnLoad === 'NOT_STARTED') { + addEncounterBtn.disabled = true; + addEncounterBtn.classList.add('opacity-50', 'cursor-not-allowed'); + addEncounterBtn.title = "Cannot add new encounter while the previous one is not started."; + } else if (statusOnLoad === 'ON_GOING') { + addEncounterBtn.classList.remove('bg-green-600', 'hover:bg-green-700'); + addEncounterBtn.classList.add('bg-red-600', 'hover:bg-red-700'); + addEncounterBtn.title = "Current encounter is ongoing. Creating a new one might interrupt."; + } + + // Add submit event listener + newEncounterForm.addEventListener('submit', function(e) { + e.preventDefault(); // Prevent the default form submission + + console.log("[New Encounter Form Logic] Submit event triggered"); + const form = this; + const currentStatus = addEncounterBtn.dataset.latestStatus; + + console.log(`[New Encounter Form Logic] Status: ${currentStatus}`); + + if (currentStatus === 'On-going') { + console.log("[New Encounter Form Logic] Current status is On-going, showing modal"); + + // Kiểm tra cấu hình modal + if (!addEncounterModalInstance) { + alert('Lỗi: Modal xác nhận không được khởi tạo đúng. Vui lòng liên hệ quản trị viên.'); + return; // Ngăn chặn submit nếu modal lỗi + } + + let confirmMsg = ''; + let confirmButtonText = 'Xác nhận'; + let actionOnClick = null; + + if (isAdmin) { + // Admin can interrupt with confirmation + confirmMsg = `Chuyên gia DD ${dietitianName} chưa hoàn thành báo cáo cho lượt khám hiện tại (ID: ${displayId}). Bạn có chắc chắn muốn gián đoạn và tạo lượt khám mới không?`; + confirmButtonText = 'Tạo lượt khám mới'; + actionOnClick = () => { + console.log("[New Encounter Form Logic] Admin confirmed via modal, submitting form"); + addEncounterModalInstance.hide(); + form.submit(); // Submit the form + }; + console.log("[New Encounter Form Logic] Preparing admin confirmation modal"); + } else { + // Dietitian must complete report first + if (editReportUrl) { + // Modify popup for Dietitian: simple alert, OK button only + confirmMsg = 'Lượt khám hiện tại đang diễn ra. Vui lòng hoàn thành báo cáo đánh giá trước khi tạo lượt khám mới!'; + confirmButtonText = 'Đã hiểu'; // Change button text to 'OK' or similar + actionOnClick = handleCancelClick; // Just close the modal + console.log("[New Encounter Form Logic] Preparing dietitian informational modal"); + // Hide cancel button for this case + if(cancelAddEncounterBtn) cancelAddEncounterBtn.classList.add('hidden'); + } else { + // Trường hợp không có editReportUrl, chỉ hiển thị thông báo lỗi + alert('Lượt khám hiện tại đang diễn ra, nhưng không tìm thấy báo cáo liên quan để chỉnh sửa. Vui lòng liên hệ quản trị viên.'); + return; // Không làm gì cả + } + } + + // --- Hiển thị Modal --- + addEncounterConfirmMsgEl.innerText = confirmMsg; // Đặt nội dung thông báo + confirmAddEncounterBtn.innerText = confirmButtonText; // Đặt text nút xác nhận + + // Unhide cancel button initially in case it was hidden before + if(cancelAddEncounterBtn) cancelAddEncounterBtn.classList.remove('hidden'); + + // *** SỬA: Gỡ listener cũ và gắn listener mới, KHÔNG clone nút *** + // Gỡ listener cũ (nếu có) để tránh gắn chồng chéo + confirmAddEncounterBtn.removeEventListener('click', confirmClickHandler); + cancelAddEncounterBtn.removeEventListener('click', handleCancelClick); + + // Lưu lại handler mới cho nút confirm + confirmClickHandler = actionOnClick; + + // Gắn listener mới + confirmAddEncounterBtn.addEventListener('click', confirmClickHandler); + cancelAddEncounterBtn.addEventListener('click', handleCancelClick); + console.log("[Modal Logic] Attached new listeners to EXISTING confirm and cancel buttons."); // DEBUG + + // Hide cancel button again if needed (Dietitian case) + if (!isAdmin && editReportUrl && cancelAddEncounterBtn) { + cancelAddEncounterBtn.classList.add('hidden'); + } + // --- Kết thúc sửa --- + + // Hiển thị modal + addEncounterModalInstance.show(); + + } else if (currentStatus === 'NOT_STARTED') { + alert('Không thể thêm lượt khám mới khi lượt khám trước đó chưa bắt đầu.'); + } else { + // Status is FINISHED or null (no encounter) + console.log("[New Encounter Form Logic] Status is FINISHED or null, submitting form"); + form.submit(); // Submit the form + } + }); // End of newEncounterForm.addEventListener + } else { + if (!newEncounterForm) console.error("[New Encounter Form Logic] Form (#new-encounter-form) not found"); + if (!addEncounterBtn) console.error("[New Encounter Form Logic] Button (#add-encounter-button) not found"); + } + // --- End: Logic moved inside DOMContentLoaded --- + + // --- Existing logic for tabs, other modals, etc. must also be inside DOMContentLoaded --- + // *** START: Restore Tab Logic *** const tabs = document.querySelectorAll('.tab-link'); const panes = document.querySelectorAll('.tab-pane'); const tabContent = document.getElementById('tabContent'); // Ensure the tab content container exists function activateTab(targetId) { - // Hide all panes + // Hide all panes by removing active class + console.log(`[activateTab] Hiding all panes.`); panes.forEach(pane => { - pane.classList.add('hidden'); - pane.classList.remove('animate-fade-in-fast'); // Ensure animation class is removed + pane.classList.remove('active'); + pane.classList.remove('animate-fade-in-fast'); }); - // Deactivate all tabs + // Deactivate all tabs (keep using classes for styling) + console.log(`[activateTab] Deactivating all tab links.`); tabs.forEach(tab => { tab.classList.remove('border-primary-500', 'text-primary-600'); tab.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); @@ -738,34 +1219,45 @@ const activeTab = document.querySelector(`.tab-link[data-target="${targetId}"]`); const activePane = document.getElementById(targetId); const defaultTabId = 'overview'; // Default tab + console.log(`[activateTab] Target ID: ${targetId}, Found Pane: ${!!activePane}, Found Tab: ${!!activeTab}`); let tabToActivate = activeTab; let paneToActivate = activePane; // If target tab/pane doesn't exist, default to overview if (!tabToActivate || !paneToActivate) { - tabToActivate = document.querySelector(`.tab-link[data-target="${defaultTabId}"]`); - paneToActivate = document.getElementById(defaultTabId); - targetId = defaultTabId; // Update targetId for hash update - } - + console.log(`[activateTab] Target ${targetId} not found, defaulting to ${defaultTabId}`); + tabToActivate = document.querySelector(`.tab-link[data-target="${defaultTabId}"]`); + paneToActivate = document.getElementById(defaultTabId); + targetId = defaultTabId; // Update targetId for hash update + } if (tabToActivate && paneToActivate) { + console.log(`[activateTab] Activating tab ${targetId}`); tabToActivate.classList.add('border-primary-500', 'text-primary-600'); tabToActivate.classList.remove('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); - paneToActivate.classList.remove('hidden'); - // Add animation class with a slight delay to ensure it's visible + + // Show the target pane by adding active class + paneToActivate.classList.add('active'); + console.log(`[activateTab] Added active class to pane ${targetId}`); + + // Add animation class with a slight delay setTimeout(() => { - paneToActivate.classList.add('animate-fade-in-fast'); + if (paneToActivate.classList.contains('active')) { + paneToActivate.classList.add('animate-fade-in-fast'); + console.log(`[activateTab] Applied animation to ${targetId}`); + } }, 10); // Small delay + } else { + console.error(`[activateTab] Could not activate tab or pane for target: ${targetId}`); } // Update URL hash without scrolling if (history.replaceState) { - history.replaceState(null, null, `#${targetId}`); - } else { - window.location.hash = `#${targetId}`; - } + history.replaceState(null, null, `#${targetId}`); + } else { + window.location.hash = `#${targetId}`; + } } tabs.forEach(tab => { @@ -777,288 +1269,294 @@ }); // Activate tab based on URL hash on page load + console.log(`[DOMContentLoaded] Checking initial hash: ${window.location.hash}`); const hash = window.location.hash.substring(1); // Remove '#' if (hash) { const targetPane = document.getElementById(hash); // Check if hash corresponds to a valid tab pane if (targetPane && Array.from(panes).includes(targetPane)) { + console.log(`[DOMContentLoaded] Activating tab from hash: ${hash}`); activateTab(hash); } else { + console.log(`[DOMContentLoaded] Hash ${hash} is invalid, activating default.`); activateTab('overview'); // Default to overview if hash is invalid } } else { + console.log(`[DOMContentLoaded] No hash, activating default tab.`); activateTab('overview'); // Default to overview if no hash } - - // Style the status card based on patient status (Example) - const statusCard = document.querySelector('.status-card'); // Select the card to style - const patientStatus = "{{ patient.status|default('Active')|lower }}"; // Get patient status - - if (statusCard) { - let borderColorClass = 'border-green-500'; // Default for active - if (patientStatus === 'discharged') { - borderColorClass = 'border-gray-400'; // Example style for discharged - } else if (patientStatus === 'in treatment') { // Assuming 'in treatment' status exists - borderColorClass = 'border-blue-500'; // Example style for in treatment - } - statusCard.classList.add('border-l-4', borderColorClass); - } - - // Confirmation Modal Logic - var confirmationForms = document.querySelectorAll('.needs-confirmation'); - var confirmationModalElement = document.getElementById('deleteConfirmationModal'); - if (confirmationModalElement) { - var confirmationModal = new bootstrap.Modal(confirmationModalElement); - var confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); - var confirmationMessageElement = document.getElementById('confirmationMessage'); - var formToSubmit; - - confirmationForms.forEach(function (form) { - form.addEventListener('submit', function (event) { - event.preventDefault(); // Prevent default form submission - formToSubmit = form; // Store the form to be submitted - var message = form.getAttribute('data-confirmation-message') || 'Are you sure you want to proceed?'; - if(confirmationMessageElement) confirmationMessageElement.textContent = message; // Set the confirmation message - confirmationModal.show(); // Show the modal - }); - }); - - // Handle the confirmation button click inside the modal - if (confirmDeleteBtn) { - confirmDeleteBtn.addEventListener('click', function () { - if (formToSubmit) { - formToSubmit.submit(); // Submit the stored form - } - }); - } - } else { - console.warn("Confirmation modal element ('deleteConfirmationModal') not found."); - } - - // Modal handling - Debugging - const assignDietitianModal = document.getElementById('assignDietitianModal'); - const assignDietitianBtn = document.getElementById('assignDietitianBtn'); // Button to open modal - const closeModalBtn = document.getElementById('closeModalBtn'); // Cancel button inside modal - - console.log('Assign Dietitian Modal:', assignDietitianModal); - console.log('Assign Dietitian Button:', assignDietitianBtn); - console.log('Close Modal Button:', closeModalBtn); + // *** END: Restore Tab Logic *** - // Show modal - if (assignDietitianBtn && assignDietitianModal) { - console.log('Adding click listener to Assign Dietitian Button'); - assignDietitianBtn.addEventListener('click', function() { - console.log('Assign Dietitian Button clicked!'); - showChoiceView(); // Reset to choice view first - assignDietitianModal.classList.remove('hidden'); - console.log('Modal should be visible now.'); - }); + // *** START: Add Confirmation Modal Logic *** + const confirmationForms = document.querySelectorAll('form.needs-confirmation'); + const deleteModalEl = document.getElementById('deleteConfirmationModal'); // Modal được import từ macro + let deleteModalInstance = null; + if (deleteModalEl && typeof bootstrap !== 'undefined') { + deleteModalInstance = new bootstrap.Modal(deleteModalEl); + } else if (!deleteModalEl) { + console.error("Delete confirmation modal element (#deleteConfirmationModal) not found!"); } else { - console.error('Assign Dietitian button or modal not found!'); - } - - // Close modal with Cancel button - if (closeModalBtn && assignDietitianModal) { - console.log('Adding click listener to Close Modal Button'); - closeModalBtn.addEventListener('click', function() { - console.log('Close button clicked'); - assignDietitianModal.classList.add('hidden'); - showChoiceView(); // Also reset view on cancel - }); + console.error("Bootstrap Modal component not found for delete confirmation."); } - // Close on backdrop click - if (assignDietitianModal) { - console.log('Adding backdrop click listener'); - assignDietitianModal.addEventListener('click', function(e) { - // Check if the click is directly on the backdrop (the modal container itself) - if (e.target === assignDietitianModal) { - console.log('Backdrop clicked'); - assignDietitianModal.classList.add('hidden'); - showChoiceView(); // Reset view + confirmationForms.forEach(form => { + form.addEventListener('submit', function(event) { + event.preventDefault(); // Ngăn submit form ngay lập tức + + if (!deleteModalInstance) { + alert('Lỗi: Không thể hiển thị hộp thoại xác nhận.'); + return; } + + const message = this.dataset.confirmationMessage || 'Are you sure?'; // Lấy message hoặc dùng mặc định + const modalBody = deleteModalEl.querySelector('.modal-body'); // Tìm phần body của modal + // *** SỬA SELECTOR TÌM NÚT XÁC NHẬN *** + const confirmBtn = deleteModalEl.querySelector('#confirmDeleteBtn'); // Nút xác nhận trong modal macro + + if (!confirmBtn) { // Thêm kiểm tra nếu nút confirm không tồn tại + console.error("Confirm button (#confirmDeleteBtn) not found in delete modal!"); + } + + if (modalBody) { + modalBody.textContent = message; // Đặt nội dung message + } + + // Quan trọng: Xóa listener cũ trên nút confirm để tránh bị gọi nhiều lần + let newConfirmBtn = confirmBtn.cloneNode(true); + confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); + + // Thêm listener MỚI cho nút confirm + newConfirmBtn.addEventListener('click', () => { + deleteModalInstance.hide(); // Ẩn modal + this.submit(); // Submit form gốc + }); + + // Hiển thị modal + deleteModalInstance.show(); }); - } - - // Form submission spinner (Keep this) - const assignDietitianForm = document.getElementById('assignDietitianForm'); - const assignSubmitBtn = document.getElementById('assignSubmitBtn'); + }); + // *** END: Add Confirmation Modal Logic *** - if (assignDietitianForm && assignSubmitBtn) { - assignDietitianForm.addEventListener('submit', function() { - assignSubmitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...'; - assignSubmitBtn.disabled = true; - }); - } - - // New Assign Dietitian Modal Logic (Auto/Manual Choice) + // *** START: Logic for Assign Dietitian Modal *** + // const assignDietitianBtn = document.getElementById('assignDietitianBtn'); // Đã khai báo ở trên, không cần khai báo lại + const assignModalEl = document.getElementById('assignDietitianModal'); + const assignForm = document.getElementById('assignDietitianForm'); + const assignmentTypeInput = document.getElementById('assignmentTypeInput'); + const choiceView = document.getElementById('assignmentChoiceView'); + const manualView = document.getElementById('manualAssignmentView'); const chooseAutoBtn = document.getElementById('chooseAutoAssign'); const chooseManualBtn = document.getElementById('chooseManualAssign'); - const assignmentChoiceView = document.getElementById('assignmentChoiceView'); - const manualAssignmentView = document.getElementById('manualAssignmentView'); - const assignmentTypeInput = document.getElementById('assignmentTypeInput'); + const assignSubmitBtn = document.getElementById('assignSubmitBtn'); + const closeModalBtn = document.getElementById('closeModalBtn'); const backToChoiceBtn = document.getElementById('backToChoiceBtn'); - const modalSubmitBtn = document.getElementById('assignSubmitBtn'); - const modalDietitianSelect = document.getElementById('dietitian_id'); - - function showChoiceView() { - assignmentChoiceView.classList.remove('hidden'); - manualAssignmentView.classList.add('hidden'); - backToChoiceBtn.classList.remove('hidden'); - modalSubmitBtn.disabled = false; // Kích hoạt nút confirm - modalSubmitBtn.innerHTML = '<i class="fas fa-magic mr-2"></i>Confirm Auto Assignment'; // Thay đổi text nút confirm (tùy chọn) - modalDietitianSelect.required = false; // Not required in choice view - // Reset selected value if needed - modalDietitianSelect.value = ""; - assignmentTypeInput.value = ""; // Clear assignment type - console.log('Showing Choice View'); + let assignDietitianModalInstance = null; + + if (assignModalEl && typeof bootstrap !== 'undefined') { + assignDietitianModalInstance = new bootstrap.Modal(assignModalEl); + } else { + console.error("Assign Dietitian Modal element or Bootstrap JS not found."); } - function showManualView() { - assignmentChoiceView.classList.add('hidden'); - manualAssignmentView.classList.remove('hidden'); - backToChoiceBtn.classList.remove('hidden'); - assignmentTypeInput.value = 'manual'; - modalDietitianSelect.required = true; // Required for manual - // Enable submit button only if a dietitian is selected initially (or handle validation) - modalSubmitBtn.disabled = !modalDietitianSelect.value; - console.log('Showing Manual View'); + // Function to switch views + const showChoiceView = () => { + if (choiceView) choiceView.style.display = 'grid'; // Hoặc 'block' tùy layout + if (manualView) manualView.style.display = 'none'; + if (backToChoiceBtn) backToChoiceBtn.classList.add('hidden'); + if (assignSubmitBtn) assignSubmitBtn.classList.add('hidden'); // Ẩn nút submit chính khi ở view lựa chọn + }; + const showManualView = () => { + if (choiceView) choiceView.style.display = 'none'; + if (manualView) manualView.style.display = 'block'; + if (backToChoiceBtn) backToChoiceBtn.classList.remove('hidden'); + if (assignSubmitBtn) assignSubmitBtn.classList.remove('hidden'); // Hiện nút submit khi ở view manual + if (assignmentTypeInput) assignmentTypeInput.value = 'manual'; + }; + + // Event listener for the main Assign Dietitian button + if (assignDietitianBtn && assignDietitianModalInstance) { + assignDietitianBtn.addEventListener('click', () => { + console.log("[Assign Modal] Assign Dietitian button clicked."); // DEBUG + showChoiceView(); // Reset về view lựa chọn khi mở modal + assignDietitianModalInstance.show(); + }); + } else if (!assignDietitianBtn) { + console.error("[Assign Modal] Assign Dietitian button (#assignDietitianBtn) not found."); // DEBUG } - if (chooseAutoBtn) { - chooseAutoBtn.addEventListener('click', function() { - console.log('Auto Assign chosen'); + // Event listeners for buttons inside the modal + if (chooseAutoBtn && assignmentTypeInput && assignForm) { + chooseAutoBtn.addEventListener('click', () => { + console.log("[Assign Modal] Auto Assign chosen."); // DEBUG assignmentTypeInput.value = 'auto'; - modalDietitianSelect.required = false; - - // KHÔNG submit form ngay lập tức nữa - // assignDietitianForm.submit(); - - // Thay vào đó, ẩn view lựa chọn và hiển thị nút back/confirm - assignmentChoiceView.classList.add('hidden'); - manualAssignmentView.classList.add('hidden'); // Đảm bảo view manual cũng ẩn - backToChoiceBtn.classList.remove('hidden'); - modalSubmitBtn.disabled = false; // Kích hoạt nút confirm - modalSubmitBtn.innerHTML = '<i class="fas fa-magic mr-2"></i>Confirm Auto Assignment'; // Thay đổi text nút confirm (tùy chọn) - - // Không disable nút auto nữa - // this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Assigning...'; - // this.disabled = true; - // if(chooseManualBtn) chooseManualBtn.disabled = true; + assignForm.submit(); // Submit form ngay lập tức }); } - if (chooseManualBtn) { - // Sửa hàm showManualView để đổi text nút Confirm - chooseManualBtn.addEventListener('click', function() { + chooseManualBtn.addEventListener('click', () => { + console.log("[Assign Modal] Manual Assign chosen."); // DEBUG showManualView(); - modalSubmitBtn.innerHTML = '<i class="fas fa-check mr-2"></i>Confirm Manual Assignment'; - }); + }); } - if (backToChoiceBtn) { - console.log('Adding listener to Back button'); - backToChoiceBtn.addEventListener('click', function() { + backToChoiceBtn.addEventListener('click', () => { + console.log("[Assign Modal] Back button clicked."); // DEBUG showChoiceView(); - modalSubmitBtn.innerHTML = '<i class="fas fa-check mr-2"></i>Confirm Assignment'; // Reset text khi quay lại - }); + }); } - - // Enable submit button when a dietitian is selected in manual mode - if (modalDietitianSelect) { - modalDietitianSelect.addEventListener('change', function() { - if (assignmentTypeInput.value === 'manual') { - modalSubmitBtn.disabled = !this.value; // Enable only if a value is selected + if (closeModalBtn && assignDietitianModalInstance) { + closeModalBtn.addEventListener('click', () => { + console.log("[Assign Modal] Cancel button clicked. Manually hiding modal elements."); // DEBUG + // *** BỎ HOÀN TOÀN LỆNH GỌI hide() *** + // try { + // if (assignDietitianModalInstance) assignDietitianModalInstance.hide(); + // } catch (e) { + // console.error("[Assign Modal] Error during Bootstrap modalInstance.hide():", e); + // } + + // *** CHỈ DÙNG LOGIC ẨN THỦ CÔNG *** + if (assignModalEl) { + assignModalEl.style.display = 'none'; + assignModalEl.setAttribute('aria-hidden', 'true'); + assignModalEl.classList.remove('show', 'block', 'flex'); + + const backdrop = document.querySelector('.modal-backdrop.fade.show'); + if (backdrop) backdrop.remove(); + + // Xóa class và reset style cho body + document.body.classList.remove('modal-open'); + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; + console.log("[Assign Modal] Manual hide complete."); // DEBUG + + // *** THÊM: Dispose và Re-initialize modal instance *** + if (assignDietitianModalInstance) { + try { + assignDietitianModalInstance.dispose(); + console.log("[Assign Modal] Modal instance disposed."); + // Re-create the instance + if (assignModalEl && typeof bootstrap !== 'undefined') { + assignDietitianModalInstance = new bootstrap.Modal(assignModalEl); + console.log("[Assign Modal] Modal instance re-initialized."); + } else { + console.error("[Assign Modal] Cannot re-initialize modal: Element or Bootstrap JS missing."); + } + } catch (e) { + console.error("[Assign Modal] Error during modal dispose/re-initialize:", e); + } + } + // *** KẾT THÚC THÊM *** + } else { + console.error("[Assign Modal] Cannot manually hide modal, element not found."); } }); } - - // Reset modal to choice view when opened/closed (Removed redundant listeners, handled above) - // if (assignDietitianBtn) { ... } - // if (closeModalBtn) { ... } - // if (assignDietitianModal) { ... } + if (assignSubmitBtn && assignForm) { + // Nút này chỉ hoạt động khi ở Manual View và đã chọn dietitian + // Việc submit form đã được xử lý bởi thẻ <form> và type="submit" + // Có thể thêm validation ở đây nếu cần + assignSubmitBtn.addEventListener('click', (e) => { + const selectedDietitian = document.getElementById('dietitian_id'); + if (assignmentTypeInput && assignmentTypeInput.value === 'manual' && selectedDietitian && !selectedDietitian.value) { + e.preventDefault(); // Ngăn submit nếu chưa chọn dietitian + alert("Vui lòng chọn một chuyên gia dinh dưỡng."); + console.log("[Assign Modal] Submit prevented: No dietitian selected."); // DEBUG + } else { + console.log("[Assign Modal] Submit button clicked (Manual assignment)."); // DEBUG + } + }); + } + // *** END: Logic for Assign Dietitian Modal *** - // Remove old toggle logic - // Không còn sử dụng mã cũ ở đây - }); -</script> + // Add event listeners for delete buttons + const deleteButtons = document.querySelectorAll('.delete-procedure-btn'); + deleteButtons.forEach(button => { + button.addEventListener('click', () => { + const procId = button.getAttribute('data-proc-id'); + const procInfo = button.getAttribute('data-proc-info'); + const modalBody = document.querySelector('#deleteProcedureConfirmModal .modal-body #procedure-info-placeholder'); + modalBody.textContent = `Thủ thuật: ${procInfo}`; + const confirmBtn = document.getElementById('confirm-delete-procedure-btn'); + confirmBtn.addEventListener('click', () => { + // Add your delete logic here + console.log(`Thủ thuật ${procInfo} đã được xóa.`); + button.closest('tr').remove(); + document.getElementById('deleteProcedureConfirmModal').modal('hide'); + }); + }); + }); -<!-- CSS cho các nút trong trang --> -<style> - .btn-white-outline { - background-color: white; - color: #212529; - border: 1px solid #212529; - transition: transform 0.2s, box-shadow 0.2s; - } - - .btn-white-outline:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.1); - } - - .btn-orange { - background-color: #fd7e14; - color: white; - border: none; - transition: transform 0.3s, box-shadow 0.3s; - } - - .btn-orange:hover { - background-color: #e67211; - transform: scale(1.05); - box-shadow: 0 5px 15px rgba(253, 126, 20, 0.4); - } - - /* Chuyển đổi các nút không nổi bật thành nút viền trắng */ - .convert-to-white-outline { - background-color: white !important; - color: #212529 !important; - border: 1px solid #212529 !important; - transition: transform 0.2s, box-shadow 0.2s !important; - } - - .convert-to-white-outline:hover { - transform: translateY(-2px) !important; - box-shadow: 0 4px 8px rgba(0,0,0,0.1) !important; - } + // *** START: Logic for Delete Procedure Modal (Revised for Tailwind) *** + const deleteProcedureModalEl = document.getElementById('deleteProcedureConfirmModal'); + const modalBackdrop = deleteProcedureModalEl.querySelector('.modal-backdrop'); + const modalContent = deleteProcedureModalEl.querySelector('.modal-content'); + const deleteProcedureButtons = document.querySelectorAll('.delete-procedure-btn'); + const procedureInfoPlaceholder = document.getElementById('procedure-info-placeholder'); + const confirmDeleteProcedureBtn = document.getElementById('confirm-delete-procedure-btn'); + const deleteProcedureForm = document.getElementById('delete-procedure-form'); + const closeButtons = deleteProcedureModalEl.querySelectorAll('.modal-close-btn'); + let currentProcedureIdToDelete = null; - .floating-animation { - animation: float 3s ease-in-out infinite; - } - @keyframes float { - 0% { transform: translateY(0px); } - 50% { transform: translateY(-5px); } - 100% { transform: translateY(0px); } - } - - .bounce-on-hover:hover { - animation: bounce 0.5s ease; - } - @keyframes bounce { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-3px); } - } + function openModal() { + deleteProcedureModalEl.classList.remove('hidden'); + // Trigger transitions + setTimeout(() => { + modalBackdrop.classList.add('opacity-100'); + modalContent.classList.add('opacity-100', 'scale-100'); + modalContent.classList.remove('scale-95'); // Start slightly scaled down + }, 10); // Small delay for transitions + } - /* Tăng chiều cao tối đa cho modal */ - .modal-content-height { - max-height: 80vh; /* Hoặc một giá trị phù hợp khác */ - overflow-y: auto; /* Thêm thanh cuộn nếu nội dung vẫn vượt quá */ - } -</style> + function closeModal() { + modalBackdrop.classList.remove('opacity-100'); + modalContent.classList.remove('opacity-100', 'scale-100'); + modalContent.classList.add('scale-95'); + // Wait for transitions to finish before hiding + setTimeout(() => { + deleteProcedureModalEl.classList.add('hidden'); + }, 300); // Match transition duration + currentProcedureIdToDelete = null; // Reset ID when closing + } -<script> - // Áp dụng CSS mới cho các nút - $(document).ready(function() { - // Chuyển đổi các nút Edit Patient và Latest Statistics - $("#editPatientBtn").addClass("btn-white-outline").removeClass("btn-primary"); - $("#latestStatsBtn").addClass("btn-white-outline").removeClass("btn-info"); - - // Chuyển đổi nút Assign Dietitian thành màu cam - $("#assignDietitianBtn").addClass("btn-orange").removeClass("btn-primary"); + deleteProcedureButtons.forEach(button => { + button.addEventListener('click', function() { + currentProcedureIdToDelete = this.getAttribute('data-proc-id'); + const procedureInfo = this.getAttribute('data-proc-info'); + + if (procedureInfoPlaceholder) { + procedureInfoPlaceholder.textContent = procedureInfo; // Update modal body + } + + // Update form action + if (deleteProcedureForm && currentProcedureIdToDelete) { + const deleteUrl = `{{ url_for('dietitian.delete_procedure', procedure_id=0) }}`.replace('/0', '/' + currentProcedureIdToDelete); + deleteProcedureForm.action = deleteUrl; + console.log("Delete form action set to:", deleteUrl); + } else { + console.error("Could not set delete form action."); + } + + openModal(); + }); + }); + + // Add click listener for the final confirm button + if (confirmDeleteProcedureBtn && deleteProcedureForm) { + // No event listener needed here, the form submission handles it. + // The button type="submit" inside the form works. + } - // Chuyển đổi các nút khác nếu cần - $(".btn-secondary").addClass("convert-to-white-outline"); + // Add listeners to close buttons + closeButtons.forEach(button => { + button.addEventListener('click', closeModal); + }); + + // Close modal on backdrop click + modalBackdrop.addEventListener('click', closeModal); + + // *** END: Logic for Delete Procedure Modal *** + }); </script> + {% endblock %} + \ No newline at end of file diff --git a/app/templates/patients.html b/app/templates/patients.html index 011f608139f0436d60c369213681aaaaacb0ae95..14cfff0b03b0638caf0e80eb2034e25c8786d5da 100644 --- a/app/templates/patients.html +++ b/app/templates/patients.html @@ -34,7 +34,7 @@ <option value="COMPLETED">Completed</option> </select> - <button type="button" onclick="window.location.href='{{ url_for('patients.new_patient') }}'" 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"> + <button type="button" id="addPatientBtn" 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"> <i class="fas fa-plus mr-2"></i> Add Patient </button> </div> @@ -119,9 +119,12 @@ <i class="fas fa-edit text-2xl"></i> </a> {% endif %} - <button type="button" data-patient-id="{{ patient.patient_id }}" class="text-red-600 hover:text-red-900 delete-patient" title="Delete Patient"> + {# Add admin check for delete button #} + {% if current_user.is_admin %} + <button type="button" data-patient-id="{{ patient.patient_id }}" data-patient-name="{{ patient.full_name }}" class="text-red-600 hover:text-red-900 delete-patient" title="Delete Patient"> <i class="fas fa-trash text-2xl"></i> </button> + {% endif %} </td> </tr> {% endfor %} @@ -216,6 +219,14 @@ {% block scripts %} <script> document.addEventListener('DOMContentLoaded', function() { + // Add Patient button logic + const addPatientButton = document.getElementById('addPatientBtn'); + if (addPatientButton) { + addPatientButton.addEventListener('click', function() { + window.location.href = '{{ url_for("patients.new_patient") }}'; + }); + } + const searchInput = document.getElementById('search'); const statusFilter = document.getElementById('status-filter'); const patientTableBody = document.querySelector('tbody'); diff --git a/app/templates/procedure_form.html b/app/templates/procedure_form.html index 673ec083ea613156e89eb3950dd4f96827726322..54c52f99d7b0c2c9dc40c559c392ffe10b34d841 100644 --- a/app/templates/procedure_form.html +++ b/app/templates/procedure_form.html @@ -26,7 +26,7 @@ </div> {# Form Rendering #} - <form method="POST" action="{{ url_for('.new_procedure', patient_id=patient.id) if not edit_mode else '' # TODO: Add edit action url # }}" class="space-y-6" novalidate> + <form method="POST" action="{{ url_for('.new_procedure', patient_id=patient.id) if not edit_mode else url_for('.edit_procedure', procedure_id=procedure.id) }}" class="space-y-6" novalidate> {{ form.hidden_tag() }} {# Important for CSRF protection #} {# Associated Encounter #} diff --git a/app/templates/report.html b/app/templates/report.html index af032a2c1f62e5c0c4fad3873ae151bada02396e..f9cfca4c225d711e62b48c2cd5178d7a19fbdaf4 100644 --- a/app/templates/report.html +++ b/app/templates/report.html @@ -11,15 +11,7 @@ </h2> </div> <div class="mt-4 flex md:ml-4 md:mt-0 gap-2"> - {# Nút Create Report chỉ cho Admin #} - {% if current_user.is_admin %} - <a href="{{ url_for('report.new_report') }}" class="flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transform hover:scale-105 transition-all duration-200"> - <svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> - </svg> - Create Patient Report - </a> - {% endif %} + {# Nút Create Patient Report chỉ cho Admin -> Đã Bỏ #} {# Nút Generate Statistics chỉ cho Admin #} {% if current_user.is_admin %} <div class="dropdown relative"> @@ -41,14 +33,14 @@ <!-- Filters --> <div class="mb-6 p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow duration-300"> - {# Form có id để JS nhắm tới #} <form id="reportFilterForm" method="GET" action="{{ url_for('report.index') }}" - class="grid grid-cols-1 {{ 'md:grid-cols-4' if current_user.is_admin else 'md:grid-cols-2' }} gap-4"> + {# Tăng số cột tổng lên 12 để chia tỷ lệ dễ hơn #} + class="grid grid-cols-1 {{ 'md:grid-cols-12' if current_user.is_admin else 'md:grid-cols-7' }} gap-4 items-end"> - {# Report Type Filter (Chung) #} - <div> + {# Report Type Filter - Admin: 3/12, Dietitian: 2/7 #} + <div class="{{ 'md:col-span-3' if current_user.is_admin else 'md:col-span-2' }}"> <label for="report_type" class="block text-sm font-medium text-gray-700">Report Type</label> - <select id="report_type" name="report_type" class="filter-change mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"> + <select id="report_type" name="report_type" class="filter-change mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"> <option value="">All Types</option> {% for value, display in report_types_choices %} <option value="{{ value }}" {% if value == selected_report_type %}selected{% endif %}>{{ display }}</option> @@ -56,10 +48,10 @@ </select> </div> - {# Status Filter (Chung) #} - <div> + {# Status Filter - Admin: 3/12, Dietitian: 2/7 #} + <div class="{{ 'md:col-span-3' if current_user.is_admin else 'md:col-span-2' }}"> <label for="status" class="block text-sm font-medium text-gray-700">Status</label> - <select id="status" name="status" class="filter-change mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"> + <select id="status" name="status" class="filter-change mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"> <option value="">All Statuses</option> {% for value, display in report_statuses_choices %} <option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ display }}</option> @@ -67,26 +59,40 @@ </select> </div> - {# Dietitian Filter (Chỉ Admin) #} + {# Dietitian Filter (Chỉ Admin) - Chiếm 3/12 cột #} {% if current_user.is_admin %} - <div> + <div class="md:col-span-3"> <label for="dietitian_id" class="block text-sm font-medium text-gray-700">Filter by Dietitian</label> - <select id="dietitian_id" name="dietitian_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"> + <select id="dietitian_id" name="dietitian_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"> <option value="">All Dietitians</option> {% for d in dietitians_for_filter %} <option value="{{ d.userID }}" {% if d.userID == selected_dietitian_id %}selected{% endif %}>{{ d.full_name }}</option> {% endfor %} </select> </div> + {% endif %} - {# Nút Filter cho Admin #} - <div class="flex items-end"> - <button type="submit" class="w-full px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transform hover:scale-105 transition-all duration-200"> - <i class="fas fa-filter mr-1"></i> Filter Reports + {# Nút Filter - Admin: 1/12, Dietitian: 1/7 #} + <div class="{{ 'md:col-span-1' if current_user.is_admin else 'md:col-span-1' }}"> + <button type="submit" class="w-full px-3 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 transform hover:scale-105 transition-all duration-200 flex items-center justify-center"> + <i class="fas fa-filter mr-1"></i> Filter </button> </div> - {% endif %} - {# Dietitian không có nút filter riêng #} + {# Nút Generate Statistics (Admin) - Chiếm 2/12 cột #} + {% if current_user.is_admin %} + <div class="md:col-span-2"> + <div class="dropdown relative"> + <button id="reportDropdownButton" data-dropdown-toggle="reportDropdown" class="w-full flex items-center justify-center px-3 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 transform hover:scale-105 transition-all duration-200"> + <i class="fas fa-chart-bar mr-1"></i> Stats + </button> + <div id="reportDropdown" class="hidden dropdown-menu min-w-max absolute z-10 bg-white rounded-md shadow-lg py-1 mt-1 right-0"> + <a href="{{ url_for('report.create_stats_report', report_type='daily') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Daily</a> + <a href="{{ url_for('report.create_stats_report', report_type='weekly') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Weekly</a> + <a href="{{ url_for('report.create_stats_report', report_type='monthly') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Monthly</a> + </div> + </div> + </div> + {% endif %} </form> </div> @@ -100,9 +106,9 @@ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Patient</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">Report Date</th> - {# Chỉ Admin thấy cột Author #} + {# Chỉ Admin thấy cột Author/Dietitian #} {% 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">Author</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">In-charge Dietitian</th> {% endif %} <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">Actions</th> @@ -131,15 +137,16 @@ {% set type_color = type_color_map.get(report.report_type, 'gray') %} <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-{{ type_color }}-100 text-{{ type_color }}-800"> {{ report.report_type.replace('_', ' ').title() }} - </span> + </span> </td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> {{ report.report_date.strftime('%d-%m-%Y %H:%M') }} </td> - {# Chỉ Admin thấy cột Author #} + {# Chỉ Admin thấy cột Author/Dietitian #} {% if current_user.is_admin %} <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> - {{ report.author.full_name if report.author else 'N/A'}} + {# Ưu tiên hiển thị Dietitian được gán, sau đó mới đến người tạo #} + {{ report.dietitian.full_name if report.dietitian else (report.author.full_name if report.author else 'N/A') }} </td> {% endif %} <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> @@ -152,7 +159,7 @@ {% set status_color = status_color_map.get(report.status, 'gray') %} <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-{{ status_color }}-100 text-{{ status_color }}-800"> {{ report.status }} - </span> + </span> </td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> <div class="flex items-center space-x-2"> @@ -175,7 +182,8 @@ {# Nút Xóa chỉ cho Admin #} {% if current_user.is_admin %} <form action="{{ url_for('report.delete_report', report_id=report.id) }}" method="POST" class="inline needs-confirmation" data-confirmation-message="Are you sure you want to delete report #{{ report.id }}? This cannot be undone."> - {{ csrf_token() }} + {# Sử dụng delete_form.csrf_token thay vì csrf_token() #} + {{ delete_form.csrf_token }} <button type="submit" class="text-red-600 hover:text-red-900 transform hover:scale-110 transition-transform duration-200" title="Delete Report"> <i class="fas fa-trash-alt"></i> </button> @@ -246,13 +254,13 @@ <!-- Report Statistics (Chung) --> <div class="px-4 py-6 sm:px-6 lg:px-8 max-w-7xl mx-auto"> <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Report Statistics</h3> - <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> {# Thêm cột cho Pending #} + <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> <!-- Total Reports --> <div class="bg-white overflow-hidden shadow rounded-lg hover-shadow transform hover:scale-102 transition-all duration-300"> <div class="p-5"> <div class="flex items-center"> - <div class="flex-shrink-0 bg-primary-100 rounded-md p-3"> - <i class="fas fa-file-alt h-6 w-6 text-primary-600"></i> + <div class="flex-shrink-0 flex items-center justify-center h-12 w-12 bg-primary-100 rounded-md"> + <i class="fas fa-file-alt text-primary-600"></i> </div> <div class="ml-5 w-0 flex-1"> <dl> @@ -267,8 +275,8 @@ <div class="bg-white overflow-hidden shadow rounded-lg hover-shadow transform hover:scale-102 transition-all duration-300"> <div class="p-5"> <div class="flex items-center"> - <div class="flex-shrink-0 bg-gray-100 rounded-md p-3"> - <i class="fas fa-pencil-alt h-6 w-6 text-gray-600"></i> + <div class="flex-shrink-0 flex items-center justify-center h-12 w-12 bg-gray-100 rounded-md"> + <i class="fas fa-pencil-alt text-gray-600"></i> </div> <div class="ml-5 w-0 flex-1"> <dl> @@ -283,8 +291,8 @@ <div class="bg-white overflow-hidden shadow rounded-lg hover-shadow transform hover:scale-102 transition-all duration-300"> <div class="p-5"> <div class="flex items-center"> - <div class="flex-shrink-0 bg-yellow-100 rounded-md p-3"> - <i class="fas fa-hourglass-half h-6 w-6 text-yellow-600"></i> + <div class="flex-shrink-0 flex items-center justify-center h-12 w-12 bg-yellow-100 rounded-md"> + <i class="fas fa-hourglass-half text-yellow-600"></i> </div> <div class="ml-5 w-0 flex-1"> <dl> @@ -299,8 +307,8 @@ <div class="bg-white overflow-hidden shadow rounded-lg hover-shadow transform hover:scale-102 transition-all duration-300"> <div class="p-5"> <div class="flex items-center"> - <div class="flex-shrink-0 bg-green-100 rounded-md p-3"> - <i class="fas fa-check-circle h-6 w-6 text-green-600"></i> + <div class="flex-shrink-0 flex items-center justify-center h-12 w-12 bg-green-100 rounded-md"> + <i class="fas fa-check-circle text-green-600"></i> </div> <div class="ml-5 w-0 flex-1"> <dl> @@ -314,23 +322,39 @@ </div> </div> -{# Biểu đồ Contribution chỉ cho Admin #} -{% if current_user.is_admin and dietitian_contribution_chart_data %} -<div class="px-4 py-6 sm:px-6 lg:px-8 max-w-7xl mx-auto"> +{# Wrap charts in a grid container for side-by-side layout #} +<div class="px-4 py-6 sm:px-6 lg:px-8 max-w-7xl mx-auto grid grid-cols-1 {{ 'md:grid-cols-2' if not current_user.is_admin else 'md:grid-cols-2'}} gap-6"> + <!-- Report Type Distribution Chart (Chung) --> + <div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition-shadow duration-300"> + <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Report Type Distribution</h3> + <canvas id="reportTypeChart" class="mt-4 max-h-80"></canvas> + </div> + + {# Biểu đồ Contribution chỉ cho Admin #} + {% if current_user.is_admin and dietitian_contribution_chart_data %} <div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition-shadow duration-300"> <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Dietitian Report Contributions</h3> <canvas id="dietitianContributionChart" class="mt-4 max-h-80"></canvas> </div> + {# Biểu đồ Report Status Distribution cho Dietitian #} + {% elif not current_user.is_admin %} + <div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition-shadow duration-300"> + <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">My Report Status Distribution</h3> + <canvas id="reportStatusChartDietitian" class="mt-4 max-h-80"></canvas> + </div> + {% endif %} </div> -{% endif %} -<!-- Report Type Distribution Chart (Chung) --> +{# Biểu đồ Report Status Distribution cho Admin (full width) #} +{% if current_user.is_admin %} <div class="px-4 py-6 sm:px-6 lg:px-8 max-w-7xl mx-auto"> <div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition-shadow duration-300"> - <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Report Type Distribution</h3> - <canvas id="reportTypeChart" class="mt-4 max-h-80"></canvas> + <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Overall Report Status Distribution</h3> + <canvas id="reportStatusChartAdmin" class="mt-4 max-h-80"></canvas> </div> </div> +{% 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> @@ -338,21 +362,10 @@ document.addEventListener('DOMContentLoaded', function() { // Truyền vai trò người dùng vào biến 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 --- - if (currentUserRole === 'Dietitian') { - const filterForm = document.getElementById('reportFilterForm'); - // Thêm kiểm tra null phòng trường hợp form không tồn tại - if (filterForm) { - const filterSelects = filterForm.querySelectorAll('.filter-change'); // Class cho các select cần trigger - - filterSelects.forEach(select => { - select.addEventListener('change', () => { - filterForm.submit(); - }); - }); - } - } + // --- Auto-submit filter form for Dietitian -> REMOVED --- // --- Dropdown cho Generate Statistics (Chỉ Admin) --- const dropdownButton = document.getElementById('reportDropdownButton'); @@ -369,7 +382,7 @@ document.addEventListener('DOMContentLoaded', function() { } }); } - + // --- Hàm tạo màu ngẫu nhiên cho biểu đồ cột --- function getRandomColor() { const r = Math.floor(Math.random() * 200); // Giảm giá trị max để màu đậm hơn @@ -377,6 +390,13 @@ document.addEventListener('DOMContentLoaded', function() { const b = Math.floor(Math.random() * 200); return `rgba(${r}, ${g}, ${b}, 0.7)`; } + + // --- Màu cố định cho status chart --- + 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) --- const reportTypeCtx = document.getElementById('reportTypeChart')?.getContext('2d'); @@ -500,6 +520,119 @@ document.addEventListener('DOMContentLoaded', function() { contributionCtx.textAlign = "center"; contributionCtx.fillText("No contribution data available.", contributionCtx.canvas.width / 2, contributionCtx.canvas.height / 2); } + + // --- Biểu đồ Report Status Distribution (Admin - Full Width) --- + const reportStatusAdminCtx = document.getElementById('reportStatusChartAdmin')?.getContext('2d'); + if (reportStatusAdminCtx && reportStats) { + 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); + + if (filteredLabelsAdmin.length > 0) { + new Chart(reportStatusAdminCtx, { + type: 'bar', // Có thể đổi thành 'pie' hoặc 'doughnut' + data: { + labels: filteredLabelsAdmin, + datasets: [{ + label: 'Number of Reports', + data: filteredDataAdmin, + backgroundColor: filteredLabelsAdmin.map(label => statusColors[label]), + borderColor: 'rgba(255, 255, 255, 0.8)', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + indexAxis: 'x', // Trục x cho status + scales: { + y: { + beginAtZero: true, + title: { display: true, text: 'Number of Reports' } + }, + x: { + title: { display: true, text: 'Report Status' } + } + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.parsed.y || 0; + return `${label}: ${value}`; + } + } + } + } + } + }); + } 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); + } + } + } + // --- Biểu đồ Report Status Distribution (Dietitian - Side) --- + else if (currentUserRole === 'Dietitian') { + const reportStatusDietitianCtx = document.getElementById('reportStatusChartDietitian')?.getContext('2d'); + if (reportStatusDietitianCtx && reportStats) { + 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); + + if (filteredLabelsDietitian.length > 0) { + new Chart(reportStatusDietitianCtx, { + type: 'pie', // Pie chart cho Dietitian + data: { + labels: filteredLabelsDietitian, + datasets: [{ + label: 'Number of Reports', + data: filteredDataDietitian, + backgroundColor: filteredLabelsDietitian.map(label => statusColors[label]), + borderColor: 'rgba(255, 255, 255, 0.8)', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom' }, + tooltip: { + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.parsed || 0; + return `${label}: ${value}`; + } + } + } + } + } + }); + } 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); + } + } } // Confirmation modal logic (dùng confirm() đơn giản) @@ -514,3 +647,4 @@ document.addEventListener('DOMContentLoaded', function() { }); </script> {% endblock %} + diff --git a/app/templates/report_detail.html b/app/templates/report_detail.html index d7cd078717c9595b89f74f5440cb6155cfe3b390..87da5cfd0797d76b74e774c5764b0e5734a85254 100644 --- a/app/templates/report_detail.html +++ b/app/templates/report_detail.html @@ -95,10 +95,8 @@ <div class="text-right"> <p class="text-sm text-gray-500">Report ID: #{{ report.id }}</p> <p class="text-sm text-gray-500">Generated: {{ report.created_at.strftime('%B %d, %Y') }}</p> - {% if report.updated_at != report.created_at %} - <p class="text-sm text-gray-500">Last Updated: {{ report.updated_at.strftime('%B %d, %Y') }}</p> - {% endif %} - <p class="text-sm text-gray-500">Author: {{ report.author.full_name }}</p> + <p class="text-sm text-gray-600">Last Updated: {{ report.updated_at.strftime('%d %b %Y, %H:%M') if report.updated_at else 'N/A' }}</p> + <p class="text-sm text-gray-600">In-charge Dietitian: {{ report.dietitian.full_name if report.dietitian else (report.author.full_name if report.author else 'N/A') }}</p> </div> </div> </div> @@ -159,7 +157,7 @@ <div class="mb-4"> <p class="font-medium text-gray-700">Nutrition Diagnosis:</p> - <p class="ml-4">{{ report.nutrition_diagnosis }}</p> + <div class="ml-4 prose max-w-none">{{ report.nutrition_diagnosis | safe }}</div> </div> <div> diff --git a/app/templates/report_form.html b/app/templates/report_form.html index 6d9cdb30e50e1bbd0cabb711eaf4e59902e047e1..adbf97be2e5b0ea593d6d857f48b512511c46989 100644 --- a/app/templates/report_form.html +++ b/app/templates/report_form.html @@ -59,24 +59,20 @@ <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div> <label for="report_type" class="block text-sm font-medium text-gray-700 mb-1">Report Type*</label> - {{ form.report_type(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50") }} + {{ form.report_type(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 " + ('border-red-500 ring-red-500' if form.report_type.errors else 'border-gray-300')) }} {% if form.report_type.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.report_type.errors %} - <span>{{ error }}</span> - {% endfor %} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.report_type.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} </div> <div> <label for="report_date" class="block text-sm font-medium text-gray-700 mb-1">Report Date*</label> - {{ form.report_date(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50", placeholder="MM/DD/YYYY") }} + {{ form.report_date(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 " + ('border-red-500 ring-red-500' if form.report_date.errors else 'border-gray-300'), placeholder="MM/DD/YYYY") }} {% if form.report_date.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.report_date.errors %} - <span>{{ error }}</span> - {% endfor %} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.report_date.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} </div> @@ -90,7 +86,7 @@ <div> <label for="patient_id" class="block text-sm font-medium text-gray-700 mb-1">Patient*</label> <div class="relative"> - {{ form.patient_id(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50" ~ (' bg-gray-100 cursor-not-allowed' if report else ''), disabled=report) }} + {{ form.patient_id(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50" + (' border-red-500 ring-red-500' if form.patient_id.errors else ' border-gray-300') + (' bg-gray-100 cursor-not-allowed' if report else ''), disabled=report) }} <div id="patient-info" class="mt-2 p-2 bg-blue-50 rounded-md text-sm {{ 'hidden' if not (report or current_patient_id) }}"> {# Initial display if report exists #} {% if report and report.patient %} @@ -102,7 +98,8 @@ {# Display Encounter Info #} <div id="encounter-info" class="mt-2 p-2 bg-indigo-50 rounded-md text-sm {{ 'hidden' if not (report and report.encounter_id or current_encounter_id) }}"> {% if report and report.encounter %} - <strong>Related Encounter:</strong> #{{ report.encounter.id }} | Started: {{ report.encounter.start_time.strftime('%Y-%m-%d %H:%M') if report.encounter.start_time else 'N/A' }} + {# Ưu tiên hiển thị custom_encounter_id nếu có #} + <strong>Related Encounter:</strong> #{{ report.encounter.custom_encounter_id if report.encounter.custom_encounter_id else report.encounter.id }} | Started: {{ report.encounter.start_time.strftime('%Y-%m-%d %H:%M') if report.encounter.start_time else 'N/A' }} {% elif current_encounter_id %} Loading encounter info... {% endif %} @@ -120,10 +117,33 @@ {% endif %} </div> + {# Thêm trường Related Procedure vào đây #} + <div> + <label for="related_procedure_id" class="block text-sm font-medium text-gray-700 mb-1">Related Procedure (Optional)</label> + <div class="relative"> + {{ form.related_procedure_id(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 " + ('border-red-500 ring-red-500' if form.related_procedure_id.errors else 'border-gray-300')) }} + <div id="procedure-info" class="mt-2 p-2 bg-indigo-50 rounded-md text-sm hidden"></div> + </div> + {% if form.related_procedure_id.errors %} + <div class="text-red-500 text-sm mt-1"> + {% for error in form.related_procedure_id.errors %} + <span>{{ error }}</span> + {% endfor %} + </div> + {% endif %} + {# 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 + </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> + <div> <label for="referral_id" class="block text-sm font-medium text-gray-700 mb-1">Referral (Optional)</label> <div class="relative"> - {{ form.referral_id(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50") }} + {{ form.referral_id(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 " + ('border-red-500 ring-red-500' if form.referral_id.errors else 'border-gray-300')) }} <div id="referral-info" class="mt-2 p-2 bg-blue-50 rounded-md text-sm hidden"></div> </div> {% if form.referral_id.errors %} @@ -141,40 +161,45 @@ <div class="border border-gray-300 rounded-md p-4"> <h2 class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-200">Assessment</h2> <div class="space-y-6"> - <div> + {# Nutritional Status Field - Remove tooltip, keep border #} + <div> {# Removed relative positioning wrapper #} <label for="nutritional_status" class="block text-sm font-medium text-gray-700 mb-1">Nutritional Status*</label> - {{ form.nutritional_status(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50") }} + {{ form.nutritional_status(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 " + ('border-red-500 ring-red-500' if form.nutritional_status.errors else 'border-gray-300')) }} {% if form.nutritional_status.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.nutritional_status.errors %} - <span>{{ error }}</span> - {% endfor %} + {# Simple Error Message #} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.nutritional_status.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} </div> - - <div> + + {# Nutrition Diagnosis Field - Remove tooltip, keep border #} + <div> {# Removed relative positioning wrapper #} <label for="nutrition_diagnosis" class="block text-sm font-medium text-gray-700 mb-1">Nutrition Diagnosis*</label> - {{ form.nutrition_diagnosis(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50", rows=3) }} + <div class="{{ 'ring-1 ring-red-500 rounded' if form.nutrition_diagnosis.errors else 'ring-1 ring-gray-300 rounded' }}"> + {{ form.nutrition_diagnosis(class="rich-editor w-full") }} {# Ensure base border exists, add red on error #} + </div> {% if form.nutrition_diagnosis.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.nutrition_diagnosis.errors %} - <span>{{ error }}</span> - {% endfor %} + {# Simple Error Message below TinyMCE #} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.nutrition_diagnosis.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} </div> - + + {# Assessment Details Field #} <div> <label for="assessment_details" class="block text-sm font-medium text-gray-700 mb-1">Assessment Details*</label> - {{ form.assessment_details(class="rich-editor w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50") }} + {# Add error class to the wrapper div or target TinyMCE container if possible #} + <div class="{{ 'ring-1 ring-red-500 rounded' if form.assessment_details.errors else 'ring-1 ring-gray-300 rounded' }}"> + {{ form.assessment_details(class="rich-editor w-full") }} {# Removed border classes from textarea itself #} + </div> {% if form.assessment_details.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.assessment_details.errors %} - <span>{{ error }}</span> - {% endfor %} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.assessment_details.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} + {# Removed potential stray 'p' tag here #} </div> </div> </div> @@ -183,28 +208,32 @@ <div class="border border-gray-300 rounded-md p-4"> <h2 class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-200">Recommendations</h2> <div class="space-y-6"> + {# Intervention Plan Field #} <div> <label for="intervention_plan" class="block text-sm font-medium text-gray-700 mb-1">Intervention Plan*</label> - {{ form.intervention_plan(class="rich-editor w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50") }} + <div class="{{ 'ring-1 ring-red-500 rounded' if form.intervention_plan.errors else 'ring-1 ring-gray-300 rounded' }}"> + {{ form.intervention_plan(class="rich-editor w-full") }} + </div> {% if form.intervention_plan.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.intervention_plan.errors %} - <span>{{ error }}</span> - {% endfor %} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.intervention_plan.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} + {# Removed potential stray 'p' tag here #} </div> - + + {# Monitoring Plan Field #} <div> <label for="monitoring_plan" class="block text-sm font-medium text-gray-700 mb-1">Monitoring & Follow-up Plan*</label> - {{ form.monitoring_plan(class="rich-editor w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50") }} + <div class="{{ 'ring-1 ring-red-500 rounded' if form.monitoring_plan.errors else 'ring-1 ring-gray-300 rounded' }}"> + {{ form.monitoring_plan(class="rich-editor w-full") }} + </div> {% if form.monitoring_plan.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.monitoring_plan.errors %} - <span>{{ error }}</span> - {% endfor %} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.monitoring_plan.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} + {# Removed potential stray 'p' tag here #} </div> </div> </div> @@ -214,7 +243,7 @@ <h2 class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-200">Attachments</h2> <div> <label for="attachments" class="block text-sm font-medium text-gray-700 mb-1">Upload New Files (Optional)</label> - {{ form.attachments(class="block w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-400 cursor-pointer focus:outline-none", multiple=True) }} + {{ form.attachments(class="block w-full text-sm text-gray-900 bg-gray-50 rounded-lg border cursor-pointer focus:outline-none " + ('border-red-500 ring-red-500' if form.attachments.errors else 'border-gray-300'), multiple=True) }} <p class="text-xs text-gray-500 mt-1"> Accepted file types: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG (Max 5MB per file). Hold Ctrl/Cmd to select multiple files. </p> @@ -260,7 +289,7 @@ <div class="space-y-6"> <div> <label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status*</label> - {{ form.status(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50") }} + {{ form.status(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 " + ('border-red-500 ring-red-500' if form.status.errors else 'border-gray-300')) }} {% if form.status.errors %} <div class="text-red-500 text-sm mt-1"> {% for error in form.status.errors %} @@ -272,7 +301,7 @@ <div> <label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Internal Notes (Optional)</label> - {{ form.notes(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50", rows=4) }} + {{ form.notes(class="w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50", rows=4) }} <p class="text-xs text-gray-500 mt-1"> These notes are for internal use only and won't be included in the final report. </p> @@ -292,12 +321,10 @@ <h2 class="text-lg font-semibold text-gray-700 mb-4 pb-2 border-b border-gray-200">Intervention Summary</h2> <div> <label for="intervention_summary" class="block text-sm font-medium text-gray-700 mb-1">Intervention Summary {% if form.intervention_summary.flags.required %}*{% endif %}</label> - {{ form.intervention_summary(class="w-full rounded-md border-gray-400 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50", rows=4) }} + {{ form.intervention_summary(class="w-full rounded-md border shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 " + ('border-red-500 ring-red-500' if form.intervention_summary.errors else 'border-gray-300'), rows=4) }} {% if form.intervention_summary.errors %} - <div class="text-red-500 text-sm mt-1"> - {% for error in form.intervention_summary.errors %} - <span>{{ error }}</span> - {% endfor %} + <div class="mt-1 text-red-600 text-sm"> + {% for error in form.intervention_summary.errors %}<span>{{ error }}</span>{% endfor %} </div> {% endif %} </div> @@ -305,29 +332,35 @@ <!-- Form Actions --> <div class="flex justify-end space-x-4 pt-6 border-t border-gray-200"> + {# Simplify cancel URL to always go back to the report index #} {% set cancel_url = url_for('report.index') %} - {% if report %} - {% set cancel_url = url_for('report.view_report', report_id=report.id) %} - {% elif request.args.get('patient_id') %} - {% set cancel_url = url_for('patients.patient_detail', patient_id=request.args.get('patient_id'), _anchor='reports') %} - {% endif %} <button type="button" onclick="window.location.href='{{ cancel_url }}'" class="bg-white border border-gray-300 hover:bg-gray-100 text-gray-700 font-medium py-2 px-5 rounded-md transition duration-300 shadow-sm hover:shadow"> Cancel </button> - {% if report and report.status == 'draft' %} + {# Nút Lưu bản nháp #} <button type="submit" name="action" value="save_draft" class="bg-yellow-500 hover:bg-yellow-600 text-white font-medium py-2 px-5 rounded-md transition duration-300 shadow hover:shadow-md"> - Save Draft + <i class="fas fa-save mr-2"></i> Save Draft </button> - <button type="submit" name="action" value="finalize" class="bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-5 rounded-md transition duration-300 shadow hover:shadow-md"> - Finalize Report + {# Nút Hoàn thành - Chỉ hiện khi status là Pending và user có quyền #} + {% if report and report.status in ['Draft', 'Pending'] and (current_user.is_admin or current_user.userID == report.author_id or (report.patient and current_user.userID == report.patient.assigned_dietitian_user_id)) %} + <button type="submit" name="action" value="complete" class="bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-5 rounded-md transition duration-300 shadow hover:shadow-md"> + <i class="fas fa-check-circle mr-2"></i> Complete Report </button> - {% else %} + {% endif %} + + {# Nút Cập nhật (khi edit và không phải Pending) hoặc Tạo mới #} + {# {% if report and report.status != 'Pending' %} <button type="submit" name="action" value="save" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-md transition duration-300 shadow hover:shadow-md"> {% if report %}Update Report{% else %}Create Report{% endif %} </button> - {% endif %} + {% elif not report %} + <button type="submit" name="action" value="save" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-md transition duration-300 shadow hover:shadow-md"> + Create Report + </button> + {% endif %} #} + {# Logic nút Save/Update/Create có thể cần xem lại. Hiện tại chỉ có Save Draft và Complete #} </div> </form> </div> @@ -345,6 +378,41 @@ const encounterInfo = document.getElementById('encounter-info'); const encounterIdField = document.querySelector('input[name="encounter_id"]'); + // Thêm handler cho nút Complete Report + const completeButtons = document.querySelectorAll('button.needs-confirmation'); + completeButtons.forEach(function(button) { + button.addEventListener('click', function(e) { + e.preventDefault(); // Ngăn form submit ngay lập tức + + // Lấy thông báo xác nhận từ thuộc tính data + const confirmMessage = this.getAttribute('data-confirmation-message') || 'Bạn có chắc chắn muốn thực hiện?'; + + // Hiển thị hộp thoại xác nhận + if (confirm(confirmMessage)) { + // Người dùng đã xác nhận, cho phép form submit + console.log("Xác nhận hoàn thành báo cáo, tiếp tục submit form"); + + // Vô hiệu hóa nút + this.disabled = true; + this.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Đang xử lý...'; + + // Lưu nội dung TinyMCE nếu có + if (typeof tinymce !== 'undefined') { + try { + tinymce.triggerSave(); + } catch (error) { + console.error("Lỗi khi lưu nội dung TinyMCE:", error); + } + } + + // Submit form + this.form.submit(); + } else { + console.log("Người dùng đã hủy hành động"); + } + }); + }); + function updateReferrals(patient_id) { if (!referralSelect) return; referralSelect.innerHTML = '<option value="">Loading referrals...</option>'; @@ -502,7 +570,7 @@ }, 700); // Adjust delay if needed } // --- End Initial Load Logic --- - } + }, document.querySelectorAll('.delete-attachment').forEach(button => { button.addEventListener('click', function() { @@ -573,6 +641,30 @@ console.log("Form submit proceeding..."); }); } + + // --- Scroll to first error --- + // Assign the JSON value directly, ensure it's valid JSON string or null + let firstErrorFieldId = {{ first_error_field_id|default(none)|tojson|safe }}; + if (firstErrorFieldId !== null) { // Check against null explicitly + const errorElement = document.getElementById(firstErrorFieldId); + if (errorElement) { + setTimeout(() => { + console.log('Scrolling to error field:', firstErrorFieldId); + const fieldWrapper = errorElement.closest('.relative') || errorElement.closest('div') || errorElement; + fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); + errorElement.focus({ preventScroll: true }); + if (errorElement.classList.contains('rich-editor')) { + const editorInstance = tinymce.get(firstErrorFieldId); + if (editorInstance) { + console.log('Focusing TinyMCE instance:', firstErrorFieldId); + editorInstance.focus(); + } + } + }, 100); + } else { + console.warn('Could not find error element with ID:', firstErrorFieldId); + } + } }); </script> {% endblock %} \ No newline at end of file diff --git a/app/templates/support.html b/app/templates/support.html new file mode 100644 index 0000000000000000000000000000000000000000..5b7ca1270ed16b15a8464ec3a282b90a67df2715 --- /dev/null +++ b/app/templates/support.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} +{% from '_macros.html' import flash_messages %} + +{% block title %}Support - {{ super() }}{% endblock %} + +{% block header %}Support Center{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8"> + {{ flash_messages() }} + + <div class="bg-white shadow-lg rounded-lg overflow-hidden"> + <!-- Message Display Area --> + <div class="h-[60vh] overflow-y-auto p-6 space-y-4 bg-gray-50 flex flex-col" id="message-list"> + {% if messages %} + {% for message in messages | reverse %} + <div class="flex {{ 'justify-end' if message.sender_id == current_user.userID else 'justify-start' }}"> + <div class="max-w-xs lg:max-w-md px-4 py-3 rounded-lg shadow {{ 'bg-blue-500 text-white' if message.sender_id == current_user.userID else 'bg-gray-200 text-gray-800' }}"> + <p class="text-sm font-semibold mb-1">{{ message.sender.full_name }}</p> + <p class="text-sm mb-1">{{ message.content }}</p> + <p class="text-xs {{ 'text-blue-100' if message.sender_id == current_user.userID else 'text-gray-500' }} text-right"> + {{ message.timestamp.strftime('%d/%m/%Y %H:%M') }} + </p> + </div> + </div> + {% endfor %} + {% else %} + <p class="text-center text-gray-500 mt-auto mb-auto">No support messages yet. Start the conversation!</p> + {% endif %} + </div> + + <!-- Message Input Form --> + <div class="p-6 bg-white border-t border-gray-200"> + <form method="POST" action="{{ url_for('.index') }}" class="flex items-center"> + {{ form.hidden_tag() }} + <div class="flex-grow mr-4"> + {{ form.content(class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + (' border-red-500' if form.content.errors else ' border-gray-300'), rows="1", placeholder="Type your message...") }} + {% if form.content.errors %} + <div class="text-red-500 text-sm mt-1"> + {% for error in form.content.errors %}<span>{{ error }}</span>{% endfor %} + </div> + {% endif %} + </div> + <button type="submit" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg shadow focus:outline-none focus:shadow-outline transition duration-300"> + Send + </button> + </form> + </div> + </div> +</div> +{% endblock %} + +{% block scripts %} +{{ super() }} +<script> +document.addEventListener('DOMContentLoaded', function() { + // Scroll to the bottom of the message list on page load + const messageList = document.getElementById('message-list'); + if (messageList) { + messageList.scrollTop = messageList.scrollHeight; + } +}); +</script> +{% endblock %} \ No newline at end of file diff --git a/migrations/versions/af3981c4f7e5_add_chat.py b/migrations/versions/af3981c4f7e5_add_chat.py new file mode 100644 index 0000000000000000000000000000000000000000..690999d6537b1d2ff1b457b2189d7aac83de0889 --- /dev/null +++ b/migrations/versions/af3981c4f7e5_add_chat.py @@ -0,0 +1,58 @@ +"""add chat + +Revision ID: af3981c4f7e5 +Revises: eaf7e2cfc895 +Create Date: 2025-04-20 19:25:34.946616 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'af3981c4f7e5' +down_revision = 'eaf7e2cfc895' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('support_messages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['sender_id'], ['users.userID'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('support_message_read_status', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('message_id', sa.Integer(), nullable=False), + sa.Column('read_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['message_id'], ['support_messages.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.userID'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'message_id', name='uq_user_message_read') + ) + with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: + batch_op.alter_column('error_details', + existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), + type_=sa.Text(length=16777215), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: + batch_op.alter_column('error_details', + existing_type=sa.Text(length=16777215), + type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), + existing_nullable=True) + + op.drop_table('support_message_read_status') + op.drop_table('support_messages') + # ### end Alembic commands ###