Compare commits

...

21 Commits

Author SHA1 Message Date
9ea74e47ef Merge feat/feedback-workflow-realignment: realign inquiry/observation/suggestion/appreciation workflows
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m52s
Realigns the five patient-feedback workflows to the client's vision:
- Inquiry: department owns resolution end-to-end (removed PX-side respond UI,
  dept_manager now in response permission, contact_status hint)
- Observation: dept_manager can respond; Action/RCA/QI decision tree in
  post-response banner; Inform Reporter step on resolved
- Suggestion: optional implemented-notice SMS on close
- Appreciation: collapsed to 3 states (DRAFT/ACTIVATED/SENT), migration 0010
- Complaint: enriched Resolve modal (resolution_category/outcome) + view fix
- Department dashboard: Appreciations Received card
- Dead code removal; root conftest.py for template-rendering tests

13 commits, 23 files, +674/-527. 55 tests pass, 0 new failures vs baseline.
Whole-branch review passed (opus); Important + key Minor findings fixed.
2026-07-21 06:55:46 +03:00
615b8467ef fix(review): observation dept_manager button gate + appreciation status guard
Two issues from the whole-branch review:

1. (Important) Observation detail's can_respond_to_department context var
   did not include is_department_manager, so even though Task 2 let dept
   managers pass the view's permission check, the 'Submit Response'
   button stayed hidden from them. Now the context var matches the view
   predicate. Regression test added.

2. (Minor) appreciation_list had no guard against now-invalid status
   query params (?status=acknowledged from an old bookmark would show
   an unexplained empty list). Unknown values are now silently reset
   to 'all statuses'.
2026-07-20 22:38:12 +03:00
c88c5cf973 ux(complaint): surface resolution_category and resolution_outcome in resolve modal
These fields already existed on the Complaint model and are read by
ComplaintService.change_status, but PX had to fill them via separate
endpoints after resolving. Now they're captured in the same modal as
the resolution text. Also fixed the view (complaint_change_status) to
extract resolution_category from POST and pass it to the service — the
service accepted the kwarg but the view never forwarded it.
2026-07-20 22:26:05 +03:00
85980f232b feat(department): surface appreciations on the department dashboard
Per the workflow realignment, the point of appreciation is recognition —
managers should see the appreciations their department received alongside
other feedback. Adds an 'Appreciations Received' card (amber theme) after
the Pending Actions section, showing the count and the 10 most recent
SENT appreciations. The card only renders when the department has at
least one appreciation.
2026-07-20 22:23:35 +03:00
295ff298a8 refactor(appreciation): collapse to 3-state DRAFT → ACTIVATED → SENT
Drops the dead AI_ANALYZED status (a prior data migration already converted
existing rows; the choice was never removed from the model) and the
unreachable ACKNOWLEDGED status (is_recipient was hardcoded False so the
acknowledge button never rendered).

Changes:
- AppreciationStatus: DRAFT, ACTIVATED, SENT only. SENT is terminal.
- VALID_APPRECIATION_TRANSITIONS: DRAFT→ACTIVATED→SENT, SENT terminal.
- Removed the model.acknowledge() method.
- DRF viewset acknowledge action: returns 410 Gone (deprecated).
- UI appreciation_acknowledge view: redirects with deprecation message.
- Removed URL routes: appreciation_acknowledge, appreciation_send_dept_reminder.
- appreciation_send_dept_reminder status check no longer references ACKNOWLEDGED.
- appreciation_list stats + status filter + badge: dropped ai_analyzed/acknowledged.
- appreciation_detail status badge + sent banner: dropped acknowledged references.
- Migration 0010_drop_acknowledged_status: AlterField on status choices.

AI analysis still runs on activation and is stored in ai_analysis JSON; it
just no longer has its own status. Badges and leaderboard fire at SENT.
6 tests lock in the new lifecycle and removed URLs.
2026-07-20 22:20:29 +03:00
b1776494f9 feat(suggestion): optional implemented-notice SMS on close
When PX closes a suggestion that was actually implemented, ticking the
checkbox sends a high-value 'Good news — your suggestion was implemented'
SMS instead of the generic closed message. The flag is ignored for any
status other than CLOSED. Acknowledge-on-submit SMS already existed; this
completes the closed-loop value path.

The checkbox appears in the status-change form in feedback_detail.html.
Three tests cover: implemented flag on close, flag off on close, flag
ignored when transitioning to a non-closed status.
2026-07-20 22:13:22 +03:00
93f4fc6342 feat(observation): surface Action/RCA/QI decision tree and Inform Reporter step
After the department responds, PX decides what to do next. The post-response
banner now presents the choice inline instead of only in the actions toolbar:
- Create Action (minor / isolated) — or 'Action exists' badge if already converted
- Start RCA (serious / pattern)
- QI Project (systemic trend) — admins only
- Resolve (no action needed)

Non-admin viewers still see the simple Resolve form.

