more update

This commit is contained in:
ismail 2026-07-05 21:34:34 +03:00
parent 5dc61c8e30
commit d63ed6f956
16 changed files with 3259 additions and 2214 deletions

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

@ -226,7 +226,7 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
("other", _("Other")),
],
blank=True,
verbose_name=_("Relation to Patient"),
verbose_name=_("Complinant"),
help_text="Complainant's relationship to the patient",
)

View File

@ -0,0 +1,80 @@
# Generated by Django 6.0.1 on 2026-07-05 13:33
import apps.core.encryption
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('organizations', '0015_hospital_branding'),
]
operations = [
migrations.AlterField(
model_name='patient',
name='address',
field=models.TextField(blank=True, verbose_name='Address'),
),
migrations.AlterField(
model_name='patient',
name='city',
field=models.CharField(blank=True, max_length=100, verbose_name='City'),
),
migrations.AlterField(
model_name='patient',
name='date_of_birth',
field=models.DateField(blank=True, null=True, verbose_name='Date of Birth'),
),
migrations.AlterField(
model_name='patient',
name='email',
field=models.EmailField(blank=True, max_length=254, verbose_name='Email'),
),
migrations.AlterField(
model_name='patient',
name='first_name',
field=models.CharField(max_length=100, verbose_name='First Name'),
),
migrations.AlterField(
model_name='patient',
name='first_name_ar',
field=models.CharField(blank=True, max_length=100, verbose_name='First Name (Arabic)'),
),
migrations.AlterField(
model_name='patient',
name='gender',
field=models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], max_length=10, verbose_name='Gender'),
),
migrations.AlterField(
model_name='patient',
name='last_name',
field=models.CharField(max_length=100, verbose_name='Last Name'),
),
migrations.AlterField(
model_name='patient',
name='last_name_ar',
field=models.CharField(blank=True, max_length=100, verbose_name='Last Name (Arabic)'),
),
migrations.AlterField(
model_name='patient',
name='national_id',
field=apps.core.encryption.EncryptedCharField(blank=True, default='', max_length=256, verbose_name='National ID'),
),
migrations.AlterField(
model_name='patient',
name='phone',
field=models.CharField(blank=True, max_length=20, verbose_name='Phone'),
),
migrations.AlterField(
model_name='patient',
name='primary_hospital',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patients', to='organizations.hospital', verbose_name='Primary Hospital'),
),
migrations.AlterField(
model_name='patient',
name='status',
field=models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20, verbose_name='Status'),
),
]

View File

@ -559,35 +559,37 @@ class Patient(UUIDModel, TimeStampedModel):
"""Patient model"""
# Basic information
mrn = models.CharField(max_length=50, unique=True, verbose_name="Medical Record Number")
national_id = EncryptedCharField(max_length=256, blank=True, default="")
mrn = models.CharField(max_length=50, unique=True, verbose_name=_("Medical Record Number"))
national_id = EncryptedCharField(max_length=256, blank=True, default="", verbose_name=_("National ID"))
national_id_hash = models.CharField(max_length=64, blank=True, db_index=True, default="")
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
first_name_ar = models.CharField(max_length=100, blank=True)
last_name_ar = models.CharField(max_length=100, blank=True)
first_name = models.CharField(max_length=100, verbose_name=_("First Name"))
last_name = models.CharField(max_length=100, verbose_name=_("Last Name"))
first_name_ar = models.CharField(max_length=100, blank=True, verbose_name=_("First Name (Arabic)"))
last_name_ar = models.CharField(max_length=100, blank=True, verbose_name=_("Last Name (Arabic)"))
# Demographics
date_of_birth = models.DateField(null=True, blank=True)
date_of_birth = models.DateField(null=True, blank=True, verbose_name=_("Date of Birth"))
gender = models.CharField(
max_length=10, choices=[("male", _("Male")), ("female", _("Female")), ("other", _("Other"))], blank=True
max_length=10, choices=[("male", _("Male")), ("female", _("Female")), ("other", _("Other"))], blank=True,
verbose_name=_("Gender"),
)
nationality = models.CharField(max_length=100, blank=True, db_index=True)
# Contact
phone = models.CharField(max_length=20, blank=True)
email = models.EmailField(blank=True)
address = models.TextField(blank=True)
city = models.CharField(max_length=100, blank=True)
phone = models.CharField(max_length=20, blank=True, verbose_name=_("Phone"))
email = models.EmailField(blank=True, verbose_name=_("Email"))
address = models.TextField(blank=True, verbose_name=_("Address"))
city = models.CharField(max_length=100, blank=True, verbose_name=_("City"))
# Primary hospital
primary_hospital = models.ForeignKey(
Hospital, on_delete=models.SET_NULL, null=True, blank=True, related_name="patients"
Hospital, on_delete=models.SET_NULL, null=True, blank=True, related_name="patients",
verbose_name=_("Primary Hospital"),
)
# Status
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True)
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True, verbose_name=_("Status"))
class Meta:
ordering = ["last_name", "first_name"]

