Compare commits
No commits in common. "9ea74e47efa14107e44b322fb345eb41324b2ba9" and "ae4afbcca9f8e73f08638efe47ce21d6a0c66b37" have entirely different histories.
9ea74e47ef
...
ae4afbcca9
68
.env.bak_qa
68
.env.bak_qa
@ -1,68 +0,0 @@
|
||||
# Django Settings
|
||||
DEBUG=True
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgres://px360:px360@localhost:5433/px360
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
||||
CELERY_TASK_ALWAYS_EAGER=False
|
||||
|
||||
# Email Configuration
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST=smtp-mail.outlook.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=px360@hh.med.sa
|
||||
EMAIL_HOST_PASSWORD=A-Za-z0-9!@#$%^&*
|
||||
DEFAULT_FROM_EMAIL=px360@hh.med.sa
|
||||
|
||||
# AI Configuration (LiteLLM with OpenRouter)
|
||||
OPENROUTER_API_KEY=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
|
||||
AI_MODEL=openrouter/google/gemini-2.5-flash-lite
|
||||
# AI_MODEL=z-ai/glm-4.5-air:free
|
||||
AI_TEMPERATURE=0.3
|
||||
AI_MAX_TOKENS=500
|
||||
|
||||
# Notification Channels
|
||||
SMS_ENABLED=False
|
||||
SMS_PROVIDER=mshastra
|
||||
WHATSAPP_ENABLED=False
|
||||
WHATSAPP_PROVIDER=console
|
||||
EMAIL_ENABLED=True
|
||||
EMAIL_PROVIDER=smtp
|
||||
|
||||
# Admin URL (change in production)
|
||||
ADMIN_URL=admin/
|
||||
|
||||
# Integration APIs (Stubs - Replace with actual credentials)
|
||||
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
|
||||
# HIS_API_URL=http://localhost:8000/api/simulator/generate-visit/
|
||||
HIS_RATINGS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchDoctorRatingMAPI
|
||||
HIS_API_USERNAME=AlhhSUNZHippo
|
||||
# HIS_API_PASSWORD is set in config/settings/base.py (contains special characters)
|
||||
MOH_API_URL=
|
||||
MOH_API_KEY=
|
||||
CHI_API_URL=
|
||||
CHI_API_KEY=
|
||||
|
||||
EMAIL_API_ENABLED=false
|
||||
SMS_API_ENABLED=false
|
||||
EMAIL_API_URL=http://localhost:8000/api/simulator/send-email/
|
||||
SMS_API_URL=http://localhost:8000/api/simulator/send-sms/
|
||||
EMAIL_API_KEY=simulator-test-keyrom
|
||||
SMS_API_KEY=simulator-test-key
|
||||
|
||||
# Dev SMS Recipient - all SMS redirected to this number in development
|
||||
DEV_SMS_RECIPIENT=+966566703794
|
||||
|
||||
# Session Settings - prevent logout on server reload
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE=False
|
||||
|
||||
# Mshastra SMS API
|
||||
MSHASTRA_USERNAME=M556999091
|
||||
MSHASTRA_PASSWORD=4bpem6su
|
||||
MSHASTRA_SENDER_ID=M556999091
|
||||
@ -2,7 +2,6 @@
|
||||
DEBUG=True
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
SITE_URL=http://localhost:8000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://px360:px360@db:5432/px360
|
||||
|
||||
@ -11,7 +11,6 @@ DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||
DEBUG=False
|
||||
SECRET_KEY=CHANGE-ME-generate-with-python3-c-from-django.core.management.utils-import-get_random_secret_key-print-get_random_secret_key
|
||||
ALLOWED_HOSTS=your-production-domain.com
|
||||
SITE_URL=https://your-production-domain.com
|
||||
ADMIN_URL=CHANGE-ME-use-a-non-obvious-url/
|
||||
|
||||
# --- PostgreSQL (External Server) ---
|
||||
|
||||
@ -10,7 +10,6 @@ DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||
DEBUG=False
|
||||
SECRET_KEY=CHANGE-ME-generate-with-python3-c-from-django.core.management.utils-import-get_random_secret_key-print-get_random_secret_key
|
||||
ALLOWED_HOSTS=px360test.tenhal.sa
|
||||
SITE_URL=https://px360test.tenhal.sa
|
||||
ADMIN_URL=admin/
|
||||
|
||||
# --- PostgreSQL ---
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -18,10 +18,6 @@ lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
# Database backups
|
||||
backups/*.dump
|
||||
backups/*.tar.gz
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
@ -103,5 +99,4 @@ dump.rdb
|
||||
.~lock.*
|
||||
|
||||
node_modules
|
||||
data/Documents
|
||||
deploy/target.dev-home.yaml
|
||||
data/Documents
|
||||
@ -1,17 +0,0 @@
|
||||
# Fix: Hospital select not visible on /config/users/create/
|
||||
|
||||
## Problem
|
||||
The `HospitalFieldMixin` in `apps/core/form_mixins.py:44` sets `hospital_field.widget = forms.HiddenInput()`, hiding the hospital field. This mixin is designed for forms where hospital is auto-set from user context, but for user creation we need a visible dropdown.
|
||||
|
||||
## Fix
|
||||
Remove `HospitalFieldMixin` from `UserCreateForm` and `UserEditForm` in `apps/accounts/forms.py`. Keep `DepartmentFieldMixin` for department filtering.
|
||||
|
||||
The forms already define `hospital` as a `ModelChoiceField` with a `Select` widget — removing the mixin will let it render normally.
|
||||
|
||||
## Files to modify
|
||||
- `apps/accounts/forms.py` — Remove `HospitalFieldMixin` from both form classes, adjust `__init__` to not pop `request`/`user` kwargs (since `HospitalFieldMixin` no longer handles that)
|
||||
|
||||
## Verification
|
||||
- Navigate to `/config/users/create/` — hospital dropdown should be visible
|
||||
- PX Admin sees all active hospitals
|
||||
- Hospital Admin sees only their hospital (can pre-set it in the view)
|
||||
@ -1,39 +0,0 @@
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "HH Logo" [ref=e6]
|
||||
- heading "Welcome to PX360" [level=1] [ref=e7]
|
||||
- paragraph [ref=e8]: Patient Experience Management System
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]:
|
||||
- img [ref=e13]
|
||||
- text: Email Address
|
||||
- generic [ref=e16]:
|
||||
- img [ref=e17]
|
||||
- textbox "Email Address" [active] [ref=e20]:
|
||||
- /placeholder: Enter your email
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- img [ref=e23]
|
||||
- text: Password
|
||||
- generic [ref=e26]:
|
||||
- img [ref=e27]
|
||||
- textbox "Password" [ref=e31]:
|
||||
- /placeholder: Enter your password
|
||||
- button [ref=e32] [cursor=pointer]:
|
||||
- img [ref=e33]
|
||||
- generic [ref=e36]:
|
||||
- generic [ref=e37] [cursor=pointer]:
|
||||
- checkbox "Remember me" [ref=e38]
|
||||
- generic [ref=e39]: Remember me
|
||||
- link "Forgot password?" [ref=e40] [cursor=pointer]:
|
||||
- /url: /accounts/password/reset/
|
||||
- button "Sign In" [ref=e41] [cursor=pointer]:
|
||||
- img [ref=e42]
|
||||
- text: Sign In
|
||||
- generic [ref=e45]:
|
||||
- paragraph [ref=e46]:
|
||||
- text: Secure login powered by
|
||||
- link "tenhal.sa" [ref=e47] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
- paragraph [ref=e48]: © 2026 Al Hammadi Hospital
|
||||
@ -1,41 +0,0 @@
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "HH Logo" [ref=e6]
|
||||
- heading "Welcome to PX360" [level=1] [ref=e7]
|
||||
- paragraph [ref=e8]: Patient Experience Management System
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]:
|
||||
- img [ref=e49]
|
||||
- text: Email Address
|
||||
- generic [ref=e16]:
|
||||
- img [ref=e52]
|
||||
- textbox "Email Address" [ref=e20]:
|
||||
- /placeholder: Enter your email
|
||||
- text: e2e-px-employee@px360.test
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- img [ref=e55]
|
||||
- text: Password
|
||||
- generic [ref=e26]:
|
||||
- img [ref=e58]
|
||||
- textbox "Password" [ref=e31]:
|
||||
- /placeholder: Enter your password
|
||||
- text: Dev@123456
|
||||
- button [active] [ref=e32] [cursor=pointer]:
|
||||
- img [ref=e62]
|
||||
- generic [ref=e36]:
|
||||
- generic [ref=e37] [cursor=pointer]:
|
||||
- checkbox "Remember me" [ref=e38]
|
||||
- generic [ref=e39]: Remember me
|
||||
- link "Forgot password?" [ref=e40] [cursor=pointer]:
|
||||
- /url: /accounts/password/reset/
|
||||
- button "Sign In" [ref=e41] [cursor=pointer]:
|
||||
- img [ref=e67]
|
||||
- text: Sign In
|
||||
- generic [ref=e45]:
|
||||
- paragraph [ref=e46]:
|
||||
- text: Secure login powered by
|
||||
- link "tenhal.sa" [ref=e47] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
- paragraph [ref=e48]: © 2026 Al Hammadi Hospital
|
||||
@ -1,227 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Departments" [ref=e141] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: Contact Center Department
|
||||
- generic [ref=e147]:
|
||||
- generic [ref=e148]:
|
||||
- img [ref=e150]
|
||||
- generic [ref=e154]:
|
||||
- heading "Contact Center Department" [level=1] [ref=e155]
|
||||
- paragraph [ref=e156]: قسم مركز الاتصال
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e158]:
|
||||
- img [ref=e159]
|
||||
- text: hh_n_contact_center_department
|
||||
- generic [ref=e162]: Administrative
|
||||
- generic [ref=e163]:
|
||||
- img [ref=e164]
|
||||
- text: E2E Test Hospital
|
||||
- generic [ref=e168]:
|
||||
- generic [ref=e170]:
|
||||
- img [ref=e172]
|
||||
- generic [ref=e177]:
|
||||
- paragraph [ref=e178]: Staff
|
||||
- paragraph [ref=e179]: "4"
|
||||
- link "Open Complaints 3 3 total" [ref=e180] [cursor=pointer]:
|
||||
- /url: /organizations/departments/d810102c-d14a-4661-8672-6ef85a77fc8f/complaints/?status=open
|
||||
- generic [ref=e181]:
|
||||
- img [ref=e183]
|
||||
- generic [ref=e186]:
|
||||
- paragraph [ref=e187]: Open Complaints
|
||||
- paragraph [ref=e188]: "3"
|
||||
- paragraph [ref=e189]: 3 total
|
||||
- link "Pending Inquiries 0 2 total" [ref=e190] [cursor=pointer]:
|
||||
- /url: /organizations/departments/d810102c-d14a-4661-8672-6ef85a77fc8f/inquiries/?status=open
|
||||
- generic [ref=e191]:
|
||||
- img [ref=e193]
|
||||
- generic [ref=e196]:
|
||||
- paragraph [ref=e197]: Pending Inquiries
|
||||
- paragraph [ref=e198]: "0"
|
||||
- paragraph [ref=e199]: 2 total
|
||||
- link "Open Observations 1 1 total" [ref=e200] [cursor=pointer]:
|
||||
- /url: /organizations/departments/d810102c-d14a-4661-8672-6ef85a77fc8f/observations/?status=open
|
||||
- generic [ref=e201]:
|
||||
- img [ref=e203]
|
||||
- generic [ref=e206]:
|
||||
- paragraph [ref=e207]: Open Observations
|
||||
- paragraph [ref=e208]: "1"
|
||||
- paragraph [ref=e209]: 1 total
|
||||
- generic [ref=e210]:
|
||||
- generic [ref=e211]:
|
||||
- img [ref=e213]
|
||||
- generic [ref=e215]:
|
||||
- heading "Pending Actions" [level=3] [ref=e216]
|
||||
- paragraph [ref=e217]: 3 items awaiting response
|
||||
- table [ref=e219]:
|
||||
- rowgroup [ref=e220]:
|
||||
- row "Type Reference Subject SLA Deadline Action" [ref=e221]:
|
||||
- columnheader "Type" [ref=e222]
|
||||
- columnheader "Reference" [ref=e223]
|
||||
- columnheader "Subject" [ref=e224]
|
||||
- columnheader "SLA Deadline" [ref=e225]
|
||||
- columnheader "Action" [ref=e226]
|
||||
- rowgroup [ref=e227]:
|
||||
- row "Complaint Response CMP-202607-0006 Test Complaint - Analysis Required - Respond" [ref=e228]:
|
||||
- cell "Complaint Response" [ref=e229]:
|
||||
- generic [ref=e230]: Complaint Response
|
||||
- cell "CMP-202607-0006" [ref=e231]
|
||||
- cell "Test Complaint - Analysis Required" [ref=e232]
|
||||
- cell "-" [ref=e233]
|
||||
- cell "Respond" [ref=e234]:
|
||||
- link "Respond" [ref=e236] [cursor=pointer]:
|
||||
- /url: /complaints/f8aa6216-bb94-455e-ba18-d16a01024662/explain/dM0ilaUpzA1OuAceyjvwV9oxYA0geW_jsgipvrYUt_g/
|
||||
- img [ref=e237]
|
||||
- text: Respond
|
||||
- row "Complaint Explanation CMP-202607-0005 Test Complaint - No Details - Respond" [ref=e239]:
|
||||
- cell "Complaint Explanation" [ref=e240]:
|
||||
- generic [ref=e241]: Complaint Explanation
|
||||
- cell "CMP-202607-0005" [ref=e242]
|
||||
- cell "Test Complaint - No Details" [ref=e243]
|
||||
- cell "-" [ref=e244]
|
||||
- cell "Respond" [ref=e245]:
|
||||
- link "Respond" [ref=e247] [cursor=pointer]:
|
||||
- /url: /complaints/9ca01d52-712d-4bca-b30d-6f1f818ff839/explain/iBIf0PrMq1RZYSEIDxSn4WZik4PFk6dunoNwarFTnXI/
|
||||
- img [ref=e248]
|
||||
- text: Respond
|
||||
- row "Complaint Explanation CMP-202607-0005 Test Complaint - No Details - Respond" [ref=e252]:
|
||||
- cell "Complaint Explanation" [ref=e253]:
|
||||
- generic [ref=e254]: Complaint Explanation
|
||||
- cell "CMP-202607-0005" [ref=e255]
|
||||
- cell "Test Complaint - No Details" [ref=e256]
|
||||
- cell "-" [ref=e257]
|
||||
- cell "Respond" [ref=e258]:
|
||||
- link "Respond" [ref=e260] [cursor=pointer]:
|
||||
- /url: /complaints/9ca01d52-712d-4bca-b30d-6f1f818ff839/explain/X1Zd54Tb1NcExZeRr6ACN5RtPCwB_RTKeqNeFcVP24E/
|
||||
- img [ref=e261]
|
||||
- text: Respond
|
||||
- generic [ref=e266]:
|
||||
- button "Dashboard" [ref=e267] [cursor=pointer]:
|
||||
- img [ref=e268]
|
||||
- text: Dashboard
|
||||
- button "Staff (4)" [ref=e270] [cursor=pointer]:
|
||||
- img [ref=e271]
|
||||
- text: Staff (4)
|
||||
- button "Roles" [ref=e276] [cursor=pointer]:
|
||||
- img [ref=e277]
|
||||
- text: Roles
|
||||
- button "Sections (2)" [ref=e289] [cursor=pointer]:
|
||||
- img [ref=e290]
|
||||
- text: Sections (2)
|
||||
- button "Complaints (3)" [ref=e294] [cursor=pointer]:
|
||||
- img [ref=e295]
|
||||
- text: Complaints (3)
|
||||
- button "Inquiries (2)" [ref=e298] [cursor=pointer]:
|
||||
- img [ref=e299]
|
||||
- text: Inquiries (2)
|
||||
- button "Observations (1)" [ref=e302] [cursor=pointer]:
|
||||
- img [ref=e303]
|
||||
- text: Observations (1)
|
||||
- button "Suggestions (1)" [ref=e306] [cursor=pointer]:
|
||||
- img [ref=e307]
|
||||
- text: Suggestions (1)
|
||||
- button "Appreciations (2)" [ref=e309] [cursor=pointer]:
|
||||
- img [ref=e310]
|
||||
- text: Appreciations (2)
|
||||
- button "Standards (0)" [ref=e312] [cursor=pointer]:
|
||||
- img [ref=e313]
|
||||
- text: Standards (0)
|
||||
- button "Projects (8)" [ref=e316] [cursor=pointer]:
|
||||
- img [ref=e317]
|
||||
- text: Projects (8)
|
||||
- paragraph [ref=e324]: Loading analytics...
|
||||
- contentinfo [ref=e325]:
|
||||
- paragraph [ref=e326]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e327] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,258 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Inquiries Console" [level=1] [ref=e142]
|
||||
- paragraph [ref=e143]: Manage patient inquiries and requests
|
||||
- link "New Inquiry" [ref=e144] [cursor=pointer]:
|
||||
- /url: /inquiries/new/
|
||||
- img [ref=e145]
|
||||
- text: New Inquiry
|
||||
- generic [ref=e146]:
|
||||
- generic [ref=e147]:
|
||||
- img [ref=e150]
|
||||
- generic [ref=e153]:
|
||||
- heading "Total Inquiries" [level=6] [ref=e154]
|
||||
- heading "5" [level=2] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- img [ref=e159]
|
||||
- generic [ref=e162]:
|
||||
- heading "Resolved" [level=6] [ref=e163]
|
||||
- heading "2 (40.0%)" [level=2] [ref=e164]
|
||||
- generic [ref=e165]:
|
||||
- img [ref=e168]
|
||||
- generic [ref=e171]:
|
||||
- heading "In Progress" [level=6] [ref=e172]
|
||||
- heading "1" [level=2] [ref=e173]
|
||||
- generic [ref=e174]:
|
||||
- img [ref=e177]
|
||||
- generic [ref=e179]:
|
||||
- heading "Overdue" [level=6] [ref=e180]
|
||||
- heading "0" [level=2] [ref=e181]
|
||||
- generic [ref=e182]:
|
||||
- generic [ref=e183]:
|
||||
- img [ref=e185]
|
||||
- heading "Filters" [level=5] [ref=e186]
|
||||
- generic [ref=e188]:
|
||||
- generic [ref=e189]:
|
||||
- generic [ref=e190]: Search
|
||||
- textbox "Subject, contact name..." [ref=e191]
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]: Status
|
||||
- combobox [ref=e194]:
|
||||
- option "All Status" [selected]
|
||||
- option "Open"
|
||||
- option "In Progress"
|
||||
- option "Resolved"
|
||||
- option "Closed"
|
||||
- generic [ref=e195]:
|
||||
- generic [ref=e196]: Category
|
||||
- combobox [ref=e197]:
|
||||
- option "All Categories" [selected]
|
||||
- option "General"
|
||||
- option "Appointments"
|
||||
- option "Billing"
|
||||
- option "Medical Records"
|
||||
- option "Pharmacy"
|
||||
- option "Insurance"
|
||||
- option "Feedback"
|
||||
- option "Other"
|
||||
- button "Filter" [ref=e198] [cursor=pointer]:
|
||||
- img [ref=e199]
|
||||
- text: Filter
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]:
|
||||
- generic [ref=e204]:
|
||||
- img [ref=e206]
|
||||
- heading "All Inquiries" [level=5] [ref=e209]
|
||||
- generic [ref=e210]: "Total: 5"
|
||||
- paragraph [ref=e211]: "Showing: 1-5 of 5"
|
||||
- generic [ref=e212]:
|
||||
- table [ref=e213]:
|
||||
- rowgroup [ref=e214]:
|
||||
- row "Reference Subject Contact Category Status Priority Created Assigned" [ref=e215]:
|
||||
- columnheader "Reference" [ref=e216]
|
||||
- columnheader "Subject" [ref=e217]
|
||||
- columnheader "Contact" [ref=e218]
|
||||
- columnheader "Category" [ref=e219]
|
||||
- columnheader "Status" [ref=e220]
|
||||
- columnheader "Priority" [ref=e221]
|
||||
- columnheader "Created" [ref=e222]
|
||||
- columnheader "Assigned" [ref=e223]
|
||||
- rowgroup [ref=e224]:
|
||||
- row "INQ-202607-0007 test12121212 ismail mosa 0569874563 General Information Open Medium Jul 12, 2026 Unassigned" [ref=e225] [cursor=pointer]:
|
||||
- cell "INQ-202607-0007" [ref=e226]:
|
||||
- code [ref=e227]: INQ-202607-0007
|
||||
- cell "test12121212" [ref=e228]:
|
||||
- link "test12121212" [ref=e229]:
|
||||
- /url: /inquiries/b4a65498-e8d8-403c-82bb-ae09adc7fc1c/
|
||||
- cell "ismail mosa 0569874563" [ref=e230]:
|
||||
- generic [ref=e231]: ismail mosa
|
||||
- generic [ref=e232]: "0569874563"
|
||||
- cell "General Information" [ref=e233]
|
||||
- cell "Open" [ref=e234]
|
||||
- cell "Medium" [ref=e235]
|
||||
- cell "Jul 12, 2026" [ref=e236]
|
||||
- cell "Unassigned" [ref=e237]
|
||||
- row "INQ-202607-0006 test12121212 test 0569874563 General Information Open Medium Jul 12, 2026 Unassigned" [ref=e238] [cursor=pointer]:
|
||||
- cell "INQ-202607-0006" [ref=e239]:
|
||||
- code [ref=e240]: INQ-202607-0006
|
||||
- cell "test12121212" [ref=e241]:
|
||||
- link "test12121212" [ref=e242]:
|
||||
- /url: /inquiries/1c3eda08-ff03-4521-bcb8-ec137c79b417/
|
||||
- cell "test 0569874563" [ref=e243]:
|
||||
- generic [ref=e244]: test
|
||||
- generic [ref=e245]: "0569874563"
|
||||
- cell "General Information" [ref=e246]
|
||||
- cell "Open" [ref=e247]
|
||||
- cell "Medium" [ref=e248]
|
||||
- cell "Jul 12, 2026" [ref=e249]
|
||||
- cell "Unassigned" [ref=e250]
|
||||
- row "INQ-202607-0005 test test 0569874589 General Information In Progress Medium Jul 09, 2026 Unassigned" [ref=e251] [cursor=pointer]:
|
||||
- cell "INQ-202607-0005" [ref=e252]:
|
||||
- code [ref=e253]: INQ-202607-0005
|
||||
- cell "test" [ref=e254]:
|
||||
- link "test" [ref=e255]:
|
||||
- /url: /inquiries/c05309c2-3518-46ae-9b1c-c144acdb5d58/
|
||||
- cell "test 0569874589" [ref=e256]:
|
||||
- generic [ref=e257]: test
|
||||
- generic [ref=e258]: "0569874589"
|
||||
- cell "General Information" [ref=e259]
|
||||
- cell "In Progress" [ref=e260]
|
||||
- cell "Medium" [ref=e261]
|
||||
- cell "Jul 09, 2026" [ref=e262]
|
||||
- cell "Unassigned" [ref=e263]
|
||||
- row "INQ-202607-0004 hi ismail 0569874589 General Information Resolved Medium Jul 09, 2026 ismail mosa" [ref=e264] [cursor=pointer]:
|
||||
- cell "INQ-202607-0004" [ref=e265]:
|
||||
- code [ref=e266]: INQ-202607-0004
|
||||
- cell "hi" [ref=e267]:
|
||||
- link "hi" [ref=e268]:
|
||||
- /url: /inquiries/d1df0b83-5c16-4f51-b735-e89f612deb50/
|
||||
- cell "ismail 0569874589" [ref=e269]:
|
||||
- generic [ref=e270]: ismail
|
||||
- generic [ref=e271]: "0569874589"
|
||||
- cell "General Information" [ref=e272]
|
||||
- cell "Resolved" [ref=e273]
|
||||
- cell "Medium" [ref=e274]
|
||||
- cell "Jul 09, 2026" [ref=e275]
|
||||
- cell "ismail mosa" [ref=e276]
|
||||
- row "INQ-202607-0003 test test 0569874589 General Information Resolved Medium Jul 08, 2026 ismail mosa" [ref=e277] [cursor=pointer]:
|
||||
- cell "INQ-202607-0003" [ref=e278]:
|
||||
- code [ref=e279]: INQ-202607-0003
|
||||
- cell "test" [ref=e280]:
|
||||
- link "test" [ref=e281]:
|
||||
- /url: /inquiries/031cbd06-e2dc-4cd9-9b81-b1218e090d6c/
|
||||
- cell "test 0569874589" [ref=e282]:
|
||||
- generic [ref=e283]: test
|
||||
- generic [ref=e284]: "0569874589"
|
||||
- cell "General Information" [ref=e285]
|
||||
- cell "Resolved" [ref=e286]
|
||||
- cell "Medium" [ref=e287]
|
||||
- cell "Jul 08, 2026" [ref=e288]
|
||||
- cell "ismail mosa" [ref=e289]
|
||||
- generic [ref=e290]:
|
||||
- generic [ref=e291]:
|
||||
- generic [ref=e292]: Showing 1-5 of 5
|
||||
- generic [ref=e293]:
|
||||
- generic [ref=e294]: Show
|
||||
- combobox [ref=e295]:
|
||||
- option "10"
|
||||
- option "25" [selected]
|
||||
- option "50"
|
||||
- option "100"
|
||||
- generic [ref=e296]:
|
||||
- img [ref=e298]
|
||||
- generic [ref=e300]: "1"
|
||||
- img [ref=e302]
|
||||
- contentinfo [ref=e304]:
|
||||
- paragraph [ref=e305]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e306] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,172 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Inquiries" [ref=e141] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: INQ-202607-0007
|
||||
- generic [ref=e145]: Open
|
||||
- generic [ref=e146]: Medium
|
||||
- generic [ref=e147]:
|
||||
- img [ref=e148]
|
||||
- text: Not Sent to Dept
|
||||
- generic [ref=e150]:
|
||||
- heading "test12121212" [level=1] [ref=e151]
|
||||
- generic [ref=e153]:
|
||||
- img [ref=e154]
|
||||
- text: 2026-07-12
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e158]:
|
||||
- img [ref=e160]
|
||||
- generic [ref=e162]: Created
|
||||
- generic [ref=e164]:
|
||||
- img [ref=e166]
|
||||
- generic [ref=e169]: Assigned
|
||||
- generic [ref=e171]:
|
||||
- img [ref=e173]
|
||||
- generic [ref=e176]: Sent to Dept
|
||||
- generic [ref=e178]:
|
||||
- img [ref=e180]
|
||||
- generic [ref=e182]: Dept Response
|
||||
- generic [ref=e184]:
|
||||
- img [ref=e186]
|
||||
- generic [ref=e189]: Resolved
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]:
|
||||
- img [ref=e193]
|
||||
- generic [ref=e195]:
|
||||
- paragraph [ref=e196]: Activate this inquiry
|
||||
- paragraph [ref=e197]: Activation moves it to In Progress so actions become available.
|
||||
- generic [ref=e198]:
|
||||
- button "Cancel" [ref=e200] [cursor=pointer]
|
||||
- button "Activate" [ref=e202] [cursor=pointer]
|
||||
- navigation [ref=e203]:
|
||||
- button "Details" [ref=e204] [cursor=pointer]
|
||||
- button "Timeline" [ref=e205] [cursor=pointer]
|
||||
- button "Notes" [ref=e206] [cursor=pointer]
|
||||
- main [ref=e207]:
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]:
|
||||
- generic [ref=e211]:
|
||||
- paragraph
|
||||
- paragraph [ref=e212]: "231313131"
|
||||
- paragraph
|
||||
- generic [ref=e213]:
|
||||
- generic [ref=e215]:
|
||||
- img [ref=e216]
|
||||
- generic [ref=e219]: AI Analysis
|
||||
- generic [ref=e220]:
|
||||
- paragraph [ref=e221]: "231313131"
|
||||
- paragraph [ref=e222]: "231313131"
|
||||
- generic [ref=e223]:
|
||||
- heading "Patient" [level=3] [ref=e224]:
|
||||
- img [ref=e225]
|
||||
- text: Patient
|
||||
- generic [ref=e228]:
|
||||
- generic [ref=e229]: ismail mosa
|
||||
- generic [ref=e230]: "|"
|
||||
- generic [ref=e231]:
|
||||
- img [ref=e232]
|
||||
- text: "0569874563"
|
||||
- generic [ref=e235]:
|
||||
- heading "Quick Actions" [level=3] [ref=e236]
|
||||
- generic [ref=e237]:
|
||||
- button "Activate" [ref=e239] [cursor=pointer]:
|
||||
- img [ref=e240]
|
||||
- generic [ref=e243]: Activate
|
||||
- button "Cancel Inquiry" [ref=e245] [cursor=pointer]:
|
||||
- img [ref=e246]
|
||||
- generic [ref=e250]: Cancel Inquiry
|
||||
- contentinfo [ref=e251]:
|
||||
- paragraph [ref=e252]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e253] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,344 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Observations" [level=1] [ref=e142]:
|
||||
- img [ref=e143]
|
||||
- text: Observations
|
||||
- paragraph [ref=e146]: Manage and triage staff-reported observations
|
||||
- generic [ref=e147]:
|
||||
- link "New Observation" [ref=e148] [cursor=pointer]:
|
||||
- /url: /observations/create/
|
||||
- img [ref=e149]
|
||||
- text: New Observation
|
||||
- link "Public Form" [ref=e151] [cursor=pointer]:
|
||||
- /url: /core/public/submit/
|
||||
- img [ref=e152]
|
||||
- text: Public Form
|
||||
- generic [ref=e153]:
|
||||
- generic [ref=e155]:
|
||||
- generic [ref=e156]:
|
||||
- paragraph [ref=e157]: Total
|
||||
- paragraph [ref=e158]: "2"
|
||||
- img [ref=e160]
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- paragraph [ref=e164]: New
|
||||
- paragraph
|
||||
- img [ref=e166]
|
||||
- generic [ref=e170]:
|
||||
- generic [ref=e171]:
|
||||
- paragraph [ref=e172]: In Progress
|
||||
- paragraph [ref=e173]: "2"
|
||||
- img [ref=e175]
|
||||
- generic [ref=e178]:
|
||||
- generic [ref=e179]:
|
||||
- img [ref=e181]
|
||||
- heading "Filters" [level=5] [ref=e183]
|
||||
- button [ref=e184] [cursor=pointer]:
|
||||
- img [ref=e185]
|
||||
- generic [ref=e188]:
|
||||
- generic [ref=e189]:
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: Search
|
||||
- textbox "Tracking code, description..." [ref=e192]
|
||||
- generic [ref=e193]:
|
||||
- generic [ref=e194]: Status
|
||||
- combobox [ref=e195]:
|
||||
- option "All Statuses" [selected]
|
||||
- option "Open"
|
||||
- option "In Progress"
|
||||
- option "Resolved"
|
||||
- option "Closed"
|
||||
- generic [ref=e196]:
|
||||
- generic [ref=e197]: Severity
|
||||
- combobox [ref=e198]:
|
||||
- option "All Severities" [selected]
|
||||
- option "Low"
|
||||
- option "Medium"
|
||||
- option "High"
|
||||
- option "Critical"
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: Category
|
||||
- combobox [ref=e201]:
|
||||
- option "All Categories" [selected]
|
||||
- option "Patient Safety"
|
||||
- option "Clinical Quality"
|
||||
- option "Infection Control"
|
||||
- option "Medication Safety"
|
||||
- option "Equipment & Devices"
|
||||
- option "Facility & Environment"
|
||||
- option "Staff Behavior"
|
||||
- option "Communication"
|
||||
- option "Documentation"
|
||||
- option "Process & Workflow"
|
||||
- option "Security"
|
||||
- option "IT & Systems"
|
||||
- option "Housekeeping"
|
||||
- option "Food Services"
|
||||
- option "Other"
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Department
|
||||
- combobox [ref=e204]:
|
||||
- option "All Departments" [selected]
|
||||
- option "Contact Center Department"
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Executive Administration"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Security Department"
|
||||
- option "Anesthesia Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Surgery Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Nursing Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Laundry Department"
|
||||
- generic [ref=e206] [cursor=pointer]:
|
||||
- generic [ref=e207]: All Departments
|
||||
- combobox "All Departments" [ref=e208]
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]: Assigned To
|
||||
- combobox [ref=e211]:
|
||||
- option "All Users" [selected]
|
||||
- option "E2E PX Employee"
|
||||
- option "test test"
|
||||
- option "test test"
|
||||
- generic [ref=e212]:
|
||||
- generic [ref=e213]: Date From
|
||||
- textbox [ref=e214]
|
||||
- generic [ref=e215]:
|
||||
- generic [ref=e216]: Date To
|
||||
- textbox [ref=e217]
|
||||
- generic [ref=e218]:
|
||||
- button "Apply Filters" [ref=e219] [cursor=pointer]:
|
||||
- img [ref=e220]
|
||||
- text: Apply Filters
|
||||
- link "Clear" [ref=e223] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e224]
|
||||
- text: Clear
|
||||
- generic [ref=e229]:
|
||||
- img [ref=e231]
|
||||
- heading "Observations List" [level=5] [ref=e233]
|
||||
- generic [ref=e234]: Showing 1 to 4 of 4 observations
|
||||
- table [ref=e237]:
|
||||
- rowgroup [ref=e238]:
|
||||
- row "Reference Date Category Description Severity Status Reporter Department Assigned Actions" [ref=e239]:
|
||||
- columnheader "Reference" [ref=e240]
|
||||
- columnheader "Date" [ref=e241]
|
||||
- columnheader "Category" [ref=e242]
|
||||
- columnheader "Description" [ref=e243]
|
||||
- columnheader "Severity" [ref=e244]
|
||||
- columnheader "Status" [ref=e245]
|
||||
- columnheader "Reporter" [ref=e246]
|
||||
- columnheader "Department" [ref=e247]
|
||||
- columnheader "Assigned" [ref=e248]
|
||||
- columnheader "Actions" [ref=e249]
|
||||
- rowgroup [ref=e250]:
|
||||
- row "OBS-202607-0001 Jul 08, 2026 16:33 - test observation Medium In Progress ismail mosa Contact Center Department ismail mosa" [ref=e251]:
|
||||
- cell "OBS-202607-0001" [ref=e252]
|
||||
- cell "Jul 08, 2026 16:33" [ref=e253]:
|
||||
- text: Jul 08, 2026
|
||||
- text: 16:33
|
||||
- cell "-" [ref=e254]
|
||||
- cell "test observation" [ref=e255]:
|
||||
- generic [ref=e256]: test observation
|
||||
- cell "Medium" [ref=e257]
|
||||
- cell "In Progress" [ref=e258]
|
||||
- cell "ismail mosa" [ref=e259]
|
||||
- cell "Contact Center Department" [ref=e260]
|
||||
- cell "ismail mosa" [ref=e261]
|
||||
- cell [ref=e262]:
|
||||
- link [ref=e263] [cursor=pointer]:
|
||||
- /url: /observations/1d925706-110e-4bad-808f-26aae2029144/
|
||||
- img [ref=e264]
|
||||
- row "OBS-202606-E2EHOSP-0056 Jun 22, 2026 10:16 Other Observation i notice some trash in the hall Medium In Progress E2E PX Admin Contact Center Department E2E PX Admin" [ref=e267]:
|
||||
- cell "OBS-202606-E2EHOSP-0056" [ref=e268]
|
||||
- cell "Jun 22, 2026 10:16" [ref=e269]:
|
||||
- text: Jun 22, 2026
|
||||
- text: 10:16
|
||||
- cell "Other" [ref=e270]:
|
||||
- generic [ref=e271]: Other
|
||||
- cell "Observation i notice some trash in the hall" [ref=e272]:
|
||||
- generic [ref=e273]:
|
||||
- strong [ref=e274]: Observation
|
||||
- text: i notice some trash in the hall
|
||||
- cell "Medium" [ref=e275]
|
||||
- cell "In Progress" [ref=e276]
|
||||
- cell "E2E PX Admin" [ref=e277]
|
||||
- cell "Contact Center Department" [ref=e278]
|
||||
- cell "E2E PX Admin" [ref=e279]
|
||||
- cell [ref=e280]:
|
||||
- link [ref=e281] [cursor=pointer]:
|
||||
- /url: /observations/b9a80c99-31dc-4cda-a1e3-c20d94a28729/
|
||||
- img [ref=e282]
|
||||
- row "OBS-O8TCIC Jun 09, 2026 13:36 - Wet floor near the main entrance, no warning sign posted. Medium In Progress Anonymous Reporter - ismail mosa" [ref=e285]:
|
||||
- cell "OBS-O8TCIC" [ref=e286]
|
||||
- cell "Jun 09, 2026 13:36" [ref=e287]:
|
||||
- text: Jun 09, 2026
|
||||
- text: 13:36
|
||||
- cell "-" [ref=e288]
|
||||
- cell "Wet floor near the main entrance, no warning sign posted." [ref=e289]:
|
||||
- generic [ref=e290]: Wet floor near the main entrance, no warning sign posted.
|
||||
- cell "Medium" [ref=e291]
|
||||
- cell "In Progress" [ref=e292]
|
||||
- cell "Anonymous Reporter" [ref=e293]
|
||||
- cell "-" [ref=e294]
|
||||
- cell "ismail mosa" [ref=e295]
|
||||
- cell [ref=e296]:
|
||||
- link [ref=e297] [cursor=pointer]:
|
||||
- /url: /observations/5f8fc6c6-3b38-4b88-8144-3f926c24e290/
|
||||
- img [ref=e298]
|
||||
- row "OBS-QPE02F Jun 07, 2026 21:08 Other Observation safsafsad Medium Open ismail Daif - Unassigned" [ref=e301]:
|
||||
- cell "OBS-QPE02F" [ref=e302]
|
||||
- cell "Jun 07, 2026 21:08" [ref=e303]:
|
||||
- text: Jun 07, 2026
|
||||
- text: 21:08
|
||||
- cell "Other" [ref=e304]:
|
||||
- generic [ref=e305]: Other
|
||||
- cell "Observation safsafsad" [ref=e306]:
|
||||
- generic [ref=e307]:
|
||||
- strong [ref=e308]: Observation
|
||||
- text: safsafsad
|
||||
- cell "Medium" [ref=e309]
|
||||
- cell "Open" [ref=e310]
|
||||
- cell "ismail Daif" [ref=e311]
|
||||
- cell "-" [ref=e312]
|
||||
- cell "Unassigned" [ref=e313]
|
||||
- cell [ref=e314]:
|
||||
- link [ref=e315] [cursor=pointer]:
|
||||
- /url: /observations/327d9b79-2126-4800-8cd7-647f006271ce/
|
||||
- img [ref=e316]
|
||||
- generic [ref=e319]:
|
||||
- generic [ref=e320]:
|
||||
- generic [ref=e321]: Showing 1-4 of 4
|
||||
- generic [ref=e322]:
|
||||
- generic [ref=e323]: Show
|
||||
- combobox [ref=e324]:
|
||||
- option "10"
|
||||
- option "25" [selected]
|
||||
- option "50"
|
||||
- option "100"
|
||||
- generic [ref=e325]:
|
||||
- img [ref=e327]
|
||||
- generic [ref=e329]: "1"
|
||||
- img [ref=e331]
|
||||
- contentinfo [ref=e333]:
|
||||
- paragraph [ref=e334]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e335] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,259 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Observations" [ref=e141] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: OBS-202607-0001
|
||||
- generic [ref=e145]: In Progress
|
||||
- generic [ref=e146]: Medium
|
||||
- generic [ref=e147]:
|
||||
- img [ref=e148]
|
||||
- text: Not Sent to Dept
|
||||
- heading "test observation" [level=1] [ref=e151]
|
||||
- generic [ref=e153]:
|
||||
- generic [ref=e154]:
|
||||
- img [ref=e156]
|
||||
- generic [ref=e158]: Created
|
||||
- generic [ref=e160]:
|
||||
- img [ref=e162]
|
||||
- generic [ref=e164]: Assigned
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e168]
|
||||
- generic [ref=e171]: Sent to Dept
|
||||
- generic [ref=e173]:
|
||||
- img [ref=e175]
|
||||
- generic [ref=e177]: Dept Response
|
||||
- generic [ref=e179]:
|
||||
- img [ref=e181]
|
||||
- generic [ref=e184]: Resolved
|
||||
- navigation [ref=e185]:
|
||||
- button "Details" [ref=e186] [cursor=pointer]
|
||||
- button "Timeline" [ref=e187] [cursor=pointer]
|
||||
- button "RCA" [ref=e188] [cursor=pointer]
|
||||
- button "Notes" [ref=e189] [cursor=pointer]
|
||||
- main [ref=e190]:
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]:
|
||||
- paragraph [ref=e195]: test observation
|
||||
- generic [ref=e196]:
|
||||
- generic [ref=e197]:
|
||||
- paragraph [ref=e198]: Category
|
||||
- paragraph [ref=e199]: "-"
|
||||
- generic [ref=e200]:
|
||||
- paragraph [ref=e201]: Location
|
||||
- paragraph [ref=e202]: "-"
|
||||
- generic [ref=e203]:
|
||||
- paragraph [ref=e204]: Incident Date
|
||||
- paragraph [ref=e205]: 08 Jul 2026
|
||||
- generic [ref=e209]:
|
||||
- img [ref=e210]
|
||||
- paragraph [ref=e214]: No department assigned to this observation
|
||||
- generic [ref=e215]:
|
||||
- generic [ref=e216]:
|
||||
- heading "Quick Actions" [level=3] [ref=e217]
|
||||
- generic [ref=e218]:
|
||||
- button "Respond to Patient" [ref=e219] [cursor=pointer]:
|
||||
- img [ref=e220]
|
||||
- generic [ref=e222]: Respond to Patient
|
||||
- button "Reassign" [ref=e223] [cursor=pointer]:
|
||||
- img [ref=e224]
|
||||
- generic [ref=e227]: Reassign
|
||||
- button "Send to Dept" [ref=e228] [cursor=pointer]:
|
||||
- img [ref=e229]
|
||||
- generic [ref=e232]: Send to Dept
|
||||
- button "Resolve" [ref=e234] [cursor=pointer]:
|
||||
- img [ref=e235]
|
||||
- generic [ref=e238]: Resolve
|
||||
- link "RCA" [ref=e239] [cursor=pointer]:
|
||||
- /url: /rca/create/?related_model=observation&related_id=1d925706-110e-4bad-808f-26aae2029144
|
||||
- img [ref=e240]
|
||||
- generic [ref=e243]: RCA
|
||||
- link "QI Project" [ref=e244] [cursor=pointer]:
|
||||
- /url: /projects/create/?related_model=observation&related_id=1d925706-110e-4bad-808f-26aae2029144
|
||||
- img [ref=e245]
|
||||
- generic [ref=e247]: QI Project
|
||||
- button "Delete" [ref=e249] [cursor=pointer]:
|
||||
- img [ref=e250]
|
||||
- generic [ref=e253]: Delete
|
||||
- generic [ref=e254]:
|
||||
- heading "Assignment" [level=3] [ref=e255]:
|
||||
- img [ref=e256]
|
||||
- text: Assignment
|
||||
- list [ref=e261]:
|
||||
- listitem [ref=e262]:
|
||||
- generic [ref=e263]: Department
|
||||
- generic [ref=e264]: Contact Center Department
|
||||
- listitem [ref=e265]:
|
||||
- generic [ref=e266]: Assigned To
|
||||
- generic [ref=e267]: ismail mosa
|
||||
- generic [ref=e268]:
|
||||
- heading "Linked PX Action" [level=3] [ref=e269]:
|
||||
- img [ref=e270]
|
||||
- text: Linked PX Action
|
||||
- paragraph [ref=e273]: Observation OBS-202607-0001
|
||||
- link "View Action" [ref=e274] [cursor=pointer]:
|
||||
- /url: /actions/9b185c2a-e3fe-4e13-a727-7aab21df8754/
|
||||
- img [ref=e275]
|
||||
- text: View Action
|
||||
- generic [ref=e277]:
|
||||
- heading "Triage" [level=3] [ref=e278]:
|
||||
- img [ref=e279]
|
||||
- text: Triage
|
||||
- generic [ref=e280]:
|
||||
- generic [ref=e281]:
|
||||
- generic [ref=e282]: Department
|
||||
- combobox [ref=e283]:
|
||||
- option "Select department"
|
||||
- option "Contact Center Department" [selected]
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Executive Administration"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Security Department"
|
||||
- option "Anesthesia Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Surgery Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Nursing Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Laundry Department"
|
||||
- generic [ref=e284]:
|
||||
- generic [ref=e285]: Assign To
|
||||
- combobox [ref=e286]:
|
||||
- option "Select assignee" [selected]
|
||||
- option "E2E PX Employee (e2e-px-employee@px360.test)"
|
||||
- option "test test (9m5s6gzmft@ruutukf.com)"
|
||||
- option "test test (esma3el555@gmail.com)"
|
||||
- generic [ref=e287]:
|
||||
- generic [ref=e288]: Status
|
||||
- combobox [ref=e289]:
|
||||
- option "Open"
|
||||
- option "In Progress" [selected]
|
||||
- option "Resolved"
|
||||
- option "Closed"
|
||||
- generic [ref=e290]:
|
||||
- generic [ref=e291]: Note
|
||||
- textbox "Add a note about this triage action (optional)..." [ref=e292]
|
||||
- button "Update" [ref=e293] [cursor=pointer]:
|
||||
- img [ref=e294]
|
||||
- text: Update
|
||||
- contentinfo [ref=e297]:
|
||||
- paragraph [ref=e298]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e299] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,201 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Suggestions" [level=1] [ref=e142]
|
||||
- paragraph [ref=e143]: Manage and review submitted suggestions
|
||||
- link "New Suggestion" [ref=e145] [cursor=pointer]:
|
||||
- /url: /suggestions/create/
|
||||
- img [ref=e146]
|
||||
- text: New Suggestion
|
||||
- generic [ref=e147]:
|
||||
- generic [ref=e148]:
|
||||
- img [ref=e151]
|
||||
- generic [ref=e153]:
|
||||
- heading "Total Suggestions" [level=6] [ref=e154]
|
||||
- heading "3" [level=2] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- img [ref=e159]
|
||||
- generic [ref=e161]:
|
||||
- heading "Compliments" [level=6] [ref=e162]
|
||||
- heading "0" [level=2] [ref=e163]
|
||||
- generic [ref=e164]:
|
||||
- img [ref=e167]
|
||||
- generic [ref=e169]:
|
||||
- heading "Avg Rating" [level=6] [ref=e170]
|
||||
- heading "0.0 /5" [level=2] [ref=e171]
|
||||
- generic [ref=e172]:
|
||||
- img [ref=e175]
|
||||
- generic [ref=e178]:
|
||||
- heading "Pending Review" [level=6] [ref=e179]
|
||||
- heading "2" [level=2] [ref=e180]
|
||||
- generic [ref=e181]:
|
||||
- generic [ref=e182]:
|
||||
- generic [ref=e183]:
|
||||
- img [ref=e185]
|
||||
- heading "Suggestions" [level=5] [ref=e187]
|
||||
- generic [ref=e188]:
|
||||
- link "All" [ref=e189] [cursor=pointer]:
|
||||
- /url: "?"
|
||||
- link "Suggestions" [ref=e190] [cursor=pointer]:
|
||||
- /url: "?feedback_type=suggestion"
|
||||
- link "Compliments" [ref=e191] [cursor=pointer]:
|
||||
- /url: "?feedback_type=compliment"
|
||||
- button "Advanced" [ref=e193] [cursor=pointer]:
|
||||
- img [ref=e194]
|
||||
- text: Advanced
|
||||
- paragraph [ref=e195]: "Showing: 1-3 of 3"
|
||||
- table [ref=e197]:
|
||||
- rowgroup [ref=e198]:
|
||||
- row "ID Type Contact Title Category Rating Sentiment Status Created Actions" [ref=e199]:
|
||||
- columnheader "ID" [ref=e200]
|
||||
- columnheader "Type" [ref=e201]
|
||||
- columnheader "Contact" [ref=e202]
|
||||
- columnheader "Title" [ref=e203]
|
||||
- columnheader "Category" [ref=e204]
|
||||
- columnheader "Rating" [ref=e205]
|
||||
- columnheader "Sentiment" [ref=e206]
|
||||
- columnheader "Status" [ref=e207]
|
||||
- columnheader "Created" [ref=e208]
|
||||
- columnheader "Actions" [ref=e209]
|
||||
- rowgroup [ref=e210]:
|
||||
- row "SGT-202607-0003 Suggestion ismail Daif t Other - Neutral Submitted Jul 09, 2026" [ref=e211] [cursor=pointer]:
|
||||
- cell "SGT-202607-0003" [ref=e212]
|
||||
- cell "Suggestion" [ref=e213]
|
||||
- cell "ismail Daif" [ref=e214]:
|
||||
- generic [ref=e215]: ismail Daif
|
||||
- cell "t" [ref=e216]:
|
||||
- generic [ref=e217]: t
|
||||
- cell "Other" [ref=e218]
|
||||
- cell "-" [ref=e219]
|
||||
- cell "Neutral" [ref=e220]
|
||||
- cell "Submitted" [ref=e221]
|
||||
- cell "Jul 09, 2026" [ref=e222]
|
||||
- cell [ref=e223]:
|
||||
- link "View" [ref=e225]:
|
||||
- /url: /suggestions/78f015c5-fbed-4e10-987f-aa8ced75545a/
|
||||
- img [ref=e226]
|
||||
- row "SGT-202607-0002 Suggestion ismail test Other - Neutral Submitted Jul 09, 2026" [ref=e229] [cursor=pointer]:
|
||||
- cell "SGT-202607-0002" [ref=e230]
|
||||
- cell "Suggestion" [ref=e231]
|
||||
- cell "ismail" [ref=e232]:
|
||||
- generic [ref=e233]: ismail
|
||||
- cell "test" [ref=e234]:
|
||||
- generic [ref=e235]: test
|
||||
- cell "Other" [ref=e236]
|
||||
- cell "-" [ref=e237]
|
||||
- cell "Neutral" [ref=e238]
|
||||
- cell "Submitted" [ref=e239]
|
||||
- cell "Jul 09, 2026" [ref=e240]
|
||||
- cell [ref=e241]:
|
||||
- link "View" [ref=e243]:
|
||||
- /url: /suggestions/04b009f3-53aa-4eac-9baf-39b3633e56b0/
|
||||
- img [ref=e244]
|
||||
- row "SGT-202607-0001 Suggestion ismail The suggestion titled 'test' with content 'test' appears … Other - Neutral Closed Jul 09, 2026" [ref=e247] [cursor=pointer]:
|
||||
- cell "SGT-202607-0001" [ref=e248]
|
||||
- cell "Suggestion" [ref=e249]
|
||||
- cell "ismail" [ref=e250]:
|
||||
- generic [ref=e251]: ismail
|
||||
- cell "The suggestion titled 'test' with content 'test' appears …" [ref=e252]:
|
||||
- generic [ref=e253]: The suggestion titled 'test' with content 'test' appears …
|
||||
- cell "Other" [ref=e254]
|
||||
- cell "-" [ref=e255]
|
||||
- cell "Neutral" [ref=e256]
|
||||
- cell "Closed" [ref=e257]
|
||||
- cell "Jul 09, 2026" [ref=e258]
|
||||
- cell [ref=e259]:
|
||||
- link "View" [ref=e261]:
|
||||
- /url: /suggestions/ef1e633b-7fae-46e2-baab-0d5bdd71e7b7/
|
||||
- img [ref=e262]
|
||||
- contentinfo [ref=e265]:
|
||||
- paragraph [ref=e266]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e267] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,188 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Suggestions" [ref=e141] [cursor=pointer]:
|
||||
- /url: /suggestions/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: test
|
||||
- generic [ref=e145]: Submitted
|
||||
- generic [ref=e146]: Medium
|
||||
- generic [ref=e147]:
|
||||
- heading "Suggestion Detail" [level=1] [ref=e148]
|
||||
- link "Edit" [ref=e150] [cursor=pointer]:
|
||||
- /url: /suggestions/04b009f3-53aa-4eac-9baf-39b3633e56b0/update/
|
||||
- img [ref=e151]
|
||||
- text: Edit
|
||||
- generic [ref=e155]:
|
||||
- generic [ref=e156]:
|
||||
- img [ref=e158]
|
||||
- generic [ref=e160]: Submitted
|
||||
- generic [ref=e162]:
|
||||
- img [ref=e164]
|
||||
- generic [ref=e167]: Assigned
|
||||
- generic [ref=e169]:
|
||||
- img [ref=e171]
|
||||
- generic [ref=e174]: Reviewed
|
||||
- generic [ref=e176]:
|
||||
- img [ref=e178]
|
||||
- generic [ref=e180]: Acknowledged
|
||||
- generic [ref=e182]:
|
||||
- img [ref=e184]
|
||||
- generic [ref=e187]: Closed
|
||||
- navigation [ref=e188]:
|
||||
- button "Details" [ref=e189] [cursor=pointer]
|
||||
- button "AI Analysis" [ref=e190] [cursor=pointer]
|
||||
- button "Notes" [ref=e191] [cursor=pointer]
|
||||
- main [ref=e192]:
|
||||
- generic [ref=e195]:
|
||||
- heading "Suggestion Details" [level=3] [ref=e196]:
|
||||
- img [ref=e197]
|
||||
- text: Suggestion Details
|
||||
- paragraph [ref=e200]: test
|
||||
- generic [ref=e201]:
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Hospital
|
||||
- generic [ref=e204]: E2E Test Hospital
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: Category
|
||||
- generic [ref=e207]: Other
|
||||
- generic [ref=e208]:
|
||||
- generic [ref=e209]: Location
|
||||
- generic [ref=e210]: Not specified
|
||||
- generic [ref=e211]:
|
||||
- generic [ref=e212]: Sentiment
|
||||
- generic [ref=e214]: Neutral
|
||||
- generic [ref=e215]:
|
||||
- heading "Contact" [level=6] [ref=e216]
|
||||
- generic [ref=e217]:
|
||||
- generic [ref=e218]: ismail
|
||||
- generic [ref=e219]: "0569874589"
|
||||
- generic [ref=e221]:
|
||||
- img [ref=e222]
|
||||
- text: July 09, 2026 13:46
|
||||
- generic [ref=e225]:
|
||||
- heading "Quick Actions" [level=3] [ref=e226]
|
||||
- generic [ref=e227]:
|
||||
- generic [ref=e228]:
|
||||
- text: Status
|
||||
- combobox [ref=e229]:
|
||||
- option "Submitted" [selected]
|
||||
- option "Reviewed"
|
||||
- option "Acknowledged"
|
||||
- option "Closed"
|
||||
- option "Reopened"
|
||||
- textbox "Add a note..." [ref=e230]
|
||||
- button "Update" [ref=e231] [cursor=pointer]:
|
||||
- img [ref=e232]
|
||||
- text: Update
|
||||
- separator [ref=e237]
|
||||
- generic [ref=e238]:
|
||||
- text: Assign To
|
||||
- combobox [ref=e239]:
|
||||
- option "Select user..." [selected]
|
||||
- option "E2E PX Employee"
|
||||
- option "test test"
|
||||
- option "test test"
|
||||
- button "Assign" [ref=e240] [cursor=pointer]:
|
||||
- img [ref=e241]
|
||||
- text: Assign
|
||||
- separator [ref=e245]
|
||||
- generic [ref=e246]:
|
||||
- text: Department
|
||||
- button "Send to Department" [ref=e247] [cursor=pointer]:
|
||||
- img [ref=e248]
|
||||
- text: Send to Department
|
||||
- separator [ref=e251]
|
||||
- contentinfo [ref=e252]:
|
||||
- paragraph [ref=e253]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e254] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,221 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Appreciations" [level=1] [ref=e142]:
|
||||
- img [ref=e143]
|
||||
- text: Appreciations
|
||||
- paragraph [ref=e145]: Manage and review patient appreciation submissions
|
||||
- generic [ref=e146]:
|
||||
- link "Create Internal" [ref=e147] [cursor=pointer]:
|
||||
- /url: /appreciation/new/
|
||||
- img [ref=e148]
|
||||
- text: Create Internal
|
||||
- link "Public Form" [ref=e149] [cursor=pointer]:
|
||||
- /url: /core/public/submit/
|
||||
- img [ref=e150]
|
||||
- text: Public Form
|
||||
- generic [ref=e154]:
|
||||
- paragraph [ref=e155]: "0"
|
||||
- paragraph [ref=e156]: Pending
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e159]:
|
||||
- img [ref=e161]
|
||||
- generic [ref=e164]:
|
||||
- paragraph [ref=e165]: Total
|
||||
- paragraph [ref=e166]: "3"
|
||||
- generic [ref=e168]:
|
||||
- img [ref=e170]
|
||||
- generic [ref=e173]:
|
||||
- paragraph [ref=e174]: Draft
|
||||
- paragraph [ref=e175]: "0"
|
||||
- generic [ref=e177]:
|
||||
- img [ref=e179]
|
||||
- generic [ref=e181]:
|
||||
- paragraph [ref=e182]: Activated
|
||||
- paragraph [ref=e183]: "0"
|
||||
- generic [ref=e185]:
|
||||
- img [ref=e187]
|
||||
- generic [ref=e190]:
|
||||
- paragraph [ref=e191]: Sent
|
||||
- paragraph [ref=e192]: "3"
|
||||
- generic [ref=e193]:
|
||||
- link "Leaderboard See top performers" [ref=e194] [cursor=pointer]:
|
||||
- /url: /appreciation/leaderboard/
|
||||
- generic [ref=e196]:
|
||||
- img [ref=e198]
|
||||
- generic [ref=e204]:
|
||||
- heading "Leaderboard" [level=3] [ref=e205]
|
||||
- paragraph [ref=e206]: See top performers
|
||||
- link "My Badges View earned badges" [ref=e207] [cursor=pointer]:
|
||||
- /url: /appreciation/badges/
|
||||
- generic [ref=e209]:
|
||||
- img [ref=e211]
|
||||
- generic [ref=e214]:
|
||||
- heading "My Badges" [level=3] [ref=e215]
|
||||
- paragraph [ref=e216]: View earned badges
|
||||
- generic [ref=e217]:
|
||||
- generic [ref=e218]:
|
||||
- img [ref=e220]
|
||||
- heading "Filters" [level=3] [ref=e222]
|
||||
- generic [ref=e224]:
|
||||
- generic [ref=e225]:
|
||||
- generic [ref=e226]: Search
|
||||
- textbox "Search messages..." [ref=e227]
|
||||
- generic [ref=e228]:
|
||||
- generic [ref=e229]: Status
|
||||
- combobox [ref=e230]:
|
||||
- option "All Status" [selected]
|
||||
- option "Draft"
|
||||
- option "Activated"
|
||||
- option "AI Analyzed"
|
||||
- option "Sent"
|
||||
- option "Acknowledged"
|
||||
- button "Search" [ref=e231] [cursor=pointer]:
|
||||
- img [ref=e232]
|
||||
- text: Search
|
||||
- link "Clear" [ref=e235] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- table [ref=e237]:
|
||||
- rowgroup [ref=e238]:
|
||||
- row "From Message Staff Department Category Status Date Actions" [ref=e239]:
|
||||
- columnheader "From" [ref=e240]
|
||||
- columnheader "Message" [ref=e241]
|
||||
- columnheader "Staff" [ref=e242]
|
||||
- columnheader "Department" [ref=e243]
|
||||
- columnheader "Category" [ref=e244]
|
||||
- columnheader "Status" [ref=e245]
|
||||
- columnheader "Date" [ref=e246]
|
||||
- columnheader "Actions" [ref=e247]
|
||||
- rowgroup [ref=e248]:
|
||||
- row "ismail thank - Contact Center Department - Sent Jul 09, 2026" [ref=e249] [cursor=pointer]:
|
||||
- cell "ismail" [ref=e250]
|
||||
- cell "thank" [ref=e251]:
|
||||
- generic [ref=e252]: thank
|
||||
- cell "-" [ref=e253]
|
||||
- cell "Contact Center Department" [ref=e254]
|
||||
- cell "-" [ref=e255]
|
||||
- cell "Sent" [ref=e256]
|
||||
- cell "Jul 09, 2026" [ref=e257]
|
||||
- cell [ref=e258]:
|
||||
- link "View" [ref=e260]:
|
||||
- /url: /appreciation/detail/5306821e-d596-498f-88c5-fa3addcdd491/
|
||||
- img [ref=e261]
|
||||
- row "ismail mosa test - Contact Center Department - Sent Jul 09, 2026" [ref=e264] [cursor=pointer]:
|
||||
- cell "ismail mosa" [ref=e265]
|
||||
- cell "test" [ref=e266]:
|
||||
- generic [ref=e267]: test
|
||||
- cell "-" [ref=e268]
|
||||
- cell "Contact Center Department" [ref=e269]
|
||||
- cell "-" [ref=e270]
|
||||
- cell "Sent" [ref=e271]
|
||||
- cell "Jul 09, 2026" [ref=e272]
|
||||
- cell [ref=e273]:
|
||||
- link "View" [ref=e275]:
|
||||
- /url: /appreciation/detail/3697b78b-5ff3-4392-8982-af20f58daebf/
|
||||
- img [ref=e276]
|
||||
- row "ismail mosa thank you - Contact Center Department - Sent Jul 09, 2026" [ref=e279] [cursor=pointer]:
|
||||
- cell "ismail mosa" [ref=e280]
|
||||
- cell "thank you" [ref=e281]:
|
||||
- generic [ref=e282]: thank you
|
||||
- cell "-" [ref=e283]
|
||||
- cell "Contact Center Department" [ref=e284]
|
||||
- cell "-" [ref=e285]
|
||||
- cell "Sent" [ref=e286]
|
||||
- cell "Jul 09, 2026" [ref=e287]
|
||||
- cell [ref=e288]:
|
||||
- link "View" [ref=e290]:
|
||||
- /url: /appreciation/detail/240ea5bd-a12d-4f76-8a79-a8dd9de5714a/
|
||||
- img [ref=e291]
|
||||
- contentinfo [ref=e294]:
|
||||
- paragraph [ref=e295]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e296] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,137 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Appreciations" [ref=e141] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: Detail
|
||||
- generic [ref=e145]: Sent
|
||||
- generic [ref=e146]:
|
||||
- heading "Appreciation Detail" [level=1] [ref=e147]:
|
||||
- img [ref=e148]
|
||||
- text: Appreciation Detail
|
||||
- generic [ref=e151]: 2026-07-09 13:55 — E2E Test Hospital
|
||||
- navigation [ref=e152]:
|
||||
- button "Details" [ref=e153] [cursor=pointer]
|
||||
- button "AI Analysis" [ref=e154] [cursor=pointer]
|
||||
- button "Notes" [ref=e155] [cursor=pointer]
|
||||
- main [ref=e156]:
|
||||
- generic [ref=e158]:
|
||||
- generic [ref=e159]:
|
||||
- heading "Appreciation Message" [level=3] [ref=e160]:
|
||||
- img [ref=e161]
|
||||
- text: Appreciation Message
|
||||
- paragraph
|
||||
- paragraph [ref=e163]: thank
|
||||
- paragraph
|
||||
- generic [ref=e164]:
|
||||
- heading "Submitted By" [level=3] [ref=e165]:
|
||||
- img [ref=e166]
|
||||
- text: Submitted By
|
||||
- generic [ref=e169]:
|
||||
- generic [ref=e170]:
|
||||
- paragraph [ref=e171]: Name
|
||||
- paragraph [ref=e172]: ismail
|
||||
- generic [ref=e173]:
|
||||
- paragraph [ref=e174]: Phone
|
||||
- paragraph [ref=e175]: "0569874589"
|
||||
- generic [ref=e176]:
|
||||
- heading "Sent" [level=3] [ref=e177]:
|
||||
- img [ref=e178]
|
||||
- text: Sent
|
||||
- generic [ref=e182]:
|
||||
- paragraph [ref=e183]: Sent At
|
||||
- paragraph [ref=e184]: 2026-07-09 13:57
|
||||
- contentinfo [ref=e186]:
|
||||
- paragraph [ref=e187]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e188] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,256 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]:
|
||||
- link "Inquiries" [ref=e143] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e144]
|
||||
- generic [ref=e146]: New Inquiry
|
||||
- heading "Create New Inquiry" [level=1] [ref=e147]
|
||||
- generic [ref=e149]:
|
||||
- generic [ref=e150]:
|
||||
- img [ref=e152]
|
||||
- heading "Inquiry Information" [level=5] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- generic [ref=e157]:
|
||||
- heading "Organization" [level=6] [ref=e158]:
|
||||
- img [ref=e159]
|
||||
- text: Organization
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- generic [ref=e164]: Location Type *
|
||||
- combobox "Location Type *" [ref=e165]:
|
||||
- option "Select Location Type" [selected]
|
||||
- option "Outpatient"
|
||||
- option "Inpatient"
|
||||
- option "Emergency"
|
||||
- option "General"
|
||||
- generic [ref=e166]:
|
||||
- generic [ref=e167]: Area
|
||||
- combobox "Area" [ref=e168]:
|
||||
- option "Select Area (optional)" [selected]
|
||||
- option "Anesthesia (E2E-HOSP)"
|
||||
- option "Dialysis (E2E-HOSP)"
|
||||
- option "Emergency (E2E-HOSP)"
|
||||
- option "Endoscopy (E2E-HOSP)"
|
||||
- option "ICCU (E2E-HOSP)"
|
||||
- option "ICU (E2E-HOSP)"
|
||||
- option "ICU Stepdown (E2E-HOSP)"
|
||||
- option "LTACU (E2E-HOSP)"
|
||||
- option "Labor & Delivery (E2E-HOSP)"
|
||||
- option "Main OR (E2E-HOSP)"
|
||||
- option "Medical Ward (E2E-HOSP)"
|
||||
- option "NICU - Neonatal Intensive Care Unit (E2E-HOSP)"
|
||||
- option "Nursery (E2E-HOSP)"
|
||||
- option "OB Wards (E2E-HOSP)"
|
||||
- option "OPD1 (E2E-HOSP)"
|
||||
- option "OPD2 (E2E-HOSP)"
|
||||
- option "OPD3 (E2E-HOSP)"
|
||||
- option "OPD4 (E2E-HOSP)"
|
||||
- option "OPD5 (E2E-HOSP)"
|
||||
- option "OPD6 (E2E-HOSP)"
|
||||
- option "OPD7 (E2E-HOSP)"
|
||||
- option "PICU (E2E-HOSP)"
|
||||
- option "PICU Stepdown (E2E-HOSP)"
|
||||
- option "Pediatric Ward (E2E-HOSP)"
|
||||
- option "Recovery (E2E-HOSP)"
|
||||
- option "Stepdown 3-4 (E2E-HOSP)"
|
||||
- option "Surgical Wards (E2E-HOSP)"
|
||||
- generic [ref=e169]:
|
||||
- generic [ref=e170]: Department Category *
|
||||
- combobox [ref=e171]:
|
||||
- option "Select Category" [selected]
|
||||
- option "Medical"
|
||||
- option "Nursing"
|
||||
- option "Administrative"
|
||||
- option "Support Services"
|
||||
- generic [ref=e172]:
|
||||
- generic [ref=e173]: Department (Optional)
|
||||
- combobox "Department (Optional)" [ref=e174]:
|
||||
- option "Select Department" [selected]
|
||||
- option "Anesthesia Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Contact Center Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "Executive Administration"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Laundry Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Nursing Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Security Department"
|
||||
- option "Surgery Department"
|
||||
- generic [ref=e175]:
|
||||
- generic [ref=e176]: Section
|
||||
- combobox "Section" [ref=e177]:
|
||||
- option "Select Section" [selected]
|
||||
- generic [ref=e178]:
|
||||
- heading "Contact Information" [level=6] [ref=e179]:
|
||||
- img [ref=e180]
|
||||
- text: Contact Information
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- generic [ref=e185]: Contact Name *
|
||||
- textbox "Contact Name *" [ref=e186]
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e188]: Contact Phone *
|
||||
- textbox "Contact Phone *" [ref=e189]
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: Contact Email
|
||||
- textbox "Contact Email" [ref=e192]
|
||||
- generic [ref=e193]:
|
||||
- heading "Inquiry Details" [level=6] [ref=e194]:
|
||||
- img [ref=e195]
|
||||
- text: Inquiry Details
|
||||
- generic [ref=e198]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: Inquiry Type *
|
||||
- combobox "Inquiry Type *" [ref=e201]:
|
||||
- option "General Inquiry" [selected]
|
||||
- option "Appointment Related"
|
||||
- option "Billing & Insurance"
|
||||
- option "Medical Records"
|
||||
- option "Pharmacy"
|
||||
- option "Insurance"
|
||||
- option "Feedback"
|
||||
- option "Other"
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Priority
|
||||
- combobox "Priority" [ref=e204]:
|
||||
- option "Low"
|
||||
- option "Medium" [selected]
|
||||
- option "High"
|
||||
- option "Critical"
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: Subject *
|
||||
- textbox "Subject *" [ref=e207]:
|
||||
- /placeholder: Brief subject
|
||||
- generic [ref=e208]:
|
||||
- generic [ref=e209]: Message *
|
||||
- textbox "Message *" [ref=e210]:
|
||||
- /placeholder: Describe your inquiry
|
||||
- generic [ref=e211]:
|
||||
- button "Create Inquiry" [ref=e212] [cursor=pointer]:
|
||||
- img [ref=e213]
|
||||
- text: Create Inquiry
|
||||
- link "Cancel" [ref=e216] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- contentinfo [ref=e217]:
|
||||
- paragraph [ref=e218]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e219] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,258 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]:
|
||||
- link "Inquiries" [ref=e143] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e144]
|
||||
- generic [ref=e146]: New Inquiry
|
||||
- heading "Create New Inquiry" [level=1] [ref=e147]
|
||||
- generic [ref=e149]:
|
||||
- generic [ref=e150]:
|
||||
- img [ref=e152]
|
||||
- heading "Inquiry Information" [level=5] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- generic [ref=e157]:
|
||||
- heading "Organization" [level=6] [ref=e158]:
|
||||
- img [ref=e159]
|
||||
- text: Organization
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- generic [ref=e164]: Location Type *
|
||||
- combobox "Location Type *" [active] [ref=e165]:
|
||||
- option "Select Location Type" [selected]
|
||||
- option "Outpatient"
|
||||
- option "Inpatient"
|
||||
- option "Emergency"
|
||||
- option "General"
|
||||
- generic [ref=e166]:
|
||||
- generic [ref=e167]: Area
|
||||
- combobox "Area" [ref=e168]:
|
||||
- option "Select Area (optional)" [selected]
|
||||
- option "Anesthesia (E2E-HOSP)"
|
||||
- option "Dialysis (E2E-HOSP)"
|
||||
- option "Emergency (E2E-HOSP)"
|
||||
- option "Endoscopy (E2E-HOSP)"
|
||||
- option "ICCU (E2E-HOSP)"
|
||||
- option "ICU (E2E-HOSP)"
|
||||
- option "ICU Stepdown (E2E-HOSP)"
|
||||
- option "LTACU (E2E-HOSP)"
|
||||
- option "Labor & Delivery (E2E-HOSP)"
|
||||
- option "Main OR (E2E-HOSP)"
|
||||
- option "Medical Ward (E2E-HOSP)"
|
||||
- option "NICU - Neonatal Intensive Care Unit (E2E-HOSP)"
|
||||
- option "Nursery (E2E-HOSP)"
|
||||
- option "OB Wards (E2E-HOSP)"
|
||||
- option "OPD1 (E2E-HOSP)"
|
||||
- option "OPD2 (E2E-HOSP)"
|
||||
- option "OPD3 (E2E-HOSP)"
|
||||
- option "OPD4 (E2E-HOSP)"
|
||||
- option "OPD5 (E2E-HOSP)"
|
||||
- option "OPD6 (E2E-HOSP)"
|
||||
- option "OPD7 (E2E-HOSP)"
|
||||
- option "PICU (E2E-HOSP)"
|
||||
- option "PICU Stepdown (E2E-HOSP)"
|
||||
- option "Pediatric Ward (E2E-HOSP)"
|
||||
- option "Recovery (E2E-HOSP)"
|
||||
- option "Stepdown 3-4 (E2E-HOSP)"
|
||||
- option "Surgical Wards (E2E-HOSP)"
|
||||
- generic [ref=e169]:
|
||||
- generic [ref=e170]: Department Category *
|
||||
- combobox [ref=e171]:
|
||||
- option "Select Category" [selected]
|
||||
- option "Medical"
|
||||
- option "Nursing"
|
||||
- option "Administrative"
|
||||
- option "Support Services"
|
||||
- generic [ref=e172]:
|
||||
- generic [ref=e173]: Department (Optional)
|
||||
- combobox "Department (Optional)" [ref=e174]:
|
||||
- option "Select Department" [selected]
|
||||
- option "Anesthesia Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Contact Center Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "Executive Administration"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Laundry Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Nursing Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Security Department"
|
||||
- option "Surgery Department"
|
||||
- generic [ref=e175]:
|
||||
- generic [ref=e176]: Section
|
||||
- combobox "Section" [ref=e177]:
|
||||
- option "Select Section" [selected]
|
||||
- generic [ref=e178]:
|
||||
- heading "Contact Information" [level=6] [ref=e179]:
|
||||
- img [ref=e180]
|
||||
- text: Contact Information
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- generic [ref=e185]: Contact Name *
|
||||
- textbox "Contact Name *" [ref=e186]: Test User
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e188]: Contact Phone *
|
||||
- textbox "Contact Phone *" [ref=e189]: "+966501234567"
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: Contact Email
|
||||
- textbox "Contact Email" [ref=e192]
|
||||
- generic [ref=e193]:
|
||||
- heading "Inquiry Details" [level=6] [ref=e194]:
|
||||
- img [ref=e195]
|
||||
- text: Inquiry Details
|
||||
- generic [ref=e198]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: Inquiry Type *
|
||||
- combobox "Inquiry Type *" [ref=e201]:
|
||||
- option "General Inquiry" [selected]
|
||||
- option "Appointment Related"
|
||||
- option "Billing & Insurance"
|
||||
- option "Medical Records"
|
||||
- option "Pharmacy"
|
||||
- option "Insurance"
|
||||
- option "Feedback"
|
||||
- option "Other"
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Priority
|
||||
- combobox "Priority" [ref=e204]:
|
||||
- option "Low"
|
||||
- option "Medium" [selected]
|
||||
- option "High"
|
||||
- option "Critical"
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: Subject *
|
||||
- textbox "Subject *" [ref=e207]:
|
||||
- /placeholder: Brief subject
|
||||
- text: Playwright Test Inquiry
|
||||
- generic [ref=e208]:
|
||||
- generic [ref=e209]: Message *
|
||||
- textbox "Message *" [ref=e210]:
|
||||
- /placeholder: Describe your inquiry
|
||||
- text: This is a test inquiry created by Playwright to verify px-employee access.
|
||||
- generic [ref=e211]:
|
||||
- button "Create Inquiry" [ref=e212] [cursor=pointer]:
|
||||
- img [ref=e213]
|
||||
- text: Create Inquiry
|
||||
- link "Cancel" [ref=e216] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- contentinfo [ref=e217]:
|
||||
- paragraph [ref=e218]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e219] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,258 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]:
|
||||
- link "Inquiries" [ref=e143] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e144]
|
||||
- generic [ref=e146]: New Inquiry
|
||||
- heading "Create New Inquiry" [level=1] [ref=e147]
|
||||
- generic [ref=e149]:
|
||||
- generic [ref=e150]:
|
||||
- img [ref=e152]
|
||||
- heading "Inquiry Information" [level=5] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- generic [ref=e157]:
|
||||
- heading "Organization" [level=6] [ref=e158]:
|
||||
- img [ref=e159]
|
||||
- text: Organization
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- generic [ref=e164]: Location Type *
|
||||
- combobox "Location Type *" [active] [ref=e165]:
|
||||
- option "Select Location Type"
|
||||
- option "Outpatient"
|
||||
- option "Inpatient"
|
||||
- option "Emergency"
|
||||
- option "General"
|
||||
- generic [ref=e166]:
|
||||
- generic [ref=e167]: Area
|
||||
- combobox "Area" [ref=e168]:
|
||||
- option "Select Area (optional)" [selected]
|
||||
- option "Anesthesia"
|
||||
- option "Dialysis"
|
||||
- option "Emergency"
|
||||
- option "Endoscopy"
|
||||
- option "ICCU"
|
||||
- option "ICU"
|
||||
- option "ICU Stepdown"
|
||||
- option "LTACU"
|
||||
- option "Labor & Delivery"
|
||||
- option "Main OR"
|
||||
- option "Medical Ward"
|
||||
- option "NICU - Neonatal Intensive Care Unit"
|
||||
- option "Nursery"
|
||||
- option "OB Wards"
|
||||
- option "OPD1"
|
||||
- option "OPD2"
|
||||
- option "OPD3"
|
||||
- option "OPD4"
|
||||
- option "OPD5"
|
||||
- option "OPD6"
|
||||
- option "OPD7"
|
||||
- option "PICU"
|
||||
- option "PICU Stepdown"
|
||||
- option "Pediatric Ward"
|
||||
- option "Recovery"
|
||||
- option "Stepdown 3-4"
|
||||
- option "Surgical Wards"
|
||||
- generic [ref=e169]:
|
||||
- generic [ref=e170]: Department Category *
|
||||
- combobox [ref=e171]:
|
||||
- option "Select Category" [selected]
|
||||
- option "Medical"
|
||||
- option "Nursing"
|
||||
- option "Administrative"
|
||||
- option "Support Services"
|
||||
- generic [ref=e172]:
|
||||
- generic [ref=e173]: Department (Optional)
|
||||
- combobox "Department (Optional)" [ref=e174]:
|
||||
- option "Select Department" [selected]
|
||||
- option "Anesthesia Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Contact Center Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "Executive Administration"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Laundry Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Nursing Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Security Department"
|
||||
- option "Surgery Department"
|
||||
- generic [ref=e175]:
|
||||
- generic [ref=e176]: Section
|
||||
- combobox "Section" [ref=e177]:
|
||||
- option "Select Section" [selected]
|
||||
- generic [ref=e178]:
|
||||
- heading "Contact Information" [level=6] [ref=e179]:
|
||||
- img [ref=e180]
|
||||
- text: Contact Information
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- generic [ref=e185]: Contact Name *
|
||||
- textbox "Contact Name *" [ref=e186]: Test User
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e188]: Contact Phone *
|
||||
- textbox "Contact Phone *" [ref=e189]: "+966501234567"
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: Contact Email
|
||||
- textbox "Contact Email" [ref=e192]
|
||||
- generic [ref=e193]:
|
||||
- heading "Inquiry Details" [level=6] [ref=e194]:
|
||||
- img [ref=e195]
|
||||
- text: Inquiry Details
|
||||
- generic [ref=e198]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: Inquiry Type *
|
||||
- combobox "Inquiry Type *" [ref=e201]:
|
||||
- option "General Inquiry"
|
||||
- option "Appointment Related" [selected]
|
||||
- option "Billing & Insurance"
|
||||
- option "Medical Records"
|
||||
- option "Pharmacy"
|
||||
- option "Insurance"
|
||||
- option "Feedback"
|
||||
- option "Other"
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Priority
|
||||
- combobox "Priority" [ref=e204]:
|
||||
- option "Low"
|
||||
- option "Medium" [selected]
|
||||
- option "High"
|
||||
- option "Critical"
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: Subject *
|
||||
- textbox "Subject *" [ref=e207]:
|
||||
- /placeholder: Brief subject
|
||||
- text: Playwright Test Inquiry
|
||||
- generic [ref=e208]:
|
||||
- generic [ref=e209]: Message *
|
||||
- textbox "Message *" [ref=e210]:
|
||||
- /placeholder: Describe your inquiry
|
||||
- text: This is a test inquiry created by Playwright to verify px-employee access.
|
||||
- generic [ref=e211]:
|
||||
- button "Create Inquiry" [ref=e212] [cursor=pointer]:
|
||||
- img [ref=e213]
|
||||
- text: Create Inquiry
|
||||
- link "Cancel" [ref=e216] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- contentinfo [ref=e217]:
|
||||
- paragraph [ref=e218]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e219] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,39 +0,0 @@
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "HH Logo" [ref=e6]
|
||||
- heading "Welcome to PX360" [level=1] [ref=e7]
|
||||
- paragraph [ref=e8]: Patient Experience Management System
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]:
|
||||
- img [ref=e13]
|
||||
- text: Email Address
|
||||
- generic [ref=e16]:
|
||||
- img [ref=e17]
|
||||
- textbox "Email Address" [active] [ref=e20]:
|
||||
- /placeholder: Enter your email
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- img [ref=e23]
|
||||
- text: Password
|
||||
- generic [ref=e26]:
|
||||
- img [ref=e27]
|
||||
- textbox "Password" [ref=e31]:
|
||||
- /placeholder: Enter your password
|
||||
- button [ref=e32] [cursor=pointer]:
|
||||
- img [ref=e33]
|
||||
- generic [ref=e36]:
|
||||
- generic [ref=e37] [cursor=pointer]:
|
||||
- checkbox "Remember me" [ref=e38]
|
||||
- generic [ref=e39]: Remember me
|
||||
- link "Forgot password?" [ref=e40] [cursor=pointer]:
|
||||
- /url: /accounts/password/reset/
|
||||
- button "Sign In" [ref=e41] [cursor=pointer]:
|
||||
- img [ref=e42]
|
||||
- text: Sign In
|
||||
- generic [ref=e45]:
|
||||
- paragraph [ref=e46]:
|
||||
- text: Secure login powered by
|
||||
- link "tenhal.sa" [ref=e47] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
- paragraph [ref=e48]: © 2026 Al Hammadi Hospital
|
||||
@ -1,350 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Departments" [ref=e141] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: Contact Center Department
|
||||
- generic [ref=e147]:
|
||||
- generic [ref=e148]:
|
||||
- img [ref=e150]
|
||||
- generic [ref=e154]:
|
||||
- heading "Contact Center Department" [level=1] [ref=e155]
|
||||
- paragraph [ref=e156]: قسم مركز الاتصال
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e158]:
|
||||
- img [ref=e159]
|
||||
- text: hh_n_contact_center_department
|
||||
- generic [ref=e162]: Administrative
|
||||
- generic [ref=e163]:
|
||||
- img [ref=e164]
|
||||
- text: E2E Test Hospital
|
||||
- generic [ref=e168]:
|
||||
- generic [ref=e170]:
|
||||
- img [ref=e172]
|
||||
- generic [ref=e177]:
|
||||
- paragraph [ref=e178]: Staff
|
||||
- paragraph [ref=e179]: "4"
|
||||
- link "Open Complaints 3 3 total" [ref=e180] [cursor=pointer]:
|
||||
- /url: /organizations/departments/d810102c-d14a-4661-8672-6ef85a77fc8f/complaints/?status=open
|
||||
- generic [ref=e181]:
|
||||
- img [ref=e183]
|
||||
- generic [ref=e186]:
|
||||
- paragraph [ref=e187]: Open Complaints
|
||||
- paragraph [ref=e188]: "3"
|
||||
- paragraph [ref=e189]: 3 total
|
||||
- link "Pending Inquiries 0 2 total" [ref=e190] [cursor=pointer]:
|
||||
- /url: /organizations/departments/d810102c-d14a-4661-8672-6ef85a77fc8f/inquiries/?status=open
|
||||
- generic [ref=e191]:
|
||||
- img [ref=e193]
|
||||
- generic [ref=e196]:
|
||||
- paragraph [ref=e197]: Pending Inquiries
|
||||
- paragraph [ref=e198]: "0"
|
||||
- paragraph [ref=e199]: 2 total
|
||||
- link "Open Observations 1 1 total" [ref=e200] [cursor=pointer]:
|
||||
- /url: /organizations/departments/d810102c-d14a-4661-8672-6ef85a77fc8f/observations/?status=open
|
||||
- generic [ref=e201]:
|
||||
- img [ref=e203]
|
||||
- generic [ref=e206]:
|
||||
- paragraph [ref=e207]: Open Observations
|
||||
- paragraph [ref=e208]: "1"
|
||||
- paragraph [ref=e209]: 1 total
|
||||
- generic [ref=e210]:
|
||||
- generic [ref=e211]:
|
||||
- img [ref=e213]
|
||||
- generic [ref=e215]:
|
||||
- heading "Pending Actions" [level=3] [ref=e216]
|
||||
- paragraph [ref=e217]: 3 items awaiting response
|
||||
- table [ref=e219]:
|
||||
- rowgroup [ref=e220]:
|
||||
- row "Type Reference Subject SLA Deadline Action" [ref=e221]:
|
||||
- columnheader "Type" [ref=e222]
|
||||
- columnheader "Reference" [ref=e223]
|
||||
- columnheader "Subject" [ref=e224]
|
||||
- columnheader "SLA Deadline" [ref=e225]
|
||||
- columnheader "Action" [ref=e226]
|
||||
- rowgroup [ref=e227]:
|
||||
- row "Complaint Response CMP-202607-0006 Test Complaint - Analysis Required - Respond" [ref=e228]:
|
||||
- cell "Complaint Response" [ref=e229]:
|
||||
- generic [ref=e230]: Complaint Response
|
||||
- cell "CMP-202607-0006" [ref=e231]
|
||||
- cell "Test Complaint - Analysis Required" [ref=e232]
|
||||
- cell "-" [ref=e233]
|
||||
- cell "Respond" [ref=e234]:
|
||||
- link "Respond" [ref=e236] [cursor=pointer]:
|
||||
- /url: /complaints/f8aa6216-bb94-455e-ba18-d16a01024662/explain/dM0ilaUpzA1OuAceyjvwV9oxYA0geW_jsgipvrYUt_g/
|
||||
- img [ref=e237]
|
||||
- text: Respond
|
||||
- row "Complaint Explanation CMP-202607-0005 Test Complaint - No Details - Respond" [ref=e239]:
|
||||
- cell "Complaint Explanation" [ref=e240]:
|
||||
- generic [ref=e241]: Complaint Explanation
|
||||
- cell "CMP-202607-0005" [ref=e242]
|
||||
- cell "Test Complaint - No Details" [ref=e243]
|
||||
- cell "-" [ref=e244]
|
||||
- cell "Respond" [ref=e245]:
|
||||
- link "Respond" [ref=e247] [cursor=pointer]:
|
||||
- /url: /complaints/9ca01d52-712d-4bca-b30d-6f1f818ff839/explain/iBIf0PrMq1RZYSEIDxSn4WZik4PFk6dunoNwarFTnXI/
|
||||
- img [ref=e248]
|
||||
- text: Respond
|
||||
- row "Complaint Explanation CMP-202607-0005 Test Complaint - No Details - Respond" [ref=e252]:
|
||||
- cell "Complaint Explanation" [ref=e253]:
|
||||
- generic [ref=e254]: Complaint Explanation
|
||||
- cell "CMP-202607-0005" [ref=e255]
|
||||
- cell "Test Complaint - No Details" [ref=e256]
|
||||
- cell "-" [ref=e257]
|
||||
- cell "Respond" [ref=e258]:
|
||||
- link "Respond" [ref=e260] [cursor=pointer]:
|
||||
- /url: /complaints/9ca01d52-712d-4bca-b30d-6f1f818ff839/explain/X1Zd54Tb1NcExZeRr6ACN5RtPCwB_RTKeqNeFcVP24E/
|
||||
- img [ref=e261]
|
||||
- text: Respond
|
||||
- generic [ref=e266]:
|
||||
- button "Dashboard" [ref=e267] [cursor=pointer]:
|
||||
- img [ref=e268]
|
||||
- text: Dashboard
|
||||
- button "Staff (4)" [ref=e270] [cursor=pointer]:
|
||||
- img [ref=e271]
|
||||
- text: Staff (4)
|
||||
- button "Roles" [ref=e276] [cursor=pointer]:
|
||||
- img [ref=e277]
|
||||
- text: Roles
|
||||
- button "Sections (2)" [ref=e289] [cursor=pointer]:
|
||||
- img [ref=e290]
|
||||
- text: Sections (2)
|
||||
- button "Complaints (3)" [ref=e294] [cursor=pointer]:
|
||||
- img [ref=e295]
|
||||
- text: Complaints (3)
|
||||
- button "Inquiries (2)" [ref=e298] [cursor=pointer]:
|
||||
- img [ref=e299]
|
||||
- text: Inquiries (2)
|
||||
- button "Observations (1)" [ref=e302] [cursor=pointer]:
|
||||
- img [ref=e303]
|
||||
- text: Observations (1)
|
||||
- button "Suggestions (1)" [ref=e306] [cursor=pointer]:
|
||||
- img [ref=e307]
|
||||
- text: Suggestions (1)
|
||||
- button "Appreciations (2)" [ref=e309] [cursor=pointer]:
|
||||
- img [ref=e310]
|
||||
- text: Appreciations (2)
|
||||
- button "Standards (0)" [ref=e312] [cursor=pointer]:
|
||||
- img [ref=e313]
|
||||
- text: Standards (0)
|
||||
- button "Projects (8)" [ref=e316] [cursor=pointer]:
|
||||
- img [ref=e317]
|
||||
- text: Projects (8)
|
||||
- generic [ref=e321]:
|
||||
- generic [ref=e322]:
|
||||
- generic [ref=e323]:
|
||||
- paragraph [ref=e324]: Avg Physician Rating
|
||||
- paragraph [ref=e325]: "0.0"
|
||||
- paragraph [ref=e326]: out of 5.0
|
||||
- generic [ref=e327]:
|
||||
- paragraph [ref=e328]: Resolution Rate
|
||||
- paragraph [ref=e329]: 0%
|
||||
- paragraph [ref=e330]: 3 total
|
||||
- generic [ref=e331]:
|
||||
- paragraph [ref=e332]: Open Actions
|
||||
- paragraph [ref=e333]: "3"
|
||||
- paragraph [ref=e334]: PX actions pending
|
||||
- generic [ref=e335]:
|
||||
- paragraph [ref=e336]: Satisfaction Rate
|
||||
- paragraph [ref=e337]: 0%
|
||||
- paragraph [ref=e338]: resolution satisfaction
|
||||
- generic [ref=e339]:
|
||||
- paragraph [ref=e340]: Reopened
|
||||
- paragraph [ref=e341]: "0"
|
||||
- paragraph [ref=e342]: reopened complaints
|
||||
- generic [ref=e343]:
|
||||
- paragraph [ref=e344]: Reassigned
|
||||
- paragraph [ref=e345]: "5"
|
||||
- paragraph [ref=e346]: reassigned complaints
|
||||
- generic [ref=e347]:
|
||||
- generic [ref=e348]:
|
||||
- heading "Complaint Trend" [level=4] [ref=e349]
|
||||
- generic [ref=e351]:
|
||||
- application "area chart with 1 data series" [ref=e352]:
|
||||
- generic [ref=e355]:
|
||||
- generic "4" [ref=e356]
|
||||
- generic "3.5" [ref=e357]
|
||||
- generic "3" [ref=e358]
|
||||
- generic "2.5" [ref=e359]
|
||||
- generic "2" [ref=e360]
|
||||
- generic [ref=e361]:
|
||||
- generic [ref=e365]:
|
||||
- generic:
|
||||
- generic:
|
||||
- generic: "3"
|
||||
- generic "2026-07" [ref=e371]
|
||||
- tooltip: ●
|
||||
- generic [ref=e372]:
|
||||
- heading "Physician Rating Trend" [level=4] [ref=e373]
|
||||
- paragraph [ref=e375]: No physician rating data
|
||||
- generic [ref=e376]:
|
||||
- heading "Complaint Status" [level=4] [ref=e377]
|
||||
- generic [ref=e379]:
|
||||
- application "donut chart with 1 data series" [ref=e380]:
|
||||
- generic [ref=e386]:
|
||||
- generic:
|
||||
- generic: 100.0%
|
||||
- button "In Progress, visible. Press Enter or Space to toggle." [ref=e390] [cursor=pointer]:
|
||||
- generic [ref=e391]:
|
||||
- img
|
||||
- generic [ref=e392]: In Progress
|
||||
- text: ●
|
||||
- generic [ref=e393]:
|
||||
- heading "Actions Overview" [level=4] [ref=e394]
|
||||
- generic [ref=e396]:
|
||||
- application "donut chart with 2 data series" [ref=e397]:
|
||||
- generic [ref=e403]:
|
||||
- generic:
|
||||
- generic: 66.7%
|
||||
- generic:
|
||||
- generic: 33.3%
|
||||
- generic [ref=e406]:
|
||||
- button "In Progress, visible. Press Enter or Space to toggle." [ref=e407] [cursor=pointer]:
|
||||
- generic [ref=e408]:
|
||||
- img
|
||||
- generic [ref=e409]: In Progress
|
||||
- button "Open, visible. Press Enter or Space to toggle." [ref=e410] [cursor=pointer]:
|
||||
- generic [ref=e411]:
|
||||
- img
|
||||
- generic [ref=e412]: Open
|
||||
- text: ● ●
|
||||
- generic [ref=e413]:
|
||||
- heading "Complaint Severity" [level=4] [ref=e414]
|
||||
- generic [ref=e416]:
|
||||
- application "bar chart with 1 data series" [ref=e417]:
|
||||
- generic [ref=e420]:
|
||||
- generic "2" [ref=e421]
|
||||
- generic "1.5" [ref=e422]
|
||||
- generic "1" [ref=e423]
|
||||
- generic "0.5" [ref=e424]
|
||||
- generic "0" [ref=e425]
|
||||
- generic [ref=e426]:
|
||||
- generic [ref=e430]:
|
||||
- generic:
|
||||
- generic:
|
||||
- generic: "2"
|
||||
- generic:
|
||||
- generic: "1"
|
||||
- generic [ref=e435]:
|
||||
- generic "Low" [ref=e436]
|
||||
- generic "Medium" [ref=e437]
|
||||
- tooltip: ●
|
||||
- generic [ref=e438]:
|
||||
- heading "Satisfaction Distribution" [level=4] [ref=e439]
|
||||
- paragraph [ref=e441]: No satisfaction data
|
||||
- generic [ref=e442]:
|
||||
- heading "Top Physicians" [level=4] [ref=e443]
|
||||
- paragraph [ref=e445]: No physician data
|
||||
- generic [ref=e446]:
|
||||
- heading "Top Categories" [level=4] [ref=e447]
|
||||
- generic [ref=e449]:
|
||||
- application "bar chart with 1 data series" [ref=e450]:
|
||||
- generic [ref=e452]:
|
||||
- generic [ref=e455]:
|
||||
- generic:
|
||||
- generic:
|
||||
- generic: "3"
|
||||
- generic "Institutional Issues" [ref=e460]
|
||||
- generic [ref=e462]:
|
||||
- generic "3.0" [ref=e463]
|
||||
- generic "2.5" [ref=e464]
|
||||
- generic "2.0" [ref=e465]
|
||||
- generic "1.5" [ref=e466]
|
||||
- generic "1.0" [ref=e467]
|
||||
- generic "0.5" [ref=e468]
|
||||
- generic "0.0" [ref=e469]
|
||||
- tooltip: ●
|
||||
- contentinfo [ref=e470]:
|
||||
- paragraph [ref=e471]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e472] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,258 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Inquiries Console" [level=1] [ref=e142]
|
||||
- paragraph [ref=e143]: Manage patient inquiries and requests
|
||||
- link "New Inquiry" [ref=e144] [cursor=pointer]:
|
||||
- /url: /inquiries/new/
|
||||
- img [ref=e145]
|
||||
- text: New Inquiry
|
||||
- generic [ref=e146]:
|
||||
- generic [ref=e147]:
|
||||
- img [ref=e150]
|
||||
- generic [ref=e153]:
|
||||
- heading "Total Inquiries" [level=6] [ref=e154]
|
||||
- heading "5" [level=2] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- img [ref=e159]
|
||||
- generic [ref=e162]:
|
||||
- heading "Resolved" [level=6] [ref=e163]
|
||||
- heading "2 (40.0%)" [level=2] [ref=e164]
|
||||
- generic [ref=e165]:
|
||||
- img [ref=e168]
|
||||
- generic [ref=e171]:
|
||||
- heading "In Progress" [level=6] [ref=e172]
|
||||
- heading "1" [level=2] [ref=e173]
|
||||
- generic [ref=e174]:
|
||||
- img [ref=e177]
|
||||
- generic [ref=e179]:
|
||||
- heading "Overdue" [level=6] [ref=e180]
|
||||
- heading "0" [level=2] [ref=e181]
|
||||
- generic [ref=e182]:
|
||||
- generic [ref=e183]:
|
||||
- img [ref=e185]
|
||||
- heading "Filters" [level=5] [ref=e186]
|
||||
- generic [ref=e188]:
|
||||
- generic [ref=e189]:
|
||||
- generic [ref=e190]: Search
|
||||
- textbox "Subject, contact name..." [ref=e191]
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]: Status
|
||||
- combobox [ref=e194]:
|
||||
- option "All Status" [selected]
|
||||
- option "Open"
|
||||
- option "In Progress"
|
||||
- option "Resolved"
|
||||
- option "Closed"
|
||||
- generic [ref=e195]:
|
||||
- generic [ref=e196]: Category
|
||||
- combobox [ref=e197]:
|
||||
- option "All Categories" [selected]
|
||||
- option "General"
|
||||
- option "Appointments"
|
||||
- option "Billing"
|
||||
- option "Medical Records"
|
||||
- option "Pharmacy"
|
||||
- option "Insurance"
|
||||
- option "Feedback"
|
||||
- option "Other"
|
||||
- button "Filter" [ref=e198] [cursor=pointer]:
|
||||
- img [ref=e199]
|
||||
- text: Filter
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]:
|
||||
- generic [ref=e204]:
|
||||
- img [ref=e206]
|
||||
- heading "All Inquiries" [level=5] [ref=e209]
|
||||
- generic [ref=e210]: "Total: 5"
|
||||
- paragraph [ref=e211]: "Showing: 1-5 of 5"
|
||||
- generic [ref=e212]:
|
||||
- table [ref=e213]:
|
||||
- rowgroup [ref=e214]:
|
||||
- row "Reference Subject Contact Category Status Priority Created Assigned" [ref=e215]:
|
||||
- columnheader "Reference" [ref=e216]
|
||||
- columnheader "Subject" [ref=e217]
|
||||
- columnheader "Contact" [ref=e218]
|
||||
- columnheader "Category" [ref=e219]
|
||||
- columnheader "Status" [ref=e220]
|
||||
- columnheader "Priority" [ref=e221]
|
||||
- columnheader "Created" [ref=e222]
|
||||
- columnheader "Assigned" [ref=e223]
|
||||
- rowgroup [ref=e224]:
|
||||
- row "INQ-202607-0007 test12121212 ismail mosa 0569874563 General Information Open Medium Jul 12, 2026 Unassigned" [ref=e225] [cursor=pointer]:
|
||||
- cell "INQ-202607-0007" [ref=e226]:
|
||||
- code [ref=e227]: INQ-202607-0007
|
||||
- cell "test12121212" [ref=e228]:
|
||||
- link "test12121212" [ref=e229]:
|
||||
- /url: /inquiries/b4a65498-e8d8-403c-82bb-ae09adc7fc1c/
|
||||
- cell "ismail mosa 0569874563" [ref=e230]:
|
||||
- generic [ref=e231]: ismail mosa
|
||||
- generic [ref=e232]: "0569874563"
|
||||
- cell "General Information" [ref=e233]
|
||||
- cell "Open" [ref=e234]
|
||||
- cell "Medium" [ref=e235]
|
||||
- cell "Jul 12, 2026" [ref=e236]
|
||||
- cell "Unassigned" [ref=e237]
|
||||
- row "INQ-202607-0006 test12121212 test 0569874563 General Information Open Medium Jul 12, 2026 Unassigned" [ref=e238] [cursor=pointer]:
|
||||
- cell "INQ-202607-0006" [ref=e239]:
|
||||
- code [ref=e240]: INQ-202607-0006
|
||||
- cell "test12121212" [ref=e241]:
|
||||
- link "test12121212" [ref=e242]:
|
||||
- /url: /inquiries/1c3eda08-ff03-4521-bcb8-ec137c79b417/
|
||||
- cell "test 0569874563" [ref=e243]:
|
||||
- generic [ref=e244]: test
|
||||
- generic [ref=e245]: "0569874563"
|
||||
- cell "General Information" [ref=e246]
|
||||
- cell "Open" [ref=e247]
|
||||
- cell "Medium" [ref=e248]
|
||||
- cell "Jul 12, 2026" [ref=e249]
|
||||
- cell "Unassigned" [ref=e250]
|
||||
- row "INQ-202607-0005 test test 0569874589 General Information In Progress Medium Jul 09, 2026 Unassigned" [ref=e251] [cursor=pointer]:
|
||||
- cell "INQ-202607-0005" [ref=e252]:
|
||||
- code [ref=e253]: INQ-202607-0005
|
||||
- cell "test" [ref=e254]:
|
||||
- link "test" [ref=e255]:
|
||||
- /url: /inquiries/c05309c2-3518-46ae-9b1c-c144acdb5d58/
|
||||
- cell "test 0569874589" [ref=e256]:
|
||||
- generic [ref=e257]: test
|
||||
- generic [ref=e258]: "0569874589"
|
||||
- cell "General Information" [ref=e259]
|
||||
- cell "In Progress" [ref=e260]
|
||||
- cell "Medium" [ref=e261]
|
||||
- cell "Jul 09, 2026" [ref=e262]
|
||||
- cell "Unassigned" [ref=e263]
|
||||
- row "INQ-202607-0004 hi ismail 0569874589 General Information Resolved Medium Jul 09, 2026 ismail mosa" [ref=e264] [cursor=pointer]:
|
||||
- cell "INQ-202607-0004" [ref=e265]:
|
||||
- code [ref=e266]: INQ-202607-0004
|
||||
- cell "hi" [ref=e267]:
|
||||
- link "hi" [ref=e268]:
|
||||
- /url: /inquiries/d1df0b83-5c16-4f51-b735-e89f612deb50/
|
||||
- cell "ismail 0569874589" [ref=e269]:
|
||||
- generic [ref=e270]: ismail
|
||||
- generic [ref=e271]: "0569874589"
|
||||
- cell "General Information" [ref=e272]
|
||||
- cell "Resolved" [ref=e273]
|
||||
- cell "Medium" [ref=e274]
|
||||
- cell "Jul 09, 2026" [ref=e275]
|
||||
- cell "ismail mosa" [ref=e276]
|
||||
- row "INQ-202607-0003 test test 0569874589 General Information Resolved Medium Jul 08, 2026 ismail mosa" [ref=e277] [cursor=pointer]:
|
||||
- cell "INQ-202607-0003" [ref=e278]:
|
||||
- code [ref=e279]: INQ-202607-0003
|
||||
- cell "test" [ref=e280]:
|
||||
- link "test" [ref=e281]:
|
||||
- /url: /inquiries/031cbd06-e2dc-4cd9-9b81-b1218e090d6c/
|
||||
- cell "test 0569874589" [ref=e282]:
|
||||
- generic [ref=e283]: test
|
||||
- generic [ref=e284]: "0569874589"
|
||||
- cell "General Information" [ref=e285]
|
||||
- cell "Resolved" [ref=e286]
|
||||
- cell "Medium" [ref=e287]
|
||||
- cell "Jul 08, 2026" [ref=e288]
|
||||
- cell "ismail mosa" [ref=e289]
|
||||
- generic [ref=e290]:
|
||||
- generic [ref=e291]:
|
||||
- generic [ref=e292]: Showing 1-5 of 5
|
||||
- generic [ref=e293]:
|
||||
- generic [ref=e294]: Show
|
||||
- combobox [ref=e295]:
|
||||
- option "10"
|
||||
- option "25" [selected]
|
||||
- option "50"
|
||||
- option "100"
|
||||
- generic [ref=e296]:
|
||||
- img [ref=e298]
|
||||
- generic [ref=e300]: "1"
|
||||
- img [ref=e302]
|
||||
- contentinfo [ref=e304]:
|
||||
- paragraph [ref=e305]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e306] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,243 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Complaints Registry" [level=1] [ref=e142]
|
||||
- paragraph [ref=e143]: Manage and monitor patient feedback in real-time
|
||||
- generic [ref=e144]:
|
||||
- generic [ref=e145]:
|
||||
- img [ref=e146]
|
||||
- textbox "Search ID, Name or Dept..." [ref=e149]
|
||||
- link "Public Form" [ref=e150] [cursor=pointer]:
|
||||
- /url: /core/public/submit/
|
||||
- img [ref=e151]
|
||||
- text: Public Form
|
||||
- button "Export" [ref=e155] [cursor=pointer]:
|
||||
- img [ref=e156]
|
||||
- text: Export
|
||||
- link "New Case" [ref=e159] [cursor=pointer]:
|
||||
- /url: /complaints/new/
|
||||
- img [ref=e160]
|
||||
- text: New Case
|
||||
- generic [ref=e161]:
|
||||
- generic [ref=e162]:
|
||||
- img [ref=e165]
|
||||
- generic [ref=e169]:
|
||||
- heading "Total Received" [level=6] [ref=e170]
|
||||
- heading "3" [level=2] [ref=e171]
|
||||
- generic [ref=e172]:
|
||||
- img [ref=e175]
|
||||
- generic [ref=e178]:
|
||||
- heading "Resolved" [level=6] [ref=e179]
|
||||
- heading "0 (0.0%)" [level=2] [ref=e180]
|
||||
- generic [ref=e181]:
|
||||
- img [ref=e184]
|
||||
- generic [ref=e187]:
|
||||
- heading "Pending" [level=6] [ref=e188]
|
||||
- heading "3 (100.0%)" [level=2] [ref=e189]
|
||||
- generic [ref=e190]:
|
||||
- img [ref=e193]
|
||||
- generic [ref=e195]:
|
||||
- heading "TAT Alert" [level=6] [ref=e196]
|
||||
- heading "3 (100.0%)" [level=2] [ref=e197]
|
||||
- generic [ref=e198]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]:
|
||||
- img [ref=e202]
|
||||
- heading "Complaints" [level=5] [ref=e204]
|
||||
- generic [ref=e205]:
|
||||
- link "All Cases" [ref=e206] [cursor=pointer]:
|
||||
- /url: "?"
|
||||
- link "Pending" [ref=e207] [cursor=pointer]:
|
||||
- /url: "?status=open"
|
||||
- link "In Progress" [ref=e208] [cursor=pointer]:
|
||||
- /url: "?status=in_progress"
|
||||
- link "Resolved" [ref=e209] [cursor=pointer]:
|
||||
- /url: "?status=resolved"
|
||||
- button "Advanced" [ref=e211] [cursor=pointer]:
|
||||
- img [ref=e212]
|
||||
- text: Advanced
|
||||
- paragraph [ref=e213]: "Showing: 1-3 of 3"
|
||||
- generic [ref=e214]:
|
||||
- table [ref=e215]:
|
||||
- rowgroup [ref=e216]:
|
||||
- row "Complaint ID Patient Name Description Source Department Status SLA Severity Priority Assigned Actions" [ref=e217]:
|
||||
- columnheader "Complaint ID" [ref=e218]
|
||||
- columnheader "Patient Name" [ref=e219]
|
||||
- columnheader "Description" [ref=e220]
|
||||
- columnheader "Source" [ref=e221]
|
||||
- columnheader "Department" [ref=e222]
|
||||
- columnheader "Status" [ref=e223]
|
||||
- columnheader "SLA" [ref=e224]
|
||||
- columnheader "Severity" [ref=e225]
|
||||
- columnheader "Priority" [ref=e226]
|
||||
- columnheader "Assigned" [ref=e227]
|
||||
- columnheader "Actions" [ref=e228]
|
||||
- rowgroup [ref=e229]:
|
||||
- row "CMP-202607-0006 test شكوى عامة Patient Patient Experience Department In Progress Overdue 21h 46m Medium Medium test test" [ref=e230] [cursor=pointer]:
|
||||
- cell "CMP-202607-0006" [ref=e231]
|
||||
- cell "test" [ref=e232]:
|
||||
- generic [ref=e233]: test
|
||||
- cell "شكوى عامة" [ref=e234]:
|
||||
- generic [ref=e235]: شكوى عامة
|
||||
- button "View description" [ref=e236]:
|
||||
- img [ref=e237]
|
||||
- cell "Patient" [ref=e246]
|
||||
- cell "Patient Experience Department" [ref=e247]
|
||||
- cell "In Progress" [ref=e248]
|
||||
- cell "Overdue 21h 46m" [ref=e249]
|
||||
- cell "Medium" [ref=e250]
|
||||
- cell "Medium" [ref=e251]
|
||||
- cell "test test" [ref=e252]
|
||||
- cell [ref=e253]:
|
||||
- generic [ref=e254]:
|
||||
- link "View" [ref=e255]:
|
||||
- /url: /complaints/f8aa6216-bb94-455e-ba18-d16a01024662/
|
||||
- img [ref=e256]
|
||||
- link "Assign" [ref=e259]:
|
||||
- /url: /complaints/f8aa6216-bb94-455e-ba18-d16a01024662/assign/
|
||||
- img [ref=e260]
|
||||
- row "CMP-202607-0005 ismail شكوى اختبار Patient Contact Center Department In Progress Overdue 23h 22m Low Low test test" [ref=e263] [cursor=pointer]:
|
||||
- cell "CMP-202607-0005" [ref=e264]
|
||||
- cell "ismail" [ref=e265]:
|
||||
- generic [ref=e266]: ismail
|
||||
- cell "شكوى اختبار" [ref=e267]:
|
||||
- generic [ref=e268]: شكوى اختبار
|
||||
- button "View description" [ref=e269]:
|
||||
- img [ref=e270]
|
||||
- cell "Patient" [ref=e279]
|
||||
- cell "Contact Center Department" [ref=e280]
|
||||
- cell "In Progress" [ref=e281]
|
||||
- cell "Overdue 23h 22m" [ref=e282]
|
||||
- cell "Low" [ref=e283]
|
||||
- cell "Low" [ref=e284]
|
||||
- cell "test test" [ref=e285]
|
||||
- cell [ref=e286]:
|
||||
- link "View" [ref=e288]:
|
||||
- /url: /complaints/9ca01d52-712d-4bca-b30d-6f1f818ff839/
|
||||
- img [ref=e289]
|
||||
- row "CMP-202607-0004 OVR ismail اختبار عام Internal Contact Center Department In Progress Overdue 4d 7h 22m Low Low ismail mosa" [ref=e292] [cursor=pointer]:
|
||||
- cell "CMP-202607-0004 OVR" [ref=e293]
|
||||
- cell "ismail" [ref=e294]:
|
||||
- generic [ref=e295]: ismail
|
||||
- cell "اختبار عام" [ref=e296]:
|
||||
- generic [ref=e297]: اختبار عام
|
||||
- button "View description" [ref=e298]:
|
||||
- img [ref=e299]
|
||||
- cell "Internal" [ref=e308]
|
||||
- cell "Contact Center Department" [ref=e309]
|
||||
- cell "In Progress" [ref=e310]
|
||||
- cell "Overdue 4d 7h 22m" [ref=e311]
|
||||
- cell "Low" [ref=e312]
|
||||
- cell "Low" [ref=e313]
|
||||
- cell "ismail mosa" [ref=e314]
|
||||
- cell [ref=e315]:
|
||||
- link "View" [ref=e317]:
|
||||
- /url: /complaints/1c4e8dde-48d8-42ab-8c8d-4ada8314fc40/
|
||||
- img [ref=e318]
|
||||
- generic [ref=e321]:
|
||||
- generic [ref=e322]:
|
||||
- generic [ref=e323]: Showing 1-3 of 3
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e325]: Show
|
||||
- combobox [ref=e326]:
|
||||
- option "10"
|
||||
- option "25" [selected]
|
||||
- option "50"
|
||||
- option "100"
|
||||
- generic [ref=e327]:
|
||||
- img [ref=e329]
|
||||
- generic [ref=e331]: "1"
|
||||
- img [ref=e333]
|
||||
- contentinfo [ref=e335]:
|
||||
- paragraph [ref=e336]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e337] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,344 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Observations" [level=1] [ref=e142]:
|
||||
- img [ref=e143]
|
||||
- text: Observations
|
||||
- paragraph [ref=e146]: Manage and triage staff-reported observations
|
||||
- generic [ref=e147]:
|
||||
- link "New Observation" [ref=e148] [cursor=pointer]:
|
||||
- /url: /observations/create/
|
||||
- img [ref=e149]
|
||||
- text: New Observation
|
||||
- link "Public Form" [ref=e151] [cursor=pointer]:
|
||||
- /url: /core/public/submit/
|
||||
- img [ref=e152]
|
||||
- text: Public Form
|
||||
- generic [ref=e153]:
|
||||
- generic [ref=e155]:
|
||||
- generic [ref=e156]:
|
||||
- paragraph [ref=e157]: Total
|
||||
- paragraph [ref=e158]: "2"
|
||||
- img [ref=e160]
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- paragraph [ref=e164]: New
|
||||
- paragraph
|
||||
- img [ref=e166]
|
||||
- generic [ref=e170]:
|
||||
- generic [ref=e171]:
|
||||
- paragraph [ref=e172]: In Progress
|
||||
- paragraph [ref=e173]: "2"
|
||||
- img [ref=e175]
|
||||
- generic [ref=e178]:
|
||||
- generic [ref=e179]:
|
||||
- img [ref=e181]
|
||||
- heading "Filters" [level=5] [ref=e183]
|
||||
- button [ref=e184] [cursor=pointer]:
|
||||
- img [ref=e185]
|
||||
- generic [ref=e188]:
|
||||
- generic [ref=e189]:
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: Search
|
||||
- textbox "Tracking code, description..." [ref=e192]
|
||||
- generic [ref=e193]:
|
||||
- generic [ref=e194]: Status
|
||||
- combobox [ref=e195]:
|
||||
- option "All Statuses" [selected]
|
||||
- option "Open"
|
||||
- option "In Progress"
|
||||
- option "Resolved"
|
||||
- option "Closed"
|
||||
- generic [ref=e196]:
|
||||
- generic [ref=e197]: Severity
|
||||
- combobox [ref=e198]:
|
||||
- option "All Severities" [selected]
|
||||
- option "Low"
|
||||
- option "Medium"
|
||||
- option "High"
|
||||
- option "Critical"
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: Category
|
||||
- combobox [ref=e201]:
|
||||
- option "All Categories" [selected]
|
||||
- option "Patient Safety"
|
||||
- option "Clinical Quality"
|
||||
- option "Infection Control"
|
||||
- option "Medication Safety"
|
||||
- option "Equipment & Devices"
|
||||
- option "Facility & Environment"
|
||||
- option "Staff Behavior"
|
||||
- option "Communication"
|
||||
- option "Documentation"
|
||||
- option "Process & Workflow"
|
||||
- option "Security"
|
||||
- option "IT & Systems"
|
||||
- option "Housekeeping"
|
||||
- option "Food Services"
|
||||
- option "Other"
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Department
|
||||
- combobox [ref=e204]:
|
||||
- option "All Departments" [selected]
|
||||
- option "Contact Center Department"
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Executive Administration"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Security Department"
|
||||
- option "Anesthesia Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Surgery Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Nursing Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Laundry Department"
|
||||
- generic [ref=e206] [cursor=pointer]:
|
||||
- generic [ref=e207]: All Departments
|
||||
- combobox "All Departments" [ref=e208]
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]: Assigned To
|
||||
- combobox [ref=e211]:
|
||||
- option "All Users" [selected]
|
||||
- option "E2E PX Employee"
|
||||
- option "test test"
|
||||
- option "test test"
|
||||
- generic [ref=e212]:
|
||||
- generic [ref=e213]: Date From
|
||||
- textbox [ref=e214]
|
||||
- generic [ref=e215]:
|
||||
- generic [ref=e216]: Date To
|
||||
- textbox [ref=e217]
|
||||
- generic [ref=e218]:
|
||||
- button "Apply Filters" [ref=e219] [cursor=pointer]:
|
||||
- img [ref=e220]
|
||||
- text: Apply Filters
|
||||
- link "Clear" [ref=e223] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e224]
|
||||
- text: Clear
|
||||
- generic [ref=e229]:
|
||||
- img [ref=e231]
|
||||
- heading "Observations List" [level=5] [ref=e233]
|
||||
- generic [ref=e234]: Showing 1 to 4 of 4 observations
|
||||
- table [ref=e237]:
|
||||
- rowgroup [ref=e238]:
|
||||
- row "Reference Date Category Description Severity Status Reporter Department Assigned Actions" [ref=e239]:
|
||||
- columnheader "Reference" [ref=e240]
|
||||
- columnheader "Date" [ref=e241]
|
||||
- columnheader "Category" [ref=e242]
|
||||
- columnheader "Description" [ref=e243]
|
||||
- columnheader "Severity" [ref=e244]
|
||||
- columnheader "Status" [ref=e245]
|
||||
- columnheader "Reporter" [ref=e246]
|
||||
- columnheader "Department" [ref=e247]
|
||||
- columnheader "Assigned" [ref=e248]
|
||||
- columnheader "Actions" [ref=e249]
|
||||
- rowgroup [ref=e250]:
|
||||
- row "OBS-202607-0001 Jul 08, 2026 16:33 - test observation Medium In Progress ismail mosa Contact Center Department ismail mosa" [ref=e251]:
|
||||
- cell "OBS-202607-0001" [ref=e252]
|
||||
- cell "Jul 08, 2026 16:33" [ref=e253]:
|
||||
- text: Jul 08, 2026
|
||||
- text: 16:33
|
||||
- cell "-" [ref=e254]
|
||||
- cell "test observation" [ref=e255]:
|
||||
- generic [ref=e256]: test observation
|
||||
- cell "Medium" [ref=e257]
|
||||
- cell "In Progress" [ref=e258]
|
||||
- cell "ismail mosa" [ref=e259]
|
||||
- cell "Contact Center Department" [ref=e260]
|
||||
- cell "ismail mosa" [ref=e261]
|
||||
- cell [ref=e262]:
|
||||
- link [ref=e263] [cursor=pointer]:
|
||||
- /url: /observations/1d925706-110e-4bad-808f-26aae2029144/
|
||||
- img [ref=e264]
|
||||
- row "OBS-202606-E2EHOSP-0056 Jun 22, 2026 10:16 Other Observation i notice some trash in the hall Medium In Progress E2E PX Admin Contact Center Department E2E PX Admin" [ref=e267]:
|
||||
- cell "OBS-202606-E2EHOSP-0056" [ref=e268]
|
||||
- cell "Jun 22, 2026 10:16" [ref=e269]:
|
||||
- text: Jun 22, 2026
|
||||
- text: 10:16
|
||||
- cell "Other" [ref=e270]:
|
||||
- generic [ref=e271]: Other
|
||||
- cell "Observation i notice some trash in the hall" [ref=e272]:
|
||||
- generic [ref=e273]:
|
||||
- strong [ref=e274]: Observation
|
||||
- text: i notice some trash in the hall
|
||||
- cell "Medium" [ref=e275]
|
||||
- cell "In Progress" [ref=e276]
|
||||
- cell "E2E PX Admin" [ref=e277]
|
||||
- cell "Contact Center Department" [ref=e278]
|
||||
- cell "E2E PX Admin" [ref=e279]
|
||||
- cell [ref=e280]:
|
||||
- link [ref=e281] [cursor=pointer]:
|
||||
- /url: /observations/b9a80c99-31dc-4cda-a1e3-c20d94a28729/
|
||||
- img [ref=e282]
|
||||
- row "OBS-O8TCIC Jun 09, 2026 13:36 - Wet floor near the main entrance, no warning sign posted. Medium In Progress Anonymous Reporter - ismail mosa" [ref=e285]:
|
||||
- cell "OBS-O8TCIC" [ref=e286]
|
||||
- cell "Jun 09, 2026 13:36" [ref=e287]:
|
||||
- text: Jun 09, 2026
|
||||
- text: 13:36
|
||||
- cell "-" [ref=e288]
|
||||
- cell "Wet floor near the main entrance, no warning sign posted." [ref=e289]:
|
||||
- generic [ref=e290]: Wet floor near the main entrance, no warning sign posted.
|
||||
- cell "Medium" [ref=e291]
|
||||
- cell "In Progress" [ref=e292]
|
||||
- cell "Anonymous Reporter" [ref=e293]
|
||||
- cell "-" [ref=e294]
|
||||
- cell "ismail mosa" [ref=e295]
|
||||
- cell [ref=e296]:
|
||||
- link [ref=e297] [cursor=pointer]:
|
||||
- /url: /observations/5f8fc6c6-3b38-4b88-8144-3f926c24e290/
|
||||
- img [ref=e298]
|
||||
- row "OBS-QPE02F Jun 07, 2026 21:08 Other Observation safsafsad Medium Open ismail Daif - Unassigned" [ref=e301]:
|
||||
- cell "OBS-QPE02F" [ref=e302]
|
||||
- cell "Jun 07, 2026 21:08" [ref=e303]:
|
||||
- text: Jun 07, 2026
|
||||
- text: 21:08
|
||||
- cell "Other" [ref=e304]:
|
||||
- generic [ref=e305]: Other
|
||||
- cell "Observation safsafsad" [ref=e306]:
|
||||
- generic [ref=e307]:
|
||||
- strong [ref=e308]: Observation
|
||||
- text: safsafsad
|
||||
- cell "Medium" [ref=e309]
|
||||
- cell "Open" [ref=e310]
|
||||
- cell "ismail Daif" [ref=e311]
|
||||
- cell "-" [ref=e312]
|
||||
- cell "Unassigned" [ref=e313]
|
||||
- cell [ref=e314]:
|
||||
- link [ref=e315] [cursor=pointer]:
|
||||
- /url: /observations/327d9b79-2126-4800-8cd7-647f006271ce/
|
||||
- img [ref=e316]
|
||||
- generic [ref=e319]:
|
||||
- generic [ref=e320]:
|
||||
- generic [ref=e321]: Showing 1-4 of 4
|
||||
- generic [ref=e322]:
|
||||
- generic [ref=e323]: Show
|
||||
- combobox [ref=e324]:
|
||||
- option "10"
|
||||
- option "25" [selected]
|
||||
- option "50"
|
||||
- option "100"
|
||||
- generic [ref=e325]:
|
||||
- img [ref=e327]
|
||||
- generic [ref=e329]: "1"
|
||||
- img [ref=e331]
|
||||
- contentinfo [ref=e333]:
|
||||
- paragraph [ref=e334]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e335] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,201 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Suggestions" [level=1] [ref=e142]
|
||||
- paragraph [ref=e143]: Manage and review submitted suggestions
|
||||
- link "New Suggestion" [ref=e145] [cursor=pointer]:
|
||||
- /url: /suggestions/create/
|
||||
- img [ref=e146]
|
||||
- text: New Suggestion
|
||||
- generic [ref=e147]:
|
||||
- generic [ref=e148]:
|
||||
- img [ref=e151]
|
||||
- generic [ref=e153]:
|
||||
- heading "Total Suggestions" [level=6] [ref=e154]
|
||||
- heading "3" [level=2] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- img [ref=e159]
|
||||
- generic [ref=e161]:
|
||||
- heading "Compliments" [level=6] [ref=e162]
|
||||
- heading "0" [level=2] [ref=e163]
|
||||
- generic [ref=e164]:
|
||||
- img [ref=e167]
|
||||
- generic [ref=e169]:
|
||||
- heading "Avg Rating" [level=6] [ref=e170]
|
||||
- heading "0.0 /5" [level=2] [ref=e171]
|
||||
- generic [ref=e172]:
|
||||
- img [ref=e175]
|
||||
- generic [ref=e178]:
|
||||
- heading "Pending Review" [level=6] [ref=e179]
|
||||
- heading "2" [level=2] [ref=e180]
|
||||
- generic [ref=e181]:
|
||||
- generic [ref=e182]:
|
||||
- generic [ref=e183]:
|
||||
- img [ref=e185]
|
||||
- heading "Suggestions" [level=5] [ref=e187]
|
||||
- generic [ref=e188]:
|
||||
- link "All" [ref=e189] [cursor=pointer]:
|
||||
- /url: "?"
|
||||
- link "Suggestions" [ref=e190] [cursor=pointer]:
|
||||
- /url: "?feedback_type=suggestion"
|
||||
- link "Compliments" [ref=e191] [cursor=pointer]:
|
||||
- /url: "?feedback_type=compliment"
|
||||
- button "Advanced" [ref=e193] [cursor=pointer]:
|
||||
- img [ref=e194]
|
||||
- text: Advanced
|
||||
- paragraph [ref=e195]: "Showing: 1-3 of 3"
|
||||
- table [ref=e197]:
|
||||
- rowgroup [ref=e198]:
|
||||
- row "ID Type Contact Title Category Rating Sentiment Status Created Actions" [ref=e199]:
|
||||
- columnheader "ID" [ref=e200]
|
||||
- columnheader "Type" [ref=e201]
|
||||
- columnheader "Contact" [ref=e202]
|
||||
- columnheader "Title" [ref=e203]
|
||||
- columnheader "Category" [ref=e204]
|
||||
- columnheader "Rating" [ref=e205]
|
||||
- columnheader "Sentiment" [ref=e206]
|
||||
- columnheader "Status" [ref=e207]
|
||||
- columnheader "Created" [ref=e208]
|
||||
- columnheader "Actions" [ref=e209]
|
||||
- rowgroup [ref=e210]:
|
||||
- row "SGT-202607-0003 Suggestion ismail Daif t Other - Neutral Submitted Jul 09, 2026" [ref=e211] [cursor=pointer]:
|
||||
- cell "SGT-202607-0003" [ref=e212]
|
||||
- cell "Suggestion" [ref=e213]
|
||||
- cell "ismail Daif" [ref=e214]:
|
||||
- generic [ref=e215]: ismail Daif
|
||||
- cell "t" [ref=e216]:
|
||||
- generic [ref=e217]: t
|
||||
- cell "Other" [ref=e218]
|
||||
- cell "-" [ref=e219]
|
||||
- cell "Neutral" [ref=e220]
|
||||
- cell "Submitted" [ref=e221]
|
||||
- cell "Jul 09, 2026" [ref=e222]
|
||||
- cell [ref=e223]:
|
||||
- link "View" [ref=e225]:
|
||||
- /url: /suggestions/78f015c5-fbed-4e10-987f-aa8ced75545a/
|
||||
- img [ref=e226]
|
||||
- row "SGT-202607-0002 Suggestion ismail test Other - Neutral Submitted Jul 09, 2026" [ref=e229] [cursor=pointer]:
|
||||
- cell "SGT-202607-0002" [ref=e230]
|
||||
- cell "Suggestion" [ref=e231]
|
||||
- cell "ismail" [ref=e232]:
|
||||
- generic [ref=e233]: ismail
|
||||
- cell "test" [ref=e234]:
|
||||
- generic [ref=e235]: test
|
||||
- cell "Other" [ref=e236]
|
||||
- cell "-" [ref=e237]
|
||||
- cell "Neutral" [ref=e238]
|
||||
- cell "Submitted" [ref=e239]
|
||||
- cell "Jul 09, 2026" [ref=e240]
|
||||
- cell [ref=e241]:
|
||||
- link "View" [ref=e243]:
|
||||
- /url: /suggestions/04b009f3-53aa-4eac-9baf-39b3633e56b0/
|
||||
- img [ref=e244]
|
||||
- row "SGT-202607-0001 Suggestion ismail The suggestion titled 'test' with content 'test' appears … Other - Neutral Closed Jul 09, 2026" [ref=e247] [cursor=pointer]:
|
||||
- cell "SGT-202607-0001" [ref=e248]
|
||||
- cell "Suggestion" [ref=e249]
|
||||
- cell "ismail" [ref=e250]:
|
||||
- generic [ref=e251]: ismail
|
||||
- cell "The suggestion titled 'test' with content 'test' appears …" [ref=e252]:
|
||||
- generic [ref=e253]: The suggestion titled 'test' with content 'test' appears …
|
||||
- cell "Other" [ref=e254]
|
||||
- cell "-" [ref=e255]
|
||||
- cell "Neutral" [ref=e256]
|
||||
- cell "Closed" [ref=e257]
|
||||
- cell "Jul 09, 2026" [ref=e258]
|
||||
- cell [ref=e259]:
|
||||
- link "View" [ref=e261]:
|
||||
- /url: /suggestions/ef1e633b-7fae-46e2-baab-0d5bdd71e7b7/
|
||||
- img [ref=e262]
|
||||
- contentinfo [ref=e265]:
|
||||
- paragraph [ref=e266]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e267] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,221 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Appreciations" [level=1] [ref=e142]:
|
||||
- img [ref=e143]
|
||||
- text: Appreciations
|
||||
- paragraph [ref=e145]: Manage and review patient appreciation submissions
|
||||
- generic [ref=e146]:
|
||||
- link "Create Internal" [ref=e147] [cursor=pointer]:
|
||||
- /url: /appreciation/new/
|
||||
- img [ref=e148]
|
||||
- text: Create Internal
|
||||
- link "Public Form" [ref=e149] [cursor=pointer]:
|
||||
- /url: /core/public/submit/
|
||||
- img [ref=e150]
|
||||
- text: Public Form
|
||||
- generic [ref=e154]:
|
||||
- paragraph [ref=e155]: "0"
|
||||
- paragraph [ref=e156]: Pending
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e159]:
|
||||
- img [ref=e161]
|
||||
- generic [ref=e164]:
|
||||
- paragraph [ref=e165]: Total
|
||||
- paragraph [ref=e166]: "3"
|
||||
- generic [ref=e168]:
|
||||
- img [ref=e170]
|
||||
- generic [ref=e173]:
|
||||
- paragraph [ref=e174]: Draft
|
||||
- paragraph [ref=e175]: "0"
|
||||
- generic [ref=e177]:
|
||||
- img [ref=e179]
|
||||
- generic [ref=e181]:
|
||||
- paragraph [ref=e182]: Activated
|
||||
- paragraph [ref=e183]: "0"
|
||||
- generic [ref=e185]:
|
||||
- img [ref=e187]
|
||||
- generic [ref=e190]:
|
||||
- paragraph [ref=e191]: Sent
|
||||
- paragraph [ref=e192]: "3"
|
||||
- generic [ref=e193]:
|
||||
- link "Leaderboard See top performers" [ref=e194] [cursor=pointer]:
|
||||
- /url: /appreciation/leaderboard/
|
||||
- generic [ref=e196]:
|
||||
- img [ref=e198]
|
||||
- generic [ref=e204]:
|
||||
- heading "Leaderboard" [level=3] [ref=e205]
|
||||
- paragraph [ref=e206]: See top performers
|
||||
- link "My Badges View earned badges" [ref=e207] [cursor=pointer]:
|
||||
- /url: /appreciation/badges/
|
||||
- generic [ref=e209]:
|
||||
- img [ref=e211]
|
||||
- generic [ref=e214]:
|
||||
- heading "My Badges" [level=3] [ref=e215]
|
||||
- paragraph [ref=e216]: View earned badges
|
||||
- generic [ref=e217]:
|
||||
- generic [ref=e218]:
|
||||
- img [ref=e220]
|
||||
- heading "Filters" [level=3] [ref=e222]
|
||||
- generic [ref=e224]:
|
||||
- generic [ref=e225]:
|
||||
- generic [ref=e226]: Search
|
||||
- textbox "Search messages..." [ref=e227]
|
||||
- generic [ref=e228]:
|
||||
- generic [ref=e229]: Status
|
||||
- combobox [ref=e230]:
|
||||
- option "All Status" [selected]
|
||||
- option "Draft"
|
||||
- option "Activated"
|
||||
- option "AI Analyzed"
|
||||
- option "Sent"
|
||||
- option "Acknowledged"
|
||||
- button "Search" [ref=e231] [cursor=pointer]:
|
||||
- img [ref=e232]
|
||||
- text: Search
|
||||
- link "Clear" [ref=e235] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- table [ref=e237]:
|
||||
- rowgroup [ref=e238]:
|
||||
- row "From Message Staff Department Category Status Date Actions" [ref=e239]:
|
||||
- columnheader "From" [ref=e240]
|
||||
- columnheader "Message" [ref=e241]
|
||||
- columnheader "Staff" [ref=e242]
|
||||
- columnheader "Department" [ref=e243]
|
||||
- columnheader "Category" [ref=e244]
|
||||
- columnheader "Status" [ref=e245]
|
||||
- columnheader "Date" [ref=e246]
|
||||
- columnheader "Actions" [ref=e247]
|
||||
- rowgroup [ref=e248]:
|
||||
- row "ismail thank - Contact Center Department - Sent Jul 09, 2026" [ref=e249] [cursor=pointer]:
|
||||
- cell "ismail" [ref=e250]
|
||||
- cell "thank" [ref=e251]:
|
||||
- generic [ref=e252]: thank
|
||||
- cell "-" [ref=e253]
|
||||
- cell "Contact Center Department" [ref=e254]
|
||||
- cell "-" [ref=e255]
|
||||
- cell "Sent" [ref=e256]
|
||||
- cell "Jul 09, 2026" [ref=e257]
|
||||
- cell [ref=e258]:
|
||||
- link "View" [ref=e260]:
|
||||
- /url: /appreciation/detail/5306821e-d596-498f-88c5-fa3addcdd491/
|
||||
- img [ref=e261]
|
||||
- row "ismail mosa test - Contact Center Department - Sent Jul 09, 2026" [ref=e264] [cursor=pointer]:
|
||||
- cell "ismail mosa" [ref=e265]
|
||||
- cell "test" [ref=e266]:
|
||||
- generic [ref=e267]: test
|
||||
- cell "-" [ref=e268]
|
||||
- cell "Contact Center Department" [ref=e269]
|
||||
- cell "-" [ref=e270]
|
||||
- cell "Sent" [ref=e271]
|
||||
- cell "Jul 09, 2026" [ref=e272]
|
||||
- cell [ref=e273]:
|
||||
- link "View" [ref=e275]:
|
||||
- /url: /appreciation/detail/3697b78b-5ff3-4392-8982-af20f58daebf/
|
||||
- img [ref=e276]
|
||||
- row "ismail mosa thank you - Contact Center Department - Sent Jul 09, 2026" [ref=e279] [cursor=pointer]:
|
||||
- cell "ismail mosa" [ref=e280]
|
||||
- cell "thank you" [ref=e281]:
|
||||
- generic [ref=e282]: thank you
|
||||
- cell "-" [ref=e283]
|
||||
- cell "Contact Center Department" [ref=e284]
|
||||
- cell "-" [ref=e285]
|
||||
- cell "Sent" [ref=e286]
|
||||
- cell "Jul 09, 2026" [ref=e287]
|
||||
- cell [ref=e288]:
|
||||
- link "View" [ref=e290]:
|
||||
- /url: /appreciation/detail/240ea5bd-a12d-4f76-8a79-a8dd9de5714a/
|
||||
- img [ref=e291]
|
||||
- contentinfo [ref=e294]:
|
||||
- paragraph [ref=e295]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e296] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,256 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]:
|
||||
- link "Inquiries" [ref=e143] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e144]
|
||||
- generic [ref=e146]: New Inquiry
|
||||
- heading "Create New Inquiry" [level=1] [ref=e147]
|
||||
- generic [ref=e149]:
|
||||
- generic [ref=e150]:
|
||||
- img [ref=e152]
|
||||
- heading "Inquiry Information" [level=5] [ref=e155]
|
||||
- generic [ref=e156]:
|
||||
- generic [ref=e157]:
|
||||
- heading "Organization" [level=6] [ref=e158]:
|
||||
- img [ref=e159]
|
||||
- text: Organization
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- generic [ref=e164]: Location Type *
|
||||
- combobox "Location Type *" [ref=e165]:
|
||||
- option "Select Location Type" [selected]
|
||||
- option "Outpatient"
|
||||
- option "Inpatient"
|
||||
- option "Emergency"
|
||||
- option "General"
|
||||
- generic [ref=e166]:
|
||||
- generic [ref=e167]: Area
|
||||
- combobox "Area" [ref=e168]:
|
||||
- option "Select Area (optional)" [selected]
|
||||
- option "Anesthesia (E2E-HOSP)"
|
||||
- option "Dialysis (E2E-HOSP)"
|
||||
- option "Emergency (E2E-HOSP)"
|
||||
- option "Endoscopy (E2E-HOSP)"
|
||||
- option "ICCU (E2E-HOSP)"
|
||||
- option "ICU (E2E-HOSP)"
|
||||
- option "ICU Stepdown (E2E-HOSP)"
|
||||
- option "LTACU (E2E-HOSP)"
|
||||
- option "Labor & Delivery (E2E-HOSP)"
|
||||
- option "Main OR (E2E-HOSP)"
|
||||
- option "Medical Ward (E2E-HOSP)"
|
||||
- option "NICU - Neonatal Intensive Care Unit (E2E-HOSP)"
|
||||
- option "Nursery (E2E-HOSP)"
|
||||
- option "OB Wards (E2E-HOSP)"
|
||||
- option "OPD1 (E2E-HOSP)"
|
||||
- option "OPD2 (E2E-HOSP)"
|
||||
- option "OPD3 (E2E-HOSP)"
|
||||
- option "OPD4 (E2E-HOSP)"
|
||||
- option "OPD5 (E2E-HOSP)"
|
||||
- option "OPD6 (E2E-HOSP)"
|
||||
- option "OPD7 (E2E-HOSP)"
|
||||
- option "PICU (E2E-HOSP)"
|
||||
- option "PICU Stepdown (E2E-HOSP)"
|
||||
- option "Pediatric Ward (E2E-HOSP)"
|
||||
- option "Recovery (E2E-HOSP)"
|
||||
- option "Stepdown 3-4 (E2E-HOSP)"
|
||||
- option "Surgical Wards (E2E-HOSP)"
|
||||
- generic [ref=e169]:
|
||||
- generic [ref=e170]: Department Category *
|
||||
- combobox [ref=e171]:
|
||||
- option "Select Category" [selected]
|
||||
- option "Medical"
|
||||
- option "Nursing"
|
||||
- option "Administrative"
|
||||
- option "Support Services"
|
||||
- generic [ref=e172]:
|
||||
- generic [ref=e173]: Department (Optional)
|
||||
- combobox "Department (Optional)" [ref=e174]:
|
||||
- option "Select Department" [selected]
|
||||
- option "Anesthesia Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Contact Center Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "Executive Administration"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Laundry Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Nursing Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Security Department"
|
||||
- option "Surgery Department"
|
||||
- generic [ref=e175]:
|
||||
- generic [ref=e176]: Section
|
||||
- combobox "Section" [ref=e177]:
|
||||
- option "Select Section" [selected]
|
||||
- generic [ref=e178]:
|
||||
- heading "Contact Information" [level=6] [ref=e179]:
|
||||
- img [ref=e180]
|
||||
- text: Contact Information
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- generic [ref=e185]: Contact Name *
|
||||
- textbox "Contact Name *" [ref=e186]
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e188]: Contact Phone *
|
||||
- textbox "Contact Phone *" [ref=e189]
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: Contact Email
|
||||
- textbox "Contact Email" [ref=e192]
|
||||
- generic [ref=e193]:
|
||||
- heading "Inquiry Details" [level=6] [ref=e194]:
|
||||
- img [ref=e195]
|
||||
- text: Inquiry Details
|
||||
- generic [ref=e198]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: Inquiry Type *
|
||||
- combobox "Inquiry Type *" [ref=e201]:
|
||||
- option "General Inquiry" [selected]
|
||||
- option "Appointment Related"
|
||||
- option "Billing & Insurance"
|
||||
- option "Medical Records"
|
||||
- option "Pharmacy"
|
||||
- option "Insurance"
|
||||
- option "Feedback"
|
||||
- option "Other"
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Priority
|
||||
- combobox "Priority" [ref=e204]:
|
||||
- option "Low"
|
||||
- option "Medium" [selected]
|
||||
- option "High"
|
||||
- option "Critical"
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: Subject *
|
||||
- textbox "Subject *" [ref=e207]:
|
||||
- /placeholder: Brief subject
|
||||
- generic [ref=e208]:
|
||||
- generic [ref=e209]: Message *
|
||||
- textbox "Message *" [ref=e210]:
|
||||
- /placeholder: Describe your inquiry
|
||||
- generic [ref=e211]:
|
||||
- button "Create Inquiry" [ref=e212] [cursor=pointer]:
|
||||
- img [ref=e213]
|
||||
- text: Create Inquiry
|
||||
- link "Cancel" [ref=e216] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- contentinfo [ref=e217]:
|
||||
- paragraph [ref=e218]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e219] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,124 +0,0 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- banner [ref=e2]:
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- img "HH Logo" [ref=e7]
|
||||
- generic [ref=e8]:
|
||||
- heading "PX360" [level=1] [ref=e9]
|
||||
- paragraph [ref=e10]: Patient Experience Management
|
||||
- generic [ref=e11]:
|
||||
- link "🇬🇧 English" [ref=e12] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e13]: 🇬🇧
|
||||
- generic [ref=e14]: English
|
||||
- link "🇸🇦 Arabic" [ref=e15] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e16]: 🇸🇦
|
||||
- generic [ref=e17]: Arabic
|
||||
- main [ref=e18]:
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- img [ref=e24]
|
||||
- heading "Report an Observation" [level=1] [ref=e27]
|
||||
- paragraph [ref=e28]: Help us improve by reporting issues you notice
|
||||
- generic [ref=e30]:
|
||||
- img [ref=e31]
|
||||
- generic [ref=e34]:
|
||||
- strong [ref=e35]: Anonymous Reporting
|
||||
- paragraph [ref=e36]: You can submit this report anonymously. Providing your information is optional but may help us follow up if needed.
|
||||
- generic [ref=e37]:
|
||||
- generic [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- img [ref=e40]
|
||||
- text: Hospital
|
||||
- generic [ref=e44]: (optional)
|
||||
- combobox [ref=e45]:
|
||||
- option "Select hospital" [selected]
|
||||
- option "Al Nuzha"
|
||||
- option "Al Olya"
|
||||
- option "Al Suwaidi"
|
||||
- option "Al-Hammadi Hospital (KPI Test)"
|
||||
- option "E2E Test Hospital"
|
||||
- generic [ref=e46]:
|
||||
- generic [ref=e47]:
|
||||
- img [ref=e48]
|
||||
- text: Location Type
|
||||
- generic [ref=e51]: "*"
|
||||
- combobox [ref=e52]:
|
||||
- option "Select Location Type" [selected]
|
||||
- option "Outpatient (OP)"
|
||||
- option "Inpatient (IP)"
|
||||
- option "Emergency (ER)"
|
||||
- option "General"
|
||||
- generic [ref=e53]:
|
||||
- generic [ref=e54]:
|
||||
- img [ref=e55]
|
||||
- text: Category
|
||||
- generic [ref=e58]: (optional)
|
||||
- combobox [ref=e59]:
|
||||
- option "Select Category" [selected]
|
||||
- option "Medical"
|
||||
- option "Nursing"
|
||||
- option "Administrative"
|
||||
- option "Support Services"
|
||||
- generic [ref=e60]:
|
||||
- generic [ref=e61]:
|
||||
- img [ref=e62]
|
||||
- text: Description
|
||||
- generic [ref=e65]: "*"
|
||||
- textbox "Please describe what you observed in detail..." [ref=e66]
|
||||
- paragraph [ref=e67]: Please describe what you observed in detail.
|
||||
- generic [ref=e68]:
|
||||
- generic [ref=e69]:
|
||||
- img [ref=e70]
|
||||
- text: Location Details
|
||||
- generic [ref=e73]: (optional)
|
||||
- textbox "e.g., Building A, Floor 2, Room 205" [ref=e74]
|
||||
- generic [ref=e75]:
|
||||
- generic [ref=e76]:
|
||||
- img [ref=e77]
|
||||
- text: When did this occur?
|
||||
- textbox [ref=e79]: 2026-07-12T23:40:27
|
||||
- generic [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- img [ref=e82]
|
||||
- text: Attachments
|
||||
- generic [ref=e84]: (optional)
|
||||
- generic [ref=e85] [cursor=pointer]:
|
||||
- img [ref=e86]
|
||||
- paragraph [ref=e89]: Click to upload files
|
||||
- paragraph [ref=e90]: Images, PDF, Word, Excel (max 10MB each)
|
||||
- generic [ref=e91]:
|
||||
- generic [ref=e92]:
|
||||
- img [ref=e93]
|
||||
- text: Your Information
|
||||
- generic [ref=e96]: (optional)
|
||||
- paragraph [ref=e97]: Providing your information helps us follow up if needed. Leave blank to submit anonymously.
|
||||
- generic [ref=e98]:
|
||||
- generic [ref=e99]:
|
||||
- generic [ref=e100]: Staff ID
|
||||
- textbox "Your staff ID (optional)" [ref=e101]
|
||||
- generic [ref=e102]:
|
||||
- generic [ref=e103]: Name
|
||||
- textbox "Your name (optional)" [ref=e104]
|
||||
- generic [ref=e105]:
|
||||
- generic [ref=e106]: Phone
|
||||
- textbox "Your phone number (optional)" [ref=e107]
|
||||
- generic [ref=e108]:
|
||||
- generic [ref=e109]: Email
|
||||
- textbox "Your email (optional)" [ref=e110]
|
||||
- button "Submit Observation" [ref=e111] [cursor=pointer]:
|
||||
- img [ref=e112]
|
||||
- text: Submit Observation
|
||||
- link "Track an existing observation" [ref=e116] [cursor=pointer]:
|
||||
- /url: /observations/track/
|
||||
- img [ref=e117]
|
||||
- text: Track an existing observation
|
||||
- contentinfo [ref=e120]:
|
||||
- generic [ref=e121]:
|
||||
- paragraph [ref=e122]: © 2026 Al Hammadi Hospital - PX360. All rights reserved.
|
||||
- paragraph [ref=e123]: Your feedback helps us improve our services
|
||||
- paragraph [ref=e124]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e125] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,133 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]:
|
||||
- link "Suggestions" [ref=e143] [cursor=pointer]:
|
||||
- /url: /suggestions/
|
||||
- img [ref=e144]
|
||||
- generic [ref=e146]: New
|
||||
- heading "New Suggestion" [level=1] [ref=e147]
|
||||
- link "Back" [ref=e148] [cursor=pointer]:
|
||||
- /url: /suggestions/
|
||||
- img [ref=e149]
|
||||
- text: Back
|
||||
- generic [ref=e151]:
|
||||
- generic [ref=e152]:
|
||||
- img [ref=e154]
|
||||
- heading "Suggestion Details" [level=5] [ref=e156]
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e158]:
|
||||
- generic [ref=e159]:
|
||||
- generic [ref=e160]: Your Name *
|
||||
- textbox "Your full name" [ref=e161]
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]: Phone Number *
|
||||
- textbox "Your phone number" [ref=e164]
|
||||
- generic [ref=e165]:
|
||||
- generic [ref=e166]: Hospital *
|
||||
- combobox [ref=e167]:
|
||||
- option "Select Hospital" [selected]
|
||||
- option "Al Nuzha"
|
||||
- option "Al Olya"
|
||||
- option "Al Suwaidi"
|
||||
- option "Al-Hammadi Hospital (KPI Test)"
|
||||
- option "E2E Test Hospital"
|
||||
- generic [ref=e168]:
|
||||
- generic [ref=e169]: Message *
|
||||
- textbox "Describe your suggestion in detail..." [ref=e170]
|
||||
- button "Submit Suggestion" [ref=e172] [cursor=pointer]:
|
||||
- img [ref=e173]
|
||||
- text: Submit Suggestion
|
||||
- contentinfo [ref=e176]:
|
||||
- paragraph [ref=e177]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e178] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,260 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]:
|
||||
- heading "New Appreciation" [level=1] [ref=e143]:
|
||||
- img [ref=e144]
|
||||
- text: New Appreciation
|
||||
- paragraph [ref=e146]: Recognize a department for excellent service or care.
|
||||
- link "Back" [ref=e147] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e148]
|
||||
- text: Back
|
||||
- generic [ref=e150]:
|
||||
- generic [ref=e151]:
|
||||
- generic [ref=e152]: Hospital *
|
||||
- combobox "Hospital *" [ref=e153]:
|
||||
- option "---------"
|
||||
- option "Al Nuzha"
|
||||
- option "Al Olya"
|
||||
- option "Al Suwaidi"
|
||||
- option "Al-Hammadi Hospital (KPI Test)"
|
||||
- option "E2E Test Hospital" [selected]
|
||||
- generic [ref=e154]:
|
||||
- generic [ref=e155]: Department *
|
||||
- combobox "Department *" [ref=e156]:
|
||||
- option "---------" [selected]
|
||||
- option "Anesthesia Department — Al Nuzha"
|
||||
- option "Biomedical Department — Al Nuzha"
|
||||
- option "Contact Center Department — Al Nuzha"
|
||||
- option "Critical Care Department — Al Nuzha"
|
||||
- option "Dental Department — Al Nuzha"
|
||||
- option "Dermatology Department — Al Nuzha"
|
||||
- option "Emergency Administrative Department — Al Nuzha"
|
||||
- option "Emergency Medicine Department — Al Nuzha"
|
||||
- option "Executive Administration — Al Nuzha"
|
||||
- option "Facility Management & Maintenance Department — Al Nuzha"
|
||||
- option "Financial Collection & Claims Department — Al Nuzha"
|
||||
- option "Food Services Department — Al Nuzha"
|
||||
- option "Housekeeping & Hospitality Department — Al Nuzha"
|
||||
- option "IVF — Al Nuzha"
|
||||
- option "Infection Control Department — Al Nuzha"
|
||||
- option "Information Technology Department — Al Nuzha"
|
||||
- option "Inpatient Department — Al Nuzha"
|
||||
- option "Internal Medicine Department — Al Nuzha"
|
||||
- option "Laboratory Department — Al Nuzha"
|
||||
- option "Laundry Department — Al Nuzha"
|
||||
- option "Medical Administration — Al Nuzha"
|
||||
- option "Medical Ancillary Services Department — Al Nuzha"
|
||||
- option "Medical Approvals Department — Al Nuzha"
|
||||
- option "Medical Records Department — Al Nuzha"
|
||||
- option "Nursing Department — Al Nuzha"
|
||||
- option "ONCOLOGY Department — Al Nuzha"
|
||||
- option "Obstetrics & Gynecology Department — Al Nuzha"
|
||||
- option "Operating Rooms (OR) Department — Al Nuzha"
|
||||
- option "Ophthalmology Department — Al Nuzha"
|
||||
- option "Outpatient Department — Al Nuzha"
|
||||
- option "Patient Affairs Department — Al Nuzha"
|
||||
- option "Patient Experience Department — Al Nuzha"
|
||||
- option "Pediatric Department — Al Nuzha"
|
||||
- option "Pharmacy Department — Al Nuzha"
|
||||
- option "Radiology Department — Al Nuzha"
|
||||
- option "Security Department — Al Nuzha"
|
||||
- option "Surgery Department — Al Nuzha"
|
||||
- option "Facility Management & Maintenance Department — Al Olya"
|
||||
- option "HR Department — Al Olya"
|
||||
- option "Support Services Department — Al Olya"
|
||||
- option "Anesthesia Department — Al Suwaidi"
|
||||
- option "Biomedical Department — Al Suwaidi"
|
||||
- option "Contact Center Department — Al Suwaidi"
|
||||
- option "Critical Care Department — Al Suwaidi"
|
||||
- option "Dental Department — Al Suwaidi"
|
||||
- option "Dermatology Department — Al Suwaidi"
|
||||
- option "Emergency Administrative Department — Al Suwaidi"
|
||||
- option "Emergency Medicine Department — Al Suwaidi"
|
||||
- option "Executive Administration — Al Suwaidi"
|
||||
- option "Facility Management & Maintenance Department — Al Suwaidi"
|
||||
- option "Financial Collection & Claims Department — Al Suwaidi"
|
||||
- option "Food Services Department — Al Suwaidi"
|
||||
- option "Housekeeping & Hospitality Department — Al Suwaidi"
|
||||
- option "IVF — Al Suwaidi"
|
||||
- option "Infection Control Department — Al Suwaidi"
|
||||
- option "Information Technology Department — Al Suwaidi"
|
||||
- option "Inpatient Department — Al Suwaidi"
|
||||
- option "Internal Medicine Department — Al Suwaidi"
|
||||
- option "Laboratory Department — Al Suwaidi"
|
||||
- option "Laundry Department — Al Suwaidi"
|
||||
- option "Medical Administration — Al Suwaidi"
|
||||
- option "Medical Ancillary Services Department — Al Suwaidi"
|
||||
- option "Medical Approvals Department — Al Suwaidi"
|
||||
- option "Medical Records Department — Al Suwaidi"
|
||||
- option "Nursing Department — Al Suwaidi"
|
||||
- option "ONCOLOGY Department — Al Suwaidi"
|
||||
- option "Obstetrics & Gynecology Department — Al Suwaidi"
|
||||
- option "Operating Rooms (OR) Department — Al Suwaidi"
|
||||
- option "Ophthalmology Department — Al Suwaidi"
|
||||
- option "Outpatient Department — Al Suwaidi"
|
||||
- option "Patient Affairs Department — Al Suwaidi"
|
||||
- option "Patient Experience Department — Al Suwaidi"
|
||||
- option "Pediatric Department — Al Suwaidi"
|
||||
- option "Pharmacy Department — Al Suwaidi"
|
||||
- option "Radiology Department — Al Suwaidi"
|
||||
- option "Security Department — Al Suwaidi"
|
||||
- option "Surgery Department — Al Suwaidi"
|
||||
- option "Administration — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Cardiology — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Emergency — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Housekeeping — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "ICU — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Laboratory — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Maintenance — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Medical Reports — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Nursing — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Orthopedics — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Pediatrics — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Pharmacy — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Reception — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Security — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Surgery — Al-Hammadi Hospital (KPI Test)"
|
||||
- option "Anesthesia Department — E2E Test Hospital"
|
||||
- option "Biomedical Department — E2E Test Hospital"
|
||||
- option "Contact Center Department — E2E Test Hospital"
|
||||
- option "Critical Care Department — E2E Test Hospital"
|
||||
- option "Dental Department — E2E Test Hospital"
|
||||
- option "Dermatology Department — E2E Test Hospital"
|
||||
- option "E2E Dept 1781720032405 — E2E Test Hospital"
|
||||
- option "E2E Dept 1781720188555 — E2E Test Hospital"
|
||||
- option "Emergency Administrative Department — E2E Test Hospital"
|
||||
- option "Emergency Medicine Department — E2E Test Hospital"
|
||||
- option "Executive Administration — E2E Test Hospital"
|
||||
- option "Facility Management & Maintenance Department — E2E Test Hospital"
|
||||
- option "Financial Collection & Claims Department — E2E Test Hospital"
|
||||
- option "Food Services Department — E2E Test Hospital"
|
||||
- option "Housekeeping & Hospitality Department — E2E Test Hospital"
|
||||
- option "IVF — E2E Test Hospital"
|
||||
- option "Infection Control Department — E2E Test Hospital"
|
||||
- option "Information Technology Department — E2E Test Hospital"
|
||||
- option "Inpatient Department — E2E Test Hospital"
|
||||
- option "Internal Medicine Department — E2E Test Hospital"
|
||||
- option "Laboratory Department — E2E Test Hospital"
|
||||
- option "Laundry Department — E2E Test Hospital"
|
||||
- option "Medical Administration — E2E Test Hospital"
|
||||
- option "Medical Ancillary Services Department — E2E Test Hospital"
|
||||
- option "Medical Approvals Department — E2E Test Hospital"
|
||||
- option "Medical Records Department — E2E Test Hospital"
|
||||
- option "Nursing Department — E2E Test Hospital"
|
||||
- option "ONCOLOGY Department — E2E Test Hospital"
|
||||
- option "Obstetrics & Gynecology Department — E2E Test Hospital"
|
||||
- option "Operating Rooms (OR) Department — E2E Test Hospital"
|
||||
- option "Ophthalmology Department — E2E Test Hospital"
|
||||
- option "Outpatient Department — E2E Test Hospital"
|
||||
- option "Patient Affairs Department — E2E Test Hospital"
|
||||
- option "Patient Experience Department — E2E Test Hospital"
|
||||
- option "Pediatric Department — E2E Test Hospital"
|
||||
- option "Pharmacy Department — E2E Test Hospital"
|
||||
- option "Radiology Department — E2E Test Hospital"
|
||||
- option "Security Department — E2E Test Hospital"
|
||||
- option "Surgery Department — E2E Test Hospital"
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e158]: Message *
|
||||
- textbox "Message *" [ref=e159]:
|
||||
- /placeholder: Write your message of appreciation...
|
||||
- generic [ref=e160]:
|
||||
- button "Create Appreciation" [ref=e161] [cursor=pointer]:
|
||||
- img [ref=e162]
|
||||
- text: Create Appreciation
|
||||
- link "Cancel" [ref=e164] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- contentinfo [ref=e165]:
|
||||
- paragraph [ref=e166]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e167] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,172 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Inquiries" [ref=e141] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: INQ-202607-0007
|
||||
- generic [ref=e145]: Open
|
||||
- generic [ref=e146]: Medium
|
||||
- generic [ref=e147]:
|
||||
- img [ref=e148]
|
||||
- text: Not Sent to Dept
|
||||
- generic [ref=e150]:
|
||||
- heading "test12121212" [level=1] [ref=e151]
|
||||
- generic [ref=e153]:
|
||||
- img [ref=e154]
|
||||
- text: 2026-07-12
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e158]:
|
||||
- img [ref=e160]
|
||||
- generic [ref=e162]: Created
|
||||
- generic [ref=e164]:
|
||||
- img [ref=e166]
|
||||
- generic [ref=e169]: Assigned
|
||||
- generic [ref=e171]:
|
||||
- img [ref=e173]
|
||||
- generic [ref=e176]: Sent to Dept
|
||||
- generic [ref=e178]:
|
||||
- img [ref=e180]
|
||||
- generic [ref=e182]: Dept Response
|
||||
- generic [ref=e184]:
|
||||
- img [ref=e186]
|
||||
- generic [ref=e189]: Resolved
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]:
|
||||
- img [ref=e193]
|
||||
- generic [ref=e195]:
|
||||
- paragraph [ref=e196]: Activate this inquiry
|
||||
- paragraph [ref=e197]: Activation moves it to In Progress so actions become available.
|
||||
- generic [ref=e198]:
|
||||
- button "Cancel" [ref=e200] [cursor=pointer]
|
||||
- button "Activate" [ref=e202] [cursor=pointer]
|
||||
- navigation [ref=e203]:
|
||||
- button "Details" [ref=e204] [cursor=pointer]
|
||||
- button "Timeline" [ref=e205] [cursor=pointer]
|
||||
- button "Notes" [ref=e206] [cursor=pointer]
|
||||
- main [ref=e207]:
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]:
|
||||
- generic [ref=e211]:
|
||||
- paragraph
|
||||
- paragraph [ref=e212]: "231313131"
|
||||
- paragraph
|
||||
- generic [ref=e213]:
|
||||
- generic [ref=e215]:
|
||||
- img [ref=e216]
|
||||
- generic [ref=e219]: AI Analysis
|
||||
- generic [ref=e220]:
|
||||
- paragraph [ref=e221]: "231313131"
|
||||
- paragraph [ref=e222]: "231313131"
|
||||
- generic [ref=e223]:
|
||||
- heading "Patient" [level=3] [ref=e224]:
|
||||
- img [ref=e225]
|
||||
- text: Patient
|
||||
- generic [ref=e228]:
|
||||
- generic [ref=e229]: ismail mosa
|
||||
- generic [ref=e230]: "|"
|
||||
- generic [ref=e231]:
|
||||
- img [ref=e232]
|
||||
- text: "0569874563"
|
||||
- generic [ref=e235]:
|
||||
- heading "Quick Actions" [level=3] [ref=e236]
|
||||
- generic [ref=e237]:
|
||||
- button "Activate" [ref=e239] [cursor=pointer]:
|
||||
- img [ref=e240]
|
||||
- generic [ref=e243]: Activate
|
||||
- button "Cancel Inquiry" [ref=e245] [cursor=pointer]:
|
||||
- img [ref=e246]
|
||||
- generic [ref=e250]: Cancel Inquiry
|
||||
- contentinfo [ref=e251]:
|
||||
- paragraph [ref=e252]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e253] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,243 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- heading "Complaints Registry" [level=1] [ref=e142]
|
||||
- paragraph [ref=e143]: Manage and monitor patient feedback in real-time
|
||||
- generic [ref=e144]:
|
||||
- generic [ref=e145]:
|
||||
- img [ref=e146]
|
||||
- textbox "Search ID, Name or Dept..." [ref=e149]
|
||||
- link "Public Form" [ref=e150] [cursor=pointer]:
|
||||
- /url: /core/public/submit/
|
||||
- img [ref=e151]
|
||||
- text: Public Form
|
||||
- button "Export" [ref=e155] [cursor=pointer]:
|
||||
- img [ref=e156]
|
||||
- text: Export
|
||||
- link "New Case" [ref=e159] [cursor=pointer]:
|
||||
- /url: /complaints/new/
|
||||
- img [ref=e160]
|
||||
- text: New Case
|
||||
- generic [ref=e161]:
|
||||
- generic [ref=e162]:
|
||||
- img [ref=e165]
|
||||
- generic [ref=e169]:
|
||||
- heading "Total Received" [level=6] [ref=e170]
|
||||
- heading "3" [level=2] [ref=e171]
|
||||
- generic [ref=e172]:
|
||||
- img [ref=e175]
|
||||
- generic [ref=e178]:
|
||||
- heading "Resolved" [level=6] [ref=e179]
|
||||
- heading "0 (0.0%)" [level=2] [ref=e180]
|
||||
- generic [ref=e181]:
|
||||
- img [ref=e184]
|
||||
- generic [ref=e187]:
|
||||
- heading "Pending" [level=6] [ref=e188]
|
||||
- heading "3 (100.0%)" [level=2] [ref=e189]
|
||||
- generic [ref=e190]:
|
||||
- img [ref=e193]
|
||||
- generic [ref=e195]:
|
||||
- heading "TAT Alert" [level=6] [ref=e196]
|
||||
- heading "3 (100.0%)" [level=2] [ref=e197]
|
||||
- generic [ref=e198]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]:
|
||||
- img [ref=e202]
|
||||
- heading "Complaints" [level=5] [ref=e204]
|
||||
- generic [ref=e205]:
|
||||
- link "All Cases" [ref=e206] [cursor=pointer]:
|
||||
- /url: "?"
|
||||
- link "Pending" [ref=e207] [cursor=pointer]:
|
||||
- /url: "?status=open"
|
||||
- link "In Progress" [ref=e208] [cursor=pointer]:
|
||||
- /url: "?status=in_progress"
|
||||
- link "Resolved" [ref=e209] [cursor=pointer]:
|
||||
- /url: "?status=resolved"
|
||||
- button "Advanced" [ref=e211] [cursor=pointer]:
|
||||
- img [ref=e212]
|
||||
- text: Advanced
|
||||
- paragraph [ref=e213]: "Showing: 1-3 of 3"
|
||||
- generic [ref=e214]:
|
||||
- table [ref=e215]:
|
||||
- rowgroup [ref=e216]:
|
||||
- row "Complaint ID Patient Name Description Source Department Status SLA Severity Priority Assigned Actions" [ref=e217]:
|
||||
- columnheader "Complaint ID" [ref=e218]
|
||||
- columnheader "Patient Name" [ref=e219]
|
||||
- columnheader "Description" [ref=e220]
|
||||
- columnheader "Source" [ref=e221]
|
||||
- columnheader "Department" [ref=e222]
|
||||
- columnheader "Status" [ref=e223]
|
||||
- columnheader "SLA" [ref=e224]
|
||||
- columnheader "Severity" [ref=e225]
|
||||
- columnheader "Priority" [ref=e226]
|
||||
- columnheader "Assigned" [ref=e227]
|
||||
- columnheader "Actions" [ref=e228]
|
||||
- rowgroup [ref=e229]:
|
||||
- row "CMP-202607-0006 test شكوى عامة Patient Patient Experience Department In Progress Overdue 21h 47m Medium Medium test test" [ref=e230] [cursor=pointer]:
|
||||
- cell "CMP-202607-0006" [ref=e231]
|
||||
- cell "test" [ref=e232]:
|
||||
- generic [ref=e233]: test
|
||||
- cell "شكوى عامة" [ref=e234]:
|
||||
- generic [ref=e235]: شكوى عامة
|
||||
- button "View description" [ref=e236]:
|
||||
- img [ref=e237]
|
||||
- cell "Patient" [ref=e246]
|
||||
- cell "Patient Experience Department" [ref=e247]
|
||||
- cell "In Progress" [ref=e248]
|
||||
- cell "Overdue 21h 47m" [ref=e249]
|
||||
- cell "Medium" [ref=e250]
|
||||
- cell "Medium" [ref=e251]
|
||||
- cell "test test" [ref=e252]
|
||||
- cell [ref=e253]:
|
||||
- generic [ref=e254]:
|
||||
- link "View" [ref=e255]:
|
||||
- /url: /complaints/f8aa6216-bb94-455e-ba18-d16a01024662/
|
||||
- img [ref=e256]
|
||||
- link "Assign" [ref=e259]:
|
||||
- /url: /complaints/f8aa6216-bb94-455e-ba18-d16a01024662/assign/
|
||||
- img [ref=e260]
|
||||
- row "CMP-202607-0005 ismail شكوى اختبار Patient Contact Center Department In Progress Overdue 23h 22m Low Low test test" [ref=e263] [cursor=pointer]:
|
||||
- cell "CMP-202607-0005" [ref=e264]
|
||||
- cell "ismail" [ref=e265]:
|
||||
- generic [ref=e266]: ismail
|
||||
- cell "شكوى اختبار" [ref=e267]:
|
||||
- generic [ref=e268]: شكوى اختبار
|
||||
- button "View description" [ref=e269]:
|
||||
- img [ref=e270]
|
||||
- cell "Patient" [ref=e279]
|
||||
- cell "Contact Center Department" [ref=e280]
|
||||
- cell "In Progress" [ref=e281]
|
||||
- cell "Overdue 23h 22m" [ref=e282]
|
||||
- cell "Low" [ref=e283]
|
||||
- cell "Low" [ref=e284]
|
||||
- cell "test test" [ref=e285]
|
||||
- cell [ref=e286]:
|
||||
- link "View" [ref=e288]:
|
||||
- /url: /complaints/9ca01d52-712d-4bca-b30d-6f1f818ff839/
|
||||
- img [ref=e289]
|
||||
- row "CMP-202607-0004 OVR ismail اختبار عام Internal Contact Center Department In Progress Overdue 4d 7h 22m Low Low ismail mosa" [ref=e292] [cursor=pointer]:
|
||||
- cell "CMP-202607-0004 OVR" [ref=e293]
|
||||
- cell "ismail" [ref=e294]:
|
||||
- generic [ref=e295]: ismail
|
||||
- cell "اختبار عام" [ref=e296]:
|
||||
- generic [ref=e297]: اختبار عام
|
||||
- button "View description" [ref=e298]:
|
||||
- img [ref=e299]
|
||||
- cell "Internal" [ref=e308]
|
||||
- cell "Contact Center Department" [ref=e309]
|
||||
- cell "In Progress" [ref=e310]
|
||||
- cell "Overdue 4d 7h 22m" [ref=e311]
|
||||
- cell "Low" [ref=e312]
|
||||
- cell "Low" [ref=e313]
|
||||
- cell "ismail mosa" [ref=e314]
|
||||
- cell [ref=e315]:
|
||||
- link "View" [ref=e317]:
|
||||
- /url: /complaints/1c4e8dde-48d8-42ab-8c8d-4ada8314fc40/
|
||||
- img [ref=e318]
|
||||
- generic [ref=e321]:
|
||||
- generic [ref=e322]:
|
||||
- generic [ref=e323]: Showing 1-3 of 3
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e325]: Show
|
||||
- combobox [ref=e326]:
|
||||
- option "10"
|
||||
- option "25" [selected]
|
||||
- option "50"
|
||||
- option "100"
|
||||
- generic [ref=e327]:
|
||||
- img [ref=e329]
|
||||
- generic [ref=e331]: "1"
|
||||
- img [ref=e333]
|
||||
- contentinfo [ref=e335]:
|
||||
- paragraph [ref=e336]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e337] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,259 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Observations" [ref=e141] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: OBS-202607-0001
|
||||
- generic [ref=e145]: In Progress
|
||||
- generic [ref=e146]: Medium
|
||||
- generic [ref=e147]:
|
||||
- img [ref=e148]
|
||||
- text: Not Sent to Dept
|
||||
- heading "test observation" [level=1] [ref=e151]
|
||||
- generic [ref=e153]:
|
||||
- generic [ref=e154]:
|
||||
- img [ref=e156]
|
||||
- generic [ref=e158]: Created
|
||||
- generic [ref=e160]:
|
||||
- img [ref=e162]
|
||||
- generic [ref=e164]: Assigned
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e168]
|
||||
- generic [ref=e171]: Sent to Dept
|
||||
- generic [ref=e173]:
|
||||
- img [ref=e175]
|
||||
- generic [ref=e177]: Dept Response
|
||||
- generic [ref=e179]:
|
||||
- img [ref=e181]
|
||||
- generic [ref=e184]: Resolved
|
||||
- navigation [ref=e185]:
|
||||
- button "Details" [ref=e186] [cursor=pointer]
|
||||
- button "Timeline" [ref=e187] [cursor=pointer]
|
||||
- button "RCA" [ref=e188] [cursor=pointer]
|
||||
- button "Notes" [ref=e189] [cursor=pointer]
|
||||
- main [ref=e190]:
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]:
|
||||
- paragraph [ref=e195]: test observation
|
||||
- generic [ref=e196]:
|
||||
- generic [ref=e197]:
|
||||
- paragraph [ref=e198]: Category
|
||||
- paragraph [ref=e199]: "-"
|
||||
- generic [ref=e200]:
|
||||
- paragraph [ref=e201]: Location
|
||||
- paragraph [ref=e202]: "-"
|
||||
- generic [ref=e203]:
|
||||
- paragraph [ref=e204]: Incident Date
|
||||
- paragraph [ref=e205]: 08 Jul 2026
|
||||
- generic [ref=e209]:
|
||||
- img [ref=e210]
|
||||
- paragraph [ref=e214]: No department assigned to this observation
|
||||
- generic [ref=e215]:
|
||||
- generic [ref=e216]:
|
||||
- heading "Quick Actions" [level=3] [ref=e217]
|
||||
- generic [ref=e218]:
|
||||
- button "Respond to Patient" [ref=e219] [cursor=pointer]:
|
||||
- img [ref=e220]
|
||||
- generic [ref=e222]: Respond to Patient
|
||||
- button "Reassign" [ref=e223] [cursor=pointer]:
|
||||
- img [ref=e224]
|
||||
- generic [ref=e227]: Reassign
|
||||
- button "Send to Dept" [ref=e228] [cursor=pointer]:
|
||||
- img [ref=e229]
|
||||
- generic [ref=e232]: Send to Dept
|
||||
- button "Resolve" [ref=e234] [cursor=pointer]:
|
||||
- img [ref=e235]
|
||||
- generic [ref=e238]: Resolve
|
||||
- link "RCA" [ref=e239] [cursor=pointer]:
|
||||
- /url: /rca/create/?related_model=observation&related_id=1d925706-110e-4bad-808f-26aae2029144
|
||||
- img [ref=e240]
|
||||
- generic [ref=e243]: RCA
|
||||
- link "QI Project" [ref=e244] [cursor=pointer]:
|
||||
- /url: /projects/create/?related_model=observation&related_id=1d925706-110e-4bad-808f-26aae2029144
|
||||
- img [ref=e245]
|
||||
- generic [ref=e247]: QI Project
|
||||
- button "Delete" [ref=e249] [cursor=pointer]:
|
||||
- img [ref=e250]
|
||||
- generic [ref=e253]: Delete
|
||||
- generic [ref=e254]:
|
||||
- heading "Assignment" [level=3] [ref=e255]:
|
||||
- img [ref=e256]
|
||||
- text: Assignment
|
||||
- list [ref=e261]:
|
||||
- listitem [ref=e262]:
|
||||
- generic [ref=e263]: Department
|
||||
- generic [ref=e264]: Contact Center Department
|
||||
- listitem [ref=e265]:
|
||||
- generic [ref=e266]: Assigned To
|
||||
- generic [ref=e267]: ismail mosa
|
||||
- generic [ref=e268]:
|
||||
- heading "Linked PX Action" [level=3] [ref=e269]:
|
||||
- img [ref=e270]
|
||||
- text: Linked PX Action
|
||||
- paragraph [ref=e273]: Observation OBS-202607-0001
|
||||
- link "View Action" [ref=e274] [cursor=pointer]:
|
||||
- /url: /actions/9b185c2a-e3fe-4e13-a727-7aab21df8754/
|
||||
- img [ref=e275]
|
||||
- text: View Action
|
||||
- generic [ref=e277]:
|
||||
- heading "Triage" [level=3] [ref=e278]:
|
||||
- img [ref=e279]
|
||||
- text: Triage
|
||||
- generic [ref=e280]:
|
||||
- generic [ref=e281]:
|
||||
- generic [ref=e282]: Department
|
||||
- combobox [ref=e283]:
|
||||
- option "Select department"
|
||||
- option "Contact Center Department" [selected]
|
||||
- option "Emergency Administrative Department"
|
||||
- option "Executive Administration"
|
||||
- option "Financial Collection & Claims Department"
|
||||
- option "Inpatient Department"
|
||||
- option "Medical Approvals Department"
|
||||
- option "Medical Records Department"
|
||||
- option "Outpatient Department"
|
||||
- option "Patient Affairs Department"
|
||||
- option "Patient Experience Department"
|
||||
- option "Security Department"
|
||||
- option "Anesthesia Department"
|
||||
- option "Critical Care Department"
|
||||
- option "Dental Department"
|
||||
- option "Dermatology Department"
|
||||
- option "Emergency Medicine Department"
|
||||
- option "IVF"
|
||||
- option "Infection Control Department"
|
||||
- option "Internal Medicine Department"
|
||||
- option "Laboratory Department"
|
||||
- option "Medical Administration"
|
||||
- option "Medical Ancillary Services Department"
|
||||
- option "ONCOLOGY Department"
|
||||
- option "Obstetrics & Gynecology Department"
|
||||
- option "Operating Rooms (OR) Department"
|
||||
- option "Ophthalmology Department"
|
||||
- option "Pediatric Department"
|
||||
- option "Pharmacy Department"
|
||||
- option "Radiology Department"
|
||||
- option "Surgery Department"
|
||||
- option "E2E Dept 1781720032405"
|
||||
- option "E2E Dept 1781720188555"
|
||||
- option "Nursing Department"
|
||||
- option "Biomedical Department"
|
||||
- option "Facility Management & Maintenance Department"
|
||||
- option "Food Services Department"
|
||||
- option "Housekeeping & Hospitality Department"
|
||||
- option "Information Technology Department"
|
||||
- option "Laundry Department"
|
||||
- generic [ref=e284]:
|
||||
- generic [ref=e285]: Assign To
|
||||
- combobox [ref=e286]:
|
||||
- option "Select assignee" [selected]
|
||||
- option "E2E PX Employee (e2e-px-employee@px360.test)"
|
||||
- option "test test (9m5s6gzmft@ruutukf.com)"
|
||||
- option "test test (esma3el555@gmail.com)"
|
||||
- generic [ref=e287]:
|
||||
- generic [ref=e288]: Status
|
||||
- combobox [ref=e289]:
|
||||
- option "Open"
|
||||
- option "In Progress" [selected]
|
||||
- option "Resolved"
|
||||
- option "Closed"
|
||||
- generic [ref=e290]:
|
||||
- generic [ref=e291]: Note
|
||||
- textbox "Add a note about this triage action (optional)..." [ref=e292]
|
||||
- button "Update" [ref=e293] [cursor=pointer]:
|
||||
- img [ref=e294]
|
||||
- text: Update
|
||||
- contentinfo [ref=e297]:
|
||||
- paragraph [ref=e298]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e299] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,188 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Suggestions" [ref=e141] [cursor=pointer]:
|
||||
- /url: /suggestions/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: test
|
||||
- generic [ref=e145]: Submitted
|
||||
- generic [ref=e146]: Medium
|
||||
- generic [ref=e147]:
|
||||
- heading "Suggestion Detail" [level=1] [ref=e148]
|
||||
- link "Edit" [ref=e150] [cursor=pointer]:
|
||||
- /url: /suggestions/04b009f3-53aa-4eac-9baf-39b3633e56b0/update/
|
||||
- img [ref=e151]
|
||||
- text: Edit
|
||||
- generic [ref=e155]:
|
||||
- generic [ref=e156]:
|
||||
- img [ref=e158]
|
||||
- generic [ref=e160]: Submitted
|
||||
- generic [ref=e162]:
|
||||
- img [ref=e164]
|
||||
- generic [ref=e167]: Assigned
|
||||
- generic [ref=e169]:
|
||||
- img [ref=e171]
|
||||
- generic [ref=e174]: Reviewed
|
||||
- generic [ref=e176]:
|
||||
- img [ref=e178]
|
||||
- generic [ref=e180]: Acknowledged
|
||||
- generic [ref=e182]:
|
||||
- img [ref=e184]
|
||||
- generic [ref=e187]: Closed
|
||||
- navigation [ref=e188]:
|
||||
- button "Details" [ref=e189] [cursor=pointer]
|
||||
- button "AI Analysis" [ref=e190] [cursor=pointer]
|
||||
- button "Notes" [ref=e191] [cursor=pointer]
|
||||
- main [ref=e192]:
|
||||
- generic [ref=e195]:
|
||||
- heading "Suggestion Details" [level=3] [ref=e196]:
|
||||
- img [ref=e197]
|
||||
- text: Suggestion Details
|
||||
- paragraph [ref=e200]: test
|
||||
- generic [ref=e201]:
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]: Hospital
|
||||
- generic [ref=e204]: E2E Test Hospital
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: Category
|
||||
- generic [ref=e207]: Other
|
||||
- generic [ref=e208]:
|
||||
- generic [ref=e209]: Location
|
||||
- generic [ref=e210]: Not specified
|
||||
- generic [ref=e211]:
|
||||
- generic [ref=e212]: Sentiment
|
||||
- generic [ref=e214]: Neutral
|
||||
- generic [ref=e215]:
|
||||
- heading "Contact" [level=6] [ref=e216]
|
||||
- generic [ref=e217]:
|
||||
- generic [ref=e218]: ismail
|
||||
- generic [ref=e219]: "0569874589"
|
||||
- generic [ref=e221]:
|
||||
- img [ref=e222]
|
||||
- text: July 09, 2026 13:46
|
||||
- generic [ref=e225]:
|
||||
- heading "Quick Actions" [level=3] [ref=e226]
|
||||
- generic [ref=e227]:
|
||||
- generic [ref=e228]:
|
||||
- text: Status
|
||||
- combobox [ref=e229]:
|
||||
- option "Submitted" [selected]
|
||||
- option "Reviewed"
|
||||
- option "Acknowledged"
|
||||
- option "Closed"
|
||||
- option "Reopened"
|
||||
- textbox "Add a note..." [ref=e230]
|
||||
- button "Update" [ref=e231] [cursor=pointer]:
|
||||
- img [ref=e232]
|
||||
- text: Update
|
||||
- separator [ref=e237]
|
||||
- generic [ref=e238]:
|
||||
- text: Assign To
|
||||
- combobox [ref=e239]:
|
||||
- option "Select user..." [selected]
|
||||
- option "E2E PX Employee"
|
||||
- option "test test"
|
||||
- option "test test"
|
||||
- button "Assign" [ref=e240] [cursor=pointer]:
|
||||
- img [ref=e241]
|
||||
- text: Assign
|
||||
- separator [ref=e245]
|
||||
- generic [ref=e246]:
|
||||
- text: Department
|
||||
- button "Send to Department" [ref=e247] [cursor=pointer]:
|
||||
- img [ref=e248]
|
||||
- text: Send to Department
|
||||
- separator [ref=e251]
|
||||
- contentinfo [ref=e252]:
|
||||
- paragraph [ref=e253]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e254] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1,137 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "Al Hammadi" [ref=e6]
|
||||
- img "Al Hammadi" [ref=e8]
|
||||
- navigation [ref=e9]:
|
||||
- link [ref=e10] [cursor=pointer]:
|
||||
- /url: /analytics/dashboard/
|
||||
- img [ref=e11]
|
||||
- link [ref=e16] [cursor=pointer]:
|
||||
- /url: /projects/my-tasks/
|
||||
- img [ref=e17]
|
||||
- link [ref=e20] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e21]
|
||||
- link [ref=e23] [cursor=pointer]:
|
||||
- /url: /complaints/
|
||||
- img [ref=e24]
|
||||
- link [ref=e27] [cursor=pointer]:
|
||||
- /url: /inquiries/
|
||||
- img [ref=e28]
|
||||
- link [ref=e31] [cursor=pointer]:
|
||||
- /url: /observations/
|
||||
- img [ref=e32]
|
||||
- link [ref=e35] [cursor=pointer]:
|
||||
- /url: /suggestions/?feedback_type=suggestion
|
||||
- img [ref=e36]
|
||||
- link [ref=e38] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e39]
|
||||
- link [ref=e41] [cursor=pointer]:
|
||||
- /url: /organizations/patients/
|
||||
- img [ref=e42]
|
||||
- button [ref=e46] [cursor=pointer]:
|
||||
- img [ref=e47]
|
||||
- button [ref=e52] [cursor=pointer]:
|
||||
- img [ref=e53]
|
||||
- link [ref=e58] [cursor=pointer]:
|
||||
- /url: /organizations/departments/
|
||||
- img [ref=e59]
|
||||
- link [ref=e63] [cursor=pointer]:
|
||||
- /url: /projects/
|
||||
- img [ref=e64]
|
||||
- button [ref=e69] [cursor=pointer]:
|
||||
- img [ref=e70]
|
||||
- button [ref=e74] [cursor=pointer]:
|
||||
- img [ref=e75]
|
||||
- button [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e81]
|
||||
- button [ref=e88] [cursor=pointer]:
|
||||
- img [ref=e89]
|
||||
- button [ref=e93] [cursor=pointer]:
|
||||
- img [ref=e94]
|
||||
- link [ref=e96] [cursor=pointer]:
|
||||
- /url: /accounts/acknowledgements/my-acknowledgements/
|
||||
- img [ref=e97]
|
||||
- button [ref=e103] [cursor=pointer]:
|
||||
- img [ref=e104]
|
||||
- generic [ref=e107]:
|
||||
- link "🇬🇧" [ref=e108] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e109]: 🇬🇧
|
||||
- link "🇸🇦" [ref=e110] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e111]: 🇸🇦
|
||||
- generic [ref=e112]:
|
||||
- banner [ref=e113]:
|
||||
- generic [ref=e114]:
|
||||
- heading "Welcome, E2E" [level=2] [ref=e115]
|
||||
- paragraph [ref=e116]: Welcome to PX360 Patient Experience Management
|
||||
- generic [ref=e117]:
|
||||
- button [ref=e119] [cursor=pointer]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e123]:
|
||||
- link "🇬🇧 English" [ref=e124] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=en
|
||||
- generic [ref=e125]: 🇬🇧
|
||||
- generic [ref=e126]: English
|
||||
- link "🇸🇦 Arabic" [ref=e127] [cursor=pointer]:
|
||||
- /url: /core/set-language/?language=ar
|
||||
- generic [ref=e128]: 🇸🇦
|
||||
- generic [ref=e129]: Arabic
|
||||
- button "E2E PX Employee PX Employee E" [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]: E2E PX Employee
|
||||
- generic [ref=e134]: PX Employee
|
||||
- generic [ref=e135]: E
|
||||
- img [ref=e136]
|
||||
- main [ref=e138]:
|
||||
- generic [ref=e139]:
|
||||
- generic [ref=e140]:
|
||||
- link "Appreciations" [ref=e141] [cursor=pointer]:
|
||||
- /url: /appreciation/
|
||||
- img [ref=e142]
|
||||
- generic [ref=e144]: Detail
|
||||
- generic [ref=e145]: Sent
|
||||
- generic [ref=e146]:
|
||||
- heading "Appreciation Detail" [level=1] [ref=e147]:
|
||||
- img [ref=e148]
|
||||
- text: Appreciation Detail
|
||||
- generic [ref=e151]: 2026-07-09 13:55 — E2E Test Hospital
|
||||
- navigation [ref=e152]:
|
||||
- button "Details" [ref=e153] [cursor=pointer]
|
||||
- button "AI Analysis" [ref=e154] [cursor=pointer]
|
||||
- button "Notes" [ref=e155] [cursor=pointer]
|
||||
- main [ref=e156]:
|
||||
- generic [ref=e158]:
|
||||
- generic [ref=e159]:
|
||||
- heading "Appreciation Message" [level=3] [ref=e160]:
|
||||
- img [ref=e161]
|
||||
- text: Appreciation Message
|
||||
- paragraph
|
||||
- paragraph [ref=e163]: thank
|
||||
- paragraph
|
||||
- generic [ref=e164]:
|
||||
- heading "Submitted By" [level=3] [ref=e165]:
|
||||
- img [ref=e166]
|
||||
- text: Submitted By
|
||||
- generic [ref=e169]:
|
||||
- generic [ref=e170]:
|
||||
- paragraph [ref=e171]: Name
|
||||
- paragraph [ref=e172]: ismail
|
||||
- generic [ref=e173]:
|
||||
- paragraph [ref=e174]: Phone
|
||||
- paragraph [ref=e175]: "0569874589"
|
||||
- generic [ref=e176]:
|
||||
- heading "Sent" [level=3] [ref=e177]:
|
||||
- img [ref=e178]
|
||||
- text: Sent
|
||||
- generic [ref=e182]:
|
||||
- paragraph [ref=e183]: Sent At
|
||||
- paragraph [ref=e184]: 2026-07-09 13:57
|
||||
- contentinfo [ref=e186]:
|
||||
- paragraph [ref=e187]:
|
||||
- text: Powered by
|
||||
- link "tenhal.sa" [ref=e188] [cursor=pointer]:
|
||||
- /url: https://tenhal.sa
|
||||
@ -1 +0,0 @@
|
||||
hh-dev:hh-dev-build-1784453317|1784453370
|
||||
23
Dockerfile
23
Dockerfile
@ -12,8 +12,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3-dev \
|
||||
musl-dev \
|
||||
libpq-dev \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml ./
|
||||
@ -21,13 +19,14 @@ COPY pyproject.toml ./
|
||||
RUN pip install --upgrade pip setuptools wheel && \
|
||||
pip install -e "."
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build:css
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install && npm run build:css
|
||||
|
||||
RUN python manage.py collectstatic --noinput || true
|
||||
|
||||
@ -44,9 +43,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
postgresql-client \
|
||||
curl \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG UID=1000
|
||||
@ -55,17 +51,14 @@ RUN groupadd -g ${UID} -r appuser && useradd -u ${UID} -r -g appuser -m appuser
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# App source WITHOUT node_modules (.dockerignore excludes it), + builder's built artifacts.
|
||||
COPY . /app
|
||||
COPY --from=builder /app/static/dist /app/static/dist
|
||||
COPY --from=builder /app/staticfiles /app/staticfiles
|
||||
COPY --from=builder /app /app
|
||||
|
||||
RUN pip uninstall -y pip setuptools wheel && \
|
||||
rm -rf /usr/local/lib/python3.12/site-packages/pip* \
|
||||
/usr/local/lib/python3.12/site-packages/setuptools* \
|
||||
/usr/local/lib/python3.12/site-packages/wheel*
|
||||
|
||||
COPY entrypoint.prod.sh /app/entrypoint.prod.sh
|
||||
RUN chmod +x /app/entrypoint.prod.sh && \
|
||||
mkdir -p logs media staticfiles && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
41
Makefile
41
Makefile
@ -1,41 +0,0 @@
|
||||
# PX360 deployment helpers for the `target` tool.
|
||||
#
|
||||
# First time (per environment):
|
||||
# make init-dev # creates deploy/env.dev from the template
|
||||
# $EDITOR deploy/env.dev # fill in SECRET_KEY, API keys, DB password, etc.
|
||||
#
|
||||
# Deploy (builds image, ships the whole .env, runs compose up):
|
||||
# make deploy-dev
|
||||
# make deploy-prod
|
||||
#
|
||||
# Each environment is a single gitignored file (deploy/env.dev / deploy/env.prod)
|
||||
# holding ALL config + secrets. `target deploy` ships it to the server as .env.
|
||||
|
||||
.PHONY: deploy-dev deploy-prod init-dev init-prod check-dev check-prod
|
||||
|
||||
# --- One-time: create the gitignored env file from the committed template ---
|
||||
init-dev:
|
||||
cp -n deploy/env.dev.example deploy/env.dev 2>/dev/null || true
|
||||
@echo "Created deploy/env.dev — edit it and fill in the secrets, then: make deploy-dev"
|
||||
|
||||
init-prod:
|
||||
cp -n deploy/env.prod.example deploy/env.prod 2>/dev/null || true
|
||||
@echo "Created deploy/env.prod — edit it and fill in the secrets, then: make deploy-prod"
|
||||
|
||||
# --- Guards ---
|
||||
check-dev:
|
||||
@if [ ! -f deploy/env.dev ]; then \
|
||||
echo "ERROR: deploy/env.dev not found. Run: make init-dev"; exit 1; \
|
||||
fi
|
||||
|
||||
check-prod:
|
||||
@if [ ! -f deploy/env.prod ]; then \
|
||||
echo "ERROR: deploy/env.prod not found. Run: make init-prod"; exit 1; \
|
||||
fi
|
||||
|
||||
# --- Deploy (builds image, ships .env, runs compose up) ---
|
||||
deploy-dev: check-dev
|
||||
target deploy --config deploy/target.dev.yaml
|
||||
|
||||
deploy-prod: check-prod
|
||||
target deploy --config deploy/target.prod.yaml
|
||||
@ -207,4 +207,4 @@ META_APP_SECRET = "b80750bd12ab7f1c21d7d0ca891ba5ab"
|
||||
META_REDIRECT_URI = "https://micha-nonparabolic-lovie.ngrok-free.dev/social/callback/META/"
|
||||
META_WEBHOOK_VERIFY_TOKEN = "random_secret_string_khanfaheed123456"
|
||||
|
||||
EMAIL_LOGO_URL = "http://127.0.0.1:8000/static/images/logos/HH_P_H_Logo_CMYK---LANDSCIP1.png"
|
||||
EMAIL_LOGO_URL = "http://127.0.0.1:8000/static/img/HH_R_H_Logo.png"
|
||||
|
||||
308
appreciation/forms.py
Normal file
308
appreciation/forms.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""
|
||||
Appreciation forms
|
||||
"""
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import (
|
||||
Appreciation,
|
||||
AppreciationAttachment,
|
||||
AppreciationCategory,
|
||||
AppreciationComment,
|
||||
AppreciationStatus,
|
||||
AppreciationType,
|
||||
)
|
||||
|
||||
|
||||
class AppreciationCategoryForm(forms.ModelForm):
|
||||
"""Form for AppreciationCategory"""
|
||||
|
||||
class Meta:
|
||||
model = AppreciationCategory
|
||||
fields = ['name', 'description', 'is_active', 'display_order']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('Category name')
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': _('Description')
|
||||
}),
|
||||
'is_active': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
'display_order': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 0
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class AppreciationForm(forms.ModelForm):
|
||||
"""Form for Appreciation"""
|
||||
submitter_role = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('Your role, e.g., Nurse Manager')
|
||||
})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Appreciation
|
||||
fields = [
|
||||
'title',
|
||||
'description',
|
||||
'story',
|
||||
'appreciation_type',
|
||||
'category',
|
||||
'recipient_name',
|
||||
'recipient_type',
|
||||
'recipient_id',
|
||||
'submitter_role',
|
||||
'tags',
|
||||
'is_public',
|
||||
'share_on_dashboard',
|
||||
'share_in_newsletter',
|
||||
]
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('Appreciation title')
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 5,
|
||||
'placeholder': _('Describe the appreciation')
|
||||
}),
|
||||
'story': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 8,
|
||||
'placeholder': _('Tell the story behind this appreciation')
|
||||
}),
|
||||
'appreciation_type': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'category': forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
}),
|
||||
'recipient_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('Name of person or team')
|
||||
}),
|
||||
'recipient_type': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('e.g., Staff, Patient, Department')
|
||||
}),
|
||||
'recipient_id': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('System ID if applicable')
|
||||
}),
|
||||
'tags': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': _('Comma-separated tags')
|
||||
}),
|
||||
'is_public': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
'share_on_dashboard': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
'share_in_newsletter': forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
hospital = kwargs.pop('hospital', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter categories to active ones only
|
||||
if self.fields.get('category'):
|
||||
self.fields['category'].queryset = AppreciationCategory.objects.filter(
|
||||
is_active=True
|
||||
).order_by('display_order', 'name')
|
||||
|
||||
# Set default values based on context
|
||||
if user and not self.instance.pk:
|
||||
self.instance.submitted_by = user
|
||||
|
||||
if hospital and not self.instance.pk:
|
||||
self.instance.hospital = hospital
|
||||
|
||||
def clean_tags(self):
|
||||
tags = self.cleaned_data.get('tags', '')
|
||||
if tags:
|
||||
# Convert comma-separated string to list
|
||||
return [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
return []
|
||||
|
||||
|
||||
class AppreciationSubmitForm(AppreciationForm):
|
||||
"""Form for submitting an appreciation (sets status to submitted)"""
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
instance.status = AppreciationStatus.SUBMITTED
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class AppreciationAcknowledgeForm(forms.ModelForm):
|
||||
"""Form for acknowledging an appreciation"""
|
||||
acknowledgment_notes = forms.CharField(
|
||||
required=True,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': _('Add your acknowledgment notes here')
|
||||
})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Appreciation
|
||||
fields = ['acknowledgment_notes']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if user and not self.instance.pk:
|
||||
self.instance.acknowledged_by = user
|
||||
|
||||
|
||||
class AppreciationAttachmentForm(forms.ModelForm):
|
||||
"""Form for uploading appreciation attachments"""
|
||||
description = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 2,
|
||||
'placeholder': _('Describe the attachment')
|
||||
})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = AppreciationAttachment
|
||||
fields = ['file', 'description']
|
||||
widgets = {
|
||||
'file': forms.FileInput(attrs={
|
||||
'class': 'form-control',
|
||||
'accept': 'image/*,.pdf,.doc,.docx'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if user and not self.instance.pk:
|
||||
self.instance.uploaded_by = user
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Extract file information
|
||||
if instance.file:
|
||||
instance.filename = instance.file.name
|
||||
instance.file_type = instance.file.content_type
|
||||
instance.file_size = instance.file.size
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class AppreciationCommentForm(forms.ModelForm):
|
||||
"""Form for adding comments to appreciations"""
|
||||
comment = forms.CharField(
|
||||
required=True,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': _('Write your comment here')
|
||||
})
|
||||
)
|
||||
is_internal = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Internal Comment'),
|
||||
help_text=_('Check if this is an internal comment only visible to staff'),
|
||||
widget=forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = AppreciationComment
|
||||
fields = ['comment', 'is_internal']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user', None)
|
||||
appreciation = kwargs.pop('appreciation', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if user and not self.instance.pk:
|
||||
self.instance.user = user
|
||||
|
||||
if appreciation and not self.instance.pk:
|
||||
self.instance.appreciation = appreciation
|
||||
|
||||
|
||||
class AppreciationFilterForm(forms.Form):
|
||||
"""Form for filtering appreciations"""
|
||||
search = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': _('Search appreciations...')
|
||||
})
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', _('All Statuses'))] + AppreciationStatus.choices,
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
)
|
||||
appreciation_type = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[('', _('All Types'))] + AppreciationType.choices,
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
)
|
||||
category = forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=AppreciationCategory.objects.filter(
|
||||
is_active=True
|
||||
).order_by('display_order', 'name'),
|
||||
empty_label=_('All Categories'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'form-select'
|
||||
})
|
||||
)
|
||||
date_from = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
})
|
||||
)
|
||||
date_to = forms.DateField(
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={
|
||||
'class': 'form-control',
|
||||
'type': 'date'
|
||||
})
|
||||
)
|
||||
is_public = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
})
|
||||
)
|
||||
@ -1,236 +0,0 @@
|
||||
"""
|
||||
Accounts forms - User create/edit for config console
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.models import Group
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.organizations.models import Hospital, Department
|
||||
from apps.core.form_mixins import DepartmentFieldMixin
|
||||
|
||||
|
||||
class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
|
||||
"""Form for creating a new user in the config console"""
|
||||
|
||||
email = forms.EmailField(
|
||||
label=_("Email"),
|
||||
widget=forms.EmailInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
"placeholder": "user@example.com",
|
||||
}),
|
||||
)
|
||||
first_name = forms.CharField(
|
||||
label=_("First Name"),
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label=_("Last Name"),
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
phone = forms.CharField(
|
||||
label=_("Phone"),
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
"placeholder": "+966501234567",
|
||||
}),
|
||||
)
|
||||
employee_id = forms.CharField(
|
||||
label=_("Employee ID"),
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
hospital = forms.ModelChoiceField(
|
||||
queryset=Hospital.objects.filter(status="active"),
|
||||
label=_("Hospital"),
|
||||
required=False,
|
||||
widget=forms.Select(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
department = forms.ModelChoiceField(
|
||||
queryset=Department.objects.none(),
|
||||
label=_("Department"),
|
||||
required=False,
|
||||
widget=forms.Select(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
groups = forms.ModelMultipleChoiceField(
|
||||
queryset=Group.objects.all(),
|
||||
label=_("Roles"),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={
|
||||
"class": "role-checkbox",
|
||||
}),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_("Password"),
|
||||
required=False,
|
||||
widget=forms.PasswordInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
"placeholder": _("Leave blank to send a password setup link via email"),
|
||||
}),
|
||||
)
|
||||
password_confirm = forms.CharField(
|
||||
label=_("Confirm Password"),
|
||||
required=False,
|
||||
widget=forms.PasswordInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
is_active = forms.BooleanField(
|
||||
label=_("Active"),
|
||||
initial=True,
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={
|
||||
"class": "w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy",
|
||||
}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"email", "first_name", "last_name", "phone", "employee_id",
|
||||
"hospital", "department", "groups", "is_active",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop("request", None)
|
||||
self.user = kwargs.pop("user", None) or (self.request.user if self.request else None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["groups"].queryset = Group.objects.all().order_by("name")
|
||||
if self.user and self.user.is_hospital_admin() and self.user.hospital:
|
||||
self.fields["hospital"].initial = self.user.hospital
|
||||
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.user.hospital.id)
|
||||
elif self.user and self.user.is_px_admin() and getattr(self.request, "tenant_hospital", None):
|
||||
self.fields["hospital"].initial = self.request.tenant_hospital
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data.get("email")
|
||||
if User.objects.filter(email=email).exists():
|
||||
raise forms.ValidationError(_("A user with this email already exists."))
|
||||
return email
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password = cleaned_data.get("password")
|
||||
password_confirm = cleaned_data.get("password_confirm")
|
||||
if password and password_confirm and password != password_confirm:
|
||||
self.add_error("password_confirm", _("Passwords do not match."))
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
password = self.cleaned_data.get("password")
|
||||
|
||||
if password:
|
||||
user.set_password(password)
|
||||
else:
|
||||
user.set_unusable_password()
|
||||
|
||||
if commit:
|
||||
user.save()
|
||||
selected_groups = self.cleaned_data.get("groups")
|
||||
if selected_groups:
|
||||
user.groups.set(selected_groups)
|
||||
return user
|
||||
|
||||
|
||||
class UserEditForm(DepartmentFieldMixin, forms.ModelForm):
|
||||
"""Form for editing an existing user in the config console"""
|
||||
|
||||
first_name = forms.CharField(
|
||||
label=_("First Name"),
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label=_("Last Name"),
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
phone = forms.CharField(
|
||||
label=_("Phone"),
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
"placeholder": "+966501234567",
|
||||
}),
|
||||
)
|
||||
employee_id = forms.CharField(
|
||||
label=_("Employee ID"),
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
hospital = forms.ModelChoiceField(
|
||||
queryset=Hospital.objects.filter(status="active"),
|
||||
label=_("Hospital"),
|
||||
required=False,
|
||||
widget=forms.Select(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
department = forms.ModelChoiceField(
|
||||
queryset=Department.objects.none(),
|
||||
label=_("Department"),
|
||||
required=False,
|
||||
widget=forms.Select(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
}),
|
||||
)
|
||||
groups = forms.ModelMultipleChoiceField(
|
||||
queryset=Group.objects.all(),
|
||||
label=_("Roles"),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={
|
||||
"class": "role-checkbox",
|
||||
}),
|
||||
)
|
||||
is_active = forms.BooleanField(
|
||||
label=_("Active"),
|
||||
initial=True,
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={
|
||||
"class": "w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy",
|
||||
}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"first_name", "last_name", "phone", "employee_id",
|
||||
"hospital", "department", "groups", "is_active",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop("request", None)
|
||||
self.user = kwargs.pop("user", None) or (self.request.user if self.request else None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["groups"].queryset = Group.objects.all().order_by("name")
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields["groups"].initial = self.instance.groups.all()
|
||||
if self.instance.hospital:
|
||||
self.fields["hospital"].initial = self.instance.hospital
|
||||
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.instance.hospital.id)
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
if commit:
|
||||
user.save()
|
||||
selected_groups = self.cleaned_data.get("groups")
|
||||
if selected_groups is not None:
|
||||
user.groups.set(selected_groups)
|
||||
return user
|
||||
@ -1,221 +0,0 @@
|
||||
"""
|
||||
Server-side PDF generation service for KPI Reports.
|
||||
|
||||
Uses matplotlib for chart rendering and WeasyPrint for HTML-to-PDF conversion.
|
||||
No browser/JS required — works in Celery tasks and behind firewalls.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _render_trend_chart(monthly_data, target, threshold):
|
||||
"""
|
||||
Render the monthly performance trend chart as a base64-encoded PNG.
|
||||
|
||||
Args:
|
||||
monthly_data: list of 12 KPIReportMonthlyData instances (or None)
|
||||
target: target percentage (float)
|
||||
threshold: threshold percentage (float)
|
||||
|
||||
Returns:
|
||||
base64-encoded PNG string, or empty string on failure
|
||||
"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError:
|
||||
logger.warning("matplotlib not available — skipping trend chart")
|
||||
return ""
|
||||
|
||||
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
values = []
|
||||
for m in monthly_data:
|
||||
if m and m.percentage is not None:
|
||||
values.append(float(m.percentage))
|
||||
else:
|
||||
values.append(None)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(14, 6.5))
|
||||
fig.patch.set_facecolor("white")
|
||||
|
||||
non_none = [(i, v) for i, v in enumerate(values) if v is not None]
|
||||
if non_none:
|
||||
x_indices = [i for i, _ in non_none]
|
||||
y_values = [v for _, v in non_none]
|
||||
ax.plot(range(12), values, color="#005696", linewidth=2, marker="o", markersize=5, zorder=3)
|
||||
ax.scatter(x_indices, y_values, color="#005696", s=40, zorder=4)
|
||||
|
||||
below = [(i, v) for i, v in non_none if v < threshold]
|
||||
if below:
|
||||
bx = [i for i, _ in below]
|
||||
by = [v for _, v in below]
|
||||
ax.scatter(bx, by, color="#dc2626", s=60, zorder=5, edgecolors="white", linewidths=1)
|
||||
|
||||
ax.axhline(y=target, color="#16a34a", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Target ({target:.0f}%)")
|
||||
ax.axhline(y=threshold, color="#dc2626", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Threshold ({threshold:.0f}%)")
|
||||
|
||||
ax.set_ylim(0, 105)
|
||||
ax.set_xticks(range(12))
|
||||
ax.set_xticklabels(months, fontsize=8)
|
||||
ax.set_ylabel("Percentage (%)", fontsize=9)
|
||||
ax.set_title("Monthly Performance Trend", fontsize=11, fontweight="bold", color="#005696", pad=12)
|
||||
ax.legend(loc="lower right", fontsize=7, framealpha=0.9)
|
||||
ax.grid(True, axis="y", alpha=0.3, linestyle="-")
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
|
||||
plt.tight_layout()
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format="png", dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
buf.seek(0)
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def _render_source_chart(source_breakdowns):
|
||||
"""
|
||||
Render the complaints-by-source donut chart as a base64-encoded PNG.
|
||||
|
||||
Args:
|
||||
source_breakdowns: QuerySet of KPIReportSourceBreakdown
|
||||
|
||||
Returns:
|
||||
base64-encoded PNG string, or empty string on failure
|
||||
"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError:
|
||||
logger.warning("matplotlib not available — skipping source chart")
|
||||
return ""
|
||||
|
||||
sources = list(source_breakdowns)
|
||||
if not sources:
|
||||
return ""
|
||||
|
||||
labels = [s.source_name for s in sources]
|
||||
sizes = [float(s.percentage) for s in sources]
|
||||
counts = [s.complaint_count for s in sources]
|
||||
|
||||
colors = ["#005696", "#007bbd", "#3b82f6", "#f59e0b", "#10b981", "#8b5cf6", "#ef4444", "#6b7280"]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 7))
|
||||
fig.patch.set_facecolor("white")
|
||||
|
||||
wedges, texts, autotexts = ax.pie(
|
||||
sizes,
|
||||
labels=None,
|
||||
autopct=lambda pct: f"{pct:.0f}%" if pct > 5 else "",
|
||||
colors=colors[: len(sources)],
|
||||
startangle=90,
|
||||
pctdistance=0.80,
|
||||
wedgeprops=dict(width=0.4, edgecolor="white", linewidth=2),
|
||||
)
|
||||
|
||||
for autotext in autotexts:
|
||||
autotext.set_color("white")
|
||||
autotext.set_fontsize(8)
|
||||
autotext.set_fontweight("bold")
|
||||
|
||||
total_count = sum(counts)
|
||||
ax.text(0, 0.05, f"{total_count}", ha="center", va="center", fontsize=20, fontweight="bold", color="#005696")
|
||||
ax.text(0, -0.15, "Total", ha="center", va="center", fontsize=8, color="#64748b")
|
||||
|
||||
ax.legend(
|
||||
wedges,
|
||||
[f"{l} ({c})" for l, c in zip(labels, counts)],
|
||||
loc="center left",
|
||||
bbox_to_anchor=(1, 0.5),
|
||||
fontsize=8,
|
||||
frameon=False,
|
||||
)
|
||||
|
||||
ax.set_title("Complaints by Source", fontsize=11, fontweight="bold", color="#005696", pad=12)
|
||||
|
||||
plt.tight_layout()
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format="png", dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
buf.seek(0)
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def _get_logo_base64():
|
||||
"""
|
||||
Load the hospital logo, thumbnail it, and return as a base64 data URI.
|
||||
|
||||
Returns:
|
||||
base64 data URI string, or empty string on failure
|
||||
"""
|
||||
try:
|
||||
import io
|
||||
from django.conf import settings
|
||||
from PIL import Image as PILImage
|
||||
|
||||
logo_path = settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png"
|
||||
logo_img = PILImage.open(logo_path)
|
||||
logo_img.thumbnail((500, 500), PILImage.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
logo_img.save(buf, format="PNG", optimize=True)
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load logo for KPI PDF: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def generate_kpi_report_pdf(report):
|
||||
"""
|
||||
Generate a PDF for a KPI Report using WeasyPrint.
|
||||
|
||||
Args:
|
||||
report: KPIReport instance with related data loaded
|
||||
|
||||
Returns:
|
||||
PDF file contents as bytes
|
||||
"""
|
||||
from weasyprint import HTML
|
||||
|
||||
monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by("month")
|
||||
total_data = report.monthly_data.filter(month=0).first()
|
||||
|
||||
monthly_data_dict = {m.month: m for m in monthly_data_qs}
|
||||
monthly_data = [monthly_data_dict.get(i) for i in range(1, 13)]
|
||||
|
||||
source_breakdowns = report.source_breakdowns.all()
|
||||
department_breakdowns = report.department_breakdowns.all()
|
||||
location_breakdowns = report.location_breakdowns.all()
|
||||
|
||||
target = float(report.target_percentage) if report.target_percentage else 95.0
|
||||
threshold = float(report.threshold_percentage) if report.threshold_percentage else 90.0
|
||||
|
||||
trend_chart_b64 = _render_trend_chart(monthly_data, target, threshold)
|
||||
source_chart_b64 = _render_source_chart(source_breakdowns)
|
||||
|
||||
ai_analysis = report.ai_analysis or {}
|
||||
|
||||
context = {
|
||||
"report": report,
|
||||
"monthly_data": monthly_data,
|
||||
"total_data": total_data,
|
||||
"source_breakdowns": source_breakdowns,
|
||||
"department_breakdowns": department_breakdowns,
|
||||
"location_breakdowns": location_breakdowns,
|
||||
"trend_chart": trend_chart_b64,
|
||||
"source_chart": source_chart_b64,
|
||||
"ai_analysis": ai_analysis,
|
||||
"logo_path": _get_logo_base64(),
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
}
|
||||
|
||||
html_string = render_to_string("analytics/kpi_report_weasyprint.html", context)
|
||||
from django.conf import settings
|
||||
return HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
|
||||
@ -348,14 +348,11 @@ def kpi_report_regenerate(request, report_id):
|
||||
@login_required
|
||||
def kpi_report_pdf(request, report_id):
|
||||
"""
|
||||
Generate and download a server-side PDF for a KPI report.
|
||||
Generate PDF version of KPI report
|
||||
|
||||
Uses WeasyPrint + matplotlib for high-quality, consistent PDF output
|
||||
without requiring browser JavaScript or external CDNs.
|
||||
Returns HTML page with print-friendly styling and
|
||||
html2pdf.js for client-side PDF generation.
|
||||
"""
|
||||
from .kpi_pdf_service import generate_kpi_report_pdf
|
||||
from django.http import HttpResponse
|
||||
|
||||
user = request.user
|
||||
|
||||
report = get_object_or_404(KPIReport.objects.select_related("hospital", "generated_by"), id=report_id)
|
||||
@ -365,17 +362,55 @@ def kpi_report_pdf(request, report_id):
|
||||
messages.error(request, _("You do not have permission to view this report."))
|
||||
return redirect("analytics:kpi_report_list")
|
||||
|
||||
try:
|
||||
pdf_bytes = generate_kpi_report_pdf(report)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate KPI PDF for report {report_id}: {e}", exc_info=True)
|
||||
messages.error(request, _("Failed to generate PDF. Please try again."))
|
||||
return redirect("analytics:kpi_report_detail", report_id=report_id)
|
||||
# Get monthly data (1-12)
|
||||
monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by("month")
|
||||
total_data = report.monthly_data.filter(month=0).first()
|
||||
|
||||
response = HttpResponse(pdf_bytes, content_type="application/pdf")
|
||||
filename = f"KPI_{report.kpi_id}_{report.year}_{report.month:02d}.pdf"
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
# Build monthly data array ensuring 12 months
|
||||
monthly_data_dict = {m.month: m for m in monthly_data_qs}
|
||||
monthly_data = [monthly_data_dict.get(i) for i in range(1, 13)]
|
||||
|
||||
# Get source breakdowns for pie chart
|
||||
source_breakdowns = report.source_breakdowns.all()
|
||||
source_chart_data = {
|
||||
"labels": [s.source_name for s in source_breakdowns] or ["No Data"],
|
||||
"data": [float(s.percentage) for s in source_breakdowns] or [100],
|
||||
}
|
||||
|
||||
# Get department breakdowns
|
||||
department_breakdowns = report.department_breakdowns.all()
|
||||
|
||||
# Get location breakdowns
|
||||
location_breakdowns = report.location_breakdowns.all()
|
||||
|
||||
# Prepare trend chart data - ensure we have 12 values
|
||||
trend_data_values = []
|
||||
for m in monthly_data:
|
||||
if m:
|
||||
trend_data_values.append(float(m.percentage))
|
||||
else:
|
||||
trend_data_values.append(0.0)
|
||||
|
||||
trend_chart_data = {
|
||||
"labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
"data": trend_data_values,
|
||||
"target": float(report.target_percentage) if report.target_percentage else 95.0,
|
||||
"threshold": float(report.threshold_percentage) if report.threshold_percentage else 90.0,
|
||||
}
|
||||
|
||||
context = {
|
||||
"report": report,
|
||||
"monthly_data": monthly_data,
|
||||
"total_data": total_data,
|
||||
"source_breakdowns": source_breakdowns,
|
||||
"department_breakdowns": department_breakdowns,
|
||||
"location_breakdowns": location_breakdowns,
|
||||
"source_chart_data_json": json.dumps(source_chart_data),
|
||||
"trend_chart_data_json": json.dumps(trend_chart_data),
|
||||
"is_pdf": True,
|
||||
}
|
||||
|
||||
return render(request, "analytics/kpi_report_pdf.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@ -123,7 +123,7 @@ class Command(BaseCommand):
|
||||
"early_warnings": early_warnings,
|
||||
"recommendations": recommendations,
|
||||
"dashboard_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/dashboard/",
|
||||
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/",
|
||||
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/command-center/",
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/px_digest_weekly.html", context)
|
||||
|
||||
@ -128,7 +128,7 @@ def _send_digest(task, period="weekly", days=7):
|
||||
"early_warnings": early_warnings,
|
||||
"recommendations": recommendations,
|
||||
"dashboard_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/dashboard/",
|
||||
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/",
|
||||
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/command-center/",
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/px_digest_weekly.html", context)
|
||||
|
||||
@ -7,7 +7,8 @@ from datetime import datetime
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.cache import cache
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Avg, Count, F, Q
|
||||
from django.db.models import Avg, Count, F, Q, Value
|
||||
from django.db.models.functions import Concat
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
|
||||
@ -15,8 +16,10 @@ from apps.complaints.models import Complaint
|
||||
from apps.organizations.models import Department, Hospital
|
||||
from apps.px_action_center.models import PXAction
|
||||
from apps.surveys.models import SurveyInstance
|
||||
from apps.physicians.models import PhysicianMonthlyRating
|
||||
|
||||
from .models import KPI
|
||||
from .models import KPI, KPIValue
|
||||
from .services import UnifiedAnalyticsService, ExportService
|
||||
from .services.ai_analytics import (
|
||||
ExecutiveSummaryGenerator,
|
||||
EarlyWarningSystem,
|
||||
@ -920,3 +923,498 @@ def kpi_list(request):
|
||||
}
|
||||
|
||||
return render(request, "analytics/kpi_list.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def command_center(request):
|
||||
"""
|
||||
PX Command Center - Unified Dashboard
|
||||
|
||||
Comprehensive dashboard showing all PX360 metrics:
|
||||
- Complaints, Surveys, Actions KPIs
|
||||
- Interactive charts with ApexCharts
|
||||
- Department and Physician rankings
|
||||
- Export to Excel/PDF
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
# Get filter parameters
|
||||
filters = {
|
||||
"date_range": request.GET.get("date_range", "30d"),
|
||||
"hospital": request.GET.get("hospital", ""),
|
||||
"department": request.GET.get("department", ""),
|
||||
"kpi_category": request.GET.get("kpi_category", ""),
|
||||
"custom_start": request.GET.get("custom_start", ""),
|
||||
"custom_end": request.GET.get("custom_end", ""),
|
||||
}
|
||||
|
||||
# Get hospitals for filter
|
||||
hospitals = Hospital.objects.filter(status="active")
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
hospitals = hospitals.filter(id=user.hospital.id)
|
||||
|
||||
# Get departments for filter
|
||||
departments = Department.objects.filter(status="active")
|
||||
if filters.get("hospital"):
|
||||
departments = departments.filter(hospital_id=filters["hospital"])
|
||||
elif not user.is_px_admin() and user.hospital:
|
||||
departments = departments.filter(hospital=user.hospital)
|
||||
|
||||
# Get initial KPIs
|
||||
custom_start = None
|
||||
custom_end = None
|
||||
if filters["custom_start"] and filters["custom_end"]:
|
||||
custom_start = datetime.strptime(filters["custom_start"], "%Y-%m-%d")
|
||||
custom_end = datetime.strptime(filters["custom_end"], "%Y-%m-%d")
|
||||
|
||||
kpis = UnifiedAnalyticsService.get_all_kpis(
|
||||
user=user,
|
||||
date_range=filters["date_range"],
|
||||
hospital_id=filters["hospital"] if filters["hospital"] else None,
|
||||
department_id=filters["department"] if filters["department"] else None,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
# Initial AI data for server-side render
|
||||
from .services.ai_analytics import (
|
||||
ExecutiveSummaryGenerator,
|
||||
EarlyWarningSystem,
|
||||
ComplaintVolumeForecaster,
|
||||
SLABreachPredictor,
|
||||
ActionRecommendationEngine,
|
||||
)
|
||||
|
||||
hospital_id = filters["hospital"] if filters["hospital"] else None
|
||||
department_id = filters["department"] if filters["department"] else None
|
||||
|
||||
if not hospital_id and (user.is_px_admin() or user.is_executive()):
|
||||
tenant = getattr(request, "tenant_hospital", None)
|
||||
if not tenant:
|
||||
tenant = getattr(user, "hospital", None)
|
||||
if tenant:
|
||||
hospital_id = str(tenant.id)
|
||||
|
||||
# Trigger async refresh
|
||||
from .tasks import generate_executive_summary_task, generate_action_recommendations_task
|
||||
|
||||
generate_executive_summary_task.delay(
|
||||
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
|
||||
)
|
||||
generate_action_recommendations_task.delay(
|
||||
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
||||
)
|
||||
|
||||
# Read AI analytics from cache ONLY (never block request with LLM calls)
|
||||
exec_summary = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, department_id, filters["date_range"]))
|
||||
if not exec_summary:
|
||||
exec_summary = {
|
||||
"summary_en": "Executive summary is being computed in the background...",
|
||||
"summary_ar": "جاري حساب الملخص التنفيذي في الخلفية...",
|
||||
"key_findings_en": [],
|
||||
"key_findings_ar": [],
|
||||
"recommendations_en": [],
|
||||
"recommendations_ar": [],
|
||||
"risk_level": "medium",
|
||||
"_data": {},
|
||||
}
|
||||
|
||||
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
|
||||
if early_warnings is None:
|
||||
early_warnings = []
|
||||
|
||||
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
|
||||
if not complaint_forecast:
|
||||
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
|
||||
|
||||
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
|
||||
if sla_breach_predictions is None:
|
||||
sla_breach_predictions = []
|
||||
|
||||
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, department_id, 5))
|
||||
if not action_recommendations:
|
||||
action_recommendations = ActionRecommendationEngine._no_data_response()
|
||||
|
||||
context = {
|
||||
"filters": filters,
|
||||
"departments": departments,
|
||||
"kpis": kpis,
|
||||
"exec_summary": exec_summary,
|
||||
"early_warnings": early_warnings,
|
||||
"complaint_forecast": complaint_forecast,
|
||||
"sla_breach_predictions": sla_breach_predictions,
|
||||
"action_recommendations": action_recommendations,
|
||||
}
|
||||
|
||||
return render(request, "analytics/command_center.html", context)
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def command_center_api(request):
|
||||
"""
|
||||
API endpoint for Command Center data
|
||||
|
||||
Returns JSON data for KPIs, charts, and tables based on filters.
|
||||
Used by JavaScript to dynamically update dashboard.
|
||||
"""
|
||||
if request.method != "GET":
|
||||
return JsonResponse({"error": "Only GET requests allowed"}, status=405)
|
||||
|
||||
user = request.user
|
||||
|
||||
# Get filter parameters
|
||||
date_range = request.GET.get("date_range", "30d")
|
||||
hospital_id = request.GET.get("hospital")
|
||||
department_id = request.GET.get("department")
|
||||
kpi_category = request.GET.get("kpi_category")
|
||||
custom_start_str = request.GET.get("custom_start")
|
||||
custom_end_str = request.GET.get("custom_end")
|
||||
|
||||
# Parse custom dates
|
||||
custom_start = None
|
||||
custom_end = None
|
||||
if custom_start_str and custom_end_str:
|
||||
try:
|
||||
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
|
||||
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle hospital_id (can be integer or UUID string)
|
||||
hospital_id = hospital_id if hospital_id else None
|
||||
|
||||
# Handle department_id (UUID string)
|
||||
department_id = department_id if department_id else None
|
||||
|
||||
if not hospital_id and (user.is_px_admin() or user.is_executive()):
|
||||
tenant = getattr(request, "tenant_hospital", None)
|
||||
if not tenant:
|
||||
tenant = getattr(user, "hospital", None)
|
||||
if tenant:
|
||||
hospital_id = str(tenant.id)
|
||||
|
||||
# Get KPIs
|
||||
kpis = UnifiedAnalyticsService.get_all_kpis(
|
||||
user=user,
|
||||
date_range=date_range,
|
||||
hospital_id=hospital_id,
|
||||
department_id=department_id,
|
||||
kpi_category=kpi_category,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
# Ensure numeric KPIs are proper Python types for JSON serialization
|
||||
numeric_kpis = [
|
||||
"total_complaints",
|
||||
"open_complaints",
|
||||
"overdue_complaints",
|
||||
"high_severity_complaints",
|
||||
"resolved_complaints",
|
||||
"reopened_complaints",
|
||||
"escalated_ovr_complaints",
|
||||
"total_actions",
|
||||
"open_actions",
|
||||
"overdue_actions",
|
||||
"escalated_actions",
|
||||
"resolved_actions",
|
||||
"total_surveys",
|
||||
"negative_surveys",
|
||||
"avg_survey_score",
|
||||
"negative_social_mentions",
|
||||
"low_call_ratings",
|
||||
"total_sentiment_analyses",
|
||||
]
|
||||
|
||||
for key in numeric_kpis:
|
||||
if key in kpis:
|
||||
value = kpis[key]
|
||||
if value is None:
|
||||
kpis[key] = 0.0 if key == "avg_survey_score" else 0
|
||||
elif isinstance(value, (int, float)):
|
||||
# Already a number - ensure floats for specific fields
|
||||
if key == "avg_survey_score":
|
||||
kpis[key] = float(value)
|
||||
else:
|
||||
# Try to convert to number
|
||||
try:
|
||||
kpis[key] = float(value)
|
||||
except (ValueError, TypeError):
|
||||
kpis[key] = 0.0 if key == "avg_survey_score" else 0
|
||||
|
||||
# Handle nested trend data
|
||||
if "complaints_trend" in kpis and isinstance(kpis["complaints_trend"], dict):
|
||||
trend = kpis["complaints_trend"]
|
||||
trend["current"] = int(trend.get("current", 0))
|
||||
trend["previous"] = int(trend.get("previous", 0))
|
||||
trend["percentage_change"] = float(trend.get("percentage_change", 0))
|
||||
|
||||
# Get chart data
|
||||
chart_types = [
|
||||
"complaints_trend",
|
||||
"complaints_by_category",
|
||||
"survey_satisfaction_trend",
|
||||
"survey_distribution",
|
||||
"department_performance",
|
||||
"physician_leaderboard",
|
||||
]
|
||||
|
||||
charts = {}
|
||||
for chart_type in chart_types:
|
||||
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
|
||||
user=user,
|
||||
chart_type=chart_type,
|
||||
date_range=date_range,
|
||||
hospital_id=hospital_id,
|
||||
department_id=department_id,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
# Get table data
|
||||
tables = {}
|
||||
|
||||
# Overdue complaints table
|
||||
complaints_qs = Complaint.objects.filter(is_overdue=True)
|
||||
if hospital_id:
|
||||
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
|
||||
if department_id:
|
||||
complaints_qs = complaints_qs.filter(department_id=department_id)
|
||||
|
||||
# Apply role-based filtering
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
complaints_qs = complaints_qs.filter(hospital=user.hospital)
|
||||
if user.is_department_manager() and user.department:
|
||||
complaints_qs = complaints_qs.filter(department=user.department)
|
||||
|
||||
tables["overdue_complaints"] = list(
|
||||
complaints_qs.select_related("hospital", "department", "patient", "source")
|
||||
.order_by("due_at")[:20]
|
||||
.values(
|
||||
"id",
|
||||
"title",
|
||||
"severity",
|
||||
"due_at",
|
||||
"complaint_source_type",
|
||||
hospital_name=F("hospital__name"),
|
||||
department_name=F("department__name"),
|
||||
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
|
||||
source_name=F("source__name_en"),
|
||||
assigned_to_full_name=Concat("assigned_to__first_name", Value(" "), "assigned_to__last_name"),
|
||||
)
|
||||
)
|
||||
|
||||
# Physician leaderboard table
|
||||
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
|
||||
tables["physician_leaderboard"] = [
|
||||
{
|
||||
"physician_id": p["physician_id"],
|
||||
"name": p["name"],
|
||||
"specialization": p["specialization"],
|
||||
"department": p["department"],
|
||||
"rating": float(p["rating"]) if p["rating"] is not None else 0.0,
|
||||
"surveys": int(p["surveys"]) if p["surveys"] is not None else 0,
|
||||
"positive": int(p["positive"]) if p["positive"] is not None else 0,
|
||||
"neutral": int(p["neutral"]) if p["neutral"] is not None else 0,
|
||||
"negative": int(p["negative"]) if p["negative"] is not None else 0,
|
||||
}
|
||||
for p in physician_data
|
||||
]
|
||||
|
||||
# ============ AI-POWERED ANALYTICS ============
|
||||
from .services.ai_analytics import (
|
||||
ExecutiveSummaryGenerator,
|
||||
EarlyWarningSystem,
|
||||
ComplaintVolumeForecaster,
|
||||
SLABreachPredictor,
|
||||
ActionRecommendationEngine,
|
||||
)
|
||||
|
||||
# Trigger async Celery tasks for background refresh
|
||||
from .tasks import (
|
||||
generate_executive_summary_task,
|
||||
generate_action_recommendations_task,
|
||||
)
|
||||
|
||||
generate_executive_summary_task.delay(
|
||||
user_id=str(user.id),
|
||||
hospital_id=hospital_id,
|
||||
department_id=department_id,
|
||||
period=date_range.replace("d", "") if date_range.endswith("d") else "30d",
|
||||
)
|
||||
generate_action_recommendations_task.delay(
|
||||
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
||||
)
|
||||
|
||||
# AI features — read from cache ONLY (never block request with LLM calls)
|
||||
exec_summary = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, department_id, date_range))
|
||||
if not exec_summary:
|
||||
exec_summary = {
|
||||
"summary_en": "Executive summary is being computed in the background...",
|
||||
"summary_ar": "جاري حساب الملخص التنفيذي في الخلفية...",
|
||||
"key_findings_en": [],
|
||||
"key_findings_ar": [],
|
||||
"recommendations_en": [],
|
||||
"recommendations_ar": [],
|
||||
"risk_level": "medium",
|
||||
"_data": {},
|
||||
}
|
||||
|
||||
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
|
||||
if early_warnings is None:
|
||||
early_warnings = []
|
||||
|
||||
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
|
||||
if not complaint_forecast:
|
||||
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
|
||||
|
||||
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
|
||||
if sla_breach_predictions is None:
|
||||
sla_breach_predictions = []
|
||||
|
||||
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, department_id, 5))
|
||||
if not action_recommendations:
|
||||
action_recommendations = ActionRecommendationEngine._no_data_response()
|
||||
|
||||
ai_data = {
|
||||
"executive_summary": exec_summary,
|
||||
"early_warnings": early_warnings,
|
||||
"complaint_forecast": complaint_forecast,
|
||||
"sla_breach_predictions": sla_breach_predictions,
|
||||
"action_recommendations": action_recommendations,
|
||||
}
|
||||
|
||||
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables, "ai": ai_data})
|
||||
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
def export_command_center(request, export_format):
|
||||
"""
|
||||
Export Command Center data to Excel or PDF
|
||||
|
||||
Args:
|
||||
export_format: 'excel' or 'pdf'
|
||||
|
||||
Returns:
|
||||
HttpResponse with file download
|
||||
"""
|
||||
if export_format not in ["excel", "pdf"]:
|
||||
return JsonResponse({"error": "Invalid export format"}, status=400)
|
||||
|
||||
user = request.user
|
||||
|
||||
# Get filter parameters
|
||||
date_range = request.GET.get("date_range", "30d")
|
||||
hospital_id = request.GET.get("hospital")
|
||||
department_id = request.GET.get("department")
|
||||
kpi_category = request.GET.get("kpi_category")
|
||||
custom_start_str = request.GET.get("custom_start")
|
||||
custom_end_str = request.GET.get("custom_end")
|
||||
|
||||
# Parse custom dates
|
||||
custom_start = None
|
||||
custom_end = None
|
||||
if custom_start_str and custom_end_str:
|
||||
try:
|
||||
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
|
||||
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle hospital_id and department_id (can be integer or UUID string)
|
||||
hospital_id = hospital_id if hospital_id else None
|
||||
department_id = department_id if department_id else None
|
||||
|
||||
# Get all data
|
||||
kpis = UnifiedAnalyticsService.get_all_kpis(
|
||||
user=user,
|
||||
date_range=date_range,
|
||||
hospital_id=hospital_id,
|
||||
department_id=department_id,
|
||||
kpi_category=kpi_category,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
chart_types = [
|
||||
"complaints_trend",
|
||||
"complaints_by_category",
|
||||
"survey_satisfaction_trend",
|
||||
"survey_distribution",
|
||||
"department_performance",
|
||||
"physician_leaderboard",
|
||||
]
|
||||
|
||||
charts = {}
|
||||
for chart_type in chart_types:
|
||||
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
|
||||
user=user,
|
||||
chart_type=chart_type,
|
||||
date_range=date_range,
|
||||
hospital_id=hospital_id,
|
||||
department_id=department_id,
|
||||
custom_start=custom_start,
|
||||
custom_end=custom_end,
|
||||
)
|
||||
|
||||
# Get table data
|
||||
tables = {}
|
||||
|
||||
# Overdue complaints
|
||||
complaints_qs = Complaint.objects.filter(is_overdue=True)
|
||||
if hospital_id:
|
||||
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
|
||||
if department_id:
|
||||
complaints_qs = complaints_qs.filter(department_id=department_id)
|
||||
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
complaints_qs = complaints_qs.filter(hospital=user.hospital)
|
||||
if user.is_department_manager() and user.department:
|
||||
complaints_qs = complaints_qs.filter(department=user.department)
|
||||
|
||||
tables["overdue_complaints"] = {
|
||||
"headers": ["ID", "Title", "Patient", "Severity", "Hospital", "Department", "Due Date"],
|
||||
"rows": list(
|
||||
complaints_qs.select_related("hospital", "department", "patient")
|
||||
.order_by("due_at")[:100]
|
||||
.annotate(
|
||||
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
|
||||
hospital_name=F("hospital__name"),
|
||||
department_name=F("department__name"),
|
||||
)
|
||||
.values_list("id", "title", "patient_full_name", "severity", "hospital_name", "department_name", "due_at")
|
||||
),
|
||||
}
|
||||
|
||||
# Physician leaderboard
|
||||
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
|
||||
tables["physician_leaderboard"] = {
|
||||
"headers": ["Name", "Specialization", "Department", "Rating", "Surveys", "Positive", "Neutral", "Negative"],
|
||||
"rows": [
|
||||
[
|
||||
p["name"],
|
||||
p["specialization"],
|
||||
p["department"],
|
||||
str(p["rating"]),
|
||||
str(p["surveys"]),
|
||||
str(p["positive"]),
|
||||
str(p["neutral"]),
|
||||
str(p["negative"]),
|
||||
]
|
||||
for p in physician_data
|
||||
],
|
||||
}
|
||||
|
||||
# Prepare export data
|
||||
export_data = ExportService.prepare_dashboard_data(user=user, kpis=kpis, charts=charts, tables=tables)
|
||||
|
||||
# Export based on format
|
||||
if export_format == "excel":
|
||||
return ExportService.export_to_excel(export_data)
|
||||
elif export_format == "pdf":
|
||||
return ExportService.export_to_pdf(export_data)
|
||||
|
||||
return JsonResponse({"error": "Export failed"}, status=500)
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from django.urls import path, reverse_lazy
|
||||
from django.views.generic import RedirectView
|
||||
from django.urls import path
|
||||
from . import ui_views, kpi_views, ask_views
|
||||
|
||||
app_name = 'analytics'
|
||||
@ -10,8 +9,10 @@ urlpatterns = [
|
||||
path('kpis/', ui_views.kpi_list, name='kpi_list'),
|
||||
path('ask-your-data/', ask_views.ask_your_data, name='ask_your_data'),
|
||||
|
||||
# Command Center now lives at the dashboard app (homepage). Redirect old links.
|
||||
path('command-center/', RedirectView.as_view(url=reverse_lazy('dashboard:command-center'), permanent=False), name='command_center'),
|
||||
# Command Center - Unified Dashboard
|
||||
path('command-center/', ui_views.command_center, name='command_center'),
|
||||
path('api/command-center/', ui_views.command_center_api, name='command_center_api'),
|
||||
path('api/command-center/export/<str:export_format>/', ui_views.export_command_center, name='command_center_export'),
|
||||
|
||||
# AI Analytics API
|
||||
path('api/ai-analytics/refresh/', ui_views.refresh_ai_analytics, name='refresh_ai_analytics'),
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
"""Forms for the appreciation module."""
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.appreciation.models import Appreciation
|
||||
from apps.organizations.models import Department
|
||||
|
||||
|
||||
class AppreciationForm(forms.ModelForm):
|
||||
"""Internal form for creating an appreciation (department-targeted)."""
|
||||
|
||||
national_id = forms.CharField(
|
||||
label=_("National ID/Iqama No."),
|
||||
max_length=10,
|
||||
required=True,
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 outline-none",
|
||||
"placeholder": _("National ID or Iqama number"),
|
||||
"inputmode": "numeric",
|
||||
"pattern": "[0-9]{10}",
|
||||
"maxlength": "10",
|
||||
"minlength": "10",
|
||||
}),
|
||||
help_text=_("Exactly 10 digits, numbers only."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Appreciation
|
||||
fields = ["department", "message_en", "national_id"]
|
||||
widgets = {
|
||||
"message_en": forms.Textarea(
|
||||
attrs={
|
||||
"rows": 5,
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 outline-none",
|
||||
"placeholder": "Write your message of appreciation...",
|
||||
}
|
||||
),
|
||||
"department": forms.Select(
|
||||
attrs={"class": "w-full px-4 py-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 outline-none"}
|
||||
),
|
||||
}
|
||||
|
||||
def clean_national_id(self):
|
||||
national_id = self.cleaned_data.get("national_id", "").replace(" ", "")
|
||||
if not national_id.isdigit():
|
||||
raise forms.ValidationError(_("National ID/Iqama must contain digits only."))
|
||||
if len(national_id) != 10:
|
||||
raise forms.ValidationError(_("National ID/Iqama must be exactly 10 digits (you entered %(n)d).") % {"n": len(national_id)})
|
||||
return national_id
|
||||
|
||||
def __init__(self, *args, hospital=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if hospital:
|
||||
dept_qs = Department.objects.filter(hospital=hospital, status="active").order_by("name")
|
||||
else:
|
||||
dept_qs = Department.objects.filter(status="active").select_related("hospital").order_by("name")
|
||||
self.fields["department"].label_from_instance = lambda obj: (
|
||||
f"{obj.name} — {obj.hospital.name}" if obj.hospital else obj.name
|
||||
)
|
||||
self.fields["department"].queryset = dept_qs
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-13 16:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('appreciation', '0006_appreciation_reference_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='appreciation',
|
||||
name='dept_response_reminder_sent_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='appreciation',
|
||||
name='dept_response_second_reminder_sent_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,21 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def convert_ai_analyzed_to_activated(apps, schema_editor):
|
||||
Appreciation = apps.get_model("appreciation", "Appreciation")
|
||||
Appreciation.objects.filter(status="ai_analyzed").update(status="activated")
|
||||
|
||||
|
||||
def reverse_conversion(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("appreciation", "0007_add_reminder_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(convert_ai_analyzed_to_activated, reverse_conversion),
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-18 15:10
|
||||
|
||||
import apps.core.encryption
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('appreciation', '0008_remove_ai_analyzed_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='appreciation',
|
||||
name='national_id',
|
||||
field=apps.core.encryption.EncryptedCharField(blank=True, help_text='National ID/Iqama of the submitter', max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='appreciation',
|
||||
name='national_id_hash',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-20 19:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('appreciation', '0009_add_national_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='appreciation',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('draft', 'Draft'), ('activated', 'Activated'), ('sent', 'Sent')], db_index=True, default='draft', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -12,29 +12,25 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
|
||||
from apps.core.encryption import EncryptedCharField, compute_national_id_hash
|
||||
from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class AppreciationStatus(models.TextChoices):
|
||||
"""Appreciation status choices.
|
||||
|
||||
Collapsed to a 3-state lifecycle: an appreciation is drafted, PX activates
|
||||
it, and sending it to the department/manager is terminal. AI analysis still
|
||||
runs on activation but is stored in ``ai_analysis`` JSON rather than
|
||||
surfaced as a separate status. There is no acknowledge step — the recipient
|
||||
is notified on send, and recognition (badges, leaderboard) fires at SENT.
|
||||
"""
|
||||
"""Appreciation status choices"""
|
||||
|
||||
DRAFT = "draft", "Draft"
|
||||
ACTIVATED = "activated", "Activated"
|
||||
AI_ANALYZED = "ai_analyzed", "AI Analyzed"
|
||||
SENT = "sent", "Sent"
|
||||
ACKNOWLEDGED = "acknowledged", "Acknowledged"
|
||||
|
||||
|
||||
VALID_APPRECIATION_TRANSITIONS = {
|
||||
AppreciationStatus.DRAFT: {AppreciationStatus.ACTIVATED},
|
||||
AppreciationStatus.ACTIVATED: {AppreciationStatus.SENT},
|
||||
AppreciationStatus.SENT: set(), # terminal
|
||||
AppreciationStatus.ACTIVATED: {AppreciationStatus.AI_ANALYZED, AppreciationStatus.SENT},
|
||||
AppreciationStatus.AI_ANALYZED: {AppreciationStatus.SENT},
|
||||
AppreciationStatus.SENT: {AppreciationStatus.ACKNOWLEDGED},
|
||||
AppreciationStatus.ACKNOWLEDGED: set(),
|
||||
}
|
||||
|
||||
|
||||
@ -224,14 +220,6 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
notification_sent = models.BooleanField(default=False)
|
||||
notification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Reminder tracking
|
||||
dept_response_reminder_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
dept_response_second_reminder_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Submitter identity
|
||||
national_id = EncryptedCharField(max_length=512, blank=True, help_text="National ID/Iqama of the submitter")
|
||||
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@ -257,10 +245,6 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
from apps.core.reference import generate_reference
|
||||
|
||||
self.reference_number = generate_reference("APR", self.hospital)
|
||||
if self.national_id:
|
||||
self.national_id_hash = compute_national_id_hash(self.national_id)
|
||||
else:
|
||||
self.national_id_hash = ""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_owner(self):
|
||||
@ -336,16 +320,20 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
def mark_ai_analyzed(self, analysis_data):
|
||||
from django.utils import timezone
|
||||
|
||||
if self.status != AppreciationStatus.ACTIVATED:
|
||||
raise ValueError(f"Cannot mark as AI analyzed from '{self.status}' status. Must be in 'activated'.")
|
||||
|
||||
self.status = AppreciationStatus.AI_ANALYZED
|
||||
self.ai_analyzed_at = timezone.now()
|
||||
self.ai_analysis = analysis_data
|
||||
self.save(update_fields=["ai_analyzed_at", "ai_analysis"])
|
||||
self.save(update_fields=["status", "ai_analyzed_at", "ai_analysis"])
|
||||
|
||||
def send(self):
|
||||
"""Send appreciation and trigger notification"""
|
||||
from django.utils import timezone
|
||||
|
||||
if self.status not in (AppreciationStatus.ACTIVATED,):
|
||||
raise ValueError(f"Cannot send appreciation in '{self.status}' status. Must be 'activated'.")
|
||||
if self.status not in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED):
|
||||
raise ValueError(f"Cannot send appreciation in '{self.status}' status. Must be 'activated' or 'ai_analyzed'.")
|
||||
|
||||
self.status = AppreciationStatus.SENT
|
||||
self.sent_at = timezone.now()
|
||||
@ -353,6 +341,17 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
|
||||
self.send_notification()
|
||||
|
||||
def acknowledge(self):
|
||||
"""Mark appreciation as acknowledged"""
|
||||
from django.utils import timezone
|
||||
|
||||
if self.status != AppreciationStatus.SENT:
|
||||
raise ValueError(f"Cannot acknowledge appreciation in '{self.status}' status. Must be in 'sent'.")
|
||||
|
||||
self.status = AppreciationStatus.ACKNOWLEDGED
|
||||
self.acknowledged_at = timezone.now()
|
||||
self.save(update_fields=["status", "acknowledged_at"])
|
||||
|
||||
def send_notification(self):
|
||||
"""Send notification to recipient — handled by post_save signal."""
|
||||
pass
|
||||
|
||||
@ -1,202 +0,0 @@
|
||||
"""Celery tasks for the appreciation module."""
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_appreciation_notifications(
|
||||
appreciation_id,
|
||||
staff_id=None,
|
||||
note="",
|
||||
email_subject="",
|
||||
email_body="",
|
||||
host="",
|
||||
):
|
||||
"""Send the appreciation email to the appreciated staff member with the
|
||||
department champion and manager CC'd.
|
||||
|
||||
Behaviour:
|
||||
- If ``staff_id`` is provided AND the staff member has an email: ONE email
|
||||
is sent To: the staff member with champion + manager as CC: (deduplicated).
|
||||
- If no staff member is selected (or the staff has no email): separate
|
||||
emails are sent To: each of the champion and manager (current behaviour).
|
||||
- In-app notifications are created for every recipient (To and CC) whose
|
||||
email maps to a User account.
|
||||
- SMS notifications are NOT sent (email-only flow).
|
||||
"""
|
||||
from apps.appreciation.models import Appreciation
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
from apps.organizations.department_contacts import get_champion_and_manager, _staff_contact
|
||||
from apps.organizations.models import Staff
|
||||
|
||||
try:
|
||||
appreciation = Appreciation.objects.select_related("department", "hospital").get(pk=appreciation_id)
|
||||
except Appreciation.DoesNotExist:
|
||||
logger.error(f"Appreciation {appreciation_id} not found for notification task")
|
||||
return
|
||||
|
||||
department = appreciation.department
|
||||
if not department:
|
||||
logger.warning(f"Appreciation {appreciation_id} has no department, skipping notifications")
|
||||
return
|
||||
|
||||
targets = get_champion_and_manager(department)
|
||||
link = f"https://{host}/appreciation/detail/{appreciation.pk}/"
|
||||
notified = []
|
||||
|
||||
# Generate the appreciation letter PDF once and reuse for all email notifications.
|
||||
# Wrapped in try/except so email delivery still succeeds if PDF rendering fails.
|
||||
pdf_attachment = None
|
||||
try:
|
||||
from apps.appreciation.ui_views import _render_appreciation_pdf_bytes, _appreciation_pdf_context
|
||||
|
||||
ctx = _appreciation_pdf_context(appreciation)
|
||||
pdf_bytes = _render_appreciation_pdf_bytes(
|
||||
"appreciation/appreciation_letter.html", ctx, obj=appreciation,
|
||||
)
|
||||
pdf_attachment = (
|
||||
f"appreciation_letter_{appreciation.reference_number}.pdf",
|
||||
pdf_bytes,
|
||||
"application/pdf",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to generate appreciation letter PDF for {appreciation_id}; "
|
||||
f"sending notifications without attachment"
|
||||
)
|
||||
|
||||
# Build the champion + manager email list (in order: champion first, then manager)
|
||||
target_emails_raw = [t["email"] for t in targets if t.get("email")]
|
||||
|
||||
# Resolve the appreciated staff member (if any) and their email
|
||||
staff_email = None
|
||||
appreciated_staff = None
|
||||
if staff_id:
|
||||
try:
|
||||
appreciated_staff = Staff.objects.get(id=staff_id)
|
||||
s_email, _ = _staff_contact(appreciated_staff)
|
||||
staff_email = s_email or None
|
||||
except Staff.DoesNotExist:
|
||||
appreciated_staff = None
|
||||
|
||||
# Build the deduplicated CC list (exclude the staff member's own email)
|
||||
seen_lower = {staff_email.lower()} if staff_email else set()
|
||||
cc_emails = []
|
||||
for email in target_emails_raw:
|
||||
key = email.lower()
|
||||
if key not in seen_lower:
|
||||
seen_lower.add(key)
|
||||
cc_emails.append(email)
|
||||
|
||||
attachments_arg = [pdf_attachment] if pdf_attachment else None
|
||||
|
||||
if staff_email:
|
||||
# CASE 1 (primary): ONE email to the staff member, CC champion + manager.
|
||||
send_subject = email_subject or f"An Appreciation Has Been Submitted About You - {appreciation.reference_number}"
|
||||
send_body = email_body or (
|
||||
f"An appreciation (#{appreciation.reference_number}) has been submitted about you."
|
||||
)
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
email=staff_email,
|
||||
subject=send_subject,
|
||||
message=send_body + f"\n\n{link}",
|
||||
html_message=f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">You've Been Appreciated!</h2>
|
||||
<p>An appreciation <strong>#{appreciation.reference_number}</strong> has been submitted about you.</p>
|
||||
<p><strong>Message:</strong> {appreciation.message_en or 'N/A'}</p>
|
||||
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
||||
<p><a href="{link}">View Appreciation</a></p>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
related_object=appreciation,
|
||||
attachments=attachments_arg,
|
||||
cc=cc_emails,
|
||||
)
|
||||
notified.append(staff_email)
|
||||
for cc_addr in cc_emails:
|
||||
notified.append(f"CC: {cc_addr}")
|
||||
except Exception:
|
||||
logger.exception(f"Failed to send staff email to {staff_email} with CC {cc_emails}")
|
||||
|
||||
elif cc_emails:
|
||||
# CASE 2 (fallback): no specific staff — separate emails to champion + manager.
|
||||
send_subject = email_subject or f"Appreciation Sent to Department - {appreciation.reference_number}"
|
||||
send_body = email_body or (
|
||||
f"Appreciation #{appreciation.reference_number} has been sent to your department ({department.name})."
|
||||
)
|
||||
for email in cc_emails:
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
email=email,
|
||||
subject=send_subject,
|
||||
message=send_body + f"\n\n{link}",
|
||||
html_message=f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Appreciation Sent to Department</h2>
|
||||
<p>Appreciation <strong>#{appreciation.reference_number}</strong> has been sent to your department <strong>({department.name})</strong>.</p>
|
||||
<p><strong>Message:</strong> {appreciation.message_en or 'N/A'}</p>
|
||||
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
||||
<p><a href="{link}">View Appreciation</a></p>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
related_object=appreciation,
|
||||
attachments=attachments_arg,
|
||||
)
|
||||
notified.append(email)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to send department email to {email}")
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
f"Appreciation {appreciation_id}: no recipients to notify "
|
||||
f"(no staff email and no champion/manager emails)"
|
||||
)
|
||||
|
||||
logger.info(f"Appreciation {appreciation_id} notifications sent to: {', '.join(notified)}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_appreciation_reminder_email(appreciation_id, reminder_type, recipients):
|
||||
"""Send a reminder email to department champion and manager for an appreciation."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from apps.appreciation.models import Appreciation
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
|
||||
appreciation = Appreciation.objects.get(pk=appreciation_id)
|
||||
|
||||
for recipient in recipients:
|
||||
NotificationService.send_email(
|
||||
email=recipient["email"],
|
||||
subject=f"Reminder: Appreciation {appreciation.reference_number} - Acknowledgment Required",
|
||||
message=f"This is a reminder that appreciation {appreciation.reference_number} is awaiting acknowledgment. Please acknowledge it as soon as possible.",
|
||||
html_message=f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Reminder: Acknowledgment Required</h2>
|
||||
<p style="margin: 0 0 12px 0;">This is a reminder that appreciation <strong>{appreciation.reference_number}</strong> is awaiting acknowledgment from your department.</p>
|
||||
<p style="margin: 0; color: #6b7280;">Please acknowledge it as soon as possible.</p>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
related_object=appreciation,
|
||||
)
|
||||
|
||||
logger.info(f"Appreciation {appreciation_id} {reminder_type} reminder sent to {len(recipients)} recipients")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send appreciation reminder email for {appreciation_id}: {e}")
|
||||
@ -1,45 +0,0 @@
|
||||
"""Tests for the appreciation 3-state lifecycle realignment."""
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse, NoReverseMatch
|
||||
|
||||
from apps.appreciation.models import AppreciationStatus, VALID_APPRECIATION_TRANSITIONS
|
||||
|
||||
|
||||
class TestAppreciationThreeStateLifecycle(TestCase):
|
||||
"""Appreciation has exactly 3 statuses: DRAFT, ACTIVATED, SENT."""
|
||||
|
||||
def test_only_three_statuses_exist(self):
|
||||
choices = [value for value, _ in AppreciationStatus.choices]
|
||||
assert set(choices) == {"draft", "activated", "sent"}, \
|
||||
f"Expected exactly 3 statuses, got: {choices}"
|
||||
|
||||
def test_no_ai_analyzed_or_acknowledged(self):
|
||||
assert not hasattr(AppreciationStatus, "AI_ANALYZED"), \
|
||||
"AI_ANALYZED should be removed"
|
||||
assert not hasattr(AppreciationStatus, "ACKNOWLEDGED"), \
|
||||
"ACKNOWLEDGED should be removed"
|
||||
|
||||
def test_sent_is_terminal(self):
|
||||
"""SENT has no outgoing transitions."""
|
||||
assert VALID_APPRECIATION_TRANSITIONS.get(AppreciationStatus.SENT) == set(), \
|
||||
"SENT should be terminal"
|
||||
|
||||
def test_activated_can_go_to_sent(self):
|
||||
"""ACTIVATED transitions directly to SENT (no AI_ANALYZED intermediate)."""
|
||||
assert AppreciationStatus.SENT in \
|
||||
VALID_APPRECIATION_TRANSITIONS[AppreciationStatus.ACTIVATED]
|
||||
|
||||
|
||||
class TestAppreciationRemovedUrls(TestCase):
|
||||
"""The acknowledge and send-dept-reminder URL routes are gone."""
|
||||
|
||||
def test_acknowledge_url_does_not_resolve(self):
|
||||
with pytest.raises(NoReverseMatch):
|
||||
reverse("appreciation:appreciation_acknowledge",
|
||||
kwargs={"pk": "00000000-0000-0000-0000-000000000000"})
|
||||
|
||||
def test_send_dept_reminder_url_does_not_resolve(self):
|
||||
with pytest.raises(NoReverseMatch):
|
||||
reverse("appreciation:appreciation_send_dept_reminder",
|
||||
kwargs={"pk": "00000000-0000-0000-0000-000000000000"})
|
||||
@ -1,96 +0,0 @@
|
||||
"""
|
||||
Tests for the appreciation send-flow fix (Fix 1.1):
|
||||
- The REST API create path no longer crashes (it used to call send() on DRAFT).
|
||||
- activate() + send() from DRAFT lands at SENT.
|
||||
- The activation gate is still intact: send() alone from DRAFT raises ValueError.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.appreciation.models import Appreciation, AppreciationStatus
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
|
||||
class AppreciationModelSendFlowTests(TestCase):
|
||||
"""Model-level proof that the activate-then-send pattern works from DRAFT."""
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="Apr Test Hospital", code="APR", status="active")
|
||||
self.user = User.objects.create_user(email="sender@example.com", password="pass12345")
|
||||
|
||||
def _make_draft(self):
|
||||
return Appreciation.objects.create(
|
||||
hospital=self.hospital,
|
||||
sender=self.user,
|
||||
recipient_content_type=ContentType.objects.get_for_model(User),
|
||||
recipient_object_id=self.user.id,
|
||||
message_en="Great work!",
|
||||
)
|
||||
|
||||
def test_send_alone_from_draft_still_raises(self):
|
||||
"""The activation gate is intact: bare send() from DRAFT is rejected."""
|
||||
ap = self._make_draft()
|
||||
self.assertEqual(ap.status, AppreciationStatus.DRAFT.value)
|
||||
with self.assertRaises(ValueError):
|
||||
ap.send()
|
||||
|
||||
@patch("apps.notifications.services.send_email")
|
||||
@patch("apps.notifications.services.send_sms")
|
||||
def test_activate_then_send_lands_at_sent(self, mock_sms, mock_email):
|
||||
"""Fix 1.1 pattern: activate() -> send() moves DRAFT to SENT cleanly."""
|
||||
ap = self._make_draft()
|
||||
ap.activate(activated_by=self.user)
|
||||
self.assertEqual(ap.status, AppreciationStatus.ACTIVATED.value)
|
||||
self.assertIsNotNone(ap.activated_at)
|
||||
ap.send()
|
||||
self.assertEqual(ap.status, AppreciationStatus.SENT.value)
|
||||
self.assertIsNotNone(ap.sent_at)
|
||||
|
||||
|
||||
class AppreciationAPICreateTests(TestCase):
|
||||
"""Fix 1.1 — the REST API create path (which calls send on a fresh DRAFT)."""
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="API Hospital", code="AP2", status="active")
|
||||
px_group = Group.objects.create(name="PX Admin")
|
||||
self.user = User.objects.create_user(email="api@example.com", password="pass12345")
|
||||
self.user.groups.add(px_group)
|
||||
self.recipient = User.objects.create_user(
|
||||
email="nurse@example.com", password="pass12345", hospital=self.hospital
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
# Don't re-raise view exceptions: the response serializer has a
|
||||
# pre-existing GFK serialization bug (unrelated to this fix) that
|
||||
# raises TypeError during rendering. We assert on DB state instead.
|
||||
self.client.raise_request_exception = False
|
||||
|
||||
@patch("apps.notifications.services.send_email")
|
||||
@patch("apps.notifications.services.send_sms")
|
||||
@patch("apps.complaints.tasks.notify_staff_new_item.delay")
|
||||
def test_api_create_lands_at_sent(self, mock_notify, mock_sms, mock_email):
|
||||
url = "/appreciation/api/appreciations/"
|
||||
data = {
|
||||
"recipient_type": "user",
|
||||
"recipient_id": str(self.recipient.id),
|
||||
"message_en": "Thank you for your excellent care.",
|
||||
"hospital_id": str(self.hospital.id),
|
||||
"visibility": "private",
|
||||
"is_anonymous": False,
|
||||
}
|
||||
self.client.post(url, data, format="json")
|
||||
|
||||
# The create path used to raise ValueError on send(); it now activates
|
||||
# then sends, landing at SENT. We assert on DB state because the response
|
||||
# serializer has a pre-existing GFK serialization issue (raw `recipient`)
|
||||
# unrelated to this fix.
|
||||
ap = Appreciation.objects.get()
|
||||
self.assertEqual(ap.status, AppreciationStatus.SENT.value)
|
||||
self.assertIsNotNone(ap.activated_at)
|
||||
self.assertIsNotNone(ap.sent_at)
|
||||
self.assertEqual(ap.activated_by, self.user)
|
||||
@ -41,33 +41,14 @@ def appreciation_list(request):
|
||||
).order_by("-created_at")
|
||||
|
||||
user = request.user
|
||||
selected_hospital = getattr(request, "tenant_hospital", None)
|
||||
|
||||
if user.is_px_admin():
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_px_management() or user.is_px_employee():
|
||||
if user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
queryset = queryset.filter(department=user.department)
|
||||
elif user.is_champion() and user.department:
|
||||
queryset = queryset.filter(department=user.department)
|
||||
elif user.is_source_user():
|
||||
queryset = queryset.filter(created_by=user)
|
||||
pass
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
status_filter_val = request.GET.get("status", "")
|
||||
# Guard: silently reset unknown/legacy status values (e.g. a bookmarked
|
||||
# ?status=acknowledged from before the 3-state collapse) to "all" so the
|
||||
# user doesn't see an unexplained empty list.
|
||||
if status_filter_val and status_filter_val not in dict(AppreciationStatus.choices):
|
||||
status_filter_val = ""
|
||||
if status_filter_val:
|
||||
queryset = queryset.filter(status=status_filter_val)
|
||||
|
||||
@ -80,32 +61,18 @@ def appreciation_list(request):
|
||||
)
|
||||
|
||||
base_qs = Appreciation.objects.all()
|
||||
if user.is_px_admin():
|
||||
if selected_hospital:
|
||||
base_qs = base_qs.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
base_qs = base_qs.filter(hospital=user.hospital)
|
||||
elif user.is_px_management() or user.is_px_employee():
|
||||
if user.hospital:
|
||||
base_qs = base_qs.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
base_qs = base_qs.filter(department=user.department)
|
||||
elif user.is_champion() and user.department:
|
||||
base_qs = base_qs.filter(department=user.department)
|
||||
elif user.is_source_user():
|
||||
base_qs = base_qs.filter(created_by=user)
|
||||
elif user.hospital:
|
||||
base_qs = base_qs.filter(hospital=user.hospital)
|
||||
else:
|
||||
elif not user.is_px_admin():
|
||||
base_qs = base_qs.none()
|
||||
|
||||
stats = {
|
||||
"total": base_qs.count(),
|
||||
"draft": base_qs.filter(status=AppreciationStatus.DRAFT).count(),
|
||||
"activated": base_qs.filter(
|
||||
status=AppreciationStatus.ACTIVATED
|
||||
status__in=[AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED]
|
||||
).count(),
|
||||
"sent": base_qs.filter(status=AppreciationStatus.SENT).count(),
|
||||
"sent": base_qs.filter(status__in=[AppreciationStatus.SENT, AppreciationStatus.ACKNOWLEDGED]).count(),
|
||||
}
|
||||
|
||||
paginator = Paginator(queryset, 25)
|
||||
@ -150,105 +117,27 @@ def appreciation_detail(request, pk):
|
||||
|
||||
categories = AppreciationCategory.objects.filter(is_active=True).order_by("order", "name_en")
|
||||
|
||||
# Sendable departments for the "Send to Department" modal
|
||||
from apps.organizations.department_contacts import has_contact_target
|
||||
hospital_departments = []
|
||||
if appreciation.hospital:
|
||||
hospital_departments = [
|
||||
d for d in Department.objects.filter(
|
||||
hospital=appreciation.hospital, status="active"
|
||||
).select_related("champion", "manager", "manager__staff_profile").order_by("name")
|
||||
if has_contact_target(d)
|
||||
]
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
appreciation_ct = ContentType.objects.get_for_model(appreciation)
|
||||
generic_notes = appreciation.notes.select_related("created_by").all()
|
||||
|
||||
has_recipient = appreciation.recipient is not None
|
||||
is_activated = appreciation.status == AppreciationStatus.ACTIVATED
|
||||
|
||||
context = {
|
||||
"appreciation": appreciation,
|
||||
"metadata": metadata,
|
||||
"staff_list": staff_queryset,
|
||||
"departments": departments,
|
||||
"categories": categories,
|
||||
"hospital_departments": hospital_departments,
|
||||
"can_activate": appreciation.status == AppreciationStatus.DRAFT,
|
||||
"can_send": is_activated and appreciation.department_id is not None,
|
||||
"has_recipient": has_recipient,
|
||||
"can_send": appreciation.status in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED),
|
||||
"is_recipient": False,
|
||||
"content_type_id": appreciation_ct.pk,
|
||||
"object_id": appreciation.pk,
|
||||
"notes": generic_notes,
|
||||
"notes_count": generic_notes.count(),
|
||||
}
|
||||
_apr_steps = [
|
||||
{"label": _("Activate"), "icon": "zap", "done": appreciation.activated_at is not None},
|
||||
{"label": _("Select Staff"), "icon": "user-check", "done": has_recipient or appreciation.sent_at is not None},
|
||||
{"label": _("Send"), "icon": "send", "done": appreciation.sent_at is not None},
|
||||
]
|
||||
_apr_next = next((i for i, s in enumerate(_apr_steps) if not s["done"]), len(_apr_steps))
|
||||
if _apr_next < len(_apr_steps):
|
||||
_apr_steps[_apr_next]["current"] = True
|
||||
context["workflow_steps"] = _apr_steps
|
||||
return render(request, "appreciation/appreciation_detail.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def appreciation_create(request):
|
||||
"""Internal form for creating a new appreciation (department-targeted)."""
|
||||
user = request.user
|
||||
if not (
|
||||
user.is_px_admin()
|
||||
or user.is_hospital_admin()
|
||||
or user.is_px_management()
|
||||
or user.is_px_employee()
|
||||
):
|
||||
messages.error(request, _("You don't have permission to create appreciations."))
|
||||
return redirect("appreciation:appreciation_list")
|
||||
|
||||
from .forms import AppreciationForm
|
||||
|
||||
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
||||
|
||||
if request.method == "POST":
|
||||
form = AppreciationForm(request.POST, hospital=hospital)
|
||||
if form.is_valid():
|
||||
appreciation = form.save(commit=False)
|
||||
appreciation.sender = user
|
||||
appreciation.hospital = hospital
|
||||
appreciation.status = AppreciationStatus.DRAFT
|
||||
appreciation.save()
|
||||
form.save_m2m()
|
||||
|
||||
staff_id = request.POST.get("staff_id")
|
||||
if staff_id:
|
||||
try:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
staff_ct = ContentType.objects.get_for_model(Staff)
|
||||
staff = Staff.objects.get(pk=staff_id, status="active")
|
||||
appreciation.recipient_content_type = staff_ct
|
||||
appreciation.recipient_object_id = staff.id
|
||||
appreciation.save(update_fields=["recipient_content_type", "recipient_object_id"])
|
||||
except Staff.DoesNotExist:
|
||||
pass
|
||||
|
||||
messages.success(request, _("Appreciation created successfully."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
else:
|
||||
messages.error(request, _("Please correct the errors below."))
|
||||
else:
|
||||
form = AppreciationForm(hospital=hospital)
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"hospital": hospital,
|
||||
}
|
||||
return render(request, "appreciation/appreciation_create.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def appreciation_activate(request, pk):
|
||||
@ -267,6 +156,33 @@ def appreciation_activate(request, pk):
|
||||
messages.error(request, _("Permission denied."))
|
||||
return redirect("appreciation:appreciation_list")
|
||||
|
||||
staff_id = request.POST.get("staff")
|
||||
department_id = request.POST.get("department")
|
||||
category_id = request.POST.get("category")
|
||||
|
||||
if not staff_id:
|
||||
messages.error(request, _("Please select a staff member before activating."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
staff = Staff.objects.filter(id=staff_id).select_related("department", "user").first()
|
||||
if staff:
|
||||
if staff.user:
|
||||
user_ct = ContentType.objects.get_for_model(staff.user)
|
||||
appreciation.recipient_content_type = user_ct
|
||||
appreciation.recipient_object_id = staff.user.id
|
||||
else:
|
||||
staff_ct = ContentType.objects.get_for_model(staff)
|
||||
appreciation.recipient_content_type = staff_ct
|
||||
appreciation.recipient_object_id = staff.id
|
||||
if not department_id and staff.department:
|
||||
department_id = str(staff.department.id)
|
||||
|
||||
if department_id:
|
||||
appreciation.department_id = department_id
|
||||
|
||||
if category_id:
|
||||
appreciation.category_id = category_id
|
||||
|
||||
appreciation.status = AppreciationStatus.ACTIVATED
|
||||
appreciation.activated_at = timezone.now()
|
||||
appreciation.activated_by = user
|
||||
@ -320,71 +236,12 @@ def appreciation_activate(request, pk):
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def appreciation_select_recipient(request, pk):
|
||||
"""Select the department and optional staff recipient for an appreciation (without sending)."""
|
||||
appreciation = get_object_or_404(Appreciation, pk=pk)
|
||||
|
||||
if appreciation.status != AppreciationStatus.ACTIVATED:
|
||||
messages.error(request, _("Appreciation must be activated before selecting a recipient."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
user = request.user
|
||||
if not (
|
||||
user.is_px_admin() or user.is_hospital_admin()
|
||||
or user.is_px_management() or user.is_px_employee()
|
||||
):
|
||||
messages.error(request, _("Permission denied."))
|
||||
return redirect("appreciation:appreciation_list")
|
||||
|
||||
department_id = request.POST.get("department_id")
|
||||
staff_id = request.POST.get("staff_id")
|
||||
|
||||
if request.POST.get("clear_recipient"):
|
||||
appreciation.department = None
|
||||
appreciation.recipient_content_type = None
|
||||
appreciation.recipient_object_id = None
|
||||
appreciation.save(update_fields=["department", "recipient_content_type", "recipient_object_id"])
|
||||
messages.info(request, _("Recipient cleared. Please select a new recipient."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
if not department_id:
|
||||
messages.error(request, _("Please select a department."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
try:
|
||||
department = Department.objects.get(id=department_id, status="active")
|
||||
except Department.DoesNotExist:
|
||||
messages.error(request, _("Invalid department selected."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
appreciation.department = department
|
||||
|
||||
if staff_id:
|
||||
try:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
staff_ct = ContentType.objects.get_for_model(Staff)
|
||||
staff = Staff.objects.get(pk=staff_id, status="active")
|
||||
appreciation.recipient_content_type = staff_ct
|
||||
appreciation.recipient_object_id = staff.id
|
||||
except Staff.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
appreciation.recipient_content_type = None
|
||||
appreciation.recipient_object_id = None
|
||||
|
||||
appreciation.save()
|
||||
messages.success(request, _("Recipient selected: {}").format(department.name))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def appreciation_send(request, pk):
|
||||
appreciation = get_object_or_404(Appreciation, pk=pk)
|
||||
|
||||
if appreciation.status != AppreciationStatus.ACTIVATED:
|
||||
if appreciation.status not in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED):
|
||||
messages.error(request, _("This appreciation must be activated before sending."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
@ -435,167 +292,6 @@ def appreciation_send(request, pk):
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def appreciation_send_to(request, pk):
|
||||
"""Unified AJAX endpoint to send an appreciation to a person or department.
|
||||
|
||||
Mirrors the inquiry/complaint send-to flow used by the shared modal
|
||||
(components/send_to_modal.html). Requires the appreciation to be activated
|
||||
first; advances it to SENT on success.
|
||||
"""
|
||||
appreciation = get_object_or_404(Appreciation, pk=pk)
|
||||
user = request.user
|
||||
|
||||
# Permission
|
||||
if not (
|
||||
user.is_px_admin() or user.is_hospital_admin()
|
||||
or user.is_department_manager()
|
||||
or user.is_px_management() or user.is_px_employee()
|
||||
):
|
||||
return JsonResponse(
|
||||
{"success": False, "error": str(_("You don't have permission to send this appreciation."))},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Must be activated before it can be sent
|
||||
if appreciation.status != AppreciationStatus.ACTIVATED:
|
||||
return JsonResponse(
|
||||
{"success": False, "error": str(_("Activate this appreciation before sending it to a department."))},
|
||||
status=400,
|
||||
)
|
||||
|
||||
recipient_type = request.POST.get("recipient_type", "department")
|
||||
note = request.POST.get("note", "").strip()
|
||||
email_subject = request.POST.get("email_subject", "").strip()
|
||||
email_body = request.POST.get("email_body", "").strip()
|
||||
|
||||
try:
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
|
||||
if recipient_type == "person":
|
||||
person_id = request.POST.get("person_id")
|
||||
if not person_id:
|
||||
return JsonResponse(
|
||||
{"success": False, "error": str(_("Please select a person."))}, status=400
|
||||
)
|
||||
try:
|
||||
person = User.objects.get(id=person_id)
|
||||
except User.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"success": False, "error": str(_("User not found."))}, status=400
|
||||
)
|
||||
|
||||
if person.email:
|
||||
send_subject = email_subject or f"Appreciation Shared - {appreciation.reference_number}"
|
||||
send_body = email_body or (
|
||||
f"An appreciation ({appreciation.reference_number}) has been shared with you."
|
||||
)
|
||||
NotificationService.send_email(
|
||||
email=person.email,
|
||||
subject=send_subject,
|
||||
message=send_body,
|
||||
html_message=f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Appreciation Shared With You</h2>
|
||||
<p>An appreciation <strong>#{appreciation.reference_number}</strong> has been shared with you.</p>
|
||||
<p><strong>Message:</strong> {appreciation.message_en or 'N/A'}</p>
|
||||
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
||||
<p><a href="https://{request.get_host()}/appreciation/detail/{appreciation.pk}/">View Appreciation</a></p>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
related_object=appreciation,
|
||||
)
|
||||
message = f"Appreciation sent to {person.get_full_name()}."
|
||||
|
||||
else: # department
|
||||
department_id = request.POST.get("department_id")
|
||||
if not department_id:
|
||||
return JsonResponse(
|
||||
{"success": False, "error": str(_("Please select a department."))}, status=400
|
||||
)
|
||||
try:
|
||||
department = Department.objects.get(id=department_id, status="active")
|
||||
except Department.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{"success": False, "error": str(_("Department not found."))}, status=400
|
||||
)
|
||||
|
||||
# Auto-target the department's champion and manager (no contact-person picker)
|
||||
from apps.organizations.department_contacts import get_champion_and_manager
|
||||
|
||||
targets = get_champion_and_manager(department)
|
||||
if not targets:
|
||||
return JsonResponse(
|
||||
{"success": False,
|
||||
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending."))},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Route to the chosen department
|
||||
appreciation.department = department
|
||||
appreciation.send_to_department = True
|
||||
if note:
|
||||
appreciation.custom_message = note
|
||||
|
||||
# Set the appreciated staff member as the recipient (if selected)
|
||||
staff_id = request.POST.get("staff_id", "").strip()
|
||||
if staff_id:
|
||||
try:
|
||||
appreciated_staff = Staff.objects.get(id=staff_id)
|
||||
from django.contrib.contenttypes.models import ContentType as CT
|
||||
staff_ct = CT.objects.get_for_model(Staff)
|
||||
appreciation.recipient_content_type = staff_ct
|
||||
appreciation.recipient_object_id = appreciated_staff.id
|
||||
except Staff.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Advance to SENT (validates status is ACTIVATED)
|
||||
appreciation.send()
|
||||
|
||||
# Send notifications (email + SMS to champion, manager, staff) in the background
|
||||
from apps.appreciation.tasks import send_appreciation_notifications
|
||||
send_appreciation_notifications.delay(
|
||||
str(appreciation.pk),
|
||||
staff_id=staff_id or None,
|
||||
note=note,
|
||||
email_subject=email_subject,
|
||||
email_body=email_body,
|
||||
host=request.get_host(),
|
||||
)
|
||||
|
||||
message = f"Appreciation sent to {department.name}."
|
||||
|
||||
StaffActivityService.log_from_request(
|
||||
request,
|
||||
activity_type="send",
|
||||
description=f"Sent appreciation {appreciation.pk} to {recipient_type}",
|
||||
content_object=appreciation,
|
||||
module="appreciation",
|
||||
)
|
||||
AuditService.log_event(
|
||||
event_type="appreciation_sent",
|
||||
description=f"Appreciation {appreciation.pk} sent to {recipient_type}",
|
||||
user=user,
|
||||
content_object=appreciation,
|
||||
metadata={"recipient_type": recipient_type},
|
||||
)
|
||||
|
||||
return JsonResponse({"success": True, "message": message})
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error(f"Error in appreciation_send_to: {e}")
|
||||
return JsonResponse(
|
||||
{"success": False, "error": str(_("An error occurred while sending the appreciation."))},
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ACKNOWLEDGE
|
||||
# ============================================================================
|
||||
@ -603,91 +299,27 @@ def appreciation_send_to(request, pk):
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def appreciation_acknowledge(request, pk):
|
||||
"""DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
|
||||
|
||||
The URL route was removed too. This view function is kept only so that
|
||||
any stale import or bookmarked URL that somehow resolves does not crash
|
||||
the worker; it redirects with a clear message. Safe to delete once no
|
||||
references remain.
|
||||
"""
|
||||
messages.error(
|
||||
request,
|
||||
_("Appreciation acknowledge is no longer supported — sending is the final step."),
|
||||
)
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def appreciation_send_dept_reminder(request, pk):
|
||||
"""Send a reminder to the department that hasn't acknowledged an appreciation."""
|
||||
"""Acknowledge appreciation"""
|
||||
appreciation = get_object_or_404(Appreciation, pk=pk)
|
||||
|
||||
user = request.user
|
||||
# Check if user is recipient
|
||||
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||
if not (
|
||||
user.is_px_admin() or user.is_hospital_admin()
|
||||
or user.is_px_management() or user.is_px_employee()
|
||||
appreciation.recipient_content_type == user_content_type and
|
||||
appreciation.recipient_object_id == request.user.id
|
||||
):
|
||||
messages.error(request, _("You don't have permission to send reminders."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
messages.error(request, "You can only acknowledge appreciations sent to you.")
|
||||
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||
|
||||
if appreciation.status != AppreciationStatus.SENT:
|
||||
messages.warning(request, _("Appreciation must be sent before sending reminders."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
messages.error(request, "This appreciation cannot be acknowledged in its current status.")
|
||||
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||
|
||||
dept = appreciation.department
|
||||
if not dept:
|
||||
messages.error(request, _("No department assigned."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
# Acknowledge
|
||||
appreciation.acknowledge()
|
||||
|
||||
reminder_type = request.POST.get("reminder_type", "first")
|
||||
|
||||
if reminder_type == "first" and appreciation.dept_response_reminder_sent_at:
|
||||
messages.warning(request, _("First reminder already sent."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
if reminder_type == "second" and appreciation.dept_response_second_reminder_sent_at:
|
||||
messages.warning(request, _("Second reminder already sent."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
if reminder_type == "second" and not appreciation.dept_response_reminder_sent_at:
|
||||
messages.warning(request, _("Please send the first reminder before the second."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
try:
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
from apps.organizations.department_contacts import get_champion_and_manager
|
||||
|
||||
targets = get_champion_and_manager(dept)
|
||||
if not targets:
|
||||
messages.error(request, _(f"No champion or manager assigned to {dept.name}."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
recipients = []
|
||||
for target in targets:
|
||||
email = target.get("email") or ""
|
||||
if email:
|
||||
recipients.append({"email": email, "name": target.get("display_name", "")})
|
||||
|
||||
if reminder_type == "first":
|
||||
appreciation.dept_response_reminder_sent_at = timezone.now()
|
||||
else:
|
||||
appreciation.dept_response_second_reminder_sent_at = timezone.now()
|
||||
appreciation.save(update_fields=["dept_response_reminder_sent_at", "dept_response_second_reminder_sent_at"])
|
||||
|
||||
from apps.appreciation.tasks import send_appreciation_reminder_email
|
||||
send_appreciation_reminder_email.delay(str(appreciation.id), reminder_type, recipients)
|
||||
|
||||
AuditService.log_event(
|
||||
event_type="appreciation_reminder_sent",
|
||||
description=f"{reminder_type.capitalize()} reminder sent for appreciation {appreciation.reference_number} by {user.get_full_name()}",
|
||||
content_object=appreciation,
|
||||
)
|
||||
|
||||
messages.success(request, _(f"{reminder_type.capitalize()} reminder sent to {len(recipients)} recipient(s)."))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send appreciation reminder: {e}")
|
||||
messages.error(request, _("Failed to send reminder."))
|
||||
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
messages.success(request, "Appreciation acknowledged successfully.")
|
||||
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@ -715,23 +347,8 @@ def leaderboard_view(request):
|
||||
queryset = AppreciationStats.objects.filter(year=year, month=month)
|
||||
|
||||
# Apply RBAC
|
||||
selected_hospital = getattr(request, "tenant_hospital", None)
|
||||
if user.is_px_admin():
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin() and user.hospital:
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_px_management() or user.is_px_employee():
|
||||
if user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
elif user.is_department_manager() and user.department:
|
||||
queryset = queryset.filter(department=user.department)
|
||||
elif user.is_champion() and user.department:
|
||||
queryset = queryset.filter(department=user.department)
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
# Apply filters
|
||||
hospital_filter = request.GET.get('hospital')
|
||||
@ -866,7 +483,7 @@ def category_list(request):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to manage categories.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -912,7 +529,7 @@ def category_create(request):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to create categories.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -970,7 +587,7 @@ def category_edit(request, pk):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to edit categories.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -1015,7 +632,7 @@ def category_delete(request, pk):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to delete categories.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -1050,7 +667,7 @@ def badge_list(request):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to manage badges.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -1096,7 +713,7 @@ def badge_create(request):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to create badges.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -1158,7 +775,7 @@ def badge_edit(request, pk):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to edit badges.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -1205,7 +822,7 @@ def badge_delete(request, pk):
|
||||
user = request.user
|
||||
|
||||
# Check permission
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to delete badges.")
|
||||
return redirect('appreciation:appreciation_list')
|
||||
|
||||
@ -1236,7 +853,7 @@ def appreciation_restore(request, pk):
|
||||
"""Restore deleted appreciation"""
|
||||
user = request.user
|
||||
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, "You don't have permission to restore appreciation.")
|
||||
return redirect('config:deleted_items')
|
||||
|
||||
@ -1421,16 +1038,6 @@ def public_appreciation_submit(request):
|
||||
if not contact_name or not contact_phone or not message:
|
||||
return JsonResponse({"success": False, "message": "Name, phone, and message are required."}, status=400)
|
||||
|
||||
from apps.core.validators import validate_saudi_phone
|
||||
from django.core.exceptions import ValidationError
|
||||
try:
|
||||
validate_saudi_phone(contact_phone)
|
||||
except ValidationError:
|
||||
return JsonResponse(
|
||||
{"success": False, "message": "Please enter a valid Saudi mobile number (e.g. 05XXXXXXXX or +9665XXXXXXXX)."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not hospital_id:
|
||||
return JsonResponse({"success": False, "message": "Please select a hospital."}, status=400)
|
||||
|
||||
@ -1479,144 +1086,3 @@ def public_appreciation_submit(request):
|
||||
except Exception as e:
|
||||
logger.exception("ERROR in public_appreciation_submit")
|
||||
return JsonResponse({"success": False, "message": str(e)}, status=500)
|
||||
|
||||
def _appreciation_pdf_context(appreciation):
|
||||
"""Build the common context for the appreciation letter & certificate PDFs."""
|
||||
hospital = appreciation.hospital
|
||||
hospital_name = hospital.name if hospital else ""
|
||||
hospital_name_ar = getattr(hospital, "name_ar", "") if hospital else ""
|
||||
|
||||
recipient = appreciation.recipient
|
||||
recipient_name = appreciation.get_recipient_name() or "—"
|
||||
department_name = appreciation.department.get_localized_name() if appreciation.department else ""
|
||||
recipient_title = ""
|
||||
if recipient is not None:
|
||||
getter = getattr(recipient, "get_localized_job_title", None)
|
||||
if callable(getter):
|
||||
recipient_title = getter() or ""
|
||||
recipient_title = recipient_title or getattr(recipient, "job_title", "") or ""
|
||||
|
||||
meta = appreciation.metadata or {}
|
||||
is_patient = meta.get("source") == "public_form" or bool(meta.get("submitted_by_name"))
|
||||
patient_name = (meta.get("submitted_by_name") or "").strip()
|
||||
|
||||
# Signer: sender (unless anonymous), else the activating officer, else generic PX team.
|
||||
signer = appreciation.sender if (appreciation.sender and not appreciation.is_anonymous) else appreciation.activated_by
|
||||
signer_name = _("Al Hammadi Patient Experience")
|
||||
signer_role = _("Patient Experience Team")
|
||||
if signer:
|
||||
signer_name = signer.get_full_name() or signer_name
|
||||
role_names = signer.get_role_names() if hasattr(signer, "get_role_names") else []
|
||||
if role_names:
|
||||
signer_role = ", ".join(role_names)
|
||||
|
||||
date_dt = appreciation.sent_at or appreciation.activated_at or appreciation.created_at
|
||||
|
||||
return {
|
||||
"hospital_name": hospital_name,
|
||||
"hospital_name_ar": hospital_name_ar,
|
||||
"recipient_name": recipient_name,
|
||||
"recipient_title": recipient_title,
|
||||
"department_name": department_name,
|
||||
"is_patient": is_patient,
|
||||
"patient_name": patient_name,
|
||||
"message_en": appreciation.message_en or "",
|
||||
"message_ar": appreciation.message_ar or "",
|
||||
"has_ar": bool((appreciation.message_ar or "").strip()),
|
||||
"signer_name": signer_name,
|
||||
"signer_role": signer_role,
|
||||
"date_dt": date_dt,
|
||||
"reference": appreciation.reference_number or "",
|
||||
}
|
||||
|
||||
|
||||
def _render_appreciation_pdf_bytes(template_name, context, obj=None):
|
||||
"""Render a PDF template via WeasyPrint and return the raw PDF bytes.
|
||||
|
||||
When ``obj`` is provided, its hospital letterhead is injected into the context
|
||||
as ``letterhead_path`` (data URI) via the shared ``pdf_utils`` helper.
|
||||
Used by both the PDF download views (wraps bytes in HttpResponse) and the
|
||||
notification task (attaches bytes to email).
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from weasyprint import HTML
|
||||
|
||||
full_context = dict(context)
|
||||
if obj is not None:
|
||||
from apps.core.pdf_utils import _get_letterhead_data_uri
|
||||
full_context.setdefault("letterhead_path", _get_letterhead_data_uri(obj))
|
||||
|
||||
html_string = render_to_string(template_name, full_context)
|
||||
return HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
|
||||
|
||||
|
||||
def _appreciation_pdf_response(template_name, context, filename, obj=None):
|
||||
"""Render a PDF template via WeasyPrint and return it as an attachment response."""
|
||||
from django.http import HttpResponse
|
||||
|
||||
pdf_file = _render_appreciation_pdf_bytes(template_name, context, obj=obj)
|
||||
response = HttpResponse(pdf_file, content_type="application/pdf")
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
|
||||
def _check_appreciation_pdf_access(request, appreciation):
|
||||
"""Shared permission + activation gate for the appreciation PDF views."""
|
||||
user = request.user
|
||||
allowed = (
|
||||
user.is_px_admin()
|
||||
or user.is_hospital_admin()
|
||||
or user.is_px_management()
|
||||
or user.is_px_employee()
|
||||
or (user.hospital_id and appreciation.hospital_id == user.hospital_id)
|
||||
)
|
||||
if not allowed:
|
||||
messages.error(request, _("You don't have permission to view this appreciation."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
if not appreciation.activated_at:
|
||||
messages.error(request, _("This appreciation must be activated before generating a document."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
if not appreciation.recipient:
|
||||
messages.error(request, _("Select a recipient before generating a document."))
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
return None
|
||||
|
||||
|
||||
@login_required
|
||||
def appreciation_letter(request, pk):
|
||||
"""Everyday Letter of Appreciation (portrait, message-forward, sender-signed)."""
|
||||
obj = get_object_or_404(Appreciation, id=pk)
|
||||
denied = _check_appreciation_pdf_access(request, obj)
|
||||
if denied:
|
||||
return denied
|
||||
ctx = _appreciation_pdf_context(obj)
|
||||
return _appreciation_pdf_response(
|
||||
"appreciation/appreciation_letter.html",
|
||||
ctx,
|
||||
f"appreciation_letter_{obj.reference_number}.pdf",
|
||||
obj=obj,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def appreciation_certificate(request, pk):
|
||||
"""Formal Certificate of Appreciation (landscape, leadership-signed)."""
|
||||
obj = get_object_or_404(Appreciation, id=pk)
|
||||
denied = _check_appreciation_pdf_access(request, obj)
|
||||
if denied:
|
||||
return denied
|
||||
ctx = _appreciation_pdf_context(obj)
|
||||
hospital = obj.hospital
|
||||
ctx["ceo_name"] = hospital.ceo.get_full_name() if hospital and hospital.ceo else ""
|
||||
ctx["medical_director_name"] = (
|
||||
hospital.medical_director.get_full_name() if hospital and hospital.medical_director else ""
|
||||
)
|
||||
ctx["hospital_initials"] = (
|
||||
"".join(w[0] for w in (hospital.name or "").split()[:2]).upper() if hospital and hospital.name else "HH"
|
||||
)
|
||||
return _appreciation_pdf_response(
|
||||
"appreciation/appreciation_certificate.html",
|
||||
ctx,
|
||||
f"appreciation_certificate_{obj.reference_number}.pdf",
|
||||
)
|
||||
|
||||
@ -31,12 +31,10 @@ urlpatterns = [
|
||||
|
||||
# UI Routes
|
||||
path('', ui_views.appreciation_list, name='appreciation_list'),
|
||||
path('new/', ui_views.appreciation_create, name='appreciation_create'),
|
||||
path('detail/<uuid:pk>/', ui_views.appreciation_detail, name='appreciation_detail'),
|
||||
path('detail/<uuid:pk>/activate/', ui_views.appreciation_activate, name='appreciation_activate'),
|
||||
path('detail/<uuid:pk>/select-recipient/', ui_views.appreciation_select_recipient, name='appreciation_select_recipient'),
|
||||
path('detail/<uuid:pk>/send/', ui_views.appreciation_send, name='appreciation_send'),
|
||||
path('detail/<uuid:pk>/send-to/', ui_views.appreciation_send_to, name='appreciation_send_to'),
|
||||
path('acknowledge/<uuid:pk>/', ui_views.appreciation_acknowledge, name='appreciation_acknowledge'),
|
||||
path('leaderboard/', ui_views.leaderboard_view, name='leaderboard_view'),
|
||||
path('badges/', ui_views.my_badges_view, name='my_badges_view'),
|
||||
|
||||
@ -64,7 +62,4 @@ urlpatterns = [
|
||||
|
||||
# Public submission (no auth required)
|
||||
path('public/submit/', ui_views.public_appreciation_submit, name='public_appreciation_submit'),
|
||||
# PDF downloads (require activation)
|
||||
path('detail/<uuid:pk>/letter/', ui_views.appreciation_letter, name='appreciation_letter'),
|
||||
path('detail/<uuid:pk>/certificate/', ui_views.appreciation_certificate, name='appreciation_certificate'),
|
||||
]
|
||||
|
||||
@ -194,9 +194,7 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
||||
is_anonymous=data["is_anonymous"],
|
||||
)
|
||||
|
||||
# Activate + send. activate() honors the activation gate (DRAFT ->
|
||||
# ACTIVATED) and records the activator; send() then moves to SENT.
|
||||
appreciation.activate(activated_by=request.user)
|
||||
# Send appreciation
|
||||
appreciation.send()
|
||||
|
||||
try:
|
||||
@ -211,15 +209,25 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def acknowledge(self, request, pk=None):
|
||||
"""DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
|
||||
"""Acknowledge an appreciation"""
|
||||
appreciation = self.get_object()
|
||||
|
||||
Kept as a 410 Gone endpoint so old API clients get a clear error
|
||||
instead of a 404. Will be removed in a future release.
|
||||
"""
|
||||
return Response(
|
||||
{"error": "Appreciation acknowledge is no longer supported — SENT is terminal."},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
# Check if user is the recipient
|
||||
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||
if not (
|
||||
appreciation.recipient_content_type == user_content_type
|
||||
and appreciation.recipient_object_id == request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You can only acknowledge appreciations sent to you"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Acknowledge
|
||||
appreciation.acknowledge()
|
||||
|
||||
# Serialize and return
|
||||
serializer = AppreciationSerializer(appreciation)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def my_appreciations(self, request):
|
||||
@ -388,7 +396,7 @@ class AppreciationBadgeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
queryset = AppreciationBadge.objects.all()
|
||||
serializer_class = AppreciationBadgeSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [IsAuthenticated, IsPXAdminOrHospitalAdmin]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter badges by hospital"""
|
||||
|
||||
@ -15,7 +15,7 @@ from .models import (
|
||||
ComplaintSLAConfig,
|
||||
ComplaintThreshold,
|
||||
ComplaintUpdate,
|
||||
|
||||
EscalationRule,
|
||||
Inquiry,
|
||||
ExplanationSLAConfig,
|
||||
ComplaintInvolvedDepartment,
|
||||
@ -35,9 +35,10 @@ class ExplanationSLAConfigAdmin(admin.ModelAdmin):
|
||||
"reminder_hours_before",
|
||||
"second_reminder_enabled",
|
||||
"second_reminder_hours_before",
|
||||
"auto_escalate_enabled",
|
||||
"is_active",
|
||||
)
|
||||
list_filter = ("is_active", "second_reminder_enabled")
|
||||
list_filter = ("is_active", "second_reminder_enabled", "auto_escalate_enabled")
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@ -52,6 +53,7 @@ class ExplanationSLAConfigAdmin(admin.ModelAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
("Escalation", {"fields": ("auto_escalate_enabled", "escalation_hours_overdue", "max_escalation_levels")}),
|
||||
)
|
||||
|
||||
|
||||
@ -448,8 +450,6 @@ class InquiryAdmin(admin.ModelAdmin):
|
||||
"created_by",
|
||||
"assigned_to",
|
||||
"created_at",
|
||||
"resolved_at",
|
||||
"closed_at",
|
||||
]
|
||||
list_filter = ["status", "category", "source", "hospital", "created_by", "created_at"]
|
||||
search_fields = [
|
||||
@ -473,19 +473,15 @@ class InquiryAdmin(admin.ModelAdmin):
|
||||
("Inquiry Details", {"fields": ("subject", "message", "category", "source")}),
|
||||
("Creator Tracking", {"fields": ("created_by",)}),
|
||||
("Status & Assignment", {"fields": ("status", "assigned_to")}),
|
||||
("Resolution & Closure", {"fields": ("resolved_at", "resolved_by", "closed_at", "closed_by")}),
|
||||
("Response", {"fields": ("response", "responded_at", "responded_by")}),
|
||||
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
|
||||
readonly_fields = ["responded_at", "resolved_at", "resolved_by", "closed_at", "closed_by", "created_at", "updated_at"]
|
||||
readonly_fields = ["responded_at", "created_at", "updated_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related(
|
||||
"patient", "hospital", "department", "assigned_to",
|
||||
"responded_by", "resolved_by", "closed_by", "created_by",
|
||||
)
|
||||
return qs.select_related("patient", "hospital", "department", "assigned_to", "responded_by", "created_by")
|
||||
|
||||
def subject_preview(self, obj):
|
||||
"""Show preview of subject"""
|
||||
@ -510,7 +506,7 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
|
||||
(
|
||||
"Source-Based Timing (Hours After Creation)",
|
||||
{
|
||||
"fields": ("first_reminder_hours_after", "second_reminder_hours_after"),
|
||||
"fields": ("first_reminder_hours_after", "second_reminder_hours_after", "escalation_hours_after"),
|
||||
"description": 'When set, these override the "Hours Before Deadline" timing. Used for source-based SLAs (e.g., MOH, CCHI).',
|
||||
},
|
||||
),
|
||||
@ -580,6 +576,39 @@ class ComplaintCategoryAdmin(admin.ModelAdmin):
|
||||
hospitals_display.short_description = "Hospitals"
|
||||
|
||||
|
||||
@admin.register(EscalationRule)
|
||||
class EscalationRuleAdmin(admin.ModelAdmin):
|
||||
"""Escalation Rule admin"""
|
||||
|
||||
list_display = ["name", "hospital", "escalate_to_role", "trigger_on_overdue", "order", "is_active"]
|
||||
list_filter = [
|
||||
"hospital",
|
||||
"escalate_to_role",
|
||||
"trigger_on_overdue",
|
||||
"severity_filter",
|
||||
"priority_filter",
|
||||
"is_active",
|
||||
]
|
||||
search_fields = ["name", "description", "hospital__name_en"]
|
||||
ordering = ["hospital", "order"]
|
||||
|
||||
fieldsets = (
|
||||
("Hospital", {"fields": ("hospital",)}),
|
||||
("Rule Details", {"fields": ("name", "description")}),
|
||||
("Trigger Conditions", {"fields": ("trigger_on_overdue", "trigger_hours_overdue")}),
|
||||
("Escalation Target", {"fields": ("escalate_to_role", "escalate_to_user")}),
|
||||
("Filters", {"fields": ("severity_filter", "priority_filter"), "classes": ("collapse",)}),
|
||||
("Order & Status", {"fields": ("order", "is_active")}),
|
||||
("Metadata", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||
)
|
||||
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related("hospital", "escalate_to_user")
|
||||
|
||||
|
||||
@admin.register(ComplaintThreshold)
|
||||
class ComplaintThresholdAdmin(admin.ModelAdmin):
|
||||
"""Complaint Threshold admin"""
|
||||
|
||||
@ -19,6 +19,7 @@ from apps.complaints.models import (
|
||||
ComplaintType,
|
||||
Inquiry,
|
||||
ComplaintSLAConfig,
|
||||
EscalationRule,
|
||||
ComplaintThreshold,
|
||||
ComplaintInvolvedDepartment,
|
||||
ComplaintInvolvedStaff,
|
||||
@ -26,7 +27,6 @@ from apps.complaints.models import (
|
||||
)
|
||||
from apps.core.models import PriorityChoices, SeverityChoices
|
||||
from apps.core.form_mixins import HospitalFieldMixin, DepartmentFieldMixin
|
||||
from apps.core.validators import SAUDI_PHONE_HTML_PATTERN, validate_saudi_phone
|
||||
from apps.organizations.models import Area, Department, Hospital, LocationType, Patient, Staff, Section
|
||||
|
||||
|
||||
@ -78,7 +78,9 @@ class PublicComplaintForm(forms.ModelForm):
|
||||
label=_("Relation to Patient"),
|
||||
choices=[
|
||||
("patient", "Patient"),
|
||||
("relative", "Relative")
|
||||
("relative", "Relative"),
|
||||
("friend", "Friend"),
|
||||
("other", "Other"),
|
||||
],
|
||||
required=True,
|
||||
widget=forms.Select(attrs={"class": "form-control"}),
|
||||
@ -94,12 +96,7 @@ class PublicComplaintForm(forms.ModelForm):
|
||||
label=_("Mobile Number"),
|
||||
max_length=20,
|
||||
required=True,
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": _("05XXXXXXXX or +9665XXXXXXXX"),
|
||||
"pattern": SAUDI_PHONE_HTML_PATTERN,
|
||||
"inputmode": "tel",
|
||||
}),
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Your mobile number")}),
|
||||
)
|
||||
|
||||
# Patient Information
|
||||
@ -286,10 +283,16 @@ class PublicComplaintForm(forms.ModelForm):
|
||||
).order_by("name_en")
|
||||
|
||||
def clean_mobile_number(self):
|
||||
"""Validate Saudi mobile number format (05xxxxxxxx / 5xxxxxxxx / +9665xxxxxxxx / 9665xxxxxxxx)."""
|
||||
"""Validate mobile number format"""
|
||||
mobile_number = self.cleaned_data.get("mobile_number")
|
||||
if mobile_number:
|
||||
validate_saudi_phone(mobile_number)
|
||||
|
||||
# Remove spaces and dashes
|
||||
mobile_number = mobile_number.replace(" ", "").replace("-", "")
|
||||
|
||||
# Validate Saudi mobile format (05xxxxxxxx)
|
||||
if not mobile_number.startswith("05") or len(mobile_number) != 10:
|
||||
raise ValidationError(_("Please enter a valid Saudi mobile number (10 digits starting with 05)"))
|
||||
|
||||
return mobile_number
|
||||
|
||||
def clean_national_id(self):
|
||||
@ -711,7 +714,6 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"contact_name",
|
||||
"contact_phone",
|
||||
"contact_email",
|
||||
"national_id",
|
||||
"section",
|
||||
"priority",
|
||||
"source",
|
||||
@ -764,24 +766,6 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
||||
department_id=department_id
|
||||
).order_by("name_en")
|
||||
|
||||
def clean_area(self):
|
||||
area = self.cleaned_data.get("area")
|
||||
if not area:
|
||||
return None
|
||||
return area
|
||||
|
||||
def clean_department(self):
|
||||
dept = self.cleaned_data.get("department")
|
||||
if not dept:
|
||||
return None
|
||||
return dept
|
||||
|
||||
def clean_section(self):
|
||||
section = self.cleaned_data.get("section")
|
||||
if not section:
|
||||
return None
|
||||
return section
|
||||
|
||||
|
||||
class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""Form for creating and editing SLA configurations"""
|
||||
@ -792,8 +776,12 @@ class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"hospital",
|
||||
"source",
|
||||
"severity",
|
||||
"priority",
|
||||
"sla_hours",
|
||||
"first_reminder_hours_after",
|
||||
"second_reminder_hours_after",
|
||||
"reminder_hours_before",
|
||||
"second_reminder_enabled",
|
||||
"second_reminder_hours_before",
|
||||
"is_active",
|
||||
]
|
||||
@ -801,8 +789,12 @@ class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"hospital": forms.Select(attrs={"class": "form-select"}),
|
||||
"source": forms.Select(attrs={"class": "form-select"}),
|
||||
"severity": forms.Select(attrs={"class": "form-select"}),
|
||||
"priority": forms.Select(attrs={"class": "form-select"}),
|
||||
"sla_hours": forms.NumberInput(attrs={"class": "form-control", "min": "1"}),
|
||||
"first_reminder_hours_after": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
|
||||
"second_reminder_hours_after": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
|
||||
"reminder_hours_before": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
|
||||
"second_reminder_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"second_reminder_hours_before": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
@ -812,25 +804,31 @@ class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
||||
hospital = cleaned_data.get("hospital")
|
||||
source = cleaned_data.get("source")
|
||||
severity = cleaned_data.get("severity")
|
||||
priority = cleaned_data.get("priority")
|
||||
sla_hours = cleaned_data.get("sla_hours")
|
||||
first_reminder = cleaned_data.get("first_reminder_hours_after")
|
||||
second_reminder = cleaned_data.get("second_reminder_hours_after")
|
||||
reminder_hours_before = cleaned_data.get("reminder_hours_before")
|
||||
second_reminder_hours_before = cleaned_data.get("second_reminder_hours_before")
|
||||
|
||||
# Validate SLA hours is positive
|
||||
if sla_hours and sla_hours <= 0:
|
||||
raise ValidationError({"sla_hours": "SLA hours must be greater than 0"})
|
||||
|
||||
# Validate first reminder hours (must be less than SLA hours)
|
||||
if reminder_hours_before and reminder_hours_before > 0:
|
||||
if reminder_hours_before >= sla_hours:
|
||||
raise ValidationError({"reminder_hours_before": "First reminder must be less than SLA hours"})
|
||||
# Validate first reminder hours
|
||||
if first_reminder and first_reminder > 0:
|
||||
if first_reminder >= sla_hours:
|
||||
raise ValidationError({"first_reminder_hours_after": "First reminder must be less than SLA hours"})
|
||||
|
||||
# Validate second reminder hours
|
||||
if second_reminder_hours_before and second_reminder_hours_before > 0:
|
||||
if second_reminder_hours_before >= sla_hours:
|
||||
raise ValidationError({"second_reminder_hours_before": "Second reminder must be less than SLA hours"})
|
||||
if reminder_hours_before and second_reminder_hours_before >= reminder_hours_before:
|
||||
raise ValidationError({"second_reminder_hours_before": "Second reminder must be closer to deadline than first"})
|
||||
if second_reminder and second_reminder > 0:
|
||||
if second_reminder >= sla_hours:
|
||||
raise ValidationError({"second_reminder_hours_after": "Second reminder must be less than SLA hours"})
|
||||
if first_reminder and second_reminder <= first_reminder:
|
||||
raise ValidationError({"second_reminder_hours_after": "Second reminder must be after first reminder"})
|
||||
|
||||
# Validate legacy reminder hours < SLA hours
|
||||
if sla_hours and reminder_hours_before and reminder_hours_before >= sla_hours:
|
||||
raise ValidationError({"reminder_hours_before": "Reminder hours must be less than SLA hours"})
|
||||
|
||||
# Check for unique combination (excluding current instance when editing)
|
||||
filters = {}
|
||||
@ -840,6 +838,8 @@ class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
||||
filters["source"] = source
|
||||
if severity:
|
||||
filters["severity"] = severity
|
||||
if priority:
|
||||
filters["priority"] = priority
|
||||
|
||||
if filters:
|
||||
queryset = ComplaintSLAConfig.objects.filter(**filters)
|
||||
@ -851,6 +851,84 @@ class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class EscalationRuleForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""Form for creating and editing escalation rules"""
|
||||
|
||||
class Meta:
|
||||
model = EscalationRule
|
||||
fields = [
|
||||
"hospital",
|
||||
"name",
|
||||
"description",
|
||||
"escalation_level",
|
||||
"max_escalation_level",
|
||||
"trigger_on_overdue",
|
||||
"trigger_hours_overdue",
|
||||
"reminder_escalation_enabled",
|
||||
"reminder_escalation_hours",
|
||||
"escalate_to_role",
|
||||
"escalate_to_user",
|
||||
"severity_filter",
|
||||
"priority_filter",
|
||||
"is_active",
|
||||
]
|
||||
widgets = {
|
||||
"hospital": forms.Select(attrs={"class": "form-select"}),
|
||||
"name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"description": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"escalation_level": forms.NumberInput(attrs={"class": "form-control", "min": "1"}),
|
||||
"max_escalation_level": forms.NumberInput(attrs={"class": "form-control", "min": 1}),
|
||||
"trigger_on_overdue": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"trigger_hours_overdue": forms.NumberInput(attrs={"class": "form-control", "min": 0}),
|
||||
"reminder_escalation_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
"reminder_escalation_hours": forms.NumberInput(attrs={"class": "form-control", "min": 0}),
|
||||
"escalate_to_role": forms.Select(attrs={"class": "form-select", "id": "escalate_to_role"}),
|
||||
"escalate_to_user": forms.Select(attrs={"class": "form-select"}),
|
||||
"severity_filter": forms.Select(attrs={"class": "form-select"}),
|
||||
"priority_filter": forms.Select(attrs={"class": "form-select"}),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Filter users for escalate_to_user field based on hospital
|
||||
from apps.accounts.models import User
|
||||
|
||||
# Get hospital context
|
||||
hospital = None
|
||||
if self.data.get("hospital"):
|
||||
try:
|
||||
hospital = Hospital.objects.get(id=self.data["hospital"])
|
||||
except Hospital.DoesNotExist:
|
||||
pass
|
||||
elif self.initial.get("hospital"):
|
||||
hospital = self.initial.get("hospital")
|
||||
elif self.instance and self.instance.pk and self.instance.hospital_id:
|
||||
hospital = self.instance.hospital
|
||||
elif self.user and self.user.is_px_admin():
|
||||
hospital = getattr(self.request, "tenant_hospital", None)
|
||||
elif self.user and self.user.hospital:
|
||||
hospital = self.user.hospital
|
||||
|
||||
if hospital:
|
||||
from apps.core.utils import get_assignable_users
|
||||
self.fields["escalate_to_user"].queryset = get_assignable_users(hospital)
|
||||
else:
|
||||
self.fields["escalate_to_user"].queryset = User.objects.none()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
escalate_to_role = cleaned_data.get("escalate_to_role")
|
||||
escalate_to_user = cleaned_data.get("escalate_to_user")
|
||||
|
||||
# If role is 'specific_user', user must be specified
|
||||
if escalate_to_role == "specific_user" and not escalate_to_user:
|
||||
raise ValidationError({"escalate_to_user": "Please select a user when role is set to Specific User"})
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ComplaintThresholdForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"""
|
||||
Form for creating and editing complaint thresholds.
|
||||
@ -888,13 +966,7 @@ class PublicInquiryForm(forms.Form):
|
||||
label=_("Phone Number"),
|
||||
max_length=20,
|
||||
required=True,
|
||||
validators=[validate_saudi_phone],
|
||||
widget=forms.TextInput(attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": _("05XXXXXXXX or +9665XXXXXXXX"),
|
||||
"pattern": SAUDI_PHONE_HTML_PATTERN,
|
||||
"inputmode": "tel",
|
||||
}),
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Your phone number")}),
|
||||
)
|
||||
|
||||
email = forms.EmailField(
|
||||
@ -903,13 +975,6 @@ class PublicInquiryForm(forms.Form):
|
||||
widget=forms.EmailInput(attrs={"class": "form-control", "placeholder": _("your@email.com")}),
|
||||
)
|
||||
|
||||
national_id = forms.CharField(
|
||||
label=_("National ID/Iqama"),
|
||||
max_length=20,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("National ID or Iqama (optional)")}),
|
||||
)
|
||||
|
||||
# Inquiry Details
|
||||
hospital = forms.ModelChoiceField(
|
||||
label=_("Hospital"),
|
||||
|
||||
@ -1,213 +0,0 @@
|
||||
"""
|
||||
Backfill source (PXSource FK) for complaints that were imported without one.
|
||||
|
||||
Handles two import batches:
|
||||
1. historical_excel_2022: metadata.source contains the Arabic source value
|
||||
2. 2025_excel: metadata has no source — re-reads the Excel file to extract it
|
||||
|
||||
Usage:
|
||||
# Preview what would change
|
||||
python manage.py backfill_complaint_sources --dry-run
|
||||
|
||||
# Execute the backfill
|
||||
python manage.py backfill_complaint_sources
|
||||
|
||||
# Only process one batch
|
||||
python manage.py backfill_complaint_sources --batch=historical_excel_2022
|
||||
python manage.py backfill_complaint_sources --batch=2025_excel
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.complaints.models import Complaint
|
||||
from apps.complaints.management.commands.complaint_source_mapping import (
|
||||
resolve_px_source,
|
||||
get_complaint_source_type,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXCEL_2025_PATH = "data/Complaints Report - 2025.xlsx"
|
||||
|
||||
# Header aliases for 2025 Excel column detection
|
||||
HEADER_ALIASES_2025 = {
|
||||
"complaint_num": ["رقم الشكوى"],
|
||||
"source": ["جهة الشكوى"],
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill PXSource for complaints imported without one."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without saving")
|
||||
parser.add_argument(
|
||||
"--batch",
|
||||
choices=["historical_excel_2022", "2025_excel"],
|
||||
default=None,
|
||||
help="Only process a specific import batch",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--excel-file",
|
||||
default=EXCEL_2025_PATH,
|
||||
help=f"Path to 2025 Excel file (default: {EXCEL_2025_PATH})",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.dry_run = options["dry_run"]
|
||||
self.excel_file = options["excel_file"]
|
||||
batch_filter = options["batch"]
|
||||
|
||||
stats = {"total": 0, "updated": 0, "unmapped": 0, "no_source_value": 0, "skipped_2026": 0}
|
||||
|
||||
no_source_qs = Complaint.objects.filter(source__isnull=True)
|
||||
|
||||
if batch_filter:
|
||||
no_source_qs = no_source_qs.filter(metadata__import_source=batch_filter)
|
||||
|
||||
self.stdout.write(f"\nFound {no_source_qs.count()} complaints with no source.")
|
||||
if self.dry_run:
|
||||
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be saved.\n"))
|
||||
|
||||
# Build 2025 Excel lookup if needed
|
||||
excel_lookup = {}
|
||||
needs_excel = not batch_filter or batch_filter == "2025_excel"
|
||||
if needs_excel:
|
||||
self.stdout.write("Reading 2025 Excel for source column...")
|
||||
excel_lookup = self._build_2025_excel_lookup()
|
||||
self.stdout.write(f" Built lookup with {len(excel_lookup)} entries.\n")
|
||||
|
||||
for complaint in no_source_qs.iterator():
|
||||
stats["total"] += 1
|
||||
|
||||
# Skip 2026 test data
|
||||
if complaint.created_at.year == 2026:
|
||||
stats["skipped_2026"] += 1
|
||||
continue
|
||||
|
||||
import_source = complaint.metadata.get("import_source", "")
|
||||
source_value = None
|
||||
|
||||
if import_source == "historical_excel_2022":
|
||||
source_value = complaint.metadata.get("source")
|
||||
|
||||
elif import_source == "2025_excel":
|
||||
complaint_num = complaint.metadata.get("complaint_num")
|
||||
original_sheet = complaint.metadata.get("original_sheet", "")
|
||||
lookup_key = (complaint_num, original_sheet)
|
||||
source_value = excel_lookup.get(lookup_key)
|
||||
|
||||
else:
|
||||
# Unknown import source — try metadata.source as fallback
|
||||
source_value = complaint.metadata.get("source")
|
||||
|
||||
if not source_value:
|
||||
stats["no_source_value"] += 1
|
||||
continue
|
||||
|
||||
px_source = resolve_px_source(source_value)
|
||||
|
||||
if px_source is None:
|
||||
stats["unmapped"] += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" UNMAPPED: Ref={complaint.reference_number} source_value=\"{source_value}\""
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if not self.dry_run:
|
||||
with transaction.atomic():
|
||||
complaint.source = px_source
|
||||
complaint.complaint_source_type = get_complaint_source_type(px_source)
|
||||
complaint.save(update_fields=["source", "complaint_source_type"])
|
||||
|
||||
# Store source in metadata for 2025_excel if missing
|
||||
if import_source == "2025_excel" and "source" not in complaint.metadata:
|
||||
complaint.metadata["source"] = source_value
|
||||
complaint.save(update_fields=["metadata"])
|
||||
|
||||
stats["updated"] += 1
|
||||
|
||||
self._print_report(stats)
|
||||
|
||||
def _build_2025_excel_lookup(self):
|
||||
"""Read all sheets from 2025 Excel and build (complaint_num, sheet) → source_value map."""
|
||||
import openpyxl
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(self.excel_file, read_only=True, data_only=True)
|
||||
except FileNotFoundError:
|
||||
self.stdout.write(self.style.ERROR(f"Excel file not found: {self.excel_file}"))
|
||||
return {}
|
||||
|
||||
lookup = {}
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if sheet_name == "DropDown":
|
||||
continue
|
||||
|
||||
ws = wb[sheet_name]
|
||||
col_map = self._detect_columns(ws)
|
||||
|
||||
if "complaint_num" not in col_map or "source" not in col_map:
|
||||
self.stdout.write(self.style.WARNING(f" Sheet '{sheet_name}': could not detect columns, skipping"))
|
||||
continue
|
||||
|
||||
complaint_col = col_map["complaint_num"]
|
||||
source_col = col_map["source"]
|
||||
|
||||
for row in ws.iter_rows(min_row=3, values_only=True):
|
||||
comp_num = row[complaint_col - 1] if complaint_col - 1 < len(row) else None
|
||||
source_val = row[source_col - 1] if source_col - 1 < len(row) else None
|
||||
|
||||
if comp_num and source_val:
|
||||
normalized_source = str(source_val).strip()
|
||||
if normalized_source and not normalized_source.replace(".", "", 1).isdigit():
|
||||
lookup[(comp_num, sheet_name)] = normalized_source
|
||||
|
||||
wb.close()
|
||||
return lookup
|
||||
|
||||
def _detect_columns(self, ws):
|
||||
"""Scan first 10 rows to find header row and map columns."""
|
||||
mapping = {}
|
||||
for r in range(1, 11):
|
||||
row_values = {}
|
||||
for c in range(1, 80):
|
||||
val = ws.cell(r, c).value
|
||||
if val:
|
||||
if val not in row_values:
|
||||
row_values[val] = c
|
||||
|
||||
if "رقم الشكوى" in row_values:
|
||||
for field, aliases in HEADER_ALIASES_2025.items():
|
||||
for alias in aliases:
|
||||
if alias in row_values:
|
||||
mapping[field] = row_values[alias]
|
||||
break
|
||||
break
|
||||
|
||||
return mapping
|
||||
|
||||
def _print_report(self, stats):
|
||||
self.stdout.write(f"\n{'=' * 60}")
|
||||
self.stdout.write(self.style.SUCCESS("Backfill Report"))
|
||||
self.stdout.write(f"{'=' * 60}")
|
||||
self.stdout.write(f"Total complaints scanned: {stats['total']}")
|
||||
self.stdout.write(f"Updated: {stats['updated']}")
|
||||
self.stdout.write(f"Unmapped source value: {stats['unmapped']}")
|
||||
self.stdout.write(f"No source value found: {stats['no_source_value']}")
|
||||
self.stdout.write(f"Skipped (2026 test data): {stats['skipped_2026']}")
|
||||
|
||||
if stats["updated"] > 0:
|
||||
if self.dry_run:
|
||||
self.stdout.write(self.style.WARNING("\nDry run complete — no changes saved."))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f"\nSuccessfully updated {stats['updated']} complaints."))
|
||||
|
||||
if stats["unmapped"] > 0:
|
||||
self.stdout.write(self.style.WARNING(f"\n{stats['unmapped']} complaints had source values that could not be mapped."))
|
||||
@ -1,153 +0,0 @@
|
||||
"""
|
||||
Bulk-close stale complaints.
|
||||
|
||||
Closes complaints older than a cutoff that are still in an active (non-terminal)
|
||||
status, without triggering patient-facing side effects (no resolution-survey
|
||||
emails/SMS, no per-instance notifications) — the canonical ComplaintService
|
||||
change_status() path is deliberately bypassed for this administrative cleanup.
|
||||
|
||||
An audit trail is still written: one ComplaintUpdate per complaint + one
|
||||
batch AuditEvent.
|
||||
|
||||
Usage:
|
||||
python manage.py close_stale_complaints --dry-run
|
||||
python manage.py close_stale_complaints
|
||||
python manage.py close_stale_complaints --before 2026-01-01 --user ismail@tenhal.sa
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.complaints.models import Complaint, ComplaintStatus, ComplaintUpdate
|
||||
from apps.core.services import AuditService
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
DEFAULT_BEFORE = "2026-01-01"
|
||||
DEFAULT_USER_EMAIL = "ismail@tenhal.sa"
|
||||
ACTIVE_STATUSES = [ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Bulk-close stale complaints older than a cutoff (default: pre-2026, open/in_progress)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--before", default=DEFAULT_BEFORE,
|
||||
help="Cutoff date (YYYY-MM-DD). Complaints created before this are eligible.")
|
||||
parser.add_argument("--user", default=DEFAULT_USER_EMAIL,
|
||||
help="Email/username to attribute the close to (default: %(default)s).")
|
||||
parser.add_argument("--note", default="Bulk close: stale complaint (administrative cleanup)",
|
||||
help="Audit note attached to each closed complaint.")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be closed without changing anything.")
|
||||
|
||||
def _resolve_user(self, ref):
|
||||
for field in ("email", "username"):
|
||||
try:
|
||||
return User.objects.get(**{field: ref})
|
||||
except User.DoesNotExist:
|
||||
continue
|
||||
try:
|
||||
return User.objects.get(pk=ref)
|
||||
except (User.DoesNotExist, ValueError):
|
||||
pass
|
||||
user = User.objects.filter(is_superuser=True).order_by("id").first()
|
||||
if user:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"User '{ref}' not found; falling back to superuser {user.email or user.username}."))
|
||||
return user
|
||||
raise CommandError(f"Could not resolve a user for '{ref}' and no superuser exists.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
before_str = options["before"]
|
||||
try:
|
||||
naive = datetime.strptime(before_str + " 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError as exc:
|
||||
raise CommandError(f"--before must be YYYY-MM-DD: {exc}") from exc
|
||||
before = timezone.make_aware(naive, timezone.get_current_timezone())
|
||||
before_label = before_str
|
||||
|
||||
user = self._resolve_user(options["user"])
|
||||
note = options["note"]
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
qs = Complaint.objects.filter(created_at__lt=before, status__in=ACTIVE_STATUSES)
|
||||
total = qs.count()
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||
f"close_stale_complaints (before={before_label}, user={user.email or user.username}, "
|
||||
f"dry_run={dry_run})"))
|
||||
self.stdout.write(f"Eligible complaints: {total}")
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(self.style.SUCCESS("Nothing to do."))
|
||||
return
|
||||
|
||||
# Breakdown
|
||||
self.stdout.write("\nBy current status:")
|
||||
for row in qs.values("status").annotate(n=Count("id")).order_by("-n"):
|
||||
self.stdout.write(f" {row['status']:<20} {row['n']}")
|
||||
self.stdout.write("\nBy year (created_at):")
|
||||
for row in qs.extra(select={"y": "date_part('year', created_at)::int"}
|
||||
).values("y").annotate(n=Count("id")).order_by("y"):
|
||||
self.stdout.write(f" {int(row['y'])} {row['n']}")
|
||||
self.stdout.write("\nSample (5 oldest):")
|
||||
for c in qs.order_by("created_at")[:5]:
|
||||
self.stdout.write(f" id={c.id} status={c.status} created={c.created_at.date()}")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING("\nDRY-RUN: no changes made. Re-run without --dry-run to apply."))
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
# Capture targets + old status for audit before the UPDATE
|
||||
targets = list(qs.values_list("id", "status"))
|
||||
ids = [t[0] for t in targets]
|
||||
|
||||
updated = qs.update(
|
||||
status=ComplaintStatus.CLOSED,
|
||||
closed_at=now,
|
||||
closed_by=user,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
# Audit per complaint (bulk_create bypasses signals → no notifications)
|
||||
ComplaintUpdate.objects.bulk_create([
|
||||
ComplaintUpdate(
|
||||
complaint_id=cid,
|
||||
update_type="status_change",
|
||||
message=note,
|
||||
old_status=old,
|
||||
new_status=ComplaintStatus.CLOSED,
|
||||
created_by=user,
|
||||
metadata={"bulk_close": True, "source": "close_stale_complaints"},
|
||||
)
|
||||
for cid, old in targets
|
||||
])
|
||||
|
||||
# Batch audit event — kept OUTSIDE atomic so a logging failure can never
|
||||
# roll back the data change above. Uses a valid AuditEvent.EVENT_TYPES value.
|
||||
AuditService.log_event(
|
||||
event_type="complaint_closed",
|
||||
description=(
|
||||
f"Bulk closed {updated} stale complaints (created before {before_label}) "
|
||||
f"by {user.email or user.username}"
|
||||
),
|
||||
user=user,
|
||||
metadata={
|
||||
"count": updated,
|
||||
"before": before_label,
|
||||
"complaint_ids": ids,
|
||||
"note": note,
|
||||
},
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nClosed {updated} complaint(s)."))
|
||||
remaining = Complaint.objects.filter(created_at__lt=before, status__in=ACTIVE_STATUSES).count()
|
||||
self.stdout.write(f"Remaining eligible (open/in_progress before {before_label}): {remaining}")
|
||||
@ -14,9 +14,9 @@ SOURCE_CODE_MAP = {
|
||||
"المراجع": "PATIENT",
|
||||
"ذوي المراجع": "FAMILY",
|
||||
"مجلس الضمان الصحي": "CHI",
|
||||
"شركة تأمين": "INSURANCE",
|
||||
# English / code sources
|
||||
"moh": "MOH",
|
||||
"cchi": "CCHI",
|
||||
"chi": "CHI",
|
||||
"patients": "PATIENT",
|
||||
"patient": "PATIENT",
|
||||
@ -30,7 +30,8 @@ SOURCE_CODE_MAP = {
|
||||
"social media": "SOCIAL-MEDIA",
|
||||
"social-media": "SOCIAL-MEDIA",
|
||||
"public form": "PUBL-FORM",
|
||||
"insurance company": "INSURANCE",
|
||||
"insurance company": None,
|
||||
"شركة تأمين": None,
|
||||
}
|
||||
|
||||
|
||||
@ -69,13 +70,13 @@ def resolve_px_source(source_value: str) -> "PXSource | None":
|
||||
return None
|
||||
|
||||
|
||||
EXTERNAL_SOURCE_CODES = {"MOH", "CHI", "INSURANCE"}
|
||||
EXTERNAL_SOURCE_CODES = {"MOH", "CCHI", "CHI"}
|
||||
|
||||
|
||||
def get_complaint_source_type(px_source) -> str:
|
||||
"""
|
||||
Determine complaint_source_type based on PXSource.
|
||||
Only MOH/CHI/INSURANCE are external; everything else is internal.
|
||||
Only MOH/CHI/CCHI are external; everything else is internal.
|
||||
"""
|
||||
if px_source and px_source.code in EXTERNAL_SOURCE_CODES:
|
||||
return "external"
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
"""
|
||||
Deduplicate historical complaints created by repeated Excel imports.
|
||||
|
||||
The importers (import_historical_complaints / import_2025_complaints_basic)
|
||||
build reference numbers of the form ``CMP-YYYY-MM-NNNN``. When re-run, the
|
||||
existing dedup logic appended letter suffixes (-A, -B, ... -H) instead of
|
||||
skipping, producing duplicate rows with byte-identical content.
|
||||
|
||||
A wrinkle: the same ``complaint_num`` (column 3) is occasionally reused across
|
||||
genuinely distinct complaints, so a single base reference (e.g.
|
||||
``CMP-2025-10-5112``) can map to several different complaints that were each
|
||||
re-imported. To handle this safely, dedup is done by
|
||||
**base reference + content signature**:
|
||||
|
||||
1. Group every complaint (base + suffixed) by its stripped base ref.
|
||||
2. Within each group, cluster by (title, description, created_at).
|
||||
3. In each cluster, keep ONE representative - preferring the no-suffix base
|
||||
if it is in that cluster, else the lowest-suffix copy.
|
||||
4. Delete the rest. ComplaintUpdate rows on the deleted copies are removed
|
||||
too (they are re-import artifacts).
|
||||
|
||||
Usage:
|
||||
# Dry run (default) - no writes
|
||||
python manage.py dedup_historical_complaints
|
||||
python manage.py dedup_historical_complaints --year 2025
|
||||
python manage.py dedup_historical_complaints --hospital-code HH-N
|
||||
|
||||
# Actually delete (requires explicit --confirm)
|
||||
python manage.py dedup_historical_complaints --confirm
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.complaints.models import Complaint, ComplaintUpdate
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
# Anchored pattern: CMP-YYYY-MM-NNNN optionally followed by a single uppercase
|
||||
# letter suffix. Base refs always end in 4 digits, so the suffixed branch is
|
||||
# never matched on a legitimate base.
|
||||
REF_RE = re.compile(r"^(?P<base>CMP-\d{4}-\d{2}-\d{4})(?:-(?P<suffix>[A-Z]))?$")
|
||||
|
||||
|
||||
def _content_signature(c):
|
||||
return (c.title or "", c.description or "", c.created_at)
|
||||
|
||||
|
||||
def _rank(c):
|
||||
"""Lower rank = preferred keeper. No-suffix base ranks first (0), then -A (1), -B (2)..."""
|
||||
m = REF_RE.match(c.reference_number or "")
|
||||
suffix = m.group("suffix") if m and m.group("suffix") else ""
|
||||
return (0 if suffix == "" else ord(suffix), str(c.id))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Deduplicate historical complaints that were re-imported with letter suffixes"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--year", type=int, help="Scope to a single year (e.g. 2025)")
|
||||
parser.add_argument(
|
||||
"--hospital-code",
|
||||
type=str,
|
||||
default="HH-N",
|
||||
help="Hospital code to scope (default: HH-N)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--confirm",
|
||||
action="store_true",
|
||||
help="Actually perform the deletion. Without it, the command only reports.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
year = options.get("year")
|
||||
hospital_code = options["hospital_code"]
|
||||
confirm = options["confirm"]
|
||||
|
||||
# Validate hospital (but don't hard-fail if it doesn't exist; just don't filter)
|
||||
hospital = None
|
||||
try:
|
||||
hospital = Hospital.objects.get(code=hospital_code)
|
||||
except Hospital.DoesNotExist:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"Hospital code '{hospital_code}' not found - running across all hospitals."
|
||||
))
|
||||
|
||||
qs = Complaint.objects.all()
|
||||
if hospital:
|
||||
qs = qs.filter(hospital=hospital)
|
||||
if year:
|
||||
qs = qs.filter(created_at__year=year)
|
||||
|
||||
# Pull every complaint whose reference matches the base-or-suffixed pattern,
|
||||
# plus group them by their stripped base ref. We include the no-suffix base
|
||||
# itself so that content-collision groups are handled correctly.
|
||||
all_rows = list(
|
||||
qs.only("id", "reference_number", "created_at", "title", "description")
|
||||
.filter(reference_number__regex=r"^CMP-\d{4}-\d{2}-\d{4}(?:-[A-Z])?$")
|
||||
)
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
by_base = defaultdict(list)
|
||||
for c in all_rows:
|
||||
m = REF_RE.match(c.reference_number or "")
|
||||
if not m:
|
||||
continue
|
||||
by_base[m.group("base")].append(c)
|
||||
|
||||
candidate_suffixed = 0
|
||||
for c in all_rows:
|
||||
m = REF_RE.match(c.reference_number or "")
|
||||
if m and m.group("suffix"):
|
||||
candidate_suffixed += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("=" * 70))
|
||||
if confirm:
|
||||
self.stdout.write(self.style.SUCCESS("DEDUP - CONFIRM MODE (deletions will run)"))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS("DEDUP - DRY RUN (no writes)"))
|
||||
self.stdout.write(self.style.SUCCESS("=" * 70))
|
||||
scope = f"hospital={hospital_code}" + (f", year={year}" if year else ", all years")
|
||||
self.stdout.write(f"Scope: {scope}")
|
||||
self.stdout.write(f"Complaints matching reference pattern: {len(all_rows)}")
|
||||
self.stdout.write(f" of which suffixed (-A/-B/...): {candidate_suffixed}")
|
||||
self.stdout.write(f"Distinct base refs: {len(by_base)}")
|
||||
self.stdout.write("")
|
||||
|
||||
# Content-aware clustering: within each base group, cluster by content.
|
||||
# Keep one representative per cluster, delete the rest.
|
||||
to_delete_ids = []
|
||||
true_unique = 0
|
||||
pure_dup_groups = 0 # base refs whose rows are all identical content
|
||||
collision_groups = 0 # base refs with >1 distinct content
|
||||
per_year_deleted = {}
|
||||
collisions_detail = []
|
||||
|
||||
for base_ref, items in by_base.items():
|
||||
clusters = defaultdict(list)
|
||||
for c in items:
|
||||
clusters[_content_signature(c)].append(c)
|
||||
|
||||
if len(clusters) == 1:
|
||||
pure_dup_groups += 1
|
||||
else:
|
||||
collision_groups += 1
|
||||
collisions_detail.append((base_ref, len(items), len(clusters)))
|
||||
|
||||
for _sig, members in clusters.items():
|
||||
true_unique += 1
|
||||
members_sorted = sorted(members, key=_rank)
|
||||
for v in members_sorted[1:]:
|
||||
y = v.created_at.year if v.created_at else 0
|
||||
per_year_deleted[y] = per_year_deleted.get(y, 0) + 1
|
||||
to_delete_ids.append(v.id)
|
||||
|
||||
# Count updates attached to the to-be-deleted complaints
|
||||
updates_to_delete_ids = list(
|
||||
ComplaintUpdate.objects.filter(
|
||||
complaint_id__in=to_delete_ids
|
||||
).values_list("id", flat=True)
|
||||
) if to_delete_ids else []
|
||||
|
||||
# Report
|
||||
self.stdout.write(self.style.SUCCESS("Plan summary"))
|
||||
self.stdout.write("-" * 70)
|
||||
self.stdout.write(f"Base refs - pure re-import dups: {pure_dup_groups}")
|
||||
self.stdout.write(f"Base refs - content collisions (>1 text): {collision_groups}")
|
||||
self.stdout.write(f"TRUE unique complaints to keep: {true_unique}")
|
||||
self.stdout.write(f"Duplicate complaints to delete: {len(to_delete_ids)}")
|
||||
self.stdout.write(f"ComplaintUpdate rows to delete: {len(updates_to_delete_ids)}")
|
||||
self.stdout.write("")
|
||||
self.stdout.write("Per-year complaints to delete:")
|
||||
for y in sorted(per_year_deleted):
|
||||
self.stdout.write(f" {y}: {per_year_deleted[y]}")
|
||||
|
||||
if collision_groups:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"{collision_groups} base refs contain >1 distinct complaint "
|
||||
f"(reference collision). One copy of EACH distinct text is kept."
|
||||
))
|
||||
for base_ref, n_rows, n_clusters in collisions_detail[:15]:
|
||||
self.stdout.write(f" {base_ref}: {n_rows} rows -> {n_clusters} distinct complaints kept")
|
||||
|
||||
if not confirm:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.WARNING("DRY RUN - no changes made. Re-run with --confirm to delete."))
|
||||
return
|
||||
|
||||
# Execute
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.SUCCESS("Executing deletion in a single transaction..."))
|
||||
with transaction.atomic():
|
||||
deleted_updates, _ = ComplaintUpdate.objects.filter(
|
||||
id__in=updates_to_delete_ids
|
||||
).delete()
|
||||
deleted_complaints, _ = Complaint.objects.filter(
|
||||
id__in=to_delete_ids
|
||||
).delete()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Done."))
|
||||
self.stdout.write(f"Deleted ComplaintUpdate rows: {deleted_updates}")
|
||||
self.stdout.write(f"Deleted Complaint rows: {deleted_complaints}")
|
||||
@ -298,7 +298,6 @@ class Command(BaseCommand):
|
||||
"import_source": "2025_excel",
|
||||
"original_sheet": self.sheet_name,
|
||||
"complaint_num": row_data.get("complaint_num"),
|
||||
"source": row_data.get("source"),
|
||||
},
|
||||
sent_to_department=bool(dept),
|
||||
sent_to_department_at=created_at if dept else None,
|
||||
|
||||
@ -22,7 +22,7 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **kwargs):
|
||||
# Full SHCT Data Structure: (EN, AR)
|
||||
shct_data = {
|
||||
('Clinical Care', 'سريري'): {
|
||||
('CLINICAL', 'سريري'): {
|
||||
('Quality', 'الجودة'): {
|
||||
('Examination', 'الفحص'): [
|
||||
('Examination not performed', 'لم يتم إجراء الفحص'),
|
||||
@ -87,7 +87,7 @@ class Command(BaseCommand):
|
||||
]
|
||||
}
|
||||
},
|
||||
('Management', 'إداري'): {
|
||||
('MANAGEMENT', 'إداري'): {
|
||||
('Institutional Issues', 'القضايا المؤسسية'): {
|
||||
('Administrative Policies', 'السياسات الإدارية'): [
|
||||
('Paperwork delays', 'تأخير في المعاملات الورقية'),
|
||||
@ -129,7 +129,7 @@ class Command(BaseCommand):
|
||||
],
|
||||
}
|
||||
},
|
||||
('Relationships', 'علاقات'): {
|
||||
('RELATIONSHIPS', 'علاقات'): {
|
||||
('Communication', 'التواصل'): {
|
||||
('Patient-staff communication', 'التواصل بين المريض والموظفين'): [
|
||||
('Miscommunication with Patient', 'سوء فهم مع المريض'),
|
||||
@ -171,16 +171,12 @@ class Command(BaseCommand):
|
||||
total_created = 0
|
||||
|
||||
for (dom_en, dom_ar), categories in shct_data.items():
|
||||
# Level 1: Domain — look up by name_en + level only so existing
|
||||
# records (which may have different Arabic name/domain_type) are
|
||||
# found instead of being duplicated.
|
||||
# Level 1: Domain
|
||||
domain, created = ComplaintCategory.objects.get_or_create(
|
||||
name_en=dom_en,
|
||||
name_ar=dom_ar,
|
||||
level=ComplaintCategory.LevelChoices.DOMAIN,
|
||||
defaults={
|
||||
"name_ar": dom_ar,
|
||||
"domain_type": dom_en.upper(),
|
||||
},
|
||||
domain_type=dom_en.upper()
|
||||
)
|
||||
if created:
|
||||
domains_created += 1
|
||||
|
||||
@ -8,13 +8,14 @@ from apps.complaints.models import (
|
||||
ComplaintCategory,
|
||||
ComplaintSLAConfig,
|
||||
ComplaintThreshold,
|
||||
EscalationRule,
|
||||
InquirySLAConfig,
|
||||
)
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seed default complaint configurations (categories, SLA configs, thresholds)'
|
||||
help = 'Seed default complaint configurations (categories, SLA configs, thresholds, escalation rules)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@ -46,6 +47,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f"\nProcessing hospital: {hospital.name}")
|
||||
self.seed_sla_configs(hospital)
|
||||
self.seed_thresholds(hospital)
|
||||
self.seed_escalation_rules(hospital)
|
||||
self.seed_inquiry_dept_response_sla(hospital)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\nSuccessfully seeded complaint configurations!'))
|
||||
@ -200,6 +202,58 @@ class Command(BaseCommand):
|
||||
|
||||
self.stdout.write(f" Created {created_count} thresholds")
|
||||
|
||||
def seed_escalation_rules(self, hospital):
|
||||
"""Seed escalation rules for a hospital"""
|
||||
self.stdout.write(f" Seeding escalation rules...")
|
||||
|
||||
rules = [
|
||||
{
|
||||
'name': 'Default Escalation to Department Manager',
|
||||
'description': 'Escalate overdue complaints to department manager',
|
||||
'trigger_on_overdue': True,
|
||||
'trigger_hours_overdue': 0,
|
||||
'escalate_to_role': 'department_manager',
|
||||
'order': 1,
|
||||
},
|
||||
{
|
||||
'name': 'Critical Escalation to Hospital Admin',
|
||||
'description': 'Escalate critical complaints to hospital admin after 4 hours overdue',
|
||||
'trigger_on_overdue': True,
|
||||
'trigger_hours_overdue': 4,
|
||||
'escalate_to_role': 'hospital_admin',
|
||||
'severity_filter': 'critical',
|
||||
'order': 2,
|
||||
},
|
||||
{
|
||||
'name': 'Final Escalation to PX Admin',
|
||||
'description': 'Escalate to PX Admin after 24 hours overdue',
|
||||
'trigger_on_overdue': True,
|
||||
'trigger_hours_overdue': 24,
|
||||
'escalate_to_role': 'px_admin',
|
||||
'order': 3,
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
for rule_data in rules:
|
||||
rule, created = EscalationRule.objects.get_or_create(
|
||||
hospital=hospital,
|
||||
name=rule_data['name'],
|
||||
defaults={
|
||||
'description': rule_data['description'],
|
||||
'trigger_on_overdue': rule_data['trigger_on_overdue'],
|
||||
'trigger_hours_overdue': rule_data['trigger_hours_overdue'],
|
||||
'escalate_to_role': rule_data['escalate_to_role'],
|
||||
'severity_filter': rule_data.get('severity_filter', ''),
|
||||
'order': rule_data['order'],
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
|
||||
self.stdout.write(f" Created {created_count} escalation rules")
|
||||
|
||||
def seed_inquiry_dept_response_sla(self, hospital):
|
||||
"""Seed department response SLA defaults for inquiry configs"""
|
||||
self.stdout.write(f" Seeding inquiry dept response SLA defaults...")
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-21 17:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0021_inquiry_satisfaction_inquiry_satisfaction_set_at'),
|
||||
('organizations', '0014_remove_department_manager_1st'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='primary_dept_involved_removed',
|
||||
field=models.ForeignKey(blank=True, help_text='Tracks the primary department whose involved record the user explicitly removed; ensure_involved_records() will skip auto-recreation while this matches complaint.department.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints_primary_dept_removed', to='organizations.department'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='primary_staff_involved_removed',
|
||||
field=models.ForeignKey(blank=True, help_text='Tracks the primary staff whose involved record the user explicitly removed; ensure_involved_records() will skip auto-recreation while this matches complaint.staff.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints_primary_staff_removed', to='organizations.staff'),
|
||||
),
|
||||
]
|
||||
@ -1,19 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-23 13:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0022_complaint_primary_dept_involved_removed_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='investigationquestion',
|
||||
name='response',
|
||||
field=models.ForeignKey(blank=True, help_text='When set, this question is specific to this staff response (per-staff questions).', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='complaints.investigationresponse'),
|
||||
),
|
||||
]
|
||||
@ -1,54 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-25 14:57
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0023_investigation_question_response'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InvestigationAttachment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('file', models.FileField(upload_to='investigation_attachments/%Y/%m/%d/')),
|
||||
('filename', models.CharField(max_length=500)),
|
||||
('file_type', models.CharField(blank=True, max_length=100)),
|
||||
('file_size', models.IntegerField(help_text='File size in bytes')),
|
||||
('investigation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.championinvestigation')),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investigation_attachments', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Investigation Attachment',
|
||||
'verbose_name_plural': 'Investigation Attachments',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvestigationResponseAttachment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('file', models.FileField(upload_to='investigation_response_attachments/%Y/%m/%d/')),
|
||||
('filename', models.CharField(max_length=500)),
|
||||
('file_type', models.CharField(blank=True, max_length=100)),
|
||||
('file_size', models.IntegerField(help_text='File size in bytes')),
|
||||
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.investigationresponse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Investigation Response Attachment',
|
||||
'verbose_name_plural': 'Investigation Response Attachments',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-27 21:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0024_investigation_attachments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='investigationquestion',
|
||||
name='question_type',
|
||||
field=models.CharField(choices=[('text', 'Text'), ('yes_no', 'Yes/No')], default='text', max_length=10),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-27 22:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0025_investigation_question_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='championinvestigation',
|
||||
name='otp_code',
|
||||
field=models.CharField(blank=True, default='', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='championinvestigation',
|
||||
name='otp_sent_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,33 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-28 09:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0026_champion_investigation_otp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='championinvestigation',
|
||||
name='improvement_project_note',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='championinvestigation',
|
||||
name='negligence_finding',
|
||||
field=models.CharField(blank=True, default='', max_length=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='championinvestigation',
|
||||
name='policy_issue_finding',
|
||||
field=models.CharField(blank=True, default='', max_length=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='championinvestigation',
|
||||
name='requires_improvement_project',
|
||||
field=models.CharField(blank=True, default='', max_length=3),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-29 11:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0027_investigation_findings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='satisfaction_locked_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='satisfaction_locked_by_patient',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-30 08:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0028_complaint_satisfaction_lock'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='championinvestigation',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('questions_sent', 'Questions Sent'), ('answers_received', 'Answers Received'), ('reply_submitted', 'Reply Submitted'), ('direct_reply_in_progress', 'Direct Reply In Progress')], default='questions_sent', max_length=30),
|
||||
),
|
||||
]
|
||||
@ -1,26 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 13:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0029_investigationstatus_direct_reply'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='taxonomy_reviewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='taxonomy_reviewed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_taxonomy_complaints', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -1,21 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 20:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0030_complaint_taxonomy_review'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='satisfaction_set_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='set_satisfaction_complaints', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 22:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0031_complaint_satisfaction_set_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='ai_response_suggestions',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='ai_response_suggestions_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 23:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0032_complaint_ai_response_suggestions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='satisfaction_change_count',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@ -1,21 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-04 19:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0033_complaint_satisfaction_change_count'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='satisfaction',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='satisfaction_set_at',
|
||||
),
|
||||
]
|
||||
@ -1,57 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("complaints", "0034_remove_inquiry_observation_satisfaction"),
|
||||
("organizations", "0014_remove_department_manager_1st"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="routing_status",
|
||||
field=models.CharField(
|
||||
choices=[("sent", "Sent"), ("accepted", "Accepted"), ("rejected", "Rejected")],
|
||||
default="sent",
|
||||
help_text="Whether the department has accepted or rejected the routing of this complaint",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="rejected_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When the champion rejected the routing", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="rejected_by_staff",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member (champion/manager) who rejected the routing",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rejected_dept_involvements",
|
||||
to="organizations.staff",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="rejection_reason",
|
||||
field=models.TextField(blank=True, help_text="Reason the department rejected the routing"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="suggested_department",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Department the champion suggested as the correct one",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="suggested_for_involvements",
|
||||
to="organizations.department",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-05 14:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0035_involved_department_routing_rejection'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='complaint',
|
||||
name='relation_to_patient',
|
||||
field=models.CharField(blank=True, choices=[('patient', 'Patient'), ('relative', 'Relative'), ('friend', 'Friend'), ('other', 'Other')], help_text="Complainant's relationship to the patient", max_length=20, verbose_name='Complinant'),
|
||||
),
|
||||
]
|
||||
@ -1,20 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-06 20:41
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0036_alter_complaint_relation_to_patient'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='escalationrule',
|
||||
name='complaints__hospita_3c8bac_idx',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EscalationRule',
|
||||
),
|
||||
]
|
||||
@ -1,37 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-07 09:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0037_remove_escalationrule'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='complaintexplanation',
|
||||
name='acceptance_notes',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='complaintexplanation',
|
||||
name='acceptance_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='complaintexplanation',
|
||||
name='accepted_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='complaintexplanation',
|
||||
name='accepted_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='complaintexplanation',
|
||||
name='escalated_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='complaintexplanation',
|
||||
name='escalated_to_manager',
|
||||
),
|
||||
]
|
||||
@ -1,29 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-07 10:11
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0038_remove_complaintexplanation_acceptance_notes_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='dept_response_acceptance_notes',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='dept_response_acceptance_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='dept_response_accepted_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='dept_response_accepted_by',
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-08 10:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0039_remove_inquiry_dept_response_acceptance_notes_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='complaint',
|
||||
name='relation_to_patient',
|
||||
field=models.CharField(blank=True, choices=[('patient', 'Patient'), ('relative', 'Relative')], help_text="Complainant's relationship to the patient", max_length=20, verbose_name='Complinant'),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-09 09:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0040_alter_complaint_relation_to_patient'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='inquiry',
|
||||
name='closed_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, help_text='When the inquiry was closed', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inquiry',
|
||||
name='resolved_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, help_text='When the inquiry was resolved', null=True),
|
||||
),
|
||||
]
|
||||
@ -1,26 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-09 09:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0041_inquiry_closed_at_inquiry_resolved_at'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='inquiry',
|
||||
name='closed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_inquiries', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inquiry',
|
||||
name='resolved_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_inquiries', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-15 15:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0042_inquiry_closed_by_inquiry_resolved_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='inquiry',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('contact_no_response', 'Contacted - No Response')], db_index=True, default='open', max_length=25),
|
||||
),
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-18 14:46
|
||||
|
||||
import apps.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0043_alter_inquiry_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='complaint',
|
||||
name='contact_phone',
|
||||
field=models.CharField(blank=True, max_length=20, validators=[apps.core.validators.validate_saudi_phone]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inquiry',
|
||||
name='contact_phone',
|
||||
field=models.CharField(blank=True, max_length=20, validators=[apps.core.validators.validate_saudi_phone]),
|
||||
),
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-19 06:23
|
||||
|
||||
import apps.core.encryption
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0044_alter_complaint_contact_phone_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='inquiry',
|
||||
name='national_id',
|
||||
field=apps.core.encryption.EncryptedCharField(blank=True, help_text='National ID/Iqama of the contact', max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inquiry',
|
||||
name='national_id_hash',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64),
|
||||
),
|
||||
]
|
||||
@ -16,10 +16,9 @@ from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
|
||||
|
||||
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
|
||||
from apps.core.models import PriorityChoices, SeverityChoices, SoftDeleteModel, TenantModel, TimeStampedModel, UUIDModel
|
||||
from apps.core.validators import validate_saudi_phone
|
||||
from apps.organizations.models import LocationType
|
||||
|
||||
|
||||
@ -214,7 +213,7 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
|
||||
# Contact information for anonymous/unregistered submissions
|
||||
contact_name = models.CharField(max_length=200, blank=True)
|
||||
contact_phone = models.CharField(max_length=20, blank=True, validators=[validate_saudi_phone])
|
||||
contact_phone = models.CharField(max_length=20, blank=True)
|
||||
contact_email = models.EmailField(blank=True)
|
||||
|
||||
# Public complaint form fields
|
||||
@ -222,10 +221,12 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
max_length=20,
|
||||
choices=[
|
||||
("patient", _("Patient")),
|
||||
("relative", _("Relative"))
|
||||
("relative", _("Relative")),
|
||||
("friend", _("Friend")),
|
||||
("other", _("Other")),
|
||||
],
|
||||
blank=True,
|
||||
verbose_name=_("Complinant"),
|
||||
verbose_name=_("Relation to Patient"),
|
||||
help_text="Complainant's relationship to the patient",
|
||||
)
|
||||
|
||||
@ -271,24 +272,6 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
staff = models.ForeignKey(
|
||||
"organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
||||
)
|
||||
primary_dept_involved_removed = models.ForeignKey(
|
||||
"organizations.Department",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="complaints_primary_dept_removed",
|
||||
help_text="Tracks the primary department whose involved record the user explicitly removed; "
|
||||
"ensure_involved_records() will skip auto-recreation while this matches complaint.department.",
|
||||
)
|
||||
primary_staff_involved_removed = models.ForeignKey(
|
||||
"organizations.Staff",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="complaints_primary_staff_removed",
|
||||
help_text="Tracks the primary staff whose involved record the user explicitly removed; "
|
||||
"ensure_involved_records() will skip auto-recreation while this matches complaint.staff.",
|
||||
)
|
||||
location_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=LocationType.choices,
|
||||
@ -588,45 +571,6 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
)
|
||||
satisfaction_set_at = models.DateTimeField(null=True, blank=True, help_text="When satisfaction was last set")
|
||||
|
||||
# Who set the satisfaction (PX-team only; patient locks via satisfaction_locked_by_patient)
|
||||
satisfaction_set_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="set_satisfaction_complaints",
|
||||
)
|
||||
|
||||
# Patient lock — once the patient submits satisfaction via the public tracking
|
||||
# page, the value becomes authoritative and can no longer be edited by anyone
|
||||
# (neither patient nor PX-team).
|
||||
satisfaction_locked_by_patient = models.BooleanField(default=False, db_index=True)
|
||||
satisfaction_locked_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Taxonomy review gate — PX-team must review/confirm the AI-generated
|
||||
# classification before the complaint can be sent to the department.
|
||||
# Once set, stays set permanently (taxonomy remains editable afterwards,
|
||||
# but the gate is passed once).
|
||||
taxonomy_reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
taxonomy_reviewed_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reviewed_taxonomy_complaints",
|
||||
)
|
||||
|
||||
# AI response suggestions — persisted bilingual output from the
|
||||
# "Response Suggestions" button in the Resolution tab.
|
||||
ai_response_suggestions = models.JSONField(default=dict, blank=True)
|
||||
ai_response_suggestions_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# PX-team satisfaction change budget — only "satisfied", "neutral", and
|
||||
# "dissatisfied" consume this counter. "no_response" does NOT count.
|
||||
# Patient override is separate (satisfaction_locked_by_patient).
|
||||
satisfaction_change_count = models.IntegerField(default=0)
|
||||
MAX_SATISFACTION_CHANGES = 3
|
||||
|
||||
# External references
|
||||
moh_reference = models.CharField(max_length=100, blank=True, help_text="Ministry of Health reference number")
|
||||
moh_reference_date = models.DateField(null=True, blank=True, help_text="MOH reference date")
|
||||
@ -808,6 +752,7 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
hospital=self.hospital,
|
||||
source__isnull=True, # Explicitly check for null source
|
||||
severity=self.severity,
|
||||
priority=self.priority,
|
||||
is_active=True,
|
||||
)
|
||||
sla_hours = sla_config.sla_hours
|
||||
@ -815,10 +760,10 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
except ComplaintSLAConfig.DoesNotExist:
|
||||
pass # Fall through to next option
|
||||
|
||||
# Try severity-based config without source filter (backward compatibility)
|
||||
# Try severity/priority-based config without source filter (backward compatibility)
|
||||
try:
|
||||
sla_config = ComplaintSLAConfig.objects.get(
|
||||
hospital=self.hospital, severity=self.severity, is_active=True
|
||||
hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True
|
||||
)
|
||||
sla_hours = sla_config.sla_hours
|
||||
return timezone.now() + timedelta(hours=sla_hours)
|
||||
@ -850,15 +795,16 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
hospital=self.hospital,
|
||||
source__isnull=True,
|
||||
severity=self.severity,
|
||||
priority=self.priority,
|
||||
is_active=True,
|
||||
)
|
||||
except ComplaintSLAConfig.DoesNotExist:
|
||||
pass # Fall through to next option
|
||||
|
||||
# Try severity-based config without source filter (backward compatibility)
|
||||
# Try severity/priority-based config without source filter (backward compatibility)
|
||||
try:
|
||||
return ComplaintSLAConfig.objects.get(
|
||||
hospital=self.hospital, severity=self.severity, is_active=True
|
||||
hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True
|
||||
)
|
||||
except ComplaintSLAConfig.DoesNotExist:
|
||||
pass # No config found
|
||||
@ -892,22 +838,6 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
ComplaintStatus.PENDING_EXTERNAL,
|
||||
]
|
||||
|
||||
@property
|
||||
def satisfaction_window_expired(self):
|
||||
"""True if 5+ days have passed since resolution/closure.
|
||||
|
||||
Mirrors the tracking-page expiry in core/views.py.
|
||||
When expired, satisfaction can no longer be set or changed by anyone.
|
||||
"""
|
||||
if not self.resolved_at and not self.closed_at:
|
||||
return False
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
expiry_base = self.resolved_at or self.closed_at
|
||||
return timezone.now() > expiry_base + timedelta(days=5)
|
||||
|
||||
@property
|
||||
def sla_time_remaining(self):
|
||||
if not self.due_at:
|
||||
@ -1122,11 +1052,16 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
"""
|
||||
Get the public tracking URL for this complaint.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.urls import reverse
|
||||
|
||||
base = settings.SITE_URL.rstrip("/")
|
||||
return f"{base}{reverse('complaints:public_complaint_track')}?reference={self.reference_number}"
|
||||
try:
|
||||
site = get_current_site(None)
|
||||
domain = site.domain
|
||||
except:
|
||||
domain = "localhost:8000"
|
||||
|
||||
return f"https://{domain}{reverse('complaints:public_complaint_track')}?reference={self.reference_number}"
|
||||
|
||||
|
||||
class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
||||
@ -1305,6 +1240,112 @@ class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
||||
pri_display = self.priority if self.priority else "Any Priority"
|
||||
return f"{self.hospital.name} - {source_display} - {sev_display}/{pri_display} - {self.sla_hours}h"
|
||||
|
||||
def get_first_reminder_hours_after(self, complaint_created_at=None):
|
||||
if self.first_reminder_hours_after > 0:
|
||||
return self.first_reminder_hours_after
|
||||
else:
|
||||
return max(0, self.sla_hours - self.reminder_hours_before)
|
||||
|
||||
def get_second_reminder_hours_after(self, complaint_created_at=None):
|
||||
if self.second_reminder_hours_after > 0:
|
||||
return self.second_reminder_hours_after
|
||||
elif self.second_reminder_enabled:
|
||||
return max(0, self.sla_hours - self.second_reminder_hours_before)
|
||||
else:
|
||||
return 0
|
||||
|
||||
def get_escalation_hours_after(self, complaint_created_at=None):
|
||||
if self.escalation_hours_after > 0:
|
||||
return self.escalation_hours_after
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class EscalationRule(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Configurable escalation rules for complaints.
|
||||
|
||||
Defines who receives escalated complaints based on conditions.
|
||||
Supports multi-level escalation with configurable hierarchy.
|
||||
"""
|
||||
|
||||
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="escalation_rules")
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
# Escalation level (supports multi-level escalation)
|
||||
escalation_level = models.IntegerField(default=1, help_text="Escalation level (1 = first level, 2 = second, etc.)")
|
||||
|
||||
max_escalation_level = models.IntegerField(
|
||||
default=3, help_text="Maximum escalation level before stopping (default: 3)"
|
||||
)
|
||||
|
||||
# Trigger conditions
|
||||
trigger_on_overdue = models.BooleanField(default=True, help_text="Trigger when complaint is overdue")
|
||||
|
||||
trigger_hours_overdue = models.IntegerField(default=0, help_text="Trigger X hours after overdue (0 = immediately)")
|
||||
|
||||
# Reminder-based escalation
|
||||
reminder_escalation_enabled = models.BooleanField(
|
||||
default=False, help_text="Enable escalation after reminder if no action taken"
|
||||
)
|
||||
|
||||
reminder_escalation_hours = models.IntegerField(
|
||||
default=24, help_text="Escalate X hours after reminder if no action"
|
||||
)
|
||||
|
||||
# Escalation target
|
||||
escalate_to_role = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
("department_manager", "Department Manager"),
|
||||
("hospital_admin", "Hospital Admin"),
|
||||
("medical_director", "Medical Director"),
|
||||
("admin_director", "Administrative Director"),
|
||||
("px_admin", "PX Admin"),
|
||||
("ceo", "CEO"),
|
||||
("specific_user", "Specific User"),
|
||||
],
|
||||
help_text="Role to escalate to",
|
||||
)
|
||||
|
||||
escalate_to_user = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="escalation_target_rules",
|
||||
help_text="Specific user if escalate_to_role is 'specific_user'",
|
||||
)
|
||||
|
||||
# Conditions
|
||||
severity_filter = models.CharField(
|
||||
max_length=20,
|
||||
choices=SeverityChoices.choices,
|
||||
blank=True,
|
||||
help_text="Only escalate complaints with this severity (blank = all)",
|
||||
)
|
||||
|
||||
priority_filter = models.CharField(
|
||||
max_length=20,
|
||||
choices=PriorityChoices.choices,
|
||||
blank=True,
|
||||
help_text="Only escalate complaints with this priority (blank = all)",
|
||||
)
|
||||
|
||||
order = models.IntegerField(default=0, help_text="Escalation order (lower = first)")
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["hospital", "order"]
|
||||
indexes = [
|
||||
models.Index(fields=["hospital", "is_active"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.hospital.name} - {self.name}"
|
||||
|
||||
|
||||
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
||||
@ -1540,10 +1581,8 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
|
||||
# Contact information (if patient not in system)
|
||||
contact_name = models.CharField(max_length=200, blank=True)
|
||||
contact_phone = models.CharField(max_length=20, blank=True, validators=[validate_saudi_phone])
|
||||
contact_phone = models.CharField(max_length=20, blank=True)
|
||||
contact_email = models.EmailField(blank=True)
|
||||
national_id = EncryptedCharField(max_length=512, blank=True, help_text="National ID/Iqama of the contact")
|
||||
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
|
||||
# Organization
|
||||
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="inquiries")
|
||||
@ -1702,7 +1741,6 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
("in_progress", _("In Progress")),
|
||||
("resolved", _("Resolved")),
|
||||
("closed", _("Closed")),
|
||||
("contact_no_response", _("Contacted - No Response")),
|
||||
],
|
||||
default="open",
|
||||
db_index=True,
|
||||
@ -1752,28 +1790,6 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
help_text="Timestamp when inquiry was first activated (moved from OPEN to IN_PROGRESS)",
|
||||
)
|
||||
|
||||
# Resolution / closure tracking (stamped in save() on status transition)
|
||||
resolved_at = models.DateTimeField(
|
||||
null=True, blank=True, db_index=True, help_text="When the inquiry was resolved"
|
||||
)
|
||||
resolved_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="resolved_inquiries",
|
||||
)
|
||||
closed_at = models.DateTimeField(
|
||||
null=True, blank=True, db_index=True, help_text="When the inquiry was closed"
|
||||
)
|
||||
closed_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="closed_inquiries",
|
||||
)
|
||||
|
||||
# SLA tracking
|
||||
due_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text="SLA deadline")
|
||||
is_overdue = models.BooleanField(default=False, db_index=True)
|
||||
@ -1794,6 +1810,13 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries"
|
||||
)
|
||||
|
||||
# Satisfaction
|
||||
satisfaction = models.CharField(
|
||||
max_length=20, blank=True, default="",
|
||||
choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
|
||||
)
|
||||
satisfaction_set_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Metadata (stores AI analysis, form data, etc.)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@ -1884,6 +1907,32 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
null=True, blank=True, help_text="When dept response was escalated to manager",
|
||||
)
|
||||
|
||||
# Department response acceptance review
|
||||
dept_response_acceptance_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
("pending", _("Pending Review")),
|
||||
("acceptable", _("Acceptable")),
|
||||
("not_acceptable", _("Not Acceptable")),
|
||||
],
|
||||
default="pending",
|
||||
help_text="Review status of the department response",
|
||||
)
|
||||
dept_response_accepted_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reviewed_inquiry_dept_responses",
|
||||
help_text="User who reviewed the department response",
|
||||
)
|
||||
dept_response_accepted_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When the department response was reviewed",
|
||||
)
|
||||
dept_response_acceptance_notes = models.TextField(
|
||||
blank=True, help_text="Notes about the acceptance decision",
|
||||
)
|
||||
|
||||
# Contact tracking — matches the 3-stage timeline in the Excel reports
|
||||
# Each stage has: date, time, staff, duration
|
||||
|
||||
@ -1982,27 +2031,6 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Track previous status to stamp resolved_at / closed_at on transition
|
||||
if self.pk:
|
||||
try:
|
||||
old = Inquiry.objects.get(pk=self.pk)
|
||||
self._status_was = old.status
|
||||
except Inquiry.DoesNotExist:
|
||||
self._status_was = None
|
||||
else:
|
||||
self._status_was = None
|
||||
|
||||
# Stamp timestamps + actor on entry into resolved / closed
|
||||
acting_user = getattr(self, "_acting_user", None)
|
||||
if self.status == "resolved" and self._status_was != "resolved":
|
||||
self.resolved_at = timezone.now()
|
||||
if acting_user is not None:
|
||||
self.resolved_by = acting_user
|
||||
if self.status == "closed" and self._status_was != "closed":
|
||||
self.closed_at = timezone.now()
|
||||
if acting_user is not None:
|
||||
self.closed_by = acting_user
|
||||
|
||||
if not self.reference_number:
|
||||
from apps.core.reference import generate_reference
|
||||
|
||||
@ -2028,27 +2056,11 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
else:
|
||||
self.timeline_sla = "more_than_72_hours"
|
||||
|
||||
if self.national_id:
|
||||
self.national_id_hash = compute_national_id_hash(self.national_id)
|
||||
else:
|
||||
self.national_id_hash = ""
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.subject} ({self.status})"
|
||||
|
||||
@property
|
||||
def handling_department_id(self):
|
||||
"""The department currently responsible for handling this inquiry.
|
||||
|
||||
When the inquiry has been forwarded, this is the receiving department
|
||||
(``transferred_to_department``); otherwise it's the primary ``department``.
|
||||
Used to gate department actions so a department that has forwarded an
|
||||
inquiry away can no longer act on it — only the current handler can.
|
||||
"""
|
||||
return self.transferred_to_department_id or self.department_id
|
||||
|
||||
def get_absolute_url(self):
|
||||
from django.urls import reverse
|
||||
|
||||
@ -2337,6 +2349,43 @@ class ComplaintExplanation(UUIDModel, TimeStampedModel):
|
||||
null=True, blank=True, help_text="Second reminder sent to staff about overdue explanation"
|
||||
)
|
||||
|
||||
escalated_to_manager = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="escalated_from_staff",
|
||||
help_text="Escalated to this explanation (manager's explanation request)",
|
||||
)
|
||||
|
||||
escalated_at = models.DateTimeField(null=True, blank=True, help_text="When explanation was escalated to manager")
|
||||
|
||||
# Acceptance review fields
|
||||
class AcceptanceStatus(models.TextChoices):
|
||||
PENDING = "pending", _("Pending Review")
|
||||
ACCEPTABLE = "acceptable", _("Acceptable")
|
||||
NOT_ACCEPTABLE = "not_acceptable", _("Not Acceptable")
|
||||
|
||||
acceptance_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=AcceptanceStatus.choices,
|
||||
default=AcceptanceStatus.PENDING,
|
||||
help_text="Review status of the explanation",
|
||||
)
|
||||
|
||||
accepted_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reviewed_explanations",
|
||||
help_text="User who reviewed and marked the explanation",
|
||||
)
|
||||
|
||||
accepted_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation was reviewed")
|
||||
|
||||
acceptance_notes = models.TextField(blank=True, help_text="Notes about the acceptance decision")
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Complaint Explanation"
|
||||
@ -2726,37 +2775,6 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
manager_reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Routing acceptance / rejection (champion can reject a wrongly-routed complaint)
|
||||
class RoutingStatus(models.TextChoices):
|
||||
SENT = "sent", _("Sent")
|
||||
ACCEPTED = "accepted", _("Accepted")
|
||||
REJECTED = "rejected", _("Rejected")
|
||||
|
||||
routing_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=RoutingStatus.choices,
|
||||
default=RoutingStatus.SENT,
|
||||
help_text="Whether the department has accepted or rejected the routing of this complaint",
|
||||
)
|
||||
rejected_at = models.DateTimeField(null=True, blank=True, help_text="When the champion rejected the routing")
|
||||
rejected_by_staff = models.ForeignKey(
|
||||
"organizations.Staff",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rejected_dept_involvements",
|
||||
help_text="Staff member (champion/manager) who rejected the routing",
|
||||
)
|
||||
rejection_reason = models.TextField(blank=True, help_text="Reason the department rejected the routing")
|
||||
suggested_department = models.ForeignKey(
|
||||
"organizations.Department",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="suggested_for_involvements",
|
||||
help_text="Department the champion suggested as the correct one",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-is_primary", "-created_at"]
|
||||
verbose_name = "Complaint Involved Department"
|
||||
@ -2797,17 +2815,6 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
||||
return timedelta(0)
|
||||
return remaining
|
||||
|
||||
@property
|
||||
def can_reject_routing(self):
|
||||
"""Whether the champion can still reject this routing as 'wrong department'."""
|
||||
if self.response_submitted:
|
||||
return False
|
||||
if self.routing_status != self.RoutingStatus.SENT:
|
||||
return False
|
||||
if self.complaint_id and self.complaint.status in ("closed", "cancelled"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
@ -3754,7 +3761,6 @@ class InvestigationStatus(models.TextChoices):
|
||||
QUESTIONS_SENT = "questions_sent", "Questions Sent"
|
||||
ANSWERS_RECEIVED = "answers_received", "Answers Received"
|
||||
REPLY_SUBMITTED = "reply_submitted", "Reply Submitted"
|
||||
DIRECT_REPLY_IN_PROGRESS = "direct_reply_in_progress", "Direct Reply In Progress"
|
||||
|
||||
|
||||
class ChampionInvestigation(UUIDModel, TimeStampedModel):
|
||||
@ -3785,17 +3791,11 @@ class ChampionInvestigation(UUIDModel, TimeStampedModel):
|
||||
related_name="investigation",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=30,
|
||||
max_length=20,
|
||||
choices=InvestigationStatus.choices,
|
||||
default=InvestigationStatus.QUESTIONS_SENT,
|
||||
)
|
||||
final_reply = models.TextField(blank=True)
|
||||
otp_code = models.CharField(max_length=10, blank=True, default="")
|
||||
otp_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
negligence_finding = models.CharField(max_length=3, blank=True, default="")
|
||||
policy_issue_finding = models.CharField(max_length=3, blank=True, default="")
|
||||
requires_improvement_project = models.CharField(max_length=3, blank=True, default="")
|
||||
improvement_project_note = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Champion Investigation"
|
||||
@ -3816,20 +3816,7 @@ class InvestigationQuestion(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="questions",
|
||||
)
|
||||
response = models.ForeignKey(
|
||||
"InvestigationResponse",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="questions",
|
||||
help_text="When set, this question is specific to this staff response (per-staff questions).",
|
||||
)
|
||||
question_text = models.TextField()
|
||||
question_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=[("text", "Text"), ("yes_no", "Yes/No")],
|
||||
default="text",
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
@ -3886,57 +3873,3 @@ class InvestigationAnswer(UUIDModel, TimeStampedModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"A: {self.answer_text[:80]}"
|
||||
|
||||
|
||||
class InvestigationAttachment(UUIDModel, TimeStampedModel):
|
||||
"""Context document attached by the champion when composing investigation questions."""
|
||||
|
||||
investigation = models.ForeignKey(
|
||||
ChampionInvestigation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attachments",
|
||||
)
|
||||
|
||||
file = models.FileField(upload_to="investigation_attachments/%Y/%m/%d/")
|
||||
filename = models.CharField(max_length=500)
|
||||
file_type = models.CharField(max_length=100, blank=True)
|
||||
file_size = models.IntegerField(help_text="File size in bytes")
|
||||
|
||||
uploaded_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="investigation_attachments",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Investigation Attachment"
|
||||
verbose_name_plural = "Investigation Attachments"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.investigation} - {self.filename}"
|
||||
|
||||
|
||||
class InvestigationResponseAttachment(UUIDModel, TimeStampedModel):
|
||||
"""File attached by a staff member when submitting their investigation response."""
|
||||
|
||||
response = models.ForeignKey(
|
||||
InvestigationResponse,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attachments",
|
||||
)
|
||||
|
||||
file = models.FileField(upload_to="investigation_response_attachments/%Y/%m/%d/")
|
||||
filename = models.CharField(max_length=500)
|
||||
file_type = models.CharField(max_length=100, blank=True)
|
||||
file_size = models.IntegerField(help_text="File size in bytes")
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Investigation Response Attachment"
|
||||
verbose_name_plural = "Investigation Response Attachments"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.response} - {self.filename}"
|
||||
|
||||
@ -7,13 +7,7 @@ from django.utils import timezone
|
||||
from apps.core.services import AuditService
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
from apps.organizations.models import Department
|
||||
from apps.complaints.models import (
|
||||
Complaint,
|
||||
ComplaintExplanation,
|
||||
ComplaintInvolvedDepartment,
|
||||
ComplaintStatus,
|
||||
ComplaintUpdate,
|
||||
)
|
||||
from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -81,16 +75,14 @@ class ComplaintService:
|
||||
return True
|
||||
if user.is_hospital_admin() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
if complaint.assigned_to and complaint.assigned_to == user:
|
||||
if user.is_px_management() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
if user.is_px_employee() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
# Before activation: PX staff can claim/activate the complaint
|
||||
if not complaint.activated_at:
|
||||
if user.is_px_management() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
if user.is_px_employee() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
if user.is_department_manager() and user.department == complaint.department:
|
||||
return True
|
||||
if complaint.assigned_to and complaint.assigned_to == user:
|
||||
return True
|
||||
if user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists():
|
||||
return True
|
||||
return False
|
||||
@ -125,21 +117,12 @@ class ComplaintService:
|
||||
complaint.assigned_to = user
|
||||
complaint.assigned_at = timezone.now()
|
||||
|
||||
# Record first-activation timestamp regardless of starting status.
|
||||
# Previously this was only set in the OPEN branch, which left the workflow
|
||||
# stepper showing "not activated" for already-IN_PROGRESS complaints.
|
||||
first_activation = complaint.activated_at is None
|
||||
if first_activation:
|
||||
complaint.activated_at = timezone.now()
|
||||
|
||||
if complaint.status == ComplaintStatus.OPEN:
|
||||
complaint.status = ComplaintStatus.IN_PROGRESS
|
||||
update_fields = ["assigned_to", "assigned_at", "status"]
|
||||
complaint.activated_at = timezone.now()
|
||||
complaint.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"])
|
||||
else:
|
||||
update_fields = ["assigned_to", "assigned_at"]
|
||||
if first_activation:
|
||||
update_fields.append("activated_at")
|
||||
complaint.save(update_fields=update_fields)
|
||||
complaint.save(update_fields=["assigned_to", "assigned_at"])
|
||||
|
||||
assign_message = f"Complaint activated and assigned to {user.get_full_name()}"
|
||||
if previous_assignee:
|
||||
@ -212,14 +195,6 @@ class ComplaintService:
|
||||
):
|
||||
raise ComplaintServiceError("You don't have permission to assign complaints.")
|
||||
|
||||
# Complaints can only be assigned to Patient Experience team members.
|
||||
if not target_user.groups.filter(
|
||||
name__in=["PX Employee", "PX Admin", "PX Management"]
|
||||
).exists():
|
||||
raise ComplaintServiceError(
|
||||
"Complaints can only be assigned to Patient Experience team members."
|
||||
)
|
||||
|
||||
old_assignee = complaint.assigned_to
|
||||
old_status = complaint.status
|
||||
|
||||
@ -404,10 +379,6 @@ class ComplaintService:
|
||||
if not new_status:
|
||||
raise ComplaintServiceError("Please select a status.")
|
||||
|
||||
# Require activation before any status change other than closing the complaint.
|
||||
if not complaint.activated_at and new_status != ComplaintStatus.CLOSED and new_status != "closed":
|
||||
raise ComplaintServiceError("Complaint must be activated before changing its status.")
|
||||
|
||||
old_status = complaint.status
|
||||
|
||||
# Enforce valid status transitions
|
||||
@ -956,19 +927,7 @@ This is an automated message from PX360 Complaint Management System."""
|
||||
inv_dept.sent = True
|
||||
inv_dept.sent_at = inv_dept.sent_at or now
|
||||
inv_dept.forwarded_at = inv_dept.forwarded_at or now
|
||||
inv_dept.save(update_fields=["sent", "sent_at", "forwarded_at"])
|
||||
# If re-sending to a department that previously rejected the routing,
|
||||
# reset the rejection so the champion can act again.
|
||||
if not created and inv_dept.routing_status == ComplaintInvolvedDepartment.RoutingStatus.REJECTED:
|
||||
inv_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
|
||||
inv_dept.rejected_at = None
|
||||
inv_dept.rejected_by_staff = None
|
||||
inv_dept.rejection_reason = ""
|
||||
inv_dept.suggested_department = None
|
||||
inv_dept.save(update_fields=[
|
||||
"routing_status", "rejected_at", "rejected_by_staff",
|
||||
"rejection_reason", "suggested_department",
|
||||
])
|
||||
inv_dept.save()
|
||||
|
||||
metadata = {
|
||||
"champion_count": champion_count,
|
||||
@ -1021,25 +980,24 @@ This is an automated message from PX360 Complaint Management System."""
|
||||
|
||||
@staticmethod
|
||||
def ensure_involved_records(complaint):
|
||||
"""Ensure the complaint's primary staff exists as an involved record.
|
||||
"""Ensure the complaint's primary department and staff exist as involved records.
|
||||
|
||||
Called lazily from the complaint detail view. Uses get_or_create so it's
|
||||
idempotent — only creates records that don't exist yet.
|
||||
|
||||
Note: Department involvement is NOT auto-created. The AI suggestion lives
|
||||
on complaint.department itself; the user must explicitly confirm it via
|
||||
the "Confirm" button in the Departments tab (confirm_ai_department_suggestion
|
||||
view) or add a different department via the "Add" button. This prevents
|
||||
AI guesses (often "Patient Experience" for vague complaints) from silently
|
||||
becoming the primary involved department.
|
||||
|
||||
Skips recreation when the user has explicitly removed the primary staff
|
||||
involvement (tracked via primary_staff_involved_removed). If complaint.staff
|
||||
later changes to a different record, auto-recreation resumes.
|
||||
"""
|
||||
from apps.complaints.models import ComplaintInvolvedStaff
|
||||
from apps.complaints.models import ComplaintInvolvedDepartment, ComplaintInvolvedStaff
|
||||
|
||||
if complaint.staff_id and complaint.primary_staff_involved_removed_id != complaint.staff_id:
|
||||
if complaint.department_id:
|
||||
ComplaintInvolvedDepartment.objects.get_or_create(
|
||||
complaint=complaint,
|
||||
department=complaint.department,
|
||||
defaults={
|
||||
"role": "primary",
|
||||
"is_primary": True,
|
||||
},
|
||||
)
|
||||
|
||||
if complaint.staff_id:
|
||||
ComplaintInvolvedStaff.objects.get_or_create(
|
||||
complaint=complaint,
|
||||
staff=complaint.staff,
|
||||
@ -1086,139 +1044,3 @@ This is an automated message from PX360 Complaint Management System."""
|
||||
content_object=complaint,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
class RoutingRejectionError(Exception):
|
||||
"""Raised when a routing rejection is not allowed."""
|
||||
|
||||
|
||||
def reject_department_routing(involved_dept, *, staff=None, user=None, reason="", suggested_department=None):
|
||||
"""
|
||||
Mark a ComplaintInvolvedDepartment as rejected (wrong department).
|
||||
|
||||
Shared between the no-login token view and the logged-in champion view.
|
||||
- Marks routing_status=REJECTED, sets rejected_at / rejected_by_staff /
|
||||
rejection_reason / suggested_department.
|
||||
- If the rejected department is the complaint's primary, clears
|
||||
complaint.department / complaint.section so PX can reassign.
|
||||
- Creates a ComplaintUpdate timeline entry + an AuditEvent.
|
||||
- Notifies the complaint handler (complaint.assigned_to) and PX admins by email.
|
||||
|
||||
Returns the updated involved_dept.
|
||||
Raises RoutingRejectionError if the routing cannot be rejected.
|
||||
"""
|
||||
if not involved_dept.can_reject_routing:
|
||||
raise RoutingRejectionError("This routing can no longer be rejected.")
|
||||
|
||||
complaint = involved_dept.complaint
|
||||
department = involved_dept.department
|
||||
now = timezone.now()
|
||||
|
||||
involved_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.REJECTED
|
||||
involved_dept.rejected_at = now
|
||||
involved_dept.rejected_by_staff = staff
|
||||
involved_dept.rejection_reason = reason
|
||||
involved_dept.suggested_department = suggested_department
|
||||
involved_dept.save(update_fields=[
|
||||
"routing_status", "rejected_at", "rejected_by_staff",
|
||||
"rejection_reason", "suggested_department",
|
||||
])
|
||||
|
||||
was_primary = involved_dept.is_primary or complaint.department_id == department.pk
|
||||
if was_primary and complaint.department_id == department.pk:
|
||||
complaint.department = None
|
||||
complaint.section = None
|
||||
complaint.save(update_fields=["department", "section"])
|
||||
|
||||
actor_label = staff.get_full_name() if staff else (user.get_full_name() if user else "Champion")
|
||||
|
||||
suggested_label = ""
|
||||
if suggested_department is not None:
|
||||
suggested_label = f" Suggested department: {suggested_department.get_localized_name()}."
|
||||
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type="note",
|
||||
message=(
|
||||
f"{department.get_localized_name()} rejected the routing "
|
||||
f"(wrong department). Reason: {reason or 'No reason provided.'}.{suggested_label}"
|
||||
),
|
||||
created_by=user,
|
||||
metadata={
|
||||
"department_id": str(department.id),
|
||||
"staff_id": str(staff.id) if staff else None,
|
||||
"reason": reason,
|
||||
"suggested_department_id": str(suggested_department.id) if suggested_department else None,
|
||||
"was_primary": was_primary,
|
||||
},
|
||||
)
|
||||
|
||||
AuditService.log_event(
|
||||
event_type="dept_routing_rejected",
|
||||
description=(
|
||||
f"{actor_label} rejected routing of complaint {complaint.reference_number} "
|
||||
f"to {department.get_localized_name()} (wrong department)."
|
||||
),
|
||||
user=user,
|
||||
content_object=complaint,
|
||||
metadata={
|
||||
"department_id": str(department.id),
|
||||
"reason": reason,
|
||||
"suggested_department_id": str(suggested_department.id) if suggested_department else None,
|
||||
"was_primary": was_primary,
|
||||
},
|
||||
)
|
||||
|
||||
_notify_routing_rejected(
|
||||
complaint=complaint,
|
||||
department=department,
|
||||
reason=reason,
|
||||
suggested_label=suggested_label,
|
||||
actor_label=actor_label,
|
||||
)
|
||||
|
||||
return involved_dept
|
||||
|
||||
|
||||
def _notify_routing_rejected(*, complaint, department, reason, suggested_label, actor_label):
|
||||
"""Email + in-app notification to the complaint handler and PX admins."""
|
||||
subject = f"Department Rejected Routing - {complaint.reference_number}"
|
||||
plain = (
|
||||
f"{department.get_localized_name()} has rejected the routing of complaint "
|
||||
f"{complaint.reference_number} as it was sent to the wrong department.\n\n"
|
||||
f"Rejected by: {actor_label}\n"
|
||||
f"Reason: {reason or 'No reason provided.'}{suggested_label}\n\n"
|
||||
f"Please review and re-route to the correct department.\n"
|
||||
)
|
||||
html = f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Department Rejected Routing</h2>
|
||||
<p style="margin: 0 0 8px 0;"><strong>{department.get_localized_name()}</strong> has rejected the routing of
|
||||
complaint <strong>#{complaint.reference_number}</strong> as it was sent to the wrong department.</p>
|
||||
<p style="margin: 0 0 4px 0;"><strong>Rejected by:</strong> {actor_label}</p>
|
||||
<p style="margin: 0 0 4px 0;"><strong>Reason:</strong> {reason or 'No reason provided.'}{suggested_label}</p>
|
||||
<p style="margin: 12px 0 0 0; color: #6b7280; font-size: 13px;">Please review and re-route to the correct department.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
recipients = []
|
||||
if complaint.assigned_to and complaint.assigned_to.email:
|
||||
recipients.append(complaint.assigned_to)
|
||||
|
||||
for recipient in recipients:
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
email=recipient.email,
|
||||
subject=subject,
|
||||
message=plain,
|
||||
html_message=html,
|
||||
related_object=complaint,
|
||||
user=recipient,
|
||||
notification_type="dept_routing_rejected",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send routing-rejected notification for complaint %s", complaint.id)
|
||||
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
"""
|
||||
Complaint signals.
|
||||
Complaint signals - Automatic SMS notifications on status changes
|
||||
|
||||
Heavy notification work (SMS/email to complainants and champions) has been moved
|
||||
off these signals into Celery tasks (see ``apps.complaints.tasks``) so it never
|
||||
blocks the request. The signals below now just dispatch those tasks.
|
||||
|
||||
The pre_save department-sync and the ComplaintUpdate logging remain inline
|
||||
(they are cheap, in-process work).
|
||||
This module handles automatic SMS notifications to complainants when:
|
||||
1. Complaint is created (confirmation)
|
||||
2. Complaint status changes to resolved or closed
|
||||
3. Auto-sync department from staff when staff is assigned
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.db.models.signals import pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
from .models import Complaint, ComplaintInvolvedDepartment, ComplaintUpdate
|
||||
from .models import Complaint, ComplaintUpdate, ComplaintInvolvedDepartment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -22,11 +21,12 @@ logger = logging.getLogger(__name__)
|
||||
def sync_department_from_staff(sender, instance, **kwargs):
|
||||
"""
|
||||
Automatically set complaint.department from staff.department when staff is assigned.
|
||||
|
||||
Ensures the department stays in sync with the assigned staff member regardless
|
||||
of how the complaint is saved (API, admin, forms, etc.).
|
||||
|
||||
This ensures the department is always in sync with the assigned staff member,
|
||||
regardless of how the complaint is saved (API, admin, forms, etc.).
|
||||
"""
|
||||
if instance.staff:
|
||||
# If staff is assigned, set department from staff's department
|
||||
staff_department = instance.staff.department
|
||||
if staff_department and instance.department_id != staff_department.id:
|
||||
instance.department = staff_department
|
||||
@ -35,66 +35,200 @@ def sync_department_from_staff(sender, instance, **kwargs):
|
||||
f"from staff '{instance.staff.name}'"
|
||||
)
|
||||
elif instance.pk:
|
||||
# Staff being removed (set to None); department kept as-is.
|
||||
# If staff is being removed (set to None), check if we should clear department
|
||||
# Only clear if the department was originally from a staff member
|
||||
# We keep the department if it was manually set
|
||||
pass
|
||||
|
||||
|
||||
@receiver(post_save, sender=Complaint)
|
||||
def send_complaint_creation_sms(sender, instance, created, **kwargs):
|
||||
"""Dispatch the complaint-received SMS/email task when a complaint is created."""
|
||||
"""
|
||||
Send SMS notification when complaint is created.
|
||||
|
||||
Only sends for public complaints (those with contact_phone).
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
if not instance.contact_phone and not instance.contact_email:
|
||||
logger.info(f"Complaint #{instance.id} created but no phone/email provided. Skipping notification.")
|
||||
|
||||
# Only send SMS if phone number is provided
|
||||
if not instance.contact_phone:
|
||||
logger.info(f"Complaint #{instance.id} created but no phone number provided. Skipping SMS.")
|
||||
return
|
||||
|
||||
# Send SMS notification
|
||||
try:
|
||||
from .tasks import send_complaint_creation_sms_task
|
||||
|
||||
send_complaint_creation_sms_task.delay(str(instance.id))
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
# Get tracking URL
|
||||
tracking_url = instance.get_tracking_url()
|
||||
|
||||
# Bilingual SMS messages
|
||||
messages = {
|
||||
'en': f"PX360: Your complaint #{instance.reference_number} has been received. Track: {tracking_url}",
|
||||
'ar': f"PX360: تم استلام شكوتك #{instance.reference_number}. تتبع الشكوى: {tracking_url}"
|
||||
}
|
||||
|
||||
# Default to English (can be enhanced to detect language)
|
||||
sms_message = messages['en']
|
||||
|
||||
# Send SMS
|
||||
notification_log = NotificationService.send_sms(
|
||||
phone=instance.contact_phone,
|
||||
message=sms_message,
|
||||
related_object=instance,
|
||||
metadata={
|
||||
'notification_type': 'complaint_created',
|
||||
'reference_number': instance.reference_number,
|
||||
'tracking_url': tracking_url,
|
||||
'language': 'en' # Default to English
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Creation SMS sent to {instance.contact_phone} for complaint #{instance.id}")
|
||||
|
||||
# Create complaint update to track SMS
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=instance,
|
||||
update_type='communication',
|
||||
message=f"SMS notification sent to complainant: Your complaint has been received",
|
||||
metadata={
|
||||
'notification_type': 'complaint_created',
|
||||
'notification_log_id': str(notification_log.id) if notification_log else None
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the complaint save
|
||||
logger.error(f"Failed to dispatch creation SMS for complaint #{instance.id}: {e}")
|
||||
logger.error(f"Failed to send creation SMS for complaint #{instance.id}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Complaint)
|
||||
def send_complaint_status_change_sms(sender, instance, created, **kwargs):
|
||||
"""Dispatch SMS/email when a complaint status changes to resolved or closed."""
|
||||
# Skip on creation (handled by the creation signal)
|
||||
"""
|
||||
Send SMS notification when complaint status changes to resolved or closed.
|
||||
|
||||
Uses update_fields to detect actual status changes (not just re-saves).
|
||||
"""
|
||||
# Skip on creation (handled by creation signal)
|
||||
if created:
|
||||
return
|
||||
|
||||
# Detect an actual status change using the in-memory previous-status set in Complaint.save()
|
||||
if not hasattr(instance, "_status_was"):
|
||||
|
||||
# Check if this is a status change to resolved or closed
|
||||
# Use update_fields to detect actual status changes
|
||||
if not hasattr(instance, '_status_was'):
|
||||
return
|
||||
|
||||
|
||||
old_status = instance._status_was
|
||||
new_status = instance.status
|
||||
|
||||
if new_status not in ("resolved", "closed"):
|
||||
|
||||
# Only send SMS for resolved or closed status changes
|
||||
if new_status not in ['resolved', 'closed']:
|
||||
return
|
||||
|
||||
# Only send if status actually changed
|
||||
if old_status == new_status:
|
||||
return
|
||||
|
||||
# Only send if phone or email is provided
|
||||
if not instance.contact_phone and not instance.contact_email:
|
||||
logger.info(
|
||||
f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification."
|
||||
)
|
||||
logger.info(f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification.")
|
||||
return
|
||||
|
||||
|
||||
# Send SMS + email notification
|
||||
try:
|
||||
from .tasks import send_complaint_status_change_task
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
from apps.core.utils import build_public_track_url
|
||||
|
||||
send_complaint_status_change_task.delay(str(instance.id), old_status, new_status)
|
||||
track_url = build_public_track_url("complaint", instance.reference_number)
|
||||
|
||||
status_label = "resolved" if new_status == "resolved" else "closed"
|
||||
sms_message = f"PX360: Your complaint #{instance.reference_number} has been {status_label}. View response: {track_url}"
|
||||
|
||||
if instance.contact_phone:
|
||||
notification_log = NotificationService.send_sms(
|
||||
phone=instance.contact_phone,
|
||||
message=sms_message,
|
||||
related_object=instance,
|
||||
metadata={
|
||||
'notification_type': 'complaint_status_change',
|
||||
'reference_number': instance.reference_number,
|
||||
'old_status': old_status,
|
||||
'new_status': new_status,
|
||||
'language': 'en'
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
|
||||
|
||||
if instance.contact_email:
|
||||
email_subject = f"PX360: Your complaint #{instance.reference_number} has been {status_label}"
|
||||
email_body = (
|
||||
f"Dear Valued Patient,\n\n"
|
||||
f"Your complaint #{instance.reference_number} has been {status_label}.\n\n"
|
||||
f"To view the full response, please visit:\n{track_url}\n\n"
|
||||
f"Thank you for your feedback.\n\n"
|
||||
f"Reference: {instance.reference_number}\n"
|
||||
f"This is an automated message from PX 360."
|
||||
)
|
||||
NotificationService.send_email(
|
||||
email=instance.contact_email,
|
||||
subject=email_subject,
|
||||
message=email_body,
|
||||
html_message=f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Update: {status_label.title()}</h2>
|
||||
<p style="margin: 0 0 12px 0;">Dear Valued Patient,</p>
|
||||
<p style="margin: 0 0 12px 0;">Your complaint <strong>#{instance.reference_number}</strong> has been <strong>{status_label}</strong>.</p>
|
||||
<p style="margin: 0 0 12px 0;">To view the full response, please click the link below:</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
|
||||
</div>
|
||||
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Reference: {instance.reference_number}</p>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
related_object=instance,
|
||||
metadata={
|
||||
'notification_type': 'complaint_status_change_email',
|
||||
'reference_number': instance.reference_number,
|
||||
}
|
||||
)
|
||||
logger.info(f"Status change email sent to {instance.contact_email} for complaint #{instance.id}")
|
||||
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=instance,
|
||||
update_type='communication',
|
||||
message=f"Notification sent to complainant: Status changed to {new_status} (SMS: {bool(instance.contact_phone)}, Email: {bool(instance.contact_email)})",
|
||||
metadata={
|
||||
'notification_type': 'complaint_status_change',
|
||||
'old_status': old_status,
|
||||
'new_status': new_status,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the complaint save
|
||||
logger.error(f"Failed to dispatch status change notification for complaint #{instance.id}: {e}")
|
||||
logger.error(f"Failed to send status change SMS for complaint #{instance.id}: {str(e)}")
|
||||
|
||||
|
||||
# Hook into ComplaintUpdate to track SMS sent manually via API
|
||||
@receiver(post_save, sender=ComplaintUpdate)
|
||||
def track_manual_sms(sender, instance, created, **kwargs):
|
||||
"""Track manually sent SMS notifications recorded as ComplaintUpdate rows."""
|
||||
"""
|
||||
Track manually sent SMS notifications.
|
||||
|
||||
This ensures that SMS sent via API endpoints (like send_resolution_notification)
|
||||
are also properly tracked.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
if instance.update_type == "communication":
|
||||
|
||||
# Check if this update was for a communication/notification
|
||||
if instance.update_type == 'communication':
|
||||
# Log tracking info
|
||||
logger.info(
|
||||
f"Manual communication update created for complaint #{instance.complaint.id}: "
|
||||
f"{instance.message[:50]}..."
|
||||
@ -103,16 +237,18 @@ def track_manual_sms(sender, instance, created, **kwargs):
|
||||
|
||||
@receiver(post_save, sender=ComplaintInvolvedDepartment)
|
||||
def notify_champion_on_department_assignment(sender, instance, created, **kwargs):
|
||||
"""Dispatch the champion-notification email when a complaint is sent to a department."""
|
||||
"""
|
||||
Send email notification to department champion when a complaint is assigned to their department.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
# Only notify when this department is actually being sent to (not just added)
|
||||
|
||||
# Only notify when this department is actually being sent to (not just added to involvement list)
|
||||
if not instance.sent:
|
||||
return
|
||||
# Only notify if the department champion has a user with an email
|
||||
champion = instance.department.champion
|
||||
champion_user = champion.user if champion else None
|
||||
if not champion_user or not champion_user.email:
|
||||
|
||||
# Only notify if the department has a respondent (champion) with email
|
||||
if not instance.department.champion or not instance.department.champion.user or not instance.department.champion.user.email:
|
||||
logger.info(
|
||||
f"ComplaintInvolvedDepartment #{instance.id}: No respondent email configured for department "
|
||||
f"'{instance.department.name}'. Skipping notification."
|
||||
@ -120,8 +256,63 @@ def notify_champion_on_department_assignment(sender, instance, created, **kwargs
|
||||
return
|
||||
|
||||
try:
|
||||
from .tasks import notify_champion_on_dept_assignment_task
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
champion = instance.department.champion.user
|
||||
complaint = instance.complaint
|
||||
department = instance.department
|
||||
|
||||
# Build response URL
|
||||
current_site = Site.objects.get_current()
|
||||
domain = current_site.domain if current_site else "px360.tenhal.sa"
|
||||
department_url = f"https://{domain}/organizations/departments/{department.pk}/"
|
||||
|
||||
# Send email
|
||||
NotificationService.send_email(
|
||||
recipient=champion.email,
|
||||
subject=f"New Complaint Assigned - {complaint.reference_number}",
|
||||
message=f"""A new complaint has been assigned to your department ({department.name}).
|
||||
|
||||
Complaint Reference: {complaint.reference_number}
|
||||
Title: {complaint.title or 'No title'}
|
||||
Patient: {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}
|
||||
|
||||
Please review and respond through your department page:
|
||||
{department_url}
|
||||
|
||||
Best regards,
|
||||
PX360 Team""",
|
||||
html_message=f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 30px;">
|
||||
<h2 style="color: #005696; margin-top: 0;">New Complaint Assigned</h2>
|
||||
<p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p>
|
||||
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
|
||||
<p><strong>Reference:</strong> {complaint.reference_number}</p>
|
||||
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
|
||||
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
|
||||
</div>
|
||||
<p>Please review and respond through your department page:</p>
|
||||
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
|
||||
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
related_object=complaint,
|
||||
metadata={
|
||||
'notification_type': 'complaint_department_assigned',
|
||||
'complaint_id': str(complaint.id),
|
||||
'department_id': str(department.id),
|
||||
'champion_email': champion.email,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Notification sent to champion {champion.email} for complaint "
|
||||
f"#{complaint.reference_number} assigned to department {department.name}"
|
||||
)
|
||||
|
||||
notify_champion_on_dept_assignment_task.delay(str(instance.id))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dispatch champion notification for ComplaintInvolvedDepartment #{instance.id}: {e}")
|
||||
logger.error(f"Failed to send champion notification for ComplaintInvolvedDepartment #{instance.id}: {str(e)}")
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,148 +0,0 @@
|
||||
"""Tests for the feedback workflow realignment (Inquiry department-ownership)."""
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.organizations.models import Department, Hospital
|
||||
from apps.complaints.models import Inquiry
|
||||
|
||||
|
||||
def _ensure_group(name):
|
||||
"""Get or create a role group (pytest runs with --nomigrations, so seeded
|
||||
groups may not exist in the test DB — mirrors the pattern in
|
||||
tests_routing_rejection.py and tests_workflow_fixes.py)."""
|
||||
grp, _ = Group.objects.get_or_create(name=name)
|
||||
return grp
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestInquiryDepartmentResponsePermission(TestCase):
|
||||
"""A department manager of the handling department can record a response.
|
||||
|
||||
Uses self.client (full middleware incl. django.contrib.messages) rather
|
||||
than calling the view directly with RequestFactory, because the view
|
||||
calls messages.error() on the permission-denied path.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="Test Hospital", code="TH01")
|
||||
self.dept = Department.objects.create(name="Dept A", hospital=self.hospital, code="DA")
|
||||
self.other_dept = Department.objects.create(name="Dept B", hospital=self.hospital, code="DB")
|
||||
|
||||
_ensure_group("Department Manager")
|
||||
self.dept_manager = User.objects.create_user(
|
||||
username="dmgr", email="dmgr@test", password="x", department=self.dept
|
||||
)
|
||||
self.dept_manager.groups.add(Group.objects.get(name="Department Manager"))
|
||||
|
||||
self.other_manager = User.objects.create_user(
|
||||
username="other_dmgr", email="o@test", password="x", department=self.other_dept
|
||||
)
|
||||
self.other_manager.groups.add(Group.objects.get(name="Department Manager"))
|
||||
|
||||
self.inquiry = Inquiry.objects.create(
|
||||
hospital=self.hospital,
|
||||
department=self.dept,
|
||||
subject="Test",
|
||||
message="msg",
|
||||
status="in_progress",
|
||||
sent_to_department=True,
|
||||
)
|
||||
|
||||
def test_dept_manager_of_handling_dept_can_access_response_view(self):
|
||||
"""GET to inquiry_department_response succeeds (200) for dept manager of handling dept."""
|
||||
self.client.force_login(self.dept_manager)
|
||||
response = self.client.get(
|
||||
reverse("inquiries:inquiry_department_response", kwargs={"pk": self.inquiry.pk})
|
||||
)
|
||||
# 200 = permission granted, 302 = permission denied redirect.
|
||||
assert response.status_code == 200, \
|
||||
f"dept_manager of handling dept should be allowed, got {response.status_code}"
|
||||
|
||||
def test_dept_manager_of_other_dept_cannot_access(self):
|
||||
"""Dept manager of a non-handling department is denied (302 redirect)."""
|
||||
self.client.force_login(self.other_manager)
|
||||
response = self.client.get(
|
||||
reverse("inquiries:inquiry_department_response", kwargs={"pk": self.inquiry.pk})
|
||||
)
|
||||
assert response.status_code == 302, \
|
||||
f"dept_manager of other dept should be redirected (denied), got {response.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestInquiryDetailUIRealignment(TestCase):
|
||||
"""The inquiry detail page no longer surfaces the PX-side 'Response to Patient'
|
||||
action. Resolution happens via direct POST to inquiry_change_status.
|
||||
|
||||
Locks in Task 5 of the workflow realignment: department owns resolution.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="Test Hospital UI", code="TH03")
|
||||
self.dept = Department.objects.create(name="Dept UI", hospital=self.hospital, code="UI")
|
||||
|
||||
_ensure_group("PX Admin")
|
||||
self.px_admin = User.objects.create_user(
|
||||
username="pxui", email="pxui@test", password="x", hospital=self.hospital
|
||||
)
|
||||
self.px_admin.groups.add(Group.objects.get(name="PX Admin"))
|
||||
|
||||
def _make_inquiry(self, **kwargs):
|
||||
defaults = dict(
|
||||
hospital=self.hospital,
|
||||
department=self.dept,
|
||||
subject="UI test",
|
||||
message="msg",
|
||||
status="in_progress",
|
||||
activated_at=kwargs.pop("activated_at", __import__("django.utils.timezone", fromlist=["now"]).now()),
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Inquiry.objects.create(**defaults)
|
||||
|
||||
def test_response_to_patient_button_is_gone(self):
|
||||
"""The 'Response to Patient' tile button must not appear anywhere on the page."""
|
||||
inquiry = self._make_inquiry()
|
||||
self.client.force_login(self.px_admin)
|
||||
# PX admins are redirected to select-hospital unless the session has a
|
||||
# selected_hospital_id (HospitalSelectionMiddleware). Set it directly.
|
||||
session = self.client.session
|
||||
session["selected_hospital_id"] = str(self.hospital.id)
|
||||
session.save()
|
||||
response = self.client.get(reverse("inquiries:inquiry_detail", kwargs={"pk": inquiry.pk}))
|
||||
assert response.status_code == 200
|
||||
# The PX-side respond modal and its trigger are removed. The button label
|
||||
# may still appear inside {% comment %} blocks (inert), but the live
|
||||
# onclick="showRespondModal()" button in the actions tile is gone.
|
||||
content = response.content.decode()
|
||||
# showRespondModal JS function definition must be gone.
|
||||
assert "function showRespondModal" not in content, \
|
||||
"showRespondModal JS function should be removed"
|
||||
# The respondModal div must be gone.
|
||||
assert 'id="respondModal"' not in content, \
|
||||
"respondModal div should be removed"
|
||||
|
||||
def test_resolve_uses_direct_status_change_form(self):
|
||||
"""The Resolve button POSTs directly to inquiry_change_status (not a modal)."""
|
||||
from django.utils import timezone
|
||||
# Department has responded → "Department has responded" banner shows Resolve.
|
||||
inquiry = self._make_inquiry(
|
||||
sent_to_department=True,
|
||||
department_responded_at=timezone.now(),
|
||||
)
|
||||
self.client.force_login(self.px_admin)
|
||||
session = self.client.session
|
||||
session["selected_hospital_id"] = str(self.hospital.id)
|
||||
session.save()
|
||||
response = self.client.get(reverse("inquiries:inquiry_detail", kwargs={"pk": inquiry.pk}))
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
change_status_url = reverse("inquiries:inquiry_change_status", kwargs={"pk": inquiry.pk})
|
||||
# The Resolve button must be a real form posting to inquiry_change_status
|
||||
# with status=resolved — not a showRespondModal() trigger.
|
||||
assert f'action="{change_status_url}"' in content, \
|
||||
"Resolve must POST to inquiry_change_status"
|
||||
assert 'name="status" value="resolved"' in content, \
|
||||
"Resolve form must include hidden status=resolved input"
|
||||
@ -194,241 +194,3 @@ class PublicComplaintViewTests(TestCase):
|
||||
self.assertEqual(complaint.contact_email, "john@example.com")
|
||||
self.assertEqual(complaint.staff_name, "Dr. Smith")
|
||||
self.assertEqual(complaint.expected_result, "Quick resolution")
|
||||
|
||||
|
||||
class DepartmentNotificationTaskTests(TestCase):
|
||||
"""The send-to-department notifications run as a Celery task (offloaded from the view)."""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.organizations.models import Staff
|
||||
|
||||
User = get_user_model()
|
||||
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
||||
self.complaint = Complaint.objects.create(
|
||||
hospital=self.hospital,
|
||||
title="T",
|
||||
description="D",
|
||||
status="in_progress",
|
||||
reference_number="REF-TEST-1",
|
||||
)
|
||||
self.staff_user = User.objects.create_user(username="champ@x", email="champ@x", password="p")
|
||||
self.staff = Staff.objects.create(
|
||||
hospital=self.hospital,
|
||||
first_name="Champ",
|
||||
last_name="X",
|
||||
status="active",
|
||||
staff_type="other",
|
||||
job_title="C",
|
||||
employee_id="EMP-1",
|
||||
user=self.staff_user,
|
||||
email="champ@x",
|
||||
)
|
||||
|
||||
def _payload(self, **overrides):
|
||||
payload = {
|
||||
"complaint_id": str(self.complaint.id),
|
||||
"reference_number": self.complaint.reference_number,
|
||||
"title": self.complaint.title,
|
||||
"department_name": "Emergency",
|
||||
"note": "",
|
||||
"email_subject": "",
|
||||
"email_body": "",
|
||||
"targets": [
|
||||
{
|
||||
"email": "champ@x",
|
||||
"phone": "+966500000000",
|
||||
"link": "https://example.com/explain/token/",
|
||||
"label": "Champion",
|
||||
"display_name": "Champ X",
|
||||
}
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
def test_task_sends_email_and_sms_to_targets(self):
|
||||
from unittest.mock import patch
|
||||
from apps.complaints.tasks import send_department_notification_task
|
||||
|
||||
with patch("apps.notifications.services.NotificationService.send_email") as mock_email, \
|
||||
patch("apps.notifications.services.NotificationService.send_sms") as mock_sms:
|
||||
result = send_department_notification_task.apply(args=[self._payload()]).get()
|
||||
|
||||
self.assertEqual(result, {"emailed": 1, "smsed": 1})
|
||||
mock_email.assert_called_once()
|
||||
self.assertEqual(mock_email.call_args.kwargs["email"], "champ@x")
|
||||
mock_sms.assert_called_once()
|
||||
|
||||
def test_task_skips_missing_complaint(self):
|
||||
import uuid
|
||||
from apps.complaints.tasks import send_department_notification_task
|
||||
|
||||
payload = self._payload(complaint_id=str(uuid.uuid4()))
|
||||
result = send_department_notification_task.apply(args=[payload]).get()
|
||||
self.assertEqual(result, {"emailed": 0, "smsed": 0})
|
||||
|
||||
def test_task_continues_if_one_send_fails(self):
|
||||
from unittest.mock import patch
|
||||
from apps.complaints.tasks import send_department_notification_task
|
||||
|
||||
payload = self._payload(targets=[
|
||||
{"email": "a@x", "phone": "", "link": "l", "label": "Champion", "display_name": "A"},
|
||||
{"email": "", "phone": "+9665", "link": "l", "label": "Manager", "display_name": "B"},
|
||||
])
|
||||
with patch("apps.notifications.services.NotificationService.send_email", side_effect=Exception("smtp down")), \
|
||||
patch("apps.notifications.services.NotificationService.send_sms") as mock_sms:
|
||||
result = send_department_notification_task.apply(args=[payload]).get()
|
||||
|
||||
# Email failed (best-effort), SMS still sent; task did not raise.
|
||||
self.assertEqual(result, {"emailed": 0, "smsed": 1})
|
||||
mock_sms.assert_called_once()
|
||||
|
||||
|
||||
class InvestigationReviewOtpTests(TestCase):
|
||||
"""The investigation-review verification code goes to the logged-in reviewer."""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.organizations.models import Staff
|
||||
from apps.complaints.models import ComplaintExplanation, ChampionInvestigation
|
||||
|
||||
User = get_user_model()
|
||||
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
||||
self.complaint = Complaint.objects.create(
|
||||
hospital=self.hospital, title="T", description="D",
|
||||
status="in_progress", reference_number="REF-INV-1",
|
||||
)
|
||||
# Manager = investigator of record (explanation.staff / investigation.champion)
|
||||
self.manager_user = User.objects.create_user(username="mgr@x", email="mgr@x", password="p")
|
||||
self.manager = Staff.objects.create(
|
||||
hospital=self.hospital, first_name="Mgr", last_name="X",
|
||||
status="active", staff_type="other", job_title="M",
|
||||
employee_id="EMP-M", user=self.manager_user, email="mgr@x",
|
||||
)
|
||||
# Champion = the actual reviewer (logged in)
|
||||
self.champ_user = User.objects.create_user(username="champ@x", email="champ@x", password="p")
|
||||
self.champion = Staff.objects.create(
|
||||
hospital=self.hospital, first_name="Champ", last_name="X",
|
||||
status="active", staff_type="other", job_title="C",
|
||||
employee_id="EMP-C", user=self.champ_user, email="champ@x",
|
||||
)
|
||||
self.token = "manager-token-abc"
|
||||
self.explanation = ComplaintExplanation.objects.create(
|
||||
complaint=self.complaint, staff=self.manager, token=self.token,
|
||||
)
|
||||
self.investigation = ChampionInvestigation.objects.create(
|
||||
complaint=self.complaint, explanation=self.explanation,
|
||||
champion=self.manager, status="answers_received",
|
||||
)
|
||||
|
||||
def test_otp_goes_to_logged_in_reviewer_not_investigator_of_record(self):
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
self.client.force_login(self.champ_user)
|
||||
url = reverse("complaints:champion_review_answers", args=[self.complaint.id, self.token])
|
||||
|
||||
with patch("apps.notifications.services.NotificationService.send_email",
|
||||
return_value=Mock(status="sent")) as mock_email, \
|
||||
patch("apps.notifications.services.NotificationService.send_sms",
|
||||
return_value=Mock(status="sent")) as mock_sms:
|
||||
self.client.post(url, {
|
||||
"action": "send_code",
|
||||
"final_reply": "My final reply.",
|
||||
"consent": "on",
|
||||
})
|
||||
|
||||
# The code MUST go to the logged-in champion, not the manager of record.
|
||||
mock_email.assert_called_once()
|
||||
self.assertEqual(mock_email.call_args.kwargs["email"], "champ@x")
|
||||
# Champion has no phone on file -> SMS not used (and manager's phone not pulled in).
|
||||
mock_sms.assert_not_called()
|
||||
|
||||
def test_send_code_persists_assessment_draft(self):
|
||||
"""The assessment must be persisted at send_code so a return visit keeps it.
|
||||
|
||||
Regression: previously only otp_code/otp_sent_at were saved, so leaving the
|
||||
page and coming back lost the final reply / findings, and verify_submit then
|
||||
rejected the submission as an empty assessment.
|
||||
"""
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
self.client.force_login(self.champ_user)
|
||||
url = reverse("complaints:champion_review_answers", args=[self.complaint.id, self.token])
|
||||
with patch("apps.notifications.services.NotificationService.send_email",
|
||||
return_value=Mock(status="sent")), \
|
||||
patch("apps.notifications.services.NotificationService.send_sms",
|
||||
return_value=Mock(status="sent")):
|
||||
self.client.post(url, {
|
||||
"action": "send_code",
|
||||
"final_reply": "Persisted final reply.",
|
||||
"negligence_finding": "no",
|
||||
"policy_issue_finding": "yes",
|
||||
"requires_improvement_project": "yes",
|
||||
"improvement_project_note": "Need a project.",
|
||||
"consent": "on",
|
||||
})
|
||||
|
||||
self.investigation.refresh_from_db()
|
||||
self.assertEqual(self.investigation.final_reply, "Persisted final reply.")
|
||||
self.assertEqual(self.investigation.negligence_finding, "no")
|
||||
self.assertEqual(self.investigation.policy_issue_finding, "yes")
|
||||
self.assertEqual(self.investigation.requires_improvement_project, "yes")
|
||||
self.assertEqual(self.investigation.improvement_project_note, "Need a project.")
|
||||
self.assertTrue(self.investigation.otp_code)
|
||||
|
||||
|
||||
class InquiryTimestampTests(TestCase):
|
||||
"""resolved_at / closed_at are auto-stamped on status transition."""
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
||||
self.inquiry = Inquiry.objects.create(
|
||||
hospital=self.hospital,
|
||||
subject="Test",
|
||||
message="M",
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
def test_resolved_at_stamped_on_resolve(self):
|
||||
self.inquiry.status = "resolved"
|
||||
self.inquiry.save()
|
||||
self.inquiry.refresh_from_db()
|
||||
self.assertIsNotNone(self.inquiry.resolved_at)
|
||||
self.assertIsNone(self.inquiry.closed_at)
|
||||
|
||||
def test_closed_at_stamped_on_close(self):
|
||||
self.inquiry.status = "closed"
|
||||
self.inquiry.save()
|
||||
self.inquiry.refresh_from_db()
|
||||
self.assertIsNotNone(self.inquiry.closed_at)
|
||||
|
||||
def test_reopen_preserves_timestamps(self):
|
||||
self.inquiry.status = "resolved"
|
||||
self.inquiry.save()
|
||||
stamped = self.inquiry.resolved_at
|
||||
self.inquiry.status = "in_progress"
|
||||
self.inquiry.save()
|
||||
self.inquiry.refresh_from_db()
|
||||
self.assertEqual(self.inquiry.resolved_at, stamped)
|
||||
|
||||
def test_reresolve_updates_timestamp(self):
|
||||
self.inquiry.status = "resolved"
|
||||
self.inquiry.save()
|
||||
first = self.inquiry.resolved_at
|
||||
self.inquiry.status = "in_progress"
|
||||
self.inquiry.save()
|
||||
self.inquiry.status = "resolved"
|
||||
self.inquiry.save()
|
||||
self.inquiry.refresh_from_db()
|
||||
self.assertGreater(self.inquiry.resolved_at, first)
|
||||
|
||||
def test_resolved_by_stamped_from_acting_user(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
resolver = User.objects.create_user(username="resolver@x", email="resolver@x", password="p")
|
||||
self.inquiry._acting_user = resolver
|
||||
self.inquiry.status = "resolved"
|
||||
self.inquiry.save()
|
||||
self.inquiry.refresh_from_db()
|
||||
self.assertEqual(self.inquiry.resolved_by, resolver)
|
||||
|
||||
@ -1,260 +0,0 @@
|
||||
"""
|
||||
Tests for the champion "reject wrong-department routing" feature.
|
||||
|
||||
Covers:
|
||||
- Token-based (no-login) rejection via complaint_explanation_form
|
||||
- Login-based rejection via involved_department_reject_routing
|
||||
- Primary department rejection clears complaint.department
|
||||
- Guards: can't reject twice, can't reject after response submitted
|
||||
- Re-send to a rejected department resets the rejection
|
||||
"""
|
||||
import secrets
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.complaints.models import (
|
||||
Complaint,
|
||||
ComplaintExplanation,
|
||||
ComplaintInvolvedDepartment,
|
||||
ComplaintUpdate,
|
||||
)
|
||||
from apps.complaints.services.complaint_service import (
|
||||
RoutingRejectionError,
|
||||
reject_department_routing,
|
||||
)
|
||||
from apps.organizations.models import Department, Hospital, Staff
|
||||
|
||||
|
||||
class RoutingRejectionBase(TestCase):
|
||||
"""Shared fixtures: hospital, two departments, PX admin, champion, complaint."""
|
||||
|
||||
@classmethod
|
||||
def _make_staff(cls, first_name, last_name, emp_id, hospital, department):
|
||||
return Staff.objects.create(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
staff_type="admin",
|
||||
job_title="Champion",
|
||||
employee_id=emp_id,
|
||||
hospital=hospital,
|
||||
department=department,
|
||||
status="active",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="Test Hosp", code="TH001", status="active")
|
||||
self.dept_a = Department.objects.create(
|
||||
hospital=self.hospital, name="Dept A", name_en="Dept A", code="DA", status="active",
|
||||
)
|
||||
self.dept_b = Department.objects.create(
|
||||
hospital=self.hospital, name="Dept B", name_en="Dept B", code="DB", status="active",
|
||||
)
|
||||
|
||||
# PX admin
|
||||
self.px_admin = User.objects.create_user(
|
||||
email="px@test.com", password="pass123", hospital=self.hospital,
|
||||
)
|
||||
px_group, _ = Group.objects.get_or_create(name="PX Admin")
|
||||
self.px_admin.groups.add(px_group)
|
||||
|
||||
# Champion of dept_a
|
||||
self.champ_user = User.objects.create_user(
|
||||
email="champ@test.com", password="pass123",
|
||||
hospital=self.hospital, department=self.dept_a,
|
||||
)
|
||||
self.champ_staff = self._make_staff(
|
||||
"Champ", "One", "EMP-CHAMP-1", self.hospital, self.dept_a,
|
||||
)
|
||||
self.champ_staff.user = self.champ_user
|
||||
self.champ_staff.save()
|
||||
self.dept_a.champion = self.champ_staff
|
||||
self.dept_a.save(update_fields=["champion"])
|
||||
|
||||
# Complaint routed to dept_a (primary, sent)
|
||||
self.complaint = Complaint.objects.create(
|
||||
hospital=self.hospital,
|
||||
department=self.dept_a,
|
||||
title="Wrong routing test",
|
||||
description="Sent to the wrong department.",
|
||||
created_by=self.px_admin,
|
||||
status="in_progress",
|
||||
)
|
||||
self.involved = ComplaintInvolvedDepartment.objects.create(
|
||||
complaint=self.complaint,
|
||||
department=self.dept_a,
|
||||
role="primary",
|
||||
is_primary=True,
|
||||
sent=True,
|
||||
)
|
||||
|
||||
|
||||
class ModelHelperTests(RoutingRejectionBase):
|
||||
def test_can_reject_routing_initially_true(self):
|
||||
self.assertTrue(self.involved.can_reject_routing)
|
||||
|
||||
def test_cannot_reject_after_response_submitted(self):
|
||||
self.involved.response_submitted = True
|
||||
self.involved.save()
|
||||
self.assertFalse(self.involved.can_reject_routing)
|
||||
|
||||
def test_cannot_reject_twice(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
|
||||
self.involved.refresh_from_db()
|
||||
self.assertFalse(self.involved.can_reject_routing)
|
||||
with self.assertRaises(RoutingRejectionError):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="again")
|
||||
|
||||
|
||||
class ServiceRejectTests(RoutingRejectionBase):
|
||||
def test_service_reject_marks_fields(self):
|
||||
reject_department_routing(
|
||||
self.involved,
|
||||
staff=self.champ_staff,
|
||||
reason="not ours",
|
||||
suggested_department=self.dept_b,
|
||||
)
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
self.assertIsNotNone(self.involved.rejected_at)
|
||||
self.assertEqual(self.involved.rejected_by_staff_id, self.champ_staff.id)
|
||||
self.assertEqual(self.involved.rejection_reason, "not ours")
|
||||
self.assertEqual(self.involved.suggested_department_id, self.dept_b.id)
|
||||
|
||||
def test_service_reject_primary_clears_complaint_department(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
|
||||
self.complaint.refresh_from_db()
|
||||
self.assertIsNone(self.complaint.department_id)
|
||||
self.assertIsNone(self.complaint.section_id)
|
||||
|
||||
def test_service_reject_secondary_keeps_other_department(self):
|
||||
# Make dept_b a secondary involved department
|
||||
inv_b = ComplaintInvolvedDepartment.objects.create(
|
||||
complaint=self.complaint, department=self.dept_b, role="secondary", sent=True,
|
||||
)
|
||||
reject_department_routing(inv_b, staff=self.champ_staff, reason="wrong")
|
||||
# Primary dept_a must remain untouched
|
||||
self.complaint.refresh_from_db()
|
||||
self.assertEqual(self.complaint.department_id, self.dept_a.id)
|
||||
|
||||
def test_service_reject_creates_update_and_audit(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
|
||||
self.assertTrue(
|
||||
ComplaintUpdate.objects.filter(
|
||||
complaint=self.complaint, update_type="note",
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class TokenViewRejectTests(RoutingRejectionBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.explanation = ComplaintExplanation.objects.create(
|
||||
complaint=self.complaint,
|
||||
staff=self.champ_staff,
|
||||
token=secrets.token_urlsafe(32),
|
||||
explanation="",
|
||||
is_used=False,
|
||||
)
|
||||
self.url = reverse(
|
||||
"complaints:complaint_explanation_form",
|
||||
kwargs={"complaint_id": self.complaint.id, "token": self.explanation.token},
|
||||
)
|
||||
|
||||
def test_token_reject_success(self):
|
||||
resp = self.client.post(self.url, {
|
||||
"action": "reject_routing",
|
||||
"rejection_reason": "Belongs to another dept",
|
||||
})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTemplateUsed(resp, "complaints/explanation_routing_rejected.html")
|
||||
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
self.assertEqual(self.involved.rejection_reason, "Belongs to another dept")
|
||||
|
||||
# Token consumed
|
||||
self.explanation.refresh_from_db()
|
||||
self.assertTrue(self.explanation.is_used)
|
||||
|
||||
def test_token_reject_requires_reason(self):
|
||||
resp = self.client.post(self.url, {"action": "reject_routing", "rejection_reason": ""})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTemplateUsed(resp, "complaints/explanation_form.html")
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "sent")
|
||||
|
||||
def test_token_reject_already_used_token_blocked(self):
|
||||
self.explanation.is_used = True
|
||||
self.explanation.save()
|
||||
resp = self.client.post(self.url, {
|
||||
"action": "reject_routing", "rejection_reason": "x",
|
||||
})
|
||||
# Already-submitted template is rendered when token is used
|
||||
self.assertTemplateUsed(resp, "complaints/explanation_already_submitted.html")
|
||||
|
||||
|
||||
class LoginViewRejectTests(RoutingRejectionBase):
|
||||
def test_login_reject_by_champion(self):
|
||||
self.client.login(username="champ@test.com", password="pass123")
|
||||
url = reverse(
|
||||
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
|
||||
)
|
||||
resp = self.client.post(url, {"rejection_reason": "Wrong department"})
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
|
||||
def test_login_reject_requires_permission(self):
|
||||
# A random user with no relation to the department
|
||||
User.objects.create_user(
|
||||
email="other@test.com", password="pass123", hospital=self.hospital,
|
||||
)
|
||||
self.client.login(username="other@test.com", password="pass123")
|
||||
url = reverse(
|
||||
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
|
||||
)
|
||||
resp = self.client.post(url, {"rejection_reason": "Wrong department"})
|
||||
self.assertEqual(resp.status_code, 302) # redirected, but not rejected
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "sent")
|
||||
|
||||
def test_login_reject_requires_reason(self):
|
||||
self.client.login(username="champ@test.com", password="pass123")
|
||||
url = reverse(
|
||||
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
|
||||
)
|
||||
resp = self.client.post(url, {"rejection_reason": ""})
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "sent")
|
||||
|
||||
|
||||
class ResendResetTests(RoutingRejectionBase):
|
||||
def test_resend_to_rejected_department_resets_routing(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
|
||||
# Simulate re-send via the service path
|
||||
from django.utils import timezone
|
||||
|
||||
inv, created = ComplaintInvolvedDepartment.objects.get_or_create(
|
||||
complaint=self.complaint, department=self.dept_a,
|
||||
defaults={"role": "primary", "sent": True, "sent_at": timezone.now()},
|
||||
)
|
||||
self.assertFalse(created)
|
||||
inv.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
|
||||
inv.rejected_at = None
|
||||
inv.rejected_by_staff = None
|
||||
inv.rejection_reason = ""
|
||||
inv.suggested_department = None
|
||||
inv.sent = True
|
||||
inv.sent_at = timezone.now()
|
||||
inv.save()
|
||||
|
||||
inv.refresh_from_db()
|
||||
self.assertEqual(inv.routing_status, "sent")
|
||||
self.assertEqual(inv.rejection_reason, "")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user