The resolved-state banner now shows whether the reporter has been informed
(response_sent_at) and surfaces an 'Inform Reporter' button that opens the
existing observation_respond modal — closing the loop with the patient who
reported the observation. Both changes use existing endpoints; this is pure
UI surfacing at the moment of decision.
2026-07-20 22:06:29 +03:00
185cc67c10 ux(inquiry): visible hint when Resolve is disabled until contact_status set
The department-side Resolve button is disabled (greyed out) until the
department records a patient contact outcome — the contact_status gate
in inquiry_change_status enforces this. Previously only a hover tooltip
explained why; now an inline amber hint appears next to the actions bar
so the reason is immediately visible.
2026-07-20 22:04:24 +03:00
c8db98880a feat(inquiry): remove PX-side Response to Patient from UI
Per the workflow realignment, the department owns inquiry resolution
end-to-end. The PX-side respond modal and its 'Response to Patient'
tile button are removed; the 'Resolve' button in the 'Department has
responded' banner now POSTs directly to inquiry_change_status (with a
confirm dialog), which already enforces the contact_status gate.

The inquiry_respond view and URL are kept for backward compatibility
but no UI surfaces them. Two regression tests lock in the new behavior:
- showRespondModal JS function and respondModal div are gone
- Resolve form posts directly to inquiry_change_status with status=resolved
2026-07-20 22:03:02 +03:00
b0d77c21e5 chore: remove dead top-level appreciation/forms.py
This file referenced model fields (AppreciationAttachment, submitted_by,
acknowledged_by, AppreciationStatus.SUBMITTED) that no longer exist on the
current model, and was not wired into any URL. grep confirms no imports.
The live form is apps/appreciation/forms.py.

(apps/complaints/views.py.backup was also deleted but was never tracked by
git, so no commit is needed for it.)
2026-07-20 21:55:03 +03:00
8c1c29aae0 chore(task-3): verified send_to_modal works after contact-person-picker removal
Static analysis confirms: modal HTML/JS internally consistent, department
dropdown server-rendered from 'departments' context var (not dependent on
the removed picker), loadDepartmentContacts is an inert stub, all five
item-type endpoints exist. The e2e PNGs are diagnostic snapshots from an
untracked exploratory test, not bug evidence. No code change needed.
2026-07-20 21:52:27 +03:00
2829308bef fix(observation): dept_manager of assigned dept can now respond
Matches the complaint predicate. Previously a department manager could
respond to a complaint sent to their dept but not to an observation.
2026-07-20 21:50:12 +03:00
17064fc33c fix(inquiry): dept_manager of handling dept can now record department response
The inquiry_department_response view excluded dept managers even though
they can resolve the inquiry via inquiry_change_status. Now both actions
use the same handling_department_id predicate so forwarding locks the
old department out consistently.
2026-07-20 21:46:04 +03:00
eb38bbd9e3 chore: start feedback-workflow-realignment branch 2026-07-20 21:14:08 +03:00
8783d738f9 chore: pre-realignment cleanup
- department_record_complaint: broaden permission to include any involved
  department (mirrors the OR-filter used to surface complaints in the list)
- send_to_modal: simplify — sends directly to champion+manager, drop the
  contact-person picker (loadDepartmentContacts is now a no-op stub)
- department_inquiry_detail: confirm dialogs on Resolve/Close; fold No-Response
  into the contact_status dropdown
- department_detail: JS string-escaping fixes
2026-07-20 21:14:00 +03:00
41bb9b5dd7 update api doc 2026-07-19 12:27:55 +03:00
fe0a643607 update 2026-07-12 11:18:20 +03:00
6baa34dec3 update and nug fixes 2026-07-11 19:24:28 +03:00
d63ed6f956 more update 2026-07-05 21:34:34 +03:00
5dc61c8e30 update on the px-actions 2026-07-05 14:49:45 +03:00
7f19b3283c update regarding the complaint flow 2026-06-30 13:12:48 +03:00
438 changed files with 62042 additions and 22615 deletions

68
.env.bak_qa Normal file
View File

@ -0,0 +1,68 @@
# 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

View File

@ -2,6 +2,7 @@
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

View File

@ -11,6 +11,7 @@ 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) ---

View File

@ -10,6 +10,7 @@ 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
View File