View File

@ -91,6 +91,7 @@ class DepartmentSerializer(serializers.ModelSerializer):
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
parent_name = serializers.CharField(source="parent.name", read_only=True)
manager_name = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
class Meta:
model = Department
@ -101,6 +102,7 @@ class DepartmentSerializer(serializers.ModelSerializer):
"name",
"name_en",
"name_ar",
"display_name",
"code",
"category",
"parent",
@ -124,6 +126,10 @@ class DepartmentSerializer(serializers.ModelSerializer):
return obj.manager.get_full_name()
return None
def get_display_name(self, obj):
"""Localized name respecting the active language (set by LocaleMiddleware)."""
return obj.get_localized_name()
class StaffSerializer(serializers.ModelSerializer):
"""Staff serializer"""

View File

@ -2165,6 +2165,36 @@ def department_detail(request, pk):
"existing_ar": inq.department_response_ar or "",
})
# Merge routed complaints into pending_actions (unified table)
_existing_complaint_ids = {
str(a.get("complaint_id")) for a in pending_actions if a.get("complaint_id")
}
pending_routing_involvements = list(
ComplaintInvolvedDepartment.objects.filter(
department=department,
sent=True,
response_submitted=False,
routing_status="sent",
).select_related("complaint", "complaint__department", "department").order_by("-sent_at")[:10]
)
for inv in pending_routing_involvements:
if str(inv.complaint_id) not in _existing_complaint_ids:
pending_actions.append({
"type": "complaint_department_response",
"type_label": _("Complaint Response"),
"reference": inv.complaint.reference_number or str(inv.complaint.id),
"subject": inv.complaint.title or _("No title"),
"complaint_id": str(inv.complaint_id),
"item_id": str(inv.id),
"routing_inv_id": str(inv.id),
"sla_due_at": None,
"is_overdue": False,
"url": "#",
"explanation_url": "#",
"badge_color": "blue",
})
_existing_complaint_ids.add(str(inv.complaint_id))
# Standards for this department (global + department-specific)
from apps.standards.models import Standard, StandardCompliance
from django.db.models import Count
@ -2248,12 +2278,7 @@ def department_detail(request, pk):
status__in=["open", "in_progress"],
involved_departments__department=department,
).distinct().select_related("department", "assigned_to")[:5],
"pending_routing_involvements": ComplaintInvolvedDepartment.objects.filter(
department=department,
sent=True,
response_submitted=False,
routing_status="sent",
).select_related("complaint", "complaint__department", "department").order_by("-sent_at")[:10],
"pending_routing_involvements": pending_routing_involvements,
"hospital_departments": (
Department.objects.filter(hospital=department.hospital, status="active")
.filter(Q(champion__isnull=False) | Q(manager__isnull=False))

View File

@ -398,6 +398,8 @@ class ConvertToProjectForm(forms.Form):
self.action = kwargs.pop("action", None)
super().__init__(*args, **kwargs)
from apps.organizations.models import Staff
if self.user and self.user.hospital:
# Filter templates by hospital (or global)
from django.db.models import Q
@ -408,7 +410,6 @@ class ConvertToProjectForm(forms.Form):
).order_by("name")
# Filter project lead by hospital
from apps.organizations.models import Staff
self.fields["project_lead"].queryset = Staff.objects.filter(
hospital=self.user.hospital, status="active"
).order_by("first_name", "last_name")

