Compare commits

..

39 Commits

Author SHA1 Message Date
8c16a46740 docs: drop the spec's reference to the auto-fill exemption
origin/master removed the dispatch panel's remembered-prosthesis auto-fill
(f52ad6b), so the exemption this branch carried for it went away in the
rebase. The spec named the mechanism and a verification step for a feature
that no longer exists.

The rule it existed to enforce still stands and is worth keeping written
down — confirm fills exactly what the sheet previewed — so it stays, marked
as a constraint on whatever gets added next rather than a description of
something in the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:18:22 +03:30
dc10d8dbe3 docs: cut the comments that were not earning their place
I wrote 731 comment lines on this branch against 4,530 lines of code — 14%,
where the rest of the repo runs at 1.8%. CLAUDE.md asks for code that reads
like its surroundings, and this did not.

Removed by genre rather than by taste:

- restating the code, e.g. "JS getUTCDay() numbering: Sunday = 0" above the
  map that literally shows it, and a docblock on startOfWeek explaining that
  it returns the start of the week;
- narrating history — "this used to rebuild the whole map", "left the bar
  recording forever" — which the commit message and git blame already carry;
- saying the same thing in several places: the "cannot record is not a
  denied microphone" reason appeared three times in one file, and the
  "aborting stops a per-minute metered call" reason across three files. Each
  now lives once, where the behaviour it explains lives;
- defending decisions nobody would question, like why toLatinDigits is its
  own module;
- over-explaining defensive branches, three separate comments to distinguish
  null from missing-kind from unrecognised-kind.

What stays is what the code cannot say: the patient-right convention in
toFdi, whose failure mode is a valid code for the wrong tooth; the
"this"-vs-"next" week anchoring; StrictMode re-arming mountedRef; Safari
accepting no mimeType hint; and the invariants whose violation already cost
a bug — the body parser's middleware ordering and the dispatch panel's
auto-fill rules.

Comments only. The diff contains no non-comment line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:14:15 +03:30
562ef2ae6e docs: bring the voice spec in line with the flow as built
The spec still described the flow as designed, not as it works after the
first live recordings.

§2 gains what confirm actually writes: the detail and its lab case are
persisted on confirm, because the autosave effect watches `details` only
and a lab draft left in component state loses the lab, the due date and the
prosthesis map on reload — while the detail survives, which is what makes
that loss look like a save. Plus the guard: a preview treatment comes back
instead when any detail is still untyped, and confirm skips the lab-case
save rather than posting an id the server has never seen.

§9 corrects three codes: VOICE_MIC_DENIED is now only a real permission
failure, VOICE_UNSUPPORTED_FORMAT covers every "this browser cannot record"
path, and both it and VOICE_CLIP_TOO_LONG are named on their DTO
constraints rather than falling through the shared map to an unrelated
message. §8 no longer claims there is no duration cap — there is, 2
minutes, decided before implementation.

§12 gains the checks these changes need, including the reload that catches
an unsaved lab case, and decisions 31-33 record the three rules the live
testing settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:53 +03:30
8f2d3f97ba fix: stop voice entry posting an unsaved detail id, and name failures right
Review findings on this branch.

The lab-case save could be posted against a detail the server has never
seen. persistDraft returns a *preview* treatment instead of saving when any
detail lacks a treatment type — the blank one the workspace opens with is
enough — and a preview's detail id falls back to the client id. Recording
straight after opening a visit and confirming a result with a lab or due
date would send that id and fail the whole save. It now checks what came
back rather than the precondition, so it holds for every early return
persistDraft has.

stop() optional-chained into a no-op when the recorder was already gone,
leaving the bar recording forever with a live timer and only Cancel as a
way out.

Three "this browser cannot record" paths reported VOICE_MIC_DENIED — no
MediaRecorder at all, no container the API accepts, and a recorder that
throws after permission was already granted. Telling clinicians their
microphone was denied sends them hunting for a permission nothing asked
for; they now report VOICE_UNSUPPORTED_FORMAT.

The voice route's large-body match stripped every trailing slash while
Express ignores exactly one, so '/api/voice/extract//' bought a 10 MB
buffer for a request that then 404s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:53 +03:30
bdf6da135b fix(frontend): stop the remembered prosthesis default rewriting the map
Dictating "12 روکش PFM, 13 روکش PFZ" previewed correctly and then landed in
the form as PFM on both teeth.

The stored data was never wrong — the dev database holds 12 → pfm_crown and
13 → pfz_crown with selectionGroupIds matching the detail's groups exactly,
and a page reload renders it correctly. The damage was live client state:
the dispatch panel's "last type used for this lab" default rebuilt the
*entire* map from one code, so a single row reading as unfilled destroyed
every type already set.

Two changes:

- It fills blanks now, and leaves every entry that already carries a type
  alone. The bulk "apply to all" select only pre-sets itself when the fill
  really did cover every tooth, instead of claiming one type while the rows
  below disagree.
- A lab case created by confirming a voice result is exempt from the
  default entirely. The review sheet is a contract: topping the case up
  with a type for a tooth the preview never showed makes the confirmation
  step a lie about what it was going to fill.

The exemption is tracked in workspace state rather than on LabCaseDraft
because a draft field is dropped by mapLabCaseDraftFromApi on the first
server round-trip — exactly the window this failure lives in.

isProsthesisMapComplete is deliberately untouched: its strict
selectionGroupId match succeeds on the real data, so loosening it would
have been a blind change to a working path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:53 +03:30
3fb7f02f43 docs: add CLAUDE.md for project guidance and conventions 2026-08-23 23:05:30 +03:30
f46ecbd771 docs: mark transcript salvage as specified but not built
The backend returns the transcript on VOICE_EXTRACT_FAILED and the client
never reads it, so dictation the clinic paid for is shipped in an error
body and dropped. The spec claimed the whole feature was implemented; it
now names the gap and the two ways out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
3d64414dd3 fix(backend): stop showing the clinician null, NaN and the wrong failure
Two ways a voice failure described itself wrongly.

describe() built the quoted-back text from fields that are all nullable on
the wire, and toVoiceIntent casts rather than checks — so a half-classified
deadline rendered as “null null” — not a usable date, and an offset with no
amount as “+NaN day”. Blank is already handled by the sheet; it now falls
back to that.

The DTO's constraints resolved to unrelated codes: maxLength fell through
to VALIDATION_FIELD_REQUIRED, so an oversized recording said a field was
missing, and isIn maps to VALIDATION_LANGUAGE_INVALID, so an unsupported
container said the language was invalid. Both now name their own code —
the validation factory already returns a message verbatim when it is itself
a known ErrorCode, so this needs no change to the shared mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
6a4c0cb1bb fix(frontend): stop the level meter re-rendering the whole workspace
The meter wrote React state from a requestAnimationFrame loop, and the hook
lives in TreatmentWorkspace — so every frame re-rendered the details editor,
the FDI chart, the lab panel and the history rail. About 7,200 whole-tree
renders across a two-minute recording, while the user is dictating.

Now samples every frame but publishes at LEVEL_POLL_MS, the rate the
elapsed timer already used. Peaks between publishes are carried forward, so
the meter stays responsive to transients rather than sampling at 10 Hz.

Also adds the catch the start path never had: new MediaRecorder() and
recorder.start() both throw on some browsers, and by then the stream is
live. The rejection went unhandled, the UI sat at 'idle' showing nothing,
and the browser's recording indicator stayed lit until unmount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
3911477e42 fix(frontend): persist the lab case a voice result creates
applyVoiceResult put the lab draft in state and stopped there. Every other
path that creates a LabCaseDraft — handleContinueToLab, handleLabCasesChange
— immediately runs persistDraft + persistLabCases, and the autosave effect
only watches `details`. So applying a voice result carrying a lab, a due
date and a prosthesis map, then reloading, kept the detail and silently
dropped all three: the surviving detail made it look like the save worked.

applyVoiceResult moves below persistDraft/persistLabCases so it can call
them, and writes detailsRef itself before persisting — persistDraft reads
that ref, and setDetails has not rendered by the time the save runs. The
ref is already written imperatively elsewhere for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
62deff0523 fix(frontend): untick prosthesis when a picked tooth breaks its map
initialVoiceSelection deliberately never auto-ticks an incomplete
prosthesis map, because a detail with an untyped tooth cannot ship — it
fails at dispatch instead. Picking a candidate tooth walked straight
through that rule: the tick was seeded once, so a map that was complete at
extraction stayed ticked after a tooth with no prosthesis type joined it,
and Apply attached a map assertCompleteToothProsthesisMap rejects.

Recomputed on each pick, and only ever downwards — re-ticking is the
clinician's call, not a side effect of un-picking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
b7ee61433e docs: record the FDI-first tooth rule in the voice spec
The spec's tooth section described the design that the first live test
disproved — descriptive phrasing primary, bare numerals refused in en.
Rewrites §6 around the rule the product actually wants, records the chip
affordance in §7, and closes open item §11.5: a two-digit number is FDI in
all three locales, with the Universal-numbering trade-off written down
rather than left implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
754efdee09 feat(frontend): let the clinician pick the tooth from the candidates
An under-specified tooth was a dead end: the sheet said what was missing
and the clinician had to leave and hunt for it on the chart. The readings
are enumerable, so the review sheet now renders them as chips — the one
interactive part of an otherwise read-only confirmation step.

