Compare commits

...

30 Commits

Author SHA1 Message Date
Khalid Abdi 07cfd5d335 ci: build release images on native arm64 runners, not QEMU
The release build emulated linux/arm64 with QEMU on the amd64 runner. A
runner/QEMU update started crashing 'npm ci' under emulation (illegal
instruction) and the build then hung for hours instead of failing, so
v0.17.0 never published. Build each arch on its native runner instead
(amd64 on ubuntu-24.04, arm64 on the free ubuntu-24.04-arm) and merge the
per-arch digests into one manifest with buildx imagetools. Add per-job
timeouts so a stuck build can never run for hours again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:12:27 +03:00
Khalid Abdi c5812511b5 chore: release v0.17.0
Hardware barcode scanner in the pharmacy Add-item dialog, Separated Panels
in Care team settings, and a pointer cursor on Activity rows. Also fixes
the README changelog badge, which was stuck at 0.14.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:53:53 +03:00
Khalid Abdi 196a22bffd frontend: pointer cursor on Activity feed rows
Activity rows are native buttons, which default to cursor:default; add
cursor-pointer so hovering an activity shows the pointer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:42:00 +03:00
Khalid Abdi 531cbbad7f frontend: Separated Panels in Care team settings
Apply the same COSS Separated Panels treatment used in the other settings
frames (Preferences, AI) to the Care team section: pass separated to its
SettingsSection so the cards render as distinct panels on a muted tray.
The members list keeps p-0/overflow-hidden so its divided rows sit flush
inside their panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:42:00 +03:00
Khalid Abdi 65975177cf inventory: hardware (keyboard-wedge) barcode scanner in Add-item
Replace the camera scanner in the Add inventory item dialog with support
for a USB barcode scanner attached to the computer. A wedge scanner types
the code as a fast keystroke burst ending in Enter; a global keydown
listener (active while the dialog is open) buffers the burst by timing and
routes it through the existing GS1 parser, so scanning a medication
auto-fills barcode/expiry/lot without focusing any field. The barcode
input also parses on Enter for manual entry. The camera BarcodeScanner
component stays (still used by patient wallet import). i18n: replace the
inventory.dialog.scan label with a scanHint across all five locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:42:00 +03:00
Khalid Abdi 75502ceb60 chore: release v0.16.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:59:58 +03:00
Khalid Abdi b285158fec frontend: scan a patient's wallet code in Import from a patient app
Adds a Scan button beside the wallet-number field in the import dialog,
reusing the BarcodeScanner. A scanned QR or 2D barcode from the patient's
wallet app drops the wallet number straight into the field. i18n added to
all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:34:56 +03:00
Khalid Abdi 0754ce96a4 inventory: barcode scanner in Add-item dialog
Add a camera barcode/QR scanner (components/scan/barcode-scanner.tsx,
@zxing/browser lazy-loaded) and a Barcode/NDC field to the pharmacy
Add-item dialog. Scanning a GS1 DataMatrix auto-fills expiry (AI 17) and
lot (AI 10 -> notes), with the GTIN (AI 01) as the barcode; plain 1D
barcodes drop straight into the field. Persists a nullable inventory
barcode column (migration 0036) through the type/validation/service, and
surfaces it on the item detail. i18n added to all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:32:45 +03:00
Khalid Abdi bac3e7a1d0 frontend: Show/Add tabs in patient edit dialog
Editing a record now defaults to a read-only Show tab (reusing
PatientDetail) with an Add tab for the editable form + Add buttons.
Create and import-review keep the plain form. The existing post-save
wallet-sync step (Next -> send to patient's wallet when linked) is
unchanged. Adds patientForm.tabs.{add,show} + patientForm.close to all
locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:20:22 +03:00
Khalid Abdi b9d4d4f458 frontend: real COSS Separated Panels in settings frames
Replace the gap-4-on-CardFrame hack with a faithful COSS Frame: a muted
tray (bg-muted/72) holding distinct bordered FramePanels spaced by mt-1,
matching coss.com/ui/docs/components/frame. Adds components/ui/frame.tsx
and switches SettingsFrame/SettingsCard to render Frame/FramePanel when
separated (via context), keeping joined frames on CardFrame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:13:34 +03:00
Khalid Abdi aa535f08c4 chore: release v0.15.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:05:33 +03:00
Khalid Abdi 000591f87f frontend: space separated panels in settings frames
Add an opt-in `separated` prop to SettingsFrame/SettingsSection that adds the
COSS "Separated Panels" gap-4 between sibling panels (matches CardFrame's
built-in -1rem clip vars so rounded corners clip across the gap). Turn it on
for the multi-toggle frames — AI availability and the two notification
sections — so their panels no longer stack flush. Joined divide-y lists stay
flush.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:48:30 +03:00
Khalid Abdi 7312d15d67 chore: release v0.15.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:12:01 +03:00
Khalid Abdi e8f3ed9ffe frontend: clear the 99 lint errors
`npm run lint` reported 99 errors and 26 warnings across 65 files and
had presumably been failing for a while — next.config.ts sets
eslint.ignoreDuringBuilds, so the build never surfaced it.

68 were in vendored code: components/charts and components/ai-elements,
pulled from upstream registries. Re-linting those reports upstream's
style back at us, and "fixing" them means diverging and eating conflicts
on every update — ai-elements already has exactly this carve-out on the
TypeScript side via ignoreBuildErrors. Ignore both, plus the two registry
files in components/ui (carousel publishes its api from an effect; the
sidebar skeleton picks a random width).

The other 31 were ours, nearly all react-hooks/set-state-in-effect on
the same shape: an effect that re-seeds form state when a dialog opens or
a selection changes. Moved to render-phase adjustment, which is both what
React recommends and a real fix — the effect version paints one frame of
the *previous* record's values before correcting itself. Two carried
sharper bugs: the employee dialog could keep a typed password across a
switch to another member, and use-wallet-sync could carry `linked` over
to a newly-selected patient, briefly offering to push a record to
someone else's wallet.

The rest: useIsMobile and speech-support detection become
useSyncExternalStore (correct on first paint, no mount flash); refs
mirroring state are written in effects rather than during render; the
care-team fetch moves into its effect behind a reload key, dropping an
exhaustive-deps suppression.

Two effects in chat-panel keep the rule disabled with a reason. Both are
what effects are for: draining the queued-message buffer when the
transport goes idle, and resolving ?thread from the URL — the latter
mints an id with nanoid(), so moving it into render would just trade this
error for a purity one.

Also removes a dead /explore-era import and unused directives found on
the way. lint now exits 0, with the ai-elements tsc carve-out unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:54:37 +03:00
Khalid Abdi 7c92c03eed docker: stop a Windows checkout from breaking the backend image
The repo had no .gitattributes at all, so nothing pinned line endings —
`git ls-files --eol backend/docker-entrypoint.sh` showed an empty attr/.
Git for Windows installs with core.autocrlf=true, which rewrites the
entrypoint to CRLF on checkout. The image then ships a script whose
shebang is "#!/bin/sh\r" and the container dies at startup with

  exec /usr/local/bin/docker-entrypoint.sh: no such file or directory

naming the file it just copied in, which reads like the COPY failed.

Verified by building an image from a deliberately CRLF-ified copy of the
entrypoint: it reproduces that exact error, and passes with the sed.

Pin *.sh (and the Dockerfiles) to LF, and strip CR in the image before
chmod as well — .gitattributes only helps fresh clones, and a Windows
contributor who cloned before this commit still has CRLF on disk.
Renormalizing changes no tracked file today; this is purely a guard.

The arm64/Apple Silicon failure that prompted this is already fixed by
6127a0a (Turbopack has no musl/arm64 binding; the build uses webpack).
Confirmed by building both images on an arm64 Mac. Release publishes
linux/amd64 + linux/arm64, there are no bind mounts, no platform: keys
and no host.docker.internal, so the remaining cross-platform hazard was
just the line endings.

Also tighten .dockerignore: backend was shipping docker-compose.tunnel
.yml, fly.toml, railway.json, render.yaml, .env.example and
drizzle.config.ts into the build context; frontend was shipping
tsconfig.tsbuildinfo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:27:03 +03:00
Khalid Abdi 5961d969b1 frontend: give settings frames the COSS default padding
The Clinician profile frame inset its content 40px and drew a second
border around it — a box inside a box. Both came from CardFramePanel,
added in 44d653f. It doesn't exist in the COSS registry: upstream
@coss/card ships Card, CardFrame, CardFrameHeader/Title/Description/
Action/Footer and nothing else. There is no body part because the frame
isn't meant to pad its body.

CardFrame styles its cards through *direct-child* selectors — -m-px,
the clip-path, rounded-t/b-xl, shadow-none, before:hidden — pulling each
card out by a pixel so it sits flush inside the frame's own border. The
panel wrapper made every card a grandchild, so none of those selectors
matched: the 20px came from the panel's p-5, the second border came from
the card keeping the treatment the frame was supposed to suppress. The
commit that added it claimed the cards would "pick up the frame's card
treatment"; they never did.

Drop the panel and render children directly inside the frame, which is
the COSS default. Body padding goes on the Card, where COSS puts it, so
content is inset once rather than twice. Card-bearing bodies now get the
frame treatment they were meant to have, and the divide-y lists in
Records, Care team and Signing stack flush, which is what -m-px and
rounded-t-xl were for. The notification ToggleRows lose their space-y
wrapper and read as one list.

Frames holding raw form content (the AI panel, Care team's error and
toolbar) get an explicit padded Card instead of leaning on the frame.

card.tsx is now upstream @coss/card verbatim, minus font-heading on
CardTitle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:23:33 +03:00
Khalid Abdi fa566ced86 backend: ship prescriptions + a stable clinic id to wallets
A prescription written in the clinic never reached the patient's wallet.
createPrescription writes to the `prescriptions` table, but the pushed
bundle only carried `patient.medications`, which comes from the separate
`patient_medications` table and never grows when a prescription is
written. The patient approved a bundle identical to what they already
had, so the app's Prescriptions section showed nothing new — the
"New prescription: X" line in `changes` is display text, not data.

Pull listPrescriptions into the bundle alongside the appointments and
invoices that were already handled the same way, keeping the full
prescription shape rather than flattening to {name, dose, frequency}.

Also add `clinicId` (the org id) to the update event. Wallets pinned a
clinic's signing key against `clinicName`, which is mutable and falls
back to the literal "A clinic" for unnamed orgs, so unnamed clinics
collided on one pin and tripped a bogus "key changed" warning. It also
gives the wallet the relay routing id it needs to fetch document bytes.

Log the silent drops on this path: sendToWallet no-ops when an org has
no live hub, and applyUpdateResponse returned null without a word when a
response arrived unsigned or already resolved. Both looked, from the
clinic side, exactly like a patient who never tapped Approve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:03:50 +03:00
Khalid Abdi 62a5f39683 chore: release v0.14.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:22 +03:00
Khalid Abdi 6127a0ac42 frontend: build with webpack so Docker images build on arm64
Next 16's default Turbopack build has no native bindings for linux/arm64 in
the Alpine image, so `next build` failed in Docker ("Turbopack is not
supported on this platform"). Switch the production build to webpack, which
Next recommends for this case and which builds on every arch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:02 +03:00
Khalid Abdi 352ca65838 chore: release v0.14.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:31:54 +03:00
Khalid Abdi 44d653ffbd frontend: compose settings frames from CardFrame primitives
SettingsFrame used a hand-rolled <div className="p-5"> body and
SettingsCard was a plain div (no data-slot=card), so the frame's card
styling never applied.

- Add CardFramePanel (data-slot="card-frame-panel") as the padded frame
  body, mirroring the other CardFrame* subcomponents.
- SettingsFrame now composes CardFrameHeader + CardFramePanel (same p-5
  padding as before) instead of a raw div.
- SettingsCard renders a real COSS Card (data-slot=card). Since Card is
  flex flex-col, the horizontal-row call sites (ToggleRow, billing and
  preferences rows) now pass flex-row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:19 +03:00
Khalid Abdi 76da310766 frontend: fix wallet push silently skipping in form dialogs
The "send to wallet" step in the appointment/invoice/prescription/
patient-edit/scribe dialogs was gated on sync.linked, which resolves
asynchronously via getWalletLink in an effect. A fast save (or a
transient failure that collapsed it to false) left a wallet-linked
patient looking unlinked, so the dialog took the else branch and pushed
nothing.

- Add ensureLinked() to useWalletSync: awaits getWalletLink at submit and
  returns the resolved status. Each of the 5 dialogs now awaits it before
  deciding whether to show the wallet step.
- Harden push() to drop empty/whitespace changes (the backend 400s on an
  empty change set) and add a translated summaryFallback so the step
  never sends an empty summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:04 +03:00
Khalid Abdi c326e9f794 chore: release v0.14.0
Bump root/backend/frontend to 0.14.0, move Unreleased notes under a dated
0.14.0 heading, and fix the stale README changelog badge (0.2.1 → 0.14.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:48 +03:00
Khalid Abdi b299501ab2 frontend+backend: patients/settings UI, AI Auto/Off modes, wallet doc push
- patients: move status filter to its own "Filter" row above the table
- patient sheet: name + ⋯ menu on one left row; Edit moved into the ⋯ menu
- settings: redesign sections with the COSS CardFrame surface (SettingsFrame)
- AI: add Automatic (auto-pick provider) and Off modes; default to Automatic
  so a fresh install shows the setup banner until a provider is configured
- AI: fix the setup banner never showing (defaulted Ollama URL counted as
  configured); add an "AI off" notice; add a fallback render for unknown chat
  data parts so cards never silently vanish
- backend: include patient attachment metadata in the wallet record-update
  bundle so pushed documents reach the wallet
- i18n: add mode/off/card keys across all five locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:36 +03:00
Khalid Abdi 17209a2cf1 chore: release v0.13.1
Bump version to 0.13.1 across root/backend/frontend and add the 0.13.1
CHANGELOG entry (COSS checkbox/table on the web, wallet-app home sheets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:16:07 +03:00
Khalid Abdi bd8fdfadda frontend: COSS checkbox/table + fewer toolbar/sheet buttons
- Invoice dialog: the Back-date / Due-date toggles now use the COSS Checkbox
  instead of raw, misshapen native checkboxes.
- Patients page: rebuilt the list as a COSS Table in a CardFrame, and moved
  "Import from a patient app" into a ⋯ overflow menu so the toolbar keeps one
  primary "Add patient" CTA.
- Patient detail sheet: collapsed the five header buttons + delete into a
  primary Edit action plus a ⋯ More menu (Download summary, Record visit,
  Transfer, Push to wallet, and a destructive Delete).
- Added the COSS table + checkbox primitives; new i18n keys across all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:11:06 +03:00
Khalid Abdi 7846dacd42 chore: release v0.13.0
Bump version to 0.13.0 across root/backend/frontend, add the 0.13.0 CHANGELOG
entry, and refresh stale CLAUDE.md docs (the AI chat is real and @ai-sdk/react
is installed; the signing/approval flow is built).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:17:55 +03:00
Khalid Abdi 2c239fbd27 frontend: UI fixes + settings/patients features
- Record history: replace fragile COSS Timeline separator math with a
  continuous-rail list so every audited change renders in full (RTL-safe).
- Prescriptions: new reusable COSS DatePicker (Popover + Calendar) replaces
  raw <input type="date"> for Start/End date.
- Update banner: rebuilt with COSS Alert variant="warning"; pinned physical
  bottom-right so it stays bottom-right under Arabic RTL.
- AI setup notice: thin attached warning banner with clearer copy; now shown
  above the input in active chats too, not just the empty state.
- Settings profile: wire the previously dead Specialty (Select) and
  Professional links (editable rows) into the persisted preferences.
- Patients: add a status filter and broaden search to conditions/allergies.
- Sidebar user menu: quick language switch submenu (RTL already wired).
- ai-elements: fix a subset of Base UI type drift (22 -> 12; remainder is
  cmdk->Autocomplete migration drift kept behind ignoreBuildErrors).
- i18n: new keys added across all five locales (en/de/fr/ar/so).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:06:38 +03:00
Khalid Abdi bffed5525d chore: release v0.12.1
Bump root/backend/frontend to 0.12.1 and move the CHANGELOG notes under a dated
0.12.1 heading (centered wallet-sync stepper, timeline record history, and the
landing-page globe deferral). Adds the radix-ui dependency pulled in by the
Origin UI stepper/timeline primitives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:51:04 +03:00
Khalid Abdi 591b2f9170 frontend: center wallet-sync stepper and record-history timeline
Replace the bespoke left-aligned DialogStepper with the Origin UI Stepper
primitive (components/ui/stepper.tsx) so the numbered indicators sit centered
inline with their labels. Rebuild the patient sheet's Record History section as
a vertical Timeline (components/ui/timeline.tsx) showing the actor name, an
entity-type icon, the action, and the date, replacing the flat avatar list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:50:54 +03:00
89 changed files with 9244 additions and 781 deletions
+32
View File
@@ -0,0 +1,32 @@
# Normalize line endings so the repo builds the same on Windows, macOS and Linux.
#
# Git for Windows installs with core.autocrlf=true by default, which rewrites
# checked-out files to CRLF. That is harmless for source we only ever compile,
# but fatal for anything the Docker images execute: a shell script whose shebang
# becomes "#!/bin/sh\r" fails at container start with
# exec /usr/local/bin/docker-entrypoint.sh: no such file or directory
# which names the file it just copied in and reads like the file is missing.
* text=auto
# Scripts that run inside a Linux container must stay LF regardless of platform.
*.sh text eol=lf
docker-entrypoint.sh text eol=lf
Dockerfile text eol=lf
*.Dockerfile text eol=lf
.dockerignore text eol=lf
# Lockfiles: keep LF so they don't churn across platforms.
package-lock.json text eol=lf
# Binary assets — never touch these.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
+104 -28
View File
@@ -3,6 +3,11 @@
# Trigger: push a semver tag, e.g.
# git tag v0.1.0 && git push origin v0.1.0
#
# Multi-arch (amd64 + arm64) is built on NATIVE runners — amd64 on ubuntu-24.04,
# arm64 on ubuntu-24.04-arm — and merged into a manifest. We do NOT emulate arm64
# with QEMU anymore: a runner/QEMU update started crashing `npm ci` under
# emulation ("illegal instruction") and hanging the build for hours.
#
# The frontend image bakes NO API URL — it resolves the backend from the host
# the browser uses at runtime — so one published image works for every clinic.
#
@@ -18,27 +23,30 @@ on:
tags:
- "v*.*.*"
permissions:
contents: write # create the GitHub Release (the update check reads this)
env:
REGISTRY_NAMESPACE: khalidxv
jobs:
publish:
runs-on: ubuntu-latest
# One build per (image × platform) on the platform's native runner, pushed to
# Docker Hub by digest (no tag yet). The merge job stitches the per-arch
# digests into a single tagged multi-arch manifest.
build:
name: Build ${{ matrix.image }} (${{ matrix.platform }})
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
image: [backend, frontend]
platform: [linux/amd64, linux/arm64]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# QEMU lets the amd64 runner emulate arm64 so the images below build for
# both platforms (Intel + Apple Silicon self-hosters).
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
@@ -49,25 +57,93 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & push backend
- name: Build & push by digest
id: build
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:latest
context: ./${{ matrix.image }}
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
- name: Build & push frontend
uses: docker/build-push-action@v6
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
context: ./frontend
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
name: digests-${{ matrix.image }}-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-arch digests for each image into one multi-arch manifest and
# tag it (X.Y.Z + latest).
merge:
name: Merge ${{ matrix.image }} manifest
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
image: [backend, frontend]
steps:
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-${{ matrix.image }}-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
VERSION: ${{ steps.meta.outputs.version }}
run: |
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \
$(printf "$IMAGE@sha256:%s " *)
- name: Inspect
env:
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
VERSION: ${{ steps.meta.outputs.version }}
run: docker buildx imagetools inspect "$IMAGE:$VERSION"
# Both images are published — now cut the GitHub Release with the changelog.
release:
name: GitHub Release
runs-on: ubuntu-latest
timeout-minutes: 10
needs: merge
permissions:
contents: write # create the GitHub Release (the update check reads this)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# Pull this version's section out of CHANGELOG.md so the release has real,
# human-written notes (the auto "Full Changelog" link is still appended
+215
View File
@@ -7,6 +7,221 @@ for how releases are cut and published.
## [Unreleased]
## [0.17.0] — 2026-07-19
### Changed
- **Pharmacy Add-item now uses a hardware barcode scanner.** The inventory Add-item dialog is
built for a USB barcode scanner connected to the computer (a keyboard wedge): scanning a
medication types the code as a fast keystroke burst ending in Enter, which is parsed and
auto-fills the Barcode/NDC, expiry (AI 17), and lot (AI 10) fields — no need to focus any input
first. The barcode field also parses on Enter for manual entry. This replaces the camera scanner
in this dialog (the camera scanner stays for importing a patient's wallet code)
(`frontend/components/pharmacy/add-inventory-dialog.tsx`).
- **Care team settings use Separated Panels.** The Care team section now renders as distinct
bordered panels on a muted tray, matching the Preferences and AI settings frames
(`frontend/components/settings/settings-care-team.tsx`).
### Fixed
- **Pointer cursor on Activity rows.** Hovering an entry in the Activity feed now shows the pointer
cursor (`frontend/components/activity/activity-view.tsx`).
## [0.16.0] — 2026-07-18
### Added
- **Show / Add tabs on the patient record dialog.** Editing a record now opens on a read-only
"Show" view (the existing `PatientDetail`), with an "Add" tab for the editable form and its Add
buttons. Saving from the Add tab still offers the existing "send to the patient's wallet" step
when the patient is wallet-linked (`frontend/components/chat/patient-form-dialog.tsx`).
- **Barcode scanning in the pharmacy.** The inventory Add-item dialog can now scan a medication
barcode with the camera (`@zxing/browser`, native `BarcodeDetector` where available) into a new
Barcode/NDC field, auto-filling expiry (AI 17) and lot (AI 10) from a GS1 DataMatrix. Adds a
nullable `inventory.barcode` column (migration `0036`).
- **Scan a patient's wallet code when importing.** "Import from a patient app" gains a Scan button
that reads the QR or 2D barcode shown in the patient's wallet app and fills the wallet number
(`frontend/components/patients/import-from-wallet-dialog.tsx`).
### Changed
- **Settings "separated panels" now match COSS exactly.** The opt-in `separated` settings frames
render a real COSS Frame — a muted tray holding distinct bordered panels spaced apart — instead
of a `gap-4` bolted onto the panel-fusing `CardFrame`. New `frontend/components/ui/frame.tsx`.
## [0.15.1] — 2026-07-17
### Fixed
- **Settings frames with more than one panel now breathe.** When a settings section stacked
several cards — the AI availability toggles and the patient/account notification lists — the
panels sat flush against each other with no gap, reading as one joined block. Those frames now
use the COSS "separated panels" spacing (a 1rem gap that keeps each card's rounded corners),
while intentional joined lists (care team, records) stay flush. Opt-in via a new `separated`
prop on `SettingsFrame`/`SettingsSection` (`frontend/components/settings/settings-parts.tsx`).
## [0.15.0] — 2026-07-15
### Fixed
- **Prescriptions pushed to a wallet now actually arrive.** `createPrescription` writes to the
`prescriptions` table, but the sealed bundle only carried `patient.medications`, which comes from
the separate `patient_medications` table and never grows when a prescription is written. The
patient approved a bundle identical to what they already had, so nothing appeared in the app's
Prescriptions section — the "New prescription: X" line in `changes` is display text, not data.
The bundle now ships `prescriptions` alongside the appointments and invoices that were already
handled this way, keeping prescriber/status/dates (`backend/src/services/wallet-updates.ts`).
- **No more bogus "this clinic's key changed" warnings.** Update events now carry a stable
`clinicId`. Wallets pinned a clinic's signing key against its display *name*, which is mutable
and falls back to the literal "A clinic" for unnamed orgs — so two unnamed clinics collided on
one pin and the second always warned.
- **Silent drops on the wallet-push path are now logged.** `sendToWallet` no-ops when an org has no
live relay hub, and `applyUpdateResponse` returned `null` without a word when a response arrived
unsigned or already resolved. From the clinic side both looked exactly like a patient who never
tapped Approve.
- **Windows checkouts no longer break the backend image.** The repo had no `.gitattributes`, so Git
for Windows' default `core.autocrlf=true` rewrote `docker-entrypoint.sh` to CRLF; the container
then died with `exec /usr/local/bin/docker-entrypoint.sh: no such file or directory`. Line
endings are pinned, and the image strips CR before `chmod` so existing checkouts are fixed too.
- **Settings frames use the COSS default padding.** `CardFramePanel` (added in 0.14.0) isn't part of
the COSS registry: its `p-5` inset content twice over, and wrapping the body made every card a
grandchild of `CardFrame`, so the frame's direct-child selectors never applied and each card drew
its own border — a box inside a box. Body padding now sits on the card, where COSS puts it.
- **99 lint errors cleared.** `npm run lint` had been failing (the build hides it via
`eslint.ignoreDuringBuilds`). Vendored `components/charts` and `components/ai-elements` are now
ignored; the 31 in our own code are fixed. Two were real bugs: the employee dialog could keep a
typed password when switching to another member, and the wallet-sync hook could carry a patient's
`linked` state to a newly-selected patient, briefly offering to push a record to someone else's
wallet.
### Changed
- The clinic→wallet update event carries `clinicId`, and the sealed bundle carries `prescriptions`.
Both are additive; older wallets ignore them. See the
[signing API docs](https://docs.temetro.com/docs/api/signing#clinic--wallet-record-updates).
## [0.14.2] — 2026-07-15
### Fixed
- **Docker images build on ARM64 again.** Next 16's default Turbopack production build has no native
bindings for `linux/arm64` in the Alpine image, so `docker compose up --build` failed with
"Turbopack is not supported on this platform". The frontend now builds with Webpack
(`next build --webpack`), which builds on every architecture (`frontend/package.json`).
## [0.14.1] — 2026-07-15
### Fixed
- **"Send to wallet" now works from every dialog, not just the patient sheet.** The wallet step in
the appointment, invoice, prescription, patient-edit, and scribe dialogs was gated on a wallet-link
check that resolved asynchronously; if the clinician saved before it resolved (or it briefly
failed), the dialog silently closed without pushing. The dialogs now await the link check before
deciding, and an empty change summary can no longer be sent
(`frontend/components/wallet/use-wallet-sync.ts`, `wallet-sync-step.tsx`).
### Changed
- **Settings sections composed from `CardFrame` primitives.** Each settings section now builds on
`CardFrameHeader`/`CardFrameTitle`/`CardFrameDescription` + a new `CardFramePanel` body, and the
per-row `SettingsCard` renders a real `Card`, giving a consistent framed surface
(`frontend/components/ui/card.tsx`, `frontend/components/settings/settings-parts.tsx`).
## [0.14.0] — 2026-07-13
### Changed
- **Patients page filter moved to its own row.** The status filter left the toolbar and now sits on a
dedicated **"Filter"** row directly above the table, so the header stays a clean title + search +
"Add patient" + `⋯` cluster (`frontend/components/patients/patients-view.tsx`).
- **Patient detail sheet header.** The patient name, status badge, and the `⋯` menu now share one
left-aligned row, and the standalone **Edit** button moved to be the first item inside the `⋯` menu
(`frontend/components/patients/patient-detail.tsx`).
- **Settings redesigned with the COSS frame surface.** Every settings section now renders in a
`CardFrame` (header + body) via a new `SettingsFrame` part, replacing the flat `SettingsCard` divs
(`frontend/components/settings/settings-parts.tsx`, `settings-ai.tsx`).
### Added
- **AI Mode: Automatic and Off.** Settings → AI gains two modes beyond API / Local: **Automatic**
(use a cloud API key when set, else fall back to local Ollama) and **Off** (assistant disabled).
Automatic is the new default, so a fresh install shows the setup banner until a provider is wired
(`frontend/lib/ai-settings.ts`, `backend/src/services/ai/{config,provider}.ts`, `types/ai.ts`).
### Fixed
- **AI setup banner now appears when no provider is configured.** Previously the defaulted Ollama URL
counted as "configured," so the "connect an AI model" banner never showed; it now reflects the
actual mode (`frontend/components/chat/ai-setup-notice.tsx`).
- **Chat cards no longer silently vanish.** An unrecognized/renamed streamed data part now renders a
small placeholder instead of nothing (`frontend/components/chat/chat-panel.tsx`).
### Wallet app
- **Documents/files reach the wallet.** Clinic record-update pushes now include attachment metadata,
which the wallet folds into the record so the **Documents** tile counts them and the Documents
screen lists them (`backend/src/services/wallet-updates.ts`, `temetro-app` types + home/documents).
- **Failed pushes are no longer swallowed.** A record update that fails signature verification or
decoding is now logged (and a "couldn't verify" notice is raised) instead of disappearing silently.
- **i18next translation.** The wallet app now uses `i18next` + `react-i18next` + `expo-localization`
(English shipped; other locales can be added later), with the home, record-update inbox, navigation
titles, and the visits/prescriptions-adjacent detail screens extracted to translation keys.
## [0.13.1] — 2026-07-13
### Changed
- **New Invoice checkboxes.** The Back-date / Due-date toggles now use the COSS `Checkbox`
(`frontend/components/ui/checkbox.tsx`) instead of raw, misshapen native checkboxes.
- **Patients page toolbar & table.** The list is now a COSS **Table in a `CardFrame`**
(`frontend/components/ui/table.tsx`), and the secondary "Import from a patient app" action moved
into a `⋯` overflow menu so the toolbar keeps a single primary "Add patient" CTA.
- **Patient detail sheet header.** The five action buttons plus delete collapse into a primary
**Edit** button and a `⋯ More` menu (Download summary, Record visit, Transfer, Push to wallet, and
a destructive Delete).
### Wallet app
- **Home quick actions open sheets.** "Share record" now opens a bottom sheet with a scannable **QR**
of the wallet number (`react-native-qrcode-svg`); "My wallet" opens a bottom sheet with copyable
wallet number / fingerprint / algorithm; and the duplicate "Notifications" action (the header
already has a bell) is replaced with **Scan**.
## [0.13.0] — 2026-07-12
### Added
- **Prescription date pickers.** The New Prescription dialog's Start/End date now use a proper COSS
date picker (`frontend/components/ui/date-picker.tsx`, Popover + Calendar) instead of the raw
native date input.
- **Working profile fields.** Settings → Profile **Specialty** (a Select) and **Professional links**
(editable rows) are now wired to persisted preferences — previously they were inert stubs.
- **Patient filters.** The Patients page gains a status filter (all / active / inpatient /
discharged) and its search now also matches conditions and allergies.
- **Language quick-switch.** A language submenu in the sidebar user menu (alongside Theme), applied
immediately with Arabic RTL.
- **Wallet app — home & settings.** The patient app home screen adds a quick-action row (Share
record / My wallet / Notifications) and a recent-activity feed; the settings screen adds an About
section (version, docs, blog) and a privacy footer.
### Changed
- **Record history renders in full.** The patient sheet's Record history replaces the fragile
timeline-separator layout with a continuous-rail list, so every audited change shows completely
(and mirrors correctly under RTL).
- **Update banner + AI setup notice.** The "update available" banner now uses the COSS warning Alert
and stays bottom-right under Arabic; the "connect an AI provider" notice is a clearer, thinner bar
shown above the chat input in active conversations too.
- **Wallet app — bottom sheets.** Polished the shared sheet building blocks (spacing, radii, close
and action affordances).
### Fixed
- **Blog reachable.** `blog.temetro.com` was returning 502 due to a custom-domain target-port
mismatch (Ghost listens on 2368); documented and corrected.
- **Docs freshness.** Corrected stale `CLAUDE.md` claims (the AI chat is real and `@ai-sdk/react` is
installed; the signing/approval flow is built) and reduced the vendored `ai-elements` Base UI
type-drift errors.
## [0.12.1] — 2026-07-10
### Changed
- **Centered wallet-sync stepper.** The in-dialog "Sync to wallet" stepper (`DialogStepper` in
`frontend/components/wallet/wallet-sync-step.tsx`) now uses a proper Stepper primitive
(`components/ui/stepper.tsx`), so the numbered indicators sit centered inline with their labels
instead of the previous left-aligned look.
- **Record history is now a timeline.** The patient sheet's **Record history** section
(`RecordHistory` in `frontend/components/patients/patient-detail.tsx`) renders as a vertical
timeline (`components/ui/timeline.tsx`) — who made the change, an entity-type icon, what happened,
and when — replacing the flat avatar list.
### Performance
- **Landing page: defer the 3D globe.** The Temetro Network globe (three.js) on the marketing site
now mounts only when its section scrolls near the viewport (IntersectionObserver), instead of on
hydration — removing ~730 KB of JS and its main-thread execution from initial page load. (Landing
page lives in the sibling `temetro/landing-page` repo.)
## [0.12.0] — 2026-07-09
### Added
+5 -2
View File
@@ -31,9 +31,12 @@ repository (published as `temetro`).
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
>
> **Also built:** the **AI chat is real** — the frontend streams from the backend's tool-using
> agent (`POST /api/chat`), not mock replies (see `frontend/CLAUDE.md`).
>
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
> shares. The AI chat is still **mock replies**. Email verification is wired but currently **not
> enforced** at sign-in (see `backend/CLAUDE.md`).
> shares. Email verification is wired but currently **not enforced** at sign-in (see
> `backend/CLAUDE.md`).
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
+1 -1
View File
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Docker images](https://img.shields.io/badge/Docker%20Hub-khalidxv-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/u/khalidxv)
[![Changelog](https://img.shields.io/badge/changelog-0.2.1-success)](./CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/changelog-0.17.0-success)](./CHANGELOG.md)
![temetro AI chat](./.github/assets/screenshot-chat.png)
+10
View File
@@ -6,5 +6,15 @@ dist
*.log
.DS_Store
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.tunnel.yml
README.md
CLAUDE.md
# Deploy manifests for other targets — not needed inside the image.
fly.toml
railway.json
render.yaml
# Tracked, but only the runtime migrator (dist/migrate.js) runs in the image.
drizzle.config.ts
.env.example
+7 -1
View File
@@ -19,7 +19,13 @@ RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Strip CR before chmod. .gitattributes keeps this file LF on fresh clones, but
# a Windows checkout made before that existed still has CRLF on disk, and a
# "#!/bin/sh\r" shebang fails at container start with a "no such file or
# directory" error that names the file it just copied in. Cheap to make the
# image self-healing rather than rely on every contributor re-cloning.
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 4000
# The entrypoint auto-generates any missing secrets, then we apply migrations
# and start the API.
@@ -0,0 +1 @@
ALTER TABLE "inventory" ADD COLUMN "barcode" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -253,6 +253,13 @@
"when": 1783530491321,
"tag": "0035_slippery_retro_girl",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1784406410448,
"tag": "0036_aspiring_expediter",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.12.0",
"version": "0.17.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+2
View File
@@ -29,6 +29,8 @@ export const inventory = pgTable(
stockQuantity: integer("stock_quantity").notNull().default(0),
reorderThreshold: integer("reorder_threshold").notNull().default(0),
location: text("location").notNull().default(""),
// Scanned medication barcode / NDC (GTIN when read from a GS1 DataMatrix).
barcode: text("barcode"),
expiresAt: date("expires_at"),
notes: text("notes"),
createdBy: text("created_by").references(() => user.id, {
+1 -1
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
// is the plaintext key for the *currently selected* provider — it is encrypted
// before storage and never echoed back.
export const aiConfigInputSchema = z.object({
mode: z.enum(["api", "local"]).optional(),
mode: z.enum(["api", "local", "auto", "off"]).optional(),
provider: z.enum(["openai", "anthropic", "gemini"]).optional(),
ollamaBaseUrl: z.string().url().optional(),
ollamaModel: z.string().min(1).max(120).optional(),
+1
View File
@@ -13,6 +13,7 @@ export const inventoryInputSchema = z.object({
stockQuantity: z.number().int().min(0).max(1_000_000).default(0),
reorderThreshold: z.number().int().min(0).max(1_000_000).default(0),
location: z.string().trim().max(200).default(""),
barcode: z.string().trim().max(120).nullish(),
expiresAt: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
+6
View File
@@ -208,6 +208,12 @@ chatRouter.post("/", async (req, res, next) => {
}
const settings = await getAiSettings(req.user!.id);
if (settings.mode === "off") {
throw new HttpError(
400,
"The AI assistant is turned off. Turn it on in Settings → AI.",
);
}
const modelId = requestedModel || settings.defaultModel;
const resolved = resolveModel(settings, modelId);
const veil = createVeil(settings.veilLevel, resolved.isExternal);
+3 -1
View File
@@ -13,7 +13,9 @@ import {
type AiSettingsRow = typeof userAiSettings.$inferSelect;
const DEFAULTS: Omit<AiSettingsRow, "userId" | "updatedAt"> = {
mode: "local",
// Default to auto: use a cloud key if the user adds one, else local Ollama.
// A fresh user with nothing configured then sees the setup banner.
mode: "auto",
provider: "anthropic",
ollamaBaseUrl: DEFAULT_OLLAMA_BASE_URL,
ollamaModel: "llama3.1",
+25 -7
View File
@@ -69,8 +69,30 @@ export function resolveModel(
settings: AiSettingsRow,
requestedModelId: string,
): ResolvedModel {
// Local mode (or the local sentinel) → Ollama's OpenAI-compatible endpoint.
if (settings.mode === "local" || requestedModelId === OLLAMA_SENTINEL) {
// Off → the assistant is disabled. Guard here in case a request slips past the
// route-level short-circuit.
if (settings.mode === "off") {
throw new HttpError(
400,
"The AI assistant is turned off. Turn it on in Settings → AI.",
);
}
const requested = providerForModel(requestedModelId);
// In api/auto mode, find a cloud provider that actually has a key. Auto with
// no key (and api mode's remaining fall-through) drops to local Ollama below.
const provider =
settings.mode === "api" || settings.mode === "auto"
? chooseProvider(settings, requested)
: null;
// Local when: explicit local mode, the local sentinel was picked, or auto with
// no configured cloud key. → Ollama's OpenAI-compatible endpoint.
if (
settings.mode === "local" ||
requestedModelId === OLLAMA_SENTINEL ||
(settings.mode === "auto" && !provider)
) {
const ollama = createOpenAICompatible({
name: "ollama",
baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`,
@@ -82,11 +104,7 @@ export function resolveModel(
};
}
// API mode. Pick a provider that actually has a key, falling back gracefully
// so a configured key (e.g. Gemini) is used even if the picked model belongs
// to a different, unconfigured provider.
const requested = providerForModel(requestedModelId);
const provider = chooseProvider(settings, requested);
// API mode with a picked cloud model but no matching/any key.
if (!provider) {
throw new HttpError(
400,
+2
View File
@@ -21,6 +21,7 @@ function toInventoryItem(row: InventoryRow): InventoryItem {
stockQuantity: row.stockQuantity,
reorderThreshold: row.reorderThreshold,
location: row.location,
barcode: row.barcode,
expiresAt: row.expiresAt,
notes: row.notes,
createdAt: row.createdAt.toISOString(),
@@ -38,6 +39,7 @@ function columns(orgId: string, input: InventoryInput, createdBy?: string) {
stockQuantity: input.stockQuantity,
reorderThreshold: input.reorderThreshold,
location: input.location,
barcode: input.barcode ?? null,
expiresAt: input.expiresAt ?? null,
notes: input.notes ?? null,
...(createdBy ? { createdBy } : {}),
+11 -1
View File
@@ -40,7 +40,17 @@ export function sendToWallet(
event: string,
data: unknown,
): void {
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
const hub = hubs.get(orgId);
if (!hub) {
// No live hub means the push is dropped on the floor. The row still sits
// pending and `wallet:online` replays it once a device reconnects, but say
// so — a silent no-op here looks exactly like a wallet that ignored us.
console.warn(
`Temetro Network: no hub connection for clinic ${orgId}; "${event}" not sent (queued for wallet:online replay).`,
);
return;
}
hub.emit("wallet:send", { walletNumber, event, data });
}
// Tell the relay to expect a device response for `requestId` and route it back
+64 -12
View File
@@ -15,8 +15,10 @@ import {
} from "../lib/wallet-crypto.js";
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
import { listAppointments } from "./appointments.js";
import { listAttachments } from "./attachments.js";
import { listInvoices } from "./invoices.js";
import { getPatient } from "./patients.js";
import { listPrescriptions } from "./prescriptions.js";
import { signWithClinicKey } from "./signing.js";
type UpdateRow = typeof walletRecordUpdates.$inferSelect;
@@ -24,8 +26,15 @@ type UpdateRow = typeof walletRecordUpdates.$inferSelect;
// The payload the relay pushes to a wallet. `sealed` is the encrypted patient
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
// provenance (TOFU pin) before applying.
//
// `clinicId` is the stable org id. The wallet pins a clinic's signing key
// against it — pinning against the mutable `clinicName` meant every unnamed org
// collided on the "A clinic" fallback and tripped a bogus "key changed" warning.
// It also doubles as the relay routing id the wallet needs to fetch document
// bytes over the portal.
export type WalletUpdateEvent = {
requestId: string;
clinicId: string;
clinicName: string;
sealed: string;
signature: string;
@@ -119,22 +128,49 @@ export async function createRecordUpdate(
const patient = await getPatient(orgId, fileNumber);
if (!patient) throw new HttpError(404, "Patient not found.");
// Appointments and invoices live in their own tables (not on the Patient
// snapshot), so pull the ones for this patient and ship them alongside — the
// wallet has no other way to see them and they'd otherwise silently vanish.
const [orgAppointments, orgInvoices] = await Promise.all([
listAppointments(orgId),
listInvoices(orgId),
]);
// Appointments, invoices and prescriptions live in their own tables (not on
// the Patient snapshot), so pull the ones for this patient and ship them
// alongside — the wallet has no other way to see them and they'd otherwise
// silently vanish. `patient.medications` comes from `patient_medications`,
// which `createPrescription` never writes to, so a prescription that isn't
// shipped here would leave the approved bundle identical to what the wallet
// already had.
// Attachments (files/documents) are shipped as metadata; the bytes stay on the
// clinic and the wallet fetches them on demand over the portal `result-file`
// action, keyed by the same attachment id shipped here.
const [orgAppointments, orgInvoices, orgPrescriptions, attachmentRows] =
await Promise.all([
listAppointments(orgId),
listInvoices(orgId),
listPrescriptions(orgId),
listAttachments(orgId, fileNumber),
]);
const appointments = orgAppointments.filter(
(a) => a.fileNumber === fileNumber,
);
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
const prescriptions = orgPrescriptions.filter(
(p) => p.fileNumber === fileNumber,
);
const documents = attachmentRows.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
sizeBytes: a.sizeBytes,
createdAt: a.createdAt,
}));
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient` (+ appointments/invoices).
// replaces its on-device record with `patient` (+ the sibling-table lists).
const bundle = utf8ToBytes(
JSON.stringify({ patient, appointments, invoices, changes }),
JSON.stringify({
patient,
appointments,
invoices,
prescriptions,
documents,
changes,
}),
);
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
@@ -165,6 +201,7 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
.where(eq(organization.id, row.organizationId));
return {
requestId: row.id,
clinicId: row.organizationId,
clinicName: org?.name ?? "A clinic",
sealed: row.payloadSealed,
signature: row.clinicSignature,
@@ -223,9 +260,24 @@ export async function applyUpdateResponse(
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.id, requestId));
if (!row || row.resolvedAt) return null;
if (row.walletNumber !== walletNumber.trim()) return null;
if (!signatureHex) return null;
// These all mean the patient tapped Approve and nothing happened, so say so —
// silently returning null here is indistinguishable from a lost connection.
if (!row) {
console.warn(`Wallet update ${requestId}: no such update row.`);
return null;
}
if (row.resolvedAt) {
console.warn(`Wallet update ${requestId}: already resolved.`);
return null;
}
if (row.walletNumber !== walletNumber.trim()) {
console.warn(`Wallet update ${requestId}: wallet number did not match.`);
return null;
}
if (!signatureHex) {
console.warn(`Wallet update ${requestId}: response carried no signature.`);
return null;
}
const publicKey = decodeWalletNumber(walletNumber);
const message = utf8ToBytes(`${decision}:${requestId}`);
+6 -2
View File
@@ -2,8 +2,12 @@
// AI panel. The chat agent reads these to decide which provider/model to call
// and how strict the Veil de-identification safeguard should be.
// Two inference modes: a user-provided cloud API key, or a local Ollama model.
export type AiMode = "api" | "local";
// Inference modes:
// api — a user-provided cloud API key
// local — a local Ollama model
// auto — auto-pick: use a cloud key when one is set, else fall back to local
// off — the assistant is disabled
export type AiMode = "api" | "local" | "auto" | "off";
// The three supported cloud providers for API-key mode.
export type ApiProvider = "openai" | "anthropic" | "gemini";
+1
View File
@@ -11,6 +11,7 @@ export type InventoryItem = {
stockQuantity: number;
reorderThreshold: number;
location: string;
barcode: string | null;
expiresAt: string | null; // YYYY-MM-DD
notes: string | null;
createdAt: string;
+3
View File
@@ -7,3 +7,6 @@ npm-debug.log*
.DS_Store
Dockerfile
.dockerignore
tsconfig.tsbuildinfo
CLAUDE.md
AGENTS.md
+8 -6
View File
@@ -12,8 +12,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`temetro` — an **open-source** clinical "AI middleman" (see the project vision in the root
`../CLAUDE.md`). This app is the clinician-facing chat UI. It is now **wired to the real
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo); the AI
chat replies are **still mocked** (no LLM call yet).
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo). The AI
chat is **real**: `components/chat/chat-panel.tsx` streams from the backend's tool-using agent
(`POST /api/chat`) via `useChat` and renders the record data parts it streams back.
- The chat parses `/patient <file#>` (or a bare `/<file#>`) in `components/chat/chat-panel.tsx` and
renders the record as a horizontal row of cards (`components/chat/patient-cards.tsx`), with small
@@ -21,7 +22,7 @@ chat replies are **still mocked** (no LLM call yet).
dialog; the Summary card has an **Edit record** button.
- Patients can be **created and edited** via the shared `components/chat/patient-form-dialog.tsx`
(mode `create` | `edit`). The "Add patient" pill in `chat-input.tsx` opens it.
- Non-command messages still get a mock assistant reply.
- Non-command messages stream a real reply from the backend agent (`POST /api/chat`).
### Auth & data (talks to `../backend`)
@@ -42,7 +43,8 @@ chat replies are **still mocked** (no LLM call yet).
`components/settings/settings-care-team.tsx` manages members + invitations.
- `.env.local` / `.env.example` hold `NEXT_PUBLIC_API_URL`.
The signing / patient-owned-storage / approval features from the root vision are **still not built**.
The signing / patient-owned-storage / approval features from the root vision **are built** (clinic
signing key, encrypted share/import, patient approval, clinic→wallet update push, QR pairing).
## Commands
@@ -77,8 +79,8 @@ trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit poli
`components.json` (baseColor `neutral`).
- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`,
`Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`,
`ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state
is managed with local React state.
`ChatStatus`, `FileUIPart`). `@ai-sdk/react` (`useChat`) **is installed** and drives the live chat
(`chat-panel.tsx`) with a custom transport pointed at `POST /api/chat`.
- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` /
`SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav config (New chat
· Patients · Settings) + notifications; `team-switcher.tsx` is the `OrgSwitcher` (clinic switch).
@@ -212,7 +212,7 @@ export function ActivityView() {
<button
className={cn(
"-mx-2 flex-1 rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
"-mx-2 flex-1 cursor-pointer rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
isLast ? "pb-1" : "mb-5",
)}
onClick={() => setSelected(entry)}
@@ -369,15 +369,18 @@ export const AttachmentRemove = ({
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard> & {
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
// back-compat but no longer forwarded to the Root.
openDelay?: number;
closeDelay?: number;
};
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
openDelay,
closeDelay,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
}: AttachmentHoverCardProps) => <HoverCard {...props} />;
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
+1 -1
View File
@@ -56,7 +56,7 @@ export const Context = ({
return (
<ContextContext.Provider value={contextValue}>
<HoverCard closeDelay={0} openDelay={0} {...props} />
<HoverCard {...props} />
</ContextContext.Provider>
);
};
@@ -50,7 +50,7 @@ export const InlineCitationText = ({
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
<HoverCard {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
+14 -1
View File
@@ -128,5 +128,18 @@ export const PlanFooter = (props: PlanFooterProps) => (
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger render={<Button className={cn("size-8", className)} data-slot="plan-trigger" size="icon" variant="ghost" {...props} />}><ChevronsUpDownIcon className="size-4" /><span className="sr-only">Toggle plan</span></CollapsibleTrigger>
<CollapsibleTrigger
{...props}
render={
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
/>
}
>
<ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span>
</CollapsibleTrigger>
);
@@ -1311,15 +1311,18 @@ export const PromptInputSelectValue = ({
<SelectValue className={cn(className)} {...props} />
);
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard> & {
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
// back-compat but no longer forwarded to the Root.
openDelay?: number;
closeDelay?: number;
};
export const PromptInputHoverCard = ({
openDelay = 0,
closeDelay = 0,
openDelay,
closeDelay,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
}: PromptInputHoverCardProps) => <HoverCard {...props} />;
export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
@@ -107,7 +107,9 @@ export const SchemaDisplayPath = ({
<span
className={cn("font-mono text-sm", className)}
// oxlint-disable-next-line eslint-plugin-react(no-danger)
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
dangerouslySetInnerHTML={{
__html: typeof children === "string" ? children : highlightedPath,
}}
{...props}
/>
);
@@ -195,7 +195,7 @@ export function AddAppointmentDialog({
t("appointments.dialog.addedTitle"),
`${selected.name} · ${time}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.appointment", { date: keyOf(date), time }),
);
@@ -87,15 +87,18 @@ export function AppointmentDetailSheet({
const [status, setStatus] = useState<AppointmentStatus>("confirmed");
const [busy, setBusy] = useState(false);
// Seed the form from the selected appointment whenever it changes.
useEffect(() => {
if (!appt) return;
// Seed the form from the selected appointment whenever it changes. Adjusted
// during render rather than in an effect, so the sheet never paints one frame
// of the previous appointment's values.
const [prevAppt, setPrevAppt] = useState(appt);
if (appt && prevAppt !== appt) {
setPrevAppt(appt);
setDate(new Date(`${appt.date}T00:00:00`));
setTime(appt.time);
setType(appt.type);
setProvider(appt.provider);
setStatus(appt.status);
}, [appt]);
}
useEffect(() => {
if (!open) return;
@@ -1,7 +1,7 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -72,13 +72,22 @@ export function CalendarDialog({
});
const [selectedKey, setSelectedKey] = useState<string>(startKey);
// When opened via a deep-link date, jump the view to that month/day.
useEffect(() => {
if (!open || !initialDate || !/^\d{4}-\d{2}-\d{2}$/.test(initialDate)) return;
const d = parseKey(initialDate);
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
setSelectedKey(initialDate);
}, [open, initialDate]);
// When opened via a deep-link date, jump the view to that month/day. Adjusted
// during render rather than in an effect, so the calendar never paints the
// current month before jumping to the linked one.
const jumpTo =
open && initialDate && /^\d{4}-\d{2}-\d{2}$/.test(initialDate)
? initialDate
: null;
const [prevJumpTo, setPrevJumpTo] = useState<string | null>(null);
if (prevJumpTo !== jumpTo) {
setPrevJumpTo(jumpTo);
if (jumpTo) {
const d = parseKey(jumpTo);
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
setSelectedKey(jumpTo);
}
}
const byDate = useMemo(() => {
const map = new Map<string, Appointment[]>();
+51 -27
View File
@@ -1,6 +1,6 @@
"use client";
import { Sparkles, X } from "lucide-react";
import { TriangleAlert, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -8,12 +8,19 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { getAiConfig } from "@/lib/ai-settings";
// A single, dismissible heads-up shown above the chat input on a fresh chat when
// no AI provider is configured yet (no API key, and no local Ollama). It only
// renders on the empty state, so it naturally disappears once a message is sent.
// A thin, dismissible warning bar shown flush above the chat input whenever no
// AI provider is wired up yet — either no API key for any cloud provider, or the
// app is in local mode with no Ollama endpoint set. Without this, sending a
// message just fails silently, so the banner spells out the missing setup and
// links straight to AI settings. Rendered in both the empty and active chat
// states so a user mid-conversation with no provider still sees why replies fail.
// Which advisory (if any) to show: the setup nudge (no provider wired), or a
// notice that the assistant has been switched off.
type Notice = "setup" | "off" | null;
export function AiSetupNotice() {
const { t } = useTranslation();
const [needsSetup, setNeedsSetup] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
@@ -21,43 +28,60 @@ export function AiSetupNotice() {
getAiConfig()
.then((cfg) => {
if (!active) return;
// Configured = an API key for any provider, or a local Ollama endpoint.
if (cfg.mode === "off") {
setNotice("off");
return;
}
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
const hasLocal =
cfg.mode === "local" && cfg.ollamaBaseUrl.trim().length > 0;
setNeedsSetup(!(hasApiKey || hasLocal));
// Whether the assistant is actually wired up for the chosen mode:
// - api / auto → needs a cloud API key (auto silently falls back to
// local, but with no key we nudge the user to finish
// setup rather than depend on an unverified Ollama).
// - local → needs a non-empty Ollama endpoint (opted in).
const configured =
cfg.mode === "local"
? cfg.ollamaBaseUrl.trim().length > 0
: hasApiKey;
setNotice(configured ? null : "setup");
})
.catch(() => {
// If we can't read the config, don't nag — the chat still works.
if (active) setNeedsSetup(false);
if (active) setNotice(null);
});
return () => {
active = false;
};
}, []);
if (!needsSetup || dismissed) return null;
if (!notice || dismissed) return null;
const isOff = notice === "off";
return (
<div
className="flex w-full items-start gap-3 rounded-2xl border border-info/30 bg-info/8 px-4 py-3 text-sm dark:bg-info/12"
className="flex w-full items-center gap-2.5 rounded-2xl border border-warning/32 bg-warning/8 px-3.5 py-2 text-sm dark:bg-warning/12"
role="status"
>
<Sparkles className="mt-0.5 size-4 shrink-0 text-info-foreground" />
<div className="flex-1 space-y-0.5">
<p className="font-medium text-foreground">
{t("chat.setupNotice.title")}
</p>
<p className="text-muted-foreground">{t("chat.setupNotice.body")}</p>
<Button
className="mt-1 px-0 text-info-foreground"
render={<Link href="/settings?tab=ai" />}
size="sm"
variant="link"
>
{t("chat.setupNotice.action")}
</Button>
</div>
<TriangleAlert className="size-4 shrink-0 text-warning" />
<p className="min-w-0 flex-1 truncate text-foreground">
<span className="font-medium">
{isOff
? t("chat.setupNotice.offTitle")
: t("chat.setupNotice.title")}
</span>
<span className="text-muted-foreground max-sm:hidden">
{" — "}
{isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}
</span>
</p>
<Button
className="shrink-0"
render={<Link href="/settings?tab=ai" />}
size="sm"
variant="outline"
>
{t("chat.setupNotice.action")}
</Button>
<button
aria-label={t("chat.setupNotice.dismiss")}
className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+12 -5
View File
@@ -9,6 +9,7 @@ import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
@@ -78,17 +79,23 @@ export function ChatInput({
const [addKey, setAddKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
// first client render agree (button starts disabled, enabled by the effect).
const [speechSupported, setSpeechSupported] = useState(false);
// Voice dictation (Web Speech API). Read through useSyncExternalStore so SSR
// markup and the first client render agree: the server snapshot is `false`
// (no Speech API to detect), and the client re-reads on hydration. Support
// never changes for the life of the page, so the subscription is a no-op.
const speechSupported = useSyncExternalStore(
() => () => {},
() => getSpeechRecognition() !== null,
() => false,
);
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
// The textarea contents when dictation started; transcript is appended to it.
const dictationBaseRef = useRef("");
useEffect(() => {
setSpeechSupported(getSpeechRecognition() !== null);
return () => recognitionRef.current?.stop();
const recognition = recognitionRef;
return () => recognition.current?.stop();
}, []);
const toggleDictation = useCallback(() => {
+54 -6
View File
@@ -139,8 +139,14 @@ export function ChatPanel() {
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
// saved to the server after each exchange so history survives reloads.
const [threadId, setThreadId] = useState<string>(() => nanoid());
// Mirrored into a ref for the transport's fetch closure, which is memoized and
// must not be rebuilt per thread. Written in an effect rather than during
// render: a render can be thrown away or replayed, and mutating a ref there
// makes it observable — the write has to happen once the render is committed.
const threadIdRef = useRef(threadId);
threadIdRef.current = threadId;
useEffect(() => {
threadIdRef.current = threadId;
}, [threadId]);
// Skip the auto-save that would otherwise fire right after loading a thread
// (which would needlessly bump it to the top of the history).
const justLoadedRef = useRef(false);
@@ -188,7 +194,17 @@ export function ChatPanel() {
getAiConfig()
.then((cfg) => {
if (cancelled) return;
setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel);
// Seed the model to match the configured mode. In "auto" we prefer the
// user's cloud default when a key exists, else fall back to the local
// sentinel (mirrors the backend's provider resolution).
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
const seeded =
cfg.mode === "local"
? "ollama"
: cfg.mode === "auto" && !hasApiKey
? "ollama"
: cfg.defaultModel;
setModel(seeded);
setEffort(cfg.defaultEffort);
})
.catch(() => {
@@ -204,9 +220,11 @@ export function ChatPanel() {
// stays visible until acknowledged and isn't duplicated. Reset the dismissed
// flag whenever a fresh error arrives.
const [errorDismissed, setErrorDismissed] = useState(false);
useEffect(() => {
const [prevError, setPrevError] = useState(error);
if (prevError !== error) {
setPrevError(error);
if (error) setErrorDismissed(false);
}, [error]);
}
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
@@ -291,9 +309,16 @@ export function ChatPanel() {
);
// Drain the queue one message at a time whenever the chat returns to idle.
//
// This is the shape the rule exists to catch, but it's the shape we want: the
// trigger is the transport going idle — an external async system — and the
// dequeue has to happen in the same tick we hand the message to `send`, or a
// re-render could drain it twice. There's no render-phase equivalent, since
// `send` is a side effect.
useEffect(() => {
if (status !== "ready" || pendingConsent || queued.length === 0) return;
const [next, ...rest] = queued;
// eslint-disable-next-line react-hooks/set-state-in-effect
setQueued(rest);
if (next) void send(next.text, next.files);
}, [status, pendingConsent, queued, send]);
@@ -328,6 +353,10 @@ export function ChatPanel() {
// Open a saved thread from `/?thread=<id>` (sidebar history); a bare `/` starts
// a fresh chat. Driven by the URL so the sidebar links and "New chat" work.
//
// Stays an effect: the thread branch is an async fetch, and the fresh-chat
// branch mints an id with nanoid(), which is impure and so can't be adjusted
// during render — moving it there would just trade this for a purity error.
const requestedThread = searchParams.get("thread");
useEffect(() => {
if (requestedThread) {
@@ -357,6 +386,7 @@ export function ChatPanel() {
};
}
// No ?thread → fresh chat (e.g. after "New chat").
// eslint-disable-next-line react-hooks/set-state-in-effect
setThreadId(nanoid());
setMessages([]);
}, [requestedThread, setMessages]);
@@ -687,6 +717,22 @@ export function ChatPanel() {
</Badge>
);
}
// Fallback: a structured card the client doesn't recognize (e.g. a
// data part added or renamed on the backend). Surface a small
// placeholder so a written-but-unhandled result is never silently
// invisible. `data-step`/`data-source` intentionally render nothing
// here (they're consumed above), so skip them.
if (
part.type.startsWith("data-") &&
part.type !== "data-step" &&
part.type !== "data-source"
) {
return (
<Badge className="self-start" key={key} variant="outline">
{t("chat.card.unsupported")}
</Badge>
);
}
return null;
})}
@@ -723,8 +769,7 @@ export function ChatPanel() {
<div className="flex w-full flex-col gap-3">
{errorAlert}
{veilGate}
{/* One-time setup heads-up — only on the empty state, so it clears
itself once the first message is sent. */}
{/* Setup heads-up when no AI provider is configured. */}
<AiSetupNotice />
{promptInput}
<Suggestions className="justify-center pt-1">
@@ -761,6 +806,9 @@ export function ChatPanel() {
{errorAlert}
{veilGate}
{queuePanel}
{/* Also warn mid-conversation when no AI provider is configured, so
failing replies have a visible cause and a fix. */}
<AiSetupNotice />
{promptInput}
</div>
</div>
@@ -23,6 +23,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
import { PatientDetail } from "@/components/patients/patient-detail";
import { ROLE_LABELS } from "@/lib/access";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
@@ -239,6 +241,11 @@ export function PatientFormDialog({
isEdit && patient ? patient.fileNumber : generateFileNumber()
);
const [step, setStep] = useState<"form" | "wallet">("form");
// Editing an existing record shows Show/Add tabs: "Show" (default) is the
// read-only record; "Add" is the editable form with the Add buttons. Create
// and import-review keep the plain form (nothing to show yet).
const showTabs = isEdit && !isReview && Boolean(patient);
const [tab, setTab] = useState<"add" | "show">(showTabs ? "show" : "add");
// Only edits to an existing (non-review) record can sync to a wallet — a newly
// created patient has no wallet, and review mode stages an import.
@@ -396,7 +403,7 @@ export function PatientFormDialog({
t("patientForm.updatedTitle"),
t("patientForm.updatedBody", { name: saved.name }),
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setStep("wallet");
return;
}
@@ -453,10 +460,27 @@ export function PatientFormDialog({
/>
) : (
<form className="contents" onSubmit={handleSubmit}>
{showTabs && (
<div className="px-1 pt-1">
<Tabs
onValueChange={(value) => setTab(value as "add" | "show")}
value={tab}
>
<TabsList className="w-full">
<TabsTab value="add">{t("patientForm.tabs.add")}</TabsTab>
<TabsTab value="show">{t("patientForm.tabs.show")}</TabsTab>
</TabsList>
</Tabs>
</div>
)}
<DialogPanel
scrollFade={false}
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
>
{showTabs && tab === "show" && patient ? (
<PatientDetail patient={patient} />
) : (
<>
<Field label={t("patientForm.fileNumber")}>
<div className="flex items-center gap-2">
<Input
@@ -780,6 +804,8 @@ export function PatientFormDialog({
)}
<StagedFilesField onChange={setFiles} value={files} />
</>
)}
</DialogPanel>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
@@ -787,17 +813,19 @@ export function PatientFormDialog({
<p className="text-sm text-destructive sm:me-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
{t("patientForm.cancel")}
{tab === "show" ? t("patientForm.close") : t("patientForm.cancel")}
</DialogClose>
<Button disabled={!name.trim() || submitting} type="submit">
{submitting
? t("patientForm.saving")
: isReview
? t("patientForm.saveDraft")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
{tab !== "show" && (
<Button disabled={!name.trim() || submitting} type="submit">
{submitting
? t("patientForm.saving")
: isReview
? t("patientForm.saveDraft")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
)}
</DialogFooter>
</form>
)}
@@ -6,7 +6,7 @@
// array fields (invoice line items, inventory items) edited as add/remove rows.
import { Plus, X } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -196,10 +196,14 @@ export function RecordEditDialog({
const schema = EDIT_SCHEMAS[kind];
const [draft, setDraft] = useState<Rec>(record);
// Re-seed the draft whenever a fresh record is opened for editing.
useEffect(() => {
if (open) setDraft(record);
}, [open, record]);
// Re-seed the draft whenever a fresh record is opened for editing. Adjusted
// during render so the form never paints the previous record's values.
const [prevSeed, setPrevSeed] = useState<Rec | null>(null);
const seed = open ? record : null;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (seed) setDraft(seed);
}
const setField = (key: string, value: unknown) =>
setDraft((d) => ({ ...d, [key]: value }));
+1 -1
View File
@@ -75,7 +75,7 @@ const dotClass: Record<Kind, string> = {
// A round node with the label below it and hidden connection handles so edges
// meet the dot's centre cleanly. Memoized; it re-renders only when the hovered
// id changes (via context), never via React Flow re-measuring.
const RecordNode = memo(function RecordNode({ id, data }: NodeProps) {
const RecordNode = memo(function RecordNode({ data }: NodeProps) {
const { label, sub, kind, family } = data as RecordNodeData;
const hovered = useContext(HoverContext);
const focus: "active" | "dim" | null =
@@ -7,7 +7,7 @@ import {
Split,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
@@ -67,9 +67,14 @@ export function InvoiceDetailSheet({
const [count, setCount] = useState(3);
const [busy, setBusy] = useState(false);
useEffect(() => {
// Collapse the "show more" list back when a different invoice is shown.
// Adjusted during render so the sheet never paints the previous invoice's
// expanded state.
const [prevInvoiceId, setPrevInvoiceId] = useState(invoice?.id);
if (prevInvoiceId !== invoice?.id) {
setPrevInvoiceId(invoice?.id);
setCount(3);
}, [invoice?.id]);
}
if (!invoice) {
return (
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Checkbox } from "@/components/ui/checkbox";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
Dialog,
@@ -171,10 +172,14 @@ export function InvoiceFormDialog({
onOpenChange(next);
};
// Seed the form when opening.
useEffect(() => {
if (!open) return;
if (mode === "edit" && invoice) {
// Seed the form when opening. Adjusted during render rather than in an
// effect, so the dialog never paints one frame of the previous invoice.
// `false` means closed, so reopening on the same invoice re-seeds.
const [prevSeed, setPrevSeed] = useState<Invoice | null | false>(false);
const seed = open ? (invoice ?? null) : false;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (open && mode === "edit" && invoice) {
setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`));
// Existing invoices legitimately carry past issue dates.
setAllowBackdate(true);
@@ -185,7 +190,7 @@ export function InvoiceFormDialog({
setLineItems(
invoice.lineItems.length ? invoice.lineItems : [emptyLine()],
);
} else {
} else if (open) {
setSelected(null);
setIssuedAt(new Date());
setAllowBackdate(false);
@@ -195,7 +200,7 @@ export function InvoiceFormDialog({
setNotes("");
setLineItems([emptyLine()]);
}
}, [open, mode, invoice]);
}
// Load patients lazily for the create combobox.
useEffect(() => {
@@ -272,7 +277,7 @@ export function InvoiceFormDialog({
})
: await createInvoice(payload);
onSaved(saved);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
mode === "edit"
? t("walletSync.summary.invoiceUpdated", { number: saved.number })
@@ -367,11 +372,14 @@ export function InvoiceFormDialog({
<div className="flex flex-col gap-1.5">
<span className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
{t("invoices.dialog.issued")}
<label className="flex items-center gap-1 text-[11px]">
<input
<label
className="flex items-center gap-1.5 text-[11px]"
htmlFor="invoice-backdate"
>
<Checkbox
checked={allowBackdate}
onChange={(e) => setAllowBackdate(e.target.checked)}
type="checkbox"
id="invoice-backdate"
onCheckedChange={(checked) => setAllowBackdate(checked)}
/>
{t("invoices.dialog.backdate")}
</label>
@@ -385,11 +393,10 @@ export function InvoiceFormDialog({
<div className="flex flex-col gap-1.5">
<span className="flex items-center justify-between text-muted-foreground text-xs">
{t("invoices.dialog.due")}
<input
<Checkbox
aria-label={t("invoices.dialog.due")}
checked={hasDue}
onChange={(e) => setHasDue(e.target.checked)}
type="checkbox"
onCheckedChange={(checked) => setHasDue(checked)}
/>
</span>
{hasDue ? (
@@ -76,7 +76,6 @@ function VideoTile({
speaking ? "border-success" : "border-transparent",
)}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
autoPlay
className={cn("size-full object-cover", !showVideo && "invisible")}
+11 -10
View File
@@ -59,7 +59,6 @@ export function MeetingsView() {
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
// pre-targeted at that person so the user can connect with them.
const deepLinkWith = searchParams.get("with");
const openedDeepLink = useRef<string | null>(null);
const openedWith = useRef<string | null>(null);
const [tab, setTab] = useState<Tab>("rooms");
@@ -104,16 +103,18 @@ export function MeetingsView() {
};
}, []);
// Auto-join a room deep-linked from an invite (?room=).
useEffect(() => {
if (!deepLinkRoom || rooms.length === 0) return;
if (openedDeepLink.current === deepLinkRoom) return;
// Auto-join a room deep-linked from an invite (?room=). Resolved during
// render once the room list arrives, so the room opens on the same paint the
// list does instead of flashing the default tab first.
const [openedDeepLink, setOpenedDeepLink] = useState<string | null>(null);
if (deepLinkRoom && rooms.length > 0 && openedDeepLink !== deepLinkRoom) {
const room = rooms.find((r) => r.id === deepLinkRoom);
if (!room) return;
openedDeepLink.current = deepLinkRoom;
setTab("rooms");
setActiveRoom(room);
}, [deepLinkRoom, rooms]);
if (room) {
setOpenedDeepLink(deepLinkRoom);
setTab("rooms");
setActiveRoom(room);
}
}
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
useEffect(() => {
@@ -54,12 +54,21 @@ export function ScheduleMeetingDialog({
const [picked, setPicked] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
// Re-seed the form each time the dialog opens. Adjusted during render so it
// never paints the previous meeting's title or participants.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set(defaultParticipants ?? []));
}
}
useEffect(() => {
if (!open) return;
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set(defaultParticipants ?? []));
listClinicMembers()
.then(setMembers)
.catch(() => setMembers([]));
@@ -8,11 +8,17 @@ import { useEffect, useState } from "react";
export function useSpeaking(stream: MediaStream | null): boolean {
const [speaking, setSpeaking] = useState(false);
// Stop reporting "speaking" the moment the stream goes away or changes, so a
// muted tile can't keep a stale ring. Adjusted during render rather than in
// the effect below, which is only for driving the Web Audio graph.
const [prevStream, setPrevStream] = useState(stream);
if (prevStream !== stream) {
setPrevStream(stream);
setSpeaking(false);
}
useEffect(() => {
if (!stream || stream.getAudioTracks().length === 0) {
setSpeaking(false);
return;
}
if (!stream || stream.getAudioTracks().length === 0) return;
let ctx: AudioContext | null = null;
let raf = 0;
try {
@@ -245,10 +245,15 @@ export function MessagesView() {
const [appts, setAppts] = useState<Appointment[]>([]);
const [apptQuery, setApptQuery] = useState("");
// Refs so the socket handler (registered once) reads current values.
// Refs so the socket handler (registered once) reads current values. Written
// in an effect rather than during render: a render can be thrown away or
// replayed, and mutating a ref there makes it observable — the write has to
// happen once the render is committed.
const selectedIdRef = useRef<string | null>(null);
const myIdRef = useRef<string>("");
myIdRef.current = myId;
useEffect(() => {
myIdRef.current = myId;
}, [myId]);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -354,7 +359,6 @@ export function MessagesView() {
openedDeepLink.current = deepLinkConversation;
open(deepLinkConversation);
// `open` is stable enough for this one-shot; deps intentionally minimal.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deepLinkConversation, conversations]);
const send = (event: FormEvent) => {
+3
View File
@@ -61,6 +61,9 @@ export function NotesView() {
return () => {
active = false;
};
// `t` intentionally omitted: it is only read to build a failure message,
// and re-fetching on a language change would be pointless.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startNew = () => {
@@ -1,11 +1,12 @@
"use client";
import { Check, Loader2, QrCode, Smartphone, X } from "lucide-react";
import { Check, Loader2, QrCode, ScanLine, Smartphone, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { BarcodeScanner } from "@/components/scan/barcode-scanner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -63,6 +64,15 @@ export function ImportFromWalletDialog({
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("number");
const [walletNumber, setWalletNumber] = useState("");
const [scanOpen, setScanOpen] = useState(false);
// Scanning the wallet code shown on the patient's phone (QR or 2D barcode)
// drops its wallet number straight into the field.
const handleScan = (value: string) => {
setScanOpen(false);
setMode("number");
setWalletNumber(value.trim());
};
const [temporary, setTemporary] = useState(false);
const [durationHours, setDurationHours] = useState<number>(24);
const [phase, setPhase] = useState<Phase>("form");
@@ -79,8 +89,12 @@ export function ImportFromWalletDialog({
}
};
// Reset everything whenever the dialog is (re)opened.
useEffect(() => {
// Reset everything whenever the dialog is (re)opened. Adjusted during render
// so a reopened dialog never flashes the previous import's wallet number or
// state before clearing.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setMode("number");
setWalletNumber("");
@@ -92,8 +106,10 @@ export function ImportFromWalletDialog({
setPairUri(null);
setReviewOpen(false);
}
return stopPolling;
}, [open]);
}
// Stop polling when the dialog closes or unmounts.
useEffect(() => stopPolling, [open]);
// Poll the request until the patient approves/denies on their device.
useEffect(() => {
@@ -296,13 +312,25 @@ export function ImportFromWalletDialog({
<span className="text-xs text-muted-foreground">
{t("patients.importApp.walletLabel")}
</span>
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
<div className="flex items-center gap-2">
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
<Button
aria-label={t("patients.importApp.scan")}
disabled={phase === "requesting"}
onClick={() => setScanOpen(true)}
size="icon"
type="button"
variant="outline"
>
<ScanLine className="size-4" />
</Button>
</div>
</label>
) : (
<p className="text-sm text-muted-foreground">
@@ -405,6 +433,14 @@ export function ImportFromWalletDialog({
patient={request.draft}
/>
) : null}
<BarcodeScanner
description={t("patients.importApp.scanHint")}
onDetected={handleScan}
onOpenChange={setScanOpen}
open={scanOpen}
title={t("patients.importApp.scan")}
/>
</>
);
}
@@ -102,14 +102,29 @@ export function PatientDetailSheet({
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
// Clear the previous chart the moment a different one is opened, so the sheet
// can't show one patient's data under another's name while the fetch is in
// flight. Adjusted during render rather than in the effects below, which are
// only for the fetching itself. Keyed on the role too, so losing clinical
// access drops the wallet state instead of leaving the "Push update" button
// lit from the previous render.
const chart = open ? `${fileNumber ?? ""}|${role ?? ""}` : null;
const [prevChart, setPrevChart] = useState<string | null>(null);
if (prevChart !== chart) {
setPrevChart(chart);
setWalletLinked(false);
if (chart) {
setStatus("loading");
setPatient(null);
setPrescriptions([]);
setAppointments([]);
setInvoices([]);
}
}
useEffect(() => {
if (!open || !fileNumber) return;
let active = true;
setStatus("loading");
setPatient(null);
setPrescriptions([]);
setAppointments([]);
setInvoices([]);
getPatient(fileNumber)
.then((data) => {
if (!active) return;
@@ -138,9 +153,10 @@ export function PatientDetailSheet({
// Whether this patient is wallet-linked (drives the "Push update" button).
// Separate from the main load so it re-checks once the role resolves without
// refetching the record. Only clinicians can push.
// refetching the record. Only clinicians can push. The reset rides on the
// same render-phase clear as the chart above, so the button can't stay lit
// from the previous patient.
useEffect(() => {
setWalletLinked(false);
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
let active = true;
getWalletLink(fileNumber)
+119 -78
View File
@@ -2,12 +2,19 @@
import {
ArrowLeftRight,
CalendarDays,
FileDown,
type LucideIcon,
ListTodo,
Mic,
MoreHorizontal,
Network,
NotebookPen,
Pencil,
Pill,
Send,
Trash2,
UserRound,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -15,10 +22,21 @@ import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
import { AttachmentsSection } from "@/components/patients/patient-files";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { type ActivityEntry, listPatientActivity } from "@/lib/activity";
import {
type ActivityEntityType,
type ActivityEntry,
listPatientActivity,
} from "@/lib/activity";
import { printPatientSummary } from "@/lib/patient-pdf";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
import {
Dialog,
DialogClose,
@@ -122,8 +140,18 @@ function TrendBlock({ trend }: { trend: Trend }) {
);
}
// Which icon marks each kind of audited change on the timeline.
const historyIcon: Record<ActivityEntityType, LucideIcon> = {
appointment: CalendarDays,
note: NotebookPen,
patient: UserRound,
prescription: Pill,
task: ListTodo,
};
// The patient's record history: every audited add/change on this chart, newest
// first. Reuses the clinic activity log scoped to this file number.
// first. Reuses the clinic activity log scoped to this file number, laid out as
// a vertical timeline — who made the change, what happened, and when.
function RecordHistory({ fileNumber }: { fileNumber: string }) {
const { t } = useTranslation();
const [entries, setEntries] = useState<ActivityEntry[] | null>(null);
@@ -152,24 +180,40 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
{t("patientCard.history.empty")}
</p>
) : (
<ol className="flex flex-col gap-3">
{entries.map((e) => (
<li className="flex items-start gap-3" key={e.id}>
<Avatar className="mt-0.5 size-7 shrink-0">
<AvatarFallback className="text-[11px]">
{e.actorInitials}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-foreground text-sm">
<span className="font-medium">{e.actorName}</span> {e.action}
</span>
<span className="text-muted-foreground text-xs">
{new Date(e.createdAt).toLocaleString()}
</span>
</div>
</li>
))}
// A plain vertical rail so EVERY audited change renders in full — the
// icon column draws a connector that stretches to the next entry, and
// the flex layout mirrors correctly under RTL.
<ol className="space-y-0">
{entries.map((e, i) => {
const Icon = historyIcon[e.entityType] ?? Pencil;
const isLast = i === entries.length - 1;
return (
<li className="flex gap-3" key={e.id}>
<div className="flex flex-col items-center">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Icon size={14} />
</span>
{!isLast && (
<span
aria-hidden="true"
className="my-1 w-px flex-1 bg-primary/15"
/>
)}
</div>
<div
className={`min-w-0 flex-1 ${isLast ? "" : "pb-5"}`}
>
<p className="font-medium text-foreground text-sm">
{e.actorName}
</p>
<p className="text-muted-foreground text-sm">{e.action}</p>
<time className="mt-0.5 block font-medium text-muted-foreground text-xs">
{new Date(e.createdAt).toLocaleString()}
</time>
</div>
</li>
);
})}
</ol>
)}
</Section>
@@ -259,6 +303,8 @@ export function PatientDetail({
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{/* Name, status, and the overflow menu share one left-aligned row;
Edit now lives inside the menu rather than as a separate button. */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-base text-foreground">
{patient.name}
@@ -266,6 +312,59 @@ export function PatientDetail({
<Badge variant={statusVariant[patient.status]}>
{t(`patients.status.${patient.status}`)}
</Badge>
<Menu>
<MenuTrigger
render={
<Button
aria-label={t("patientCard.moreActions")}
size="icon-sm"
type="button"
variant="outline"
/>
}
>
<MoreHorizontal className="size-4" />
</MenuTrigger>
<MenuPopup align="start">
{onEdit && (
<MenuItem onClick={onEdit}>
<Pencil className="size-4" />
{t("patientCard.edit")}
</MenuItem>
)}
<MenuItem onClick={() => printPatientSummary(patient, t)}>
<FileDown className="size-4" />
{t("patientCard.exportPdf")}
</MenuItem>
{onScribe && (
<MenuItem onClick={onScribe}>
<Mic className="size-4" />
{t("scribe.recordVisit")}
</MenuItem>
)}
{onTransfer && (
<MenuItem onClick={onTransfer}>
<ArrowLeftRight className="size-4" />
{t("patients.transfer.action")}
</MenuItem>
)}
{onWalletPush && (
<MenuItem onClick={onWalletPush}>
<Send className="size-4" />
{t("walletPush.action")}
</MenuItem>
)}
{onDelete && (
<>
<MenuSeparator />
<MenuItem onClick={onDelete} variant="destructive">
<Trash2 className="size-4" />
{t("patients.delete.action")}
</MenuItem>
</>
)}
</MenuPopup>
</Menu>
</div>
<span className="text-muted-foreground text-sm">{idLine}</span>
{patient.alerts.length > 0 && (
@@ -279,64 +378,6 @@ export function PatientDetail({
)}
</div>
</div>
{/* Actions — their own wrapping row beneath the identity. */}
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => printPatientSummary(patient, t)}
size="sm"
type="button"
variant="outline"
>
<FileDown className="size-4" />
{t("patientCard.exportPdf")}
</Button>
{onTransfer && (
<Button
onClick={onTransfer}
size="sm"
type="button"
variant="outline"
>
<ArrowLeftRight className="size-4" />
{t("patients.transfer.action")}
</Button>
)}
{onScribe && (
<Button onClick={onScribe} size="sm" type="button" variant="outline">
<Mic className="size-4" />
{t("scribe.recordVisit")}
</Button>
)}
{onEdit && (
<Button onClick={onEdit} size="sm" type="button" variant="outline">
<Pencil className="size-4" />
{t("patientCard.edit")}
</Button>
)}
{onWalletPush && (
<Button
onClick={onWalletPush}
size="sm"
type="button"
variant="outline"
>
<Send className="size-4" />
{t("walletPush.action")}
</Button>
)}
{onDelete && (
<Button
aria-label={t("patients.delete.action")}
className="ms-auto"
onClick={onDelete}
size="sm"
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</div>
<Section title={t("patientCard.overview")}>
+159 -63
View File
@@ -1,6 +1,6 @@
"use client";
import { Plus, Search, Smartphone } from "lucide-react";
import { MoreHorizontal, Plus, Search, Smartphone } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,10 +11,40 @@ import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardFrame } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ListPagination } from "@/components/ui/list-pagination";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { listPatients, type Patient } from "@/lib/patients";
type StatusFilter = "all" | Patient["status"];
const STATUS_FILTERS: StatusFilter[] = [
"all",
"active",
"inpatient",
"discharged",
];
// Rows shown per page on the patients table before paginating.
const PAGE_SIZE = 10;
@@ -32,6 +62,7 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
export function PatientsView() {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [addOpen, setAddOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
// Bumped on open so the create dialog remounts with a fresh file # / form.
@@ -45,9 +76,10 @@ export function PatientsView() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// Runs once on mount, and `loading` already starts true, so there's nothing
// to flip synchronously here.
useEffect(() => {
let active = true;
setLoading(true);
listPatients()
.then((data) => {
if (!active) return;
@@ -66,12 +98,24 @@ export function PatientsView() {
return () => {
active = false;
};
// `t` intentionally omitted: it is only read to build a failure message,
// and re-fetching on a language change would be pointless.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const q = query.trim().toLowerCase();
const patients = allPatients.filter(
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
);
// Search matches name, MRN, problem/condition labels, and allergy substances;
// the status filter narrows to one clinical status.
const patients = allPatients.filter((p) => {
if (statusFilter !== "all" && p.status !== statusFilter) return false;
if (!q) return true;
return (
p.name.toLowerCase().includes(q) ||
p.fileNumber.includes(q) ||
p.problems.some((problem) => problem.label.toLowerCase().includes(q)) ||
p.allergies.some((a) => a.substance.toLowerCase().includes(q))
);
});
// Client-side pagination over the filtered list (10/page). Searching resets to
// the first page (done in the search handler); `page` is clamped at render so a
@@ -133,15 +177,6 @@ export function PatientsView() {
value={query}
/>
</div>
<Button
className="rounded-3xl"
onClick={() => setImportOpen(true)}
type="button"
variant="outline"
>
<Smartphone className="size-4" />
{t("patients.importFromApp")}
</Button>
<Button
className="rounded-3xl"
onClick={() => {
@@ -153,58 +188,119 @@ export function PatientsView() {
<Plus className="size-4" />
{t("patients.add")}
</Button>
{/* Secondary actions tuck into an overflow menu so the toolbar keeps a
single primary CTA. */}
<Menu>
<MenuTrigger
render={
<Button
aria-label={t("patients.moreActions")}
className="rounded-full"
size="icon"
type="button"
variant="outline"
/>
}
>
<MoreHorizontal className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem onClick={() => setImportOpen(true)}>
<Smartphone className="size-4" />
{t("patients.importFromApp")}
</MenuItem>
</MenuPopup>
</Menu>
</div>
</div>
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
<table className="w-full text-sm">
<thead>
<tr className="border-border border-b text-start text-xs text-muted-foreground uppercase">
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
<th className="px-4 py-3 font-medium">
{/* Filter sits on its own row just above the table so the toolbar stays a
clean title + search + primary actions cluster. */}
<div className="mt-8 flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">
{t("patients.filterLabel")}
</span>
<Select
onValueChange={(value) => {
setStatusFilter((value ?? "all") as StatusFilter);
setPage(1);
}}
value={statusFilter}
>
<SelectTrigger
aria-label={t("patients.filterStatus")}
className="w-40"
>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{STATUS_FILTERS.map((value) => (
<SelectItem key={value} value={value}>
{value === "all"
? t("patients.allStatuses")
: t(`patients.status.${value}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<CardFrame className="mt-4 w-full">
<Table variant="card">
<TableHeader>
<TableRow>
<TableHead className="ps-4 text-xs uppercase">
{t("patients.columns.name")}
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.mrn")}
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.ageSex")}
</th>
<th className="px-4 py-3 font-medium">
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.status")}
</th>
<th className="px-4 py-3 font-medium">
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.lastSeen")}
</th>
<th className="px-4 py-3 font-medium">
</TableHead>
<TableHead className="pe-4 text-xs uppercase">
{t("patients.columns.allergies")}
</th>
</tr>
</thead>
<tbody>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<tr>
<td
className="px-4 py-10 text-center text-muted-foreground"
<TableRow>
<TableCell
className="py-10 text-center text-muted-foreground"
colSpan={6}
>
{t("patients.loading")}
</td>
</tr>
</TableCell>
</TableRow>
) : loadError ? (
<tr>
<td className="px-4 py-10 text-center text-destructive" colSpan={6}>
<TableRow>
<TableCell
className="py-10 text-center text-destructive"
colSpan={6}
>
{loadError}
</td>
</tr>
</TableCell>
</TableRow>
) : patients.length === 0 ? (
<tr>
<td
className="px-4 py-10 text-center text-muted-foreground"
<TableRow>
<TableCell
className="py-10 text-center text-muted-foreground"
colSpan={6}
>
{t("patients.empty")}
</td>
</tr>
</TableCell>
</TableRow>
) : (
pageRows.map((p) => (
<tr
className="cursor-pointer border-border/50 border-b transition-colors last:border-0 hover:bg-accent/50"
<TableRow
className="cursor-pointer"
key={p.fileNumber}
onClick={() => open(p.fileNumber)}
onKeyDown={(event) => {
@@ -216,7 +312,7 @@ export function PatientsView() {
role="button"
tabIndex={0}
>
<td className="px-4 py-3 font-medium text-foreground">
<TableCell className="ps-4 py-3 font-medium text-foreground">
<span className="flex items-center gap-2">
{p.name}
<AiBadge source={p.source} />
@@ -226,30 +322,30 @@ export function PatientsView() {
</Badge>
) : null}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{p.fileNumber}
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{p.age} · {p.sex}
</td>
<td className="px-4 py-3">
</TableCell>
<TableCell className="py-3">
<Badge variant={statusVariant[p.status]}>
{t(`patients.status.${p.status}`)}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{p.encounters[0]?.date ?? "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="pe-4 py-3 text-muted-foreground">
{p.allergies.length || "—"}
</td>
</tr>
</TableCell>
</TableRow>
))
)}
</tbody>
</table>
</div>
</TableBody>
</Table>
</CardFrame>
{!loading && !loadError ? (
<ListPagination
@@ -233,7 +233,7 @@ export function ScribeDialog({
const updated = await saveNote(patient.fileNumber, draft);
notify.success(t("scribe.saved.title"), patient.name);
onSaved(updated);
if (sync.linked) {
if (await sync.ensureLinked()) {
setPhase("review");
setWalletStep(true);
} else {
@@ -44,10 +44,19 @@ export function TransferPatientDialog({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-seed the form each time the dialog opens. Adjusted during render so it
// never paints the previous patient's provider.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setProviderId(patient.primaryProviderId ?? "");
setError(null);
}
}
useEffect(() => {
if (!open) return;
setProviderId(patient.primaryProviderId ?? "");
setError(null);
let active = true;
listProviders()
.then((list) => active && setProviders(list))
@@ -1,6 +1,12 @@
"use client";
import { type FormEvent, type ReactNode, useState } from "react";
import {
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -15,6 +21,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { parseGs1 } from "@/lib/gs1";
import type { InventoryInput } from "@/lib/inventory";
import { notify } from "@/lib/toast";
@@ -48,7 +55,11 @@ export function AddInventoryDialog({
const [stockQuantity, setStockQuantity] = useState("0");
const [reorderThreshold, setReorderThreshold] = useState("0");
const [location, setLocation] = useState("");
const [barcode, setBarcode] = useState("");
const [expiresAt, setExpiresAt] = useState("");
// Lot/batch parsed from a GS1 scan. No dedicated field, so it rides along in
// notes (surfaced on the item detail).
const [notes, setNotes] = useState("");
const reset = () => {
setName("");
@@ -58,9 +69,76 @@ export function AddInventoryDialog({
setStockQuantity("0");
setReorderThreshold("0");
setLocation("");
setBarcode("");
setExpiresAt("");
setNotes("");
};
// A scanned medication barcode: store the code, and when it's a GS1
// DataMatrix, auto-fill expiry (AI 17) and lot (AI 10, -> notes).
const handleScan = useCallback(
(raw: string) => {
const gs1 = parseGs1(raw);
if (gs1) {
setBarcode(gs1.gtin ?? raw);
if (gs1.expiry) setExpiresAt(gs1.expiry);
if (gs1.lot) setNotes((n) => n || `Lot: ${gs1.lot}`);
} else {
setBarcode(raw.trim());
}
notify.success(
t("inventory.dialog.scannedTitle"),
gs1?.gtin ?? raw.trim(),
);
},
[t],
);
// Hardware barcode scanners are keyboard wedges: they "type" the code as a
// fast burst of keystrokes terminated by Enter. While the dialog is open we
// watch keydowns globally and, when a fast burst ends in Enter, treat the
// buffer as a scan — so the pharmacist can just scan a medication without
// first focusing any field. The timing gate (keys < 50ms apart) keeps
// ordinary typing out of the buffer; once a burst is detected we swallow the
// characters so they don't leak into the focused input.
useEffect(() => {
if (!open) return;
let buffer = "";
let lastTime = 0;
const MAX_GAP_MS = 50;
const MIN_LEN = 6;
const onKeyDown = (event: KeyboardEvent) => {
const now = Date.now();
const fast = now - lastTime <= MAX_GAP_MS;
lastTime = now;
if (event.key === "Enter") {
if (fast && buffer.length >= MIN_LEN) {
event.preventDefault();
event.stopPropagation();
handleScan(buffer);
}
buffer = "";
return;
}
if (
event.key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
buffer = fast ? buffer + event.key : event.key;
if (fast && buffer.length >= 2) event.preventDefault();
} else {
buffer = "";
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [open, handleScan]);
const submit = (event: FormEvent) => {
event.preventDefault();
const trimmed = name.trim();
@@ -79,7 +157,9 @@ export function AddInventoryDialog({
stockQuantity: Number.parseInt(stockQuantity, 10) || 0,
reorderThreshold: Number.parseInt(reorderThreshold, 10) || 0,
location: location.trim(),
barcode: barcode.trim() || null,
expiresAt: expiresAt || null,
notes: notes.trim() || null,
});
notify.success(t("inventory.dialog.addedTitle"), trimmed);
reset();
@@ -174,6 +254,27 @@ export function AddInventoryDialog({
/>
</Field>
</div>
<Field label={t("inventory.dialog.barcode")}>
<Input
inputMode="numeric"
onChange={(event) => setBarcode(event.target.value)}
// A hardware scanner sends Enter after the code; parse it here
// (instead of submitting the form) so a scan into this field is
// handled like the global wedge burst.
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (barcode.trim()) handleScan(barcode);
}
}}
placeholder={t("inventory.dialog.barcodePlaceholder")}
value={barcode}
/>
<span className="text-muted-foreground text-xs">
{t("inventory.dialog.scanHint")}
</span>
</Field>
</DialogPanel>
<DialogFooter>
@@ -109,6 +109,10 @@ export function InventoryDetailDialog({
label={t("inventory.dialog.expires")}
value={item.expiresAt || "—"}
/>
<Row
label={t("inventory.dialog.barcode")}
value={item.barcode || "—"}
/>
{item.notes ? (
<div className="flex flex-col gap-1 py-2">
<span className="text-muted-foreground text-sm">
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
Dialog,
DialogClose,
@@ -220,9 +221,19 @@ export function AddPrescriptionDialog({
.slice(0, 6);
}, [inventory, medication]);
// Keep the highlighted option in range as the result lists change.
useEffect(() => setActiveIndex(0), [query]);
useEffect(() => setMedIndex(0), [medication]);
// Keep the highlighted option in range as the result lists change. Adjusted
// during render rather than in an effect: React re-runs this render before
// committing, so the list never paints with a stale highlight.
const [prevQuery, setPrevQuery] = useState(query);
if (prevQuery !== query) {
setPrevQuery(query);
setActiveIndex(0);
}
const [prevMedication, setPrevMedication] = useState(medication);
if (prevMedication !== medication) {
setPrevMedication(medication);
setMedIndex(0);
}
const pickPatient = (p: Patient) => {
setSelected(p);
@@ -346,7 +357,7 @@ export function AddPrescriptionDialog({
t("prescriptions.dialog.addedTitle"),
`${medication.trim()} · ${selected.name}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.prescription", { drug: medication.trim() }),
);
@@ -578,19 +589,17 @@ export function AddPrescriptionDialog({
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={t("prescriptions.dialog.startDate")}>
<input
className={controlClass}
onChange={(event) => setStartDate(event.target.value)}
type="date"
<DatePicker
onChange={setStartDate}
placeholder={t("prescriptions.dialog.selectDate")}
value={startDate}
/>
</Field>
<Field label={t("prescriptions.dialog.endDate")}>
<input
className={controlClass}
<DatePicker
min={startDate || undefined}
onChange={(event) => setEndDate(event.target.value)}
type="date"
onChange={setEndDate}
placeholder={t("prescriptions.dialog.selectDate")}
value={endDate}
/>
</Field>
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
// Camera barcode/QR scanner in a dialog. Decoding uses @zxing/browser
// (lazy-loaded so it stays out of the initial bundle), which reads 1D barcodes
// (EAN/UPC/Code128) and 2D codes (QR, GS1 DataMatrix, PDF417) — the mix found
// on medication packaging and patient wallet codes. Emits the decoded string
// once, then the caller closes the dialog.
export function BarcodeScanner({
open,
onOpenChange,
onDetected,
title,
description,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onDetected: (value: string) => void;
title?: string;
description?: string;
}) {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
// Keep the latest onDetected without restarting the camera on every render.
const onDetectedRef = useRef(onDetected);
onDetectedRef.current = onDetected;
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let stopped = false;
let controls: { stop: () => void } | null = null;
setError(null);
(async () => {
const video = videoRef.current;
if (!video) return;
try {
const { BrowserMultiFormatReader } = await import("@zxing/browser");
const reader = new BrowserMultiFormatReader();
controls = await reader.decodeFromConstraints(
{ video: { facingMode: "environment" } },
video,
(result) => {
if (result && !stopped) {
stopped = true;
controls?.stop();
onDetectedRef.current(result.getText());
}
},
);
// The dialog may have closed while getUserMedia was resolving.
if (stopped) controls.stop();
} catch (err) {
setError(
err instanceof DOMException && err.name === "NotAllowedError"
? t("scan.permissionDenied")
: t("scan.unavailable"),
);
}
})();
return () => {
stopped = true;
controls?.stop();
};
}, [open, t]);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title ?? t("scan.title")}</DialogTitle>
<DialogDescription>
{description ?? t("scan.description")}
</DialogDescription>
</DialogHeader>
<DialogPanel>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<div className="relative aspect-video overflow-hidden rounded-2xl border bg-black">
{/* biome-ignore lint/a11y/useMediaCaption: live camera preview */}
<video
className="size-full object-cover"
muted
playsInline
ref={videoRef}
/>
<div className="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70" />
</div>
)}
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("scan.cancel")}
</DialogClose>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -8,7 +8,7 @@ import {
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -115,12 +115,19 @@ export function EmployeeDetailDialog({
const [confirmPw, setConfirmPw] = useState("");
const [savingPw, setSavingPw] = useState(false);
useEffect(() => {
// Re-seed when a different member (or their role/specialty) is shown, and
// always clear the password fields so they can't carry across. Adjusted
// during render rather than in an effect, so the dialog never paints another
// member's values — and a typed password never survives a switch.
const seed = `${member?.id ?? ""}|${member?.role ?? ""}|${member?.specialty ?? ""}`;
const [prevSeed, setPrevSeed] = useState(seed);
if (prevSeed !== seed) {
setPrevSeed(seed);
setRole(member?.role ?? "");
setSpecialty(member?.specialty ?? "");
setNewPw("");
setConfirmPw("");
}, [member?.id, member?.role, member?.specialty]);
}
const summary = rolePermissionSummary(member?.role);
const secondary = member?.username ? `@${member.username}` : member?.email;
+96 -70
View File
@@ -16,7 +16,7 @@ import {
import {
FieldLabel,
SettingsCard,
SettingsSection,
SettingsFrame,
ToggleRow,
} from "@/components/settings/settings-parts";
import {
@@ -41,7 +41,7 @@ const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
const VEIL_LEVELS: VeilLevel[] = ["full", "names", "off"];
const DEFAULTS: AiConfig = {
mode: "local",
mode: "auto",
provider: "anthropic",
ollamaBaseUrl: "http://localhost:11434",
ollamaModel: "llama3.1",
@@ -196,15 +196,26 @@ export function AIPanel() {
const keyIsSet = config.apiKeySet[config.provider];
// Which mode-hint copy to show under the Mode select.
const modeHintSuffix =
config.mode === "api"
? "Api"
: config.mode === "local"
? "Local"
: config.mode === "off"
? "Off"
: "Auto";
return (
<>
{policy ? (
<SettingsSection
<SettingsFrame
description={t("settings.ai.availability.description")}
separated
title={t("settings.ai.availability.title")}
>
{isAdmin ? (
<div className="space-y-3">
<>
<ToggleRow
checked={policy.aiEnabled}
description={t("settings.ai.availability.enabledHint")}
@@ -228,7 +239,7 @@ export function AIPanel() {
/>
) : null}
{policyDirty ? (
<div className="flex justify-end">
<SettingsCard className="flex flex-row justify-end p-4">
<Button
disabled={savingPolicy}
onClick={savePolicy}
@@ -238,11 +249,11 @@ export function AIPanel() {
? t("settings.ai.saving")
: t("settings.ai.saveChanges")}
</Button>
</div>
</SettingsCard>
) : null}
</div>
</>
) : (
<SettingsCard className="px-4 py-3.5">
<SettingsCard className="p-5">
<p className="text-sm text-muted-foreground">
{policy.aiEnabled
? policy.disabledForEmployees
@@ -252,46 +263,59 @@ export function AIPanel() {
</p>
</SettingsCard>
)}
</SettingsSection>
</SettingsFrame>
) : null}
<SettingsSection
<SettingsFrame
description={t("settings.ai.modeDescription")}
title={t("settings.ai.modeTitle")}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
<Select
onValueChange={(value) => set("mode", value as AiMode)}
value={config.mode}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectPopup>
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
<SelectItem value="local">
{t("settings.ai.modeLocal")}
</SelectItem>
</SelectPopup>
</Select>
<p className="text-xs text-muted-foreground">
{config.mode === "api"
? t("settings.ai.modeApiHint")
: t("settings.ai.modeLocalHint")}
</p>
</div>
<SettingsCard className="space-y-1.5 p-5">
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
<Select
onValueChange={(value) => set("mode", value as AiMode)}
value={config.mode}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectPopup>
<SelectItem value="auto">
{t("settings.ai.modeAuto")}
</SelectItem>
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
<SelectItem value="local">
{t("settings.ai.modeLocal")}
</SelectItem>
<SelectItem value="off">{t("settings.ai.modeOff")}</SelectItem>
</SelectPopup>
</Select>
<p className="text-xs text-muted-foreground">
{t(`settings.ai.mode${modeHintSuffix}Hint`)}
</p>
</SettingsCard>
</SettingsSection>
</SettingsFrame>
{config.mode === "api" ? (
<SettingsSection
{config.mode === "off" ? (
<SettingsFrame
description={t("settings.ai.offDescription")}
title={t("settings.ai.offTitle")}
>
<SettingsCard className="p-5">
<p className="text-sm text-muted-foreground">
{t("settings.ai.offNote")}
</p>
</SettingsCard>
</SettingsFrame>
) : config.mode !== "local" ? (
// API and Automatic both configure a cloud provider (Automatic falls
// back to local Ollama when no key is set).
<SettingsFrame
description={t("settings.ai.providerDescription")}
title={t("settings.ai.providerTitle")}
>
<SettingsCard className="space-y-5 p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
<Select
@@ -378,14 +402,14 @@ export function AIPanel() {
</Select>
</div>
</SettingsCard>
</SettingsSection>
</SettingsFrame>
) : (
<SettingsSection
<SettingsFrame
description={t("settings.ai.localDescription")}
title={t("settings.ai.localTitle")}
>
<SettingsCard className="space-y-5 p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.ollamaBaseUrl")}</FieldLabel>
<Input
@@ -415,39 +439,41 @@ export function AIPanel() {
: t("settings.ai.testConnection")}
</Button>
</SettingsCard>
</SettingsSection>
</SettingsFrame>
)}
<SettingsSection
description={t("settings.ai.veilDescription")}
title={t("settings.ai.veilTitle")}
>
<SettingsCard className="space-y-4 p-5">
{config.mode !== "off" ? (
<SettingsFrame
description={t("settings.ai.veilDescription")}
title={t("settings.ai.veilTitle")}
>
<SettingsCard className="space-y-4 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
<Select
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
value={config.veilLevel}
>
<SelectTrigger className="sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{VEIL_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{t(`settings.ai.veilLevels.${level}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{config.mode === "local"
? t("settings.ai.veilLocalNote")
: t("settings.ai.veilApiNote")}
</p>
</SettingsCard>
</SettingsSection>
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
<Select
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
value={config.veilLevel}
>
<SelectTrigger className="sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{VEIL_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{t(`settings.ai.veilLevels.${level}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{config.mode === "local"
? t("settings.ai.veilLocalNote")
: t("settings.ai.veilApiNote")}
</p>
</SettingsCard>
</SettingsFrame>
) : null}
{dirty ? (
<div className="sticky bottom-4 z-10">
@@ -176,7 +176,7 @@ export function SigningPanel() {
description={t("settings.network.description")}
title={t("settings.network.title")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-5">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-5">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
@@ -251,7 +251,7 @@ export function SigningPanel() {
description={t("settings.signing.backupDescription")}
title={t("settings.signing.backupTitle")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.signing.backupLabel")}
@@ -1,7 +1,7 @@
"use client";
import { Info, UserPlus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AddStaffDialog } from "@/components/settings/add-staff-dialog";
@@ -64,36 +64,47 @@ export function CareTeamPanel({
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null);
const [removing, setRemoving] = useState(false);
const load = useCallback(async () => {
try {
const data = await apiFetch<StaffMember[]>("/api/staff");
setMembers(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// The effect owns the fetch; `reload()` just bumps a key to re-run it. That
// keeps every setState inside a promise callback rather than running
// synchronously when the effect fires, and it drops the exhaustive-deps
// suppression the old useCallback needed.
const [reloadKey, setReloadKey] = useState(0);
const reload = () => setReloadKey((k) => k + 1);
useEffect(() => {
void load();
}, [load]);
let active = true;
apiFetch<StaffMember[]>("/api/staff")
.then((data) => {
if (!active) return;
setMembers(data);
setError(null);
})
.catch((err: unknown) => {
if (!active) return;
setError(
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
// `t` is intentionally omitted: re-fetching the staff list on a language
// change would be pointless, and the message is only read on failure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey]);
// Deep-link: open a specific member (e.g. from a password-reset system card).
const appliedDeepLink = useRef(false);
useEffect(() => {
if (appliedDeepLink.current || !initialMemberId || members.length === 0)
return;
// Resolved during render once the list arrives, so the member's dialog opens
// on the same paint the list does.
const [appliedDeepLink, setAppliedDeepLink] = useState(false);
if (!appliedDeepLink && initialMemberId && members.length > 0) {
setAppliedDeepLink(true);
const target = members.find((m) => m.userId === initialMemberId);
if (target) {
setSelected(target);
appliedDeepLink.current = true;
}
}, [initialMemberId, members]);
if (target) setSelected(target);
}
const myRole = members.find((m) => m.userId === session?.user?.id)?.role;
const canManage = myRole === "owner" || myRole === "admin";
@@ -119,22 +130,23 @@ export function CareTeamPanel({
}),
);
setPendingRemove(null);
void load();
reload();
};
return (
<SettingsSection
description={t("settings.careTeam.description")}
separated
title={t("settings.careTeam.title")}
>
{error && (
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
<SettingsCard className="bg-destructive/10 p-4">
<p className="text-sm text-destructive">{error}</p>
</SettingsCard>
)}
{canManage && (
<div className="flex items-center justify-between gap-4">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="size-3.5 shrink-0" />
{t("settings.careTeam.clickHint")}
@@ -143,10 +155,10 @@ export function CareTeamPanel({
<UserPlus className="size-4" />
{t("settings.careTeam.addMember")}
</Button>
</div>
</SettingsCard>
)}
<SettingsCard className="divide-y divide-border">
<SettingsCard className="divide-y divide-border overflow-hidden p-0">
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">
{t("settings.careTeam.loading")}
@@ -211,7 +223,7 @@ export function CareTeamPanel({
{canManage && (
<AddStaffDialog
onCreated={() => void load()}
onCreated={reload}
onOpenChange={setAdding}
open={adding}
/>
@@ -225,7 +237,7 @@ export function CareTeamPanel({
selected?.role !== "owner"
}
member={selected}
onChanged={() => void load()}
onChanged={reload}
onOpenChange={(o) => !o && setSelected(null)}
onRemove={(m) => {
setSelected(null);
+116 -20
View File
@@ -1,40 +1,130 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { createContext, useContext, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
Card,
CardFrame,
CardFrameAction,
CardFrameDescription,
CardFrameHeader,
CardFrameTitle,
} from "@/components/ui/card";
import { Frame, FramePanel } from "@/components/ui/frame";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
export function SettingsSection({
// Whether the enclosing frame is a COSS "Separated Panels" tray. When true,
// SettingsCard/ToggleRow render as a FramePanel (a distinct bordered card on the
// muted tray) instead of a fused CardFrame Card.
const SeparatedFrameContext = createContext(false);
// A settings section rendered inside the COSS "frame" surface: a titled header
// above one or more cards.
//
// Children are rendered as *direct* children of CardFrame on purpose. CardFrame
// styles its cards through direct-child selectors (`*:data-[slot=card]:-m-px`,
// the clip-path, `rounded-t/b-xl`, `shadow-none`, `before:hidden`) — it pulls
// each card out by a pixel so it sits flush inside the frame's own border. Put
// anything between the frame and the card, even an unstyled div, and every one
// of those selectors stops matching: the card keeps its own border and shadow
// and you get a box inside a box.
//
// So: no padding here. Body padding belongs on the Card (`<SettingsCard
// className="p-5">`), which is where COSS puts it.
export function SettingsFrame({
title,
description,
action,
children,
className,
separated = false,
}: {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
/**
* COSS "Separated Panels": render a muted Frame tray whose children are
* distinct bordered FramePanels spaced apart (the real coss.com/ui/frame
* look), instead of the fused CardFrame surface. Leave off for a joined list.
*/
separated?: boolean;
}) {
return (
<section className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
if (separated) {
return (
<Frame className={className}>
<div
className="flex items-start justify-between gap-4 px-4 py-3"
data-slot="frame-header"
>
<div className="flex flex-col gap-0.5">
<p className="text-base font-semibold">{title}</p>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
<SeparatedFrameContext.Provider value={true}>
{children}
</SeparatedFrameContext.Provider>
</Frame>
);
}
return (
<CardFrame className={className}>
<CardFrameHeader className="border-b border-border/60">
<CardFrameTitle className="text-base">{title}</CardFrameTitle>
{description ? (
<CardFrameDescription>{description}</CardFrameDescription>
) : null}
{action ? <CardFrameAction>{action}</CardFrameAction> : null}
</CardFrameHeader>
{children}
</section>
</CardFrame>
);
}
// Back-compat wrapper: existing panels compose with SettingsSection, which
// renders through the COSS frame surface so the whole settings page shares one
// framed look.
export function SettingsSection({
title,
description,
action,
children,
separated = false,
}: {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
/** See {@link SettingsFrame}'s `separated` — spaces sibling panels apart. */
separated?: boolean;
}) {
return (
<SettingsFrame
action={action}
description={description}
separated={separated}
title={title}
>
{children}
</SettingsFrame>
);
}
// A card surface used inside settings panels. Rendering a real COSS `Card` (with
// `data-slot="card"`) keeps settings cards consistent with the rest of the app
// and lets them pick up the frame's card treatment. `Card` is `flex flex-col`,
// so row layouts must pass `flex-row` in their className. It carries no padding
// of its own — pass `p-5` (the settings default) or a `divide-y` list.
export function SettingsCard({
className,
children,
@@ -42,11 +132,17 @@ export function SettingsCard({
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("rounded-2xl border border-border bg-card/30", className)}>
{children}
</div>
);
const separated = useContext(SeparatedFrameContext);
if (separated) {
// A distinct panel on the Frame tray. `flex flex-col` mirrors Card's default
// layout; callers that need a row (ToggleRow) override with `flex-row`.
return (
<FramePanel className={cn("flex flex-col", className)}>
{children}
</FramePanel>
);
}
return <Card className={className}>{children}</Card>;
}
export function ToggleRow({
@@ -64,7 +160,7 @@ export function ToggleRow({
onCheckedChange?: (checked: boolean) => void;
}) {
return (
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
<SettingsCard className="flex flex-row items-center justify-between gap-4 px-4 py-3.5">
<div className="space-y-0.5">
<p className="text-sm font-medium">{title}</p>
{description ? (
@@ -39,12 +39,18 @@ export function PatientPortalSection() {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
// Drop the previous clinic's QR as soon as the slug changes, rather than
// leaving it on screen until the new one arrives. Adjusted during render, so
// switching clinics never shows the old clinic's pairing code.
const [prevSlug, setPrevSlug] = useState(slug);
if (prevSlug !== slug) {
setPrevSlug(slug);
setQrUri("");
}
// Fetch the relay-based pairing descriptor for the QR (non-secret).
useEffect(() => {
if (!slug) {
setQrUri("");
return;
}
if (!slug) return;
let active = true;
getPortalLink(slug)
.then((link) => {
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import { Plus, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -26,6 +26,7 @@ import {
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import { SPECIALTIES, specialtyLabel } from "@/lib/staff";
import {
getSettings,
saveSettings,
@@ -60,8 +61,21 @@ const DEFAULT_PREFS: UserPreferences = {
"notif.recordsShared": true,
clinic: "",
contactEmail: "",
specialty: "",
// Professional links stored as a JSON array string (values are boolean|string).
links: "",
};
// Parse the stored `links` preference (a JSON array string) into an array.
function parseLinks(value: unknown): string[] {
try {
const parsed = JSON.parse(String(value || "[]"));
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
export function ProfilePanel() {
const { t, i18n } = useTranslation();
const { data: session } = authClient.useSession();
@@ -99,24 +113,38 @@ export function ProfilePanel() {
};
}, []);
// Seed the display name from the session once it loads.
useEffect(() => {
// Seed the display name from the session once it loads. Adjusted during
// render so the field isn't briefly empty after the session resolves. Still
// `prev ||`, so it never clobbers something the clinician has typed.
const [prevUserName, setPrevUserName] = useState(user?.name);
if (prevUserName !== user?.name) {
setPrevUserName(user?.name);
if (user?.name) {
setName((prev) => prev || user.name);
setBaselineName((prev) => prev || user.name);
}
}, [user?.name]);
}
const setPref = (key: string, value: boolean | string) =>
setPrefs((prev) => ({ ...prev, [key]: value }));
const links = parseLinks(prefs.links);
const setLinks = (next: string[]) =>
setPref("links", JSON.stringify(next));
const dirty =
name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline);
const save = async () => {
setSaving(true);
try {
const saved = await saveSettings(prefs);
// Drop blank link rows before persisting.
const cleanedLinks = links.map((l) => l.trim()).filter(Boolean);
const toSave: UserPreferences = {
...prefs,
links: cleanedLinks.length ? JSON.stringify(cleanedLinks) : "",
};
const saved = await saveSettings(toSave);
const trimmed = name.trim();
if (trimmed && trimmed !== baselineName) {
const { error } = await authClient.updateUser({ name: trimmed });
@@ -182,13 +210,31 @@ export function ProfilePanel() {
<div className="space-y-1.5">
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
<button
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
type="button"
<Select
onValueChange={(value) => setPref("specialty", value ?? "")}
value={String(prefs.specialty ?? "")}
>
{t("settings.profile.selectSpecialty")}
<ChevronDown className="size-4" />
</button>
<SelectTrigger>
<SelectValue>
{(value: string) =>
value ? (
specialtyLabel(t, value)
) : (
<span className="text-muted-foreground">
{t("settings.profile.selectSpecialty")}
</span>
)
}
</SelectValue>
</SelectTrigger>
<SelectPopup>
{SPECIALTIES.map((s) => (
<SelectItem key={s} value={s}>
{t(`settings.careTeam.specialties.${s}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
@@ -221,7 +267,43 @@ export function ProfilePanel() {
{t("settings.profile.professionalLinksHint")}
</p>
</div>
<Button className="rounded-lg" size="sm" variant="outline">
{links.length > 0 ? (
<div className="space-y-2">
{links.map((link, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rows are positional
<div className="flex items-center gap-2" key={index}>
<Input
inputMode="url"
onChange={(event) =>
setLinks(
links.map((value, i) =>
i === index ? event.target.value : value,
),
)
}
placeholder={t("settings.profile.linkPlaceholder")}
value={link}
/>
<Button
aria-label={t("settings.profile.removeLink")}
onClick={() =>
setLinks(links.filter((_, i) => i !== index))
}
size="icon-sm"
variant="ghost"
>
<X className="size-4" />
</Button>
</div>
))}
</div>
) : null}
<Button
className="rounded-lg"
onClick={() => setLinks([...links, ""])}
size="sm"
variant="outline"
>
<Plus className="size-4" />
{t("settings.profile.addLink")}
</Button>
@@ -257,47 +339,45 @@ export function ProfilePanel() {
<SettingsSection
description={t("settings.profile.patientNotificationsDescription")}
separated
title={t("settings.profile.patientNotifications")}
>
<div className="space-y-3">
{patientNotifications.map((item) => (
<ToggleRow
checked={Boolean(prefs[`notif.${item.titleKey}`])}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
onCheckedChange={(checked) =>
setPref(`notif.${item.titleKey}`, checked)
}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</div>
{patientNotifications.map((item) => (
<ToggleRow
checked={Boolean(prefs[`notif.${item.titleKey}`])}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
onCheckedChange={(checked) =>
setPref(`notif.${item.titleKey}`, checked)
}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</SettingsSection>
<SettingsSection
description={t("settings.profile.accountNotificationsDescription")}
separated
title={t("settings.profile.accountNotifications")}
>
<div className="space-y-3">
{accountNotifications.map((item) => (
<ToggleRow
checked={Boolean(prefs[`notif.${item.titleKey}`])}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
onCheckedChange={(checked) =>
setPref(`notif.${item.titleKey}`, checked)
}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</div>
{accountNotifications.map((item) => (
<ToggleRow
checked={Boolean(prefs[`notif.${item.titleKey}`])}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
onCheckedChange={(checked) =>
setPref(`notif.${item.titleKey}`, checked)
}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</SettingsSection>
<SettingsSection
description={t("settings.profile.dangerZoneDescription")}
title={t("settings.profile.dangerZone")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.profile.deleteAccount")}
+33 -1
View File
@@ -4,6 +4,7 @@ import {
Building2,
Check,
ChevronsUpDown,
Languages,
LogOut,
Moon,
Plus,
@@ -49,6 +50,8 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import { useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
@@ -82,7 +85,13 @@ function GitHubIcon({ className }: { className?: string }) {
// shortcut hint (below Theme) and a clinic switcher submenu (below that) whose
// popup reveals the active clinic's details on hover.
export function NavUser() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const activeLang = i18n.resolvedLanguage ?? i18n.language;
const changeLanguage = (lng: string) => {
if (lng === activeLang) return;
void i18n.changeLanguage(lng);
void persistLanguage(lng);
};
const { isMobile, state } = useSidebar();
const isCollapsed = state === "collapsed";
const router = useRouter();
@@ -197,6 +206,29 @@ export function NavUser() {
</MenuShortcut>
</MenuItem>
{/* Language: quick switch (also available in Settings). Applies
immediately and roams via the backend preferences. */}
<MenuSub>
<MenuSubTrigger>
<Languages />
<span className="truncate">{t("userMenu.language")}</span>
</MenuSubTrigger>
<MenuSubPopup className="min-w-44" sideOffset={8}>
{supportedLanguages.map((lng) => (
<MenuItem
className="gap-2"
key={lng}
onClick={() => changeLanguage(lng)}
>
<span className="flex-1 truncate">
{t(`settings.profile.language.${lng}`)}
</span>
{lng === activeLang && <Check className="size-4" />}
</MenuItem>
))}
</MenuSubPopup>
</MenuSub>
{/* Command palette: hint + shortcut, sits below Theme. */}
<MenuItem onClick={openCommand}>
<Search />
+12 -4
View File
@@ -103,18 +103,26 @@ function AddTaskDialog({
const [priority, setPriority] = useState<Priority>("medium");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
// Re-seed the column when the dialog is opened from a specific column, and
// load the clinic's providers so a task can be assigned to a specific person.
// Re-seed the column when the dialog is opened from a specific column.
// Adjusted during render rather than in an effect, so the select never paints
// with the previous column's value.
const [prevSeed, setPrevSeed] = useState<string | null>(null);
const seed = open ? initialStatus : null;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (seed) setStatus(seed);
}
// Load the clinic's providers so a task can be assigned to a specific person.
useEffect(() => {
if (!open) return;
setStatus(initialStatus);
listProviders()
.then((list) => {
setProviders(list);
setProviderId((id) => id || (list[0]?.userId ?? ""));
})
.catch(() => setProviders([]));
}, [open, initialStatus]);
}, [open]);
const reset = () => {
setTitle("");
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import type React from "react";
import { cn } from "@/lib/utils";
export function Checkbox({
className,
...props
}: CheckboxPrimitive.Root.Props): React.ReactElement {
return (
<CheckboxPrimitive.Root
className={cn(
"relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-[.25rem] border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none ring-ring transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[3px] not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-background aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 data-disabled:cursor-not-allowed data-disabled:opacity-64 sm:size-4 dark:not-data-checked:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
className,
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="absolute -inset-px flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground"
data-slot="checkbox-indicator"
render={(
props: React.ComponentProps<"span">,
state: CheckboxPrimitive.Indicator.State,
) => (
<span {...props}>
{state.indeterminate ? (
<svg
aria-hidden="true"
className="size-3.5 sm:size-3"
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.252 12h13.496" />
</svg>
) : (
<svg
aria-hidden="true"
className="size-3.5 sm:size-3"
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
</svg>
)}
</span>
)}
/>
</CheckboxPrimitive.Root>
);
}
export { CheckboxPrimitive };
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { CalendarIcon } from "lucide-react";
import type { Matcher } from "react-day-picker";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverPopup,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// Parse a `YYYY-MM-DD` string as a LOCAL date. `new Date("YYYY-MM-DD")` parses
// as UTC and can shift the day across timezones, so build it from parts.
function parseISODate(value?: string): Date | undefined {
if (!value) return undefined;
const [y, m, d] = value.split("-").map(Number);
if (!y || !m || !d) return undefined;
return new Date(y, m - 1, d);
}
function toISODate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export interface DatePickerProps {
/** Selected date as `YYYY-MM-DD` (matches native date-input semantics). */
value?: string;
onChange: (value: string) => void;
/** Earliest selectable date, `YYYY-MM-DD`. */
min?: string;
/** Latest selectable date, `YYYY-MM-DD`. */
max?: string;
placeholder?: string;
id?: string;
className?: string;
disabled?: boolean;
}
// A COSS date picker: an outline Button trigger opening a Popover with the COSS
// Calendar. Drop-in replacement for `<input type="date">`, keeping the same
// string value shape so callers don't change their state handling.
export function DatePicker({
value,
onChange,
min,
max,
placeholder,
id,
className,
disabled,
}: DatePickerProps): React.ReactElement {
const [open, setOpen] = useState(false);
const selected = parseISODate(value);
const minDate = parseISODate(min);
const maxDate = parseISODate(max);
const disabledMatchers: Matcher[] = [
...(minDate ? [{ before: minDate }] : []),
...(maxDate ? [{ after: maxDate }] : []),
];
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
render={
<Button
className={cn(
"w-full justify-between font-normal",
!selected && "text-muted-foreground",
className,
)}
disabled={disabled}
id={id}
variant="outline"
/>
}
>
{selected ? selected.toLocaleDateString() : (placeholder ?? "")}
<CalendarIcon className="opacity-80" />
</PopoverTrigger>
<PopoverPopup align="start" className="w-auto">
<Calendar
autoFocus
defaultMonth={selected ?? minDate}
disabled={disabledMatchers.length ? disabledMatchers : undefined}
mode="single"
onSelect={(date?: Date) => {
onChange(date ? toISODate(date) : "");
if (date) setOpen(false);
}}
selected={selected}
/>
</PopoverPopup>
</Popover>
);
}
+94
View File
@@ -0,0 +1,94 @@
import type React from "react";
import { cn } from "@/lib/utils";
// COSS "Frame" primitive (coss.com/ui/docs/components/frame). A Frame is a muted
// tray that holds one or more FramePanels; multiple panels are "Separated
// Panels" — spaced apart with `mt-1` by default (via the adjacent-sibling
// selector below), each a self-contained bordered card. This is the opposite of
// `CardFrame` in card.tsx, which fuses its cards into one flush surface.
export function Frame({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"relative flex flex-col rounded-2xl bg-muted/72 p-1",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
className,
)}
data-slot="frame"
{...props}
/>
);
}
export function FramePanel({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"relative rounded-xl border bg-background bg-clip-padding p-5 shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
className,
)}
data-slot="frame-panel"
{...props}
/>
);
}
export function FrameHeader({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("flex flex-col px-5 py-4", className)}
data-slot="frame-header"
{...props}
/>
);
}
export function FrameTitle({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("font-semibold text-sm", className)}
data-slot="frame-title"
{...props}
/>
);
}
export function FrameDescription({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-description"
{...props}
/>
);
}
export function FrameFooter({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("px-5 py-4", className)}
data-slot="frame-footer"
{...props}
/>
);
}
+293
View File
@@ -0,0 +1,293 @@
"use client";
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
import { Slot } from "radix-ui";
import * as React from "react";
import { createContext, useContext } from "react";
import { cn } from "@/lib/utils";
// Types
type StepperContextValue = {
activeStep: number;
setActiveStep: (step: number) => void;
orientation: "horizontal" | "vertical";
};
type StepItemContextValue = {
step: number;
state: StepState;
isDisabled: boolean;
isLoading: boolean;
};
type StepState = "active" | "completed" | "inactive" | "loading";
// Contexts
const StepperContext = createContext<StepperContextValue | undefined>(
undefined,
);
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined,
);
const useStepper = () => {
const context = useContext(StepperContext);
if (!context) {
throw new Error("useStepper must be used within a Stepper");
}
return context;
};
const useStepItem = () => {
const context = useContext(StepItemContext);
if (!context) {
throw new Error("useStepItem must be used within a StepperItem");
}
return context;
};
// Components
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: number;
value?: number;
onValueChange?: (value: number) => void;
orientation?: "horizontal" | "vertical";
}
function Stepper({
defaultValue = 0,
value,
onValueChange,
orientation = "horizontal",
className,
...props
}: StepperProps) {
const [activeStep, setInternalStep] = React.useState(defaultValue);
const setActiveStep = React.useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step);
}
onValueChange?.(step);
},
[value, onValueChange],
);
const currentStep = value ?? activeStep;
return (
<StepperContext.Provider
value={{
activeStep: currentStep,
orientation,
setActiveStep,
}}
>
<div
className={cn(
"group/stepper inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className,
)}
data-orientation={orientation}
data-slot="stepper"
{...props}
/>
</StepperContext.Provider>
);
}
// StepperItem
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number;
completed?: boolean;
disabled?: boolean;
loading?: boolean;
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper();
const state: StepState =
completed || step < activeStep
? "completed"
: activeStep === step
? "active"
: "inactive";
const isLoading = loading && step === activeStep;
return (
<StepItemContext.Provider
value={{ isDisabled: disabled, isLoading, state, step }}
>
<div
className={cn(
"group/step flex items-center group-data-[orientation=horizontal]/stepper:flex-row group-data-[orientation=vertical]/stepper:flex-col",
className,
)}
data-slot="stepper-item"
data-state={state}
{...(isLoading ? { "data-loading": true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
);
}
// StepperTrigger
interface StepperTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
function StepperTrigger({
asChild = false,
className,
children,
...props
}: StepperTriggerProps) {
const { setActiveStep } = useStepper();
const { step, isDisabled } = useStepItem();
if (asChild) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp className={className} data-slot="stepper-trigger">
{children}
</Comp>
);
}
return (
<button
className={cn(
"inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
className,
)}
data-slot="stepper-trigger"
disabled={isDisabled}
onClick={() => setActiveStep(step)}
type="button"
{...props}
>
{children}
</button>
);
}
// StepperIndicator
interface StepperIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
function StepperIndicator({
asChild = false,
className,
children,
...props
}: StepperIndicatorProps) {
const { state, step, isLoading } = useStepItem();
return (
<span
className={cn(
"relative flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs data-[state=active]:bg-primary data-[state=completed]:bg-primary data-[state=active]:text-primary-foreground data-[state=completed]:text-primary-foreground",
className,
)}
data-slot="stepper-indicator"
data-state={state}
{...props}
>
{asChild ? (
children
) : (
<>
<span className="transition-all group-data-[state=completed]/step:scale-0 group-data-loading/step:scale-0 group-data-[state=completed]/step:opacity-0 group-data-loading/step:opacity-0 group-data-loading/step:transition-none">
{step}
</span>
<CheckIcon
aria-hidden="true"
className="absolute scale-0 opacity-0 transition-all group-data-[state=completed]/step:scale-100 group-data-[state=completed]/step:opacity-100"
size={16}
/>
{isLoading && (
<span className="absolute transition-all">
<LoaderCircleIcon
aria-hidden="true"
className="animate-spin"
size={14}
/>
</span>
)}
</>
)}
</span>
);
}
// StepperTitle
function StepperTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("font-medium text-sm", className)}
data-slot="stepper-title"
{...props}
/>
);
}
// StepperDescription
function StepperDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn("text-muted-foreground text-sm", className)}
data-slot="stepper-description"
{...props}
/>
);
}
// StepperSeparator
function StepperSeparator({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"m-0.5 bg-muted group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=vertical]/stepper:h-12 group-data-[orientation=horizontal]/stepper:w-full group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=horizontal]/stepper:flex-1 group-data-[state=completed]/step:bg-primary",
className,
)}
data-slot="stepper-separator"
{...props}
/>
);
}
export {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
};
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import type React from "react";
import { cn } from "@/lib/utils";
export type TableVariant = "default" | "card";
export type TableProps = React.ComponentProps<"table"> & {
variant?: TableVariant;
render?: useRender.ComponentProps<"div">["render"];
};
export function Table({
className,
variant = "default",
render,
...props
}: TableProps): React.ReactElement {
const defaultProps = {
children: (
<table
className={cn(
"w-full caption-bottom in-data-[variant=card]:border-separate in-data-[variant=card]:border-spacing-0 text-sm",
className,
)}
data-slot="table"
{...props}
/>
),
className: "relative w-full overflow-x-auto",
"data-slot": "table-container",
"data-variant": variant,
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, {}),
render,
});
}
export function TableHeader({
className,
...props
}: React.ComponentProps<"thead">): React.ReactElement {
return (
<thead
className={cn("[&_tr]:border-b", className)}
data-slot="table-header"
{...props}
/>
);
}
export function TableBody({
className,
...props
}: React.ComponentProps<"tbody">): React.ReactElement {
return (
<tbody
className={cn(
"relative in-data-[variant=card]:rounded-xl in-data-[variant=card]:shadow-xs/5 before:pointer-events-none before:absolute before:inset-px not-in-data-[variant=card]:before:hidden before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/8%)] [&_tr:last-child]:border-0 in-data-[variant=card]:*:[tr]:border-0 in-data-[variant=card]:*:[tr]:*:[td]:border-b in-data-[variant=card]:*:[tr]:*:[td]:bg-card in-data-[variant=card]:*:[tr]:first:*:[td]:first:rounded-ss-xl in-data-[variant=card]:*:[tr]:*:[td]:first:border-s in-data-[variant=card]:*:[tr]:first:*:[td]:border-t in-data-[variant=card]:*:[tr]:last:*:[td]:last:rounded-ee-xl in-data-[variant=card]:*:[tr]:*:[td]:last:border-e in-data-[variant=card]:*:[tr]:first:*:[td]:last:rounded-se-xl in-data-[variant=card]:*:[tr]:last:*:[td]:first:rounded-es-xl in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_4%)] dark:in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_4%)] dark:in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)]",
className,
)}
data-slot="table-body"
{...props}
/>
);
}
export function TableFooter({
className,
...props
}: React.ComponentProps<"tfoot">): React.ReactElement {
return (
<tfoot
className={cn(
"border-t in-data-[variant=card]:border-none bg-transparent not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] font-medium dark:not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)] [&>tr]:last:border-b-0",
className,
)}
data-slot="table-footer"
{...props}
/>
);
}
export function TableRow({
className,
...props
}: React.ComponentProps<"tr">): React.ReactElement {
return (
<tr
className={cn(
"relative border-b not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-black)_2%)] not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-black)_4%)] dark:not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-white)_4%)] dark:not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-white)_2%)]",
className,
)}
data-slot="table-row"
{...props}
/>
);
}
export function TableHead({
className,
...props
}: React.ComponentProps<"th">): React.ReactElement {
return (
<th
className={cn(
"h-10 whitespace-nowrap px-2.5 text-left align-middle font-medium text-muted-foreground leading-none has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
className,
)}
data-slot="table-head"
{...props}
/>
);
}
export function TableCell({
className,
...props
}: React.ComponentProps<"td">): React.ReactElement {
return (
<td
className={cn(
"whitespace-nowrap bg-clip-padding p-2.5 in-data-[slot=table-footer]:py-3.5 align-middle leading-none in-data-[variant=card]:first:ps-[calc(--spacing(2.5)-1px)] in-data-[variant=card]:last:pe-[calc(--spacing(2.5)-1px)] has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
className,
)}
data-slot="table-cell"
{...props}
/>
);
}
export function TableCaption({
className,
...props
}: React.ComponentProps<"caption">): React.ReactElement {
return (
<caption
className={cn(
"in-data-[variant=card]:my-4 mt-4 text-muted-foreground text-sm",
className,
)}
data-slot="table-caption"
{...props}
/>
);
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { Slot } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
// Types
type TimelineContextValue = {
activeStep: number;
setActiveStep: (step: number) => void;
};
// Context
const TimelineContext = React.createContext<TimelineContextValue | undefined>(
undefined,
);
const useTimeline = () => {
const context = React.useContext(TimelineContext);
if (!context) {
throw new Error("useTimeline must be used within a Timeline");
}
return context;
};
// Components
interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: number;
value?: number;
onValueChange?: (value: number) => void;
orientation?: "horizontal" | "vertical";
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = React.useState(defaultValue);
const setActiveStep = React.useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step);
}
onValueChange?.(step);
},
[value, onValueChange],
);
const currentStep = value ?? activeStep;
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
<div
className={cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className,
)}
data-orientation={orientation}
data-slot="timeline"
{...props}
/>
</TimelineContext.Provider>
);
}
// TimelineContent
function TimelineContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="timeline-content"
{...props}
/>
);
}
// TimelineDate
interface TimelineDateProps extends React.HTMLAttributes<HTMLTimeElement> {
asChild?: boolean;
}
function TimelineDate({
asChild = false,
className,
...props
}: TimelineDateProps) {
const Comp = asChild ? Slot.Root : "time";
return (
<Comp
className={cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className,
)}
data-slot="timeline-date"
{...props}
/>
);
}
// TimelineHeader
function TimelineHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn(className)} data-slot="timeline-header" {...props} />
);
}
// TimelineIndicator
interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
function TimelineIndicator({
// Accepted for API parity but unused: COSS/Base UI composes with `render`,
// not `asChild`. Destructured so it never lands on the DOM node.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
asChild: _asChild = false,
className,
children,
...props
}: TimelineIndicatorProps) {
return (
<div
aria-hidden="true"
className={cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className,
)}
data-slot="timeline-indicator"
{...props}
>
{children}
</div>
);
}
// TimelineItem
interface TimelineItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number;
}
function TimelineItem({ step, className, ...props }: TimelineItemProps) {
const { activeStep } = useTimeline();
return (
<div
className={cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-12 has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-primary",
className,
)}
data-completed={step <= activeStep || undefined}
data-slot="timeline-item"
{...props}
/>
);
}
// TimelineSeparator
function TimelineSeparator({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
aria-hidden="true"
className={cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className,
)}
data-slot="timeline-separator"
{...props}
/>
);
}
// TimelineTitle
function TimelineTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("font-medium text-sm", className)}
data-slot="timeline-title"
{...props}
/>
);
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
};
+31 -21
View File
@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { X } from "lucide-react";
import { ArrowUpCircle, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { getVersionInfo } from "@/lib/version";
const DISMISS_KEY = "temetro:update-dismissed";
@@ -39,28 +41,36 @@ export function UpdateBanner() {
setLatest(null);
};
// Pinned to the physical bottom-right in both LTR and RTL. The user wants it
// in the bottom-right corner in Arabic too, so we use physical `right`/`bottom`
// rather than the logical `end` (which would flip to the left under RTL).
return (
<div className="fixed end-4 bottom-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 shadow-lg">
<div className="space-y-1.5">
<p className="text-sm text-foreground">
<div className="fixed right-4 bottom-4 z-50 w-full max-w-sm" dir="auto">
<Alert className="bg-card shadow-lg" variant="warning">
<ArrowUpCircle />
<AlertTitle className="text-foreground">
{t("settings.version.banner", { version: latest })}
</p>
<Link
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
href="/settings?tab=version"
onClick={dismiss}
>
{t("settings.version.bannerUpdate")}
</Link>
</div>
<button
aria-label={t("settings.version.bannerDismiss")}
className="-me-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={dismiss}
type="button"
>
<X className="size-4" />
</button>
</AlertTitle>
<AlertAction>
<Button
render={
<Link href="/settings?tab=version" onClick={dismiss} />
}
size="sm"
variant="outline"
>
{t("settings.version.bannerUpdate")}
</Button>
<Button
aria-label={t("settings.version.bannerDismiss")}
onClick={dismiss}
size="icon-sm"
variant="ghost"
>
<X className="size-4" />
</Button>
</AlertAction>
</Alert>
</div>
);
}
+43 -7
View File
@@ -28,15 +28,21 @@ export function useWalletSync(fileNumber: string | null | undefined) {
const [update, setUpdate] = useState<WalletUpdate | null>(null);
const [error, setError] = useState<string | null>(null);
// Drop the previous patient's link state as soon as the selection changes,
// adjusted during render: `linked` gates the wallet step, so carrying it over
// for even one render would offer to push another patient's record.
const [prevFileNumber, setPrevFileNumber] = useState(fileNumber);
if (prevFileNumber !== fileNumber) {
setPrevFileNumber(fileNumber);
setLinked(false);
setChecking(Boolean(fileNumber));
}
// Resolve link status whenever the chosen patient changes. A 404 simply means
// "not wallet-backed", so failures collapse to `linked = false`.
useEffect(() => {
if (!fileNumber) {
setLinked(false);
return;
}
if (!fileNumber) return;
let active = true;
setChecking(true);
getWalletLink(fileNumber)
.then(() => {
if (active) setLinked(true);
@@ -74,13 +80,43 @@ export function useWalletSync(fileNumber: string | null | undefined) {
};
}, [state, update]);
// Authoritatively resolve link status at submit time. The `linked` state above
// is populated asynchronously by the effect, so a fast save (or a transient
// failure that collapsed it to false) can leave a wallet-backed patient looking
// unlinked. Callers await this before deciding whether to show the wallet step,
// so the decision is never made on an unresolved check.
const ensureLinked = useCallback(async (): Promise<boolean> => {
if (!fileNumber) {
setLinked(false);
return false;
}
setChecking(true);
try {
await getWalletLink(fileNumber);
setLinked(true);
return true;
} catch {
setLinked(false);
return false;
} finally {
setChecking(false);
}
}, [fileNumber]);
const push = useCallback(
async (changes: string[]) => {
if (!fileNumber) return;
// Never push an empty/whitespace change set — the backend rejects it (400).
const clean = changes.map((c) => c.trim()).filter(Boolean);
if (clean.length === 0) {
setError("generic");
setState("error");
return;
}
setError(null);
setState("pending");
try {
const created = await pushWalletUpdate({ fileNumber, changes });
const created = await pushWalletUpdate({ fileNumber, changes: clean });
setUpdate(created);
} catch (err) {
setError(err instanceof ApiError ? err.message : "generic");
@@ -96,7 +132,7 @@ export function useWalletSync(fileNumber: string | null | undefined) {
setError(null);
}, []);
return { linked, checking, state, update, error, push, reset };
return { linked, checking, state, update, error, ensureLinked, push, reset };
}
export type UseWalletSync = ReturnType<typeof useWalletSync>;
+31 -44
View File
@@ -5,57 +5,41 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogPanel } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
Stepper,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
} from "@/components/ui/stepper";
import type { UseWalletSync } from "./use-wallet-sync";
// Two-step header shown at the top of a dialog when the selected patient has a
// linked wallet: "Details" → "Sync to wallet".
// linked wallet: "Details" → "Sync to wallet". Centered so the numbered
// indicators sit inline with their labels.
export function DialogStepper({ step }: { step: "form" | "wallet" }) {
const { t } = useTranslation();
const steps = [
{ key: "form", label: t("walletSync.step1") },
{ key: "wallet", label: t("walletSync.step2") },
] as const;
const activeIndex = step === "form" ? 0 : 1;
{ value: 1, label: t("walletSync.step1") },
{ value: 2, label: t("walletSync.step2") },
];
const activeValue = step === "form" ? 1 : 2;
return (
<div className="mt-3 flex items-center gap-2">
{steps.map((s, i) => {
const done = i < activeIndex;
const active = i === activeIndex;
return (
<div className="flex flex-1 items-center gap-2" key={s.key}>
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold transition-colors",
done && "bg-primary text-primary-foreground",
active && "bg-primary/15 text-primary ring-1 ring-primary/40",
!done && !active && "bg-muted text-muted-foreground",
)}
>
{done ? <Check className="size-3" /> : i + 1}
</span>
<span
className={cn(
"text-xs font-medium",
active || done ? "text-foreground" : "text-muted-foreground",
)}
>
{s.label}
</span>
{i < steps.length - 1 && (
<span
className={cn(
"ml-1 h-px flex-1",
done ? "bg-primary/40" : "bg-border",
)}
/>
)}
</div>
);
})}
</div>
<Stepper className="mx-auto mt-3 max-w-sm" value={activeValue}>
{steps.map(({ value, label }, i) => (
<StepperItem className="not-last:flex-1" key={value} step={value}>
<span className="inline-flex items-center gap-2">
<StepperIndicator />
<StepperTitle className="whitespace-nowrap text-xs">
{label}
</StepperTitle>
</span>
{i < steps.length - 1 && <StepperSeparator className="mx-3" />}
</StepperItem>
))}
</Stepper>
);
}
@@ -76,6 +60,9 @@ export function WalletSyncStep({
const { t } = useTranslation();
const { state, update, error, push } = sync;
const status = update?.status ?? "pending";
// The summary is the human-readable change the patient approves. Guard against
// an empty one (the backend 400s on empty changes) with a translated fallback.
const changeSummary = summary.trim() || t("walletSync.summaryFallback");
return (
<>
@@ -95,7 +82,7 @@ export function WalletSyncStep({
{t("walletSync.changesLabel")}
</span>
<div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm">
{summary}
{changeSummary}
</div>
</div>
{error && (
@@ -135,7 +122,7 @@ export function WalletSyncStep({
<Button onClick={onDone} type="button" variant="outline">
{t("walletSync.skip")}
</Button>
<Button onClick={() => push([summary])} type="button">
<Button onClick={() => push([changeSummary])} type="button">
<Send className="size-4" />
{t("walletSync.send")}
</Button>
+20
View File
@@ -12,6 +12,26 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Vendored component libraries we don't hand-maintain. Both are pulled in
// from upstream registries and re-linting them just reports upstream's
// style back at us — `react-hooks/refs` and `set-state-in-effect` alone
// account for most of it, and "fixing" them means diverging from upstream
// and eating the conflicts on every update.
//
// ai-elements additionally has pre-existing Base UI type drift already
// carved out via typescript.ignoreBuildErrors in next.config.ts; this is
// the lint half of that same carve-out. See CLAUDE.md.
"components/ai-elements/**",
"components/charts/**",
// Registry components, added/updated via `npx shadcn add` rather than
// hand-written. Two carry upstream patterns the compiler rules dislike
// (carousel publishes its api to the parent from an effect; the sidebar
// skeleton picks a random width), and editing them here would be undone by
// the next registry update.
"components/ui/carousel.tsx",
"components/ui/sidebar.tsx",
]),
]);
+18 -13
View File
@@ -2,18 +2,23 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
function subscribe(onChange: () => void) {
const mql = window.matchMedia(QUERY)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}
// `useSyncExternalStore` rather than an effect that seeds state on mount: the
// viewport is an external store, and reading it through the subscription keeps
// the value correct on the first paint instead of rendering one frame at the
// wrong breakpoint. The server snapshot is `false` (desktop-first) because
// there's no viewport to measure during SSR.
export function useIsMobile() {
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => false,
)
}
+3 -1
View File
@@ -5,7 +5,9 @@ import type { Effort } from "@/lib/ai-models";
// saved to /api/ai/config. Provider API keys are write-only: they are never
// returned, only `apiKeySet` reports which providers have a stored key.
export type AiMode = "api" | "local";
// "auto" auto-picks a cloud provider when a key is set, else falls back to local
// Ollama; "off" disables the assistant entirely.
export type AiMode = "api" | "local" | "auto" | "off";
export type ApiProvider = "openai" | "anthropic" | "gemini";
export type VeilLevel = "off" | "names" | "full";
+85
View File
@@ -0,0 +1,85 @@
// Minimal GS1 Application Identifier parser for medication barcodes (the GS1
// DataMatrix printed on drug packaging). We only care about the AIs a pharmacy
// add-item flow can use: 01 GTIN, 17 expiry, 10 lot/batch, 21 serial. Variable
// -length fields are terminated by the FNC1/GS separator (ASCII 29); the AIs
// below have fixed lengths per the GS1 General Specifications.
export type Gs1Fields = {
gtin?: string;
/** AI 17 normalised to YYYY-MM-DD. */
expiry?: string;
lot?: string;
serial?: string;
};
const GS = "\x1d";
// value length (excluding the 2-digit AI) for the fixed-length AIs we handle.
const FIXED_LEN: Record<string, number> = {
"00": 18,
"01": 14,
"02": 14,
"11": 6,
"12": 6,
"13": 6,
"15": 6,
"16": 6,
"17": 6,
"20": 2,
};
function yymmddToIso(v: string): string | undefined {
if (!/^\d{6}$/.test(v)) return undefined;
const year = 2000 + Number(v.slice(0, 2));
const month = Number(v.slice(2, 4));
let day = Number(v.slice(4, 6));
if (month < 1 || month > 12) return undefined;
// GS1 allows DD=00 to mean "end of the month".
if (day === 0) day = new Date(year, month, 0).getDate();
const pad = (n: number) => String(n).padStart(2, "0");
return `${year}-${pad(month)}-${pad(day)}`;
}
// Parse a scanned string as GS1. Returns null when it isn't GS1-structured (a
// plain EAN-13 / Code128 barcode), so callers can fall back to the raw value.
export function parseGs1(raw: string): Gs1Fields | null {
if (!raw) return null;
let s = raw;
// Strip a leading symbology identifier (e.g. "]d2", "]C1", "]e0").
if (s.startsWith("]")) s = s.slice(3);
if (s.startsWith(GS)) s = s.slice(1);
// GS1 medication codes lead with (01) GTIN; bail early otherwise so a plain
// numeric barcode isn't mis-parsed.
if (!s.startsWith("01")) return null;
const out: Gs1Fields = {};
let i = 0;
let matched = false;
while (i + 2 <= s.length) {
const ai = s.slice(i, i + 2);
i += 2;
const fixed = FIXED_LEN[ai];
let value: string;
if (fixed != null) {
value = s.slice(i, i + fixed);
i += fixed;
} else {
const gsIdx = s.indexOf(GS, i);
if (gsIdx === -1) {
value = s.slice(i);
i = s.length;
} else {
value = s.slice(i, gsIdx);
i = gsIdx + 1;
}
}
if (s[i] === GS) i += 1; // consume a separator trailing a fixed field
if (!value) break;
matched = true;
if (ai === "01") out.gtin = value;
else if (ai === "17") out.expiry = yymmddToIso(value);
else if (ai === "10") out.lot = value;
else if (ai === "21") out.serial = value;
}
return matched ? out : null;
}
+47 -7
View File
@@ -1,4 +1,11 @@
{
"scan": {
"title": "مسح رمز",
"description": "وجّه الكاميرا نحو باركود أو رمز QR.",
"cancel": "إلغاء",
"permissionDenied": "تم رفض الوصول إلى الكاميرا. فعّله للمسح.",
"unavailable": "المسح غير متاح على هذا الجهاز."
},
"scribe": {
"recordVisit": "تسجيل الزيارة",
"title": "كاتب الزيارة",
@@ -263,7 +270,8 @@
"createDialogDescription": "أضف عيادة جديدة وانتقل إليها.",
"signOutFailed": "فشل تسجيل الخروج",
"tryAgain": "يرجى المحاولة مرة أخرى.",
"signedOut": "تم تسجيل الخروج"
"signedOut": "تم تسجيل الخروج",
"language": "اللغة"
},
"clinic": {
"create": "إنشاء عيادة",
@@ -294,6 +302,8 @@
"qrCaption": "امسح هذا في تطبيق المريض",
"generateQr": "عرض رمز QR",
"walletLabel": "رقم محفظة المريض",
"scan": "امسح رمز المريض",
"scanHint": "امسح رمز QR أو الباركود الظاهر في تطبيق محفظة المريض.",
"walletPlaceholder": "tmw_…",
"tempLabel": "مشاركة مؤقتة",
"tempHint": "يُحذف السجل تلقائيًا من هذه العيادة عند انتهاء فترة المشاركة.",
@@ -374,7 +384,11 @@
"doneTitle": "تم حذف المريض",
"failedTitle": "تعذّر حذف المريض",
"failedBody": "يرجى المحاولة مرة أخرى."
}
},
"filterLabel": "تصفية",
"filterStatus": "تصفية حسب الحالة",
"allStatuses": "كل الحالات",
"moreActions": "إجراءات إضافية"
},
"appointments": {
"title": "المواعيد والجدول",
@@ -615,7 +629,8 @@
"removeDates": "إزالة",
"startDate": "تاريخ البدء",
"endDate": "تاريخ الانتهاء",
"addDates": "إضافة تاريخ البدء/الانتهاء"
"addDates": "إضافة تاريخ البدء/الانتهاء",
"selectDate": "اختر التاريخ"
}
},
"pharmacy": {
@@ -696,6 +711,10 @@
"location": "الموقع",
"locationPlaceholder": "مثال: A3",
"expires": "تنتهي الصلاحية",
"barcode": "الباركود / NDC",
"barcodePlaceholder": "امسح أو اكتب رمزًا",
"scanHint": "وصّل ماسح باركود USB وامسح الدواء — أو اكتب الرمز.",
"scannedTitle": "تم مسح الباركود",
"cancel": "إلغاء",
"submit": "إضافة عنصر",
"nameRequiredTitle": "اسم الدواء مطلوب",
@@ -1144,7 +1163,12 @@
"title": "اربط نموذج ذكاء اصطناعي للبدء",
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
"action": "فتح إعدادات الذكاء الاصطناعي",
"dismiss": "تجاهل"
"dismiss": "تجاهل",
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
},
"card": {
"unsupported": "بطاقة غير مدعومة"
},
"input": {
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
@@ -1551,9 +1575,15 @@
"mild": "خفيف",
"moderate": "متوسط",
"severe": "شديد"
}
},
"moreActions": "إجراءات إضافية"
},
"patientForm": {
"close": "إغلاق",
"tabs": {
"add": "إضافة",
"show": "عرض"
},
"editTitle": "تعديل السجل",
"createTitle": "إضافة مريض",
"editDescription": "حدّث سجل {{name}} وأضف بيانات جديدة.",
@@ -1886,7 +1916,9 @@
"pendingApprovalsDesc": "أبلغني عند موافقة مريض على تغيير معلّق أو رفضه",
"recordsShared": "سجلات شُوركت معي",
"recordsSharedDesc": "أبلغني عند مشاركة مريض سجلًا معي"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "إزالة الرابط"
},
"careTeam": {
"title": "فريق الرعاية",
@@ -2103,6 +2135,13 @@
"modeLocal": "نموذج محلي (Ollama)",
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
"modeAuto": "تلقائي",
"modeOff": "إيقاف",
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
"offTitle": "المساعد مُوقف",
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
"providerTitle": "المزوّد ومفتاح API",
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
"provider": "المزوّد",
@@ -2236,6 +2275,7 @@
"prescription": "وصفة جديدة: {{drug}}",
"demographics": "تم تحديث البيانات الديموغرافية",
"note": "ملاحظة سريرية جديدة"
}
},
"summaryFallback": "تم تحديث السجل"
}
}
+47 -7
View File
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Code scannen",
"description": "Richte die Kamera auf einen Barcode oder QR-Code.",
"cancel": "Abbrechen",
"permissionDenied": "Kamerazugriff verweigert. Aktiviere ihn zum Scannen.",
"unavailable": "Scannen ist auf diesem Gerät nicht verfügbar."
},
"scribe": {
"recordVisit": "Besuch aufnehmen",
"title": "Besuchs-Schreiber",
@@ -259,7 +266,8 @@
"createDialogDescription": "Fügen Sie eine neue Klinik hinzu und wechseln Sie zu ihr.",
"signOutFailed": "Abmeldung fehlgeschlagen",
"tryAgain": "Bitte versuchen Sie es erneut.",
"signedOut": "Abgemeldet"
"signedOut": "Abgemeldet",
"language": "Sprache"
},
"clinic": {
"create": "Klinik erstellen",
@@ -290,6 +298,8 @@
"qrCaption": "Dies in der Patienten-App scannen",
"generateQr": "QR-Code anzeigen",
"walletLabel": "Patienten-Wallet-Nummer",
"scan": "Code des Patienten scannen",
"scanHint": "Scanne den QR- oder Barcode aus der Wallet-App des Patienten.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Temporär teilen",
"tempHint": "Die Akte wird automatisch aus dieser Klinik gelöscht, wenn das Freigabefenster endet.",
@@ -362,7 +372,11 @@
"doneTitle": "Patient gelöscht",
"failedTitle": "Patient konnte nicht gelöscht werden",
"failedBody": "Bitte versuchen Sie es erneut."
}
},
"filterLabel": "Filter",
"filterStatus": "Nach Status filtern",
"allStatuses": "Alle Status",
"moreActions": "Weitere Aktionen"
},
"appointments": {
"title": "Termine & Zeitplan",
@@ -599,7 +613,8 @@
"removeDates": "Entfernen",
"startDate": "Startdatum",
"endDate": "Enddatum",
"addDates": "Start-/Enddatum hinzufügen"
"addDates": "Start-/Enddatum hinzufügen",
"selectDate": "Datum wählen"
}
},
"pharmacy": {
@@ -680,6 +695,10 @@
"location": "Standort",
"locationPlaceholder": "z. B. A3",
"expires": "Verfällt",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Code scannen oder eingeben",
"scanHint": "Schließen Sie einen USB-Barcode-Scanner an und scannen Sie das Medikament oder geben Sie den Code ein.",
"scannedTitle": "Barcode gescannt",
"cancel": "Abbrechen",
"submit": "Artikel hinzufügen",
"nameRequiredTitle": "Medikamentenname erforderlich",
@@ -1128,7 +1147,12 @@
"title": "Verbinden Sie ein KI-Modell, um zu beginnen",
"body": "Es ist noch kein KI-Anbieter eingerichtet. Fügen Sie einen API-Schlüssel hinzu oder verweisen Sie temetro auf ein lokales Ollama-Modell, damit der Assistent antworten kann.",
"action": "KI-Einstellungen öffnen",
"dismiss": "Ausblenden"
"dismiss": "Ausblenden",
"offTitle": "Der KI-Assistent ist ausgeschaltet",
"offBody": "KI ist unter Einstellungen → KI auf Aus gestellt. Schalten Sie sie ein, um den Assistenten zu nutzen."
},
"card": {
"unsupported": "Nicht unterstützte Karte"
},
"input": {
"placeholder": "Fragen Sie etwas oder tippen Sie /patient 10293",
@@ -1531,9 +1555,15 @@
"mild": "Leicht",
"moderate": "Mäßig",
"severe": "Schwer"
}
},
"moreActions": "Weitere Aktionen"
},
"patientForm": {
"close": "Schließen",
"tabs": {
"add": "Hinzufügen",
"show": "Anzeigen"
},
"editTitle": "Datensatz bearbeiten",
"createTitle": "Patient hinzufügen",
"editDescription": "Aktualisieren Sie die Akte von {{name}} und fügen Sie neue Daten hinzu.",
@@ -1866,7 +1896,9 @@
"pendingApprovalsDesc": "Benachrichtigen Sie mich, wenn ein Patient eine ausstehende Änderung genehmigt oder ablehnt",
"recordsShared": "Mit mir geteilte Datensätze",
"recordsSharedDesc": "Benachrichtigen Sie mich, wenn ein Patient einen Datensatz mit mir teilt"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Link entfernen"
},
"careTeam": {
"title": "Behandlungsteam",
@@ -2083,6 +2115,13 @@
"modeLocal": "Lokales Modell (Ollama)",
"modeApiHint": "Anfragen gehen an Ihren gewählten Anbieter. Patientenkennungen werden von Veil anonymisiert, bevor sie das System verlassen.",
"modeLocalHint": "Anfragen laufen gegen ein Modell auf Ihrer eigenen Infrastruktur. Keine Patientendaten verlassen die Klinik.",
"modeAuto": "Automatisch",
"modeOff": "Aus",
"modeAutoHint": "Verwendet einen Cloud-API-Schlüssel, sofern vorhanden, andernfalls Ihr lokales Ollama-Modell.",
"modeOffHint": "Der KI-Assistent ist deaktiviert. Es werden keine Anfragen gesendet.",
"offTitle": "Assistent aus",
"offDescription": "Der KI-Assistent ist für Ihr Konto ausgeschaltet.",
"offNote": "Wählen Sie oben Automatisch, Cloud-API-Schlüssel oder Lokales Modell, um den Assistenten zu aktivieren.",
"providerTitle": "Anbieter & API-Schlüssel",
"providerDescription": "Ihr Schlüssel wird verschlüsselt gespeichert und nie wieder angezeigt. Sie können einen Schlüssel pro Anbieter speichern und zwischen ihnen wechseln.",
"provider": "Anbieter",
@@ -2216,6 +2255,7 @@
"prescription": "Neues Rezept: {{drug}}",
"demographics": "Stammdaten aktualisiert",
"note": "Neue klinische Notiz"
}
},
"summaryFallback": "Datensatz aktualisiert"
}
}
+47 -7
View File
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Scan a code",
"description": "Point the camera at a barcode or QR code.",
"cancel": "Cancel",
"permissionDenied": "Camera access was denied. Enable it to scan.",
"unavailable": "Scanning isn't available on this device."
},
"scribe": {
"recordVisit": "Record visit",
"title": "Visit scribe",
@@ -259,7 +266,8 @@
"createDialogDescription": "Add a new clinic and switch to it.",
"signOutFailed": "Sign out failed",
"tryAgain": "Please try again.",
"signedOut": "Signed out"
"signedOut": "Signed out",
"language": "Language"
},
"clinic": {
"create": "Create clinic",
@@ -290,6 +298,8 @@
"qrCaption": "Scan this in the patient app",
"generateQr": "Show QR code",
"walletLabel": "Patient wallet number",
"scan": "Scan patient's code",
"scanHint": "Scan the QR or barcode shown in the patient's wallet app.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Share temporarily",
"tempHint": "The record is automatically deleted from this clinic when the share window ends.",
@@ -362,7 +372,11 @@
"doneTitle": "Patient deleted",
"failedTitle": "Couldn't delete patient",
"failedBody": "Please try again."
}
},
"filterLabel": "Filter",
"filterStatus": "Filter by status",
"allStatuses": "All statuses",
"moreActions": "More actions"
},
"appointments": {
"title": "Appointments & Schedule",
@@ -599,7 +613,8 @@
"removeDates": "Remove",
"startDate": "Start date",
"endDate": "End date",
"addDates": "Add start/end dates"
"addDates": "Add start/end dates",
"selectDate": "Select date"
}
},
"pharmacy": {
@@ -680,6 +695,10 @@
"location": "Location",
"locationPlaceholder": "e.g. A3",
"expires": "Expires",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Scan or type a code",
"scanHint": "Connect a USB barcode scanner and scan the medication — or type the code.",
"scannedTitle": "Barcode scanned",
"cancel": "Cancel",
"submit": "Add item",
"nameRequiredTitle": "Medication name required",
@@ -1128,7 +1147,12 @@
"title": "Connect an AI model to get started",
"body": "No AI provider is set up yet. Add an API key or point temetro at a local Ollama model so the assistant can answer.",
"action": "Open AI settings",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"offTitle": "The AI assistant is turned off",
"offBody": "AI is set to Off in Settings → AI. Turn it on to use the assistant."
},
"card": {
"unsupported": "Unsupported card"
},
"input": {
"placeholder": "Ask anything, or type /patient 10293",
@@ -1531,9 +1555,15 @@
"mild": "Mild",
"moderate": "Moderate",
"severe": "Severe"
}
},
"moreActions": "More actions"
},
"patientForm": {
"close": "Close",
"tabs": {
"add": "Add",
"show": "Show"
},
"editTitle": "Edit record",
"createTitle": "Add patient",
"editDescription": "Update {{name}}'s chart and add new data.",
@@ -1866,7 +1896,9 @@
"pendingApprovalsDesc": "Notify me when a patient approves or rejects a pending change",
"recordsShared": "Records shared with me",
"recordsSharedDesc": "Notify me when a patient shares a record with me"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Remove link"
},
"careTeam": {
"title": "Care team",
@@ -2081,8 +2113,15 @@
"mode": "Mode",
"modeApi": "Cloud API key",
"modeLocal": "Local model (Ollama)",
"modeAuto": "Automatic",
"modeOff": "Off",
"modeApiHint": "Requests go to your chosen provider. Patient identifiers are de-identified by Veil before they leave.",
"modeLocalHint": "Requests run against a model on your own infrastructure. No patient data leaves the clinic.",
"modeAutoHint": "Uses a cloud API key when one is set, otherwise falls back to your local Ollama model.",
"modeOffHint": "The AI assistant is disabled. No requests are sent.",
"offTitle": "Assistant off",
"offDescription": "The AI assistant is turned off for your account.",
"offNote": "Choose Automatic, Cloud API key, or Local model above to enable the assistant.",
"providerTitle": "Provider & API key",
"providerDescription": "Your key is encrypted at rest and never shown again. You can store a key per provider and switch between them.",
"provider": "Provider",
@@ -2216,6 +2255,7 @@
"prescription": "New prescription: {{drug}}",
"demographics": "Demographics updated",
"note": "New clinical note"
}
},
"summaryFallback": "Record updated"
}
}
+47 -7
View File
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Scanner un code",
"description": "Dirigez la caméra vers un code-barres ou un QR code.",
"cancel": "Annuler",
"permissionDenied": "Accès à la caméra refusé. Activez-le pour scanner.",
"unavailable": "Le scan n'est pas disponible sur cet appareil."
},
"scribe": {
"recordVisit": "Enregistrer la visite",
"title": "Scribe de visite",
@@ -259,7 +266,8 @@
"createDialogDescription": "Ajoutez une nouvelle clinique et passez-y.",
"signOutFailed": "Échec de la déconnexion",
"tryAgain": "Veuillez réessayer.",
"signedOut": "Déconnecté"
"signedOut": "Déconnecté",
"language": "Langue"
},
"clinic": {
"create": "Créer une clinique",
@@ -290,6 +298,8 @@
"qrCaption": "Scannez ceci dans l'application patient",
"generateQr": "Afficher le code QR",
"walletLabel": "Numéro de portefeuille du patient",
"scan": "Scanner le code du patient",
"scanHint": "Scannez le QR code ou le code-barres affiché dans l'app portefeuille du patient.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Partager temporairement",
"tempHint": "Le dossier est automatiquement supprimé de cette clinique à la fin de la période de partage.",
@@ -362,7 +372,11 @@
"doneTitle": "Patient supprimé",
"failedTitle": "Impossible de supprimer le patient",
"failedBody": "Veuillez réessayer."
}
},
"filterLabel": "Filtre",
"filterStatus": "Filtrer par statut",
"allStatuses": "Tous les statuts",
"moreActions": "Plus dactions"
},
"appointments": {
"title": "Rendez-vous & Planning",
@@ -599,7 +613,8 @@
"removeDates": "Retirer",
"startDate": "Date de début",
"endDate": "Date de fin",
"addDates": "Ajouter les dates de début/fin"
"addDates": "Ajouter les dates de début/fin",
"selectDate": "Choisir une date"
}
},
"pharmacy": {
@@ -680,6 +695,10 @@
"location": "Emplacement",
"locationPlaceholder": "ex. A3",
"expires": "Expire le",
"barcode": "Code-barres / NDC",
"barcodePlaceholder": "Scanner ou saisir un code",
"scanHint": "Branchez un lecteur de code-barres USB et scannez le médicament — ou saisissez le code.",
"scannedTitle": "Code-barres scanné",
"cancel": "Annuler",
"submit": "Ajouter l'article",
"nameRequiredTitle": "Nom du médicament requis",
@@ -1128,7 +1147,12 @@
"title": "Connectez un modèle d'IA pour commencer",
"body": "Aucun fournisseur d'IA n'est encore configuré. Ajoutez une clé API ou pointez temetro vers un modèle Ollama local pour que l'assistant puisse répondre.",
"action": "Ouvrir les paramètres d'IA",
"dismiss": "Ignorer"
"dismiss": "Ignorer",
"offTitle": "L'assistant IA est désactivé",
"offBody": "L'IA est réglée sur Désactivée dans Paramètres → IA. Activez-la pour utiliser l'assistant."
},
"card": {
"unsupported": "Carte non prise en charge"
},
"input": {
"placeholder": "Demandez n'importe quoi, ou tapez /patient 10293",
@@ -1531,9 +1555,15 @@
"mild": "Légère",
"moderate": "Modérée",
"severe": "Sévère"
}
},
"moreActions": "Plus dactions"
},
"patientForm": {
"close": "Fermer",
"tabs": {
"add": "Ajouter",
"show": "Afficher"
},
"editTitle": "Modifier le dossier",
"createTitle": "Ajouter un patient",
"editDescription": "Mettez à jour le dossier de {{name}} et ajoutez de nouvelles données.",
@@ -1866,7 +1896,9 @@
"pendingApprovalsDesc": "Me notifier lorsqu'un patient approuve ou rejette une modification en attente",
"recordsShared": "Dossiers partagés avec moi",
"recordsSharedDesc": "Me notifier lorsqu'un patient partage un dossier avec moi"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Supprimer le lien"
},
"careTeam": {
"title": "Équipe soignante",
@@ -2083,6 +2115,13 @@
"modeLocal": "Modèle local (Ollama)",
"modeApiHint": "Les requêtes vont vers le fournisseur que vous avez choisi. Les identifiants des patients sont dé-identifiés par Veil avant leur départ.",
"modeLocalHint": "Les requêtes s'exécutent sur un modèle de votre propre infrastructure. Aucune donnée patient ne quitte la clinique.",
"modeAuto": "Automatique",
"modeOff": "Désactivé",
"modeAutoHint": "Utilise une clé API cloud si elle est définie, sinon revient à votre modèle Ollama local.",
"modeOffHint": "L'assistant IA est désactivé. Aucune requête n'est envoyée.",
"offTitle": "Assistant désactivé",
"offDescription": "L'assistant IA est désactivé pour votre compte.",
"offNote": "Choisissez Automatique, Clé API cloud ou Modèle local ci-dessus pour activer l'assistant.",
"providerTitle": "Fournisseur & clé API",
"providerDescription": "Votre clé est chiffrée au repos et n'est plus jamais affichée. Vous pouvez stocker une clé par fournisseur et basculer entre elles.",
"provider": "Fournisseur",
@@ -2216,6 +2255,7 @@
"prescription": "Nouvelle ordonnance : {{drug}}",
"demographics": "Données démographiques mises à jour",
"note": "Nouvelle note clinique"
}
},
"summaryFallback": "Dossier mis à jour"
}
}
+47 -7
View File
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Iskaan garee koodh",
"description": "U jeedi kamarada barcode ama koodh QR.",
"cancel": "Jooji",
"permissionDenied": "Gelitaanka kamarada waa la diiday. Daar si aad u iskaan gariso.",
"unavailable": "Iskaanku kuma shaqeeyo qalabkan."
},
"scribe": {
"recordVisit": "Duub booqasho",
"title": "Qoraaga booqashada",
@@ -259,7 +266,8 @@
"createDialogDescription": "Ku dar rug cusub oo u beddel.",
"signOutFailed": "Ka-bixitaanka waa fashilmay",
"tryAgain": "Fadlan isku day mar kale.",
"signedOut": "Waa laga baxay"
"signedOut": "Waa laga baxay",
"language": "Luqadda"
},
"clinic": {
"create": "Samee rug",
@@ -290,6 +298,8 @@
"qrCaption": "Ku sawir tan app-ka bukaanka",
"generateQr": "Tus koodhka QR",
"walletLabel": "Lambarka wallet-ka bukaanka",
"scan": "Iskaan garee koodhka bukaanka",
"scanHint": "Iskaan garee koodhka QR ama barcode ee ka muuqda abka wallet-ka bukaanka.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Wadaag si ku meel gaar ah",
"tempHint": "Diiwaanka si toos ah ayaa looga tirtirayaa rugtan marka daaqadda wadaagista dhammaato.",
@@ -362,7 +372,11 @@
"doneTitle": "Bukaanka waa la tirtiray",
"failedTitle": "Bukaanka lama tirtiri karin",
"failedBody": "Fadlan isku day mar kale."
}
},
"filterLabel": "Kala sooc",
"filterStatus": "Ku kala sooc xaaladda",
"allStatuses": "Dhammaan xaaladaha",
"moreActions": "Ficillo dheeraad ah"
},
"appointments": {
"title": "Ballamaha & Jadwalka",
@@ -599,7 +613,8 @@
"removeDates": "Ka saar",
"startDate": "Taariikhda bilowga",
"endDate": "Taariikhda dhammaadka",
"addDates": "Ku dar taariikhaha bilow/dhammaad"
"addDates": "Ku dar taariikhaha bilow/dhammaad",
"selectDate": "Dooro taariikhda"
}
},
"pharmacy": {
@@ -680,6 +695,10 @@
"location": "Goobta",
"locationPlaceholder": "tusaale A3",
"expires": "Dhacaya",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Iskaan garee ama qor koodh",
"scanHint": "Ku xir iskaanka barcode-ka USB oo iskaan garee daawada — ama qor koodhka.",
"scannedTitle": "Barcode la iskaan gareeyay",
"cancel": "Jooji",
"submit": "Ku dar shay",
"nameRequiredTitle": "Magaca daawada ayaa loo baahan yahay",
@@ -1128,7 +1147,12 @@
"title": "Ku xir moodeel AI si aad u bilowdo",
"body": "Weli bixiye AI lama dejin. Ku dar furaha API ama ku tilmaam temetro moodeel Ollama oo maxalli ah si kaaliyuhu u jawaabo.",
"action": "Fur dejinta AI",
"dismiss": "Rid"
"dismiss": "Rid",
"offTitle": "Kaaliyaha AI waa la damiyay",
"offBody": "AI waxaa loo dejiyay Damin gudaha Settings → AI. Shid si aad u isticmaasho kaaliyaha."
},
"card": {
"unsupported": "Kaadh aan la taageerin"
},
"input": {
"placeholder": "Waydii wax kasta, ama qor /patient 10293",
@@ -1531,9 +1555,15 @@
"mild": "Fudud",
"moderate": "Dhexdhexaad",
"severe": "Daran"
}
},
"moreActions": "Ficillo dheeraad ah"
},
"patientForm": {
"close": "Xir",
"tabs": {
"add": "Ku dar",
"show": "Muuji"
},
"editTitle": "Wax ka beddel diiwaanka",
"createTitle": "Ku dar bukaan",
"editDescription": "Cusbooneysii diiwaanka {{name}} oo ku dar xog cusub.",
@@ -1866,7 +1896,9 @@
"pendingApprovalsDesc": "I ogeysii marka bukaan ansixiyo ama diido isbeddel la sugayo",
"recordsShared": "Diiwaanno la ila wadaagay",
"recordsSharedDesc": "I ogeysii marka bukaan diiwaan ila wadaago"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Ka saar linkiga"
},
"careTeam": {
"title": "Kooxda daryeelka",
@@ -2083,6 +2115,13 @@
"modeLocal": "Moodeel maxalli ah (Ollama)",
"modeApiHint": "Codsiyada waxay u socdaan bixiyahaaga aad dooratay. Aqoonsiyada bukaanka waxaa qariya Veil ka hor inta aysan bixin.",
"modeLocalHint": "Codsiyada waxay ku socdaan moodeel kaabayaashaada ah. Xog bukaan kama baxdo rugta.",
"modeAuto": "Toos ah",
"modeOff": "Damin",
"modeAutoHint": "Wuxuu isticmaalaa furaha API-ga daruuraha marka la dejiyo, haddii kale wuxuu ku noqdaa moodelkaaga Ollama ee gudaha.",
"modeOffHint": "Kaaliyaha AI waa la damiyay. Wax codsi ah lama dirayo.",
"offTitle": "Kaaliyuhu waa damsan yahay",
"offDescription": "Kaaliyaha AI waa laga damiyay akoonkaaga.",
"offNote": "Dooro Toos ah, Furaha API daruuraha, ama Moodel Gudaha kor si aad u shidid kaaliyaha.",
"providerTitle": "Bixiye & furaha API",
"providerDescription": "Furahaaga waa la shifraa marka la kaydiyo mana dib loo muujiyo. Waxaad kaydin kartaa fur bixiye kasta oo aad kala beddeli kartaa.",
"provider": "Bixiye",
@@ -2216,6 +2255,7 @@
"prescription": "Rijeeto cusub: {{drug}}",
"demographics": "Xogta bukaanka la cusboonaysiiyay",
"note": "Qoraal caafimaad cusub"
}
},
"summaryFallback": "Diiwaanka waa la cusboonaysiiyay"
}
}
+2
View File
@@ -11,6 +11,7 @@ export type InventoryItem = {
stockQuantity: number;
reorderThreshold: number;
location: string;
barcode: string | null;
expiresAt: string | null;
notes: string | null;
createdAt: string;
@@ -26,6 +27,7 @@ export type InventoryInput = {
stockQuantity?: number;
reorderThreshold?: number;
location?: string;
barcode?: string | null;
expiresAt?: string | null;
notes?: string | null;
};
+1294 -116
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,10 +1,10 @@
{
"name": "frontend",
"version": "0.12.0",
"version": "0.17.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"check-locales": "node scripts/check-locales.mjs"
@@ -34,6 +34,7 @@
"@visx/scale": "^4.0.1-alpha.0",
"@visx/shape": "^4.0.1-alpha.0",
"@xyflow/react": "^12.10.2",
"@zxing/browser": "^0.2.1",
"ai": "^6.0.193",
"ansi-to-react": "^6.2.6",
"better-auth": "^1.6.13",
@@ -52,6 +53,7 @@
"nanoid": "^5.1.11",
"next": "16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.2",
"react": "19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.12.0",
"version": "0.17.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"