@ -18,6 +18,10 @@ lib/
lib64/
parts/
sdist/
# Database backups
backups/*.dump
backups/*.tar.gz
var/
wheels/
pip-wheel-metadata/
@ -99,4 +103,5 @@ dump.rdb
.~lock.*
node_modules
data/Documents
data/Documents
deploy/target.dev-home.yaml

View File

@ -0,0 +1,17 @@
# 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)

View File

@ -0,0 +1,39 @@
- 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

View File

@ -0,0 +1,41 @@
- 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

View File

@ -0,0 +1,227 @@
- 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

View File

@ -0,0 +1,258 @@
- 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

View File

@ -0,0 +1,172 @@
- 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

View File

@ -0,0 +1,344 @@
- 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

View File

@ -0,0 +1,259 @@
- 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

View File

@ -0,0 +1,201 @@
- 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

View File

@ -0,0 +1,188 @@
- 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

View File

@ -0,0 +1,221 @@
- 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

View File

@ -0,0 +1,137 @@
- 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

View File

@ -0,0 +1,256 @@
- 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

View File

@ -0,0 +1,258 @@
- 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

View File

@ -0,0 +1,258 @@
- 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

View File

@ -0,0 +1,39 @@
- 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

View File

@ -0,0 +1,350 @@
- 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

View File

@ -0,0 +1,258 @@
- 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

View File

@ -0,0 +1,243 @@
- 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

View File

@ -0,0 +1,344 @@
- 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

View File

@ -0,0 +1,201 @@
- 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

View File

@ -0,0 +1,221 @@
- 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

View File

@ -0,0 +1,256 @@
- 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

View File

@ -0,0 +1,124 @@
- 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

View File

@ -0,0 +1,133 @@
- 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

View File

@ -0,0 +1,260 @@
- 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

View File

@ -0,0 +1,172 @@
- 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

View File

@ -0,0 +1,243 @@
- 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

View File

@ -0,0 +1,259 @@
- 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

View File

@ -0,0 +1,188 @@
- 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

View File

@ -0,0 +1,137 @@
- 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
.target-last-build Normal file
View File

@ -0,0 +1 @@
hh-dev:hh-dev-build-1784453317|1784453370

View File

@ -12,6 +12,8 @@ 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 ./
@ -19,14 +21,13 @@ COPY pyproject.toml ./
RUN pip install --upgrade pip setuptools wheel && \
pip install -e "."
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
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 npm run build:css
RUN python manage.py collectstatic --noinput || true
@ -43,6 +44,9 @@ 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
@ -51,14 +55,17 @@ 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
COPY --from=builder /app /app
# 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
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 Normal file
View File

@ -0,0 +1,41 @@
# 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

View File

@ -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/img/HH_R_H_Logo.png"
EMAIL_LOGO_URL = "http://127.0.0.1:8000/static/images/logos/HH_P_H_Logo_CMYK---LANDSCIP1.png"

View File

@ -1,308 +0,0 @@
"""
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'
})
)

236
apps/accounts/forms.py Normal file
View File

@ -0,0 +1,236 @@
"""
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

View File

@ -0,0 +1,221 @@
"""
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()

View File

@ -348,11 +348,14 @@ def kpi_report_regenerate(request, report_id):
@login_required
def kpi_report_pdf(request, report_id):
"""
Generate PDF version of KPI report
Generate and download a server-side PDF for a KPI report.
Returns HTML page with print-friendly styling and
html2pdf.js for client-side PDF generation.
Uses WeasyPrint + matplotlib for high-quality, consistent PDF output
without requiring browser JavaScript or external CDNs.
"""
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)
@ -362,55 +365,17 @@ 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")
# 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()
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)
# 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)
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
@login_required

View File

@ -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', '')}/analytics/command-center/",
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/",
}
html_content = render_to_string("emails/px_digest_weekly.html", context)

View File

@ -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', '')}/analytics/command-center/",
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/",
}
html_content = render_to_string("emails/px_digest_weekly.html", context)

View File

@ -7,8 +7,7 @@ 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, Value
from django.db.models.functions import Concat
from django.db.models import Avg, Count, F, Q
from django.http import JsonResponse
from django.shortcuts import render
@ -16,10 +15,8 @@ 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, KPIValue
from .services import UnifiedAnalyticsService, ExportService
from .models import KPI
from .services.ai_analytics import (
ExecutiveSummaryGenerator,
EarlyWarningSystem,
@ -923,498 +920,3 @@ 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)

View File

@ -1,4 +1,5 @@
from django.urls import path
from django.urls import path, reverse_lazy
from django.views.generic import RedirectView
from . import ui_views, kpi_views, ask_views
app_name = 'analytics'
@ -9,10 +10,8 @@ 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 - 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'),
# 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'),
# AI Analytics API
path('api/ai-analytics/refresh/', ui_views.refresh_ai_analytics, name='refresh_ai_analytics'),

View File

@ -0,0 +1,60 @@
"""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

View File

@ -0,0 +1,23 @@
# 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),
),
]

View File

@ -0,0 +1,21 @@
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),
]

View File

@ -0,0 +1,24 @@
# 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),
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -12,25 +12,29 @@ 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"""
"""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.
"""
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.AI_ANALYZED, AppreciationStatus.SENT},
AppreciationStatus.AI_ANALYZED: {AppreciationStatus.SENT},
AppreciationStatus.SENT: {AppreciationStatus.ACKNOWLEDGED},
AppreciationStatus.ACKNOWLEDGED: set(),
AppreciationStatus.ACTIVATED: {AppreciationStatus.SENT},
AppreciationStatus.SENT: set(), # terminal
}
@ -220,6 +224,14 @@ 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)
@ -245,6 +257,10 @@ 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):
@ -320,20 +336,16 @@ 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=["status", "ai_analyzed_at", "ai_analysis"])
self.save(update_fields=["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, AppreciationStatus.AI_ANALYZED):
raise ValueError(f"Cannot send appreciation in '{self.status}' status. Must be 'activated' or 'ai_analyzed'.")
if self.status not in (AppreciationStatus.ACTIVATED,):
raise ValueError(f"Cannot send appreciation in '{self.status}' status. Must be 'activated'.")
self.status = AppreciationStatus.SENT
self.sent_at = timezone.now()
@ -341,17 +353,6 @@ 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

202
apps/appreciation/tasks.py Normal file
View File

@ -0,0 +1,202 @@
"""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}")

View File

@ -0,0 +1,45 @@
"""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"})

View File

@ -0,0 +1,96 @@
"""
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)

View File

@ -41,14 +41,33 @@ def appreciation_list(request):
).order_by("-created_at")
user = request.user
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
pass
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)
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)
@ -61,18 +80,32 @@ def appreciation_list(request):
)
base_qs = Appreciation.objects.all()
if not user.is_px_admin() and user.hospital:
if user.is_px_admin():
if selected_hospital:
base_qs = base_qs.filter(hospital=selected_hospital)
elif user.is_hospital_admin() and user.hospital:
base_qs = base_qs.filter(hospital=user.hospital)
elif not user.is_px_admin():
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:
base_qs = base_qs.none()
stats = {
"total": base_qs.count(),
"draft": base_qs.filter(status=AppreciationStatus.DRAFT).count(),
"activated": base_qs.filter(
status__in=[AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED]
status=AppreciationStatus.ACTIVATED
).count(),
"sent": base_qs.filter(status__in=[AppreciationStatus.SENT, AppreciationStatus.ACKNOWLEDGED]).count(),
"sent": base_qs.filter(status=AppreciationStatus.SENT).count(),
}
paginator = Paginator(queryset, 25)
@ -117,27 +150,105 @@ 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": appreciation.status in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED),
"can_send": is_activated and appreciation.department_id is not None,
"has_recipient": has_recipient,
"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):
@ -156,33 +267,6 @@ 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
@ -236,12 +320,71 @@ 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 not in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED):
if appreciation.status != AppreciationStatus.ACTIVATED:
messages.error(request, _("This appreciation must be activated before sending."))
return redirect("appreciation:appreciation_detail", pk=pk)
@ -292,6 +435,167 @@ 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
# ============================================================================
@ -299,27 +603,91 @@ def appreciation_send(request, pk):
@login_required
@require_http_methods(["POST"])
def appreciation_acknowledge(request, pk):
"""Acknowledge appreciation"""
"""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."""
appreciation = get_object_or_404(Appreciation, pk=pk)
# Check if user is recipient
user_content_type = ContentType.objects.get_for_model(request.user)
user = request.user
if not (
appreciation.recipient_content_type == user_content_type and
appreciation.recipient_object_id == request.user.id
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You can only acknowledge appreciations sent to you.")
return redirect('appreciation:appreciation_detail', pk=pk)
messages.error(request, _("You don't have permission to send reminders."))
return redirect("appreciation:appreciation_detail", pk=pk)
if appreciation.status != AppreciationStatus.SENT:
messages.error(request, "This appreciation cannot be acknowledged in its current status.")
return redirect('appreciation:appreciation_detail', pk=pk)
messages.warning(request, _("Appreciation must be sent before sending reminders."))
return redirect("appreciation:appreciation_detail", pk=pk)
# Acknowledge
appreciation.acknowledge()
dept = appreciation.department
if not dept:
messages.error(request, _("No department assigned."))
return redirect("appreciation:appreciation_detail", pk=pk)
messages.success(request, "Appreciation acknowledged successfully.")
return redirect('appreciation:appreciation_detail', pk=pk)
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)
# ============================================================================
@ -347,8 +715,23 @@ def leaderboard_view(request):
queryset = AppreciationStats.objects.filter(year=year, month=month)
# Apply RBAC
if not user.is_px_admin() and user.hospital:
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.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Apply filters
hospital_filter = request.GET.get('hospital')
@ -483,7 +866,7 @@ def category_list(request):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to manage categories.")
return redirect('appreciation:appreciation_list')
@ -529,7 +912,7 @@ def category_create(request):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to create categories.")
return redirect('appreciation:appreciation_list')
@ -587,7 +970,7 @@ def category_edit(request, pk):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to edit categories.")
return redirect('appreciation:appreciation_list')
@ -632,7 +1015,7 @@ def category_delete(request, pk):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to delete categories.")
return redirect('appreciation:appreciation_list')
@ -667,7 +1050,7 @@ def badge_list(request):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to manage badges.")
return redirect('appreciation:appreciation_list')
@ -713,7 +1096,7 @@ def badge_create(request):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to create badges.")
return redirect('appreciation:appreciation_list')
@ -775,7 +1158,7 @@ def badge_edit(request, pk):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to edit badges.")
return redirect('appreciation:appreciation_list')
@ -822,7 +1205,7 @@ def badge_delete(request, pk):
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to delete badges.")
return redirect('appreciation:appreciation_list')
@ -853,7 +1236,7 @@ def appreciation_restore(request, pk):
"""Restore deleted appreciation"""
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to restore appreciation.")
return redirect('config:deleted_items')
@ -1038,6 +1421,16 @@ 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)
@ -1086,3 +1479,144 @@ 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",
)

View File

@ -31,10 +31,12 @@ 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('acknowledge/<uuid:pk>/', ui_views.appreciation_acknowledge, name='appreciation_acknowledge'),
path('detail/<uuid:pk>/send-to/', ui_views.appreciation_send_to, name='appreciation_send_to'),
path('leaderboard/', ui_views.leaderboard_view, name='leaderboard_view'),
path('badges/', ui_views.my_badges_view, name='my_badges_view'),
@ -62,4 +64,7 @@ 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'),
]

View File

@ -194,7 +194,9 @@ class AppreciationViewSet(viewsets.ModelViewSet):
is_anonymous=data["is_anonymous"],
)
# Send appreciation
# Activate + send. activate() honors the activation gate (DRAFT ->
# ACTIVATED) and records the activator; send() then moves to SENT.
appreciation.activate(activated_by=request.user)
appreciation.send()
try:
@ -209,25 +211,15 @@ class AppreciationViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def acknowledge(self, request, pk=None):
"""Acknowledge an appreciation"""
appreciation = self.get_object()
"""DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
# 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)
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,
)
@action(detail=False, methods=["get"])
def my_appreciations(self, request):
@ -396,7 +388,7 @@ class AppreciationBadgeViewSet(viewsets.ModelViewSet):
queryset = AppreciationBadge.objects.all()
serializer_class = AppreciationBadgeSerializer
permission_classes = [IsAuthenticated, IsPXAdminOrHospitalAdmin]
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Filter badges by hospital"""

View File

@ -15,7 +15,7 @@ from .models import (
ComplaintSLAConfig,
ComplaintThreshold,
ComplaintUpdate,
EscalationRule,
Inquiry,
ExplanationSLAConfig,
ComplaintInvolvedDepartment,
@ -35,10 +35,9 @@ 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", "auto_escalate_enabled")
list_filter = ("is_active", "second_reminder_enabled")
fieldsets = (
(
None,
@ -53,7 +52,6 @@ class ExplanationSLAConfigAdmin(admin.ModelAdmin):
)
},
),
("Escalation", {"fields": ("auto_escalate_enabled", "escalation_hours_overdue", "max_escalation_levels")}),
)
@ -450,6 +448,8 @@ 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,15 +473,19 @@ 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", "created_at", "updated_at"]
readonly_fields = ["responded_at", "resolved_at", "resolved_by", "closed_at", "closed_by", "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", "created_by")
return qs.select_related(
"patient", "hospital", "department", "assigned_to",
"responded_by", "resolved_by", "closed_by", "created_by",
)
def subject_preview(self, obj):
"""Show preview of subject"""
@ -506,7 +510,7 @@ class ComplaintSLAConfigAdmin(admin.ModelAdmin):
(
"Source-Based Timing (Hours After Creation)",
{
"fields": ("first_reminder_hours_after", "second_reminder_hours_after", "escalation_hours_after"),
"fields": ("first_reminder_hours_after", "second_reminder_hours_after"),
"description": 'When set, these override the "Hours Before Deadline" timing. Used for source-based SLAs (e.g., MOH, CCHI).',
},
),
@ -576,39 +580,6 @@ 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"""

View File

@ -19,7 +19,6 @@ from apps.complaints.models import (
ComplaintType,
Inquiry,
ComplaintSLAConfig,
EscalationRule,
ComplaintThreshold,
ComplaintInvolvedDepartment,
ComplaintInvolvedStaff,
@ -27,6 +26,7 @@ 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,9 +78,7 @@ class PublicComplaintForm(forms.ModelForm):
label=_("Relation to Patient"),
choices=[
("patient", "Patient"),
("relative", "Relative"),
("friend", "Friend"),
("other", "Other"),
("relative", "Relative")
],
required=True,
widget=forms.Select(attrs={"class": "form-control"}),
@ -96,7 +94,12 @@ class PublicComplaintForm(forms.ModelForm):
label=_("Mobile Number"),
max_length=20,
required=True,
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Your mobile number")}),
widget=forms.TextInput(attrs={
"class": "form-control",
"placeholder": _("05XXXXXXXX or +9665XXXXXXXX"),
"pattern": SAUDI_PHONE_HTML_PATTERN,
"inputmode": "tel",
}),
)
# Patient Information
@ -283,16 +286,10 @@ class PublicComplaintForm(forms.ModelForm):
).order_by("name_en")
def clean_mobile_number(self):
"""Validate mobile number format"""
"""Validate Saudi mobile number format (05xxxxxxxx / 5xxxxxxxx / +9665xxxxxxxx / 9665xxxxxxxx)."""
mobile_number = self.cleaned_data.get("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)"))
if mobile_number:
validate_saudi_phone(mobile_number)
return mobile_number
def clean_national_id(self):
@ -714,6 +711,7 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
"contact_name",
"contact_phone",
"contact_email",
"national_id",
"section",
"priority",
"source",
@ -766,6 +764,24 @@ 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"""
@ -776,12 +792,8 @@ 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",
]
@ -789,12 +801,8 @@ 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"}),
}
@ -804,31 +812,25 @@ 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
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 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 second reminder hours
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"})
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"})
# Check for unique combination (excluding current instance when editing)
filters = {}
@ -838,8 +840,6 @@ 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,84 +851,6 @@ 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.
@ -966,7 +888,13 @@ class PublicInquiryForm(forms.Form):
label=_("Phone Number"),
max_length=20,
required=True,
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Your phone number")}),
validators=[validate_saudi_phone],
widget=forms.TextInput(attrs={
"class": "form-control",
"placeholder": _("05XXXXXXXX or +9665XXXXXXXX"),
"pattern": SAUDI_PHONE_HTML_PATTERN,
"inputmode": "tel",
}),
)
email = forms.EmailField(
@ -975,6 +903,13 @@ 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"),

View File

@ -0,0 +1,213 @@
"""
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."))

View File

@ -0,0 +1,153 @@
"""
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}")

View File

@ -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,8 +30,7 @@ SOURCE_CODE_MAP = {
"social media": "SOCIAL-MEDIA",
"social-media": "SOCIAL-MEDIA",
"public form": "PUBL-FORM",
"insurance company": None,
"شركة تأمين": None,
"insurance company": "INSURANCE",
}
@ -70,13 +69,13 @@ def resolve_px_source(source_value: str) -> "PXSource | None":
return None
EXTERNAL_SOURCE_CODES = {"MOH", "CCHI", "CHI"}
EXTERNAL_SOURCE_CODES = {"MOH", "CHI", "INSURANCE"}
def get_complaint_source_type(px_source) -> str:
"""
Determine complaint_source_type based on PXSource.
Only MOH/CHI/CCHI are external; everything else is internal.
Only MOH/CHI/INSURANCE are external; everything else is internal.
"""
if px_source and px_source.code in EXTERNAL_SOURCE_CODES:
return "external"

View File

@ -0,0 +1,205 @@
"""
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}")

View File

@ -298,6 +298,7 @@ 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,

View File

@ -22,7 +22,7 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
# Full SHCT Data Structure: (EN, AR)
shct_data = {
('CLINICAL', 'سريري'): {
('Clinical Care', 'سريري'): {
('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,12 +171,16 @@ class Command(BaseCommand):
total_created = 0
for (dom_en, dom_ar), categories in shct_data.items():
# Level 1: Domain
# 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.
domain, created = ComplaintCategory.objects.get_or_create(
name_en=dom_en,
name_ar=dom_ar,
level=ComplaintCategory.LevelChoices.DOMAIN,
domain_type=dom_en.upper()
defaults={
"name_ar": dom_ar,
"domain_type": dom_en.upper(),
},
)
if created:
domains_created += 1

View File

@ -8,14 +8,13 @@ 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, escalation rules)'
help = 'Seed default complaint configurations (categories, SLA configs, thresholds)'
def add_arguments(self, parser):
parser.add_argument(
@ -47,7 +46,6 @@ 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!'))
@ -202,58 +200,6 @@ 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...")

View File

@ -0,0 +1,25 @@
# 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'),
),
]

View File

@ -0,0 +1,19 @@
# 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'),
),
]

View File

@ -0,0 +1,54 @@
# 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'],
},
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -0,0 +1,23 @@
# 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),
),
]

View File

@ -0,0 +1,33 @@
# 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),
),
]

View File

@ -0,0 +1,23 @@
# 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),
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -0,0 +1,26 @@
# 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),
),
]

View File

@ -0,0 +1,21 @@
# 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),
),
]

View File

@ -0,0 +1,23 @@
# 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),
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -0,0 +1,21 @@
# 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',
),
]

View File

@ -0,0 +1,57 @@
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",
),
),
]

View File

@ -0,0 +1,18 @@
# 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'),
),
]

View File

@ -0,0 +1,20 @@
# 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',
),
]

View File

@ -0,0 +1,37 @@
# 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',
),
]

View File

@ -0,0 +1,29 @@
# 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',
),
]

View File

@ -0,0 +1,18 @@
# 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'),
),
]

View File

@ -0,0 +1,23 @@
# 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),
),
]

View File

@ -0,0 +1,26 @@
# 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),
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -0,0 +1,24 @@
# 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]),
),
]

View File

@ -0,0 +1,24 @@
# 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),
),
]

View File

@ -16,9 +16,10 @@ 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.models import PriorityChoices, SeverityChoices, SoftDeleteModel, TenantModel, TimeStampedModel, UUIDModel
from apps.core.validators import validate_saudi_phone
from apps.organizations.models import LocationType
@ -213,7 +214,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)
contact_phone = models.CharField(max_length=20, blank=True, validators=[validate_saudi_phone])
contact_email = models.EmailField(blank=True)
# Public complaint form fields
@ -221,12 +222,10 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
max_length=20,
choices=[
("patient", _("Patient")),
("relative", _("Relative")),
("friend", _("Friend")),
("other", _("Other")),
("relative", _("Relative"))
],
blank=True,
verbose_name=_("Relation to Patient"),
verbose_name=_("Complinant"),
help_text="Complainant's relationship to the patient",
)
@ -272,6 +271,24 @@ 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,
@ -571,6 +588,45 @@ 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")
@ -752,7 +808,6 @@ 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
@ -760,10 +815,10 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
except ComplaintSLAConfig.DoesNotExist:
pass # Fall through to next option
# Try severity/priority-based config without source filter (backward compatibility)
# Try severity-based config without source filter (backward compatibility)
try:
sla_config = ComplaintSLAConfig.objects.get(
hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True
hospital=self.hospital, severity=self.severity, is_active=True
)
sla_hours = sla_config.sla_hours
return timezone.now() + timedelta(hours=sla_hours)
@ -795,16 +850,15 @@ 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/priority-based config without source filter (backward compatibility)
# Try severity-based config without source filter (backward compatibility)
try:
return ComplaintSLAConfig.objects.get(
hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True
hospital=self.hospital, severity=self.severity, is_active=True
)
except ComplaintSLAConfig.DoesNotExist:
pass # No config found
@ -838,6 +892,22 @@ 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:
@ -1052,16 +1122,11 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
"""
Get the public tracking URL for this complaint.
"""
from django.contrib.sites.shortcuts import get_current_site
from django.conf import settings
from django.urls import reverse
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}"
base = settings.SITE_URL.rstrip("/")
return f"{base}{reverse('complaints:public_complaint_track')}?reference={self.reference_number}"
class ComplaintAttachment(UUIDModel, TimeStampedModel):
@ -1240,112 +1305,6 @@ 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):
@ -1581,8 +1540,10 @@ 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)
contact_phone = models.CharField(max_length=20, blank=True, validators=[validate_saudi_phone])
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")
@ -1741,6 +1702,7 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
("in_progress", _("In Progress")),
("resolved", _("Resolved")),
("closed", _("Closed")),
("contact_no_response", _("Contacted - No Response")),
],
default="open",
db_index=True,
@ -1790,6 +1752,28 @@ 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)
@ -1810,13 +1794,6 @@ 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)
@ -1907,32 +1884,6 @@ 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
@ -2031,6 +1982,27 @@ 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
@ -2056,11 +2028,27 @@ 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
@ -2349,43 +2337,6 @@ 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"
@ -2775,6 +2726,37 @@ 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"
@ -2815,6 +2797,17 @@ 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):
"""
@ -3761,6 +3754,7 @@ 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):
@ -3791,11 +3785,17 @@ class ChampionInvestigation(UUIDModel, TimeStampedModel):
related_name="investigation",
)
status = models.CharField(
max_length=20,
max_length=30,
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,7 +3816,20 @@ 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:
@ -3873,3 +3886,57 @@ 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}"

View File

@ -7,7 +7,13 @@ 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, ComplaintStatus, ComplaintUpdate
from apps.complaints.models import (
Complaint,
ComplaintExplanation,
ComplaintInvolvedDepartment,
ComplaintStatus,
ComplaintUpdate,
)
logger = logging.getLogger(__name__)
@ -75,14 +81,16 @@ class ComplaintService:
return True
if user.is_hospital_admin() and user.hospital == complaint.hospital:
return True
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
# 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 user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists():
return True
return False
@ -117,12 +125,21 @@ 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
complaint.activated_at = timezone.now()
complaint.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"])
update_fields = ["assigned_to", "assigned_at", "status"]
else:
complaint.save(update_fields=["assigned_to", "assigned_at"])
update_fields = ["assigned_to", "assigned_at"]
if first_activation:
update_fields.append("activated_at")
complaint.save(update_fields=update_fields)
assign_message = f"Complaint activated and assigned to {user.get_full_name()}"
if previous_assignee:
@ -195,6 +212,14 @@ 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
@ -379,6 +404,10 @@ 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
@ -927,7 +956,19 @@ 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()
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",
])
metadata = {
"champion_count": champion_count,
@ -980,24 +1021,25 @@ This is an automated message from PX360 Complaint Management System."""
@staticmethod
def ensure_involved_records(complaint):
"""Ensure the complaint's primary department and staff exist as involved records.
"""Ensure the complaint's primary staff exists as an involved record.
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 ComplaintInvolvedDepartment, ComplaintInvolvedStaff
from apps.complaints.models import ComplaintInvolvedStaff
if complaint.department_id:
ComplaintInvolvedDepartment.objects.get_or_create(
complaint=complaint,
department=complaint.department,
defaults={
"role": "primary",
"is_primary": True,
},
)
if complaint.staff_id:
if complaint.staff_id and complaint.primary_staff_involved_removed_id != complaint.staff_id:
ComplaintInvolvedStaff.objects.get_or_create(
complaint=complaint,
staff=complaint.staff,
@ -1044,3 +1086,139 @@ 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)

View File

@ -1,18 +1,19 @@
"""
Complaint signals - Automatic SMS notifications on status changes
Complaint signals.
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
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).
"""
import logging
from django.db.models.signals import pre_save, post_save
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.contrib.sites.shortcuts import get_current_site
from .models import Complaint, ComplaintUpdate, ComplaintInvolvedDepartment
from .models import Complaint, ComplaintInvolvedDepartment, ComplaintUpdate
logger = logging.getLogger(__name__)
@ -21,12 +22,11 @@ logger = logging.getLogger(__name__)
def sync_department_from_staff(sender, instance, **kwargs):
"""
Automatically set complaint.department from staff.department when staff is assigned.
This ensures the department is always in sync with the assigned staff member,
regardless of how the complaint is saved (API, admin, forms, etc.).
Ensures the department stays 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,200 +35,66 @@ def sync_department_from_staff(sender, instance, **kwargs):
f"from staff '{instance.staff.name}'"
)
elif instance.pk:
# 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
# Staff being removed (set to None); department kept as-is.
pass
@receiver(post_save, sender=Complaint)
def send_complaint_creation_sms(sender, instance, created, **kwargs):
"""
Send SMS notification when complaint is created.
Only sends for public complaints (those with contact_phone).
"""
"""Dispatch the complaint-received SMS/email task when a complaint is created."""
if not created:
return
# 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.")
if not instance.contact_phone and not instance.contact_email:
logger.info(f"Complaint #{instance.id} created but no phone/email provided. Skipping notification.")
return
# Send SMS notification
try:
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
}
)
from .tasks import send_complaint_creation_sms_task
send_complaint_creation_sms_task.delay(str(instance.id))
except Exception as e:
# Log error but don't fail the complaint save
logger.error(f"Failed to send creation SMS for complaint #{instance.id}: {str(e)}")
logger.error(f"Failed to dispatch creation SMS for complaint #{instance.id}: {e}")
@receiver(post_save, sender=Complaint)
def send_complaint_status_change_sms(sender, instance, created, **kwargs):
"""
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)
"""Dispatch SMS/email when a complaint status changes to resolved or closed."""
# Skip on creation (handled by the creation signal)
if created:
return
# 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'):
# Detect an actual status change using the in-memory previous-status set in Complaint.save()
if not hasattr(instance, "_status_was"):
return
old_status = instance._status_was
new_status = instance.status
# Only send SMS for resolved or closed status changes
if new_status not in ['resolved', 'closed']:
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.")
return
# Send SMS + email notification
try:
from apps.notifications.services import NotificationService, get_email_header_html
from apps.core.utils import build_public_track_url
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,
}
logger.info(
f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification."
)
return
try:
from .tasks import send_complaint_status_change_task
send_complaint_status_change_task.delay(str(instance.id), old_status, new_status)
except Exception as e:
# Log error but don't fail the complaint save
logger.error(f"Failed to send status change SMS for complaint #{instance.id}: {str(e)}")
logger.error(f"Failed to dispatch status change notification for complaint #{instance.id}: {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.
This ensures that SMS sent via API endpoints (like send_resolution_notification)
are also properly tracked.
"""
"""Track manually sent SMS notifications recorded as ComplaintUpdate rows."""
if not created:
return
# Check if this update was for a communication/notification
if instance.update_type == 'communication':
# Log tracking info
if instance.update_type == "communication":
logger.info(
f"Manual communication update created for complaint #{instance.complaint.id}: "
f"{instance.message[:50]}..."
@ -237,18 +103,16 @@ def track_manual_sms(sender, instance, created, **kwargs):
@receiver(post_save, sender=ComplaintInvolvedDepartment)
def notify_champion_on_department_assignment(sender, instance, created, **kwargs):
"""
Send email notification to department champion when a complaint is assigned to their department.
"""
"""Dispatch the champion-notification email when a complaint is sent to a department."""
if not created:
return
# Only notify when this department is actually being sent to (not just added to involvement list)
# Only notify when this department is actually being sent to (not just added)
if not instance.sent:
return
# 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:
# 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:
logger.info(
f"ComplaintInvolvedDepartment #{instance.id}: No respondent email configured for department "
f"'{instance.department.name}'. Skipping notification."
@ -256,63 +120,8 @@ def notify_champion_on_department_assignment(sender, instance, created, **kwargs
return
try:
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}"
)
from .tasks import notify_champion_on_dept_assignment_task
notify_champion_on_dept_assignment_task.delay(str(instance.id))
except Exception as e:
logger.error(f"Failed to send champion notification for ComplaintInvolvedDepartment #{instance.id}: {str(e)}")
logger.error(f"Failed to dispatch champion notification for ComplaintInvolvedDepartment #{instance.id}: {e}")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,148 @@
"""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"

View File

@ -194,3 +194,241 @@ 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)

View File

@ -0,0 +1,260 @@
"""
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