diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 3c3cc53..e4c4412 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -14,6 +14,7 @@ alwaysApply: false - **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments). - **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right. - **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields. +- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index dabe36d..55e8974 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add. +1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Patient search (`PatientSearchCombobox`) sits in the **page header** (workspace-wide). **New treatment** is one shared `Button` at the **top of the left rail**, with the selected-patient card under it: it opens `NewTreatmentPatientPicker` (Walk-in always first, then a matching full-width card for the current named patient with name + mobile/email or hint, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add. 2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) @@ -201,7 +201,7 @@ Use shared `Checkbox` (not native ``) to avoid focus-driv -Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in). +Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks Walk-in, the current named patient card, or search). Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. diff --git a/AGENTS.md b/AGENTS.md index bf72e37..7ab5676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ frontend/src/ **Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow. **Treatment edit / details (quick ref):** -- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. +- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. - **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). - **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments. diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index f370657..21be004 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl const treatmentInclude = { patient: { - select: { id: true, firstName: true, lastName: true, isWalkIn: true }, + select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true }, }, details: { orderBy: [{ sortOrder: 'asc' as const }], @@ -1366,6 +1366,8 @@ export class TreatmentsService { firstName: string; lastName: string; isWalkIn: boolean; + mobile?: string | null; + email?: string | null; }; details: Array<{ id: string; @@ -1426,6 +1428,8 @@ export class TreatmentsService { firstName: treatment.patient.firstName, lastName: treatment.patient.lastName, isWalkIn: treatment.patient.isWalkIn, + mobile: treatment.patient.mobile ?? null, + email: treatment.patient.email ?? null, } : null, details: treatment.details.map((d) => this.mapDetail(d)), diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2450819..7412cea 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -683,7 +683,7 @@ "selectedPatient": "Selected patient", "purposeLabel": "Purpose:", "loadingAppointments": "Loading appointments…", - "selectDayWithAppointment": "Select a day with at least one appointment.", + "selectDayWithAppointment": "Search for a patient or select a visit from the day strip.", "confirmDiscard": "You have unsaved changes. Discard them and continue?", "errorChooseOrg": "Choose at least one active organization to send this case.", "successCaseSent": "Case sent to selected organizations.", @@ -707,6 +707,8 @@ "walkIn": "Walk-in", "newTreatment": "New treatment", "newTreatmentPatientPrompt": "Who is this visit for?", + "newTreatmentUseCurrent": "Use {name}", + "newTreatmentUseCurrentHint": "Start a new visit for the patient already open on this page.", "walkInPickerHint": "No named patient — always available, no search needed.", "errorCreateTreatment": "Could not create treatment.", "deleteEmptyTreatment": "Delete empty treatment", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 179ba2d..9316b36 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -684,7 +684,7 @@ "selectedPatient": "بیمار انتخاب شده", "purposeLabel": "هدف:", "loadingAppointments": "در حال بارگذاری نوبت‌ها...", - "selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", + "selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.", "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", @@ -708,6 +708,8 @@ "walkIn": "بدون نوبت (مراجع)", "newTreatment": "درمان جدید", "newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟", + "newTreatmentUseCurrent": "استفاده از {name}", + "newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.", "walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.", "errorCreateTreatment": "ایجاد درمان ممکن نشد.", "deleteEmptyTreatment": "حذف درمان خالی", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 167ac9e..46b7491 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -683,7 +683,7 @@ "selectedPatient": "Geselecteerde patiënt", "purposeLabel": "Doel:", "loadingAppointments": "Afspraken laden...", - "selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.", + "selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.", "confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?", "errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.", "successCaseSent": "Case verzonden naar geselecteerde organisaties.", @@ -707,6 +707,8 @@ "walkIn": "Inloop", "newTreatment": "Nieuwe behandeling", "newTreatmentPatientPrompt": "Voor wie is dit bezoek?", + "newTreatmentUseCurrent": "{name} gebruiken", + "newTreatmentUseCurrentHint": "Start een nieuw bezoek voor de patiënt die al op deze pagina openstaat.", "walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.", "errorCreateTreatment": "Behandeling aanmaken is mislukt.", "deleteEmptyTreatment": "Lege behandeling verwijderen", diff --git a/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx index 0be070d..f732bab 100644 --- a/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx +++ b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx @@ -4,19 +4,45 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; +import { formatMobileForDisplay } from '@/lib/phone'; import type { Patient } from '@/types/patient'; +const pickerChoiceClass = + 'w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45'; + interface NewTreatmentPatientPickerProps { creating?: boolean; + currentPatient?: { + id: string; + displayName: string; + mobile?: string | null; + email?: string | null; + } | null; onSelectWalkIn: () => void | Promise; onSelectPatient: (patient: Patient) => void | Promise; + onSelectCurrentPatient?: () => void | Promise; onCancel: () => void; } +function contactLine( + patient: { mobile?: string | null; email?: string | null }, + fallback: string, +): string { + const mobile = patient.mobile?.trim() + ? formatMobileForDisplay(patient.mobile.trim()) + : ''; + if (mobile) return mobile; + const email = patient.email?.trim(); + if (email) return email; + return fallback; +} + export function NewTreatmentPatientPicker({ creating = false, + currentPatient = null, onSelectWalkIn, onSelectPatient, + onSelectCurrentPatient, onCancel, }: NewTreatmentPatientPickerProps) { const t = useTranslations('treatment'); @@ -31,11 +57,25 @@ export function NewTreatmentPatientPicker({ type="button" disabled={creating} onClick={() => void onSelectWalkIn()} - className="w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45" + className={pickerChoiceClass} >

{t('walkIn')}

{t('walkInPickerHint')}

+ {currentPatient && onSelectCurrentPatient ? ( + + ) : null} ('patient'); const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState(null); - const [searchedPatient, setSearchedPatient] = useState | null>(null); + const [searchedPatient, setSearchedPatient] = useState< + (Pick & { + mobile?: string | null; + email?: string | null; + }) | null + >(null); const [patientSearchBusy, setPatientSearchBusy] = useState(false); const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false); const [creatingStandalone, setCreatingStandalone] = useState(false); @@ -502,6 +505,8 @@ export function TreatmentWorkspace({ id: selectedAppointment.patientId, firstName: selectedAppointment.patientFirstName, lastName: selectedAppointment.patientLastName, + mobile: selectedAppointment.patientMobile ?? null, + email: null, purpose: selectedAppointment.purpose, isWalkIn: false, }; @@ -512,6 +517,8 @@ export function TreatmentWorkspace({ id: selectedStandalone.patientId, firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''), lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''), + mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null), + email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null), purpose: selectedStandalone.details[0]?.treatmentType, isWalkIn, }; @@ -521,7 +528,9 @@ export function TreatmentWorkspace({ id: searchedPatient.id, firstName: searchedPatient.firstName, lastName: searchedPatient.lastName, - purpose: undefined as string | undefined, + mobile: searchedPatient.mobile ?? null, + email: searchedPatient.email ?? null, + purpose: undefined, isWalkIn: false, }; } @@ -532,6 +541,15 @@ export function TreatmentWorkspace({ const activePatientName = activePatient ? `${activePatient.firstName} ${activePatient.lastName}`.trim() : null; + const namedActivePatient = + activePatient && !activePatient.isWalkIn && activePatient.id + ? { + id: activePatient.id, + displayName: activePatientName ?? '', + mobile: activePatient.mobile ?? null, + email: activePatient.email ?? null, + } + : null; const unreadUpdatesCount = unreadLabCases.length; const otherPatientsUnreadCount = useMemo( @@ -1557,6 +1575,8 @@ export function TreatmentWorkspace({ id: patient.id, firstName: patient.firstName, lastName: patient.lastName, + mobile: patient.mobile, + email: patient.email, }); try { const response = await treatmentsApi.listPatientHistory(patient.id, 1); @@ -2146,11 +2166,24 @@ export function TreatmentWorkspace({ return (
-
-

{t('title')}

- {!canEdit ? ( -

{t('subtitleReadOnly')}

- ) : null} +
+
+

{t('title')}

+ {!canEdit ? ( +

{t('subtitleReadOnly')}

+ ) : null} +
+
+ +
- {canEdit && !isViewingPastDay ? (