A pick is folded into the result by withChosenTeeth() rather than tracked
alongside it, so the rows, the mini chart, the prosthesis warning and
applyVoiceResult all keep reading a single VoiceExtractionResult and none
of them has to know the chips exist. It unions rather than toggles: a
candidate can coincidentally be a tooth the recording already produced, and
tapping it must not deselect that one.

Two things that would otherwise make the chips look functional while
applying nothing: the teeth row is ticked on the first pick (it starts
unticked when the recording produced no teeth of its own), and the apply
count is now intersected with row availability so it cannot promise to
apply a row with nothing in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
82fad4ac02 feat(backend): offer the candidate teeth for an unspecified quadrant
A tooth the resolver refuses to guess at is currently a dead end: the sheet
says the quadrant was missing and the clinician has to leave and find the
tooth on the chart. But the readings are enumerable — "دو" is one of four
teeth, "دو بالا" one of two — so unresolved items now carry them.

Narrowed by whatever was actually said, so this stays a choice offered to
the clinician rather than a guess made for them. Only tooth_missing_quadrant
carries candidates; a wrong position or a deciduous tooth has nothing to
choose between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
52a7359b9a feat(backend): read a spoken tooth number as its FDI code
The prompt had this backwards. "Never output an FDI tooth code unless the
speaker used FDI notation. Prefer arch + side + position" pushed the model
to decompose speech into "upper / patient_right / six", so the clinician
effectively had to *describe* every tooth. Saying "دندون بیست و شش" — the
way a dentist actually dictates — was the unsupported path.

FDI is what clinicians speak, so the prompt now teaches the notation
instead of forbidding it: first digit = quadrant from the patient's own
point of view, second digit = position from the midline. arch/side/position
stays as the reading of a *described* tooth, where a single digit is a
position and the quadrant comes from words.

Two guards come with it, because bare numbers are now teeth: a single digit
alone still refuses to guess a quadrant, and dates, counts and quantities
are explicitly not teeth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
c09698aea2 fix(backend): read a tooth code whatever script its digits are in
The extraction model transcribes Persian speech, so it can hand back "۲۶"
in Persian digits or "2 6" from a digit-by-digit dictation. Both were
compared literally against /^[1-8][1-8]$/, missed, and fell through to the
positional branch with no quadrant — where the tooth was reported as "not
understood". The clinician loses a tooth and is told the words were the
problem.

normalizeFdiCode() now runs at both the branch choice and the final
validation, so the two cannot disagree. toLatinDigits moves out of
jalali.ts into common/digits.ts: it was exported but unused in production,
and a tooth module reaching into the calendar module would read as an
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
efff258910 fix(backend): apply the large-body limit to every spelling Express routes
req.path was compared to the canonical '/api/voice/extract' only, but
Express routes case-insensitively and ignores a trailing slash by default.
'/api/voice/extract/' therefore reached the controller with the 100 kb
parser, and 413'd every recording past ~20 seconds — a failure that reads
as a broken microphone rather than a routing detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
1be735a7ab fix(voice): say the quadrant is missing instead of "could not be read"
"ترمیم برای دندون دو" set the treatment type but reported the tooth as
unreadable. Nothing was misheard: position 2 arrived intact, with no
quadrant, because none was spoken — four teeth carry position 2 and the
resolver correctly refused to pick one. Only the label was wrong, and it
sent the clinician looking for a transcription fault.

Adds a tooth_missing_quadrant reason that names what is missing and shows
how to say it ("دو بالا راست"), and tells the model explicitly to report a
quadrant-less number with arch and side null rather than guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
afb30691cf fix(backend): restore the large-body limit on the voice route
POST /voice/extract returned 500 for any real recording. The threshold was
exactly 100 kb — Express's body-parser default — which is about 20 seconds of
audio, so the endpoint was unusable at its own 2-minute cap.

The scoped parser was registered as a path-mounted json() stacked in front of a
default one, which relied on two implicit behaviours: Express stripping the
mount path, and body-parser skipping a request another parser had already
handled. That coupling broke when the surrounding middleware order shifted, and
it broke silently — the parser was still registered, just no longer the one that
ran. Bisected by dumping the Express layer stack and confirming the raw error was
`entity.too.large` with `limit: 102400`.

Replaced with a single middleware that picks a parser by path. No mount-path
stripping, no dependence on parser ordering. Extracted to common/body-parsers.ts
so it is covered by a unit test rather than only reachable through main.ts, which
createTestingModule never executes.

The test is mutation-checked: forcing the default parser fails 2 of its 5 cases.
It also pins that the larger limit does not leak app-wide, and that a merely
similar path (/api/voice/extract/extra) does not get it.

Verified against the compiled server: 300 kb now reaches /api/voice/extract,
/api/auth/login still rejects it, and ordinary requests are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
e330572ad6 docs: mark the voice spec implemented
Implemented across 12 commits on feat/voice-treatment-entry. Still blocked on
the Persian ASR spike before it is trustworthy in front of patients: nothing in
the implementation compensates for a bad transcript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
3f97940a16 feat: wire voice entry into the treatment workspace
Makes the feature reachable end to end: availability is fetched alongside the
catalogs, the capture hook drives the segmented control, and confirming the
review sheet appends a new detail.

Confirm always appends — it never edits an existing detail and never calls
onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the
type row leaves the appointment-purpose default rather than a blank. Lab-side
rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new
unsaved detail can carry a lab, due date and per-tooth prosthesis map.

Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are
baked in at build time; a failure fetching it degrades to no microphone rather
than taking the treatment tab down.

From review of this commit:

- Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows
  for teeth the detail does not contain. Nothing downstream filters them —
  assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the
  reverse — so they would have reached task generation as lab work for teeth
  nobody is treating. The map is now filtered to the detail's own teeth.
- The microphone was gated on the URL locale while the server resolved
  everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a
  language toggle whose save failed), which would transcribe Persian with an
  English hint and anchor "next Thursday" to a Monday week instead of a Saturday
  one — or 403 from a visibly-enabled button. The client now sends the locale the
  microphone was offered in, so the gate and the request agree by construction.

Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day
early west of Greenwich (parsed as UTC midnight); the missing-teeth list
hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so
the common single-field case read "Apply 1 fields".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
c07f550e00 feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.

The two halves share a wrapper and nothing else. Add keeps its exact behaviour.

The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.

Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.

Fixes from review of this commit:

- mountedRef was set false on unmount and never re-armed, so under StrictMode
  the hook was permanently "unmounted" in dev and recording silently never
  started.
- onStart guarded only on `phase`, which does not change until getUserMedia
  resolves; a second click during the permission prompt orphaned the first
  MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
  Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
  a bare weekday carries no qualifier, and rejecting it discarded a real
  deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
  now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
  Safari's mp4/aac clips are not mislabelled.

Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
93c6513df6 feat(frontend): voice capture hook, API client and types
MediaRecorder handling and the API call live in lib/, not in ui/, so
TreatmentDetailsEditor can stay presentational and take only a `voice` prop.

Container choice is made at record time and needs no transcode: Chrome and
Android give webm/opus, Safari and iPad give mp4/aac, and the transcription
endpoint accepts both. Safari's `audio/mp4` is sent as `m4a`, the name the
vendor's container list actually uses, so iPad recordings do not fail while
Chrome works. Older Safari shipped MediaRecorder without isTypeSupported, so
that path lets the browser choose rather than refusing outright.

From review of this commit:

- The auto-stop at maxMs guaranteed a 413. The client measures the final length
  after the recorder has stopped, so a recording that runs to the cap always
  reports slightly over it, and the server rejected exactly the recording the
  auto-stop existed to save. The server now allows a documented 2s tolerance and
  the client keeps reporting the true length, so telemetry stays honest.
- getUserMedia is async, so a permission granted after unmount installed a live
  stream the cleanup effect had already run past — leaving the browser's
  recording indicator lit with nothing listening. Guarded with a mounted ref.
- Client-side failures are now ApiError-shaped ({code, statusCode}) rather than
  bare Errors, because getUserFacingError only resolves that shape; without it
  errors.VOICE_MIC_DENIED was dead in all three locales.

Cancelling aborts the request, which closes the connection and aborts the
metered vendor call server-side rather than letting it settle unseen. The level
meter is best-effort: a blocked AudioContext costs the meter, not the recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
db9d7d280a feat(backend): voice extraction endpoint
POST /voice/extract behind JwtAuthGuard + ClinicOrgGuard, plus
GET /voice/availability so the frontend can decide whether to render the
microphone — it cannot learn that from NEXT_PUBLIC_*, which are baked in at
build time.

