From d6bf0c20ee38de251514228367debcc5db3c8a57 Mon Sep 17 00:00:00 2001 From: ruby0322 Date: Tue, 28 Jul 2026 10:48:59 +0800 Subject: [PATCH 1/5] feat(admin): date-selectable dashboard with metric calendar Move staff homepage to /admin?date= with calendar metrics, attention patient cards, and history-overview deep links so triage is not today-only. Co-authored-by: Cursor --- apps/backend/app/api/routes/staff.py | 72 ++ apps/backend/app/schemas/staff_dashboard.py | 40 + apps/backend/app/services/staff_dashboard.py | 243 +++- .../app/services/staff_history_overview.py | 33 + apps/backend/app/services/taipei_dates.py | 10 +- .../backend/tests/test_staff_dashboard_api.py | 8 + .../tests/test_staff_today_attention_api.py | 336 +++++ apps/backend/tests/test_taipei_dates.py | 9 +- .../_components/active-uploaders-summary.tsx | 26 + .../_components/dashboard-day-calendar.tsx | 165 +++ .../_components/pending-bindings-summary.tsx | 52 + .../_components/recent-upload-thumbs.tsx | 134 ++ .../admin/_components/today-patient-pool.tsx | 50 + .../admin/_components/today-patient-row.tsx | 153 +++ .../admin/_components/today-upload-count.tsx | 34 + .../_components/today-workbench-header.tsx | 15 + .../history-overview/__tests__/page.test.tsx | 63 +- .../clinical-period-panel.tsx | 240 ++++ .../app/admin/history-overview/page.tsx | 278 ++--- .../history-overview/usage-trends-tab.tsx | 240 ++++ apps/frontend/app/admin/layout.tsx | 8 +- apps/frontend/app/admin/page.tsx | 1107 ++++------------- .../lib/admin/use-admin-selected-date.ts | 60 + apps/frontend/lib/api/staff.ts | 51 + docs/product/history-overview-api-contract.md | 7 +- .../2026-07-25-admin-daily-workbench-v2.md | 30 + ...07-25-admin-dashboard-homepage-renovate.md | 32 + ...6-07-25-admin-daily-workbench-v2-design.md | 121 ++ ...dmin-dashboard-homepage-renovate-design.md | 220 ++++ ...-25-admin-patient-card-risk-highlight.html | 136 ++ 30 files changed, 2899 insertions(+), 1074 deletions(-) create mode 100644 apps/backend/tests/test_staff_today_attention_api.py create mode 100644 apps/frontend/app/admin/_components/active-uploaders-summary.tsx create mode 100644 apps/frontend/app/admin/_components/dashboard-day-calendar.tsx create mode 100644 apps/frontend/app/admin/_components/pending-bindings-summary.tsx create mode 100644 apps/frontend/app/admin/_components/recent-upload-thumbs.tsx create mode 100644 apps/frontend/app/admin/_components/today-patient-pool.tsx create mode 100644 apps/frontend/app/admin/_components/today-patient-row.tsx create mode 100644 apps/frontend/app/admin/_components/today-upload-count.tsx create mode 100644 apps/frontend/app/admin/_components/today-workbench-header.tsx create mode 100644 apps/frontend/app/admin/history-overview/clinical-period-panel.tsx create mode 100644 apps/frontend/app/admin/history-overview/usage-trends-tab.tsx create mode 100644 apps/frontend/lib/admin/use-admin-selected-date.ts create mode 100644 docs/superpowers/plans/2026-07-25-admin-daily-workbench-v2.md create mode 100644 docs/superpowers/plans/2026-07-25-admin-dashboard-homepage-renovate.md create mode 100644 docs/superpowers/specs/2026-07-25-admin-daily-workbench-v2-design.md create mode 100644 docs/superpowers/specs/2026-07-25-admin-dashboard-homepage-renovate-design.md create mode 100644 docs/superpowers/specs/assets/2026-07-25-admin-patient-card-risk-highlight.html diff --git a/apps/backend/app/api/routes/staff.py b/apps/backend/app/api/routes/staff.py index 0abf63a..f7acd6f 100644 --- a/apps/backend/app/api/routes/staff.py +++ b/apps/backend/app/api/routes/staff.py @@ -50,6 +50,9 @@ StaffPatientUploadsResponse, StaffUploadQueueItem, StaffUploadQueueResponse, + StaffTodayAttentionPatientItem, + StaffTodayAttentionResponse, + StaffTodayAttentionRiskHighlight, StaffUploadRecord, StaffHistoryOverviewCalendarItem, StaffHistoryOverviewCalendarResponse, @@ -76,6 +79,7 @@ list_patient_upload_records_page, list_pending_bindings, list_staff_patients, + list_today_attention_patients, list_upload_queue, preview_delete_inactive_patients, update_pending_binding_status, @@ -495,6 +499,71 @@ async def get_staff_upload_queue( session.close() +@router.get("/v1/staff/uploads/today-attention", response_model=StaffTodayAttentionResponse) +async def get_staff_today_attention( + request: Request, + local_date: date | None = Query(default=None), + credentials=Depends(bearer_scheme), +) -> StaffTodayAttentionResponse: + principal = require_staff_or_admin(get_current_principal(request, credentials)) + session = get_session(request) + try: + accessible_patient_ids = _get_accessible_patient_ids( + session, + role=principal.role, + identity_id=principal.identity_id, + ) + today, total_uploads, rows = list_today_attention_patients( + session, + accessible_patient_ids=accessible_patient_ids, + local_date=local_date, + ) + suspected_patients = sum(1 for row in rows if row.tier == "suspected") + elevated_patients = sum(1 for row in rows if row.tier == "elevated") + other_patients = sum(1 for row in rows if row.tier == "other") + return StaffTodayAttentionResponse( + date=today.isoformat(), + total_uploads=total_uploads, + suspected_patients=suspected_patients, + elevated_patients=elevated_patients, + other_patients=other_patients, + items=[ + StaffTodayAttentionPatientItem( + patient_id=row.patient.id, + case_number=row.patient.case_number, + full_name=row.patient.full_name, + tier=row.tier, + representative_upload_id=row.representative_upload_id, + sort_upload_at=row.sort_upload_at, + has_annotation=row.has_annotation, + picture_url=row.picture_url, + day_upload_count=row.day_upload_count, + preview_upload_ids=row.preview_upload_ids, + risk_highlight=( + StaffTodayAttentionRiskHighlight( + upload_id=row.risk_highlight.upload_id, + screening_result=row.risk_highlight.screening_result, + probability=row.risk_highlight.probability, + threshold=row.risk_highlight.threshold, + symptom_pain=row.risk_highlight.symptom_pain, + symptom_discharge=row.risk_highlight.symptom_discharge, + symptom_pus=row.risk_highlight.symptom_pus, + symptom_cloudy_dialysate=row.risk_highlight.symptom_cloudy_dialysate, + has_high_risk_symptoms=row.risk_highlight.has_high_risk_symptoms, + symptom_aware_priority=row.risk_highlight.symptom_aware_priority, + created_at=row.risk_highlight.created_at, + ) + if row.risk_highlight is not None + else None + ), + ) + for row in rows + ], + ) + finally: + session.close() + + @router.get("/v1/staff/uploads/history-overview/days", response_model=StaffHistoryOverviewDaysResponse) async def get_staff_history_overview_days( request: Request, @@ -680,10 +749,13 @@ async def get_staff_history_overview_calendar( items=[ StaffHistoryOverviewCalendarItem( local_date=item.local_date.isoformat(), + upload_count=item.upload_count, + uploaded_users=item.uploaded_users, risky_patient_count=item.risky_patient_count, has_infection_risk=item.has_infection_risk, symptom_elevated_patient_count=item.symptom_elevated_patient_count, has_symptom_elevated_risk=item.has_symptom_elevated_risk, + unhandled_patient_count=item.unhandled_patient_count, ) for item in rows ], diff --git a/apps/backend/app/schemas/staff_dashboard.py b/apps/backend/app/schemas/staff_dashboard.py index 9ff1782..7b4ed1a 100644 --- a/apps/backend/app/schemas/staff_dashboard.py +++ b/apps/backend/app/schemas/staff_dashboard.py @@ -101,6 +101,43 @@ class StaffUploadQueueResponse(BaseModel): items: list[StaffUploadQueueItem] +class StaffTodayAttentionRiskHighlight(BaseModel): + upload_id: int + screening_result: str + probability: float | None + threshold: float | None + symptom_pain: bool + symptom_discharge: bool + symptom_pus: bool + symptom_cloudy_dialysate: bool + has_high_risk_symptoms: bool + symptom_aware_priority: Literal["normal", "suspected"] + created_at: datetime + + +class StaffTodayAttentionPatientItem(BaseModel): + patient_id: int + case_number: str + full_name: str | None + tier: Literal["suspected", "elevated", "other"] + representative_upload_id: int + sort_upload_at: datetime + has_annotation: bool + picture_url: str | None = None + day_upload_count: int = 0 + preview_upload_ids: list[int] = Field(default_factory=list) + risk_highlight: StaffTodayAttentionRiskHighlight | None = None + + +class StaffTodayAttentionResponse(BaseModel): + date: str + total_uploads: int + suspected_patients: int + elevated_patients: int + other_patients: int + items: list[StaffTodayAttentionPatientItem] + + class StaffHistoryOverviewDayItem(BaseModel): local_date: str upload_count: int @@ -182,10 +219,13 @@ class StaffHistoryOverviewResponse(BaseModel): class StaffHistoryOverviewCalendarItem(BaseModel): local_date: str + upload_count: int = 0 + uploaded_users: int = 0 risky_patient_count: int has_infection_risk: bool symptom_elevated_patient_count: int has_symptom_elevated_risk: bool + unhandled_patient_count: int = 0 class StaffHistoryOverviewCalendarResponse(BaseModel): diff --git a/apps/backend/app/services/staff_dashboard.py b/apps/backend/app/services/staff_dashboard.py index d44d15d..92522ad 100644 --- a/apps/backend/app/services/staff_dashboard.py +++ b/apps/backend/app/services/staff_dashboard.py @@ -10,8 +10,8 @@ from sqlalchemy.orm import Session, aliased from app.db.models import AIResult, Annotation, LiffIdentity, Notification, Patient, PendingBinding, StaffPatientAssignment, Upload -from app.services.symptoms import calendar_risk_tier -from app.services.taipei_dates import TAIPEI_TIMEZONE, resolve_taipei_day_bounds, to_taipei_date +from app.services.symptoms import calendar_risk_tier, has_high_risk_symptoms, symptom_aware_priority +from app.services.taipei_dates import TAIPEI_TIMEZONE, resolve_taipei_day_bounds, resolve_taipei_day_bounds_for_date, to_taipei_date from app.services.upload_history import summarize_patient_upload_history @@ -993,6 +993,245 @@ def _summarize_tiered_uploads( ) +@dataclass +class TodayAttentionRiskHighlight: + upload_id: int + screening_result: str + probability: float | None + threshold: float | None + symptom_pain: bool + symptom_discharge: bool + symptom_pus: bool + symptom_cloudy_dialysate: bool + has_high_risk_symptoms: bool + symptom_aware_priority: Literal["normal", "suspected"] + created_at: datetime + + +@dataclass +class TodayAttentionPatientRow: + patient: Patient + tier: Literal["suspected", "elevated", "other"] + representative_upload_id: int + sort_upload_at: datetime + has_annotation: bool + picture_url: str | None + day_upload_count: int + preview_upload_ids: list[int] + risk_highlight: TodayAttentionRiskHighlight | None + + +_TIER_SORT_RANK: dict[str, int] = {"suspected": 0, "elevated": 1, "other": 2} + + +def _attention_risk_rank( + *, + screening_result: str, + annotation_label: str | None, + symptom_pain: bool, + symptom_pus: bool, + symptom_cloudy_dialysate: bool, +) -> int: + """Lower rank = higher priority. Matches history-overview risk sort.""" + if annotation_label == "confirmed_infection": + return 0 + if annotation_label == "suspected": + return 1 + if annotation_label == "rejected": + return 4 + if annotation_label == "normal": + return 3 + if screening_result == "suspected": + return 1 + tier = calendar_risk_tier( + screening_result=screening_result, + annotation_label=annotation_label, + symptom_pain=symptom_pain, + symptom_pus=symptom_pus, + symptom_cloudy_dialysate=symptom_cloudy_dialysate, + ) + if tier == "elevated": + return 2 + if screening_result == "normal": + return 3 + return 4 + + +def _load_patient_picture_urls(session: Session, *, patient_ids: set[int]) -> dict[int, str | None]: + if not patient_ids: + return {} + rows = session.execute( + select(LiffIdentity) + .where(LiffIdentity.patient_id.in_(patient_ids)) + .order_by(LiffIdentity.patient_id.asc(), LiffIdentity.id.asc()) + ).scalars() + result: dict[int, str | None] = {} + for row in rows: + if row.patient_id is None or row.patient_id in result: + continue + result[row.patient_id] = row.picture_url + return result + + +def _pick_risk_highlight( + patient_uploads: list[tuple[Upload, AIResult, str]], + *, + latest_annotation_by_upload: dict[int, Annotation], +) -> TodayAttentionRiskHighlight: + def sort_key(item: tuple[Upload, AIResult, str]) -> tuple[int, float, float, int]: + upload, ai_result, _ = item + annotation = latest_annotation_by_upload.get(upload.id) + rank = _attention_risk_rank( + screening_result=ai_result.screening_result, + annotation_label=annotation.label if annotation else None, + symptom_pain=upload.symptom_pain, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + ) + # Lowest rank first; higher probability first; earlier created_at first. + prob = ai_result.probability if ai_result.probability is not None else -1.0 + return (rank, -prob, upload.created_at.timestamp(), upload.id) + + upload, ai_result, _ = min(patient_uploads, key=sort_key) + return TodayAttentionRiskHighlight( + upload_id=upload.id, + screening_result=ai_result.screening_result, + probability=ai_result.probability, + threshold=ai_result.threshold, + symptom_pain=upload.symptom_pain, + symptom_discharge=upload.symptom_discharge, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + has_high_risk_symptoms=has_high_risk_symptoms( + symptom_pain=upload.symptom_pain, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + ), + symptom_aware_priority=symptom_aware_priority( + ai_result.screening_result, + symptom_pain=upload.symptom_pain, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + ), + created_at=upload.created_at, + ) + + +def list_today_attention_patients( + session: Session, + *, + accessible_patient_ids: set[int] | None = None, + local_date: date | None = None, +) -> tuple[date, int, list[TodayAttentionPatientRow]]: + """Uploaders for a Taipei day, partitioned into suspected / elevated / other with triage sort.""" + if local_date is None: + today, today_start, tomorrow_start = resolve_taipei_day_bounds() + else: + today, today_start, tomorrow_start = resolve_taipei_day_bounds_for_date(local_date) + if accessible_patient_ids is not None and not accessible_patient_ids: + return today, 0, [] + + base_query: Select = ( + select(Upload, AIResult, Patient) + .join(AIResult, AIResult.upload_id == Upload.id) + .join(Patient, Patient.id == Upload.patient_id) + .where( + Upload.created_at >= today_start, + Upload.created_at < tomorrow_start, + AIResult.screening_result != "rejected", + Patient.is_active.is_(True), + ) + ) + if accessible_patient_ids is not None: + base_query = base_query.where(Patient.id.in_(accessible_patient_ids)) + rows = session.execute(base_query).all() + if not rows: + return today, 0, [] + + upload_ids = {upload.id for upload, _, _ in rows} + latest_annotation_by_upload = _load_latest_annotation_by_upload_ids(session, upload_ids=upload_ids) + picture_by_patient = _load_patient_picture_urls( + session, + patient_ids={patient.id for _, _, patient in rows}, + ) + + uploads_by_patient: dict[int, list[tuple[Upload, AIResult, str]]] = defaultdict(list) + patients_by_id: dict[int, Patient] = {} + for upload, ai_result, patient in rows: + tier = _tier_for_upload( + upload=upload, + screening_result=ai_result.screening_result, + annotation=latest_annotation_by_upload.get(upload.id), + ) + uploads_by_patient[patient.id].append((upload, ai_result, tier)) + patients_by_id[patient.id] = patient + + attention_rows: list[TodayAttentionPatientRow] = [] + for patient_id, patient_uploads in uploads_by_patient.items(): + has_suspected = any(tier == "suspected" for _, _, tier in patient_uploads) + has_elevated = any(tier == "elevated" for _, _, tier in patient_uploads) + day_upload_count = len(patient_uploads) + preview_upload_ids: list[int] = [] + risk_highlight: TodayAttentionRiskHighlight | None = None + + if has_suspected: + patient_tier: Literal["suspected", "elevated", "other"] = "suspected" + candidates = [(upload, tier) for upload, _, tier in patient_uploads if tier == "suspected"] + representative = min(candidates, key=lambda item: (item[0].created_at, item[0].id))[0] + sort_upload_at = representative.created_at + risk_highlight = _pick_risk_highlight( + patient_uploads, + latest_annotation_by_upload=latest_annotation_by_upload, + ) + elif has_elevated: + patient_tier = "elevated" + candidates = [(upload, tier) for upload, _, tier in patient_uploads if tier == "elevated"] + representative = min(candidates, key=lambda item: (item[0].created_at, item[0].id))[0] + sort_upload_at = representative.created_at + risk_highlight = _pick_risk_highlight( + patient_uploads, + latest_annotation_by_upload=latest_annotation_by_upload, + ) + else: + patient_tier = "other" + representative = max( + (upload for upload, _, _ in patient_uploads), + key=lambda upload: (upload.created_at, upload.id), + ) + # Other tier still sorts by earliest today upload (longer wait first within tier). + sort_upload_at = min(upload.created_at for upload, _, _ in patient_uploads) + ordered = sorted( + (upload for upload, _, _ in patient_uploads), + key=lambda upload: (upload.created_at, upload.id), + ) + # ≤4: return all; >4: first 3 for FE "3 + n" display. + limit = day_upload_count if day_upload_count <= 4 else 3 + preview_upload_ids = [upload.id for upload in ordered[:limit]] + + attention_rows.append( + TodayAttentionPatientRow( + patient=patients_by_id[patient_id], + tier=patient_tier, + representative_upload_id=representative.id, + sort_upload_at=sort_upload_at, + has_annotation=representative.id in latest_annotation_by_upload, + picture_url=picture_by_patient.get(patient_id), + day_upload_count=day_upload_count, + preview_upload_ids=preview_upload_ids, + risk_highlight=risk_highlight, + ) + ) + + attention_rows.sort( + key=lambda row: ( + _TIER_SORT_RANK[row.tier], + row.sort_upload_at, + row.patient.id, + ) + ) + return today, len(rows), attention_rows + + def get_today_suspected_summary( session: Session, *, diff --git a/apps/backend/app/services/staff_history_overview.py b/apps/backend/app/services/staff_history_overview.py index 74511ca..1ef8e38 100644 --- a/apps/backend/app/services/staff_history_overview.py +++ b/apps/backend/app/services/staff_history_overview.py @@ -25,6 +25,7 @@ class HistoryOverviewDaySummary: has_infection_risk: bool symptom_elevated_patient_count: int has_symptom_elevated_risk: bool + unhandled_patient_count: int @dataclass(frozen=True) @@ -89,10 +90,13 @@ class HistoryOverviewData: @dataclass(frozen=True) class HistoryOverviewCalendarItem: local_date: date + upload_count: int + uploaded_users: int risky_patient_count: int has_infection_risk: bool symptom_elevated_patient_count: int has_symptom_elevated_risk: bool + unhandled_patient_count: int @dataclass(frozen=True) @@ -217,6 +221,31 @@ def _day_patient_risk_sets(day_rows: list[_RawUploadRow]) -> tuple[set[int], set return suspected_patient_ids, elevated_patient_ids, rate_patient_ids +def _count_unhandled_for_day(day_rows: list[_RawUploadRow]) -> int: + """Count patients whose attention tier is suspected/elevated and representative upload is unannotated. + + Matches list_today_attention_patients representative selection + has_annotation semantics. + """ + by_patient: dict[int, list[_RawUploadRow]] = defaultdict(list) + for row in day_rows: + if row.screening_result == "rejected": + continue + by_patient[row.patient_id].append(row) + + unhandled = 0 + for patient_rows in by_patient.values(): + has_suspected = any(_tier_for_row(row) == "suspected" for row in patient_rows) + has_elevated = any(_tier_for_row(row) == "elevated" for row in patient_rows) + if not has_suspected and not has_elevated: + continue + target_tier = "suspected" if has_suspected else "elevated" + candidates = [row for row in patient_rows if _tier_for_row(row) == target_tier] + representative = min(candidates, key=lambda row: (normalize_datetime(row.created_at), row.upload_id)) + if representative.annotation_label is None: + unhandled += 1 + return unhandled + + def _raw_rows(session: Session, *, accessible_patient_ids: set[int] | None = None) -> list[_RawUploadRow]: base_query: Select = ( select(Upload, AIResult, Patient) @@ -297,6 +326,7 @@ def list_history_overview_days( has_infection_risk=suspected_infected_users > 0, symptom_elevated_patient_count=symptom_elevated_users, has_symptom_elevated_risk=symptom_elevated_users > 0, + unhandled_patient_count=_count_unhandled_for_day(day_rows), ) ) return result @@ -452,10 +482,13 @@ def get_history_overview_calendar_month( return [ HistoryOverviewCalendarItem( local_date=item.local_date, + upload_count=item.upload_count, + uploaded_users=item.uploaded_users, risky_patient_count=item.risky_patient_count, has_infection_risk=item.has_infection_risk, symptom_elevated_patient_count=item.symptom_elevated_patient_count, has_symptom_elevated_risk=item.has_symptom_elevated_risk, + unhandled_patient_count=item.unhandled_patient_count, ) for item in days if item.local_date.year == year and item.local_date.month == month diff --git a/apps/backend/app/services/taipei_dates.py b/apps/backend/app/services/taipei_dates.py index 788bc57..b53de50 100644 --- a/apps/backend/app/services/taipei_dates.py +++ b/apps/backend/app/services/taipei_dates.py @@ -15,9 +15,13 @@ def to_taipei_date(raw_dt: datetime) -> date: return normalize_datetime(raw_dt).astimezone(TAIPEI_TIMEZONE).date() -def resolve_taipei_day_bounds(reference_dt: datetime | None = None) -> tuple[date, datetime, datetime]: - resolved_reference = reference_dt if reference_dt is not None else datetime.now(tz=timezone.utc) - local_day = normalize_datetime(resolved_reference).astimezone(TAIPEI_TIMEZONE).date() +def resolve_taipei_day_bounds_for_date(local_day: date) -> tuple[date, datetime, datetime]: local_start = datetime.combine(local_day, time.min, tzinfo=TAIPEI_TIMEZONE) local_end = local_start + timedelta(days=1) return local_day, local_start.astimezone(timezone.utc), local_end.astimezone(timezone.utc) + + +def resolve_taipei_day_bounds(reference_dt: datetime | None = None) -> tuple[date, datetime, datetime]: + resolved_reference = reference_dt if reference_dt is not None else datetime.now(tz=timezone.utc) + local_day = normalize_datetime(resolved_reference).astimezone(TAIPEI_TIMEZONE).date() + return resolve_taipei_day_bounds_for_date(local_day) diff --git a/apps/backend/tests/test_staff_dashboard_api.py b/apps/backend/tests/test_staff_dashboard_api.py index 57d969e..692084a 100644 --- a/apps/backend/tests/test_staff_dashboard_api.py +++ b/apps/backend/tests/test_staff_dashboard_api.py @@ -1401,6 +1401,10 @@ def test_staff_history_overview_endpoints_return_expected_shape(tmp_path: Path) assert day_0529_calendar["has_infection_risk"] is True assert day_0529_calendar["symptom_elevated_patient_count"] == 0 assert day_0529_calendar["has_symptom_elevated_risk"] is False + assert day_0529_calendar["upload_count"] >= 1 + assert day_0529_calendar["uploaded_users"] >= 1 + assert "unhandled_patient_count" in day_0529_calendar + assert isinstance(day_0529_calendar["unhandled_patient_count"], int) def test_staff_history_overview_uses_linked_admin_identity_profile(tmp_path: Path) -> None: @@ -1642,4 +1646,8 @@ def test_staff_history_overview_counts_symptom_elevated_separately(tmp_path: Pat assert calendar_day["has_infection_risk"] is True assert calendar_day["symptom_elevated_patient_count"] == 1 assert calendar_day["has_symptom_elevated_risk"] is True + assert calendar_day["upload_count"] == 6 + assert calendar_day["uploaded_users"] == 5 + # elevated + suspected + both are risk-tier without annotation on representative. + assert calendar_day["unhandled_patient_count"] == 3 diff --git a/apps/backend/tests/test_staff_today_attention_api.py b/apps/backend/tests/test_staff_today_attention_api.py new file mode 100644 index 0000000..55b63f0 --- /dev/null +++ b/apps/backend/tests/test_staff_today_attention_api.py @@ -0,0 +1,336 @@ +from __future__ import annotations +# pyright: reportMissingImports=false + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +from app.config import Settings +from app.db.models import AIResult, Annotation, LiffIdentity, Patient, StaffPatientAssignment, Upload +from app.main import create_app +from tests.db_test_utils import migrated_sqlite_database_url + + +def make_settings(db_path: Path) -> Settings: + return Settings( + app_name="test-staff-today-attention-api", + app_env="test", + model_url="https://example.com/model.pt", + model_path=Path("/tmp/model.pt"), + model_cache_dir=Path("/tmp"), + model_timeout_seconds=5.0, + device="cpu", + model_backbone="mobilenet_v3_large", + model_arch="baseline", + transfer_dropout=0.4, + threshold=0.5, + image_size=384, + infection_class_index=4, + class_names=("class_0", "class_1", "class_2", "class_3", "class_4"), + max_upload_mb=10, + log_level="INFO", + accepted_content_types=("image/jpeg", "image/png"), + cors_allowed_origins=("http://localhost:3000",), + cors_allowed_origin_regex=r"^https?://(?:\d{1,3}\.){3}\d{1,3}:3000$", + workers=1, + eval_hflip_tta=False, + database_url=migrated_sqlite_database_url(db_path), + s3_endpoint_url="http://localhost:8333", + s3_region="us-east-1", + s3_access_key="seaweed-access", + s3_secret_key="seaweed-secret", + s3_bucket_name="pd-care-private", + image_access_token_secret="test-secret", + image_access_token_ttl_seconds=300, + auth_token_secret="test-auth-secret", + auth_token_ttl_seconds=3600, + line_verify_mode="stub", + ) + + +def _taipei_today_start_utc() -> datetime: + taipei_tz = timezone(timedelta(hours=8)) + taipei_today = datetime.now(tz=timezone.utc).astimezone(taipei_tz).date() + return datetime.combine(taipei_today, datetime.min.time(), tzinfo=taipei_tz).astimezone(timezone.utc) + + +def _seed_staff(client: TestClient, *, line_user_id: str = "U_STAFF", role: str = "staff") -> int: + session_factory = client.app.state.db_session_factory + with session_factory() as session: + staff_identity = LiffIdentity( + line_user_id=line_user_id, + display_name="Staff", + picture_url=None, + patient_id=None, + role=role, + ) + session.add(staff_identity) + session.commit() + session.refresh(staff_identity) + return staff_identity.id + + +def _login_staff_token(client: TestClient, line_user_id: str = "U_STAFF") -> str: + response = client.post("/v1/auth/login", json={"line_id_token": f"stub:{line_user_id}"}) + assert response.status_code == 200 + return response.json()["access_token"] + + +def _assign_staff_patient(client: TestClient, *, staff_identity_id: int, patient_id: int) -> None: + session_factory = client.app.state.db_session_factory + with session_factory() as session: + session.add(StaffPatientAssignment(staff_identity_id=staff_identity_id, patient_id=patient_id)) + session.commit() + + +def _seed_today_patient( + client: TestClient, + *, + case_number: str, + line_user_id: str, + uploads: list[tuple[timedelta, str, dict[str, bool] | None]], +) -> tuple[int, list[int]]: + day_start = _taipei_today_start_utc() + session_factory = client.app.state.db_session_factory + with session_factory() as session: + patient = Patient( + case_number=case_number, + birth_date="1985-01-01", + full_name=case_number, + is_active=True, + ) + session.add(patient) + session.flush() + session.add( + LiffIdentity( + line_user_id=line_user_id, + display_name=case_number, + picture_url=None, + patient_id=patient.id, + role="patient", + ) + ) + upload_ids: list[int] = [] + for index, (offset, result, symptoms) in enumerate(uploads, start=1): + upload = Upload( + patient_id=patient.id, + object_key=f"patients/{patient.id}/uploads/{index}.jpg", + content_type="image/jpeg", + created_at=day_start + offset, + symptom_pain=bool(symptoms and symptoms.get("pain")), + symptom_pus=bool(symptoms and symptoms.get("pus")), + symptom_cloudy_dialysate=bool(symptoms and symptoms.get("cloudy")), + ) + session.add(upload) + session.flush() + session.add(AIResult(upload_id=upload.id, screening_result=result, probability=0.8, threshold=0.5)) + upload_ids.append(upload.id) + session.commit() + return patient.id, upload_ids + + +def test_today_attention_partitions_sorts_and_marks_annotation(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-partition.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + suspected_later_id, _ = _seed_today_patient( + client, + case_number="P-SUS-LATE", + line_user_id="U_SUS_LATE", + uploads=[(timedelta(hours=4), "suspected", None)], + ) + suspected_earlier_id, suspected_upload_ids = _seed_today_patient( + client, + case_number="P-SUS-EARLY", + line_user_id="U_SUS_EARLY", + uploads=[(timedelta(hours=1), "suspected", None)], + ) + elevated_id, elevated_upload_ids = _seed_today_patient( + client, + case_number="P-ELEV", + line_user_id="U_ELEV", + uploads=[(timedelta(hours=2), "normal", {"pain": True})], + ) + other_id, other_upload_ids = _seed_today_patient( + client, + case_number="P-OTHER", + line_user_id="U_OTHER", + uploads=[ + (timedelta(hours=1), "normal", None), + (timedelta(hours=5), "normal", None), + ], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=suspected_later_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=suspected_earlier_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=elevated_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=other_id) + + session_factory = client.app.state.db_session_factory + with session_factory() as session: + session.add( + Annotation( + patient_id=suspected_earlier_id, + upload_id=suspected_upload_ids[0], + reviewer_identity_id=staff_identity_id, + label="suspected", + comment=None, + ) + ) + session.commit() + + token = _login_staff_token(client) + response = client.get( + "/v1/staff/uploads/today-attention", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total_uploads"] == 5 + assert payload["suspected_patients"] == 2 + assert payload["elevated_patients"] == 1 + assert payload["other_patients"] == 1 + + tiers = [item["tier"] for item in payload["items"]] + assert tiers == ["suspected", "suspected", "elevated", "other"] + assert payload["items"][0]["patient_id"] == suspected_earlier_id + assert payload["items"][0]["has_annotation"] is True + assert payload["items"][1]["patient_id"] == suspected_later_id + assert payload["items"][1]["has_annotation"] is False + assert payload["items"][2]["patient_id"] == elevated_id + assert payload["items"][2]["representative_upload_id"] == elevated_upload_ids[0] + # Other tier uses latest upload as representative, earliest for sort. + assert payload["items"][3]["patient_id"] == other_id + assert payload["items"][3]["representative_upload_id"] == other_upload_ids[1] + assert payload["items"][3]["day_upload_count"] == 2 + assert payload["items"][3]["preview_upload_ids"] == other_upload_ids + assert payload["items"][3]["risk_highlight"] is None + assert payload["items"][0]["risk_highlight"] is not None + assert payload["items"][0]["risk_highlight"]["upload_id"] == suspected_upload_ids[0] + assert payload["items"][0]["day_upload_count"] == 1 + + +def test_today_attention_local_date_filters_day(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-local-date.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + today_patient_id, _ = _seed_today_patient( + client, + case_number="P-TODAY", + line_user_id="U_TODAY", + uploads=[(timedelta(hours=2), "suspected", None)], + ) + past_day_start = _taipei_today_start_utc() - timedelta(days=2) + session_factory = client.app.state.db_session_factory + with session_factory() as session: + patient = Patient( + case_number="P-PAST", + birth_date="1985-01-01", + full_name="P-PAST", + is_active=True, + ) + session.add(patient) + session.flush() + session.add( + LiffIdentity( + line_user_id="U_PAST", + display_name="P-PAST", + picture_url="https://example.com/past.png", + patient_id=patient.id, + role="patient", + ) + ) + upload = Upload( + patient_id=patient.id, + object_key=f"patients/{patient.id}/uploads/1.jpg", + content_type="image/jpeg", + created_at=past_day_start + timedelta(hours=3), + ) + session.add(upload) + session.flush() + session.add(AIResult(upload_id=upload.id, screening_result="suspected", probability=0.9, threshold=0.5)) + past_patient_id = patient.id + past_upload_id = upload.id + session.commit() + + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=today_patient_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=past_patient_id) + + token = _login_staff_token(client) + past_date = (past_day_start.astimezone(timezone(timedelta(hours=8))).date()).isoformat() + response = client.get( + "/v1/staff/uploads/today-attention", + params={"local_date": past_date}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["date"] == past_date + assert payload["total_uploads"] == 1 + assert payload["suspected_patients"] == 1 + assert [item["patient_id"] for item in payload["items"]] == [past_patient_id] + assert payload["items"][0]["picture_url"] == "https://example.com/past.png" + assert payload["items"][0]["risk_highlight"]["upload_id"] == past_upload_id + + empty_response = client.get( + "/v1/staff/uploads/today-attention", + params={"local_date": "2020-01-01"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert empty_response.status_code == 200 + empty_payload = empty_response.json() + assert empty_payload["date"] == "2020-01-01" + assert empty_payload["total_uploads"] == 0 + assert empty_payload["items"] == [] + + +def test_today_attention_respects_staff_assignment_scope(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-scope.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + assigned_id, _ = _seed_today_patient( + client, + case_number="P-ASSIGNED", + line_user_id="U_ASSIGNED", + uploads=[(timedelta(hours=3), "suspected", None)], + ) + _seed_today_patient( + client, + case_number="P-UNASSIGNED", + line_user_id="U_UNASSIGNED", + uploads=[(timedelta(hours=2), "suspected", None)], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=assigned_id) + + token = _login_staff_token(client) + response = client.get( + "/v1/staff/uploads/today-attention", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["suspected_patients"] == 1 + assert [item["patient_id"] for item in payload["items"]] == [assigned_id] + + +def test_today_attention_empty_day(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-empty.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + _seed_staff(client) + token = _login_staff_token(client) + response = client.get( + "/v1/staff/uploads/today-attention", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total_uploads"] == 0 + assert payload["suspected_patients"] == 0 + assert payload["elevated_patients"] == 0 + assert payload["other_patients"] == 0 + assert payload["items"] == [] diff --git a/apps/backend/tests/test_taipei_dates.py b/apps/backend/tests/test_taipei_dates.py index 148d23f..3d292e2 100644 --- a/apps/backend/tests/test_taipei_dates.py +++ b/apps/backend/tests/test_taipei_dates.py @@ -2,7 +2,7 @@ from datetime import date, datetime, timezone -from app.services.taipei_dates import resolve_taipei_day_bounds, to_taipei_date +from app.services.taipei_dates import resolve_taipei_day_bounds, resolve_taipei_day_bounds_for_date, to_taipei_date def test_to_taipei_date_maps_utc_boundary_to_next_local_day() -> None: @@ -17,3 +17,10 @@ def test_resolve_taipei_day_bounds_uses_reference_datetime() -> None: assert local_day == date(2026, 7, 1) assert local_start == datetime(2026, 6, 30, 16, 0, tzinfo=timezone.utc) assert local_end == datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc) + + +def test_resolve_taipei_day_bounds_for_date() -> None: + local_day, local_start, local_end = resolve_taipei_day_bounds_for_date(date(2026, 7, 20)) + assert local_day == date(2026, 7, 20) + assert local_start == datetime(2026, 7, 19, 16, 0, tzinfo=timezone.utc) + assert local_end == datetime(2026, 7, 20, 16, 0, tzinfo=timezone.utc) diff --git a/apps/frontend/app/admin/_components/active-uploaders-summary.tsx b/apps/frontend/app/admin/_components/active-uploaders-summary.tsx new file mode 100644 index 0000000..394135e --- /dev/null +++ b/apps/frontend/app/admin/_components/active-uploaders-summary.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; + +type ActiveUploadersSummaryProps = { + activeUsers: number | null; + loading: boolean; + error: string | null; +}; + +export function ActiveUploadersSummary({ activeUsers, loading, error }: ActiveUploadersSummaryProps) { + return ( +

+ {loading ? ( + "近 7 日活躍上傳者載入中…" + ) : error ? ( + 活躍摘要暫時無法載入 + ) : ( + <> + 近 7 日活躍上傳者 {activeUsers ?? "—"} ·{" "} + + 查看區間分析 → + + + )} +

+ ); +} diff --git a/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx b/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx new file mode 100644 index 0000000..910c935 --- /dev/null +++ b/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { ChevronLeft, ChevronRight, Image as ImageIcon, TriangleAlert, Users } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; +import { buildTaipeiMonthGrid, getRelativeMonthKey } from "@/lib/utils/upload-calendar"; + +export type DayCalendarMetrics = { + uploadCount: number; + uploadedUsers: number; + riskyPatients: number; + unhandledPatients: number; +}; + +type DashboardDayCalendarProps = { + selectedDate: string; + monthKey: string; + metricsByDate: Record; + availableDates: Set | string[]; + loading?: boolean; + onSelectDate: (dateKey: string) => void; + onMonthChange: (monthKey: string) => void; +}; + +function MetricChip({ + icon, + value, + selected, + risk, +}: { + icon: ReactNode; + value: number; + selected: boolean; + risk?: boolean; +}) { + const muted = risk ? value <= 0 : false; + return ( +
+ + {icon} + + {value} +
+ ); +} + +export function DashboardDayCalendar({ + selectedDate, + monthKey, + metricsByDate, + availableDates, + loading, + onSelectDate, + onMonthChange, +}: DashboardDayCalendarProps) { + const available = availableDates instanceof Set ? availableDates : new Set(availableDates); + const grid = buildTaipeiMonthGrid(monthKey); + const title = `${grid.year} 年 ${grid.month} 月`; + + return ( +
+
+ +
+ {title} + {loading ? 載入中… : null} +
+ +
+ +
+ {["日", "一", "二", "三", "四", "五", "六"].map((label) => ( +
{label}
+ ))} +
+ +
+ {grid.cells.map((cell) => { + if (!cell.isCurrentMonth) { + return
; + } + const metrics = metricsByDate[cell.dateKey]; + const isAvailable = available.has(cell.dateKey); + const selected = selectedDate === cell.dateKey; + const unhandled = metrics?.unhandledPatients ?? 0; + const uploadCount = metrics?.uploadCount ?? 0; + const uploadedUsers = metrics?.uploadedUsers ?? 0; + const riskyPatients = metrics?.riskyPatients ?? 0; + + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/frontend/app/admin/_components/pending-bindings-summary.tsx b/apps/frontend/app/admin/_components/pending-bindings-summary.tsx new file mode 100644 index 0000000..8ef236b --- /dev/null +++ b/apps/frontend/app/admin/_components/pending-bindings-summary.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; + +import type { StaffPendingBindingItem } from "@/lib/api/staff"; + +type PendingBindingsSummaryProps = { + items: StaffPendingBindingItem[]; + loading: boolean; + error: string | null; +}; + +export function PendingBindingsSummary({ items, loading, error }: PendingBindingsSummaryProps) { + const visible = items.slice(0, 3); + return ( +
+
+

待審綁定

+ {items.length} +
+ {loading ?

載入中…

: null} + {!loading && error ?

{error}

: null} + {!loading && !error && items.length === 0 ? ( +

目前沒有待審核綁定。

+ ) : null} + {!loading && !error && visible.length > 0 ? ( +
+ {visible.map((item) => ( +
+
+

+ {item.case_number} / {item.birth_date} +

+

{item.line_user_id}

+
+ + {item.candidates.length > 0 ? `${item.candidates.length} 候選` : "無候選"} + +
+ ))} +
+ ) : null} + + 前往註冊審核 → + +
+ ); +} diff --git a/apps/frontend/app/admin/_components/recent-upload-thumbs.tsx b/apps/frontend/app/admin/_components/recent-upload-thumbs.tsx new file mode 100644 index 0000000..072a398 --- /dev/null +++ b/apps/frontend/app/admin/_components/recent-upload-thumbs.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; + +import type { StaffUploadQueueItem } from "@/lib/api/staff"; +import { fetchUploadImageAccess } from "@/lib/api/staff"; +import { cn } from "@/lib/utils"; + +const VISIBLE_THUMBS = 5; + +function formatLocalTime(raw: string): string { + return new Intl.DateTimeFormat("zh-TW", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Asia/Taipei", + }).format(new Date(raw)); +} + +function queueRisk(item: StaffUploadQueueItem): { label: string; className: string } { + if (item.screening_result === "suspected") { + return { label: "疑似", className: "bg-red-50 text-red-700" }; + } + if (item.has_high_risk_symptoms && item.screening_result === "normal") { + return { label: "高風險", className: "bg-amber-50 text-amber-700" }; + } + if (item.screening_result === "rejected") { + return { label: "退回", className: "bg-zinc-100 text-zinc-600" }; + } + return { label: "正常", className: "bg-zinc-100 text-zinc-600" }; +} + +type RecentUploadThumbsProps = { + items: StaffUploadQueueItem[]; + loading: boolean; + error: string | null; +}; + +export function RecentUploadThumbs({ items, loading, error }: RecentUploadThumbsProps) { + const visible = useMemo(() => items.slice(0, VISIBLE_THUMBS), [items]); + const overflow = Math.max(0, items.length - VISIBLE_THUMBS); + const [imageUrlById, setImageUrlById] = useState>({}); + const [imageErrorById, setImageErrorById] = useState>({}); + + useEffect(() => { + const missing = visible.filter((item) => !imageUrlById[item.upload_id] && !imageErrorById[item.upload_id]); + if (missing.length === 0) { + return; + } + let cancelled = false; + void Promise.allSettled(missing.map((item) => fetchUploadImageAccess(item.upload_id))).then((results) => { + if (cancelled) { + return; + } + setImageUrlById((current) => { + const next = { ...current }; + results.forEach((result, index) => { + if (result.status === "fulfilled") { + next[missing[index].upload_id] = result.value.image_url; + } + }); + return next; + }); + setImageErrorById((current) => { + const next = { ...current }; + results.forEach((result, index) => { + if (result.status === "rejected") { + next[missing[index].upload_id] = true; + } + }); + return next; + }); + }); + return () => { + cancelled = true; + }; + }, [visible, imageUrlById, imageErrorById]); + + return ( +
+
+

最新上傳

+ + 極速審核 → + +
+ {loading ?

載入中…

: null} + {!loading && error ?

{error}

: null} + {!loading && !error && items.length === 0 ? ( +

目前沒有上傳資料。

+ ) : null} + {!loading && !error && items.length > 0 ? ( +
+ {visible.map((item) => { + const risk = queueRisk(item); + const imageUrl = imageUrlById[item.upload_id]; + const imageError = imageErrorById[item.upload_id] ?? false; + return ( + + {imageUrl ? ( + + ) : ( +
+ {imageError ? "失敗" : "…"} +
+ )} + + {risk.label} + + + {formatLocalTime(item.created_at)} + + + ); + })} + {overflow > 0 ? ( + + +{overflow} + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/frontend/app/admin/_components/today-patient-pool.tsx b/apps/frontend/app/admin/_components/today-patient-pool.tsx new file mode 100644 index 0000000..e56ccfd --- /dev/null +++ b/apps/frontend/app/admin/_components/today-patient-pool.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { StaffTodayAttentionPatientItem } from "@/lib/api/staff"; + +import { TodayPatientRow } from "./today-patient-row"; + +type TodayPatientPoolProps = { + loading: boolean; + error: string | null; + suspectedPatients: number; + elevatedPatients: number; + otherPatients: number; + items: StaffTodayAttentionPatientItem[]; + dayScopeLabel: string; + isTodaySelected: boolean; +}; + +export function TodayPatientPool({ + loading, + error, + suspectedPatients, + elevatedPatients, + otherPatients, + items, + dayScopeLabel, + isTodaySelected, +}: TodayPatientPoolProps) { + return ( +
+
+

{dayScopeLabel}上傳病患

+

+ 疑似 {suspectedPatients} · 高風險 {elevatedPatients} · 其餘 {otherPatients} +

+
+ {loading ?

載入中…

: null} + {!loading && error ?

{error}

: null} + {!loading && !error && items.length === 0 ? ( +

{dayScopeLabel}尚無上傳病患。

+ ) : null} + {!loading && !error && items.length > 0 ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/frontend/app/admin/_components/today-patient-row.tsx b/apps/frontend/app/admin/_components/today-patient-row.tsx new file mode 100644 index 0000000..61fd8f2 --- /dev/null +++ b/apps/frontend/app/admin/_components/today-patient-row.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; + +import { PersonAvatar } from "@/app/admin/patient-assignment/person-avatar"; +import type { StaffTodayAttentionPatientItem, StaffTodayAttentionRiskHighlight } from "@/lib/api/staff"; +import { fetchUploadImageAccess } from "@/lib/api/staff"; +import { activeSymptomLabels } from "@/lib/symptoms"; +import { cn } from "@/lib/utils"; + +function formatTime(raw: string): string { + return new Intl.DateTimeFormat("zh-TW", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Asia/Taipei", + }).format(new Date(raw)); +} + +function statusLabel(item: StaffTodayAttentionPatientItem, isTodaySelected: boolean): { + text: string; + className: string; +} { + if (item.tier === "other") { + return { + text: isTodaySelected ? "今日已上傳" : "當日已上傳", + className: "text-zinc-500", + }; + } + if (item.has_annotation) { + return { text: "已註解", className: "text-green-600" }; + } + return { text: "未處理", className: "text-red-600" }; +} + +function riskMainLine(highlight: StaffTodayAttentionRiskHighlight): string { + if (highlight.screening_result === "suspected") { + const pct = + highlight.probability != null ? ` · AI ${Math.round(highlight.probability * 100)}%` : ""; + return `suspected${pct}`; + } + return "症狀高風險"; +} + +function UploadThumb({ uploadId }: { uploadId: number }) { + const [imageUrl, setImageUrl] = useState(null); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + let cancelled = false; + void fetchUploadImageAccess(uploadId) + .then((result) => { + if (!cancelled) { + setImageUrl(result.image_url); + } + }) + .catch(() => { + if (!cancelled) { + setImageError(true); + } + }); + return () => { + cancelled = true; + }; + }, [uploadId]); + + return ( +
+ {imageUrl ? ( + + ) : ( +
+ {imageError ? "失敗" : "…"} +
+ )} +
+ ); +} + +type TodayPatientRowProps = { + item: StaffTodayAttentionPatientItem; + isTodaySelected: boolean; +}; + +export function TodayPatientRow({ item, isTodaySelected }: TodayPatientRowProps) { + const name = item.full_name || item.case_number; + const status = statusLabel(item, isTodaySelected); + const highlight = item.risk_highlight; + const isRiskTier = item.tier === "suspected" || item.tier === "elevated"; + + const symptomLine = + highlight != null + ? activeSymptomLabels({ + pain: highlight.symptom_pain, + discharge: highlight.symptom_discharge, + pus: highlight.symptom_pus, + cloudyDialysate: highlight.symptom_cloudy_dialysate, + }).join("、") + : ""; + + const previewIds = item.preview_upload_ids ?? []; + const overflow = + !isRiskTier && item.day_upload_count > 4 ? item.day_upload_count - Math.min(previewIds.length, 3) : 0; + + return ( + +
+ +
+

{name}

+

+ 當日 {item.day_upload_count} 張上傳 + · + {status.text} +

+
+
+ + {isRiskTier && highlight ? ( +
+
+ +
+
+

最高風險 · {formatTime(highlight.created_at)}

+

{riskMainLine(highlight)}

+ {symptomLine ?

{symptomLine}

: null} +
+
+ ) : null} + + {!isRiskTier && previewIds.length > 0 ? ( +
+ {previewIds.slice(0, overflow > 0 ? 3 : 4).map((uploadId) => ( +
+ +
+ ))} + {overflow > 0 ? ( +
+ +{overflow} +
+ ) : null} +
+ ) : null} + + ); +} diff --git a/apps/frontend/app/admin/_components/today-upload-count.tsx b/apps/frontend/app/admin/_components/today-upload-count.tsx new file mode 100644 index 0000000..eb82b12 --- /dev/null +++ b/apps/frontend/app/admin/_components/today-upload-count.tsx @@ -0,0 +1,34 @@ +import Link from "next/link"; + +type TodayUploadCountProps = { + totalUploads: number | null; + loading?: boolean; + dayScopeLabel: string; + selectedDate: string; +}; + +export function TodayUploadCount({ + totalUploads, + loading, + dayScopeLabel, + selectedDate, +}: TodayUploadCountProps) { + return ( +
+
+
+

{dayScopeLabel}上傳

+

+ {loading ? "…" : (totalUploads ?? "—")} +

+
+ + 完整日檢視 → + +
+
+ ); +} diff --git a/apps/frontend/app/admin/_components/today-workbench-header.tsx b/apps/frontend/app/admin/_components/today-workbench-header.tsx new file mode 100644 index 0000000..e3e7627 --- /dev/null +++ b/apps/frontend/app/admin/_components/today-workbench-header.tsx @@ -0,0 +1,15 @@ +type TodayWorkbenchHeaderProps = { + selectedDate: string; + dayScopeLabel: string; +}; + +export function TodayWorkbenchHeader({ selectedDate, dayScopeLabel }: TodayWorkbenchHeaderProps) { + return ( +
+

儀表板

+

+ {selectedDate} · {dayScopeLabel}需關注的病患與工作佇列 +

+
+ ); +} diff --git a/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx b/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx index d12e363..feeaeb3 100644 --- a/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx +++ b/apps/frontend/app/admin/history-overview/__tests__/page.test.tsx @@ -3,7 +3,6 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import AdminHistoryOverviewPage from "@/app/admin/history-overview/page"; import { fetchHistoryOverview, - fetchHistoryOverviewCalendar, fetchHistoryOverviewDays, fetchUploadImageAccess, StaffHistoryOverviewResponse, @@ -20,6 +19,15 @@ jest.mock("next/image", () => ({ }, })); +const mockReplace = jest.fn(); +const mockSearchParams = new URLSearchParams(); + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + usePathname: () => "/admin/history-overview", + useSearchParams: () => mockSearchParams, +})); + jest.mock("sonner", () => ({ toast: { success: jest.fn(), @@ -30,11 +38,18 @@ jest.mock("sonner", () => ({ jest.mock("@/lib/api/staff", () => ({ fetchHistoryOverviewDays: jest.fn(), fetchHistoryOverview: jest.fn(), - fetchHistoryOverviewCalendar: jest.fn(), fetchUploadImageAccess: jest.fn(), upsertUploadAnnotation: jest.fn(), })); +jest.mock("@/app/admin/history-overview/clinical-period-panel", () => ({ + ClinicalPeriodPanel: () =>
period
, +})); + +jest.mock("@/app/admin/history-overview/usage-trends-tab", () => ({ + UsageTrendsTab: () =>
usage
, +})); + class MockIntersectionObserver { observe = jest.fn(); unobserve = jest.fn(); @@ -136,22 +151,21 @@ describe("AdminHistoryOverviewPage grouped patient navigation", () => { ], }); (fetchHistoryOverview as jest.Mock).mockResolvedValue(makeOverviewResponse()); - (fetchHistoryOverviewCalendar as jest.Mock).mockResolvedValue({ - year: 2026, - month: 7, - items: [ - { - local_date: "2026-07-10", - risky_patient_count: 1, - has_infection_risk: true, - symptom_elevated_patient_count: 0, - has_symptom_elevated_risk: false, - }, - ], - }); (fetchUploadImageAccess as jest.Mock).mockResolvedValue({ image_url: "/mock-upload.jpg" }); }); + test("shows clinical period panel and dashboard date link without month calendar", async () => { + render(); + + expect(await screen.findByTestId("clinical-period-panel")).toBeInTheDocument(); + expect(screen.getByText("檢視日期")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "在儀表板變更日期 →" })).toHaveAttribute( + "href", + "/admin?date=2026-07-10" + ); + expect(screen.queryByText("月曆風險分布")).not.toBeInTheDocument(); + }); + test("links grouped avatar and patient name to the staff patient detail page", async () => { render(); @@ -318,23 +332,10 @@ describe("AdminHistoryOverviewPage symptom elevated risk", () => { ], }) ); - (fetchHistoryOverviewCalendar as jest.Mock).mockResolvedValue({ - year: 2026, - month: 7, - items: [ - { - local_date: "2026-07-10", - risky_patient_count: 0, - has_infection_risk: false, - symptom_elevated_patient_count: 1, - has_symptom_elevated_risk: true, - }, - ], - }); (fetchUploadImageAccess as jest.Mock).mockResolvedValue({ image_url: "/mock-upload.jpg" }); }); - test("shows elevated KPI and orange calendar tone when only symptom elevated risk is present", async () => { + test("shows elevated KPI when only symptom elevated risk is present", async () => { render(); expect(await screen.findByText("症狀高風險人數")).toBeInTheDocument(); @@ -342,8 +343,6 @@ describe("AdminHistoryOverviewPage symptom elevated risk", () => { const card = screen.getByText("症狀高風險人數").closest("div"); expect(card).toHaveTextContent("1"); }); - - const elevatedDay = await screen.findByTitle("2026-07-10 症狀高風險 1"); - expect(elevatedDay.className).toContain("bg-orange-200"); + expect(screen.queryByText("月曆風險分布")).not.toBeInTheDocument(); }); }); diff --git a/apps/frontend/app/admin/history-overview/clinical-period-panel.tsx b/apps/frontend/app/admin/history-overview/clinical-period-panel.tsx new file mode 100644 index 0000000..2eea6e7 --- /dev/null +++ b/apps/frontend/app/admin/history-overview/clinical-period-panel.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import clsx from "clsx"; +import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts"; + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { getElevatedUserKpi, getSuspectedKpi } from "@/lib/admin/dashboard-kpi"; +import { getReadableApiError } from "@/lib/api/client"; +import { fetchAdminSuspectedSummary, fetchStaffPatients } from "@/lib/api/staff"; + +const PERIOD_OPTIONS = ["today", 1, 2, 3, 6, 12, 24, 36, 60] as const; +type Period = (typeof PERIOD_OPTIONS)[number]; +type ChartType = "bar" | "pie"; + +const todayChartConfig = { + count: { label: "筆數" }, + suspected: { label: "疑似感染", color: "#dc2626" }, + elevated: { label: "症狀高風險", color: "#f97316" }, + normal: { label: "正常", color: "#16a34a" }, + risk: { label: "風險合計", color: "#dc2626" }, +} satisfies ChartConfig; + +export function ClinicalPeriodPanel() { + const [months, setMonths] = useState(1); + const [riskChartMode, setRiskChartMode] = useState<"split" | "aggregate">("split"); + const [chartType, setChartType] = useState("bar"); + const [patientCount, setPatientCount] = useState(0); + const [riskSummary, setRiskSummary] = useState<{ + total_uploads: number; + suspected_uploads: number; + symptom_elevated_uploads: number; + suspected_users: number; + symptom_elevated_users: number; + normal_uploads: number; + suspected_ratio: number; + } | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const queryMonths = months === "today" ? 1 : months; + + useEffect(() => { + let cancelled = false; + async function load() { + setLoading(true); + setError(null); + try { + const [patientsData, summary] = await Promise.all([ + fetchStaffPatients({ + months: queryMonths, + infectionStatus: "all", + isActiveFilter: "active", + sortKey: "latest_upload", + sortDir: "desc", + limit: 1, + }), + months === "today" + ? fetchAdminSuspectedSummary({ isActiveFilter: "active" }) + : fetchAdminSuspectedSummary({ months, isActiveFilter: "active" }), + ]); + if (cancelled) { + return; + } + setPatientCount(patientsData.total_patients); + setRiskSummary(summary); + } catch (err) { + if (!cancelled) { + setError(getReadableApiError(err)); + setRiskSummary(null); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + void load(); + return () => { + cancelled = true; + }; + }, [months, queryMonths]); + + const riskChartData = useMemo(() => { + const suspected = riskSummary?.suspected_uploads ?? 0; + const elevated = riskSummary?.symptom_elevated_uploads ?? 0; + const normal = riskSummary?.normal_uploads ?? 0; + if (riskChartMode === "aggregate") { + return [ + { key: "risk", label: "風險合計", count: suspected + elevated, fill: "#dc2626" }, + { key: "normal", label: "正常", count: normal, fill: "#16a34a" }, + ]; + } + return [ + { key: "suspected", label: "疑似感染", count: suspected, fill: "#dc2626" }, + { key: "elevated", label: "症狀高風險", count: elevated, fill: "#f97316" }, + { key: "normal", label: "正常", count: normal, fill: "#16a34a" }, + ]; + }, [riskChartMode, riskSummary]); + + const riskChartRatio = useMemo(() => { + if (!riskSummary || riskSummary.total_uploads <= 0) { + return 0; + } + if (riskChartMode === "aggregate") { + return (riskSummary.suspected_uploads + riskSummary.symptom_elevated_uploads) / riskSummary.total_uploads; + } + return riskSummary.suspected_ratio; + }, [riskChartMode, riskSummary]); + + const suspectedKpi = getSuspectedKpi(months, riskSummary?.suspected_users); + const elevatedKpi = getElevatedUserKpi(months, riskSummary?.symptom_elevated_users); + const uploadLabel = months === "today" ? "今日上傳次數" : `${months} 月上傳次數`; + const chartTitle = months === "today" ? "今日疑似感染" : `${months} 月疑似感染`; + + return ( +
+
+ {PERIOD_OPTIONS.map((option) => ( + + ))} +
+ + {loading ?

載入區間指標…

: null} + {error ?

{error}

: null} + + {!loading && !error ? ( + <> +
+
+

{suspectedKpi.label}

+

{suspectedKpi.value}

+
+
+

{elevatedKpi.label}

+

{elevatedKpi.value}

+
+
+

{uploadLabel}

+

{riskSummary?.total_uploads ?? 0}

+
+
+

篩選病患數

+

{patientCount}

+
+
+ +
+
+

{chartTitle}

+
+
+ + +
+
+ + +
+
+
+

+ {riskChartMode === "aggregate" ? "風險比例" : "疑似比例"} {(riskChartRatio * 100).toFixed(1)}%(共{" "} + {riskSummary?.total_uploads ?? 0} 筆) +

+ + {chartType === "bar" ? ( + + + + + } /> + + {riskChartData.map((item) => ( + + ))} + + + ) : ( + + } /> + + {riskChartData.map((item) => ( + + ))} + + } /> + + )} + +
+ + ) : null} +
+ ); +} diff --git a/apps/frontend/app/admin/history-overview/page.tsx b/apps/frontend/app/admin/history-overview/page.tsx index cc528e4..5ab1d26 100644 --- a/apps/frontend/app/admin/history-overview/page.tsx +++ b/apps/frontend/app/admin/history-overview/page.tsx @@ -2,14 +2,14 @@ import Image from "next/image"; import Link from "next/link"; -import { CalendarDays, ChevronLeft, ChevronRight, RefreshCw, X } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { ChevronLeft, ChevronRight, RefreshCw, X } from "lucide-react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { getReadableApiError } from "@/lib/api/client"; import { fetchHistoryOverview, - fetchHistoryOverviewCalendar, fetchHistoryOverviewDays, fetchUploadImageAccess, StaffAnnotationItem, @@ -17,10 +17,14 @@ import { StaffHistoryOverviewUploadItem, upsertUploadAnnotation, } from "@/lib/api/staff"; -import { buildTaipeiMonthGrid, getMonthKeyFromDateKey, parseTaipeiDateKey } from "@/lib/utils/upload-calendar"; +import { parseTaipeiDateKey } from "@/lib/utils/upload-calendar"; + +import { ClinicalPeriodPanel } from "./clinical-period-panel"; +import { UsageTrendsTab } from "./usage-trends-tab"; type SortBy = "timeline" | "risk"; type GroupSortBy = "uploads" | "age" | "infection_risk"; +type OverviewTab = "clinical" | "usage"; type DraftVerdict = { label: StaffAnnotationItem["label"]; comment: string; @@ -93,10 +97,35 @@ function suggestedLabel(upload: StaffHistoryOverviewUploadItem): StaffAnnotation } export default function AdminHistoryOverviewPage() { + return ( + 載入區間分析中...}> + + + ); +} + +function AdminHistoryOverviewPageInner() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const initialTab = searchParams.get("tab") === "usage" ? "usage" : "clinical"; + const initialDateParam = searchParams.get("date"); + let initialDateFromUrl: string | null = null; + if (initialDateParam) { + try { + parseTaipeiDateKey(initialDateParam); + initialDateFromUrl = initialDateParam; + } catch { + initialDateFromUrl = null; + } + } + + const [overviewTab, setOverviewTab] = useState(initialTab); const [daysLoading, setDaysLoading] = useState(true); const [daysError, setDaysError] = useState(null); const [days, setDays] = useState([]); - const [selectedDate, setSelectedDate] = useState(null); + const [selectedDate, setSelectedDate] = useState(initialDateFromUrl); const [overviewLoading, setOverviewLoading] = useState(false); const [overviewError, setOverviewError] = useState(null); @@ -106,11 +135,6 @@ export default function AdminHistoryOverviewPage() { const [groupByUser, setGroupByUser] = useState(true); const [groupSortBy, setGroupSortBy] = useState("infection_risk"); - const [calendarLoading, setCalendarLoading] = useState(false); - const [calendarRiskByDate, setCalendarRiskByDate] = useState< - Record - >({}); - const [ungroupedVisibleCount, setUngroupedVisibleCount] = useState(INITIAL_UNGROUPED_VISIBLE); const [groupVisibleCountByPatient, setGroupVisibleCountByPatient] = useState>({}); const [selectedUploadId, setSelectedUploadId] = useState(null); @@ -121,6 +145,27 @@ export default function AdminHistoryOverviewPage() { const [imageErrorByUploadId, setImageErrorByUploadId] = useState>({}); const sentinelRef = useRef(null); + const syncUrl = useCallback( + (next: { date?: string | null; tab?: OverviewTab }) => { + const params = new URLSearchParams(searchParams.toString()); + const dateValue = next.date !== undefined ? next.date : selectedDate; + const tabValue = next.tab ?? overviewTab; + if (dateValue) { + params.set("date", dateValue); + } else { + params.delete("date"); + } + if (tabValue === "clinical") { + params.delete("tab"); + } else { + params.set("tab", tabValue); + } + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [overviewTab, pathname, router, searchParams, selectedDate] + ); + const loadDays = useCallback(async () => { setDaysLoading(true); try { @@ -185,28 +230,10 @@ export default function AdminHistoryOverviewPage() { if (!selectedDate) { return; } - const { year, month } = parseTaipeiDateKey(selectedDate); - const timer = window.setTimeout(() => { - setCalendarLoading(true); - void fetchHistoryOverviewCalendar({ year, month }) - .then((response) => { - const riskMap: Record = {}; - response.items.forEach((item) => { - riskMap[item.local_date] = { - risky: item.risky_patient_count, - elevated: item.symptom_elevated_patient_count, - }; - }); - setCalendarRiskByDate(riskMap); - }) - .finally(() => { - setCalendarLoading(false); - }); - }, 0); - return () => { - window.clearTimeout(timer); - }; - }, [selectedDate]); + syncUrl({ date: selectedDate, tab: overviewTab }); + // Intentionally only when date/tab change; syncUrl identity would loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDate, overviewTab]); useEffect(() => { const timer = window.setTimeout(() => { @@ -353,46 +380,58 @@ export default function AdminHistoryOverviewPage() { const canGoPrev = selectedDateIndex >= 0 && selectedDateIndex < days.length - 1; const canGoNext = selectedDateIndex > 0; - const calendarDates = useMemo(() => { - if (!selectedDate) { - return [] as Array<{ day: number; localDate: string | null }>; - } - const monthKey = getMonthKeyFromDateKey(selectedDate); - const grid = buildTaipeiMonthGrid(monthKey); - return grid.cells.map((cell) => ({ - day: cell.dayOfMonth, - localDate: cell.isCurrentMonth ? cell.dateKey : null, - })); - }, [selectedDate]); - - const monthRiskMax = useMemo(() => { - const values = Object.values(calendarRiskByDate).map((entry) => entry.risky); - return values.length > 0 ? Math.max(...values) : 0; - }, [calendarRiskByDate]); - - if (daysLoading && !selectedDate) { - return
載入歷史總覽中...
; - } - return (
-

歷史總覽

-

依台灣時區日期檢視上傳紀錄與感染風險分布。

+

區間分析

+

臨床回顧與使用趨勢(依台灣時區)。

+ {overviewTab === "clinical" ? ( + + ) : null} +
+ +
- + +
+ + {overviewTab === "usage" ? : null} + + {overviewTab === "clinical" && daysLoading && !selectedDate ? ( +
載入區間分析中...
+ ) : null} + + {overviewTab === "clinical" && !(daysLoading && !selectedDate) ? ( + <> + {daysError ?
{daysError}
: null} {overviewError ? ( @@ -400,24 +439,37 @@ export default function AdminHistoryOverviewPage() { ) : null}
-
- -
{selectedDate ?? "—"}
- +
+
+ 檢視日期 + +
+ {selectedDate ?? "—"} +
+ +
+ {selectedDate ? ( + + 在儀表板變更日期 → + + ) : null}
@@ -473,68 +525,6 @@ export default function AdminHistoryOverviewPage() {
-
-
- - 月曆風險分布 - {calendarLoading ? 載入中... : null} -
-
- {["日", "一", "二", "三", "四", "五", "六"].map((label) => ( -
- {label} -
- ))} -
-
- {calendarDates.map((entry, index) => { - if (!entry.localDate) { - return
; - } - const isAvailable = days.includes(entry.localDate); - const dayRisk = calendarRiskByDate[entry.localDate]; - const riskyCount = dayRisk?.risky ?? 0; - const elevatedCount = dayRisk?.elevated ?? 0; - const ratio = monthRiskMax > 0 ? riskyCount / monthRiskMax : 0; - let toneClass = "bg-zinc-100 text-zinc-500"; - if (isAvailable && riskyCount <= 0 && elevatedCount <= 0) { - toneClass = "bg-emerald-100 text-emerald-700"; - } else if (isAvailable && riskyCount > 0) { - if (ratio <= 0.25) { - toneClass = "bg-red-100 text-red-700"; - } else if (ratio <= 0.5) { - toneClass = "bg-red-200 text-red-800"; - } else if (ratio <= 0.75) { - toneClass = "bg-red-300 text-red-900"; - } else { - toneClass = "bg-red-500 text-white"; - } - } else if (isAvailable && elevatedCount > 0) { - toneClass = "bg-orange-200 text-orange-800"; - } - const selectedClass = selectedDate === entry.localDate ? "ring-2 ring-zinc-900" : ""; - const titleRisk = - riskyCount > 0 - ? `疑似 ${riskyCount}` - : elevatedCount > 0 - ? `症狀高風險 ${elevatedCount}` - : "無風險"; - return ( - - ); - })} -
-
- {overviewLoading ?
載入當日資料中...
: null} {!groupByUser ? ( @@ -764,6 +754,8 @@ export default function AdminHistoryOverviewPage() {
) : null} + + ) : null} ); } diff --git a/apps/frontend/app/admin/history-overview/usage-trends-tab.tsx b/apps/frontend/app/admin/history-overview/usage-trends-tab.tsx new file mode 100644 index 0000000..3e06f45 --- /dev/null +++ b/apps/frontend/app/admin/history-overview/usage-trends-tab.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import clsx from "clsx"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { getReadableApiError } from "@/lib/api/client"; +import { fetchAdminActiveUsersSeries, fetchAdminDailySuspectedSeries } from "@/lib/api/staff"; + +const ACTIVE_WINDOW_OPTIONS = [3, 7, 14, 30] as const; +const LOOKBACK_OPTIONS = [30, 60, 90] as const; + +const activeChartConfig = { + active_users: { label: "活躍用戶", color: "#2563eb" }, +} satisfies ChartConfig; + +const dailyChartConfig = { + suspected_uploads: { label: "疑似上傳", color: "#dc2626" }, + symptom_elevated_uploads: { label: "症狀高風險", color: "#f97316" }, + risk_total: { label: "風險合計", color: "#dc2626" }, + ratio_pct: { label: "比例 %", color: "#71717a" }, +} satisfies ChartConfig; + +export function UsageTrendsTab() { + const [activeWindowDays, setActiveWindowDays] = useState<(typeof ACTIVE_WINDOW_OPTIONS)[number]>(7); + const [activeLookbackDays, setActiveLookbackDays] = useState<(typeof LOOKBACK_OPTIONS)[number]>(30); + const [activeInterval, setActiveInterval] = useState<"day" | "week">("day"); + const [dailyLookbackDays, setDailyLookbackDays] = useState<(typeof LOOKBACK_OPTIONS)[number]>(30); + const [riskChartMode, setRiskChartMode] = useState<"split" | "aggregate">("split"); + const [activeUsersSeries, setActiveUsersSeries] = useState<{ date: string; active_users: number }[]>([]); + const [dailySuspectedSeries, setDailySuspectedSeries] = useState< + { + date: string; + total_uploads: number; + suspected_uploads: number; + symptom_elevated_uploads: number; + suspected_ratio: number; + }[] + >([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + async function load() { + setLoading(true); + setError(null); + try { + const [activeData, dailyData] = await Promise.all([ + fetchAdminActiveUsersSeries({ + activeWindowDays, + lookbackDays: activeLookbackDays, + interval: activeInterval, + }), + fetchAdminDailySuspectedSeries({ lookbackDays: dailyLookbackDays }), + ]); + if (cancelled) { + return; + } + setActiveUsersSeries(activeData.items); + setDailySuspectedSeries(dailyData.items); + } catch (err) { + if (!cancelled) { + setError(getReadableApiError(err)); + setActiveUsersSeries([]); + setDailySuspectedSeries([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + void load(); + return () => { + cancelled = true; + }; + }, [activeInterval, activeLookbackDays, activeWindowDays, dailyLookbackDays]); + + const activeUserChartData = useMemo( + () => + activeUsersSeries.map((point) => ({ + ...point, + shortDate: new Date(point.date).toLocaleDateString("zh-TW", { month: "numeric", day: "numeric" }), + })), + [activeUsersSeries] + ); + + const dailySuspectedChartData = useMemo( + () => + dailySuspectedSeries.map((point) => { + const elevated = point.symptom_elevated_uploads ?? 0; + const ratio = + point.total_uploads > 0 + ? riskChartMode === "aggregate" + ? (point.suspected_uploads + elevated) / point.total_uploads + : point.suspected_ratio + : 0; + return { + ...point, + symptom_elevated_uploads: elevated, + risk_total: point.suspected_uploads + elevated, + ratio_pct: Number((ratio * 100).toFixed(1)), + shortDate: new Date(point.date).toLocaleDateString("zh-TW", { month: "numeric", day: "numeric" }), + }; + }), + [dailySuspectedSeries, riskChartMode] + ); + + return ( +
+ {loading ?

載入使用趨勢…

: null} + {error ?

{error}

: null} + +
+
+

活躍用戶趨勢

+
+ + + +
+
+ + + + + + } /> + + + +
+ +
+
+

每日疑似感染比例與數量

+
+
+ + +
+ +
+
+ + + + + + + } /> + {riskChartMode === "aggregate" ? ( + + ) : ( + <> + + + + )} + + } /> + + +
+
+ ); +} diff --git a/apps/frontend/app/admin/layout.tsx b/apps/frontend/app/admin/layout.tsx index 5aa4383..70f6c3a 100644 --- a/apps/frontend/app/admin/layout.tsx +++ b/apps/frontend/app/admin/layout.tsx @@ -190,7 +190,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) }`} > - 歷史總覽 + 區間分析 - {!isSidebarCollapsed ? "歷史總覽" : null} + {!isSidebarCollapsed ? "區間分析" : null} (null); + const [attentionLoading, setAttentionLoading] = useState(true); + const [attentionError, setAttentionError] = useState(null); -const ACTIVE_WINDOW_OPTIONS = [3, 7, 14, 30] as const; -const LOOKBACK_OPTIONS = [30, 60, 90] as const; -type ChartType = "bar" | "pie"; + const [pendingItems, setPendingItems] = useState([]); + const [pendingLoading, setPendingLoading] = useState(true); + const [pendingError, setPendingError] = useState(null); -function exportCSV(items: StaffPatientSummary[]) { - const headers = ["病例號", "姓名", "LINE 名稱", "年齡", "LINE 帳號", "期間上傳次數", "疑似感染次數", "最近上傳日"]; - const rows = items.map((item) => - [ - item.case_number, - item.full_name ?? "未命名", - item.line_display_name ?? "-", - item.age ?? "-", - item.line_user_id ?? "-", - item.upload_count, - item.suspected_count, - item.latest_upload_at ? new Date(item.latest_upload_at).toLocaleDateString("zh-TW") : "-", - ].join(",") - ); - const csv = [headers.join(","), ...rows].join("\n"); - const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `pd-care-report-${new Date().toISOString().slice(0, 10)}.csv`; - anchor.click(); - URL.revokeObjectURL(url); -} + const isAdmin = getStaffRole() === "admin"; + const [activeUsers, setActiveUsers] = useState(null); + const [activeLoading, setActiveLoading] = useState(isAdmin); + const [activeError, setActiveError] = useState(null); -export default function AdminDashboard() { - const [months, setMonths] = useState("today"); - const [ageMin, setAgeMin] = useState(""); - const [ageMax, setAgeMax] = useState(""); - const [infectionStatus, setInfectionStatus] = useState("all"); - const [showFilters, setShowFilters] = useState(false); - const [patients, setPatients] = useState([]); - const [queue, setQueue] = useState([]); - const [pending, setPending] = useState([]); - const [stats, setStats] = useState({ - totalPatients: 0, - totalUploads: 0, - }); - const [selectedCandidate, setSelectedCandidate] = useState>({}); - const [workingPendingId, setWorkingPendingId] = useState(null); - const [newPatientNameByPendingId, setNewPatientNameByPendingId] = useState>({}); - const [errorMessage, setErrorMessage] = useState(null); - const [showAllNotifications, setShowAllNotifications] = useState(false); - const [showAllQueue, setShowAllQueue] = useState(false); - const [showAllPending, setShowAllPending] = useState(false); - const [todayChartType, setTodayChartType] = useState("bar"); - const [riskChartMode, setRiskChartMode] = useState<"split" | "aggregate">("split"); - const [activeWindowDays, setActiveWindowDays] = useState<(typeof ACTIVE_WINDOW_OPTIONS)[number]>(7); - const [activeLookbackDays, setActiveLookbackDays] = useState<(typeof LOOKBACK_OPTIONS)[number]>(30); - const [activeInterval, setActiveInterval] = useState<"day" | "week">("day"); - const [dailyLookbackDays, setDailyLookbackDays] = useState<(typeof LOOKBACK_OPTIONS)[number]>(30); - const [riskSummary, setRiskSummary] = useState<{ - total_uploads: number; - suspected_uploads: number; - symptom_elevated_uploads: number; - suspected_users: number; - symptom_elevated_users: number; - normal_uploads: number; - suspected_ratio: number; - } | null>(null); - const [activeUsersSeries, setActiveUsersSeries] = useState<{ date: string; active_users: number }[]>([]); - const [dailySuspectedSeries, setDailySuspectedSeries] = useState< - { - date: string; - total_uploads: number; - suspected_uploads: number; - symptom_elevated_uploads: number; - suspected_ratio: number; - }[] - >([]); - const [analyticsError, setAnalyticsError] = useState(null); - const { notifications, unreadCount, markNotificationRead, markingIds, error: notificationError } = useAdminNotifications(); - const queryMonths = months === "today" ? 1 : months; + const [browseMonthKey, setBrowseMonthKey] = useState(null); + const calendarMonthKey = browseMonthKey ?? monthKey; + const [availableDates, setAvailableDates] = useState([]); + const [metricsByDate, setMetricsByDate] = useState>({}); + const [calendarLoading, setCalendarLoading] = useState(true); useEffect(() => { let cancelled = false; - async function loadDashboard() { - setErrorMessage(null); - try { - const [patientsData, queueData, pendingData] = await Promise.all([ - fetchStaffPatients({ - months: queryMonths, - ageMin: ageMin ? Number(ageMin) : undefined, - ageMax: ageMax ? Number(ageMax) : undefined, - infectionStatus, - isActiveFilter: "active", - sortKey: "latest_upload", - sortDir: "desc", - }), - fetchUploadQueue({ limit: 12 }), - fetchPendingBindings(), - ]); - if (cancelled) { - return; - } - setPatients(patientsData.items); - setStats({ - totalPatients: patientsData.total_patients, - totalUploads: patientsData.total_uploads, + const timer = window.setTimeout(() => { + setAttentionLoading(true); + void fetchTodayAttention({ localDate: selectedDate }) + .then((data) => { + if (!cancelled) { + setAttention(data); + setAttentionError(null); + } + }) + .catch(() => { + if (!cancelled) { + setAttentionError(`無法載入${dayScopeLabel}上傳病患`); + setAttention(null); + } + }) + .finally(() => { + if (!cancelled) { + setAttentionLoading(false); + } }); - setQueue(queueData.items); - setPending(pendingData); - } catch (error) { - if (!cancelled) { - setErrorMessage(getReadableApiError(error)); - } - } - } - void loadDashboard(); + }, 0); return () => { cancelled = true; + window.clearTimeout(timer); }; - }, [ageMax, ageMin, infectionStatus, queryMonths]); + }, [selectedDate, dayScopeLabel]); useEffect(() => { let cancelled = false; - async function loadAnalytics() { - setAnalyticsError(null); - try { - const summaryFilters = { - ageMin: ageMin ? Number(ageMin) : undefined, - ageMax: ageMax ? Number(ageMax) : undefined, - infectionStatus, - isActiveFilter: "active" as const, - }; - const [riskData, activeData, dailyData] = await Promise.all([ - months === "today" - ? fetchAdminSuspectedSummary(summaryFilters) - : fetchAdminSuspectedSummary({ months, ...summaryFilters }), - fetchAdminActiveUsersSeries({ - activeWindowDays, - lookbackDays: activeLookbackDays, - interval: activeInterval, - }), - fetchAdminDailySuspectedSeries({ lookbackDays: dailyLookbackDays }), - ]); - if (cancelled) { - return; + void fetchHistoryOverviewDays() + .then((data) => { + if (!cancelled) { + setAvailableDates(data.items.map((item) => item.local_date)); } - setRiskSummary(riskData); - setActiveUsersSeries(activeData.items); - setDailySuspectedSeries(dailyData.items); - } catch (error) { + }) + .catch(() => { if (!cancelled) { - setAnalyticsError(getReadableApiError(error)); + setAvailableDates([]); } - } - } - void loadAnalytics(); + }); return () => { cancelled = true; }; - }, [activeInterval, activeLookbackDays, activeWindowDays, ageMax, ageMin, dailyLookbackDays, infectionStatus, months]); - - const pendingItems = useMemo(() => pending.filter((item) => item.status === "pending"), [pending]); - const visibleNotifications = useMemo( - () => (showAllNotifications ? notifications : notifications.slice(0, 3)), - [notifications, showAllNotifications] - ); - const visibleQueue = useMemo(() => (showAllQueue ? queue : queue.slice(0, 3)), [queue, showAllQueue]); - const visiblePending = useMemo( - () => (showAllPending ? pendingItems : pendingItems.slice(0, 3)), - [pendingItems, showAllPending] - ); - const riskChartData = useMemo(() => { - const suspected = riskSummary?.suspected_uploads ?? 0; - const elevated = riskSummary?.symptom_elevated_uploads ?? 0; - const normal = riskSummary?.normal_uploads ?? 0; - if (riskChartMode === "aggregate") { - return [ - { key: "risk", label: "風險合計", count: suspected + elevated, fill: "#dc2626" }, - { key: "normal", label: "正常", count: normal, fill: "#16a34a" }, - ]; - } - return [ - { key: "suspected", label: "疑似感染", count: suspected, fill: "#dc2626" }, - { key: "elevated", label: "症狀高風險", count: elevated, fill: "#f97316" }, - { key: "normal", label: "正常", count: normal, fill: "#16a34a" }, - ]; - }, [riskChartMode, riskSummary]); - const activeUserChartData = useMemo( - () => - activeUsersSeries.map((point) => ({ - ...point, - shortDate: new Date(point.date).toLocaleDateString("zh-TW", { month: "numeric", day: "numeric" }), - })), - [activeUsersSeries] - ); - const dailySuspectedChartData = useMemo( - () => - dailySuspectedSeries.map((point) => { - const elevated = point.symptom_elevated_uploads ?? 0; - const riskTotal = point.suspected_uploads + elevated; - const ratioBase = riskChartMode === "aggregate" ? riskTotal : point.suspected_uploads; - return { - ...point, - symptom_elevated_uploads: elevated, - risk_total: riskTotal, - shortDate: new Date(point.date).toLocaleDateString("zh-TW", { month: "numeric", day: "numeric" }), - ratio_pct: Number( - (point.total_uploads > 0 ? (ratioBase / point.total_uploads) * 100 : 0).toFixed(1) - ), - }; - }), - [dailySuspectedSeries, riskChartMode] - ); - const riskChartRatio = useMemo(() => { - if (!riskSummary || riskSummary.total_uploads <= 0) { - return 0; - } - if (riskChartMode === "aggregate") { - return ( - (riskSummary.suspected_uploads + riskSummary.symptom_elevated_uploads) / riskSummary.total_uploads - ); - } - return riskSummary.suspected_ratio; - }, [riskChartMode, riskSummary]); - const todayChartConfig: ChartConfig = { - suspected: { label: "疑似感染", color: "#dc2626" }, - elevated: { label: "症狀高風險", color: "#f97316" }, - risk: { label: "風險合計", color: "#dc2626" }, - normal: { label: "正常", color: "#16a34a" }, - count: { label: "筆數" }, - }; - const activeChartConfig: ChartConfig = { - active_users: { label: "活躍用戶", color: "#2563eb" }, - }; - const dailyChartConfig: ChartConfig = - riskChartMode === "aggregate" - ? { - risk_total: { label: "風險合計", color: "#dc2626" }, - ratio_pct: { label: "風險比例(%)", color: "#2563eb" }, - } - : { - suspected_uploads: { label: "疑似數量", color: "#dc2626" }, - symptom_elevated_uploads: { label: "症狀高風險", color: "#f97316" }, - ratio_pct: { label: "疑似比例(%)", color: "#2563eb" }, - }; - const suspectedKpi = getSuspectedKpi(months, riskSummary?.suspected_users); - const elevatedKpi = getElevatedUserKpi(months, riskSummary?.symptom_elevated_users); - const riskChartTitle = months === "today" ? "今日疑似感染" : `${months} 月疑似感染`; - - async function refreshPending() { - const items = await fetchPendingBindings(); - setPending(items); - } + }, []); - async function handleApprove(item: StaffPendingBindingItem) { - setWorkingPendingId(item.id); - try { - await approvePendingBinding(item.id); - await refreshPending(); - toast.success("已核准綁定申請"); - } catch (error) { - toast.error(getReadableApiError(error)); - } finally { - setWorkingPendingId(null); - } - } - - async function handleReject(item: StaffPendingBindingItem) { - setWorkingPendingId(item.id); - try { - await rejectPendingBinding(item.id); - await refreshPending(); - toast.success("已駁回綁定申請"); - } catch (error) { - toast.error(getReadableApiError(error)); - } finally { - setWorkingPendingId(null); - } - } + useEffect(() => { + let cancelled = false; + const [year, month] = calendarMonthKey.split("-").map(Number); + const timer = window.setTimeout(() => { + setCalendarLoading(true); + void fetchHistoryOverviewCalendar({ year, month }) + .then((data) => { + if (cancelled) { + return; + } + const next: Record = {}; + for (const item of data.items) { + next[item.local_date] = { + uploadCount: item.upload_count ?? 0, + uploadedUsers: item.uploaded_users ?? 0, + riskyPatients: item.risky_patient_count ?? 0, + unhandledPatients: item.unhandled_patient_count ?? 0, + }; + } + setMetricsByDate(next); + }) + .catch(() => { + if (!cancelled) { + setMetricsByDate({}); + } + }) + .finally(() => { + if (!cancelled) { + setCalendarLoading(false); + } + }); + }, 0); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [calendarMonthKey]); - async function handleLink(item: StaffPendingBindingItem) { - const patientId = Number(selectedCandidate[item.id]); - if (!patientId) { - toast.error("請先選擇要綁定的病患。"); - return; - } - setWorkingPendingId(item.id); - try { - await linkPendingBinding(item.id, patientId); - await refreshPending(); - toast.success("已完成指定綁定"); - } catch (error) { - toast.error(getReadableApiError(error)); - } finally { - setWorkingPendingId(null); - } - } + useEffect(() => { + let cancelled = false; + void fetchPendingBindings() + .then((items) => { + if (!cancelled) { + setPendingItems(items); + setPendingError(null); + } + }) + .catch(() => { + if (!cancelled) { + setPendingError("無法載入待審綁定"); + setPendingItems([]); + } + }) + .finally(() => { + if (!cancelled) { + setPendingLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); - async function handleCreateAndLink(item: StaffPendingBindingItem) { - const fullName = (newPatientNameByPendingId[item.id] ?? "").trim(); - if (!fullName) { - toast.error("請先輸入病患姓名再建檔。"); + useEffect(() => { + if (!isAdmin) { return; } - setWorkingPendingId(item.id); - try { - await createPatientAndLinkPendingBinding(item.id, { full_name: fullName }); - await refreshPending(); - toast.success("建檔並綁定完成"); - } catch (error) { - toast.error(getReadableApiError(error)); - } finally { - setWorkingPendingId(null); - } - } - - async function handleMarkNotificationRead(notificationId: number) { - try { - await markNotificationRead(notificationId); - toast.success("已標記為已讀"); - } catch (error) { - toast.error(getReadableApiError(error)); - } - } + let cancelled = false; + void fetchAdminActiveUsersSeries({ activeWindowDays: 7, lookbackDays: 7, interval: "day" }) + .then((data) => { + if (!cancelled) { + const last = data.items[data.items.length - 1]; + setActiveUsers(last?.active_users ?? 0); + setActiveError(null); + } + }) + .catch(() => { + if (!cancelled) { + setActiveError("failed"); + setActiveUsers(null); + } + }) + .finally(() => { + if (!cancelled) { + setActiveLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [isAdmin]); - function handleExportReport() { - try { - exportCSV(patients); - toast.success("報表已匯出"); - } catch { - toast.error("匯出失敗,請稍後再試"); - } - } + const availableSet = useMemo(() => new Set(availableDates), [availableDates]); return ( -
-
-
-

儀表板

-

腹膜透析出口感染監測

-
-
- - - 病患管理 - - -
-
- - {errorMessage ? ( -
{errorMessage}
- ) : null} - {notificationError ? ( -
{notificationError}
- ) : null} - {analyticsError ? ( -
{analyticsError}
- ) : null} +
+ -
-
- {PERIOD_OPTIONS.map((option) => ( - - ))} -
- -
+ { + setBrowseMonthKey(null); + setSelectedDate(dateKey); + }} + onMonthChange={(nextMonth) => { + const first = availableDates.find((d) => getMonthKeyFromDateKey(d) === nextMonth); + if (first) { + setBrowseMonthKey(null); + setSelectedDate(first); + } else { + setBrowseMonthKey(nextMonth); + } + }} + /> - {showFilters && ( -
-
- -
- setAgeMin(event.target.value)} - className="w-20 px-3 py-2 rounded-lg border border-zinc-200 text-sm text-zinc-900 outline-none focus:border-zinc-400" - /> - - setAgeMax(event.target.value)} - className="w-20 px-3 py-2 rounded-lg border border-zinc-200 text-sm text-zinc-900 outline-none focus:border-zinc-400" - /> -
-
- -
- -
- {INFECTION_OPTIONS.map((option) => ( - - ))} -
-
- -
- -
+
+
+
- )} -
- {[ - { icon: Users, label: "篩選病患數", value: stats.totalPatients }, - { - icon: Upload, - label: months === "today" ? "今日上傳次數" : `${months} 月上傳次數`, - value: riskSummary?.total_uploads ?? stats.totalUploads, - }, - { icon: AlertTriangle, label: suspectedKpi.label, value: suspectedKpi.value }, - { icon: AlertTriangle, label: elevatedKpi.label, value: elevatedKpi.value }, - ].map(({ icon: Icon, label, value }) => ( -
-
- -
-
-
{value}
-
{label}
-
-
- ))} -
- -
-
-

分析圖表

-

活躍用戶與每日疑似感染趨勢

-
- -
-
-
-

{riskChartTitle}

-
-
- - -
-
- - -
-
-
-

- {riskChartMode === "aggregate" ? "風險比例" : "疑似比例"}{" "} - {(riskChartRatio * 100).toFixed(1)}%(共 {riskSummary?.total_uploads ?? 0} 筆) +

+ + + {isAdmin ? ( + + ) : ( +

+ + 查看區間分析 → +

- - {todayChartType === "bar" ? ( - - - - - } /> - - {riskChartData.map((item) => ( - - ))} - - - ) : ( - - } /> - - {riskChartData.map((item) => ( - - ))} - - } /> - - )} - -
- -
-
-

活躍用戶趨勢

-
- - - -
-
- - - - - - } /> - - - -
- -
-
-

每日疑似感染比例與數量

-
-
- - -
- -
-
- - - - - - - } /> - {riskChartMode === "aggregate" ? ( - - ) : ( - <> - - - - )} - - } /> - - -
+ )}
-
- -
-
-
-
- -

疑似感染通知

-
- 0 ? "bg-red-50 text-red-600" : "bg-zinc-100 text-zinc-500" - )} - > - 未讀 {unreadCount} - -
-
- {notifications.length === 0 ? ( -

目前沒有疑似感染通知。

- ) : ( - visibleNotifications.map((item) => ( -
-
-
-

{item.patient_full_name ?? "未命名"} ({item.patient_case_number})

-

{new Date(item.created_at).toLocaleString("zh-TW")}

- {item.summary ?

{item.summary}

: null} -
- - {item.status === "new" ? "新通知" : item.status === "reviewed" ? "已讀" : "已處理"} - -
-
- - 檢視病患 - - {item.status === "new" ? ( - - ) : null} -
-
- )) - )} -
- {notifications.length > 3 ? ( - - ) : null} -
- -
-
-
- -

最新上傳佇列

-
- - 進入快速審核 - -
-
- {queue.length === 0 ? ( -

目前沒有上傳資料。

- ) : ( - visibleQueue.map((item) => ( -
-
-

{item.full_name ?? "未命名"}

-

- {item.case_number} · {item.screening_result} · {new Date(item.created_at).toLocaleString("zh-TW")} -

-
- - 檢視 - -
- )) - )} -
- {queue.length > 3 ? ( - - ) : null} -
- -
-
-
- -

待審核綁定

-
- - 進入註冊審核 - -
-
- {pendingItems.length === 0 ? ( -

目前沒有待審核綁定。

- ) : ( - visiblePending.map((item) => ( -
-

- {item.case_number} / {item.birth_date} -

-

{item.line_user_id}

-
- {item.candidates.length > 0 ? ( - <> - - - - - ) : ( -
- - setNewPatientNameByPendingId((current) => ({ ...current, [item.id]: event.target.value })) - } - className="rounded-lg border border-zinc-200 px-2 py-1 text-xs" - placeholder="新病患姓名" - /> - -
- )} - -
-
- )) - )} -
- {pendingItems.length > 3 ? ( - - ) : null} -
-
+
+ ); +} + +export default function AdminDashboard() { + return ( + +

載入儀表板…

+ + } + > + +
); } diff --git a/apps/frontend/lib/admin/use-admin-selected-date.ts b/apps/frontend/lib/admin/use-admin-selected-date.ts new file mode 100644 index 0000000..deb7cce --- /dev/null +++ b/apps/frontend/lib/admin/use-admin-selected-date.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +import { getMonthKeyFromDateKey, getTaipeiTodayKey, parseTaipeiDateKey } from "@/lib/utils/upload-calendar"; + +function isValidDateKey(raw: string | null): raw is string { + if (!raw) { + return false; + } + try { + parseTaipeiDateKey(raw); + return true; + } catch { + return false; + } +} + +export function useAdminSelectedDate() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const todayKey = getTaipeiTodayKey(); + + const selectedDate = useMemo(() => { + const fromUrl = searchParams.get("date"); + return isValidDateKey(fromUrl) ? fromUrl : todayKey; + }, [searchParams, todayKey]); + + const isTodaySelected = selectedDate === todayKey; + const dayScopeLabel = isTodaySelected ? "今日" : "當日"; + const monthKey = getMonthKeyFromDateKey(selectedDate); + + const setSelectedDate = useCallback( + (nextDate: string) => { + if (!isValidDateKey(nextDate)) { + return; + } + const params = new URLSearchParams(searchParams.toString()); + if (nextDate === todayKey) { + params.delete("date"); + } else { + params.set("date", nextDate); + } + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams, todayKey] + ); + + return { + selectedDate, + setSelectedDate, + isTodaySelected, + dayScopeLabel, + monthKey, + todayKey, + }; +} diff --git a/apps/frontend/lib/api/staff.ts b/apps/frontend/lib/api/staff.ts index 81fb868..600ea42 100644 --- a/apps/frontend/lib/api/staff.ts +++ b/apps/frontend/lib/api/staff.ts @@ -114,6 +114,45 @@ export type StaffUploadQueueItem = { export type StaffUploadQueueResponse = { items: StaffUploadQueueItem[] }; +export type StaffTodayAttentionTier = "suspected" | "elevated" | "other"; + +export type StaffTodayAttentionRiskHighlight = { + upload_id: number; + screening_result: string; + probability: number | null; + threshold: number | null; + symptom_pain: boolean; + symptom_discharge: boolean; + symptom_pus: boolean; + symptom_cloudy_dialysate: boolean; + has_high_risk_symptoms: boolean; + symptom_aware_priority: "normal" | "suspected"; + created_at: string; +}; + +export type StaffTodayAttentionPatientItem = { + patient_id: number; + case_number: string; + full_name: string | null; + tier: StaffTodayAttentionTier; + representative_upload_id: number; + sort_upload_at: string; + has_annotation: boolean; + picture_url: string | null; + day_upload_count: number; + preview_upload_ids: number[]; + risk_highlight: StaffTodayAttentionRiskHighlight | null; +}; + +export type StaffTodayAttentionResponse = { + date: string; + total_uploads: number; + suspected_patients: number; + elevated_patients: number; + other_patients: number; + items: StaffTodayAttentionPatientItem[]; +}; + export type StaffRapidReviewQueueItem = StaffUploadQueueItem & { risk_rank: number; }; @@ -199,10 +238,13 @@ export type StaffHistoryOverviewResponse = { export type StaffHistoryOverviewCalendarItem = { local_date: string; + upload_count: number; + uploaded_users: number; risky_patient_count: number; has_infection_risk: boolean; symptom_elevated_patient_count: number; has_symptom_elevated_risk: boolean; + unhandled_patient_count: number; }; export type StaffHistoryOverviewCalendarResponse = { @@ -552,6 +594,15 @@ export async function fetchUploadQueue(params?: { } } +export async function fetchTodayAttention(params?: { + localDate?: string; +}): Promise { + const { data } = await apiClient.get("/v1/staff/uploads/today-attention", { + params: params?.localDate ? { local_date: params.localDate } : undefined, + }); + return data; +} + export async function fetchHistoryOverviewDays(): Promise { const { data } = await apiClient.get("/v1/staff/uploads/history-overview/days"); return data; diff --git a/docs/product/history-overview-api-contract.md b/docs/product/history-overview-api-contract.md index e1ff87a..f6f1eae 100644 --- a/docs/product/history-overview-api-contract.md +++ b/docs/product/history-overview-api-contract.md @@ -115,15 +115,20 @@ Response shape: "items": [ { "local_date": "2026-05-29", + "upload_count": 12, + "uploaded_users": 7, "risky_patient_count": 3, "has_infection_risk": true, "symptom_elevated_patient_count": 2, - "has_symptom_elevated_risk": true + "has_symptom_elevated_risk": true, + "unhandled_patient_count": 1 } ] } ``` +`unhandled_patient_count`: patients whose attention tier that day is `suspected` or `elevated` and whose representative upload has no staff annotation. + ## Semantic Rules - Timezone for date bucketing is always `Asia/Taipei` (`UTC+8`). diff --git a/docs/superpowers/plans/2026-07-25-admin-daily-workbench-v2.md b/docs/superpowers/plans/2026-07-25-admin-daily-workbench-v2.md new file mode 100644 index 0000000..037473c --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-admin-daily-workbench-v2.md @@ -0,0 +1,30 @@ +# 儀表板 v2 實作計畫(設計已鎖定) + +> **Design source of truth:** companion `admin-dashboard-final-overall-v16-metrics-gap.html`(月曆 `mt-auto pt-2`、全寬 7 欄 grid、Image/Users/TriangleAlert chips) +> +> **Agent execution:** Use executing-plans skill task-by-task after approval. +> +> Canonical Cursor plan: `.cursor/plans/admin_dashboard_v2_d17eaf43.plan.md` (do not edit that file during implementation; keep this docs copy in sync if the approved plan changes). + +**Goal:** `/admin?date=YYYY-MM-DD` 可切換台北日;月曆選日驅動病患池與上傳次數;大螢幕病患池多欄;移除首頁縮圖牆。 + +**Baseline:** v1 workbench 已存在(`apps/frontend/app/admin/page.tsx` + `today-*` 元件),但 **today-only**、horizontal patient row、tier badge pills、dimmed annotated cards、`RecentUploadThumbs`。月曆僅在 `history-overview/page.tsx`(risk 染色舊版,需移出)。 + +See full phased plan in `.cursor/plans/admin_dashboard_v2_d17eaf43.plan.md`. + +## Phases (summary) + +1. Spec + plan doc 落檔 +2. Backend: `local_date` + attention 欄位 + calendar metrics + pytest +3. FE: API types + hook + `DashboardDayCalendar` +4. FE: homepage wiring + patient card rewrite +5. FE: history-overview URL sync + 移除舊月曆 +6. Tests + lint + smoke + +## Deliberately out of scope + +- Route rename(保留 `/today-attention`) +- 首頁 inline annotation modal +- 首頁 upload thumb grid +- Upload queue date filter +- 大規模 `Today*` → `Daily*` 檔名 rename diff --git a/docs/superpowers/plans/2026-07-25-admin-dashboard-homepage-renovate.md b/docs/superpowers/plans/2026-07-25-admin-dashboard-homepage-renovate.md new file mode 100644 index 0000000..5b56843 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-admin-dashboard-homepage-renovate.md @@ -0,0 +1,32 @@ +# Admin Dashboard Homepage Renovate Implementation Plan + +> **For agentic workers:** Use the approved design at `docs/superpowers/specs/2026-07-25-admin-dashboard-homepage-renovate-design.md`. + +**Goal:** Replace the metric-soup `/admin` homepage with an actionable today workbench, and make `/admin/history-overview` the period analytics home (臨床 | 使用趨勢). + +**Architecture:** New staff-scoped `GET /v1/staff/uploads/today-attention` returns today’s uploaders partitioned into suspected / elevated / other with sort + representative upload. Frontend rebuilds `/admin` as Layout B (left pool / right stack). Charts and period KPIs move into `history-overview` tabs. + +**Defaults:** +- Visible recent thumbs = 5 + `+n` +- Sidebar label 歷史總覽 → 區間分析 +- Homepage「近 7 日活躍」admin-only +- Binding panel = summary + deep link only + +## Implemented deliverables + +### Backend +- Schemas: `StaffTodayAttentionPatientItem`, `StaffTodayAttentionResponse` +- Service: `list_today_attention_patients` +- Route: `GET /v1/staff/uploads/today-attention` +- Tests: `apps/backend/tests/test_staff_today_attention_api.py` + +### Frontend +- `fetchTodayAttention` + types in `lib/api/staff.ts` +- Today workbench components under `app/admin/_components/` +- Rewritten `app/admin/page.tsx` (Layout B) +- `history-overview` tabs: `clinical-period-panel.tsx`, `usage-trends-tab.tsx` +- Nav rename to 區間分析 in `layout.tsx` + +## Out of scope + +- Notification bell removal, overdue compliance, inline bindings, thumb→review-fast, Grafana monitoring diff --git a/docs/superpowers/specs/2026-07-25-admin-daily-workbench-v2-design.md b/docs/superpowers/specs/2026-07-25-admin-daily-workbench-v2-design.md new file mode 100644 index 0000000..665afb9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-admin-daily-workbench-v2-design.md @@ -0,0 +1,121 @@ +# Admin daily workbench v2 (儀表板) + +**Date:** 2026-07-25 +**Status:** Approved (design locked via companion v16) +**Companion:** `.superpowers/brainstorm/1648867-1784983471/content/admin-dashboard-final-overall-v16-metrics-gap.html` +**Supersedes (homepage IA):** parts of `2026-07-25-admin-dashboard-homepage-renovate-design.md` that conflict with date-selectable dashboard and removal of homepage thumb grid. + +## Problem + +v1 `/admin` is a today-only workbench. Staff cannot review a past Taipei day without leaving the homepage for history-overview’s risk-colored calendar. Patient rows are horizontal with tier badge pills; risk patients lack a clear “highest risk” hero; homepage still shows a recent-upload thumb wall that duplicates the patient pool. + +## Goals + +1. Make `/admin` a **date-selectable 儀表板** (`?date=YYYY-MM-DD`, default today Taipei). +2. Move metric month calendar onto the homepage (full-width cells with upload / users / risk chips). +3. Redesign patient pool as vertical cards with avatar + day upload count + risk hero or preview thumbs. +4. Keep full-day thumbnail review + annotation on `/admin/history-overview?tab=clinical`. +5. Remove homepage `RecentUploadThumbs`. + +## Non-goals + +- Renaming `/v1/staff/uploads/today-attention`. +- Inline annotation modal on `/admin`. +- Homepage upload thumb grid. +- Date filter on upload queue endpoint. +- Mass rename of `Today*` component filenames. + +## Information architecture + +| Surface | Role | +| --- | --- | +| `/admin?date=` | **儀表板** — metric calendar + attention pool + light ops | +| `/admin/history-overview?date=&tab=clinical` | **區間分析** — period KPIs + full day browser + annotation | +| `/admin/history-overview?tab=usage` | Usage trends (unchanged) | + +```mermaid +flowchart TB + subgraph admin ["/admin?date=YYYY-MM-DD"] + Cal[DashboardDayCalendar] + Pool[AttentionPatientGrid] + Count[DailyUploadCount] + Ops[PendingBindings + ActiveUsers] + end + subgraph history ["/admin/history-overview?date=&tab=clinical"] + Period[ClinicalPeriodPanel] + Review[FullDayThumbnailBrowser + AnnotationModal] + end + Cal -->|select date| admin + Pool -->|card click| Patient["/admin/patients/id"] + Count -->|optional deep link| history +``` + +### Date scope + +| Widget | Follows `selectedDate` | +| --- | --- | +| Metric calendar, patient pool, upload count | Yes | +| Pending bindings, last-7-day active uploaders | No | +| Clinical period KPIs / charts | No (period dimension) | +| History-overview day browser | Yes (from URL `date`) | + +### Naming & copy + +- Sidebar + page h1: **儀表板** +- Subtitle uses **今日** vs **當日** based on whether `selectedDate` is Taipei today + +## Calendar cell (v16 locked) + +- Section: `rounded-2xl border border-zinc-200 bg-white p-5` +- Grid: `grid-cols-7 gap-2 w-full` (no max-width) +- Cell: `flex flex-col min-h-[96px] rounded-xl bg-zinc-50 p-2.5 min-w-0`; selected `bg-white ring-2 ring-zinc-900` +- Date row (top): day number `text-[13px]` + optional `尚有 {n} 未處理` `text-[9px] text-rose-400` +- Metrics row (bottom): `mt-auto pt-2` + `grid grid-cols-3 gap-1` +- Chips: Lucide `Image` / `Users` / `TriangleAlert`; icon above, number below; risk >0 red, =0 muted zinc +- No badge dots; no whole-cell risk tint; days without uploads dashed + disabled + +**Unhandled definition:** patient tier is `suspected` or `elevated` and the representative upload has no staff annotation. + +**Calendar API fields per day:** `upload_count`, `uploaded_users`, `risky_patient_count`, `unhandled_patient_count` + +## Patient pool cards (v2 locked) + +- Vertical card; whole card links to `/admin/patients/[id]` +- Top: LINE `PersonAvatar` + name; one-line subline `當日 N 張上傳 · status` +- Status colors: 未處理 `text-red-600` / 已註解 `text-green-600` / 今日已上傳 `text-zinc-500` +- No tier badge pills; annotated cards are **not** dimmed +- Multi-column: `grid-cols-1 lg:grid-cols-2 xl:grid-cols-3` + +### Risk tiers (`suspected` / `elevated`) + +- Hero row only (`h-14`): `[highest-risk thumb | metadata]` +- Hero pick: day’s non-rejected uploads by `_risk_rank` desc, then `probability` desc, then `created_at` asc +- Metadata (zinc text only): `最高風險 · HH:mm` → main line → optional symptom subline + +### Other tier + +- Plain thumb row `h-14`; show ≤4 or 3 + `+n` +- Backend returns up to 3 `preview_upload_ids` (created_at asc); FE derives `+n` from `day_upload_count` + +### Attention API extensions + +`StaffTodayAttentionPatientItem` adds: + +- `picture_url` +- `day_upload_count` +- `preview_upload_ids` (other tier only) +- `risk_highlight` (risk tiers only): upload id, screening/probability/threshold, symptoms, `created_at` + +`GET /v1/staff/uploads/today-attention` accepts optional `local_date=YYYY-MM-DD`. + +## History-overview changes + +- Read `?date=` and `?tab=` from URL; sync via `router.replace` +- Remove embedded risk-colored month calendar +- Keep day prev/next, period panel, KPI, grouping, thumb wall, annotation modal +- Add link back: `在儀表板變更日期 → /admin?date=…` + +## Deep links + +- Full day review: `/admin/history-overview?date=YYYY-MM-DD&tab=clinical` +- Optional: upload-count card links to the same day on history-overview diff --git a/docs/superpowers/specs/2026-07-25-admin-dashboard-homepage-renovate-design.md b/docs/superpowers/specs/2026-07-25-admin-dashboard-homepage-renovate-design.md new file mode 100644 index 0000000..10293dc --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-admin-dashboard-homepage-renovate-design.md @@ -0,0 +1,220 @@ +# Admin dashboard homepage renovate + +**Date:** 2026-07-25 +**Status:** Approved +**Companion session:** `.superpowers/brainstorm/1424761-1784972892/` + +## Problem + +`/admin` mixes informative but weakly actionable metrics (period KPIs, active-user charts, daily suspected series) with operational queues. Staff cannot quickly answer: how many patients need attention, who is most urgent, and what to do next. Visual language is denser and noisier than `/admin/patient-assignment`. + +## Goals + +1. Split **today workbench** from **period analytics**. +2. Make the homepage patient-centric and actionable. +3. Separate **clinical** vs **system/usage** presentation. +4. Restyle to quiet zinc chrome aligned with patient-assignment (full UI redo, not card polish). + +## Non-goals + +- Changing `/admin/monitoring` (Grafana / infra). +- Redesigning patient-assignment, review-fast, or registration-review pages (homepage only deep-links into them). +- Adding overdue-compliance (N days without upload) in v1. +- Keeping suspected-infection **notifications panel** on the homepage (removed from this surface). + +## Information architecture + +| Surface | Role | +| --- | --- | +| `/admin` | **今日工作台** — triage + light ops | +| `/admin/history-overview` | **區間分析** — clinical period KPIs + day browser + usage trends | +| `/admin/monitoring` | System observability (unchanged) | + +Homepage period chips / Recharts analytics panels move off `/admin` into `history-overview`. + +```mermaid +flowchart LR + subgraph today [AdminHomepage] + List[TodayUploadPatients] + UploadCount[TodayUploadCount] + Thumbs[RecentUploadThumbs] + Bind[PendingBindingsLink] + Active[ActiveUsersSummary] + end + subgraph period [HistoryOverview] + ClinicalTab[ClinicalTab] + UsageTab[UsageTrendsTab] + end + List -->|row click| PatientDetail["/admin/patients/id"] + Thumbs -->|thumb click| PatientDetail + Thumbs -->|plusN| period + Bind -->|deep link| RegReview["/admin/registration-review"] + Active -->|link| period +``` + +## Metric taxonomy + +### Clinical — today (`/admin`) + +| Metric | Definition | Action | +| --- | --- | --- | +| 今日疑似病患數 | Distinct patients with ≥1 **suspected** upload today (Taipei day) | Hero tier count | +| 今日高風險病患數 | Distinct patients with ≥1 **elevated** and **no** suspected today | Hero tier count | +| 今日其餘上傳病患數 | Distinct patients with ≥1 non-rejected upload today who are in neither tier above | Hero tier count | +| 今日上傳病患列表 | Union of the three tiers (same set as hero) | Primary triage list | +| 今日上傳次數 | Non-rejected upload count today | Scalar support | +| 最新上傳縮圖 | Recent uploads with risk badge + time | Browse → patient; `+n` → history-overview | +| 待審綁定 | Pending LINE↔patient bindings (count + summary rows) | Deep link to registration-review | + +**Removed from homepage:** unread notification panel; period filters; risk pie/bar; active-users chart; daily suspected series; “篩選病患數” as a homepage hero KPI. + +### Clinical — period (`history-overview` → 臨床 tab) + +| Metric / block | Notes | +| --- | --- | +| Period chips (months / range) | Moved from homepage | +| Suspected / elevated patient counts | Period window | +| Upload count | Period window | +| Registered / filtered patient count | Scale | +| Risk composition chart | From homepage | +| Existing calendar + thumbnail browser | Keep / integrate | + +### System / usage (`history-overview` → 使用趨勢 tab; homepage summary only) + +| Metric | Where | +| --- | --- | +| Active uploaders series | Usage tab (from homepage) | +| Daily upload / suspected series | Usage tab (from homepage) | +| 近 7 日活躍上傳者 (single number) | Homepage footer summary + link to 區間分析 | + +Infra metrics stay on `/admin/monitoring`. + +## Today list rules + +### Membership + +List = all distinct patients with ≥1 non-rejected upload **today** (Taipei), scoped by existing staff assignment rules (staff see assigned patients; admin see accessible set). + +Hero counts are a partition of that same set into three tiers: + +1. **suspected** +2. **elevated** (elevated uploads, no suspected in window) +3. **other** (today uploads, neither of the above) + +### Sort + +1. Tier: suspected → elevated → other +2. Within tier: **earlier** qualifying upload time first (longer wait = higher priority) +3. Stable tie-break: patient id + +### Row content (design C + single thumb A) + +- Small representative thumbnail (not large) +- Patient name + tier badge(疑似 / 高風險 / 今日上傳) +- Subline status: + - suspected / elevated: **已註解** iff the **representative** risk upload has a staff annotation; otherwise **未處理**; show wait-from time for that representative upload + - other: **今日已上傳** (no annotation gate) +- Trailing `開啟 →` +- Annotated risk rows may be visually de-emphasized (opacity) + +### Representative thumbnail + +- Risk tiers: the upload used for sort priority (highest severity, then earliest) +- Other tier: latest today upload +- Thumbnails only need preview for the representative upload (small square, history-overview visual language at reduced size) + +### Click + +Entire row (including thumb) → `/admin/patients/[id]` + +## Homepage layout + +### Desktop — Layout B (left / right board) + +- Quiet header: title + one-line helper (patient-assignment tone) +- **Left (primary):** dashed pool section「今日上傳病患」with hero tier counts in header + sorted rows +- **Right (stacked, no tabs):** + 1. 今日上傳次數 scalar + 2. 最新上傳 — compact thumbnail grid (history-overview style: risk badge + time), ~5 visible + `+n` overflow cell + 3. 待審綁定 — summary rows + deep link to registration-review (no full inline approve/link/create/reject on homepage) + 4. 近 7 日活躍上傳者 + link to history-overview + +### Mobile + +Single column: list first, then the same right-stack blocks. + +### Visual language + +Align with patient-assignment: + +- `bg-zinc-50` page, white panels, `rounded-xl`, zinc borders +- Dashed pool for the primary work list +- Compact controls, muted empty/loading copy +- Cards only as interaction containers +- No metric-soup KPI wall; no notification panel + +## Upload thumb grid + +- Same interaction language as history-overview thumbnails, smaller cells +- Only recent N uploads; overflow shows `+n` (assignment-lot style), not infinite “load more” on the homepage +- Thumb click → `/admin/patients/[id]` (not modal) +- `+n` → `/admin/history-overview` (today / default clinical view) +- Header link may still point to 極速審核 as secondary egress + +## Pending bindings + +- Homepage shows count + compact summary (case number / birth date / candidate hint) +- Primary action: navigate to `/admin/registration-review` +- Do **not** port the full inline binding workspace onto the homepage in this redesign + +## Notifications + +- Remove homepage「疑似感染通知」panel from this renovate +- Admin layout notification bell may remain unchanged unless a follow-up explicitly removes it + +## History-overview renovate + +### Tabs + +1. **臨床** — period KPIs (suspected/elevated patients, uploads, patient scale) + risk composition chart + existing calendar/thumbnail browser +2. **使用趨勢** — active uploaders series + daily upload/suspected series (moved from homepage) + +Period controls live on this page (not homepage). + +Visual chrome should move toward the same quiet zinc language where touched; full pixel rewrite of every history control is not required in v1 if structure/tabs land cleanly, but new KPI/chart sections should match homepage tokens. + +## Backend / API implications + +Likely need a **today attention / today uploaders** endpoint (or extend suspected summary) that returns: + +- tier counts (suspected, elevated, other) +- ordered patient rows with: patient identity, tier, representative upload id + image access, sort timestamp, annotation-handled flag (for risk tiers) + +Reuse existing: + +- upload queue (limit for thumb grid) +- pending bindings list (summary only on FE) +- active-users series (for 7-day summary number and usage tab) +- period suspected summary + chart series (history-overview) + +Exact shapes are implementation-plan details; this spec locks product semantics above. + +## RBAC + +- Preserve current rules: staff assignment-scoped lists; admin analytics where already admin-gated +- Homepage today list must respect the same visibility as current staff patient/upload queues +- History-overview admin-only charts remain admin-gated if that is current behavior + +## Success criteria + +- Staff can open `/admin` and within one viewport see who to handle first (risk-first, oldest first) with handled state visible for risk rows +- Counts answer “how many patients” by tier, not only “how many uploads” +- Period/trend questions are answered on history-overview, not the homepage +- Visual density comparable to patient-assignment; no notification panel; no three-column metric soup + +## Open follow-ups (out of v1) + +- Overdue non-uploaders (compliance) +- Notification bell / global notification UX cleanup +- Inline binding actions on homepage +- Upload thumb → review-fast deep link diff --git a/docs/superpowers/specs/assets/2026-07-25-admin-patient-card-risk-highlight.html b/docs/superpowers/specs/assets/2026-07-25-admin-patient-card-risk-highlight.html new file mode 100644 index 0000000..05ea84c --- /dev/null +++ b/docs/superpowers/specs/assets/2026-07-25-admin-patient-card-risk-highlight.html @@ -0,0 +1,136 @@ + + + + + + Admin patient card — risk highlight row + + + + + + From 7a4bc8a3892f38b3db1d6475b82955be26212d80 Mon Sep 17 00:00:00 2001 From: ruby0322 Date: Fri, 7 Aug 2026 00:14:27 +0800 Subject: [PATCH 2/5] feat(admin): refine dashboard UX, review modal, and history tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish the date-selectable workbench with week/mobile calendar, master-detail patient panel with in-app upload review, 歷史總覽 tab split, upload trend chart, and dashboard demo seed for UI review. Co-authored-by: Cursor --- .../backend/sql/manual/seed_dashboard_demo.py | 325 ++++++++++++++ .../_components/active-uploaders-summary.tsx | 26 -- .../_components/dashboard-day-calendar.tsx | 327 +++++++++++---- .../history-upload-annotation-modal.tsx | 146 +++++++ .../history-upload-review-helpers.ts | 69 +++ .../history-upload-thumbnail-grid.tsx | 77 ++++ .../patient-day-upload-review-modal.tsx | 221 ++++++++++ .../_components/pending-bindings-summary.tsx | 52 --- .../today-patient-detail-panel.tsx | 210 ++++++++++ .../admin/_components/today-patient-pool.tsx | 37 +- .../admin/_components/today-patient-row.tsx | 57 +-- .../admin/_components/today-upload-count.tsx | 34 -- .../app/admin/_components/upload-thumb.tsx | 48 +++ .../_components/use-upload-image-urls.ts | 47 +++ .../history-overview/__tests__/page.test.tsx | 14 +- .../__tests__/usage-trends-tab.test.tsx | 100 +++++ .../app/admin/history-overview/page.tsx | 395 +++++------------- .../history-overview/usage-trends-tab.tsx | 82 +++- .../usage-upload-chart-data.ts | 18 + apps/frontend/app/admin/layout.tsx | 8 +- apps/frontend/app/admin/page.tsx | 203 ++++----- .../utils/__tests__/upload-calendar.test.ts | 19 + apps/frontend/lib/utils/upload-calendar.ts | 46 ++ docs/ops/local-dev-without-line.md | 8 + package.json | 1 + 25 files changed, 1875 insertions(+), 695 deletions(-) create mode 100644 apps/backend/sql/manual/seed_dashboard_demo.py delete mode 100644 apps/frontend/app/admin/_components/active-uploaders-summary.tsx create mode 100644 apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx create mode 100644 apps/frontend/app/admin/_components/history-upload-review-helpers.ts create mode 100644 apps/frontend/app/admin/_components/history-upload-thumbnail-grid.tsx create mode 100644 apps/frontend/app/admin/_components/patient-day-upload-review-modal.tsx delete mode 100644 apps/frontend/app/admin/_components/pending-bindings-summary.tsx create mode 100644 apps/frontend/app/admin/_components/today-patient-detail-panel.tsx delete mode 100644 apps/frontend/app/admin/_components/today-upload-count.tsx create mode 100644 apps/frontend/app/admin/_components/upload-thumb.tsx create mode 100644 apps/frontend/app/admin/_components/use-upload-image-urls.ts create mode 100644 apps/frontend/app/admin/history-overview/__tests__/usage-trends-tab.test.tsx create mode 100644 apps/frontend/app/admin/history-overview/usage-upload-chart-data.ts diff --git a/apps/backend/sql/manual/seed_dashboard_demo.py b/apps/backend/sql/manual/seed_dashboard_demo.py new file mode 100644 index 0000000..78664b8 --- /dev/null +++ b/apps/backend/sql/manual/seed_dashboard_demo.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Seed ~30 days of dashboard demo uploads for local UI review. + +Creates P-DEV-DASH-* patients with mixed suspected / elevated / other tiers, +staff assignments (U_DEV_STAFF), and optional staff annotations. Dates anchor to +Taipei today so the admin week calendar always shows a realistic month of activity. + +Run after personas (and optionally fake patients): + + npm run seed:dev-personas + npm run seed:dashboard-demo + +Requires DATABASE_URL (loads apps/backend/.env when python-dotenv is installed). +""" + +from __future__ import annotations + +import os +import random +import sys +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None # type: ignore[misc, assignment] + +from sqlalchemy import delete, inspect, select, text +from sqlalchemy.orm import Session + +from app.db.migrations import upgrade_database +from app.db.models import AIResult, Annotation, LiffIdentity, Notification, Patient, StaffPatientAssignment, Upload +from app.db.session import create_engine_from_url, create_session_factory + +TZ = ZoneInfo("Asia/Taipei") +CASE_PREFIX = "P-DEV-DASH-" +LINE_PREFIX = "U_DEV_DASH_" +LOOKBACK_DAYS = 30 +RNG = random.Random(20260806) + + +@dataclass(frozen=True) +class DemoPatientSpec: + suffix: str + full_name: str + gender: str + birth_date: str + picture_url: str | None + + +_PATIENTS: tuple[DemoPatientSpec, ...] = ( + DemoPatientSpec("001", "陳志明", "male", "1968-04-12", "https://i.pravatar.cc/150?u=dash001"), + DemoPatientSpec("002", "林美華", "female", "1972-09-03", "https://i.pravatar.cc/150?u=dash002"), + DemoPatientSpec("003", "王淑芬", "female", "1960-11-28", "https://i.pravatar.cc/150?u=dash003"), + DemoPatientSpec("004", "張文雄", "male", "1955-07-19", "https://i.pravatar.cc/150?u=dash004"), + DemoPatientSpec("005", "黃雅婷", "female", "1988-02-14", "https://i.pravatar.cc/150?u=dash005"), + DemoPatientSpec("006", "李俊傑", "male", "1991-06-08", "https://i.pravatar.cc/150?u=dash006"), + DemoPatientSpec("007", "吳佳玲", "female", "1979-12-22", "https://i.pravatar.cc/150?u=dash007"), + DemoPatientSpec("008", "蔡明德", "male", "1963-03-30", None), + DemoPatientSpec("009", "許麗娟", "female", "1974-08-17", "https://i.pravatar.cc/150?u=dash009"), + DemoPatientSpec("010", "楊志豪", "male", "1982-01-05", "https://i.pravatar.cc/150?u=dash010"), + DemoPatientSpec("011", "鄭心怡", "female", "1995-10-11", "https://i.pravatar.cc/150?u=dash011"), + DemoPatientSpec("012", "謝承恩", "male", "1970-05-25", "https://i.pravatar.cc/150?u=dash012"), +) + + +def _taipei_today() -> date: + return datetime.now(tz=TZ).date() + + +def _parse_local_dt(day: date, hour: int, minute: int) -> datetime: + return datetime.combine(day, datetime.min.time().replace(hour=hour, minute=minute), tzinfo=TZ) + + +def _resolve_staff_identity_id(session: Session) -> int | None: + for line_user_id in ("U_DEV_ADMIN", "U_DEV_STAFF", "U_DEV_DUAL"): + row = session.scalar( + select(LiffIdentity.id).where( + LiffIdentity.line_user_id == line_user_id, + LiffIdentity.role.in_(("staff", "admin")), + LiffIdentity.is_active.is_(True), + ) + ) + if row is not None: + return int(row) + return None + + +def _clear_demo(session: Session) -> None: + case_numbers = [f"{CASE_PREFIX}{spec.suffix}" for spec in _PATIENTS] + line_user_ids = [f"{LINE_PREFIX}{spec.suffix}" for spec in _PATIENTS] + patient_ids = list(session.scalars(select(Patient.id).where(Patient.case_number.in_(case_numbers))).all()) + if patient_ids: + upload_ids = select(Upload.id).where(Upload.patient_id.in_(patient_ids)) + session.execute(delete(Annotation).where(Annotation.upload_id.in_(upload_ids))) + session.execute(delete(Notification).where(Notification.patient_id.in_(patient_ids))) + session.execute(delete(AIResult).where(AIResult.upload_id.in_(upload_ids))) + session.execute(delete(Upload).where(Upload.patient_id.in_(patient_ids))) + session.execute(delete(StaffPatientAssignment).where(StaffPatientAssignment.patient_id.in_(patient_ids))) + session.execute(delete(LiffIdentity).where(LiffIdentity.patient_id.in_(patient_ids))) + session.execute(delete(Patient).where(Patient.id.in_(patient_ids))) + session.execute(delete(LiffIdentity).where(LiffIdentity.line_user_id.in_(line_user_ids))) + + +def _add_annotation( + session: Session, + *, + patient_id: int, + upload_id: int, + reviewer_identity_id: int, + label: str, +) -> None: + bind = session.get_bind() + column_names = {column["name"] for column in inspect(bind).get_columns("annotations")} + if "staff_user_id" in column_names: + session.execute( + text( + """ + INSERT INTO annotations ( + patient_id, upload_id, reviewer_identity_id, staff_user_id, label, comment, patient_read_at + ) VALUES ( + :patient_id, :upload_id, :reviewer_identity_id, :staff_user_id, :label, :comment, NULL + ) + """ + ), + { + "patient_id": patient_id, + "upload_id": upload_id, + "reviewer_identity_id": reviewer_identity_id, + "staff_user_id": reviewer_identity_id, + "label": label, + "comment": "dashboard demo", + }, + ) + return + session.add( + Annotation( + patient_id=patient_id, + upload_id=upload_id, + reviewer_identity_id=reviewer_identity_id, + label=label, + comment="dashboard demo", + ) + ) + + +def _upload_profile(day_offset: int, patient_index: int, upload_index: int) -> tuple[str, dict[str, bool], float, bool]: + """Return screening_result, symptoms, probability, should_annotate.""" + roll = RNG.random() + # Today / yesterday: richer risk mix for UI review. + if day_offset >= -1: + if roll < 0.28: + return ("suspected", {}, 0.82 + RNG.random() * 0.12, roll < 0.12) + if roll < 0.48: + symptoms = RNG.choice( + ( + {"symptom_pain": True}, + {"symptom_pus": True}, + {"symptom_cloudy_dialysate": True}, + {"symptom_pain": True, "symptom_pus": True}, + ) + ) + return ("normal", symptoms, 0.12 + RNG.random() * 0.15, roll < 0.22) + return ("normal", {}, 0.05 + RNG.random() * 0.2, False) + + if day_offset >= -7 and roll < 0.18: + return ("suspected", {}, 0.75 + RNG.random() * 0.2, roll < 0.35) + if roll < 0.12: + return ("suspected", {}, 0.7 + RNG.random() * 0.25, RNG.random() < 0.4) + if roll < 0.22: + return ( + "normal", + RNG.choice(({"symptom_pain": True}, {"symptom_pus": True}, {"symptom_cloudy_dialysate": True})), + 0.1 + RNG.random() * 0.2, + RNG.random() < 0.3, + ) + return ("normal", {}, 0.05 + RNG.random() * 0.25, False) + + +def _seed_demo(session: Session, *, staff_identity_id: int | None) -> list[str]: + patients_by_suffix: dict[str, Patient] = {} + for spec in _PATIENTS: + patient = Patient( + case_number=f"{CASE_PREFIX}{spec.suffix}", + birth_date=spec.birth_date, + full_name=spec.full_name, + gender=spec.gender, + is_active=True, + ) + session.add(patient) + session.flush() + session.add( + LiffIdentity( + line_user_id=f"{LINE_PREFIX}{spec.suffix}", + display_name=spec.full_name, + picture_url=spec.picture_url, + patient_id=patient.id, + role="patient", + is_active=True, + ) + ) + if staff_identity_id is not None: + session.add( + StaffPatientAssignment( + staff_identity_id=staff_identity_id, + patient_id=patient.id, + ) + ) + patients_by_suffix[spec.suffix] = patient + + today = _taipei_today() + active_days: list[str] = [] + + for day_offset in range(-LOOKBACK_DAYS, 1): + local_day = today + timedelta(days=day_offset) + weekday = local_day.weekday() + # Skip some days; weekends slightly quieter. + skip_chance = 0.35 if weekday >= 5 else 0.22 + if day_offset not in (0, -1, -3, -7, -14, -21) and RNG.random() < skip_chance: + continue + + active_days.append(local_day.isoformat()) + upload_target = RNG.randint(5, 14) if day_offset >= -7 else RNG.randint(3, 10) + if weekday >= 5: + upload_target = max(2, upload_target - 2) + + chosen_suffixes = RNG.sample( + [spec.suffix for spec in _PATIENTS], + k=min(len(_PATIENTS), RNG.randint(3, min(9, upload_target))), + ) + upload_count = 0 + patient_cycle = 0 + while upload_count < upload_target: + suffix = chosen_suffixes[patient_cycle % len(chosen_suffixes)] + patient_cycle += 1 + patient = patients_by_suffix[suffix] + patient_index = int(suffix) + screening, symptoms, probability, annotate = _upload_profile(day_offset, patient_index, upload_count) + hour = 7 + (upload_count * 2 + patient_index) % 14 + minute = (upload_count * 11 + patient_index * 7) % 60 + + upload = Upload( + patient_id=patient.id, + object_key=f"patients/dashboard-demo/{patient.id}/{local_day.isoformat()}-{upload_count}.jpg", + content_type="image/jpeg", + created_at=_parse_local_dt(local_day, hour, minute), + symptom_pain=bool(symptoms.get("symptom_pain")), + symptom_discharge=bool(symptoms.get("symptom_discharge")), + symptom_pus=bool(symptoms.get("symptom_pus")), + symptom_cloudy_dialysate=bool(symptoms.get("symptom_cloudy_dialysate")), + ) + session.add(upload) + session.flush() + session.add( + AIResult( + upload_id=upload.id, + screening_result=screening, + probability=probability, + threshold=0.5, + predicted_class="class_4" if screening == "suspected" else "class_1", + model_version="dashboard-demo-v1", + ) + ) + if annotate and staff_identity_id is not None and screening in ("suspected", "normal"): + label = "suspected" if screening == "suspected" else "normal" + _add_annotation( + session, + patient_id=patient.id, + upload_id=upload.id, + reviewer_identity_id=staff_identity_id, + label=label, + ) + upload_count += 1 + + return active_days + + +def main() -> int: + if load_dotenv: + load_dotenv(_BACKEND_ROOT / ".env") + + database_url = os.getenv("PDCARE_DATABASE_URL") or os.getenv("DATABASE_URL") + if not database_url: + print( + "Neither PDCARE_DATABASE_URL nor DATABASE_URL is set. " + "Set one of them or put it in apps/backend/.env", + file=sys.stderr, + ) + return 1 + + print(f"Using database URL: {database_url}") + engine = create_engine_from_url(database_url) + upgrade_database(str(engine.url)) + session_factory = create_session_factory(engine) + + with session_factory() as session: + try: + staff_identity_id = _resolve_staff_identity_id(session) + if staff_identity_id is None: + print("Warning: no U_DEV_STAFF/U_DEV_ADMIN identity — run seed:dev-personas first.", file=sys.stderr) + _clear_demo(session) + active_days = _seed_demo(session, staff_identity_id=staff_identity_id) + session.commit() + except Exception: + session.rollback() + raise + + today = _taipei_today() + print(f"\nDashboard demo seeded ({len(_PATIENTS)} patients, {len(active_days)} active days).") + print(f"Taipei today: {today.isoformat()}") + print("Login as U_DEV_ADMIN or U_DEV_STAFF → /admin") + print("Use arrow keys / week nav / calendar icon on the week strip.") + print(f"Sample active range: {active_days[0]} … {active_days[-1]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/frontend/app/admin/_components/active-uploaders-summary.tsx b/apps/frontend/app/admin/_components/active-uploaders-summary.tsx deleted file mode 100644 index 394135e..0000000 --- a/apps/frontend/app/admin/_components/active-uploaders-summary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import Link from "next/link"; - -type ActiveUploadersSummaryProps = { - activeUsers: number | null; - loading: boolean; - error: string | null; -}; - -export function ActiveUploadersSummary({ activeUsers, loading, error }: ActiveUploadersSummaryProps) { - return ( -

- {loading ? ( - "近 7 日活躍上傳者載入中…" - ) : error ? ( - 活躍摘要暫時無法載入 - ) : ( - <> - 近 7 日活躍上傳者 {activeUsers ?? "—"} ·{" "} - - 查看區間分析 → - - - )} -

- ); -} diff --git a/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx b/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx index 910c935..9f43900 100644 --- a/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx +++ b/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx @@ -1,10 +1,16 @@ "use client"; -import { ChevronLeft, ChevronRight, Image as ImageIcon, TriangleAlert, Users } from "lucide-react"; -import type { ReactNode } from "react"; +import { CalendarDays, ChevronLeft, ChevronRight, Image as ImageIcon, TriangleAlert, Users } from "lucide-react"; +import { useCallback, useEffect, useId, useMemo, useRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; -import { buildTaipeiMonthGrid, getRelativeMonthKey } from "@/lib/utils/upload-calendar"; +import { + buildTaipeiWeekRow, + formatTaipeiWeekRangeLabel, + getWeekStartDateKey, + parseTaipeiDateKey, + shiftTaipeiDateKey, +} from "@/lib/utils/upload-calendar"; export type DayCalendarMetrics = { uploadCount: number; @@ -15,12 +21,12 @@ export type DayCalendarMetrics = { type DashboardDayCalendarProps = { selectedDate: string; - monthKey: string; + weekStartDateKey: string; metricsByDate: Record; availableDates: Set | string[]; loading?: boolean; onSelectDate: (dateKey: string) => void; - onMonthChange: (monthKey: string) => void; + onWeekChange: (weekStartDateKey: string) => void; }; function MetricChip({ @@ -52,113 +58,264 @@ function MetricChip({ ); } +const WEEKDAY_LABELS = ["日", "一", "二", "三", "四", "五", "六"] as const; + +function formatTaipeiDateLabel(dateKey: string): string { + const { year, month, day } = parseTaipeiDateKey(dateKey); + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + return `${year} 年 ${month} 月 ${day} 日(${WEEKDAY_LABELS[weekday]})`; +} + +function DayCellButton({ + cell, + metricsByDate, + available, + selected, + onSelectDate, + className, +}: { + cell: { dateKey: string; dayOfMonth: number }; + metricsByDate: Record; + available: Set; + selected: boolean; + onSelectDate: (dateKey: string) => void; + className?: string; +}) { + const metrics = metricsByDate[cell.dateKey]; + const isAvailable = available.has(cell.dateKey); + const unhandled = metrics?.unhandledPatients ?? 0; + const uploadCount = metrics?.uploadCount ?? 0; + const uploadedUsers = metrics?.uploadedUsers ?? 0; + const riskyPatients = metrics?.riskyPatients ?? 0; + + return ( + + ); +} + export function DashboardDayCalendar({ selectedDate, - monthKey, + weekStartDateKey, metricsByDate, availableDates, loading, onSelectDate, - onMonthChange, + onWeekChange, }: DashboardDayCalendarProps) { - const available = availableDates instanceof Set ? availableDates : new Set(availableDates); - const grid = buildTaipeiMonthGrid(monthKey); - const title = `${grid.year} 年 ${grid.month} 月`; + const available = useMemo( + () => (availableDates instanceof Set ? availableDates : new Set(availableDates)), + [availableDates] + ); + const weekCells = buildTaipeiWeekRow(weekStartDateKey); + const weekTitle = formatTaipeiWeekRangeLabel(weekStartDateKey); + const mobileTitle = formatTaipeiDateLabel(selectedDate); + const selectedCell = useMemo(() => { + const { day } = parseTaipeiDateKey(selectedDate); + return { dateKey: selectedDate, dayOfMonth: day }; + }, [selectedDate]); + const datePickerId = useId(); + const dateInputRef = useRef(null); + const panelRef = useRef(null); + + const moveSelectedByDays = useCallback( + (offsetDays: number) => { + let candidate = selectedDate; + for (let step = 0; step < 366; step += 1) { + candidate = shiftTaipeiDateKey(candidate, offsetDays); + if (available.has(candidate)) { + onSelectDate(candidate); + const weekStart = getWeekStartDateKey(candidate); + if (weekStart !== weekStartDateKey) { + onWeekChange(weekStart); + } + return; + } + } + }, + [available, onSelectDate, onWeekChange, selectedDate, weekStartDateKey] + ); + + useEffect(() => { + const panel = panelRef.current; + if (!panel) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + moveSelectedByDays(-1); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + moveSelectedByDays(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + onWeekChange(shiftTaipeiDateKey(weekStartDateKey, -7)); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + onWeekChange(shiftTaipeiDateKey(weekStartDateKey, 7)); + } + }; + panel.addEventListener("keydown", onKeyDown); + return () => { + panel.removeEventListener("keydown", onKeyDown); + }; + }, [moveSelectedByDays, onWeekChange, weekStartDateKey]); + + const openDatePicker = () => { + const input = dateInputRef.current; + if (!input) { + return; + } + if (typeof input.showPicker === "function") { + input.showPicker(); + } else { + input.focus(); + input.click(); + } + }; return ( -
+
+ -
- {title} - {loading ? 載入中… : null} +
+ +
+ {mobileTitle} + {weekTitle} + {loading ? 載入中… : null} +
+ { + const next = event.target.value; + if (!next) { + return; + } + try { + parseTaipeiDateKey(next); + } catch { + return; + } + onSelectDate(next); + onWeekChange(getWeekStartDateKey(next)); + }} + />
+
-
- {["日", "一", "二", "三", "四", "五", "六"].map((label) => ( +

+ 方向鍵:← → 切換日期 · ↑ ↓ 切換週 · 點日曆圖示選日期 +

+ +
+ +
+ +
+ {WEEKDAY_LABELS.map((label) => (
{label}
))}
-
- {grid.cells.map((cell) => { - if (!cell.isCurrentMonth) { - return
; - } - const metrics = metricsByDate[cell.dateKey]; - const isAvailable = available.has(cell.dateKey); - const selected = selectedDate === cell.dateKey; - const unhandled = metrics?.unhandledPatients ?? 0; - const uploadCount = metrics?.uploadCount ?? 0; - const uploadedUsers = metrics?.uploadedUsers ?? 0; - const riskyPatients = metrics?.riskyPatients ?? 0; - - return ( - - ); - })} +
+ {weekCells.map((cell) => ( + + ))}
); diff --git a/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx b/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx new file mode 100644 index 0000000..b1ecb04 --- /dev/null +++ b/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx @@ -0,0 +1,146 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { X } from "lucide-react"; + +import type { StaffAnnotationItem, StaffHistoryOverviewUploadItem } from "@/lib/api/staff"; + +import { historyUploadRiskLabel, type HistoryUploadDraftVerdict } from "./history-upload-review-helpers"; + +type HistoryUploadAnnotationModalProps = { + upload: StaffHistoryOverviewUploadItem; + imageUrl: string | null; + draft: HistoryUploadDraftVerdict; + saving: boolean; + onDraftChange: (next: HistoryUploadDraftVerdict) => void; + onSave: () => void; + onClose: () => void; +}; + +export function HistoryUploadAnnotationModal({ + upload, + imageUrl, + draft, + saving, + onDraftChange, + onSave, + onClose, +}: HistoryUploadAnnotationModalProps) { + return ( +
+
+
+
+

{upload.patient_full_name ?? "未命名病患"}

+

{upload.case_number}

+
+ +
+
+
+ {imageUrl ? ( + {`history-preview-${upload.upload_id}`} + ) : ( +
載入影像中...
+ )} +
+
+
+
+
年齡
+
{upload.age ?? "-"}
+
+
+
上傳時間
+
{new Date(upload.created_at).toLocaleString("zh-TW")}
+
+
+
臨床風險
+
{historyUploadRiskLabel(upload)}
+
+
+
影像判讀
+
{upload.screening_result}
+
+
+
症狀綜合
+
{upload.symptom_aware_priority}
+
+
+
機率
+
+ {upload.probability !== null ? `${(upload.probability * 100).toFixed(1)}%` : "-"} +
+
+
+
Threshold
+
+ {upload.threshold !== null ? upload.threshold.toFixed(2) : "-"} +
+
+
+
Model
+
{upload.model_version ?? "-"}
+
+
+
+ + 開啟病患完整頁 + +
+ +