mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07cfd5d335 | |||
| c5812511b5 | |||
| 196a22bffd | |||
| 531cbbad7f | |||
| 65975177cf | |||
| 75502ceb60 | |||
| b285158fec | |||
| 0754ce96a4 | |||
| bac3e7a1d0 | |||
| b9d4d4f458 | |||
| aa535f08c4 | |||
| 000591f87f | |||
| 7312d15d67 | |||
| e8f3ed9ffe | |||
| 7c92c03eed | |||
| 5961d969b1 | |||
| fa566ced86 | |||
| 62a5f39683 | |||
| 6127a0ac42 | |||
| 352ca65838 | |||
| 44d653ffbd | |||
| 76da310766 | |||
| c326e9f794 | |||
| b299501ab2 | |||
| 17209a2cf1 | |||
| bd8fdfadda |
@@ -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
@@ -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
|
||||
|
||||
+164
@@ -7,6 +7,170 @@ 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
|
||||
|
||||
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://hub.docker.com/u/khalidxv)
|
||||
[](./CHANGELOG.md)
|
||||
[](./CHANGELOG.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.13.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.",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,3 +7,6 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
tsconfig.tsbuildinfo
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -14,9 +14,13 @@ import { getAiConfig } from "@/lib/ai-settings";
|
||||
// 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(() => {
|
||||
@@ -24,22 +28,34 @@ 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
|
||||
@@ -48,10 +64,14 @@ export function AiSetupNotice() {
|
||||
>
|
||||
<TriangleAlert className="size-4 shrink-0 text-warning" />
|
||||
<p className="min-w-0 flex-1 truncate text-foreground">
|
||||
<span className="font-medium">{t("chat.setupNotice.title")}</span>
|
||||
<span className="font-medium">
|
||||
{isOff
|
||||
? t("chat.setupNotice.offTitle")
|
||||
: t("chat.setupNotice.title")}
|
||||
</span>
|
||||
<span className="text-muted-foreground max-sm:hidden">
|
||||
{" — "}
|
||||
{t("chat.setupNotice.body")}
|
||||
{isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
})}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type LucideIcon,
|
||||
ListTodo,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Pencil,
|
||||
@@ -29,6 +30,13 @@ import {
|
||||
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,
|
||||
@@ -295,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}
|
||||
@@ -302,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 && (
|
||||
@@ -315,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")}>
|
||||
|
||||
@@ -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,8 +11,15 @@ 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,
|
||||
@@ -20,6 +27,14 @@ import {
|
||||
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"];
|
||||
@@ -61,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;
|
||||
@@ -82,6 +98,9 @@ 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();
|
||||
@@ -158,38 +177,6 @@ export function PatientsView() {
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter((value ?? "all") as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("patients.filterStatus")}
|
||||
className="w-full sm: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>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setImportOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => {
|
||||
@@ -201,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) => {
|
||||
@@ -264,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} />
|
||||
@@ -274,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">
|
||||
|
||||
@@ -221,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);
|
||||
@@ -347,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() }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -113,13 +113,17 @@ 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 }));
|
||||
@@ -335,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")}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -125,7 +125,10 @@ interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
function TimelineIndicator({
|
||||
asChild = false,
|
||||
// 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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -60,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 (
|
||||
<>
|
||||
@@ -79,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 && (
|
||||
@@ -119,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>
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"scan": {
|
||||
"title": "مسح رمز",
|
||||
"description": "وجّه الكاميرا نحو باركود أو رمز QR.",
|
||||
"cancel": "إلغاء",
|
||||
"permissionDenied": "تم رفض الوصول إلى الكاميرا. فعّله للمسح.",
|
||||
"unavailable": "المسح غير متاح على هذا الجهاز."
|
||||
},
|
||||
"scribe": {
|
||||
"recordVisit": "تسجيل الزيارة",
|
||||
"title": "كاتب الزيارة",
|
||||
@@ -295,6 +302,8 @@
|
||||
"qrCaption": "امسح هذا في تطبيق المريض",
|
||||
"generateQr": "عرض رمز QR",
|
||||
"walletLabel": "رقم محفظة المريض",
|
||||
"scan": "امسح رمز المريض",
|
||||
"scanHint": "امسح رمز QR أو الباركود الظاهر في تطبيق محفظة المريض.",
|
||||
"walletPlaceholder": "tmw_…",
|
||||
"tempLabel": "مشاركة مؤقتة",
|
||||
"tempHint": "يُحذف السجل تلقائيًا من هذه العيادة عند انتهاء فترة المشاركة.",
|
||||
@@ -376,8 +385,10 @@
|
||||
"failedTitle": "تعذّر حذف المريض",
|
||||
"failedBody": "يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"filterLabel": "تصفية",
|
||||
"filterStatus": "تصفية حسب الحالة",
|
||||
"allStatuses": "كل الحالات"
|
||||
"allStatuses": "كل الحالات",
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "المواعيد والجدول",
|
||||
@@ -700,6 +711,10 @@
|
||||
"location": "الموقع",
|
||||
"locationPlaceholder": "مثال: A3",
|
||||
"expires": "تنتهي الصلاحية",
|
||||
"barcode": "الباركود / NDC",
|
||||
"barcodePlaceholder": "امسح أو اكتب رمزًا",
|
||||
"scanHint": "وصّل ماسح باركود USB وامسح الدواء — أو اكتب الرمز.",
|
||||
"scannedTitle": "تم مسح الباركود",
|
||||
"cancel": "إلغاء",
|
||||
"submit": "إضافة عنصر",
|
||||
"nameRequiredTitle": "اسم الدواء مطلوب",
|
||||
@@ -1148,7 +1163,12 @@
|
||||
"title": "اربط نموذج ذكاء اصطناعي للبدء",
|
||||
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
|
||||
"action": "فتح إعدادات الذكاء الاصطناعي",
|
||||
"dismiss": "تجاهل"
|
||||
"dismiss": "تجاهل",
|
||||
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
|
||||
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "بطاقة غير مدعومة"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
|
||||
@@ -1555,9 +1575,15 @@
|
||||
"mild": "خفيف",
|
||||
"moderate": "متوسط",
|
||||
"severe": "شديد"
|
||||
}
|
||||
},
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"patientForm": {
|
||||
"close": "إغلاق",
|
||||
"tabs": {
|
||||
"add": "إضافة",
|
||||
"show": "عرض"
|
||||
},
|
||||
"editTitle": "تعديل السجل",
|
||||
"createTitle": "إضافة مريض",
|
||||
"editDescription": "حدّث سجل {{name}} وأضف بيانات جديدة.",
|
||||
@@ -2109,6 +2135,13 @@
|
||||
"modeLocal": "نموذج محلي (Ollama)",
|
||||
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
|
||||
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
|
||||
"modeAuto": "تلقائي",
|
||||
"modeOff": "إيقاف",
|
||||
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
|
||||
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
|
||||
"offTitle": "المساعد مُوقف",
|
||||
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
|
||||
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
|
||||
"providerTitle": "المزوّد ومفتاح API",
|
||||
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
|
||||
"provider": "المزوّد",
|
||||
@@ -2242,6 +2275,7 @@
|
||||
"prescription": "وصفة جديدة: {{drug}}",
|
||||
"demographics": "تم تحديث البيانات الديموغرافية",
|
||||
"note": "ملاحظة سريرية جديدة"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "تم تحديث السجل"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -291,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.",
|
||||
@@ -364,8 +373,10 @@
|
||||
"failedTitle": "Patient konnte nicht gelöscht werden",
|
||||
"failedBody": "Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Nach Status filtern",
|
||||
"allStatuses": "Alle Status"
|
||||
"allStatuses": "Alle Status",
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Termine & Zeitplan",
|
||||
@@ -684,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",
|
||||
@@ -1132,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",
|
||||
@@ -1535,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.",
|
||||
@@ -2089,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",
|
||||
@@ -2222,6 +2255,7 @@
|
||||
"prescription": "Neues Rezept: {{drug}}",
|
||||
"demographics": "Stammdaten aktualisiert",
|
||||
"note": "Neue klinische Notiz"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Datensatz aktualisiert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -291,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.",
|
||||
@@ -364,8 +373,10 @@
|
||||
"failedTitle": "Couldn't delete patient",
|
||||
"failedBody": "Please try again."
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Filter by status",
|
||||
"allStatuses": "All statuses"
|
||||
"allStatuses": "All statuses",
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments & Schedule",
|
||||
@@ -684,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",
|
||||
@@ -1132,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",
|
||||
@@ -1535,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.",
|
||||
@@ -2087,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",
|
||||
@@ -2222,6 +2255,7 @@
|
||||
"prescription": "New prescription: {{drug}}",
|
||||
"demographics": "Demographics updated",
|
||||
"note": "New clinical note"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Record updated"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -291,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.",
|
||||
@@ -364,8 +373,10 @@
|
||||
"failedTitle": "Impossible de supprimer le patient",
|
||||
"failedBody": "Veuillez réessayer."
|
||||
},
|
||||
"filterLabel": "Filtre",
|
||||
"filterStatus": "Filtrer par statut",
|
||||
"allStatuses": "Tous les statuts"
|
||||
"allStatuses": "Tous les statuts",
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Rendez-vous & Planning",
|
||||
@@ -684,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",
|
||||
@@ -1132,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",
|
||||
@@ -1535,9 +1555,15 @@
|
||||
"mild": "Légère",
|
||||
"moderate": "Modérée",
|
||||
"severe": "Sévère"
|
||||
}
|
||||
},
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"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.",
|
||||
@@ -2089,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",
|
||||
@@ -2222,6 +2255,7 @@
|
||||
"prescription": "Nouvelle ordonnance : {{drug}}",
|
||||
"demographics": "Données démographiques mises à jour",
|
||||
"note": "Nouvelle note clinique"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Dossier mis à jour"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -291,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.",
|
||||
@@ -364,8 +373,10 @@
|
||||
"failedTitle": "Bukaanka lama tirtiri karin",
|
||||
"failedBody": "Fadlan isku day mar kale."
|
||||
},
|
||||
"filterLabel": "Kala sooc",
|
||||
"filterStatus": "Ku kala sooc xaaladda",
|
||||
"allStatuses": "Dhammaan xaaladaha"
|
||||
"allStatuses": "Dhammaan xaaladaha",
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Ballamaha & Jadwalka",
|
||||
@@ -684,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",
|
||||
@@ -1132,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",
|
||||
@@ -1535,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.",
|
||||
@@ -2089,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",
|
||||
@@ -2222,6 +2255,7 @@
|
||||
"prescription": "Rijeeto cusub: {{drug}}",
|
||||
"demographics": "Xogta bukaanka la cusboonaysiiyay",
|
||||
"note": "Qoraal caafimaad cusub"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Diiwaanka waa la cusboonaysiiyay"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Generated
+48
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.15.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.206",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
@@ -32,6 +32,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",
|
||||
@@ -6255,6 +6256,41 @@
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/browser": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz",
|
||||
"integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "^0.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@zxing/library": "^0.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/library": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
|
||||
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ts-custom-error": "^3.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 24.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "~0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/text-encoding": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
||||
"license": "(Unlicense OR Apache-2.0)",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -16002,6 +16038,16 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-custom-error": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-dedent": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.13.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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.13.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user