Audio is held in memory for the request only: never written to disk, never a
Prisma row. The transcript goes back to the client and is not persisted. What
is logged is structured and patient-free — clip length, which fields resolved,
unresolved count, vendor cost, outcome — with log lines as the interim sink
until this repo has metrics infrastructure.

On extraction failure the transcript still travels back in the error details,
so the words the clinician already paid for can be salvaged into a note.

v1 ships ungated beyond a configured locale profile; the Plan.features design
is deferred, not dropped.

From review of this commit, four of which were load-bearing:

- Express's 100 kb default body limit rejected any recording past ~20 seconds,
  making the endpoint unusable at its own 2-minute cap. Body parsers are now
  registered explicitly with a 10 MB limit scoped to the voice route only.
  Verified empirically: 600 KB reaches /api/voice/extract, while /api/auth/login
  still 413s.
- ThrottlerGuard keys on req.ip, so behind nginx the whole deployment would
  share one bucket and an abuser rotating IPs would bypass it. VoiceThrottlerGuard
  keys on the user id instead — with no plan gate, this is the only control on
  metered vendor spend.
- ThrottlerException had no 429 fallback and surfaced as INTERNAL_ERROR; the
  guard now throws VOICE_RATE_LIMITED directly.
- durationMs was optional, so omitting it bypassed VOICE_MAX_RECORDING_MS
  entirely. It is required.
- VOICE_UNSUPPORTED_FORMAT was dead code — the DTO's @IsIn already rejects
  unknown containers — so it is gone rather than left unreachable.

ThrottlerModule is deliberately not bound as a global APP_GUARD: a global
ThrottlerGuard rate-limits every route against every named throttler, which
would have capped the whole API at the voice limit.

All seven remaining VOICE_* codes have errors.* keys in en, fa and nl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
336fc76035 feat(backend): OpenRouter voice providers and per-locale registry
ASR and extraction are separate, independently swappable roles resolved per
locale from config. All three locales point at the same OpenRouter models today
(whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the
weakest link and repointing only `fa` must not be a code change.

The model emits a deliberately flat wire shape rather than the internal
discriminated unions — strict json_schema mode has poor union support — and
toVoiceIntent narrows it. That normalizer is total: a missing or malformed
payload yields a shape the resolvers report as unresolved rather than one that
throws.

The prompt supplies catalog codes with labels in the actor's locale, so the
model matches spoken words rather than translating, and carries per-locale
tooth vocabulary. English gets an explicit warning that a bare two-digit number
is ambiguous under Universal numbering, and must not be treated as FDI unless
the speaker said so.

From review of this commit:
- only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside
  valid arch/side/position used to lose the tooth entirely
- an unrecognised due kind passes through to be flagged, instead of collapsing
  to null and looking like no deadline was ever spoken
- vendor error bodies stay out of the thrown message and the default log level;
  a 4xx can echo the request back, transcript included
- the chat call sets provider.require_parameters so OpenRouter only routes to
  endpoints that honour the JSON schema, rather than ones treating it as a hint
- an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown
  provider id, instead of silently disabling the microphone everywhere

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
ae7009534e feat(backend): assemble resolved extraction from voice intents
Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.

Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.

Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.

Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.

Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
  warning on a plain restoration
- an override naming a tooth outside the selection now reports
  tooth_not_selected rather than malformed; the clinician was understood, the
  tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
  "heard but lost" row; an unrecognised kind is still flagged, and named

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
a1a999a884 fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.

"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.

The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.

- a non-object `due` (the model emitting a bare string) was treated as "no
  deadline spoken" and silently discarded; only null/undefined mean absent now,
  anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
  table, contradicting the module's own "degrade to null" contract; they now
  return false / 0, which also makes isValidJalaliDate's day check naturally
  false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
  RangeError before any fallback; it now validates and backstops to UTC, so a
  bad zone costs at most a day rather than a 500

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
70092e932d feat(backend): resolve spoken deadlines to ISO dates
Jalali conversion is arithmetic here, not inference. A model asked to turn
"۲۵ مهر" into ISO answers confidently and is often wrong, and @IsDateString()
accepts the wrong answer — so the model emits a date intent and this decides
what it means.

Deviation from the spec, deliberately: the resolver takes todayIso rather than
an IANA zone. Working in civil dates means nothing here reasons about instants.
The zone is used one level up, where civilDateInZone() derives "today" from the
actor's zone server-side — better than the spec's client-supplied date, which
the client could set arbitrarily.

Conventions pinned by tests:
- "this <weekday>" is the soonest occurrence strictly after today, so "by
  Thursday" said on a Thursday means the next one; a deadline of today is
  almost never what was meant. "next" adds a further week.
- month offsets clamp to the end of shorter months (31 Jan + 1 = 28/29 Feb)
- a resolved date in the past, or more than five years out, is treated as
  unresolved however it was arrived at — an absolute date the model invented
  can land anywhere
- no due date at all is not an error; an unparseable one is, and echoes what
  was heard so the review sheet can show it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
1076c13472 feat(backend): voice intent contract and tooth-intent resolver
The extraction model emits intents, never resolved values — no FDI codes, no
ISO dates. This adds the contract it must satisfy and the resolver that turns
spoken tooth references into FDI, so quadrant mirroring is a unit test rather
than a hope.

resolveToothIntent never guesses and never clamps: position 9, a deciduous
tooth, or a malformed shape resolve to null and are reported as unresolved with
the transcript span that produced them, so the review sheet can show the
clinician exactly which words were not understood.

Everything here parses untrusted model output, so nothing may throw:

- a non-array where a list was expected degrades like any other malformed shape
- explicit codes are trimmed, for parity with normalizeTeeth
- '51' reports as not_permanent_tooth (a real primary tooth the chart cannot
  show) while '99' reports as malformed — the clinician should not be told a
  deciduous tooth was heard when nothing tooth-shaped was
- unresolved items only dedupe when they carry a spoken span; without one,
  collapsing them would hide a lost tooth behind a single blank review row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
4b4c197c03 feat(backend): extract shared FDI tooth geometry
Voice extraction needs quadrant mapping and adjacency server-side, and
treatment.utils.ts already held a private copy of the tooth set. Lift it into
common/fdi.ts rather than create a second source of truth; treatment.utils now
imports it, behaviour unchanged (existing suites still pass).

toFdi() is the single place the patient-right convention lives: quadrant 1 is
the patient's upper right, so upper+patient_right -> 1x, upper+patient_left ->
2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards
mirrors every quadrant and yields a valid-looking code for the wrong tooth,
which no schema check can catch — so all four quadrants are pinned by tests,
along with out-of-range positions never being clamped and deciduous teeth being
rejected outright (the chart is permanent dentition only).

Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21
and 41-31 count as neighbours exactly as the chart treats them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
80ffa37edd feat(backend): port Jalali calendar arithmetic with tests
Voice extraction resolves spoken Jalali dates into ISO dates server-side, so
the backend needs the conversion the frontend already had. The resolvers live
here rather than in the frontend precisely because this half of the repo has a
test runner.

Ported from frontend/src/lib/i18n/persianCalendar.ts and verified faithful by
differential test: every day from 1900-2100 (73,414 days), zero mismatches on
conversion, leap years and month lengths.

Two deliberate divergences from the original:

- jalaliToIsoDate() returns null instead of throwing. It is fed model-supplied
  values, which may be nonsense, and an invalid date must degrade to
  "unresolved" rather than a 500. The year guard runs before jalaliDaysInMonth
  so the throwing jalCal is unreachable from it.
- toLatinDigits() also handles the Arabic-Indic block (U+0660-U+0669), not just
  Persian (U+06F0-U+06F9). ASR output can carry either, sometimes mixed with
  ASCII in one transcript; the frontend version only parses keystrokes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
648e8ed1f2 docs: spec for voice-driven treatment detail entry
Design spec for filling a TreatmentDetail by voice, settled across three
grilling sessions (30 decisions, logged in the spec).

Key shape:
- two-stage pipeline: OpenRouter whisper-1 -> gemini-3.7-flash
- the LLM emits *intents*, never FDI codes or ISO dates; pure Jest-tested
  backend resolvers own quadrant mapping and Jalali conversion
- provider registry keyed by locale so fa can diverge from en/nl
- review sheet confirms before anything touches the form
- audio and transcripts are never persisted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
b4832cd663 Merge pull request 'bugfix/treatment-bugs' (#66) from bugfix/treatment-bugs into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 2s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/66
Reviewed-by: aminmsvi <amn.mousavi@gmail.com>
2026-08-23 12:44:44 +03:30
8d34def001 improvement: the last flow improved a little. 2026-08-22 20:33:11 +03:30
7f02a84ebf improvement: a flow added to create treatment plan for searched patients with no treatment history. 2026-08-22 20:25:11 +03:30
b1405b22b1 improvement: patient search moved to the top of treatment feature. 2026-08-22 20:11:43 +03:30
50725b7b3f bugfix: no-detail treatment ui made some confusion. it is improved. at least to a degree. 2026-08-22 19:36:37 +03:30
f52ad6b84a bugfix: Treatment detail auto-fill bug fixed. 2026-08-22 18:51:57 +03:30
de46da216f bugfix: FDI tooth chart is now compatible with RTL direction. 2026-08-22 17:49:38 +03:30
17 changed files with 357 additions and 374 deletions

View File

@@ -10,16 +10,17 @@ alwaysApply: false
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**. - **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments. - **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change. - **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`. - **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first details type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **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 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. - **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). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields. - **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). Opens todays strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **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. - **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 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`. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment. - **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment.
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`). - **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. - **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only. - **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
Full map: `.cursor/skills/treatment-workspace/SKILL.md` Full map: `.cursor/skills/treatment-workspace/SKILL.md`

View File

@@ -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 once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Until typed they keep chip theming. 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. 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 details type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards 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) 2. **Treatment preview**`TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)
@@ -41,11 +41,11 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| Stage | UI | When | | Stage | UI | When |
|-------|-----|------| |-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always | | **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. | | **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments. - Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch. - Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose (purpose only defaults new details). - Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). - Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only. - Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
@@ -120,7 +120,7 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment. Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`). - Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`. - `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth). - Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
@@ -155,7 +155,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. `LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new details lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
@@ -201,7 +201,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) 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 with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.

