diff --git a/.gitignore b/.gitignore index bc0ece9..828cd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,12 @@ db.sqlite3-journal media/ staticfiles/ +# Local HODDI datasets (kept out of git) +securemed-backend/data/hoddi/ + +# Local artifacts +securemed-backend/tmp/ + # Python packaging / builds build/ dist/ diff --git a/docker-compose.yml b/docker-compose.yml index 84876e4..76dce64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,10 @@ services: restart: unless-stopped ports: - "8000:8000" + volumes: + - ./securemed-backend:/app + - backend_media:/app/media + - backend_static:/app/staticfiles environment: DB_ENGINE: django.db.backends.postgresql DB_NAME: ${DB_NAME:-securemed} @@ -56,9 +60,7 @@ services: ALLOWED_HOSTS: "localhost,127.0.0.1,backend" SECRET_KEY: ${SECRET_KEY:-ci-secret-key} DEBUG: ${DEBUG:-True} - command: > - sh -c "python manage.py migrate --noinput && - gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 2" + DEV_SERVER: "true" depends_on: db: condition: service_healthy @@ -74,6 +76,10 @@ services: restart: unless-stopped ports: - "3000:3000" + volumes: + - ./securemed-frontend:/app + - /app/node_modules + - /app/.next environment: BACKEND_URL: http://backend:8000 NEXT_PUBLIC_API_URL: /api @@ -83,3 +89,5 @@ services: volumes: postgres_data: + backend_media: + backend_static: diff --git a/securemed-backend/apps/accounts/patients/serializers.py b/securemed-backend/apps/accounts/patients/serializers.py index b597b46..8da6057 100644 --- a/securemed-backend/apps/accounts/patients/serializers.py +++ b/securemed-backend/apps/accounts/patients/serializers.py @@ -26,5 +26,5 @@ class Meta: ] read_only_fields = [ 'patient_id', 'date_of_birth', 'gender', 'blood_group', 'emergency_contacts', - 'allergies', 'chronic_conditions', 'current_medications' # Clinical data must be clinician-managed + 'chronic_conditions' # Clinician-managed ] diff --git a/securemed-backend/apps/clinical/diagnostics/views.py b/securemed-backend/apps/clinical/diagnostics/views.py index 54551fe..0994fbe 100644 --- a/securemed-backend/apps/clinical/diagnostics/views.py +++ b/securemed-backend/apps/clinical/diagnostics/views.py @@ -33,13 +33,14 @@ class LabOrderViewSet(viewsets.ModelViewSet): def get_queryset(self): user = self.request.user + base = LabOrder.objects.all().order_by('-created_at', '-id') if hasattr(user, 'patient_profile'): # patient FK on LabOrder points to AUTH_USER_MODEL, not Patient - return LabOrder.objects.filter(patient=user) + return base.filter(patient=user) elif hasattr(user, 'doctor_profile') or user.role == 'doctor': - return LabOrder.objects.filter(doctor=user) + return base.filter(doctor=user) elif user.is_staff: - return LabOrder.objects.all() + return base return LabOrder.objects.none() def perform_create(self, serializer): @@ -255,6 +256,8 @@ def secure_view(self, request, pk=None): elif hasattr(request.user, 'doctor_profile') or request.user.is_staff: if result.order.doctor_id not in [None, request.user.id] and not request.user.is_staff: return Response({"error": "Not authorized"}, status=status.HTTP_403_FORBIDDEN) + elif request.user.role == 'lab_technician': + pass else: return Response({"error": "Not authorized"}, status=status.HTTP_403_FORBIDDEN) diff --git a/securemed-backend/apps/clinical/records/interaction_service.py b/securemed-backend/apps/clinical/records/interaction_service.py index 48c0550..fd8803d 100644 --- a/securemed-backend/apps/clinical/records/interaction_service.py +++ b/securemed-backend/apps/clinical/records/interaction_service.py @@ -3,10 +3,8 @@ from typing import Dict, Iterable, List, Optional, Sequence, Tuple from django.core.cache import cache -from django.db.models import Q from .models import ( - DrugInteraction, MedicationInteractionKnowledge, MedicationInteractionReport, MedicationInteractionReportJob, @@ -71,6 +69,22 @@ def resolve_medications_for_knowledge(medications: Sequence[str]) -> List[str]: return sorted(set(resolved)) +def _build_medication_display_resolver( + normalized_meds: Sequence[str], + original_inputs: Sequence[str], +) -> Dict[str, str]: + original_map = { + normalize_medication_name(med): med + for med in original_inputs + if med + } + if not normalized_meds: + return original_map + refs = MedicationReference.objects.filter(identifier__in=normalized_meds).order_by("id") + display_map = {ref.identifier: ref.display_name for ref in refs} + return {**original_map, **display_map} + + def _get_active_medications_for_patient(patient_id: int) -> List[str]: rows = Prescription.objects.filter( medical_record__patient_id=patient_id, @@ -127,28 +141,6 @@ def _knowledge_findings_for_combos(combo_groups: Iterable[Tuple[str, ...]]) -> L return findings -def _fallback_pair_findings(pair_groups: Iterable[Tuple[str, str]]) -> List[Dict]: - findings: List[Dict] = [] - for a, b in pair_groups: - inter = DrugInteraction.objects.filter( - Q(drug_a__iexact=a, drug_b__iexact=b) | Q(drug_a__iexact=b, drug_b__iexact=a) - ).first() - if inter: - findings.append( - { - "finding_type": "interaction", - "medications": [a, b], - "combination_size": 2, - "side_effect": inter.description or f"Interaction between {a} and {b}", - "severity": inter.severity, - "description": inter.description, - "source": "SecureMed Seed", - "source_reference": "", - } - ) - return findings - - def _compute_medication_safety(medications: Sequence[str]) -> Dict: normalized = resolve_medications_for_knowledge(medications) if not normalized: @@ -171,7 +163,6 @@ def _compute_medication_safety(medications: Sequence[str]) -> Dict: findings.extend(_single_drug_findings(normalized)) findings.extend(_knowledge_findings_for_combos(triplets)) findings.extend(_knowledge_findings_for_combos(pairs)) - findings.extend(_fallback_pair_findings(pairs)) # Deduplicate by semantic identity. dedup_key = set() @@ -232,6 +223,7 @@ def bump_safety_cache_namespace() -> str: def evaluate_medication_safety(medications: Sequence[str]) -> Dict: + original_inputs = list(medications) normalized = resolve_medications_for_knowledge(medications) signature = canonical_signature(normalized) if not signature: @@ -246,6 +238,20 @@ def evaluate_medication_safety(medications: Sequence[str]) -> Dict: return cached result = _compute_medication_safety(normalized) + display_resolver = _build_medication_display_resolver( + result.get("medications", []), + original_inputs, + ) + if display_resolver: + result["medications"] = [ + display_resolver.get(med, med) + for med in result.get("medications", []) + ] + for finding in result.get("findings", []): + finding["medications"] = [ + display_resolver.get(med, med) + for med in finding.get("medications", []) + ] try: cache.set(key, result, SAFETY_CACHE_TTL_SECONDS) except Exception: @@ -321,7 +327,7 @@ def enqueue_report_generation( trigger_event: str = "manual_refresh", candidate_medications: Optional[Sequence[str]] = None, ) -> MedicationInteractionReportJob: - from .tasks import generate_interaction_report_job + from django.conf import settings task_id = uuid4().hex candidate_meds = [normalize_medication_name(m) for m in (candidate_medications or []) if m] @@ -333,6 +339,16 @@ def enqueue_report_generation( candidate_medications=candidate_meds, status="queued", ) + if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False) or getattr(settings, "DEBUG", False): + try: + run_report_job(job.id) + job.refresh_from_db() + except Exception as exc: + job.status = "failed" + job.error_message = str(exc) + job.save(update_fields=["status", "error_message"]) + return job + from .tasks import generate_interaction_report_job try: generate_interaction_report_job.delay(job.id) except Exception as exc: diff --git a/securemed-backend/apps/clinical/records/management/commands/seed_drug_interactions.py b/securemed-backend/apps/clinical/records/management/commands/seed_drug_interactions.py deleted file mode 100644 index c60e569..0000000 --- a/securemed-backend/apps/clinical/records/management/commands/seed_drug_interactions.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.core.management.base import BaseCommand -from apps.clinical.records.models import DrugInteraction - - -class Command(BaseCommand): - help = "Seed common drug interaction pairs" - - def handle(self, *args, **options): - interactions = [ - ("Warfarin", "Aspirin", "high", "Increased bleeding risk."), - ("Lisinopril", "Potassium", "moderate", "Risk of hyperkalemia."), - ("Metformin", "Contrast Dye", "high", "Risk of lactic acidosis."), - ("Ibuprofen", "Prednisone", "moderate", "Increased GI bleed risk."), - ] - - created = 0 - for a, b, severity, desc in interactions: - obj, was_created = DrugInteraction.objects.get_or_create( - drug_a=a, - drug_b=b, - defaults={"severity": severity, "description": desc} - ) - if was_created: - created += 1 - self.stdout.write(self.style.SUCCESS(f"Seeded {created} drug interactions.")) diff --git a/securemed-backend/apps/clinical/records/management/commands/seed_hoddi_mini.py b/securemed-backend/apps/clinical/records/management/commands/seed_hoddi_mini.py index c470a42..2ab09d3 100644 --- a/securemed-backend/apps/clinical/records/management/commands/seed_hoddi_mini.py +++ b/securemed-backend/apps/clinical/records/management/commands/seed_hoddi_mini.py @@ -1,90 +1,56 @@ -from django.core.management.base import BaseCommand - -from apps.clinical.records.interaction_service import canonical_signature, normalize_medication_name -from apps.clinical.records.models import ( - MedicationInteractionKnowledge, - MedicationReference, - MedicationSideEffect, -) - - -MINI_DRUGS = [ - {"identifier": "db00945", "display": "Aspirin"}, - {"identifier": "db00682", "display": "Warfarin"}, - {"identifier": "db01050", "display": "Ibuprofen"}, -] +import os +from pathlib import Path -MINI_SIDE_EFFECTS = [ - {"med": "db00945", "effect": "Nausea", "severity": "low"}, - {"med": "db00682", "effect": "Bleeding", "severity": "high"}, - {"med": "db01050", "effect": "Gastric irritation", "severity": "moderate"}, -] - -MINI_INTERACTIONS = [ - { - "meds": ["db00682", "db00945"], - "effect": "Increased bleeding risk", - "severity": "high", - }, - { - "meds": ["db00945", "db01050"], - "effect": "Gastrointestinal irritation", - "severity": "moderate", - }, - { - "meds": ["db00682", "db00945", "db01050"], - "effect": "Elevated bleeding with NSAIDs", - "severity": "high", - }, -] - -SOURCE_VERSION = "HODDI_MINI" +from django.core.management.base import BaseCommand +from django.core.management import call_command class Command(BaseCommand): - help = "Seed a minimal HODDI-like dataset for CI/E2E." + help = "Seed HODDI interactions from a dataset path (prefers local data/hoddi)." def handle(self, *args, **options): - self.stdout.write("[-] Seeding mini HODDI dataset...") - - for item in MINI_DRUGS: - normalized = normalize_medication_name(item["display"]) - MedicationReference.objects.get_or_create( - identifier=item["identifier"], - normalized_name=normalized, - defaults={ - "display_name": item["display"], - "source": "HODDI", - }, - ) - - for item in MINI_SIDE_EFFECTS: - MedicationSideEffect.objects.get_or_create( - medication_name=item["med"], - side_effect=item["effect"], - source_version=SOURCE_VERSION, - defaults={ - "severity": item["severity"], - "description": "", - "source": "HODDI", - }, + candidates = [ + os.environ.get("HODDI_DATASET_PATH"), + "/app/data/hoddi/HODDI_v2", + "/app/.data/hoddi/HODDI_v2", + "/tmp/HODDI/dataset/HODDI_v2", + ] + dataset_path = next((path for path in candidates if path and Path(path).exists()), None) + dataset_version = os.environ.get("HODDI_DATASET_VERSION", "HODDI_v2") + side_effect_map = os.environ.get("HODDI_SIDE_EFFECT_MAP") + drug_map = os.environ.get("HODDI_DRUG_MAP") + include_negative = os.environ.get("HODDI_INCLUDE_NEGATIVE", "").lower() in {"1", "true", "yes"} + + if not dataset_path: + self.stdout.write( + self.style.WARNING( + "HODDI dataset not found. Set HODDI_DATASET_PATH or place data under " + "/app/data/hoddi/HODDI_v2." + ) ) - - for item in MINI_INTERACTIONS: - meds = [normalize_medication_name(m) for m in item["meds"]] - signature = canonical_signature(meds) - MedicationInteractionKnowledge.objects.get_or_create( - combination_signature=signature, - side_effect=item["effect"], - source_version=SOURCE_VERSION, - defaults={ - "medications": meds, - "combination_size": len(meds), - "severity": item["severity"], - "description": "", - "source": "HODDI", - "evidence": {}, - }, - ) - - self.stdout.write(self.style.SUCCESS("Mini HODDI seed complete.")) + return + + if not side_effect_map: + candidate = Path(dataset_path) / "dictionary/Side_effects_unique.csv" + if candidate.exists(): + side_effect_map = str(candidate) + if not drug_map: + candidate = Path(dataset_path) / "dictionary/Drugbank_ID_SMILE_all_structure links.csv" + if candidate.exists(): + drug_map = str(candidate) + + kwargs = { + "path": dataset_path, + "dataset_version": dataset_version, + "truncate": True, + } + if side_effect_map: + kwargs["side_effect_map"] = side_effect_map + if drug_map: + kwargs["drug_map"] = drug_map + if include_negative: + kwargs["include_negative"] = True + + self.stdout.write(self.style.WARNING("[HODDI] Importing dataset...")) + call_command("import_hoddi", **kwargs) + self.stdout.write(self.style.SUCCESS("[HODDI] Dataset import complete.")) diff --git a/securemed-backend/apps/clinical/records/pdf_reports.py b/securemed-backend/apps/clinical/records/pdf_reports.py index fda91fb..a59c7f0 100644 --- a/securemed-backend/apps/clinical/records/pdf_reports.py +++ b/securemed-backend/apps/clinical/records/pdf_reports.py @@ -15,6 +15,8 @@ TableStyle, ) +from .models import MedicalRecord + PAGE_WIDTH, PAGE_HEIGHT = A4 SEVERITY_COLORS = { @@ -32,6 +34,7 @@ } SEVERITY_ORDER = ["critical", "high", "moderate", "low"] +MAX_FINDINGS_DISPLAY = 30 CLINIC_NAME = "SecureMed Hospital" CLINIC_TAGLINE = "Advanced Clinical Decision Support • Powered by HODDI" @@ -296,6 +299,45 @@ def _recommendations(report, styles): return elements +def _dedupe_report_items(items): + deduped = [] + seen = set() + for item in items: + key = ( + item.finding_type, + tuple(sorted(item.medications or [])), + (item.side_effect or "").strip().lower(), + item.severity, + ) + if key in seen: + continue + seen.add(key) + deduped.append(item) + return deduped + + +def _resolve_attending_doctor(report): + generated_by = report.generated_by + if generated_by and hasattr(generated_by, "doctor_profile"): + doctor_user = generated_by + return doctor_user.get_full_name() or doctor_user.email or doctor_user.username + + patient = report.patient + if patient: + record = ( + MedicalRecord.objects + .select_related("doctor__user") + .filter(patient=patient, doctor__isnull=False) + .order_by("-created_at", "-id") + .first() + ) + if record and record.doctor and record.doctor.user: + doctor_user = record.doctor.user + return doctor_user.get_full_name() or doctor_user.email or doctor_user.username + + return "—" + + def generate_interaction_report_pdf(report) -> BytesIO: """ Generate a PDF for a MedicationInteractionReport instance. @@ -324,8 +366,7 @@ def generate_interaction_report_pdf(report) -> BytesIO: # ── Patient & Report Metadata ───────────────────────────────────────────── patient = report.patient - generated_by = report.generated_by - doctor_name = generated_by.get_full_name() if generated_by else "—" + doctor_name = _resolve_attending_doctor(report) report_date = report.created_at.strftime("%d %B %Y, %H:%M") story.append(Paragraph("Patient Information", styles["section_header"])) @@ -361,14 +402,36 @@ def generate_interaction_report_pdf(report) -> BytesIO: story.append(Paragraph("Findings Summary", styles["section_header"])) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#E2E8F0"), spaceAfter=6)) story.append(_summary_table(report, styles)) + summary_text = f"Total findings: {report.total_findings}." + if report.total_findings > MAX_FINDINGS_DISPLAY: + summary_text += f" Showing the top {MAX_FINDINGS_DISPLAY} in this report." + story.append(Paragraph(summary_text, styles["small"])) story.append(Spacer(1, 0.5 * cm)) # ── Detailed Findings ───────────────────────────────────────────────────── - items = list(report.items.all()) + items = _dedupe_report_items(list(report.items.all())) if items: + total_findings = len(items) + limited_items = [] + for severity in SEVERITY_ORDER: + for item in items: + if item.severity == severity: + limited_items.append(item) + if len(limited_items) >= MAX_FINDINGS_DISPLAY: + break + if len(limited_items) >= MAX_FINDINGS_DISPLAY: + break + items = limited_items story.append(Paragraph("Detailed Findings", styles["section_header"])) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#E2E8F0"), spaceAfter=6)) + if total_findings > MAX_FINDINGS_DISPLAY: + story.append( + Paragraph( + f"Showing the top {MAX_FINDINGS_DISPLAY} of {total_findings} findings.", + styles["small"], + ) + ) for severity in SEVERITY_ORDER: story.extend(_finding_rows(items, severity, styles)) else: diff --git a/securemed-backend/apps/clinical/records/serializers.py b/securemed-backend/apps/clinical/records/serializers.py index 10b6f35..71cb74b 100644 --- a/securemed-backend/apps/clinical/records/serializers.py +++ b/securemed-backend/apps/clinical/records/serializers.py @@ -3,10 +3,10 @@ MedicalRecord, Prescription, VitalSign, - DrugInteraction, PharmacyOrder, MedicationAdherenceLog, MedicationHistoryEvent, + MedicationInteractionKnowledge, MedicationInteractionReport, MedicationInteractionReportItem, ) @@ -93,11 +93,14 @@ class Meta: model = MedicalRecord fields = [ 'id', 'record_id', 'record_type', 'record_type_display', - 'record_date', 'doctor_name', 'patient', 'patient_name', 'patient_display_id', - 'diagnosis', 'file', 'file_url', + 'record_date', 'doctor', 'doctor_name', 'patient', 'patient_name', 'patient_display_id', + 'diagnosis', 'symptoms', 'treatment', 'file', 'file_url', 'prescriptions', 'created_at', 'source', 'is_attested', 'notes', 'private_notes' ] + extra_kwargs = { + 'doctor': {'write_only': True, 'required': False} + } def to_representation(self, instance): data = super().to_representation(instance) @@ -189,10 +192,20 @@ def validate(self, data): return data -class DrugInteractionSerializer(serializers.ModelSerializer): +class MedicationInteractionKnowledgeSerializer(serializers.ModelSerializer): class Meta: - model = DrugInteraction - fields = '__all__' + model = MedicationInteractionKnowledge + fields = [ + 'id', + 'medications', + 'combination_size', + 'severity', + 'side_effect', + 'description', + 'source', + 'source_version', + 'created_at', + ] class MedicationInteractionReportItemSerializer(serializers.ModelSerializer): @@ -212,7 +225,10 @@ class Meta: class MedicationInteractionReportSerializer(serializers.ModelSerializer): - items = MedicationInteractionReportItemSerializer(many=True, read_only=True) + items = serializers.SerializerMethodField() + items_total = serializers.SerializerMethodField() + items_truncated = serializers.SerializerMethodField() + items_limit = serializers.SerializerMethodField() class Meta: model = MedicationInteractionReport @@ -237,8 +253,48 @@ class Meta: 'source_version', 'created_at', 'items', + 'items_total', + 'items_truncated', + 'items_limit', ] + def _get_items_cache(self, obj): + cache_attr = "_report_items_cache" + if not hasattr(obj, cache_attr): + raw_items = list(obj.items.all()) + deduped = [] + seen = set() + for item in raw_items: + key = ( + item.finding_type, + tuple(sorted(item.medications or [])), + (item.side_effect or "").strip().lower(), + item.severity, + ) + if key in seen: + continue + seen.add(key) + deduped.append(item) + setattr(obj, cache_attr, deduped) + return getattr(obj, cache_attr) + + def _get_items_limit(self): + return int(self.context.get("items_limit", 30)) + + def get_items(self, obj): + items = self._get_items_cache(obj) + limit = self._get_items_limit() + return MedicationInteractionReportItemSerializer(items[:limit], many=True).data + + def get_items_total(self, obj): + return len(self._get_items_cache(obj)) + + def get_items_truncated(self, obj): + return len(self._get_items_cache(obj)) > self._get_items_limit() + + def get_items_limit(self, obj): + return self._get_items_limit() + class PharmacyOrderSerializer(serializers.ModelSerializer): qr_payload = serializers.SerializerMethodField() diff --git a/securemed-backend/apps/clinical/records/signals.py b/securemed-backend/apps/clinical/records/signals.py index 1bb1cb1..f403dcf 100644 --- a/securemed-backend/apps/clinical/records/signals.py +++ b/securemed-backend/apps/clinical/records/signals.py @@ -2,12 +2,11 @@ from django.dispatch import receiver from .interaction_service import bump_safety_cache_namespace -from .models import DrugInteraction, MedicationInteractionKnowledge, MedicationReference, MedicationSideEffect +from .models import MedicationInteractionKnowledge, MedicationReference, MedicationSideEffect @receiver([post_save, post_delete], sender=MedicationInteractionKnowledge) @receiver([post_save, post_delete], sender=MedicationSideEffect) @receiver([post_save, post_delete], sender=MedicationReference) -@receiver([post_save, post_delete], sender=DrugInteraction) def invalidate_safety_cache_on_knowledge_change(**kwargs): bump_safety_cache_namespace() diff --git a/securemed-backend/apps/clinical/records/timeline_api.py b/securemed-backend/apps/clinical/records/timeline_api.py index 8aaa50a..f545273 100644 --- a/securemed-backend/apps/clinical/records/timeline_api.py +++ b/securemed-backend/apps/clinical/records/timeline_api.py @@ -53,6 +53,7 @@ def patient_timeline(request): "status": order.status, "priority": order.priority, "details": { + "lab_order_id": order.id, "sample_id": order.sample_id, "tests": [{"name": t.name, "code": t.code} for t in order.items.all()], "doctor": order.doctor.get_full_name() if order.doctor else "Unknown", @@ -73,6 +74,7 @@ def patient_timeline(request): "status": "released", "flag": result.flag, "details": { + "lab_result_id": result.id, "test_code": result.test.code, "test_name": result.test.name, "result_value": result.result_value, diff --git a/securemed-backend/apps/clinical/records/views.py b/securemed-backend/apps/clinical/records/views.py index b91b58d..43e928a 100644 --- a/securemed-backend/apps/clinical/records/views.py +++ b/securemed-backend/apps/clinical/records/views.py @@ -437,13 +437,17 @@ def create(self, request, *args, **kwargs): prescription, interaction_result = self.perform_create(serializer) response_serializer = self.get_serializer(prescription) response_payload = dict(response_serializer.data) + findings = interaction_result.get("findings", []) or [] + findings_limit = 25 response_payload["interaction_check"] = { - "has_findings": len(interaction_result.get("findings", [])) > 0, - "total_findings": len(interaction_result.get("findings", [])), + "has_findings": len(findings) > 0, + "total_findings": len(findings), "totals": interaction_result.get("totals", {}), "evaluated_combination_depth": interaction_result.get("evaluated_combination_depth", 3), "not_evaluated_depths": interaction_result.get("not_evaluated_depths", []), - "findings": interaction_result.get("findings", []), + "findings_limit": findings_limit, + "findings_truncated": len(findings) > findings_limit, + "findings": findings[:findings_limit], } headers = self.get_success_headers(response_serializer.data) return Response(response_payload, status=status.HTTP_201_CREATED, headers=headers) @@ -573,11 +577,11 @@ def cancel(self, request, pk=None): return Response({"status": "cancelled"}) -class DrugInteractionViewSet(viewsets.ModelViewSet): - from .models import DrugInteraction, MedicationInteractionReport, MedicationInteractionReportJob - from .serializers import DrugInteractionSerializer, MedicationInteractionReportSerializer - queryset = DrugInteraction.objects.all() - serializer_class = DrugInteractionSerializer +class DrugInteractionViewSet(viewsets.ReadOnlyModelViewSet): + from .models import MedicationInteractionKnowledge, MedicationInteractionReport, MedicationInteractionReportJob + from .serializers import MedicationInteractionKnowledgeSerializer, MedicationInteractionReportSerializer + queryset = MedicationInteractionKnowledge.objects.all() + serializer_class = MedicationInteractionKnowledgeSerializer permission_classes = [permissions.IsAuthenticated] def _resolve_patient(self, request): @@ -678,11 +682,11 @@ def check(self, request): raise ValidationError({"medications": "Must be a list of medication names."}) include_active = request.data.get("include_active", True) include_active = str(include_active).lower() not in {"false", "0", "no"} - raw_limit = request.data.get("limit_findings", 80) + raw_limit = request.data.get("limit_findings", 30) try: - limit_findings = max(1, min(int(raw_limit), 200)) + limit_findings = max(1, min(int(raw_limit), 30)) except (TypeError, ValueError): - limit_findings = 80 + limit_findings = 30 patient = self._resolve_patient(request) active_meds = get_active_medications_for_patient(patient.id) if (patient and include_active) else [] @@ -695,6 +699,15 @@ def check(self, request): interaction_findings = [finding for finding in findings if finding.get("combination_size", 0) >= 2] side_effect_findings = [finding for finding in findings if finding.get("combination_size", 0) < 2] visible_findings = (interaction_findings + side_effect_findings)[:limit_findings] + effect_counts = {} + combo_keys = set() + for finding in visible_findings: + effect = (finding.get("side_effect") or "").strip() + if effect: + effect_counts[effect] = effect_counts.get(effect, 0) + 1 + meds_key = sorted([m for m in finding.get("medications", []) if m]) + combo_keys.add(f"{finding.get('combination_size')}|{'|'.join(meds_key)}") + top_effects = [k for k, _ in sorted(effect_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:5]] result["findings"] = visible_findings result["interaction_findings_total"] = len(interaction_findings) @@ -702,6 +715,11 @@ def check(self, request): result["visible_findings_count"] = len(visible_findings) result["findings_truncated"] = len(visible_findings) < len(findings) result["limit_findings"] = limit_findings + result["summary"] = { + "total_findings": len(visible_findings), + "total_combinations": len(combo_keys), + "top_effects": top_effects, + } result["requested_medications"] = meds result["active_medications_added"] = active_meds result["include_active"] = include_active @@ -715,7 +733,7 @@ def latest_report(self, request): report = self.MedicationInteractionReport.objects.filter(patient=patient).prefetch_related('items').first() if not report: return Response({"detail": "No report found."}, status=status.HTTP_404_NOT_FOUND) - serializer = self.MedicationInteractionReportSerializer(report) + serializer = self.MedicationInteractionReportSerializer(report, context={"items_limit": 30}) return Response(serializer.data) @action(detail=False, methods=['get'], url_path='reports') @@ -724,7 +742,7 @@ def report_history(self, request): if not patient: raise ValidationError({"patient_id": "patient_id is required for doctor/admin."}) reports = self.MedicationInteractionReport.objects.filter(patient=patient).prefetch_related('items')[:20] - serializer = self.MedicationInteractionReportSerializer(reports, many=True) + serializer = self.MedicationInteractionReportSerializer(reports, many=True, context={"items_limit": 30}) return Response(serializer.data) @action(detail=False, methods=['post'], url_path='reports/generate') @@ -734,6 +752,7 @@ def generate_report(self, request): raise ValidationError({"patient_id": "patient_id is required for doctor/admin."}) from django.utils import timezone from datetime import timedelta + from django.conf import settings recent_cutoff = timezone.now() - timedelta(minutes=10) existing_job = ( self.MedicationInteractionReportJob.objects @@ -741,6 +760,13 @@ def generate_report(self, request): .first() ) if existing_job: + if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False) or getattr(settings, "DEBUG", False): + from .interaction_service import run_report_job + try: + run_report_job(existing_job.id) + existing_job.refresh_from_db() + except Exception: + pass return Response( { "task_id": existing_job.task_id, diff --git a/securemed-backend/apps/clinical/telemedicine/views.py b/securemed-backend/apps/clinical/telemedicine/views.py index bf6b91d..ef49bcb 100644 --- a/securemed-backend/apps/clinical/telemedicine/views.py +++ b/securemed-backend/apps/clinical/telemedicine/views.py @@ -189,6 +189,28 @@ def get_queryset(self): def perform_create(self, serializer): """Create room with current user as doctor.""" serializer.save(doctor=self.request.user) + + def create(self, request, *args, **kwargs): + """ + Accept either a patient user ID (expected by serializer) + or a Patient profile ID via `patient` / `patient_id` and map it. + """ + data = request.data.copy() + patient_value = data.get('patient_id') or data.get('patient') + if patient_value: + try: + from apps.accounts.patients.models import Patient as PatientProfile + patient_profile = PatientProfile.objects.select_related('user').filter(pk=int(patient_value)).first() + if patient_profile: + data['patient'] = patient_profile.user_id + except (TypeError, ValueError): + pass + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) @action(detail=True, methods=['post']) def join(self, request, room_id=None): @@ -676,10 +698,30 @@ def match_conditions_by_pain(request): normalized_intensity[str(region_id).strip().lower()] = max(1, min(10, level)) if not GEMINI_AVAILABLE: - return Response( - {'error': 'Condition matching AI is unavailable. Configure GOOGLE_GEMINI_API_KEY.'}, - status=status.HTTP_503_SERVICE_UNAVAILABLE, - ) + # Heuristic fallback: rank by region overlap + average pain intensity. + region_set = set(selected_regions) + matches = [] + for condition in ConditionCatalog.objects.filter(is_active=True).order_by('name'): + condition_regions = [str(r).strip().lower() for r in (condition.regions or []) if str(r).strip()] + if not condition_regions: + continue + overlap = region_set.intersection(condition_regions) + if not overlap: + continue + coverage = len(overlap) / max(len(condition_regions), 1) + avg_pain = sum(normalized_intensity.get(r, 5) for r in overlap) / max(len(overlap), 1) + intensity_score = avg_pain / 10 + confidence = min(0.95, (0.6 * coverage) + (0.4 * intensity_score)) + matches.append({ + 'condition_id': condition.condition_id, + 'name': condition.name, + 'confidence': round(confidence * 100), + 'matched_regions': list(overlap), + 'typical_symptoms': condition.typical_symptoms or [], + 'reasoning': f"Matches {len(overlap)} region(s) with average pain {round(avg_pain, 1)}/10.", + }) + matches.sort(key=lambda item: item['confidence'], reverse=True) + return Response({'matches': matches[:5], 'mode': 'heuristic'}, status=status.HTTP_200_OK) catalog_payload = [] for condition in ConditionCatalog.objects.filter(is_active=True).order_by('name'): diff --git a/securemed-backend/apps/finance/billing/urls.py b/securemed-backend/apps/finance/billing/urls.py index 5177b54..1e29390 100644 --- a/securemed-backend/apps/finance/billing/urls.py +++ b/securemed-backend/apps/finance/billing/urls.py @@ -2,6 +2,8 @@ from . import views urlpatterns = [ + path('insurance/providers/', views.list_insurance_providers, name='insurance_providers'), + path('admin/summary/', views.admin_billing_summary, name='admin_billing_summary'), path('invoices/', views.get_invoices, name='get_invoices'), path('invoices//', views.get_invoice_detail, name='get_invoice_detail'), path('invoices//download/', views.download_invoice, name='download_invoice'), diff --git a/securemed-backend/apps/finance/billing/views.py b/securemed-backend/apps/finance/billing/views.py index c95df17..71acdac 100644 --- a/securemed-backend/apps/finance/billing/views.py +++ b/securemed-backend/apps/finance/billing/views.py @@ -1,6 +1,8 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated +from django.db import models +from django.db.models import Sum, Count from .models import Invoice, Payment from .serializers import InvoiceSerializer, PaymentSerializer from apps.accounts.users.permissions import IsPatient @@ -15,6 +17,59 @@ 'ins_004': 'max bupa health', } + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def list_insurance_providers(request): + providers = [ + {"id": key, "name": value.title(), "code": key.split('_')[-1].upper()} + for key, value in PROVIDER_CODE_MAP.items() + ] + return Response({"providers": providers}) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def admin_billing_summary(request): + user = request.user + if not user.is_staff and user.role != 'admin': + return Response({"error": "Unauthorized"}, status=403) + + invoices = Invoice.objects.select_related('patient', 'patient__user').order_by('-issue_date') + totals = invoices.aggregate( + total_billed=Sum('total_amount'), + total_paid=Sum('paid_amount'), + total_count=Count('id'), + paid_count=Count('id', filter=models.Q(status='paid')), + overdue_count=Count('id', filter=models.Q(status='overdue')), + open_count=Count('id', filter=models.Q(status__in=['issued', 'partially_paid'])), + ) + + recent = [] + for inv in invoices[:10]: + patient_name = inv.patient.user.get_full_name() if inv.patient and inv.patient.user else inv.patient.patient_id + recent.append({ + "invoice_id": inv.invoice_id, + "patient": patient_name, + "status": inv.status, + "total": float(inv.total_amount), + "paid": float(inv.paid_amount), + "balance": float(inv.total_amount - inv.paid_amount), + "issue_date": inv.issue_date.isoformat(), + }) + + return Response({ + "summary": { + "total_billed": float(totals.get('total_billed') or 0), + "total_paid": float(totals.get('total_paid') or 0), + "total_count": totals.get('total_count') or 0, + "paid_count": totals.get('paid_count') or 0, + "overdue_count": totals.get('overdue_count') or 0, + "open_count": totals.get('open_count') or 0, + }, + "recent_invoices": recent, + }) + def get_patient_profile(user): if hasattr(user, 'patient_profile'): return user.patient_profile diff --git a/securemed-backend/apps/platform/core/management/commands/seed_db.py b/securemed-backend/apps/platform/core/management/commands/seed_db.py index df55220..6a8d840 100644 --- a/securemed-backend/apps/platform/core/management/commands/seed_db.py +++ b/securemed-backend/apps/platform/core/management/commands/seed_db.py @@ -13,6 +13,7 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand +from django.core.management import call_command from django.utils import timezone User = get_user_model() @@ -183,6 +184,16 @@ def handle(self, *args, **options): self._seed_referrals(patients, doctors, depts) self._seed_invoices(patients, appointments) self._seed_wellness_tips() + try: + self.stdout.write("[-] Seeding anatomy education content...") + call_command("seed_anatomy_content") + except Exception as exc: + self.stdout.write(self.style.WARNING(f" [!] Anatomy content seed skipped: {exc}")) + try: + self.stdout.write("[-] Seeding infection tracking graph...") + call_command("ensure_infection_demo_data") + except Exception as exc: + self.stdout.write(self.style.WARNING(f" [!] Infection graph seed skipped: {exc}")) self.stdout.write(self.style.SUCCESS("\n[+] Seeding complete!")) self._print_role_summary() @@ -463,41 +474,71 @@ def _seed_appointments(self, patients, doctors): counter = 1 # Seed 40 appointments for more realistic data for _ in range(40): - pat = random.choice(patients) - doc = random.choice(doctors) - days_offset = random.randint(-60, 60) - appt_date = date.today() + timedelta(days=days_offset) - - status = 'completed' if days_offset < -5 else ('scheduled' if days_offset > 0 else 'in_progress') - - reasons = [ - "Routine Checkup", - "Follow-up Visit", - "Annual Physical Exam", - "Vaccination", - "Symptom Consultation", - "Chronic Disease Management", - "Lab Report Discussion", - "Prescription Renewal", - "Health Screening" - ] - - appt, created = Appointment.objects.get_or_create( - appointment_id=f"APT-{counter:05d}", - defaults={ - "patient": pat, - "doctor": doc, - "appointment_date": appt_date, - "appointment_time": time(random.randint(9,16), random.choice([0, 30])), - "status": status, - "reason": random.choice(reasons), - "created_by": pat.user - } - ) - if created: - result.append(appt) - counter += 1 + attempts = 0 + while attempts < 10: + pat = random.choice(patients) + doc = random.choice(doctors) + days_offset = random.randint(-60, 60) + appt_date = date.today() + timedelta(days=days_offset) + appt_time = time(random.randint(9, 16), random.choice([0, 30])) + + status = 'completed' if days_offset < -5 else ('scheduled' if days_offset > 0 else 'in_progress') + + reasons = [ + "Routine Checkup", + "Follow-up Visit", + "Annual Physical Exam", + "Vaccination", + "Symptom Consultation", + "Chronic Disease Management", + "Lab Report Discussion", + "Prescription Renewal", + "Health Screening" + ] + + appt, created = Appointment.objects.get_or_create( + doctor=doc, + appointment_date=appt_date, + appointment_time=appt_time, + defaults={ + "appointment_id": f"APT-{counter:05d}", + "patient": pat, + "status": status, + "reason": random.choice(reasons), + "created_by": pat.user + } + ) + if created: + result.append(appt) + counter += 1 + break + attempts += 1 self.stdout.write(f" Created {len(result)} appointments") + + # Ensure two spotlight patients have multi-doctor histories. + if len(patients) >= 2 and len(doctors) >= 2: + spotlight_patients = patients[:2] + spotlight_doctors = doctors[:min(3, len(doctors))] + for p_index, pat in enumerate(spotlight_patients, start=1): + for d_index, doc in enumerate(spotlight_doctors, start=1): + appt_date = date.today() - timedelta(days=10 + (p_index * 2) + d_index) + appt_time = time(9 + (d_index % 4), 0) + appt_id = f"APT-SPOT-{p_index}{d_index}-{pat.id}-{doc.id}" + appt, created = Appointment.objects.get_or_create( + appointment_id=appt_id, + defaults={ + "patient": pat, + "doctor": doc, + "appointment_date": appt_date, + "appointment_time": appt_time, + "status": "completed", + "reason": "Multi-specialist follow-up", + "created_by": pat.user, + }, + ) + if created: + result.append(appt) + self.stdout.write(" Added spotlight multi-doctor appointment history") return result def _seed_pharmacy_data(self): diff --git a/securemed-backend/scripts/start_backend.sh b/securemed-backend/scripts/start_backend.sh index 04bc1b4..ff1803c 100755 --- a/securemed-backend/scripts/start_backend.sh +++ b/securemed-backend/scripts/start_backend.sh @@ -12,4 +12,8 @@ if [ "${AUTO_SEED_INFECTION_TRACKING:-false}" = "true" ]; then fi fi +if [ "${DEV_SERVER:-false}" = "true" ]; then + exec python manage.py runserver 0.0.0.0:8000 +fi + exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --timeout 120 --workers 2 diff --git a/securemed-frontend/app/admin/[tab]/page.tsx b/securemed-frontend/app/admin/[tab]/page.tsx index 2af260f..a01a7c1 100644 --- a/securemed-frontend/app/admin/[tab]/page.tsx +++ b/securemed-frontend/app/admin/[tab]/page.tsx @@ -21,6 +21,14 @@ export default function AdminTabPage() { useEffect(() => { if (isLoading) return; if (!isAuthenticated) { + if (typeof window !== 'undefined') { + const postLogout = window.localStorage.getItem('post_logout_redirect'); + if (postLogout) { + window.localStorage.removeItem('post_logout_redirect'); + router.replace(postLogout); + return; + } + } router.push(ROUTES.LOGIN); return; } diff --git a/securemed-frontend/app/doctor/labs/page.tsx b/securemed-frontend/app/doctor/labs/page.tsx index 606cb20..1eeb2ed 100644 --- a/securemed-frontend/app/doctor/labs/page.tsx +++ b/securemed-frontend/app/doctor/labs/page.tsx @@ -6,6 +6,9 @@ import api from '@/lib/api'; import LabOrderForm from '@/components/portals/doctor/labs/lab-order-form'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Search } from 'lucide-react'; +import { API_ORIGIN } from '@/lib/urls'; interface DoctorPatient { id: number; @@ -32,6 +35,7 @@ export default function LabsPage() { const [results, setResults] = useState([]); const [resultsLoading, setResultsLoading] = useState(true); const [releaseId, setReleaseId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); useEffect(() => { if (!isAuthenticated) return; @@ -93,7 +97,7 @@ export default function LabsPage() { toast.error('No view link available'); return; } - const viewUrl = url.startsWith('http') ? url : `${window.location.origin}${url}`; + const viewUrl = url.startsWith('http') ? url : `${API_ORIGIN}${url}`; window.open(viewUrl, '_blank', 'noopener,noreferrer'); } catch (error) { toast.error('Failed to open attachment'); @@ -117,15 +121,39 @@ export default function LabsPage() { } }; + const filteredResults = results.filter((result) => { + if (!searchQuery.trim()) return true; + const term = searchQuery.trim().toLowerCase(); + return ( + String(result.sample_id || '').toLowerCase().includes(term) || + String(result.test_name || '').toLowerCase().includes(term) || + String(result.result_value || '').toLowerCase().includes(term) || + String(result.flag || '').toLowerCase().includes(term) + ); + }); + return (