View File

@ -0,0 +1,28 @@
# Generated by Django 6.0.1 on 2026-07-05 14:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reports', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='generatedreport',
name='data_source',
field=models.CharField(choices=[('complaints', 'Complaints'), ('inquiries', 'Inquiries'), ('observations', 'Observations'), ('px_actions', 'PX Actions'), ('surveys', 'Surveys'), ('physicians', 'Physician Ratings'), ('staff', 'Staff'), ('department', 'Departments'), ('suggestion', 'Suggestions'), ('appreciation', 'Appreciations'), ('patient', 'Patients')], max_length=50),
),
migrations.AlterField(
model_name='reporttemplate',
name='data_source',
field=models.CharField(choices=[('complaints', 'Complaints'), ('inquiries', 'Inquiries'), ('observations', 'Observations'), ('px_actions', 'PX Actions'), ('surveys', 'Surveys'), ('physicians', 'Physician Ratings'), ('staff', 'Staff'), ('department', 'Departments'), ('suggestion', 'Suggestions'), ('appreciation', 'Appreciations'), ('patient', 'Patients')], default='complaints', max_length=50),
),
migrations.AlterField(
model_name='savedreport',
name='data_source',
field=models.CharField(choices=[('complaints', 'Complaints'), ('inquiries', 'Inquiries'), ('observations', 'Observations'), ('px_actions', 'PX Actions'), ('surveys', 'Surveys'), ('physicians', 'Physician Ratings'), ('staff', 'Staff'), ('department', 'Departments'), ('suggestion', 'Suggestions'), ('appreciation', 'Appreciations'), ('patient', 'Patients')], default='complaints', max_length=50),
),
]

BIN
data/visit_data.json.zip Normal file

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -290,7 +290,7 @@
{% endif %}
<div class="mt-3 flex items-center justify-end gap-2 {% if not dept_response_progress.pending %}ml-12{% endif %}">
<span class="text-xs text-amber-600">{% trans "Don't want to wait?" %}</span>
<button type="button" onclick="switchTab('resolution')"
<button type="button" onclick="switchTab('resolution', 'resolutionForm')"
class="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition">
{% trans "Proceed to Resolve" %}
</button>
@ -313,7 +313,7 @@
</p>
</div>
</div>
<button type="button" onclick="switchTab('resolution')" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap shrink-0">{% trans "Generate Resolution" %}</button>
<button type="button" onclick="switchTab('resolution', 'resolutionForm')" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap shrink-0">{% trans "Generate Resolution" %}</button>
</div>
{% endif %}
{% elif workflow_steps.resolved %}
@ -759,7 +759,7 @@
</section>
</div>
{% if can_manage_actions and complaint.is_active_status %}
{% comment %} {% if can_manage_actions and complaint.is_active_status %}
<div class="flex items-center gap-3">
<button type="button" onclick="openSendToDeptModal()"
class="flex-1 px-4 py-2.5 bg-navy text-white font-bold text-sm rounded-xl flex items-center justify-center gap-2 hover:bg-blue transition">
@ -776,7 +776,7 @@
{% trans "PDF (Letterhead)" %}
</a>
</div>
{% endif %}
{% endif %} {% endcomment %}
{% include "complaints/partials/workflow_timeline.html" %}
</div>

View File

@ -237,7 +237,7 @@
<div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div>
<div><p class="text-sm font-bold text-navy">{% trans "Resolve this complaint" %}</p><p class="text-xs text-slate">{% trans "The department has responded. Review and close the complaint." %}</p></div>
</div>
<button type="button" onclick="switchTab('resolution')" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap shrink-0">{% trans "Generate Resolution" %}</button>
<button type="button" onclick="switchTab('resolution', 'resolutionForm')" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap shrink-0">{% trans "Generate Resolution" %}</button>
</div>
{% endif %}
{% elif workflow_steps.resolved %}

View File