View File

@@ -44,8 +44,8 @@ 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 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):** **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 appointments 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). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **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 cards 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 once a type is selected (live draft for the open card). 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`). - **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first details 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`). - 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. - **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.
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. - **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
@@ -53,8 +53,9 @@ frontend/src/
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator). - **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
**Treatment lab rules (quick ref):** **Treatment lab rules (quick ref):**
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add.
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details. - New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet. - **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet.
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).

View File

@@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl
const treatmentInclude = { const treatmentInclude = {
patient: { patient: {
select: { id: true, firstName: true, lastName: true, isWalkIn: true }, select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true },
}, },
details: { details: {
orderBy: [{ sortOrder: 'asc' as const }], orderBy: [{ sortOrder: 'asc' as const }],
@@ -1366,6 +1366,8 @@ export class TreatmentsService {
firstName: string; firstName: string;
lastName: string; lastName: string;
isWalkIn: boolean; isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
}; };
details: Array<{ details: Array<{
id: string; id: string;
@@ -1426,6 +1428,8 @@ export class TreatmentsService {
firstName: treatment.patient.firstName, firstName: treatment.patient.firstName,
lastName: treatment.patient.lastName, lastName: treatment.patient.lastName,
isWalkIn: treatment.patient.isWalkIn, isWalkIn: treatment.patient.isWalkIn,
mobile: treatment.patient.mobile ?? null,
email: treatment.patient.email ?? null,
} }
: null, : null,
details: treatment.details.map((d) => this.mapDetail(d)), details: treatment.details.map((d) => this.mapDetail(d)),

View File

@@ -498,12 +498,17 @@ that justified this whole design.
- any row carrying an unresolved item or an incomplete prosthesis map. - any row carrying an unresolved item or an incomplete prosthesis map.
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the - Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
clinician can see what the system did not understand. clinician can see what the system did not understand.
- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Per-detail - **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any
conveniences that would top the case up afterwards are suppressed for a voice-created per-detail convenience that would top the case up afterwards has to be suppressed for a
case; concretely, the dispatch panel's remembered-prosthesis default voice-created case, because a default that quietly adds a prosthesis type to a tooth the
(`previewConfirmedCaseIds`). A default that quietly adds a prosthesis type to a tooth the
sheet never mentioned turns the confirmation step into a lie about what it was going to sheet never mentioned turns the confirmation step into a lie about what it was going to
do, which is the whole reason the step exists. do which is the whole reason the step exists.
> This branch carried an exemption for one such default, the dispatch panel's
> remembered-prosthesis auto-fill. `origin/master` deleted that feature outright
> (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule
> in code today. It is a constraint on whatever gets added next, not a description of
> something that exists.
- An item that carries `candidates` renders them as **tappable chips** — the one place the - An item that carries `candidates` renders them as **tappable chips** — the one place the
sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and
ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a
@@ -741,10 +746,8 @@ enabling this for real clinics.
confirm with a lab ticked → no error toast: confirm detects the preview treatment and confirm with a lab ticked → no error toast: confirm detects the preview treatment and
skips the lab-case save rather than posting an id the server has never seen; skips the lab-case save rather than posting an id the server has never seen;
- **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows - **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows
both, and the dispatch panel's remembered "last type for this lab" does **not** overwrite both, and the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder. Nothing may
either. Also check the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder; rewrite a per-tooth type the sheet already showed.
- **a hand-made prosthesis detail with an empty map** → still gets the remembered default
pre-filled. The exemption is for previewed cases only, not a removal of the convenience.
--- ---

View File

@@ -683,7 +683,9 @@
"selectedPatient": "Selected patient", "selectedPatient": "Selected patient",
"purposeLabel": "Purpose:", "purposeLabel": "Purpose:",
"loadingAppointments": "Loading appointments…", "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.",
"noTreatmentFoundTitle": "No treatment found",
"noTreatmentFoundBody": "There is no treatment or visit for {name} yet. You can add one with the {action} button in the rail.",
"confirmDiscard": "You have unsaved changes. Discard them and continue?", "confirmDiscard": "You have unsaved changes. Discard them and continue?",
"errorChooseOrg": "Choose at least one active organization to send this case.", "errorChooseOrg": "Choose at least one active organization to send this case.",
"successCaseSent": "Case sent to selected organizations.", "successCaseSent": "Case sent to selected organizations.",
@@ -707,6 +709,8 @@
"walkIn": "Walk-in", "walkIn": "Walk-in",
"newTreatment": "New treatment", "newTreatment": "New treatment",
"newTreatmentPatientPrompt": "Who is this visit for?", "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.", "walkInPickerHint": "No named patient — always available, no search needed.",
"errorCreateTreatment": "Could not create treatment.", "errorCreateTreatment": "Could not create treatment.",
"deleteEmptyTreatment": "Delete empty treatment", "deleteEmptyTreatment": "Delete empty treatment",
@@ -717,6 +721,7 @@
"detailsTitle": "Treatment details", "detailsTitle": "Treatment details",
"addDetail": "Add detail", "addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?", "confirmRemoveDetail": "Remove this treatment detail?",
"confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?",
"removeDetailAria": "Remove detail {n}", "removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}", "detailLabel": "Detail {n}",
"detailSentBadge": "sent", "detailSentBadge": "sent",

View File

@@ -684,7 +684,9 @@
"selectedPatient": "بیمار انتخاب شده", "selectedPatient": "بیمار انتخاب شده",
"purposeLabel": "هدف:", "purposeLabel": "هدف:",
"loadingAppointments": "در حال بارگذاری نوبت‌ها...", "loadingAppointments": "در حال بارگذاری نوبت‌ها...",
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", "selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.",
"noTreatmentFoundTitle": "درمانی یافت نشد",
"noTreatmentFoundBody": "هنوز درمان یا ویزیتی برای {name} ثبت نشده است. می‌توانید با دکمه {action} در نوار کناری یکی اضافه کنید.",
"confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
"successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.",
@@ -708,6 +710,8 @@
"walkIn": "بدون نوبت (مراجع)", "walkIn": "بدون نوبت (مراجع)",
"newTreatment": "درمان جدید", "newTreatment": "درمان جدید",
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟", "newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
"newTreatmentUseCurrent": "استفاده از {name}",
"newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.",
"walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.", "walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
"errorCreateTreatment": "ایجاد درمان ممکن نشد.", "errorCreateTreatment": "ایجاد درمان ممکن نشد.",
"deleteEmptyTreatment": "حذف درمان خالی", "deleteEmptyTreatment": "حذف درمان خالی",
@@ -718,6 +722,7 @@
"detailsTitle": "جزئیات درمان", "detailsTitle": "جزئیات درمان",
"addDetail": "افزودن جزئیات", "addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟",
"removeDetailAria": "حذف جزئیات {n}", "removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}", "detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده", "detailSentBadge": "ارسال‌شده",

View File

@@ -683,7 +683,9 @@
"selectedPatient": "Geselecteerde patiënt", "selectedPatient": "Geselecteerde patiënt",
"purposeLabel": "Doel:", "purposeLabel": "Doel:",
"loadingAppointments": "Afspraken laden...", "loadingAppointments": "Afspraken laden...",
"selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.", "selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.",
"noTreatmentFoundTitle": "Geen behandeling gevonden",
"noTreatmentFoundBody": "Er is nog geen behandeling of bezoek voor {name}. U kunt er een toevoegen met de knop {action} in de zijbalk.",
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?", "confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.", "errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
"successCaseSent": "Case verzonden naar geselecteerde organisaties.", "successCaseSent": "Case verzonden naar geselecteerde organisaties.",
@@ -707,6 +709,8 @@
"walkIn": "Inloop", "walkIn": "Inloop",
"newTreatment": "Nieuwe behandeling", "newTreatment": "Nieuwe behandeling",
"newTreatmentPatientPrompt": "Voor wie is dit bezoek?", "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.", "walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.",
"errorCreateTreatment": "Behandeling aanmaken is mislukt.", "errorCreateTreatment": "Behandeling aanmaken is mislukt.",
"deleteEmptyTreatment": "Lege behandeling verwijderen", "deleteEmptyTreatment": "Lege behandeling verwijderen",
@@ -717,6 +721,7 @@
"detailsTitle": "Behandeldetails", "detailsTitle": "Behandeldetails",
"addDetail": "Detail toevoegen", "addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?",
"removeDetailAria": "Detail {n} verwijderen", "removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}", "detailLabel": "Detail {n}",
"detailSentBadge": "verzonden", "detailSentBadge": "verzonden",

View File

@@ -12,3 +12,10 @@ export type DayStripItem = {
subtitle: string; subtitle: string;
canDelete?: boolean; canDelete?: boolean;
}; };
/** Unscheduled card banner: first lines type only. Empty / missing type → no color. */
export function unscheduledStripColorCode(
details: readonly { treatmentType?: string | null }[],
): string {
return details[0]?.treatmentType?.trim() ?? '';
}

View File

@@ -1,73 +1,60 @@
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.'; const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
export type LabDispatchDefaults = { /** Chips under lab search — last destinations this clinic actually sent a case to. */
lastLabId: string | null; export const MAX_RECENT_LABS = 3;
lastProsthesisByLab: Record<string, string>;
};
const EMPTY: LabDispatchDefaults = {
lastLabId: null,
lastProsthesisByLab: {},
};
function storageKey(clinicOrganizationId: string): string { function storageKey(clinicOrganizationId: string): string {
return `${STORAGE_PREFIX}${clinicOrganizationId}`; return `${STORAGE_PREFIX}${clinicOrganizationId}`;
} }
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults { function uniqueIds(ids: string[]): string[] {
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY; const out: string[] = [];
for (const id of ids) {
if (id && !out.includes(id)) out.push(id);
}
return out;
}
export function loadRecentLabIds(clinicOrganizationId: string | null | undefined): string[] {
if (!clinicOrganizationId || typeof window === 'undefined') return [];
try { try {
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId)); const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
if (!raw) return EMPTY; if (!raw) return [];
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>; const parsed = JSON.parse(raw) as {
return { recentLabIds?: unknown;
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null, lastLabId?: unknown;
lastProsthesisByLab:
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
? parsed.lastProsthesisByLab
: {},
}; };
const fromList = Array.isArray(parsed.recentLabIds)
? parsed.recentLabIds.filter((id): id is string => typeof id === 'string')
: [];
// Older builds stored a single lastLabId used as an auto-selected default.
const fromLegacy = typeof parsed.lastLabId === 'string' ? [parsed.lastLabId] : [];
return uniqueIds([...fromList, ...fromLegacy]).slice(0, MAX_RECENT_LABS);
} catch { } catch {
return EMPTY; return [];
} }
} }
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void { function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next)); window.localStorage.setItem(
storageKey(clinicOrganizationId),
JSON.stringify({ recentLabIds }),
);
} catch { } catch {
// Ignore quota / private-mode failures. // Ignore quota / private-mode failures.
} }
} }
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void { /** Record a lab after a successful send — suggestion chips only, never a form default. */
if (!clinicOrganizationId || !labId) return; export function rememberRecentLab(
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
}
export function rememberLastProsthesisType(
clinicOrganizationId: string | null | undefined, clinicOrganizationId: string | null | undefined,
labId: string, labId: string,
prosthesisTypeCode: string,
): void { ): void {
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return; if (!clinicOrganizationId || !labId) return;
const current = loadLabDispatchDefaults(clinicOrganizationId); writeRecentLabIds(
writeDefaults(clinicOrganizationId, { clinicOrganizationId,
...current, uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS),
lastLabId: labId, );
lastProsthesisByLab: {
...current.lastProsthesisByLab,
[labId]: prosthesisTypeCode,
},
});
}
export function lastProsthesisTypeForLab(
clinicOrganizationId: string | null | undefined,
labId: string | null | undefined,
): string | null {
if (!clinicOrganizationId || !labId) return null;
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
} }