Lab Orders

-

Pending Results

+
+

Pending Results

+
+ + setSearchQuery(e.target.value)} + className="pl-9 bg-background" + /> +
+
{resultsLoading ? (
Loading lab results...
- ) : results.length === 0 ? ( -
No lab results found.
+ ) : filteredResults.length === 0 ? ( +
+ {searchQuery ? 'No lab results match your search.' : 'No lab results found.'} +
) : (
@@ -140,7 +168,7 @@ export default function LabsPage() { - {results.map((result) => ( + {filteredResults.map((result) => ( @@ -191,9 +219,14 @@ export default function LabsPage() { { - console.log("Order submitted:", order); - toast.success("Lab order submitted successfully"); - return Promise.resolve(); + try { + const res = await api.post('/labs/orders/', order); + const sampleId = res?.data?.sample_id as string | undefined; + toast.success(sampleId ? `Lab order submitted (Sample ${sampleId})` : 'Lab order submitted successfully'); + } catch (error) { + toast.error('Failed to submit lab order'); + throw error; + } }} /> diff --git a/securemed-frontend/app/doctor/patients/[id]/page.tsx b/securemed-frontend/app/doctor/patients/[id]/page.tsx index 34b1a7d..d01ee46 100644 --- a/securemed-frontend/app/doctor/patients/[id]/page.tsx +++ b/securemed-frontend/app/doctor/patients/[id]/page.tsx @@ -26,6 +26,13 @@ export default function PatientDetailPage() { try { const response = await api.get(`/patients/${patientId}/`); const userData = response.data; + const parseList = (value: string | null | undefined) => { + if (!value) return []; + return value + .split(',') + .map((item: string) => item.trim()) + .filter(Boolean); + }; // Transform API data to Match Patient Interface expected by PatientProfileView setPatient({ @@ -35,6 +42,11 @@ export default function PatientDetailPage() { status: 'Outpatient', // Default or derive if available lastVisit: userData.last_visit || 'N/A', condition: userData.chronic_conditions || 'None listed', + gender: userData.gender || 'Unknown', + dateOfBirth: userData.date_of_birth || 'Unknown', + bloodType: userData.blood_group || 'Unknown', + allergies: parseList(userData.allergies), + medicalHistory: parseList(userData.chronic_conditions), }); } catch (err: any) { @@ -92,4 +104,3 @@ export default function PatientDetailPage() { /> ); } - diff --git a/securemed-frontend/app/doctor/settings/page.tsx b/securemed-frontend/app/doctor/settings/page.tsx index 100c702..024f19a 100644 --- a/securemed-frontend/app/doctor/settings/page.tsx +++ b/securemed-frontend/app/doctor/settings/page.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; import { useAuth } from '@/context/auth-context'; import api from '@/lib/api'; import { useToast } from '@/hooks/use-toast'; @@ -191,7 +192,14 @@ export default function SettingsPage() { onClick={() => fileInputRef.current?.click()} > {avatarPreview ? ( - Doctor avatar + ) : (
DR diff --git a/securemed-frontend/app/lab/[tab]/page.tsx b/securemed-frontend/app/lab/[tab]/page.tsx index 8d48488..47f1e05 100644 --- a/securemed-frontend/app/lab/[tab]/page.tsx +++ b/securemed-frontend/app/lab/[tab]/page.tsx @@ -18,6 +18,14 @@ export default function LabTabPage() { useEffect(() => { if (isLoading) return; if (!isAuthenticated) { + if (typeof window !== 'undefined') { + const postLogout = window.localStorage.getItem('post_logout_redirect'); + if (postLogout) { + window.localStorage.removeItem('post_logout_redirect'); + router.replace(postLogout); + return; + } + } router.push(ROUTES.LOGIN); return; } diff --git a/securemed-frontend/app/lib/routes.ts b/securemed-frontend/app/lib/routes.ts new file mode 100644 index 0000000..4891e28 --- /dev/null +++ b/securemed-frontend/app/lib/routes.ts @@ -0,0 +1,66 @@ +'use client'; + +import type { UserRole } from '@/lib/types'; + +// --------------------------------------------------------------------------- +// Named route constants +// --------------------------------------------------------------------------- +export const ROUTES = { + HOME: '/', + LOGIN: '/login', + REGISTER: '/register', + PORTAL: '/portal', + EMERGENCY: '/emergency', + LAB_TESTS: '/lab-tests', + + // Patient + PATIENT: '/patient', + PATIENT_DASHBOARD: '/patient/dashboard', + PATIENT_APPOINTMENTS: '/patient/appointments', + + // Doctor + DOCTOR: '/doctor', + DOCTOR_DASHBOARD: '/doctor/dashboard', + DOCTOR_PATIENTS: '/doctor/patients', + DOCTOR_TRIAGE_INBOX: '/doctor/triage-inbox', + + // Admin + ADMIN: '/admin', + ADMIN_DASHBOARD: '/admin/dashboard', + + // Lab + LAB: '/lab', + LAB_WORKLIST: '/lab/worklist', + + // Pharmacy + PHARMACY: '/pharmacy', + PHARMACY_DASHBOARD: '/pharmacy/dashboard', +} as const; + +// --------------------------------------------------------------------------- +// Valid URL tab segments per portal (must match portal component tab types) +// --------------------------------------------------------------------------- +export const VALID_TABS = { + admin: ['dashboard', 'analytics', 'hospitals', 'staff', 'patients', 'billing', 'ward-map', 'infection-tracking', 'audit-logs'] as const, + lab: ['worklist', 'completed', 'reports', 'settings'] as const, + pharmacy: ['dashboard', 'orders', 'inventory'] as const, +}; + +// --------------------------------------------------------------------------- +// Role → portal landing (dashboard) mapping +// --------------------------------------------------------------------------- +const ROLE_ROUTES: Record = { + patient: ROUTES.PATIENT_DASHBOARD, + doctor: ROUTES.DOCTOR_DASHBOARD, + admin: ROUTES.ADMIN_DASHBOARD, + lab_technician: ROUTES.LAB_WORKLIST, + pharmacist: ROUTES.PHARMACY_DASHBOARD, +}; + +/** + * Returns the portal root route for a given user role. + * Falls back to '/portal' for unknown roles. + */ +export function getPortalRouteForRole(role: UserRole | string): string { + return ROLE_ROUTES[role] ?? ROUTES.PORTAL; +} diff --git a/securemed-frontend/app/login/page.tsx b/securemed-frontend/app/login/page.tsx index 8f63a3d..855a25e 100644 --- a/securemed-frontend/app/login/page.tsx +++ b/securemed-frontend/app/login/page.tsx @@ -46,9 +46,9 @@ function LoginPageContent() { onClose={() => { // Preserve context: go back if there's history, otherwise go home if (window.history.length > 1) { - router.back(); + window.history.back(); } else { - router.push('/'); + window.location.assign('/'); } }} redirectTo={effectiveRedirect} diff --git a/securemed-frontend/app/patient/appointments/page.tsx b/securemed-frontend/app/patient/appointments/page.tsx index d8b222a..a1c59f3 100644 --- a/securemed-frontend/app/patient/appointments/page.tsx +++ b/securemed-frontend/app/patient/appointments/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, Suspense } from 'react'; +import React, { useState, useEffect, Suspense, useCallback } from 'react'; import { useSearchParams } from 'next/navigation'; import { useAuth } from '@/context/auth-context'; import { Button } from '@/components/ui/button'; @@ -10,11 +10,15 @@ import MyAppointments from '@/components/portals/patient/appointments/my-appoint import WaitingRoom from '@/components/telemedicine/waiting-room'; import VideoRoom from '@/components/telemedicine/video-room'; import { appointmentService } from '@/services/appointments'; +import { videoService } from '@/services/telemedicine'; +import { useToast } from '@/hooks/use-toast'; function AppointmentsContent() { const { isAuthenticated } = useAuth(); + const { toast } = useToast(); const searchParams = useSearchParams(); const initialDoctorId = searchParams.get('doctorId') || undefined; + const initialDoctorName = searchParams.get('doctorName') || undefined; const autoJoin = searchParams.get('join') === '1'; const [nextAppointment, setNextAppointment] = useState(null); @@ -42,8 +46,14 @@ function AppointmentsContent() { return dateA.getTime() - dateB.getTime(); }); if (upcoming.length > 0) { - setNextAppointment(upcoming[0]); - setActiveRoomId(`room-${upcoming[0].id}`); + const next = upcoming[0]; + setNextAppointment(next); + try { + const room = await videoService.getActiveRoom(next.patient); + setActiveRoomId(room?.room_id || ''); + } catch { + setActiveRoomId(''); + } } } catch (e) { console.error('Failed to fetch upcoming appointment:', e); @@ -53,12 +63,37 @@ function AppointmentsContent() { fetchNextAppointment(); }, [isAuthenticated]); + const handleJoinTelemed = useCallback(async () => { + if (!nextAppointment) return; + let roomId = activeRoomId; + if (!roomId) { + try { + const room = await videoService.getActiveRoom(nextAppointment.patient); + roomId = room?.room_id || ''; + setActiveRoomId(roomId); + } catch { + roomId = ''; + } + } + + if (!roomId) { + toast({ + title: 'Waiting room not ready', + description: 'Your doctor has not started the room yet. Please try again shortly.', + variant: 'destructive' + }); + return; + } + + setShowTelemed(true); + setTelemedStatus('waiting'); + }, [activeRoomId, nextAppointment, toast]); + useEffect(() => { - if (autoJoin && nextAppointment && activeRoomId) { - setShowTelemed(true); - setTelemedStatus('waiting'); + if (autoJoin && nextAppointment) { + handleJoinTelemed(); } - }, [autoJoin, nextAppointment, activeRoomId]); + }, [autoJoin, nextAppointment, handleJoinTelemed]); if (showTelemed) { return ( @@ -98,10 +133,7 @@ function AppointmentsContent() {
)} + {selectedRoom?.patientId && pathname.startsWith('/doctor') && ( + <> + + + + )} diff --git a/securemed-frontend/components/layout/top-navigation.tsx b/securemed-frontend/components/layout/top-navigation.tsx index 0c93bc9..98b63eb 100644 --- a/securemed-frontend/components/layout/top-navigation.tsx +++ b/securemed-frontend/components/layout/top-navigation.tsx @@ -29,7 +29,7 @@ interface TopNavigationProps { } export function TopNavigation({ userType }: TopNavigationProps) { - const pathname = usePathname(); + const pathname = usePathname() ?? ''; const router = useRouter(); const { user, logout } = useAuth(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -79,6 +79,7 @@ export function TopNavigation({ userType }: TopNavigationProps) { { name: 'Staff', href: '/admin/staff' }, { name: 'Patients', href: '/admin/patients' }, { name: 'Billing', href: '/admin/billing' }, + { name: 'Ward Map', href: '/admin/ward-map' }, { name: 'Infections', href: '/admin/infection-tracking' }, { name: 'Audit Logs', href: '/admin/audit-logs' }, ]; @@ -128,7 +129,9 @@ export function TopNavigation({ userType }: TopNavigationProps) { {/* Desktop Navigation */}
- - + - + + - {recentVerifications.map((verification: any) => ( - - - - + - - + ) : (billingSummary?.recent_invoices ?? []).length === 0 ? ( + + - ))} + ) : ( + (billingSummary?.recent_invoices ?? []).slice(0, 5).map((invoice) => ( + + + + + + + + )) + )}
{result.sample_id || '—'} {result.test_name || '—'}
PatientPolicy NumberProviderInvoice ID StatusTimestampIssuedTotal
{verification.patientName}{verification.policyNumber} - - {verification.provider} + {loadingSummary ? ( +
+ Loading recent invoices... - - {getStatusIcon(verification.status)} - {verification.status} - - - {new Date(verification.timestamp).toLocaleString()} +
+ No recent invoices found.
{invoice.patient}{invoice.invoice_id} + + {getStatusIcon(invoice.status)} + {invoice.status} + + + {new Date(invoice.issue_date).toLocaleDateString()} + + ${Number(invoice.total || 0).toFixed(2)} +
diff --git a/securemed-frontend/components/portals/admin/infection-tracking/constants.ts b/securemed-frontend/components/portals/admin/infection-tracking/constants.ts index 16cfc61..8706c0a 100644 --- a/securemed-frontend/components/portals/admin/infection-tracking/constants.ts +++ b/securemed-frontend/components/portals/admin/infection-tracking/constants.ts @@ -19,21 +19,21 @@ export const NODE_EMOJI: Record = { }; export const REL_COLORS: Record = { - SAW: '#3b82f680', - VISITED: '#f59e0b80', - WORKED_IN: '#10b98180', - USED_EQUIPMENT: '#8b5cf680', - PART_OF: '#6b728040', - BELONGS_TO: '#6b728040', + SAW: '#3b82f6b3', + VISITED: '#f59e0bb3', + WORKED_IN: '#10b981b3', + USED_EQUIPMENT: '#8b5cf6b3', + PART_OF: '#94a3b880', + BELONGS_TO: '#94a3b880', }; /** Node radius by type. */ export const NODE_RADIUS: Record = { - Patient: 14, - Doctor: 14, - Room: 10, - Equipment: 10, - Department: 18, + Patient: 16, + Doctor: 16, + Room: 12, + Equipment: 12, + Department: 20, }; export const VECTOR_LABELS: Record = { diff --git a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx index 3c84664..9226075 100644 --- a/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx +++ b/securemed-frontend/components/portals/admin/infection-tracking/force-graph.tsx @@ -28,11 +28,12 @@ type ForceGraphProps = { data: GraphVisualization; highlightTrace: InfectionTrace | null; isActive: boolean; + focusTrace?: boolean; }; -const GRAPH_HEIGHT = 500; +const GRAPH_HEIGHT = 580; const HIT_CELL_SIZE = 56; -const MAX_DPR = 1.5; +const MAX_DPR = 1.25; function getCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } { return { @@ -105,7 +106,7 @@ function filterGraphToTrace(data: GraphVisualization, trace: InfectionTrace | nu }; } -export default function ForceGraph({ data, highlightTrace, isActive }: ForceGraphProps) { +export default function ForceGraph({ data, highlightTrace, isActive, focusTrace = false }: ForceGraphProps) { const canvasRef = useRef(null); const workerRef = useRef(null); const pendingWorkerIdRef = useRef(0); @@ -125,7 +126,10 @@ export default function ForceGraph({ data, highlightTrace, isActive }: ForceGrap const linksRef = useRef([]); const gridRef = useRef>(new Map()); const nodeByIdRef = useRef>(new Map()); - const displayData = useMemo(() => filterGraphToTrace(data, highlightTrace), [data, highlightTrace]); + const displayData = useMemo( + () => (focusTrace ? filterGraphToTrace(data, highlightTrace) : data), + [data, highlightTrace, focusTrace] + ); useEffect(() => { latestHighlightRef.current = highlightTrace; @@ -181,8 +185,8 @@ export default function ForceGraph({ data, highlightTrace, isActive }: ForceGrap ctx.beginPath(); ctx.moveTo(link.sourceNode.x, link.sourceNode.y); ctx.lineTo(link.targetNode.x, link.targetNode.y); - ctx.strokeStyle = isHighlighted ? '#ef4444' : REL_COLORS[link.relationship] || '#33333330'; - ctx.lineWidth = isHighlighted ? 3 : 1; + ctx.strokeStyle = isHighlighted ? '#ef4444' : REL_COLORS[link.relationship] || '#94a3b8b3'; + ctx.lineWidth = isHighlighted ? 3.2 : 1.4; ctx.stroke(); } @@ -204,15 +208,15 @@ export default function ForceGraph({ data, highlightTrace, isActive }: ForceGrap ctx.shadowBlur = 0; ctx.strokeStyle = isHighlighted ? '#fca5a5' : '#ffffff'; - ctx.lineWidth = isHighlighted ? 2 : 1.5; + ctx.lineWidth = isHighlighted ? 2.2 : 1.6; ctx.stroke(); if (renderLabels) { const raw = node.label || node.id; const label = raw.length > 12 ? `${raw.slice(0, 10)}...` : raw; - ctx.font = `bold ${node.radius > 12 ? 10 : 8}px Inter, system-ui, sans-serif`; + ctx.font = `600 ${node.radius > 12 ? 11 : 9}px "Space Grotesk", system-ui, sans-serif`; ctx.textAlign = 'center'; - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = '#0f172a'; ctx.fillText(label, node.x, node.y + 3); } } @@ -294,7 +298,7 @@ export default function ForceGraph({ data, highlightTrace, isActive }: ForceGrap links, width, height, - iterations: 120, + iterations: 90, }); }, [displayData, isActive, canvasSize, scheduleDraw]); @@ -361,21 +365,22 @@ export default function ForceGraph({ data, highlightTrace, isActive }: ForceGrap const hasNoGraphData = !Array.isArray(data?.nodes) || data.nodes.length === 0; + const nodeCount = displayData?.nodes?.length ?? 0; + const linkCount = displayData?.links?.length ?? 0; + return ( -
-
-
- -

Contact Network Graph

-
-
- {Object.entries(NODE_COLORS).map(([type, color]) => ( - - - {type} - - ))} -
+
+ - {stats && ( -
- - - - -
- )} +
+ + {stats && ( +
+ + + + +
+ )} +
{graphData && (
-

Contact Graph

-

- {activeTrace - ? 'Focused on the selected trace only, so the contact path and timing are easier to read.' - : 'Showing the full hospital contact network. Select a trace above to isolate its chain.'} -

+
+
+

Contact Network Graph

+

+ {activeTrace + ? 'Focused on the selected trace only, so the contact path and timing are easier to read.' + : 'Showing the full hospital contact network. Select a trace above to isolate its chain.'} +

+
+
+ + {Object.entries(NODE_COLORS).map(([type, color]) => ( + + + {type} + + ))} +
+
- +
)} diff --git a/securemed-frontend/components/portals/admin/infection-tracking/trace-table.tsx b/securemed-frontend/components/portals/admin/infection-tracking/trace-table.tsx index 393a231..6447a0d 100644 --- a/securemed-frontend/components/portals/admin/infection-tracking/trace-table.tsx +++ b/securemed-frontend/components/portals/admin/infection-tracking/trace-table.tsx @@ -461,12 +461,24 @@ function describeStep(from: PathNode, relationship: PathRelationship, to: PathNo switch (relationship.relationship) { case 'VISITED': + if (from.type === 'Room' && to.type === 'Patient') { + return `${toLabel} visited ${fromLabel}${atTime}.`; + } return `${fromLabel} visited ${toLabel}${atTime}.`; case 'SAW': + if (from.type === 'Doctor' && to.type === 'Patient') { + return `${toLabel} consulted ${doctorLabel(fromLabel)}${atTime}.`; + } return `${fromLabel} consulted ${doctorLabel(toLabel)}${atTime}.`; case 'WORKED_IN': + if (from.type === 'Room' && to.type === 'Doctor') { + return `${doctorLabel(toLabel)} worked in ${fromLabel}${atTime}.`; + } return `${doctorLabel(fromLabel)} worked in ${toLabel}${atTime}.`; case 'USED_EQUIPMENT': + if (from.type === 'Equipment' && to.type === 'Patient') { + return `${toLabel} used ${fromLabel}${atTime}.`; + } return `${fromLabel} used ${toLabel}${atTime}.`; case 'PART_OF': return `${fromLabel} belongs to ${toLabel}.`; diff --git a/securemed-frontend/components/portals/doctor/dashboard/doctor-dashboard.tsx b/securemed-frontend/components/portals/doctor/dashboard/doctor-dashboard.tsx index 7fc5565..b9ee8a9 100644 --- a/securemed-frontend/components/portals/doctor/dashboard/doctor-dashboard.tsx +++ b/securemed-frontend/components/portals/doctor/dashboard/doctor-dashboard.tsx @@ -21,7 +21,7 @@ interface DoctorDashboardProps { onAcceptAppointment: (appt: Appointment) => void; formatTime: (time: string) => string; getStatusBadge: (status: string) => React.ReactNode; - onStartVideoCall?: (roomId: string) => void; + onStartVideoCall?: (appt: Appointment) => void; } export default function DoctorDashboard({ @@ -104,7 +104,7 @@ export default function DoctorDashboard({ size="sm" variant="outline" className="h-8 w-8 p-0 ml-2 border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-400" - onClick={() => onStartVideoCall('demo-room-1')} + onClick={() => onStartVideoCall(apt)} title="Start Telehealth Session" >
diff --git a/securemed-frontend/components/portals/doctor/patients/patient-profile-view.tsx b/securemed-frontend/components/portals/doctor/patients/patient-profile-view.tsx index 66fbf41..c4ce782 100644 --- a/securemed-frontend/components/portals/doctor/patients/patient-profile-view.tsx +++ b/securemed-frontend/components/portals/doctor/patients/patient-profile-view.tsx @@ -18,6 +18,11 @@ interface Patient { status: 'Admitted' | 'Outpatient' | 'Observation'; lastVisit: string; condition: string; + gender?: string; + dateOfBirth?: string; + bloodType?: string; + allergies?: string[]; + medicalHistory?: string[]; } interface PatientProfileViewProps { @@ -319,11 +324,11 @@ export default function PatientProfileView({ patient, onBack }: PatientProfileVi id: patient.id, name: patient.name, age: patient.age, - gender: 'Unknown', - dateOfBirth: 'Unknown', - bloodType: 'Unknown', - allergies: [], - medicalHistory: [] + gender: patient.gender || 'Unknown', + dateOfBirth: patient.dateOfBirth || 'Unknown', + bloodType: patient.bloodType || 'Unknown', + allergies: patient.allergies || [], + medicalHistory: patient.medicalHistory || [] }} />
diff --git a/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx b/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx index bf2c29f..35d06fd 100644 --- a/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx +++ b/securemed-frontend/components/portals/doctor/records/doctor-medical-records.tsx @@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Eye, FileText, Pill, Stethoscope, Search, Calendar, User, Plus } from 'lucide-react'; import api from '@/lib/api'; +import { API_ORIGIN } from '@/lib/urls'; import { Dialog, DialogContent, @@ -26,6 +27,8 @@ interface DoctorMedicalRecordsProps { export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecordsProps) { const searchParams = useSearchParams(); const { toast } = useToast(); + const urlPatientId = searchParams?.get('patient_id') || ''; + const resolvedPatientId = patientId || urlPatientId || ''; const [medicalRecords, setMedicalRecords] = useState([]); const [prescriptions, setPrescriptions] = useState([]); const [loading, setLoading] = useState(true); @@ -36,7 +39,7 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords const [createOpen, setCreateOpen] = useState(false); const [creating, setCreating] = useState(false); const [newRecord, setNewRecord] = useState({ - patient_id: patientId || '', + patient_id: resolvedPatientId, record_type: '', record_date: new Date().toISOString().slice(0, 10), diagnosis: '', @@ -56,6 +59,12 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords } }, [searchParams]); + useEffect(() => { + if (resolvedPatientId) { + setNewRecord((prev) => ({ ...prev, patient_id: resolvedPatientId })); + } + }, [resolvedPatientId]); + // Debounce search input useEffect(() => { const timer = setTimeout(() => { @@ -71,8 +80,8 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords if (search?.trim()) { params.search = search.trim(); } - if (patientId?.trim()) { - params.patient_id = patientId.trim(); + if (resolvedPatientId?.trim()) { + params.patient_id = resolvedPatientId.trim(); } const [recordsResponse, prescriptionsResponse] = await Promise.all([ @@ -96,11 +105,11 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords } finally { setLoading(false); } - }, [patientId]); + }, [resolvedPatientId]); useEffect(() => { fetchRecords(debouncedSearch); - }, [patientId, debouncedSearch, fetchRecords]); + }, [resolvedPatientId, debouncedSearch, fetchRecords]); const filteredRecords = medicalRecords.filter(record => { const matchesFilter = filterType === 'all' || record.record_type === filterType; @@ -152,7 +161,7 @@ export default function DoctorMedicalRecords({ patientId }: DoctorMedicalRecords

Medical Records

- {patientId ? 'Patient medical history and records' : 'Manage your practice and patients'} + {resolvedPatientId ? 'Patient medical history and records' : 'Manage your practice and patients'}

-
diff --git a/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx b/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx index 9e1c6d3..a635719 100644 --- a/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx +++ b/securemed-frontend/components/portals/patient/dashboard/anatomy-education-card.tsx @@ -12,7 +12,21 @@ import { } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { AnatomySelectionPayload, REGION_LOOKUP } from '@/components/features/anatomy/region-map'; +import { Button } from '@/components/ui/button'; +import { Slider } from '@/components/ui/slider'; +import { + AnatomySelectionPayload, + REGION_LOOKUP, + deriveSymptomsFromRegions, +} from '@/components/features/anatomy/region-map'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { AnatomyRegionExplainer, ConditionCatalogItem, @@ -44,6 +58,7 @@ export default function AnatomyEducationCard() { }); const [activeRegion, setActiveRegion] = useState(null); const [explainer, setExplainer] = useState(null); + const [explainerLoading, setExplainerLoading] = useState(false); // ── condition state ───────────────────────────────────────────────────── const [conditions, setConditions] = useState([]); @@ -51,14 +66,27 @@ export default function AnatomyEducationCard() { const [visualization, setVisualization] = useState(null); const [visualRegion, setVisualRegion] = useState(null); const [conditionMatches, setConditionMatches] = useState([]); + const [catalogLoading, setCatalogLoading] = useState(false); // ── ui state ──────────────────────────────────────────────────────────── const [loading, setLoading] = useState(false); const [matching, setMatching] = useState(false); const [error, setError] = useState(null); + const [step, setStep] = useState<1 | 2 | 3 | 4>(1); + const [wizardOpen, setWizardOpen] = useState(false); + const [wizardRegion, setWizardRegion] = useState('chest'); + const [wizardPain, setWizardPain] = useState(6); + const [wizardConcern, setWizardConcern] = useState('pain'); // Derived: which mode does the canvas operate in? const canvasMode = activeConditionId && visualization ? 'condition' : 'selection'; + const stepLabels = ['Select Regions', 'Rate Pain', 'Suggested Conditions', 'Anatomy Explainer']; + const canGoNext = useMemo(() => { + if (step === 1) return selection.selectedRegions.length > 0; + if (step === 2) return selection.selectedRegions.length > 0; + if (step === 3) return Boolean(activeConditionId || conditionMatches.length > 0); + return false; + }, [step, selection.selectedRegions.length, activeConditionId, conditionMatches.length]); const patientFocusScore = useMemo(() => { const r = selection.selectedRegions.length * 20; @@ -70,6 +98,7 @@ export default function AnatomyEducationCard() { // Fetch condition catalog once useEffect(() => { let mounted = true; + setCatalogLoading(true); fetchConditionCatalog('top20', 'patient') .then((data) => { if (mounted) { setConditions(data); setError(null); } }) .catch((e: any) => { if (mounted) setError(e?.response?.data?.error || 'Unable to load conditions.'); }); @@ -80,9 +109,11 @@ export default function AnatomyEducationCard() { useEffect(() => { if (!activeRegion) { setExplainer(null); return; } let mounted = true; + setExplainerLoading(true); fetchRegionExplainer(activeRegion, 'patient') .then((data) => { if (mounted) { setExplainer(data); setError(null); } }) - .catch((e: any) => { if (mounted) { setExplainer(null); setError(e?.response?.data?.error || 'Unable to load explainer.'); } }); + .catch((e: any) => { if (mounted) { setExplainer(null); setError(e?.response?.data?.error || 'Unable to load explainer.'); } }) + .finally(() => { if (mounted) setExplainerLoading(false); }); return () => { mounted = false; }; }, [activeRegion]); @@ -101,6 +132,13 @@ export default function AnatomyEducationCard() { const handleSelectionChange = (payload: AnatomySelectionPayload) => { setSelection(payload); setActiveRegion(payload.selectedRegions[payload.selectedRegions.length - 1] || null); + if (payload.selectedRegions.length === 0) { + setStep(1); + setActiveConditionId(''); + setConditionMatches([]); + return; + } + setStep((prev) => (prev < 2 ? 2 : prev)); }; // When a condition is selected, clear body selection (canvas switches to condition mode) @@ -148,6 +186,38 @@ export default function AnatomyEducationCard() { setActiveRegion(regionId); }; + const handleClearSelection = () => { + setSelection({ selectedRegions: [], selectedSymptoms: [], intensityByRegion: {} }); + setActiveRegion(null); + setConditionMatches([]); + setStep(1); + }; + + const applySelection = (regions: string[], intensity: Record) => { + const selectedSymptoms = deriveSymptomsFromRegions(regions); + setSelection({ selectedRegions: regions, selectedSymptoms, intensityByRegion: intensity }); + setActiveRegion(regions[regions.length - 1] || null); + setActiveConditionId(''); + setConditionMatches([]); + setStep(2); + }; + + const handleNext = () => { + if (!canGoNext) return; + setStep((prev) => (prev < 4 ? ((prev + 1) as 2 | 3 | 4) : prev)); + }; + + const handleBack = () => { + setStep((prev) => (prev > 1 ? ((prev - 1) as 1 | 2 | 3) : prev)); + }; + + const updatePainLevel = (regionId: string, value: number) => { + setSelection((prev) => ({ + ...prev, + intensityByRegion: { ...prev.intensityByRegion, [regionId]: value }, + })); + }; + const currentConditionPain = visualization && visualRegion ? (visualization.region_pain_levels?.[visualRegion] ?? 5) : null; @@ -165,15 +235,34 @@ export default function AnatomyEducationCard() { ); return ( - + <> + {/* ── Header ─────────────────────────────────────────────── */} -
+

Anatomy Education & Condition Visualization

+
+ + + +
- {/* Mode indicator */} {visualization ? ( @@ -195,158 +284,160 @@ export default function AnatomyEducationCard() {
- {/* ── Layout: [Body SVG] | [Info panel] ──────────────── */} -
- - {/* ── Left: Body SVG ──────────────────────── */} -
- -
- - {/* ── Right: Info panel ─────────────────────────────────── */} -
- - {/* Condition selector always visible at top */} -
-
- -

Condition Visualization

-
- - {activeConditionId && ( +
+ {/* ── Left: Body Explorer ─────────────────────────────── */} +
+
+ +
+
+

Selected Regions

+ {selection.selectedRegions.length > 0 ? ( +
+ {selection.selectedRegions.map((regionId) => ( + + {REGION_LOOKUP[regionId]?.label || regionId} + + ))} +
+ ) : ( +

Tap a body region to begin.

+ )} + {selection.selectedRegions.length > 0 && !activeConditionId && ( )}
+
-
- - {/* ── Condition panel ───────────────────── */} - {visualization && ( -
-
-

{visualization.name}

-

{visualization.overview}

-
- - {/* Affected regions as selectable chips */} - {visualization.regions.length > 0 && ( -
-

Affected Regions

-
- {visualization.regions.map((regionId) => ( - - ))} -
-
- )} - - {visualRegion && currentInterpretation && ( -
-

Pain interpretation

-

- {REGION_LOOKUP[visualRegion]?.label || visualRegion}: {currentConditionPain}/10 -

-

{currentInterpretation.message}

-
- )} - - {showEmergencyAlert && ( -
-
- -

- High-intensity pain in this pattern may indicate an urgent condition. Seek emergency care immediately. -

+ {/* ── Right: Guided Flow ─────────────────────────────── */} +
+ {/* Step header */} +
+
+ {stepLabels.map((label, idx) => { + const stepIndex = (idx + 1) as 1 | 2 | 3 | 4; + const active = step === stepIndex; + const completed = step > stepIndex; + return ( +
+
+ {idx + 1} +
+ {idx < stepLabels.length - 1 && ( +
+ )}
-
- )} + ); + })} +
+ Step {step} of 4 +
- {/* Typical symptoms */} - {visualization.typical_symptoms.length > 0 && ( -
-

Typical Symptoms

-
- {visualization.typical_symptoms.map((s) => ( - - {s} - - ))} -
+ {/* Step 1: Select Regions */} + {step === 1 && ( +
+

Select the area that hurts

+

+ Tap one or more regions on the body to get tailored education and condition suggestions. +

+ {selection.selectedRegions.length === 0 && ( +
+ No regions selected yet.
)} +
+ )} - {/* Condition pins */} - {visualization.pins.length > 0 && ( -
-

Condition Markers

- {visualization.pins.map((pin) => ( -
-
- {pin.label} - {pin.severity} + {/* Step 2: Rate Pain */} + {step === 2 && ( +
+

Rate your pain intensity

+ {selection.selectedRegions.length === 0 ? ( +

Select a region to rate pain.

+ ) : ( +
+ {selection.selectedRegions.map((regionId) => { + const pain = selection.intensityByRegion[regionId] ?? 5; + return ( +
+
+ + {REGION_LOOKUP[regionId]?.label || regionId} + + = 7 ? '#ef4444' : pain >= 4 ? '#f97316' : '#fbbf24' }}> + {pain}/10 + +
+ updatePainLevel(regionId, value?.[0] ?? 5)} + />
-

{pin.text}

-
- ))} -
- )} - - {/* Seek care rules */} - {visualization.seek_care_rules.length > 0 && ( -
-
- -

When to Seek Care

+ ); + })} +
+ 1 — Minimal + 5 — Moderate + 10 — Worst
-
    - {visualization.seek_care_rules.map((rule) => ( -
  • {rule}
  • - ))} -
)}
)} - {/* ── Region explainer panel (explore mode) ── */} - {!visualization && ( - <> - {/* AI condition suggestions from body + pain input */} + {/* Step 3: Suggested Conditions */} + {step === 3 && ( +
+
+
+ +

Condition Visualization

+ {catalogLoading && ( + Loading... + )} +
+ + {activeConditionId && ( + + )} +
+ {selection.selectedRegions.length > 0 && (
@@ -379,13 +470,14 @@ export default function AnatomyEducationCard() {
) : (

- {matching ? 'Generating AI matches...' : 'Select regions and pain levels to get AI-matched conditions.'} + {matching + ? 'Generating matches from your selected regions...' + : 'Select one or more regions and rate pain to see educational condition suggestions. This is not a diagnosis.'}

)}
)} - {/* Derived symptoms from selected regions */} {selection.selectedSymptoms.length > 0 && (

Region-Derived Symptoms

@@ -399,9 +491,114 @@ export default function AnatomyEducationCard() {
)} + {visualization && ( +
+
+

{visualization.name}

+

{visualization.overview}

+
+ + {visualization.regions.length > 0 && ( +
+

Affected Regions

+
+ {visualization.regions.map((regionId) => ( + + ))} +
+
+ )} + + {visualRegion && currentInterpretation && ( +
+

Pain interpretation

+

+ {REGION_LOOKUP[visualRegion]?.label || visualRegion}: {currentConditionPain}/10 +

+

{currentInterpretation.message}

+
+ )} + + {showEmergencyAlert && ( +
+
+ +

+ High-intensity pain in this pattern may indicate an urgent condition. Seek emergency care immediately. +

+
+
+ )} + + {visualization.typical_symptoms.length > 0 && ( +
+

Typical Symptoms

+
+ {visualization.typical_symptoms.map((s) => ( + + {s} + + ))} +
+
+ )} + + {visualization.pins.length > 0 && ( +
+

Condition Markers

+ {visualization.pins.map((pin) => ( +
+
+ {pin.label} + {pin.severity} +
+

{pin.text}

+
+ ))} +
+ )} + + {visualization.seek_care_rules.length > 0 && ( +
+
+ +

When to Seek Care

+
+
    + {visualization.seek_care_rules.map((rule) => ( +
  • {rule}
  • + ))} +
+
+ )} +
+ )} +
+ )} + + {/* Step 4: Explainer */} + {step === 4 && ( +

Region Explainer

+ {explainerLoading && ( + Loading... + )}
{activeRegion && explainer ? ( @@ -452,18 +649,91 @@ export default function AnatomyEducationCard() { )}
) : ( -
+
-

Click a body region
to read anatomy education

-

or select a condition above

+

Select a body region to load anatomy education.

)} - +
)} + +
+ + +
{error &&

{error}

} - + + + + + + Guided Symptom Wizard + + Pick a region and pain level to generate educational suggestions. Not a diagnosis. + + +
+
+ + +
+
+ + +
+
+ + setWizardPain(value?.[0] ?? 5)} + /> +
Selected: {wizardPain}/10
+
+
+ + + + +
+
+ ); } diff --git a/securemed-frontend/components/portals/patient/dashboard/dashboard.tsx b/securemed-frontend/components/portals/patient/dashboard/dashboard.tsx index 671cff0..fcae028 100644 --- a/securemed-frontend/components/portals/patient/dashboard/dashboard.tsx +++ b/securemed-frontend/components/portals/patient/dashboard/dashboard.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; +import dynamic from 'next/dynamic'; import { Card } from '@/components/ui/card'; import { Calendar, Clock } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -12,8 +13,8 @@ import ActivePrescriptionsCard from './active-prescriptions-card'; import LabResultsCard from './lab-results-card'; import RecentRecordsCard from './recent-records-card'; import BillingSummaryCard from './billing-summary-card'; -import HealthInsightsCard from './health-insights-card'; -import AnatomyEducationCard from './anatomy-education-card'; +const HealthInsightsCard = dynamic(() => import('./health-insights-card'), { ssr: false }); +const AnatomyEducationCard = dynamic(() => import('./anatomy-education-card'), { ssr: false }); import { getDashboardStats } from '@/lib/api'; import { getAccessToken } from '@/lib/auth-utils'; @@ -30,6 +31,8 @@ export default function PatientDashboard({ onNavigate }: PatientDashboardProps) const [appointments, setAppointments] = useState([]); const [dashboardStats, setDashboardStats] = useState(null); const [loading, setLoading] = useState(true); + const anatomyRef = useRef(null); + const [showAnatomy, setShowAnatomy] = useState(false); useEffect(() => { const fetchData = async () => { @@ -78,6 +81,20 @@ export default function PatientDashboard({ onNavigate }: PatientDashboardProps) fetchData(); }, []); + useEffect(() => { + if (!anatomyRef.current || showAnatomy) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShowAnatomy(true); + } + }, + { rootMargin: '120px' } + ); + observer.observe(anatomyRef.current); + return () => observer.disconnect(); + }, [showAnatomy]); + const formatDate = (dateStr: string) => { const date = new Date(dateStr); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); @@ -163,7 +180,15 @@ export default function PatientDashboard({ onNavigate }: PatientDashboardProps) {/* Row 4: Anatomy Education & Condition Visualization */} - +
+ {showAnatomy ? ( + + ) : ( +
+ Loading anatomy module… +
+ )} +
{/* Row 5: Upcoming Appointments */} @@ -187,7 +212,7 @@ export default function PatientDashboard({ onNavigate }: PatientDashboardProps)
- {apt.doctor_name ? apt.doctor_name.charAt(4) : 'D'} + {apt.doctor_name?.trim().charAt(0) || 'D'}

{apt.doctor_name || 'Doctor'}

diff --git a/securemed-frontend/components/portals/patient/dashboard/lab-results-card.tsx b/securemed-frontend/components/portals/patient/dashboard/lab-results-card.tsx index 03eb56a..a2ea46c 100644 --- a/securemed-frontend/components/portals/patient/dashboard/lab-results-card.tsx +++ b/securemed-frontend/components/portals/patient/dashboard/lab-results-card.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import api from '@/lib/api'; import { useToast } from '@/hooks/use-toast'; +import { API_ORIGIN } from '@/lib/urls'; import { Dialog, DialogContent, @@ -62,7 +63,7 @@ export default function LabResultsCard({ results, onNavigate }: LabResultsCardPr }); return; } - const viewUrl = url.startsWith('http') ? url : `${window.location.origin}${url}`; + const viewUrl = url.startsWith('http') ? url : `${API_ORIGIN}${url}`; window.open(viewUrl, '_blank', 'noopener,noreferrer'); } catch (error) { toast({ diff --git a/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx b/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx index b8f733d..991f102 100644 --- a/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx +++ b/securemed-frontend/components/portals/patient/dashboard/patient-timeline.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { patientService, TimelineEvent } from '@/services/patients'; import api from '@/lib/api'; +import { API_ORIGIN } from '@/lib/urls'; import { Calendar, FileText, @@ -146,14 +147,29 @@ export default function PatientTimeline({ patientId, className }: EnhancedPatien }; const parseEventId = (id: string) => { - const parts = id.split('-').filter(Boolean); - if (parts.length < 2) { - return { type: id, rawId: '' }; + if (!id) { + return { type: '', rawId: '' }; } - return { - type: parts.slice(0, -1).join('-'), - rawId: parts[parts.length - 1] - }; + if (/^\d+$/.test(id)) { + return { type: 'id', rawId: id }; + } + if (id.includes('_')) { + const parts = id.split('_').filter(Boolean); + if (parts.length >= 2) { + return { type: parts[0], rawId: parts.slice(1).join('_') }; + } + } + if (id.includes('-')) { + const parts = id.split('-').filter(Boolean); + if (parts.length >= 2) { + const last = parts[parts.length - 1]; + if (/^\d+$/.test(last)) { + return { type: parts.slice(0, -1).join('-'), rawId: last }; + } + return { type: parts[0], rawId: parts.slice(1).join('-') }; + } + } + return { type: id, rawId: '' }; }; const openLabAttachment = async (labResultId: number) => { @@ -168,7 +184,7 @@ export default function PatientTimeline({ patientId, className }: EnhancedPatien }); return; } - const viewUrl = url.startsWith('http') ? url : `${window.location.origin}${url}`; + const viewUrl = url.startsWith('http') ? url : `${API_ORIGIN}${url}`; window.open(viewUrl, '_blank', 'noopener,noreferrer'); } catch (error) { toast({ @@ -182,41 +198,44 @@ export default function PatientTimeline({ patientId, className }: EnhancedPatien const handleViewDetails = async (event: TimelineEvent) => { if (!event?.id) return; const parsed = parseEventId(event.id); - const eventType = (event.type || parsed.type || '').toLowerCase(); + const normalizedType = (event.type || parsed.type || '').toLowerCase(); const rawId = parsed.rawId; + const category = event.category?.toLowerCase(); + const isAppointment = category === 'appointment' || ['appointment', 'appt'].includes(normalizedType); + const isRecord = ['record', 'rec', 'medical_record'].includes(normalizedType); + const isLabResult = ['lab-result', 'lab_result'].includes(normalizedType); + const isLabOrder = ['lab-order', 'lab_order'].includes(normalizedType); + const isLab = category === 'lab' || isLabResult || isLabOrder || normalizedType === 'lab'; + const isInvoice = category === 'billing' || ['invoice', 'inv'].includes(normalizedType); + const isMedication = category === 'medication' || ['pharmacy', 'prescription', 'rx'].includes(normalizedType); + const labResultId = Number(event.details?.lab_result_id || event.details?.result_id || rawId); + const hasLabResultId = Number.isFinite(labResultId) && labResultId > 0; - if (eventType === 'appointment') { + if (isAppointment) { router.push(`/patient/appointments?appointmentId=${rawId}`); return; } - if (eventType === 'record') { + if (isRecord) { router.push(`/patient/records?recordId=${rawId}`); return; } - if (eventType === 'lab-result') { - const idNumber = Number(rawId); - if (Number.isFinite(idNumber) && idNumber > 0) { - if (event.details?.has_attachment) { - await openLabAttachment(idNumber); - return; - } - toast({ - title: 'No attachment found', - description: 'This lab result does not include a report file.', - variant: 'destructive' - }); + if (isLabResult || (isLab && hasLabResultId && !isLabOrder)) { + if (hasLabResultId) { + await openLabAttachment(labResultId); + return; } + router.push('/patient/records'); return; } - if (eventType === 'lab-order') { + if (isLabOrder || isLab) { router.push('/patient/records'); return; } - if (eventType === 'invoice') { + if (isInvoice) { router.push(`/patient/billing?invoiceId=${rawId}`); return; } - if (eventType === 'pharmacy' || eventType === 'prescription') { + if (isMedication) { router.push('/patient/records'); return; } diff --git a/securemed-frontend/components/portals/patient/records/medical-records.tsx b/securemed-frontend/components/portals/patient/records/medical-records.tsx index bf58896..4010309 100644 --- a/securemed-frontend/components/portals/patient/records/medical-records.tsx +++ b/securemed-frontend/components/portals/patient/records/medical-records.tsx @@ -10,6 +10,7 @@ import { medicalRecordService } from '@/services/appointments'; import { drugInteractionService } from '@/services/drug-interactions'; import FHIRExportButton from '@/components/portals/patient/records/fhir-export-button'; import { UploadRecordDialog } from '@/components/portals/patient/records/upload-record-dialog'; +import { API_ORIGIN } from '@/lib/urls'; interface MedicalRecordsProps { patientId?: string; @@ -343,11 +344,17 @@ export default function MedicalRecords({ patientId }: MedicalRecordsProps) {

{record.record_type_display || 'Medical Record'}

{record.diagnosis}

- {record.file && ( + {(record.file_url || record.file) && ( + )}
+ {joinError && ( +

+ {joinError} +

+ )} +

Room ID: {roomId?.slice(0, 8) || 'N/A'}...

diff --git a/securemed-frontend/context/auth-context.tsx b/securemed-frontend/context/auth-context.tsx index 8e2014c..85c3620 100644 --- a/securemed-frontend/context/auth-context.tsx +++ b/securemed-frontend/context/auth-context.tsx @@ -382,6 +382,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setTokens(null); localStorage.removeItem('auth_tokens'); localStorage.removeItem('auth_user'); + localStorage.setItem('post_logout_redirect', '/'); toast({ title: 'Logged out', diff --git a/securemed-frontend/lib/routes.ts b/securemed-frontend/lib/routes.ts index ed837bf..145393e 100644 --- a/securemed-frontend/lib/routes.ts +++ b/securemed-frontend/lib/routes.ts @@ -1,64 +1 @@ -import type { UserRole } from '@/lib/types'; - -// --------------------------------------------------------------------------- -// Named route constants -// --------------------------------------------------------------------------- -export const ROUTES = { - HOME: '/', - LOGIN: '/login', - REGISTER: '/register', - PORTAL: '/portal', - EMERGENCY: '/emergency', - LAB_TESTS: '/lab-tests', - - // Patient - PATIENT: '/patient', - PATIENT_DASHBOARD: '/patient/dashboard', - PATIENT_APPOINTMENTS: '/patient/appointments', - - // Doctor - DOCTOR: '/doctor', - DOCTOR_DASHBOARD: '/doctor/dashboard', - DOCTOR_PATIENTS: '/doctor/patients', - DOCTOR_TRIAGE_INBOX: '/doctor/triage-inbox', - - // Admin - ADMIN: '/admin', - ADMIN_DASHBOARD: '/admin/dashboard', - - // Lab - LAB: '/lab', - LAB_WORKLIST: '/lab/worklist', - - // Pharmacy - PHARMACY: '/pharmacy', - PHARMACY_DASHBOARD: '/pharmacy/dashboard', -} as const; - -// --------------------------------------------------------------------------- -// Valid URL tab segments per portal (must match portal component tab types) -// --------------------------------------------------------------------------- -export const VALID_TABS = { - admin: ['dashboard', 'analytics', 'hospitals', 'staff', 'patients', 'billing', 'infection-tracking', 'audit-logs'] as const, - lab: ['worklist', 'completed', 'reports', 'settings'] as const, - pharmacy: ['dashboard', 'orders', 'inventory'] as const, -}; - -// --------------------------------------------------------------------------- -// Role → portal landing (dashboard) mapping -// --------------------------------------------------------------------------- -const ROLE_ROUTES: Record = { - patient: ROUTES.PATIENT_DASHBOARD, - doctor: ROUTES.DOCTOR_DASHBOARD, - admin: ROUTES.ADMIN_DASHBOARD, - lab_technician: ROUTES.LAB_WORKLIST, - pharmacist: ROUTES.PHARMACY_DASHBOARD, -}; - -/** - * Returns the portal root route for a given user role. - * Falls back to '/portal' for unknown roles. - */ -export function getPortalRouteForRole(role: UserRole | string): string { - return ROLE_ROUTES[role] ?? ROUTES.PORTAL; -} +export * from '../app/lib/routes'; diff --git a/securemed-frontend/services/drug-interactions.ts b/securemed-frontend/services/drug-interactions.ts index 621db6d..81fe164 100644 --- a/securemed-frontend/services/drug-interactions.ts +++ b/securemed-frontend/services/drug-interactions.ts @@ -21,6 +21,11 @@ export interface InteractionCheckResult { visible_findings_count: number; findings_truncated: boolean; limit_findings: number; + summary?: { + total_findings: number; + total_combinations: number; + top_effects: string[]; + }; evaluated_combination_depth?: number; max_supported_combination_size?: number; not_evaluated_depths?: number[]; @@ -76,7 +81,7 @@ export const drugInteractionService = { async checkInteractions(medications: string[], patientId?: number): Promise { const response = await api.post('/medical-records/drug-interactions/check/', { medications, - limit_findings: 80, + limit_findings: 30, ...(patientId ? { patient_id: patientId } : {}), }); return response.data; @@ -85,8 +90,15 @@ export const drugInteractionService = { async getLatestReport(patientId?: number): Promise { const params: Record = {}; if (patientId) params.patient_id = patientId; - const response = await api.get('/medical-records/drug-interactions/reports/latest/', { params }); - return response.data || null; + try { + const response = await api.get('/medical-records/drug-interactions/reports/latest/', { params }); + return response.data || null; + } catch (error: any) { + if (error?.response?.status === 404) { + return null; + } + throw error; + } }, async getReportHistory(patientId?: number): Promise { diff --git a/securemed-frontend/services/infection-tracking.ts b/securemed-frontend/services/infection-tracking.ts index 71907c3..a51e1c0 100644 --- a/securemed-frontend/services/infection-tracking.ts +++ b/securemed-frontend/services/infection-tracking.ts @@ -133,6 +133,20 @@ function normalizeGraphVisualization(payload: unknown): GraphVisualization { }]; }); + const filteredLinks = links.filter( + (link) => link.relationship !== 'PART_OF' && link.relationship !== 'BELONGS_TO' + ); + const connectedIds = new Set(); + for (const link of filteredLinks) { + connectedIds.add(link.source); + connectedIds.add(link.target); + } + const filteredNodes = nodes.filter((node) => connectedIds.has(node.id)); + + if (filteredNodes.length >= 10 && filteredLinks.length >= 10) { + return { nodes: filteredNodes, links: filteredLinks }; + } + return { nodes, links }; } diff --git a/securemed-frontend/services/patients.ts b/securemed-frontend/services/patients.ts index 59b2ce9..6913a88 100644 --- a/securemed-frontend/services/patients.ts +++ b/securemed-frontend/services/patients.ts @@ -26,25 +26,48 @@ export const patientService = { }, getPatientTimeline: async (patientId?: string): Promise => { - try { - const params = patientId ? { patient_id: patientId } : {}; - const response = await api.get('/patients/timeline/', { params }); - const data = Array.isArray(response.data) ? response.data : (response.data?.results || []); - const categoryMap: Record = { - diagnostic: 'lab', - treatment: 'medication', - financial: 'billing', - consultation: 'appointment', - administrative: 'admin' - }; + const params = patientId ? { patient_id: patientId } : {}; + const categoryMap: Record = { + diagnostic: 'lab', + treatment: 'medication', + financial: 'billing', + consultation: 'appointment', + administrative: 'admin' + }; - return data.map((event: any) => ({ - ...event, - category: categoryMap[event.category] || event.category || 'admin' - })); - } catch (error) { - console.error('Error fetching patient timeline:', error); - return []; + const normalize = (payload: any) => { + const data = Array.isArray(payload) + ? payload + : (Array.isArray(payload?.timeline) ? payload.timeline : (payload?.results || [])); + return data.map((event: any) => { + const rawCategory = categoryMap[event.category] || event.category || 'admin'; + const rawType = String(event.type || '').toLowerCase(); + let inferredCategory = rawCategory; + + if (rawType.includes('appointment')) inferredCategory = 'appointment'; + else if (rawType.includes('medical_record') || rawType === 'record') inferredCategory = 'diagnosis'; + else if (rawType.includes('lab')) inferredCategory = 'lab'; + else if (rawType.includes('prescription') || rawType.includes('pharmacy')) inferredCategory = 'medication'; + else if (rawType.includes('invoice') || rawType.includes('billing')) inferredCategory = 'billing'; + + return { + ...event, + category: inferredCategory + }; + }); + }; + + try { + const response = await api.get('/medical-records/timeline/', { params }); + return normalize(response.data); + } catch (error: any) { + try { + const response = await api.get('/patients/timeline/', { params }); + return normalize(response.data); + } catch (fallbackError) { + console.error('Error fetching patient timeline:', fallbackError); + return []; + } } }, diff --git a/securemed-frontend/services/telemedicine.ts b/securemed-frontend/services/telemedicine.ts index af7ea12..1ab3259 100644 --- a/securemed-frontend/services/telemedicine.ts +++ b/securemed-frontend/services/telemedicine.ts @@ -34,12 +34,24 @@ export const videoService = { return response.data; }, + // Poll room status (Patient/Doctor) + checkRoomStatus: async (roomId: string): Promise<{ status: string; waiting_count?: number }> => { + const response = await api.get(`/telemedicine/rooms/${roomId}/status_check/`); + return response.data; + }, + // Start call (Doctor) startCall: async (roomId: string): Promise => { const response = await api.post(`/telemedicine/rooms/${roomId}/start/`); return response.data; }, + // Admit patient from waiting room (Doctor) + admitPatient: async (roomId: string): Promise => { + const response = await api.post(`/telemedicine/rooms/${roomId}/admit/`); + return response.data; + }, + // End call endCall: async (roomId: string): Promise => { const response = await api.post(`/telemedicine/rooms/${roomId}/end/`);