@ -823,7 +823,7 @@ document.addEventListener('DOMContentLoaded', function() {
(data || []).forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name_en || dept.name;
option.textContent = dept.display_name || dept.name_en || dept.name;
departmentSelect.appendChild(option);
});
})
@ -860,7 +860,7 @@ document.addEventListener('DOMContentLoaded', function() {
(data || []).forEach(sec => {
const opt = document.createElement('option');
opt.value = sec.id;
opt.textContent = sec.name_en || sec.name;
opt.textContent = sec.display_name || sec.name_en || sec.name;
sectionSelect.appendChild(opt);
});
})

View File

@ -180,7 +180,7 @@
{% if can_manage_actions and complaint.is_active_status %}
{% if complaint.assigned_to == current_user or can_manage_actions %}
<form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}" id="resolutionForm">
<form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}" id="resolutionForm" class="scroll-mt-28">
{% csrf_token %}
<input type="hidden" name="status" value="resolved">

View File

@ -182,6 +182,7 @@
{% endif %}
</td>
<td class="px-4 py-2 text-center">
<div class="flex items-center justify-center gap-1.5">
{% if action.type == 'complaint_department_response' %}
{% if action.explanation_url and action.explanation_url != '#' %}
<a href="{{ action.explanation_url }}"
@ -197,6 +198,12 @@
{% trans "Respond" %}
</button>
{% endif %}
{% if action.routing_inv_id %}
<button type="button" onclick="toggleRejectRouting('{{ action.routing_inv_id }}')"
class="inline-flex items-center gap-1 px-2 py-1 border-2 border-red-200 text-red-600 rounded-lg text-xs font-semibold hover:border-red-400 hover:bg-red-50 transition">
<i data-lucide="log-out" class="w-3 h-3"></i> {% trans "Wrong Dept" %}
</button>
{% endif %}
{% elif action.type == 'observation_response' %}
<button type="button"
onclick="openDeptResponseModal('observation', '{{ action.item_id }}', '{{ action.url }}', '{{ action.reference }}', '{{ action.subject|escapejs }}', '{{ action.existing_en|escapejs }}', '{{ action.existing_ar|escapejs }}')"
@ -218,6 +225,7 @@
{% trans "Respond" %}
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
@ -232,6 +240,47 @@
</div>
{% endif %}
{% if can_respond and pending_routing_involvements %}
<!-- Reject routing forms (for "Wrong Dept" buttons in the Pending Actions table) -->
{% for inv in pending_routing_involvements %}
<form method="post" action="{% url 'complaints:involved_department_reject_routing' pk=inv.pk %}" id="reject-routing-{{ inv.pk }}" class="hidden mb-4 p-4 bg-white border-2 border-red-200 rounded-xl space-y-3">
{% csrf_token %}
<div class="flex items-center gap-2 mb-2">
<i data-lucide="log-out" class="w-4 h-4 text-red-500"></i>
<span class="font-bold text-navy text-sm">{{ inv.complaint.reference_number }} — {% trans "Reject Routing" %}</span>
</div>
<div>
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Reason" %} <span class="text-red-500">*</span></label>
<textarea name="rejection_reason" rows="2" required
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy resize-none"
placeholder="{% trans 'Why does this not belong to your department?' %}"></textarea>
</div>
{% if hospital_departments %}
<div>
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Suggest Correct Department (optional)" %}</label>
<select name="suggested_department_id" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy bg-white">
<option value="">{% trans "Select Department" %}</option>
{% for dept in hospital_departments %}
<option value="{{ dept.id }}">{{ dept.get_localized_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-2">
<button type="button" onclick="toggleRejectRouting('{{ inv.pk }}')"
class="px-3 py-1.5 border-2 border-slate-200 text-slate-600 rounded-lg text-xs font-semibold hover:border-slate-400 transition">{% trans "Cancel" %}</button>
<button type="submit" class="px-3 py-1.5 bg-red-500 text-white rounded-lg text-xs font-semibold hover:bg-red-600 transition">{% trans "Reject Routing" %}</button>
</div>
</form>
{% endfor %}
<script>
function toggleRejectRouting(pk) {
const form = document.getElementById('reject-routing-' + pk);
if (form) form.classList.toggle('hidden');
}
</script>
{% endif %}
{% if active_investigations and can_respond %}
<div class="bg-white rounded-xl shadow-sm border border-blue-200 p-5 mb-6">
<div class="flex items-center gap-3 mb-4">
@ -383,85 +432,6 @@
</div>
{% endif %}
{% if can_respond and pending_routing_involvements %}
<div class="bg-white rounded-xl shadow-sm border-2 border-blue-200 p-5 mb-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<i data-lucide="inbox" class="w-5 h-5 text-blue-600"></i>
</div>
<div>
<h3 class="text-sm font-bold text-navy">{% trans "Incoming Complaints" %}</h3>
<p class="text-xs text-slate-400">{% trans "Complaints routed to your department awaiting your response" %}</p>
</div>
</div>
<div class="space-y-3">
{% for inv in pending_routing_involvements %}
<div class="border border-slate-200 rounded-xl p-4 bg-slate-50/30">
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0">
<a href="{% url 'complaints:complaint_detail' pk=inv.complaint.pk %}" class="font-mono text-xs font-bold text-navy hover:underline">
{{ inv.complaint.reference_number }}
</a>
<p class="text-sm text-slate mt-0.5">{{ inv.complaint.title|truncatechars:60 }}</p>
</div>
{% if inv.is_primary %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-navy/10 text-navy">{% trans "Primary" %}</span>
{% else %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-slate-100 text-slate-600">{{ inv.get_role_display }}</span>
{% endif %}
</div>
<div class="flex items-center gap-3 text-xs text-slate mb-3">
<span class="inline-flex items-center gap-1">
<i data-lucide="send" class="w-3.5 h-3.5"></i>
{% if inv.sent_at %}{% trans "Sent" %} {{ inv.sent_at|date:"d M Y" }}{% endif %}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<a href="{% url 'complaints:complaint_detail' pk=inv.complaint.pk %}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-navy text-white rounded-lg text-xs font-semibold hover:bg-blue transition">
<i data-lucide="external-link" class="w-3.5 h-3.5"></i> {% trans "View" %}
</a>
<button type="button" onclick="toggleRejectRouting('{{ inv.pk }}')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 border-2 border-red-200 text-red-600 rounded-lg text-xs font-semibold hover:border-red-400 hover:bg-red-50 transition">
<i data-lucide="log-out" class="w-3.5 h-3.5"></i> {% trans "Wrong Department" %}
</button>
</div>
<form method="post" action="{% url 'complaints:involved_department_reject_routing' pk=inv.pk %}" id="reject-routing-{{ inv.pk }}" class="hidden mt-3 pt-3 border-t border-slate-100 space-y-3">
{% csrf_token %}
<div>
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Reason" %} <span class="text-red-500">*</span></label>
<textarea name="rejection_reason" rows="2" required
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy resize-none"
placeholder="{% trans 'Why does this not belong to your department?' %}"></textarea>
</div>
{% if hospital_departments %}
<div>
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Suggest Correct Department (optional)" %}</label>
<select name="suggested_department_id" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy bg-white">
<option value="">{% trans "Select Department" %}</option>
{% for dept in hospital_departments %}
<option value="{{ dept.id }}">{{ dept.get_localized_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-2">
<button type="button" onclick="toggleRejectRouting('{{ inv.pk }}')"
class="px-3 py-1.5 border-2 border-slate-200 text-slate-600 rounded-lg text-xs font-semibold hover:border-slate-400 transition">{% trans "Cancel" %}</button>
<button type="submit" class="px-3 py-1.5 bg-red-500 text-white rounded-lg text-xs font-semibold hover:bg-red-600 transition">{% trans "Reject Routing" %}</button>
</div>
</form>
</div>
{% endfor %}
</div>
</div>
<script>
function toggleRejectRouting(pk) {
const form = document.getElementById('reject-routing-' + pk);
if (form) form.classList.toggle('hidden');
}
</script>
{% endif %}
<!-- Department Head Info -->
{% if staff_head %}