View File

@@ -24,6 +24,12 @@ const TOOTH_NUMBER_GAP = 'mt-1';
const REALISTIC_NUMBER_GAP = '2mm'; const REALISTIC_NUMBER_GAP = '2mm';
/** Tight interproximal gap between tooth columns. */ /** Tight interproximal gap between tooth columns. */
const TOOTH_GAP = 'gap-x-px'; const TOOTH_GAP = 'gap-x-px';
/**
* Centers a mark on the inline-end edge of a tooth column (between this tooth
* and the next in flex order). Logical `end` + 0-width flex stays correct in LTR and RTL.
*/
const EDGE_MARK_ANCHOR =
'absolute inset-y-0 end-0 z-10 w-0 flex items-center justify-center';
function quadrantMirrored(fdi: FdiToothId): boolean { function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0]; const q = fdi[0];
@@ -285,40 +291,42 @@ export function FdiToothChart({
/> />
) : null} ) : null}
{renderEdge ? ( {renderEdge ? (
linkInteractive ? ( <div className={EDGE_MARK_ANCHOR}>
<button {linkInteractive ? (
type="button" <button
disabled={isDisabled} type="button"
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')} disabled={isDisabled}
aria-label={ title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
linked aria-label={
? t('toothUnlinkAria', { a: fdi, b: next! })
: t('toothLinkAria', { a: fdi, b: next! })
}
aria-pressed={linked}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLink?.(fdi, next!);
}}
className={`
absolute right-0 z-10 translate-x-1/2 h-3.5 w-3.5 rounded-full border-2 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${
linked linked
? 'border-primary bg-primary shadow-sm' ? t('toothUnlinkAria', { a: fdi, b: next! })
: 'border-primary bg-background-secondary hover:bg-primary/15' : t('toothLinkAria', { a: fdi, b: next! })
} }
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} aria-pressed={linked}
`} onClick={(e) => {
/> e.preventDefault();
) : ( e.stopPropagation();
<span onToggleLink?.(fdi, next!);
className="absolute right-0 z-10 translate-x-1/2 h-2.5 w-2.5 rounded-full bg-primary shadow-sm" }}
title={t('toothConnectedHint')} className={`
aria-hidden h-3.5 w-3.5 shrink-0 rounded-full border-2 transition-colors
/> focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
) ${
linked
? 'border-primary bg-primary shadow-sm'
: 'border-primary bg-background-secondary hover:bg-primary/15'
}
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary shadow-sm"
title={t('toothConnectedHint')}
aria-hidden
/>
)}
</div>
) : null} ) : null}
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,40 +16,24 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments'; import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment'; import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity'; import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
import { getUserFacingError } from '@/components/shared/formatApiError'; import { getUserFacingError } from '@/components/shared/formatApiError';
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups'; import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
/** Stable empty default — a fresh Set literal would re-fire the auto-fill effect. */
const EMPTY_CASE_IDS: ReadonlySet<string> = new Set<string>();
interface LabCasesDispatchPanelProps { interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[]; details: TreatmentDetailDraft[];
activeDetailId: string; activeDetailId: string;
labCases: LabCaseDraft[]; labCases: LabCaseDraft[];
labDependentCodes: Set<string>; labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[]; treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null; labCaseSummary?: PatientLabCaseSummary | null;
locale: string; locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void; onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
onLabCaseMarkedRead?: (labCaseId: string) => void; onLabCaseMarkedRead?: (labCaseId: string) => void;
onLabCaseActivityChange?: () => void; onLabCaseActivityChange?: () => void;
activeLabCaseId: string | null; activeLabCaseId: string | null;
/**
* Lab cases whose contents the clinician already confirmed in a preview (voice entry).
* The remembered-prosthesis default is suppressed for these: the preview is a contract,
* and filling teeth it never showed would break it.
*/
previewConfirmedCaseIds?: ReadonlySet<string>;
onLabCasesChange: (labCases: LabCaseDraft[]) => void; onLabCasesChange: (labCases: LabCaseDraft[]) => void;
disabled: boolean; disabled: boolean;
canEdit: boolean; canEdit: boolean;
@@ -57,7 +41,6 @@ interface LabCasesDispatchPanelProps {
organizationSearch: string; organizationSearch: string;
onOrganizationSearchChange: (value: string) => void; onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[]; recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
canInviteLab?: boolean; canInviteLab?: boolean;
onInviteLab?: () => void; onInviteLab?: () => void;
sendBusyId: string | null; sendBusyId: string | null;
@@ -113,11 +96,6 @@ function isProsthesisMapComplete(
); );
} }
/** Identity of one prosthesis entry: a tooth on a detail. */
function toothKey(detailClientId: string, tooth: string): string {
return `${detailClientId}::${tooth}`;
}
function toothProsthesisForRows( function toothProsthesisForRows(
rows: ProsthesisGroupRow[], rows: ProsthesisGroupRow[],
prosthesisTypeCode: string, prosthesisTypeCode: string,
@@ -138,14 +116,12 @@ export function LabCasesDispatchPanel({
labCases, labCases,
labDependentCodes, labDependentCodes,
treatmentCatalog, treatmentCatalog,
clinicOrganizationId,
labCaseSummary, labCaseSummary,
locale, locale,
onLabCaseSummaryChange, onLabCaseSummaryChange,
onLabCaseMarkedRead, onLabCaseMarkedRead,
onLabCaseActivityChange, onLabCaseActivityChange,
activeLabCaseId, activeLabCaseId,
previewConfirmedCaseIds = EMPTY_CASE_IDS,
onLabCasesChange, onLabCasesChange,
disabled, disabled,
canEdit, canEdit,
@@ -153,7 +129,6 @@ export function LabCasesDispatchPanel({
organizationSearch, organizationSearch,
onOrganizationSearchChange, onOrganizationSearchChange,
recentOrganizationIds, recentOrganizationIds,
onRecentOrganizationPick,
canInviteLab = false, canInviteLab = false,
onInviteLab, onInviteLab,
sendBusyId, sendBusyId,
@@ -165,7 +140,6 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]); const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState(''); const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId); const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active); const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -183,7 +157,14 @@ export function LabCasesDispatchPanel({
const activeLabCase = const activeLabCase =
labCaseForActiveDetail ?? labCaseForActiveDetail ??
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null); (activeLabCaseId
? labCases.find(
(lc) =>
lc.clientId === activeLabCaseId &&
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
)
: null) ??
null;
const sent = Boolean(activeLabCase?.sentAt); const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1; const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -221,22 +202,9 @@ export function LabCasesDispatchPanel({
}; };
}, [activeLabCase?.destinationOrganizationId]); }, [activeLabCase?.destinationOrganizationId]);
useEffect(() => {
// No code survives that the destination lab does not offer. Guarded on a non-empty
// catalogue because the fetch above falls back to [] on failure, and a failed request
// must not erase the clinician's work.
if (!activeLabCase || sent || prosthesisOptions.length === 0) return;
const offered = new Set(prosthesisOptions.map((opt) => opt.code));
const kept = activeLabCase.toothProsthesis.filter((tp) =>
offered.has(tp.prosthesisTypeCode),
);
if (kept.length === activeLabCase.toothProsthesis.length) return;
updateActiveLabCase({ toothProsthesis: kept });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prosthesisOptions, activeLabCase?.clientId, sent]);
useEffect(() => { useEffect(() => {
setPendingComment(''); setPendingComment('');
setApplyAllProsthesis('');
}, [activeLabCase?.clientId]); }, [activeLabCase?.clientId]);
function updateActiveLabCase(patch: Partial<LabCaseDraft>) { function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -255,76 +223,6 @@ export function LabCasesDispatchPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files // eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]); }, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
// Must NOT clear toothProsthesis: this runs only when no lab is set, so no other lab's
// catalogue could have supplied a code — the map is unvalidated, not foreign, and
// clearing here discards it before it is ever rendered. The effect above validates it
// once the catalogue loads; a deliberate lab *switch* still clears.
updateActiveLabCase({ destinationOrganizationId: lastLab.id });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
// Confirmed in a preview, so it is left exactly as previewed: adding a default to a
// tooth the preview never showed makes the confirmation a lie about what it fills.
if (previewConfirmedCaseIds.has(activeLabCase.clientId)) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
// Blanks only. Rebuilding the whole map from `lastCode` lets one unfilled row destroy
// every type already set.
const typed = new Set(
activeLabCase.toothProsthesis
.filter((tp) => tp.prosthesisTypeCode)
.map((tp) => toothKey(tp.detailClientId, tp.tooth)),
);
const blanks = toothProsthesisForRows(prosthesisRows, lastCode).filter(
(entry) => !typed.has(toothKey(entry.detailClientId, entry.tooth)),
);
if (blanks.length === 0) {
autoFilledCaseRef.current = fillKey;
return;
}
autoFilledCaseRef.current = fillKey;
// Only claim "all teeth" when the fill really did cover all of them; otherwise the
// bulk select shows one type while the rows below disagree with it.
if (blanks.length === flatToothCount) setApplyAllProsthesis(lastCode);
updateActiveLabCase({
toothProsthesis: [
...activeLabCase.toothProsthesis.filter((tp) => tp.prosthesisTypeCode),
...blanks,
],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
previewConfirmedCaseIds,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) { if (!activeDetail || !isLabDependentDetail) {
return null; return null;
} }
@@ -347,25 +245,11 @@ export function LabCasesDispatchPanel({
] ]
: rest; : rest;
updateActiveLabCase({ toothProsthesis: next }); updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
} }
function applyProsthesisToAll(code: string) { function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return; if (!activeLabCase || !code) return;
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) }); updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
} }
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) { function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -377,13 +261,11 @@ export function LabCasesDispatchPanel({
} }
function handleSelectOrganization(org: LinkedOrganizationOption) { function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({ updateActiveLabCase({
destinationOrganizationId: org.id, destinationOrganizationId: org.id,
toothProsthesis: [], toothProsthesis: [],
}); });
setApplyAllProsthesis(''); setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
} }
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress); const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
@@ -523,10 +405,7 @@ export function LabCasesDispatchPanel({
key={o.id} key={o.id}
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => handleSelectOrganization(o)}
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50" className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
> >
{o.name} {o.name}
@@ -675,26 +554,7 @@ export function LabCasesDispatchPanel({
!prosthesisComplete !prosthesisComplete
} }
isLoading={sendBusyId === activeLabCase.clientId} isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => { onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
> >
{t('sendToLab')} {t('sendToLab')}
</Button> </Button>

View File

@@ -1,20 +0,0 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function LabShipmentBlockedNotice() {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
</div>
</div>
</div>
);
}

View File

@@ -4,19 +4,45 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
import { formatMobileForDisplay } from '@/lib/phone';
import type { Patient } from '@/types/patient'; 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 { interface NewTreatmentPatientPickerProps {
creating?: boolean; creating?: boolean;
currentPatient?: {
id: string;
displayName: string;
mobile?: string | null;
email?: string | null;
} | null;
onSelectWalkIn: () => void | Promise<void>; onSelectWalkIn: () => void | Promise<void>;
onSelectPatient: (patient: Patient) => void | Promise<void>; onSelectPatient: (patient: Patient) => void | Promise<void>;
onSelectCurrentPatient?: () => void | Promise<void>;
onCancel: () => void; 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({ export function NewTreatmentPatientPicker({
creating = false, creating = false,
currentPatient = null,
onSelectWalkIn, onSelectWalkIn,
onSelectPatient, onSelectPatient,
onSelectCurrentPatient,
onCancel, onCancel,
}: NewTreatmentPatientPickerProps) { }: NewTreatmentPatientPickerProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
@@ -31,11 +57,25 @@ export function NewTreatmentPatientPicker({
type="button" type="button"
disabled={creating} disabled={creating}
onClick={() => void onSelectWalkIn()} 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}
> >
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p> <p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p> <p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
</button> </button>
{currentPatient && onSelectCurrentPatient ? (
<button
type="button"
disabled={creating}
onClick={() => void onSelectCurrentPatient()}
aria-label={t('newTreatmentUseCurrent', { name: currentPatient.displayName })}
className={pickerChoiceClass}
>
<p className="text-sm font-medium text-text-primary break-words">{currentPatient.displayName}</p>
<p className="text-xs text-text-muted mt-0.5 break-words">
{contactLine(currentPatient, t('newTreatmentUseCurrentHint'))}
</p>
</button>
) : null}
<PatientSearchCombobox <PatientSearchCombobox
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}

View File

@@ -225,12 +225,12 @@ export function TreatmentDetailsEditor({
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null} {stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{activeDetail && showMissingTeethLabBlock ? (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
) : null}
{showFields && activeDetail ? ( {showFields && activeDetail ? (
<div className="space-y-3"> <div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<Dropdown <Dropdown
label={t('treatmentType')} label={t('treatmentType')}
@@ -290,6 +290,8 @@ export function TreatmentDetailsEditor({
{footer ? <div className="pt-1">{footer}</div> : null} {footer ? <div className="pt-1">{footer}</div> : null}
</div> </div>
) : showFields && !activeDetail ? (
<p className="text-sm text-text-muted">{t('noDetails')}</p>
) : null} ) : null}
</div> </div>
); );

View File

@@ -15,7 +15,6 @@ import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker'; import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel'; import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel'; import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
@@ -41,7 +40,7 @@ import type {
import { treatmentsApi } from '@/lib/api/treatments'; import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications'; import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import type { DayStripItem } from '@/components/treatment/dayStrip'; import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip';
import { import {
areDetailsPersistable, areDetailsPersistable,
defaultTreatmentTypeForAppointment, defaultTreatmentTypeForAppointment,
@@ -65,7 +64,11 @@ import {
} from '@/components/treatment/toothSelectionGroups'; } from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults'; import {
loadRecentLabIds,
MAX_RECENT_LABS,
rememberRecentLab,
} from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions'; import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain'; import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts'; import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
@@ -211,6 +214,7 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
patientId: record.patientId, patientId: record.patientId,
patientFirstName: record.patient.firstName, patientFirstName: record.patient.firstName,
patientLastName: record.patient.lastName, patientLastName: record.patient.lastName,
patientMobile: record.patient.mobile,
providerUserId: record.providerUserId, providerUserId: record.providerUserId,
startAt: record.startAt, startAt: record.startAt,
endAt: record.endAt, endAt: record.endAt,
@@ -391,10 +395,12 @@ export function TreatmentWorkspace({
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false); const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient'); const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null); const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
const [searchedPatient, setSearchedPatient] = useState<Pick< const [searchedPatient, setSearchedPatient] = useState<
Patient, (Pick<Patient, 'id' | 'firstName' | 'lastName'> & {
'id' | 'firstName' | 'lastName' mobile?: string | null;
> | null>(null); email?: string | null;
}) | null
>(null);
const [patientSearchBusy, setPatientSearchBusy] = useState(false); const [patientSearchBusy, setPatientSearchBusy] = useState(false);
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false); const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
const [creatingStandalone, setCreatingStandalone] = useState(false); const [creatingStandalone, setCreatingStandalone] = useState(false);
@@ -417,14 +423,6 @@ export function TreatmentWorkspace({
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]); const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]); const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
/**
* Lab cases created by confirming a voice result. Here rather than on LabCaseDraft because
* a draft field is dropped by mapLabCaseDraftFromApi on the first round-trip — exactly the
* window where the dispatch panel's remembered default fires.
*/
const [voiceConfirmedLabCaseIds, setVoiceConfirmedLabCaseIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId); const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null); const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null); const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
@@ -535,6 +533,8 @@ export function TreatmentWorkspace({
id: selectedAppointment.patientId, id: selectedAppointment.patientId,
firstName: selectedAppointment.patientFirstName, firstName: selectedAppointment.patientFirstName,
lastName: selectedAppointment.patientLastName, lastName: selectedAppointment.patientLastName,
mobile: selectedAppointment.patientMobile ?? null,
email: null,
purpose: selectedAppointment.purpose, purpose: selectedAppointment.purpose,
isWalkIn: false, isWalkIn: false,
}; };
@@ -545,6 +545,8 @@ export function TreatmentWorkspace({
id: selectedStandalone.patientId, id: selectedStandalone.patientId,
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''), firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''), lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null),
email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null),
purpose: selectedStandalone.details[0]?.treatmentType, purpose: selectedStandalone.details[0]?.treatmentType,
isWalkIn, isWalkIn,
}; };
@@ -554,7 +556,9 @@ export function TreatmentWorkspace({
id: searchedPatient.id, id: searchedPatient.id,
firstName: searchedPatient.firstName, firstName: searchedPatient.firstName,
lastName: searchedPatient.lastName, lastName: searchedPatient.lastName,
purpose: undefined as string | undefined, mobile: searchedPatient.mobile ?? null,
email: searchedPatient.email ?? null,
purpose: undefined,
isWalkIn: false, isWalkIn: false,
}; };
} }
@@ -565,6 +569,23 @@ export function TreatmentWorkspace({
const activePatientName = activePatient const activePatientName = activePatient
? `${activePatient.firstName} ${activePatient.lastName}`.trim() ? `${activePatient.firstName} ${activePatient.lastName}`.trim()
: null; : null;
const namedActivePatient =
activePatient && !activePatient.isWalkIn && activePatient.id
? {
id: activePatient.id,
displayName: activePatientName ?? '',
mobile: activePatient.mobile ?? null,
email: activePatient.email ?? null,
}
: null;
const searchedWithoutLiveVisit = Boolean(searchedPatient) && !hasLiveContext;
const showSearchedPatientLoading =
searchedWithoutLiveVisit && (patientSearchBusy || historyLoading);
const showNoTreatmentFound =
searchedWithoutLiveVisit &&
!patientSearchBusy &&
!historyLoading &&
history.length === 0;
const unreadUpdatesCount = unreadLabCases.length; const unreadUpdatesCount = unreadLabCases.length;
const otherPatientsUnreadCount = useMemo( const otherPatientsUnreadCount = useMemo(
@@ -636,8 +657,8 @@ export function TreatmentWorkspace({
setStandaloneTreatments((prev) => { setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId); const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev; if (!current) return prev;
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? ''; const prevColor = unscheduledStripColorCode(current.details);
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? ''; const nextColor = unscheduledStripColorCode(nextDetails);
if ( if (
prevColor === nextColor && prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) === areUnscheduledDetailsStripDeletable(current.details) ===
@@ -668,8 +689,7 @@ export function TreatmentWorkspace({
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details ? details
: tr.details; : tr.details;
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim())); const colorCode = unscheduledStripColorCode(sourceDetails);
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
return { return {
kind: 'unscheduled' as const, kind: 'unscheduled' as const,
id: tr.id, id: tr.id,
@@ -771,7 +791,7 @@ export function TreatmentWorkspace({
mapped.length > 0 mapped.length > 0
? mapped ? mapped
: options?.seedBlankIfEmpty : options?.seedBlankIfEmpty
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))] ? [newDetail()]
: []; : [];
setDetails(nextDetails); setDetails(nextDetails);
setActiveDetailId((prev) => { setActiveDetailId((prev) => {
@@ -786,7 +806,7 @@ export function TreatmentWorkspace({
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null); setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch(''); setOrganizationSearch('');
setSaveStatus('idle'); setSaveStatus('idle');
}, [treatmentCatalog]); }, []);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]); const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo( const connectedSelectedTeeth = useMemo(
@@ -839,6 +859,7 @@ export function TreatmentWorkspace({
useEffect(() => { useEffect(() => {
setShowWholeTreatmentPlan(false); setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null; rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current; const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null; pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment'); setEntryStep(pending ?? 'treatment');
@@ -952,11 +973,11 @@ export function TreatmentWorkspace({
setLabDependentCodes( setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)), new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
); );
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId; const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) { orgsResponse.data.some((o) => o.id === id && o.active),
setRecentOrganizationIds((prev) => );
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10), if (recentIds.length > 0) {
); setRecentOrganizationIds(recentIds);
} }
} catch (error: unknown) { } catch (error: unknown) {
if (!cancelled) { if (!cancelled) {
@@ -1591,24 +1612,51 @@ export function TreatmentWorkspace({
return; return;
} }
const ok = await flushDraftSave();
if (!ok) return;
setPatientSearchBusy(true); setPatientSearchBusy(true);
setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
});
try { try {
const response = await treatmentsApi.listPatientHistory(patient.id, 1); const stripAppointment = appointments.find((row) => row.patientId === patient.id);
const latest = response.data[0]; const stripStandalone = standaloneTreatments.find(
if (latest) { (row) => row.patientId === patient.id && !row.patient?.isWalkIn,
const ok = await loadTreatmentIntoWorkspace(latest); );
if (!ok) { if (stripAppointment || stripStandalone) {
setSearchedPatient(null); draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
setSelectionLocked(true);
if (stripAppointment) {
setSelectedAppointmentId(stripAppointment.id);
setSelectedStandaloneId(null);
} else if (stripStandalone) {
setSelectedAppointmentId(null);
setSelectedStandaloneId(stripStandalone.id);
} }
return; return;
} }
await refreshHistory(patient.id); setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
mobile: patient.mobile,
email: patient.email,
});
resetToLiveContext();
setNewTreatmentPickerOpen(false);
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
setSelectionLocked(true);
setHistory([]);
setHistoryLoading(true);
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
await loadTreatmentIntoWorkspace(latest);
}
} catch (error: unknown) { } catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
setSearchedPatient(null); setSearchedPatient(null);
@@ -1622,8 +1670,11 @@ export function TreatmentWorkspace({
selectedStandalone?.patientId, selectedStandalone?.patientId,
searchedPatient?.id, searchedPatient?.id,
hasLiveContext, hasLiveContext,
flushDraftSave,
appointments,
standaloneTreatments,
resetToLiveContext,
loadTreatmentIntoWorkspace, loadTreatmentIntoWorkspace,
refreshHistory,
showError, showError,
t, t,
tErrors, tErrors,
@@ -1992,8 +2043,6 @@ export function TreatmentWorkspace({
} }
const updatedLabCases = [...labCaseDrafts, draft]; const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases); setLabCaseDrafts(updatedLabCases);
// The sheet showed exactly what this case contains; nothing may top it up after.
setVoiceConfirmedLabCaseIds((prev) => new Set(prev).add(draft.clientId));
// Autosave only watches `details`, so a lab draft left in state alone loses the // Autosave only watches `details`, so a lab draft left in state alone loses the
// lab, the due date and the prosthesis map on reload — silently, because the // lab, the due date and the prosthesis map on reload — silently, because the
@@ -2035,7 +2084,9 @@ export function TreatmentWorkspace({
if (idx < 0) return; if (idx < 0) return;
const target = details[idx]; const target = details[idx];
if (!target || isDetailLocked(target)) return; if (!target || isDetailLocked(target)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return; if (!window.confirm(
details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'),
)) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId); const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive = const nextActive =
@@ -2111,14 +2162,9 @@ export function TreatmentWorkspace({
return; return;
} }
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
const lastLabStillActive = lastLabId
? orgs.some((o) => o.id === lastLabId && o.active)
: false;
const next: LabCaseDraft = { const next: LabCaseDraft = {
...newLabCaseDraft(), ...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null, detailClientId: shouldIncludeActive ? activeDetailId : null,
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [], attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
}; };
const updatedLabCases = [...cleaned, next]; const updatedLabCases = [...cleaned, next];
@@ -2134,11 +2180,9 @@ export function TreatmentWorkspace({
}, [ }, [
activeDetailId, activeDetailId,
canEditTreatmentForDay, canEditTreatmentForDay,
currentOrganization?.id,
details, details,
labCaseDrafts, labCaseDrafts,
labDependentCodes, labDependentCodes,
orgs,
persistDraft, persistDraft,
persistLabCases, persistLabCases,
selectedAppointment, selectedAppointment,
@@ -2258,8 +2302,8 @@ export function TreatmentWorkspace({
setRecentOrganizationIds((prev) => { setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!; const orgId = labCase.destinationOrganizationId!;
rememberLastLab(currentOrganization?.id, orgId); rememberRecentLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10); return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
}); });
showSuccess(t('successCaseSent')); showSuccess(t('successCaseSent'));
notifyTabBadgesChanged(); notifyTabBadgesChanged();
@@ -2300,11 +2344,24 @@ export function TreatmentWorkspace({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<header className="space-y-1"> <header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1> <div className="min-w-0 shrink-0">
{!canEdit ? ( <h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p> {!canEdit ? (
) : null} <p className="text-sm text-text-secondary mt-1">{t('subtitleReadOnly')}</p>
) : null}
</div>
<div className="min-w-0 flex-1">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
</div>
</header> </header>
<AppointmentsStrip <AppointmentsStrip
@@ -2335,15 +2392,6 @@ export function TreatmentWorkspace({
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start"> <div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="space-y-3 min-w-0 xl:max-w-[380px]"> <div className="space-y-3 min-w-0 xl:max-w-[380px]">
<div className="surface-card p-3 space-y-3"> <div className="surface-card p-3 space-y-3">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
{canEdit && !isViewingPastDay ? ( {canEdit && !isViewingPastDay ? (
<div className="space-y-2"> <div className="space-y-2">
<Button <Button
@@ -2358,10 +2406,16 @@ export function TreatmentWorkspace({
{newTreatmentPickerOpen ? ( {newTreatmentPickerOpen ? (
<NewTreatmentPatientPicker <NewTreatmentPatientPicker
creating={creatingStandalone} creating={creatingStandalone}
currentPatient={namedActivePatient}
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })} onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
onSelectPatient={(patient) => onSelectPatient={(patient) =>
createStandaloneTreatment({ patientId: patient.id }) createStandaloneTreatment({ patientId: patient.id })
} }
onSelectCurrentPatient={
namedActivePatient
? () => createStandaloneTreatment({ patientId: namedActivePatient.id })
: undefined
}
onCancel={() => setNewTreatmentPickerOpen(false)} onCancel={() => setNewTreatmentPickerOpen(false)}
/> />
) : null} ) : null}
@@ -2369,8 +2423,14 @@ export function TreatmentWorkspace({
) : null} ) : null}
{activePatient ? ( {activePatient ? (
<div className="space-y-0.5 border-t border-border/60 pt-3"> <div
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p> className={`space-y-0.5 ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{t('selectedPatient')}
</p>
<p className="text-base font-semibold text-text-primary">{activePatientName}</p> <p className="text-base font-semibold text-text-primary">{activePatientName}</p>
{activePatient.purpose ? ( {activePatient.purpose ? (
<p className="text-[11px] text-text-secondary"> <p className="text-[11px] text-text-secondary">
@@ -2382,7 +2442,11 @@ export function TreatmentWorkspace({
) : null} ) : null}
</div> </div>
) : ( ) : (
<p className="text-sm text-text-muted border-t border-border/60 pt-3"> <p
className={`text-sm text-text-muted ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')} {apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
</p> </p>
)} )}
@@ -2485,6 +2549,24 @@ export function TreatmentWorkspace({
</div> </div>
<div className="space-y-3 min-w-0 w-full"> <div className="space-y-3 min-w-0 w-full">
{showSearchedPatientLoading ? (
<div className="surface-card p-6">
<p className="text-sm text-text-muted">{t('loading')}</p>
</div>
) : showNoTreatmentFound ? (
<div className="surface-card w-full p-6 space-y-3">
<h2 className="text-lg font-semibold text-text-primary">
{t('noTreatmentFoundTitle')}
</h2>
<p className="text-sm text-text-secondary">
{t('noTreatmentFoundBody', {
name: activePatientName ?? '',
action: t('newTreatment'),
})}
</p>
</div>
) : (
<>
<TreatmentDetailsEditor <TreatmentDetailsEditor
details={details} details={details}
activeDetailId={activeDetailId} activeDetailId={activeDetailId}
@@ -2498,9 +2580,14 @@ export function TreatmentWorkspace({
saveStatus={saveStatus} saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId} uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => { onAddDetail={() => {
const next = newDetail( const seedFromAppointment =
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), details.length === 0 && selectedAppointment
); ? defaultTreatmentTypeForAppointment(
selectedAppointment.purpose,
treatmentCatalog,
)
: undefined;
const next = newDetail(seedFromAppointment);
setDetails((prev) => [...prev, next]); setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId); setActiveDetailId(next.clientId);
setEntryStep('treatment'); setEntryStep('treatment');
@@ -2740,7 +2827,6 @@ export function TreatmentWorkspace({
{entryStep === 'lab' ? ( {entryStep === 'lab' ? (
<div ref={labPanelRef} className="space-y-3"> <div ref={labPanelRef} className="space-y-3">
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? ( {showLabDispatchPanel ? (
<LabCasesDispatchPanel <LabCasesDispatchPanel
details={details} details={details}
@@ -2748,7 +2834,6 @@ export function TreatmentWorkspace({
labCases={labCaseDrafts} labCases={labCaseDrafts}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
clinicOrganizationId={currentOrganization?.id}
labCaseSummary={activeLabCaseSummary} labCaseSummary={activeLabCaseSummary}
locale={locale} locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange} onLabCaseSummaryChange={handleLabCaseSummaryChange}
@@ -2759,7 +2844,6 @@ export function TreatmentWorkspace({
} }
}} }}
activeLabCaseId={activeLabCaseId} activeLabCaseId={activeLabCaseId}
previewConfirmedCaseIds={voiceConfirmedLabCaseIds}
onLabCasesChange={handleLabCasesChange} onLabCasesChange={handleLabCasesChange}
disabled={!canEditTreatmentForDay} disabled={!canEditTreatmentForDay}
canEdit={canEdit} canEdit={canEdit}
@@ -2767,33 +2851,21 @@ export function TreatmentWorkspace({
organizationSearch={organizationSearch} organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch} onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds} recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => {
setLabCaseDrafts((prev) => {
const targetId =
activeLabCaseId ??
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
?.clientId;
if (!targetId) return prev;
return prev.map((lc) =>
lc.clientId === targetId && !lc.sentAt
? { ...lc, destinationOrganizationId: orgId }
: lc,
);
});
}}
sendBusyId={sendBusyId} sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)} onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError} onCommentError={showError}
canInviteLab={canAccessOrganizations} canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')} onInviteLab={() => router.push('/organizations?action=invite-lab')}
/> />
) : ( ) : showLabShipmentBlocked ? null : (
<p className="text-sm text-text-muted surface-card p-4"> <p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')} {t('entryStepLabUnavailable')}
</p> </p>
)} )}
</div> </div>
) : null} ) : null}
</>
)}
</div> </div>
</div> </div>
{voiceResult ? ( {voiceResult ? (

View File

@@ -38,6 +38,7 @@ export interface TreatmentAppointment {
patientId: string; patientId: string;
patientFirstName: string; patientFirstName: string;
patientLastName: string; patientLastName: string;
patientMobile?: string | null;
providerUserId: string; providerUserId: string;
startAt: string; startAt: string;
endAt: string; endAt: string;
@@ -139,6 +140,8 @@ export interface PastTreatment {
firstName: string; firstName: string;
lastName: string; lastName: string;
isWalkIn: boolean; isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
} | null; } | null;
details: PastTreatmentDetail[]; details: PastTreatmentDetail[];
labCases: PastLabCase[]; labCases: PastLabCase[];