Compare commits

...

12 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:03:50 +03:00
61 changed files with 5910 additions and 257 deletions
+32
View File
@@ -0,0 +1,32 @@
# Normalize line endings so the repo builds the same on Windows, macOS and Linux.
#
# Git for Windows installs with core.autocrlf=true by default, which rewrites
# checked-out files to CRLF. That is harmless for source we only ever compile,
# but fatal for anything the Docker images execute: a shell script whose shebang
# becomes "#!/bin/sh\r" fails at container start with
# exec /usr/local/bin/docker-entrypoint.sh: no such file or directory
# which names the file it just copied in and reads like the file is missing.
* text=auto
# Scripts that run inside a Linux container must stay LF regardless of platform.
*.sh text eol=lf
docker-entrypoint.sh text eol=lf
Dockerfile text eol=lf
*.Dockerfile text eol=lf
.dockerignore text eol=lf
# Lockfiles: keep LF so they don't churn across platforms.
package-lock.json text eol=lf
# Binary assets — never touch these.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
+68
View File
@@ -7,6 +7,74 @@ for how releases are cut and published.
## [Unreleased]
## [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
+10
View File
@@ -6,5 +6,15 @@ dist
*.log
.DS_Store
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.tunnel.yml
README.md
CLAUDE.md
# Deploy manifests for other targets — not needed inside the image.
fly.toml
railway.json
render.yaml
# Tracked, but only the runtime migrator (dist/migrate.js) runs in the image.
drizzle.config.ts
.env.example
+7 -1
View File
@@ -19,7 +19,13 @@ RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Strip CR before chmod. .gitattributes keeps this file LF on fresh clones, but
# a Windows checkout made before that existed still has CRLF on disk, and a
# "#!/bin/sh\r" shebang fails at container start with a "no such file or
# directory" error that names the file it just copied in. Cheap to make the
# image self-healing rather than rely on every contributor re-cloning.
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 4000
# The entrypoint auto-generates any missing secrets, then we apply migrations
# and start the API.
@@ -0,0 +1 @@
ALTER TABLE "inventory" ADD COLUMN "barcode" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -253,6 +253,13 @@
"when": 1783530491321,
"tag": "0035_slippery_retro_girl",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1784406410448,
"tag": "0036_aspiring_expediter",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.14.2",
"version": "0.16.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+2
View File
@@ -29,6 +29,8 @@ export const inventory = pgTable(
stockQuantity: integer("stock_quantity").notNull().default(0),
reorderThreshold: integer("reorder_threshold").notNull().default(0),
location: text("location").notNull().default(""),
// Scanned medication barcode / NDC (GTIN when read from a GS1 DataMatrix).
barcode: text("barcode"),
expiresAt: date("expires_at"),
notes: text("notes"),
createdBy: text("created_by").references(() => user.id, {
+1
View File
@@ -13,6 +13,7 @@ export const inventoryInputSchema = z.object({
stockQuantity: z.number().int().min(0).max(1_000_000).default(0),
reorderThreshold: z.number().int().min(0).max(1_000_000).default(0),
location: z.string().trim().max(200).default(""),
barcode: z.string().trim().max(120).nullish(),
expiresAt: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
+2
View File
@@ -21,6 +21,7 @@ function toInventoryItem(row: InventoryRow): InventoryItem {
stockQuantity: row.stockQuantity,
reorderThreshold: row.reorderThreshold,
location: row.location,
barcode: row.barcode,
expiresAt: row.expiresAt,
notes: row.notes,
createdAt: row.createdAt.toISOString(),
@@ -38,6 +39,7 @@ function columns(orgId: string, input: InventoryInput, createdBy?: string) {
stockQuantity: input.stockQuantity,
reorderThreshold: input.reorderThreshold,
location: input.location,
barcode: input.barcode ?? null,
expiresAt: input.expiresAt ?? null,
notes: input.notes ?? null,
...(createdBy ? { createdBy } : {}),
+11 -1
View File
@@ -40,7 +40,17 @@ export function sendToWallet(
event: string,
data: unknown,
): void {
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
const hub = hubs.get(orgId);
if (!hub) {
// No live hub means the push is dropped on the floor. The row still sits
// pending and `wallet:online` replays it once a device reconnects, but say
// so — a silent no-op here looks exactly like a wallet that ignored us.
console.warn(
`Temetro Network: no hub connection for clinic ${orgId}; "${event}" not sent (queued for wallet:online replay).`,
);
return;
}
hub.emit("wallet:send", { walletNumber, event, data });
}
// Tell the relay to expect a device response for `requestId` and route it back
+56 -15
View File
@@ -18,6 +18,7 @@ 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;
@@ -25,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;
@@ -120,20 +128,30 @@ 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.
// Attachments (files/documents) are shipped as metadata so the wallet can list
// them and show a count; the bytes stay on the clinic for now.
const [orgAppointments, orgInvoices, attachmentRows] = await Promise.all([
listAppointments(orgId),
listInvoices(orgId),
listAttachments(orgId, fileNumber),
]);
// 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,
@@ -143,9 +161,16 @@ export async function createRecordUpdate(
}));
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient` (+ appointments/invoices/documents).
// replaces its on-device record with `patient` (+ the sibling-table lists).
const bundle = utf8ToBytes(
JSON.stringify({ patient, appointments, invoices, documents, changes }),
JSON.stringify({
patient,
appointments,
invoices,
prescriptions,
documents,
changes,
}),
);
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
@@ -176,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,
@@ -234,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}`);
+1
View File
@@ -11,6 +11,7 @@ export type InventoryItem = {
stockQuantity: number;
reorderThreshold: number;
location: string;
barcode: string | null;
expiresAt: string | null; // YYYY-MM-DD
notes: string | null;
createdAt: string;
+3
View File
@@ -7,3 +7,6 @@ npm-debug.log*
.DS_Store
Dockerfile
.dockerignore
tsconfig.tsbuildinfo
CLAUDE.md
AGENTS.md
@@ -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[]>();
+12 -5
View File
@@ -9,6 +9,7 @@ import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
@@ -78,17 +79,23 @@ export function ChatInput({
const [addKey, setAddKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
// first client render agree (button starts disabled, enabled by the effect).
const [speechSupported, setSpeechSupported] = useState(false);
// Voice dictation (Web Speech API). Read through useSyncExternalStore so SSR
// markup and the first client render agree: the server snapshot is `false`
// (no Speech API to detect), and the client re-reads on hydration. Support
// never changes for the life of the page, so the subscription is a no-op.
const speechSupported = useSyncExternalStore(
() => () => {},
() => getSpeechRecognition() !== null,
() => false,
);
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
// The textarea contents when dictation started; transcript is appended to it.
const dictationBaseRef = useRef("");
useEffect(() => {
setSpeechSupported(getSpeechRecognition() !== null);
return () => recognitionRef.current?.stop();
const recognition = recognitionRef;
return () => recognition.current?.stop();
}, []);
const toggleDictation = useCallback(() => {
+23 -3
View File
@@ -139,8 +139,14 @@ export function ChatPanel() {
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
// saved to the server after each exchange so history survives reloads.
const [threadId, setThreadId] = useState<string>(() => nanoid());
// Mirrored into a ref for the transport's fetch closure, which is memoized and
// must not be rebuilt per thread. Written in an effect rather than during
// render: a render can be thrown away or replayed, and mutating a ref there
// makes it observable — the write has to happen once the render is committed.
const threadIdRef = useRef(threadId);
threadIdRef.current = threadId;
useEffect(() => {
threadIdRef.current = threadId;
}, [threadId]);
// Skip the auto-save that would otherwise fire right after loading a thread
// (which would needlessly bump it to the top of the history).
const justLoadedRef = useRef(false);
@@ -214,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";
@@ -301,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]);
@@ -338,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) {
@@ -367,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]);
@@ -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.
@@ -453,10 +460,27 @@ export function PatientFormDialog({
/>
) : (
<form className="contents" onSubmit={handleSubmit}>
{showTabs && (
<div className="px-1 pt-1">
<Tabs
onValueChange={(value) => setTab(value as "add" | "show")}
value={tab}
>
<TabsList className="w-full">
<TabsTab value="add">{t("patientForm.tabs.add")}</TabsTab>
<TabsTab value="show">{t("patientForm.tabs.show")}</TabsTab>
</TabsList>
</Tabs>
</div>
)}
<DialogPanel
scrollFade={false}
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
>
{showTabs && tab === "show" && patient ? (
<PatientDetail patient={patient} />
) : (
<>
<Field label={t("patientForm.fileNumber")}>
<div className="flex items-center gap-2">
<Input
@@ -780,6 +804,8 @@ export function PatientFormDialog({
)}
<StagedFilesField onChange={setFiles} value={files} />
</>
)}
</DialogPanel>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
@@ -787,17 +813,19 @@ export function PatientFormDialog({
<p className="text-sm text-destructive sm:me-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
{t("patientForm.cancel")}
{tab === "show" ? t("patientForm.close") : t("patientForm.cancel")}
</DialogClose>
<Button disabled={!name.trim() || submitting} type="submit">
{submitting
? t("patientForm.saving")
: isReview
? t("patientForm.saveDraft")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
{tab !== "show" && (
<Button disabled={!name.trim() || submitting} type="submit">
{submitting
? t("patientForm.saving")
: isReview
? t("patientForm.saveDraft")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
)}
</DialogFooter>
</form>
)}
@@ -6,7 +6,7 @@
// array fields (invoice line items, inventory items) edited as add/remove rows.
import { Plus, X } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -196,10 +196,14 @@ export function RecordEditDialog({
const schema = EDIT_SCHEMAS[kind];
const [draft, setDraft] = useState<Rec>(record);
// Re-seed the draft whenever a fresh record is opened for editing.
useEffect(() => {
if (open) setDraft(record);
}, [open, record]);
// Re-seed the draft whenever a fresh record is opened for editing. Adjusted
// during render so the form never paints the previous record's values.
const [prevSeed, setPrevSeed] = useState<Rec | null>(null);
const seed = open ? record : null;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (seed) setDraft(seed);
}
const setField = (key: string, value: unknown) =>
setDraft((d) => ({ ...d, [key]: value }));
+1 -1
View File
@@ -75,7 +75,7 @@ const dotClass: Record<Kind, string> = {
// A round node with the label below it and hidden connection handles so edges
// meet the dot's centre cleanly. Memoized; it re-renders only when the hovered
// id changes (via context), never via React Flow re-measuring.
const RecordNode = memo(function RecordNode({ id, data }: NodeProps) {
const RecordNode = memo(function RecordNode({ data }: NodeProps) {
const { label, sub, kind, family } = data as RecordNodeData;
const hovered = useContext(HoverContext);
const focus: "active" | "dim" | null =
@@ -7,7 +7,7 @@ import {
Split,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
@@ -67,9 +67,14 @@ export function InvoiceDetailSheet({
const [count, setCount] = useState(3);
const [busy, setBusy] = useState(false);
useEffect(() => {
// Collapse the "show more" list back when a different invoice is shown.
// Adjusted during render so the sheet never paints the previous invoice's
// expanded state.
const [prevInvoiceId, setPrevInvoiceId] = useState(invoice?.id);
if (prevInvoiceId !== invoice?.id) {
setPrevInvoiceId(invoice?.id);
setCount(3);
}, [invoice?.id]);
}
if (!invoice) {
return (
@@ -172,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);
@@ -186,7 +190,7 @@ export function InvoiceFormDialog({
setLineItems(
invoice.lineItems.length ? invoice.lineItems : [emptyLine()],
);
} else {
} else if (open) {
setSelected(null);
setIssuedAt(new Date());
setAllowBackdate(false);
@@ -196,7 +200,7 @@ export function InvoiceFormDialog({
setNotes("");
setLineItems([emptyLine()]);
}
}, [open, mode, invoice]);
}
// Load patients lazily for the create combobox.
useEffect(() => {
@@ -76,7 +76,6 @@ function VideoTile({
speaking ? "border-success" : "border-transparent",
)}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
autoPlay
className={cn("size-full object-cover", !showVideo && "invisible")}
+11 -10
View File
@@ -59,7 +59,6 @@ export function MeetingsView() {
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
// pre-targeted at that person so the user can connect with them.
const deepLinkWith = searchParams.get("with");
const openedDeepLink = useRef<string | null>(null);
const openedWith = useRef<string | null>(null);
const [tab, setTab] = useState<Tab>("rooms");
@@ -104,16 +103,18 @@ export function MeetingsView() {
};
}, []);
// Auto-join a room deep-linked from an invite (?room=).
useEffect(() => {
if (!deepLinkRoom || rooms.length === 0) return;
if (openedDeepLink.current === deepLinkRoom) return;
// Auto-join a room deep-linked from an invite (?room=). Resolved during
// render once the room list arrives, so the room opens on the same paint the
// list does instead of flashing the default tab first.
const [openedDeepLink, setOpenedDeepLink] = useState<string | null>(null);
if (deepLinkRoom && rooms.length > 0 && openedDeepLink !== deepLinkRoom) {
const room = rooms.find((r) => r.id === deepLinkRoom);
if (!room) return;
openedDeepLink.current = deepLinkRoom;
setTab("rooms");
setActiveRoom(room);
}, [deepLinkRoom, rooms]);
if (room) {
setOpenedDeepLink(deepLinkRoom);
setTab("rooms");
setActiveRoom(room);
}
}
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
useEffect(() => {
@@ -54,12 +54,21 @@ export function ScheduleMeetingDialog({
const [picked, setPicked] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
// Re-seed the form each time the dialog opens. Adjusted during render so it
// never paints the previous meeting's title or participants.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set(defaultParticipants ?? []));
}
}
useEffect(() => {
if (!open) return;
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set(defaultParticipants ?? []));
listClinicMembers()
.then(setMembers)
.catch(() => setMembers([]));
@@ -8,11 +8,17 @@ import { useEffect, useState } from "react";
export function useSpeaking(stream: MediaStream | null): boolean {
const [speaking, setSpeaking] = useState(false);
// Stop reporting "speaking" the moment the stream goes away or changes, so a
// muted tile can't keep a stale ring. Adjusted during render rather than in
// the effect below, which is only for driving the Web Audio graph.
const [prevStream, setPrevStream] = useState(stream);
if (prevStream !== stream) {
setPrevStream(stream);
setSpeaking(false);
}
useEffect(() => {
if (!stream || stream.getAudioTracks().length === 0) {
setSpeaking(false);
return;
}
if (!stream || stream.getAudioTracks().length === 0) return;
let ctx: AudioContext | null = null;
let raf = 0;
try {
@@ -245,10 +245,15 @@ export function MessagesView() {
const [appts, setAppts] = useState<Appointment[]>([]);
const [apptQuery, setApptQuery] = useState("");
// Refs so the socket handler (registered once) reads current values.
// Refs so the socket handler (registered once) reads current values. Written
// in an effect rather than during render: a render can be thrown away or
// replayed, and mutating a ref there makes it observable — the write has to
// happen once the render is committed.
const selectedIdRef = useRef<string | null>(null);
const myIdRef = useRef<string>("");
myIdRef.current = myId;
useEffect(() => {
myIdRef.current = myId;
}, [myId]);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -354,7 +359,6 @@ export function MessagesView() {
openedDeepLink.current = deepLinkConversation;
open(deepLinkConversation);
// `open` is stable enough for this one-shot; deps intentionally minimal.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deepLinkConversation, conversations]);
const send = (event: FormEvent) => {
+3
View File
@@ -61,6 +61,9 @@ export function NotesView() {
return () => {
active = false;
};
// `t` intentionally omitted: it is only read to build a failure message,
// and re-fetching on a language change would be pointless.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startNew = () => {
@@ -1,11 +1,12 @@
"use client";
import { Check, Loader2, QrCode, Smartphone, X } from "lucide-react";
import { Check, Loader2, QrCode, ScanLine, Smartphone, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { BarcodeScanner } from "@/components/scan/barcode-scanner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -63,6 +64,15 @@ export function ImportFromWalletDialog({
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("number");
const [walletNumber, setWalletNumber] = useState("");
const [scanOpen, setScanOpen] = useState(false);
// Scanning the wallet code shown on the patient's phone (QR or 2D barcode)
// drops its wallet number straight into the field.
const handleScan = (value: string) => {
setScanOpen(false);
setMode("number");
setWalletNumber(value.trim());
};
const [temporary, setTemporary] = useState(false);
const [durationHours, setDurationHours] = useState<number>(24);
const [phase, setPhase] = useState<Phase>("form");
@@ -79,8 +89,12 @@ export function ImportFromWalletDialog({
}
};
// Reset everything whenever the dialog is (re)opened.
useEffect(() => {
// Reset everything whenever the dialog is (re)opened. Adjusted during render
// so a reopened dialog never flashes the previous import's wallet number or
// state before clearing.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setMode("number");
setWalletNumber("");
@@ -92,8 +106,10 @@ export function ImportFromWalletDialog({
setPairUri(null);
setReviewOpen(false);
}
return stopPolling;
}, [open]);
}
// Stop polling when the dialog closes or unmounts.
useEffect(() => stopPolling, [open]);
// Poll the request until the patient approves/denies on their device.
useEffect(() => {
@@ -296,13 +312,25 @@ export function ImportFromWalletDialog({
<span className="text-xs text-muted-foreground">
{t("patients.importApp.walletLabel")}
</span>
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
<div className="flex items-center gap-2">
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
<Button
aria-label={t("patients.importApp.scan")}
disabled={phase === "requesting"}
onClick={() => setScanOpen(true)}
size="icon"
type="button"
variant="outline"
>
<ScanLine className="size-4" />
</Button>
</div>
</label>
) : (
<p className="text-sm text-muted-foreground">
@@ -405,6 +433,14 @@ export function ImportFromWalletDialog({
patient={request.draft}
/>
) : null}
<BarcodeScanner
description={t("patients.importApp.scanHint")}
onDetected={handleScan}
onOpenChange={setScanOpen}
open={scanOpen}
title={t("patients.importApp.scan")}
/>
</>
);
}
@@ -102,14 +102,29 @@ export function PatientDetailSheet({
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
// Clear the previous chart the moment a different one is opened, so the sheet
// can't show one patient's data under another's name while the fetch is in
// flight. Adjusted during render rather than in the effects below, which are
// only for the fetching itself. Keyed on the role too, so losing clinical
// access drops the wallet state instead of leaving the "Push update" button
// lit from the previous render.
const chart = open ? `${fileNumber ?? ""}|${role ?? ""}` : null;
const [prevChart, setPrevChart] = useState<string | null>(null);
if (prevChart !== chart) {
setPrevChart(chart);
setWalletLinked(false);
if (chart) {
setStatus("loading");
setPatient(null);
setPrescriptions([]);
setAppointments([]);
setInvoices([]);
}
}
useEffect(() => {
if (!open || !fileNumber) return;
let active = true;
setStatus("loading");
setPatient(null);
setPrescriptions([]);
setAppointments([]);
setInvoices([]);
getPatient(fileNumber)
.then((data) => {
if (!active) return;
@@ -138,9 +153,10 @@ export function PatientDetailSheet({
// Whether this patient is wallet-linked (drives the "Push update" button).
// Separate from the main load so it re-checks once the role resolves without
// refetching the record. Only clinicians can push.
// refetching the record. Only clinicians can push. The reset rides on the
// same render-phase clear as the chart above, so the button can't stay lit
// from the previous patient.
useEffect(() => {
setWalletLinked(false);
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
let active = true;
getWalletLink(fileNumber)
@@ -76,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;
@@ -97,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();
@@ -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,8 +1,10 @@
"use client";
import { ScanLine } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { BarcodeScanner } from "@/components/scan/barcode-scanner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -15,6 +17,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 +51,12 @@ 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 [scannerOpen, setScannerOpen] = useState(false);
const reset = () => {
setName("");
@@ -58,7 +66,24 @@ 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 = (raw: string) => {
setScannerOpen(false);
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());
};
const submit = (event: FormEvent) => {
@@ -79,7 +104,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();
@@ -87,6 +114,7 @@ export function AddInventoryDialog({
};
return (
<>
<Dialog
onOpenChange={(o) => {
onOpenChange(o);
@@ -174,6 +202,26 @@ export function AddInventoryDialog({
/>
</Field>
</div>
<Field label={t("inventory.dialog.barcode")}>
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
onChange={(event) => setBarcode(event.target.value)}
placeholder={t("inventory.dialog.barcodePlaceholder")}
value={barcode}
/>
<Button
aria-label={t("inventory.dialog.scan")}
onClick={() => setScannerOpen(true)}
size="icon"
type="button"
variant="outline"
>
<ScanLine className="size-4" />
</Button>
</div>
</Field>
</DialogPanel>
<DialogFooter>
@@ -187,5 +235,11 @@ export function AddInventoryDialog({
</form>
</DialogPopup>
</Dialog>
<BarcodeScanner
onDetected={handleScan}
onOpenChange={setScannerOpen}
open={scannerOpen}
/>
</>
);
}
@@ -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);
@@ -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;
+26 -18
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/select";
import {
FieldLabel,
SettingsCard,
SettingsFrame,
ToggleRow,
} from "@/components/settings/settings-parts";
@@ -209,8 +210,8 @@ export function AIPanel() {
<>
{policy ? (
<SettingsFrame
bodyClassName="space-y-3"
description={t("settings.ai.availability.description")}
separated
title={t("settings.ai.availability.title")}
>
{isAdmin ? (
@@ -238,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}
@@ -248,17 +249,19 @@ export function AIPanel() {
? t("settings.ai.saving")
: t("settings.ai.saveChanges")}
</Button>
</div>
</SettingsCard>
) : null}
</>
) : (
<p className="text-sm text-muted-foreground">
{policy.aiEnabled
? policy.disabledForEmployees
? t("settings.ai.availability.readonlyEmployeesOnly")
: t("settings.ai.availability.readonlyEnabled")
: t("settings.ai.availability.readonlyDisabled")}
</p>
<SettingsCard className="p-5">
<p className="text-sm text-muted-foreground">
{policy.aiEnabled
? policy.disabledForEmployees
? t("settings.ai.availability.readonlyEmployeesOnly")
: t("settings.ai.availability.readonlyEnabled")
: t("settings.ai.availability.readonlyDisabled")}
</p>
</SettingsCard>
)}
</SettingsFrame>
) : null}
@@ -267,7 +270,7 @@ export function AIPanel() {
description={t("settings.ai.modeDescription")}
title={t("settings.ai.modeTitle")}
>
<div className="space-y-1.5">
<SettingsCard className="space-y-1.5 p-5">
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
<Select
onValueChange={(value) => set("mode", value as AiMode)}
@@ -290,7 +293,7 @@ export function AIPanel() {
<p className="text-xs text-muted-foreground">
{t(`settings.ai.mode${modeHintSuffix}Hint`)}
</p>
</div>
</SettingsCard>
</SettingsFrame>
{config.mode === "off" ? (
@@ -298,18 +301,20 @@ export function AIPanel() {
description={t("settings.ai.offDescription")}
title={t("settings.ai.offTitle")}
>
<p className="text-sm text-muted-foreground">
{t("settings.ai.offNote")}
</p>
<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
bodyClassName="space-y-5"
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="space-y-1.5">
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
@@ -396,13 +401,14 @@ export function AIPanel() {
</SelectPopup>
</Select>
</div>
</SettingsCard>
</SettingsFrame>
) : (
<SettingsFrame
bodyClassName="space-y-5"
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="space-y-1.5">
<FieldLabel>{t("settings.ai.ollamaBaseUrl")}</FieldLabel>
@@ -432,15 +438,16 @@ export function AIPanel() {
? t("settings.ai.testing")
: t("settings.ai.testConnection")}
</Button>
</SettingsCard>
</SettingsFrame>
)}
{config.mode !== "off" ? (
<SettingsFrame
bodyClassName="space-y-4"
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
@@ -464,6 +471,7 @@ export function AIPanel() {
? t("settings.ai.veilLocalNote")
: t("settings.ai.veilApiNote")}
</p>
</SettingsCard>
</SettingsFrame>
) : null}
@@ -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,7 +130,7 @@ export function CareTeamPanel({
}),
);
setPendingRemove(null);
void load();
reload();
};
return (
@@ -128,13 +139,13 @@ export function CareTeamPanel({
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,7 +154,7 @@ export function CareTeamPanel({
<UserPlus className="size-4" />
{t("settings.careTeam.addMember")}
</Button>
</div>
</SettingsCard>
)}
<SettingsCard className="divide-y divide-border">
@@ -211,7 +222,7 @@ export function CareTeamPanel({
{canManage && (
<AddStaffDialog
onCreated={() => void load()}
onCreated={reload}
onOpenChange={setAdding}
open={adding}
/>
@@ -225,7 +236,7 @@ export function CareTeamPanel({
selected?.role !== "owner"
}
member={selected}
onChanged={() => void load()}
onChanged={reload}
onOpenChange={(o) => !o && setSelected(null)}
onRemove={(m) => {
setSelected(null);
+68 -12
View File
@@ -1,7 +1,7 @@
"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";
@@ -11,30 +11,72 @@ import {
CardFrameAction,
CardFrameDescription,
CardFrameHeader,
CardFramePanel,
CardFrameTitle,
} from "@/components/ui/card";
import { Frame, FramePanel } from "@/components/ui/frame";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
// 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
// (with optional description + action) sitting above a padded body. Replaces the
// old plain-`div` SettingsCard pattern so every settings panel shares one framed
// look. Use `bodyClassName` to control the body layout (spacing/grid).
// 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,
bodyClassName,
separated = false,
}: {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
bodyClassName?: 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;
}) {
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>
<SeparatedFrameContext.Provider value={true}>
{children}
</SeparatedFrameContext.Provider>
</Frame>
);
}
return (
<CardFrame className={className}>
<CardFrameHeader className="border-b border-border/60">
@@ -44,30 +86,33 @@ export function SettingsFrame({
) : null}
{action ? <CardFrameAction>{action}</CardFrameAction> : null}
</CardFrameHeader>
<CardFramePanel className={bodyClassName}>{children}</CardFramePanel>
{children}
</CardFrame>
);
}
// Back-compat wrapper: existing panels compose with SettingsSection, which now
// Back-compat wrapper: existing panels compose with SettingsSection, which
// renders through the COSS frame surface so the whole settings page shares one
// framed look. The body keeps vertical spacing between stacked children.
// 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}
bodyClassName="space-y-4"
description={description}
separated={separated}
title={title}
>
{children}
@@ -78,7 +123,8 @@ export function SettingsSection({
// 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.
// 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,
@@ -86,6 +132,16 @@ export function SettingsCard({
className?: string;
children: ReactNode;
}) {
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>;
}
@@ -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,40 +339,38 @@ 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
+12 -4
View File
@@ -103,18 +103,26 @@ function AddTaskDialog({
const [priority, setPriority] = useState<Priority>("medium");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
// Re-seed the column when the dialog is opened from a specific column, and
// load the clinic's providers so a task can be assigned to a specific person.
// Re-seed the column when the dialog is opened from a specific column.
// Adjusted during render rather than in an effect, so the select never paints
// with the previous column's value.
const [prevSeed, setPrevSeed] = useState<string | null>(null);
const seed = open ? initialStatus : null;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (seed) setStatus(seed);
}
// Load the clinic's providers so a task can be assigned to a specific person.
useEffect(() => {
if (!open) return;
setStatus(initialStatus);
listProviders()
.then((list) => {
setProviders(list);
setProviderId((id) => id || (list[0]?.userId ?? ""));
})
.catch(() => setProviders([]));
}, [open, initialStatus]);
}, [open]);
const reset = () => {
setTitle("");
-20
View File
@@ -136,26 +136,6 @@ export function CardFrameFooter({
});
}
// The padded body of a frame: content that sits between the frame header and
// footer. Mirrors the other CardFrame* subcomponents (useRender + data-slot) so
// a frame can be composed entirely from primitives instead of a raw <div>.
export function CardFramePanel({
className,
render,
...props
}: useRender.ComponentProps<"div">): React.ReactElement {
const defaultProps = {
className: cn("p-5", className),
"data-slot": "card-frame-panel",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
export function CardHeader({
className,
render,
+94
View File
@@ -0,0 +1,94 @@
import type React from "react";
import { cn } from "@/lib/utils";
// COSS "Frame" primitive (coss.com/ui/docs/components/frame). A Frame is a muted
// tray that holds one or more FramePanels; multiple panels are "Separated
// Panels" — spaced apart with `mt-1` by default (via the adjacent-sibling
// selector below), each a self-contained bordered card. This is the opposite of
// `CardFrame` in card.tsx, which fuses its cards into one flush surface.
export function Frame({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"relative flex flex-col rounded-2xl bg-muted/72 p-1",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
className,
)}
data-slot="frame"
{...props}
/>
);
}
export function FramePanel({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"relative rounded-xl border bg-background bg-clip-padding p-5 shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
className,
)}
data-slot="frame-panel"
{...props}
/>
);
}
export function FrameHeader({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("flex flex-col px-5 py-4", className)}
data-slot="frame-header"
{...props}
/>
);
}
export function FrameTitle({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("font-semibold text-sm", className)}
data-slot="frame-title"
{...props}
/>
);
}
export function FrameDescription({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-description"
{...props}
/>
);
}
export function FrameFooter({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("px-5 py-4", className)}
data-slot="frame-footer"
{...props}
/>
);
}
+4 -1
View File
@@ -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
+11 -5
View File
@@ -28,15 +28,21 @@ export function useWalletSync(fileNumber: string | null | undefined) {
const [update, setUpdate] = useState<WalletUpdate | null>(null);
const [error, setError] = useState<string | null>(null);
// Drop the previous patient's link state as soon as the selection changes,
// adjusted during render: `linked` gates the wallet step, so carrying it over
// for even one render would offer to push another patient's record.
const [prevFileNumber, setPrevFileNumber] = useState(fileNumber);
if (prevFileNumber !== fileNumber) {
setPrevFileNumber(fileNumber);
setLinked(false);
setChecking(Boolean(fileNumber));
}
// Resolve link status whenever the chosen patient changes. A 404 simply means
// "not wallet-backed", so failures collapse to `linked = false`.
useEffect(() => {
if (!fileNumber) {
setLinked(false);
return;
}
if (!fileNumber) return;
let active = true;
setChecking(true);
getWalletLink(fileNumber)
.then(() => {
if (active) setLinked(true);
+20
View File
@@ -12,6 +12,26 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Vendored component libraries we don't hand-maintain. Both are pulled in
// from upstream registries and re-linting them just reports upstream's
// style back at us — `react-hooks/refs` and `set-state-in-effect` alone
// account for most of it, and "fixing" them means diverging from upstream
// and eating the conflicts on every update.
//
// ai-elements additionally has pre-existing Base UI type drift already
// carved out via typescript.ignoreBuildErrors in next.config.ts; this is
// the lint half of that same carve-out. See CLAUDE.md.
"components/ai-elements/**",
"components/charts/**",
// Registry components, added/updated via `npx shadcn add` rather than
// hand-written. Two carry upstream patterns the compiler rules dislike
// (carousel publishes its api to the parent from an effect; the sidebar
// skeleton picks a random width), and editing them here would be undone by
// the next registry update.
"components/ui/carousel.tsx",
"components/ui/sidebar.tsx",
]),
]);
+18 -13
View File
@@ -2,18 +2,23 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
function subscribe(onChange: () => void) {
const mql = window.matchMedia(QUERY)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}
// `useSyncExternalStore` rather than an effect that seeds state on mount: the
// viewport is an external store, and reading it through the subscription keeps
// the value correct on the first paint instead of rendering one frame at the
// wrong breakpoint. The server snapshot is `false` (desktop-first) because
// there's no viewport to measure during SSR.
export function useIsMobile() {
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => false,
)
}
+85
View File
@@ -0,0 +1,85 @@
// Minimal GS1 Application Identifier parser for medication barcodes (the GS1
// DataMatrix printed on drug packaging). We only care about the AIs a pharmacy
// add-item flow can use: 01 GTIN, 17 expiry, 10 lot/batch, 21 serial. Variable
// -length fields are terminated by the FNC1/GS separator (ASCII 29); the AIs
// below have fixed lengths per the GS1 General Specifications.
export type Gs1Fields = {
gtin?: string;
/** AI 17 normalised to YYYY-MM-DD. */
expiry?: string;
lot?: string;
serial?: string;
};
const GS = "\x1d";
// value length (excluding the 2-digit AI) for the fixed-length AIs we handle.
const FIXED_LEN: Record<string, number> = {
"00": 18,
"01": 14,
"02": 14,
"11": 6,
"12": 6,
"13": 6,
"15": 6,
"16": 6,
"17": 6,
"20": 2,
};
function yymmddToIso(v: string): string | undefined {
if (!/^\d{6}$/.test(v)) return undefined;
const year = 2000 + Number(v.slice(0, 2));
const month = Number(v.slice(2, 4));
let day = Number(v.slice(4, 6));
if (month < 1 || month > 12) return undefined;
// GS1 allows DD=00 to mean "end of the month".
if (day === 0) day = new Date(year, month, 0).getDate();
const pad = (n: number) => String(n).padStart(2, "0");
return `${year}-${pad(month)}-${pad(day)}`;
}
// Parse a scanned string as GS1. Returns null when it isn't GS1-structured (a
// plain EAN-13 / Code128 barcode), so callers can fall back to the raw value.
export function parseGs1(raw: string): Gs1Fields | null {
if (!raw) return null;
let s = raw;
// Strip a leading symbology identifier (e.g. "]d2", "]C1", "]e0").
if (s.startsWith("]")) s = s.slice(3);
if (s.startsWith(GS)) s = s.slice(1);
// GS1 medication codes lead with (01) GTIN; bail early otherwise so a plain
// numeric barcode isn't mis-parsed.
if (!s.startsWith("01")) return null;
const out: Gs1Fields = {};
let i = 0;
let matched = false;
while (i + 2 <= s.length) {
const ai = s.slice(i, i + 2);
i += 2;
const fixed = FIXED_LEN[ai];
let value: string;
if (fixed != null) {
value = s.slice(i, i + fixed);
i += fixed;
} else {
const gsIdx = s.indexOf(GS, i);
if (gsIdx === -1) {
value = s.slice(i);
i = s.length;
} else {
value = s.slice(i, gsIdx);
i = gsIdx + 1;
}
}
if (s[i] === GS) i += 1; // consume a separator trailing a fixed field
if (!value) break;
matched = true;
if (ai === "01") out.gtin = value;
else if (ai === "17") out.expiry = yymmddToIso(value);
else if (ai === "10") out.lot = value;
else if (ai === "21") out.serial = value;
}
return matched ? out : null;
}
@@ -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": "يُحذف السجل تلقائيًا من هذه العيادة عند انتهاء فترة المشاركة.",
@@ -702,6 +711,10 @@
"location": "الموقع",
"locationPlaceholder": "مثال: A3",
"expires": "تنتهي الصلاحية",
"barcode": "الباركود / NDC",
"barcodePlaceholder": "امسح أو اكتب رمزًا",
"scan": "مسح الباركود",
"scannedTitle": "تم مسح الباركود",
"cancel": "إلغاء",
"submit": "إضافة عنصر",
"nameRequiredTitle": "اسم الدواء مطلوب",
@@ -1566,6 +1579,11 @@
"moreActions": "إجراءات إضافية"
},
"patientForm": {
"close": "إغلاق",
"tabs": {
"add": "إضافة",
"show": "عرض"
},
"editTitle": "تعديل السجل",
"createTitle": "إضافة مريض",
"editDescription": "حدّث سجل {{name}} وأضف بيانات جديدة.",
@@ -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.",
@@ -686,6 +695,10 @@
"location": "Standort",
"locationPlaceholder": "z. B. A3",
"expires": "Verfällt",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Code scannen oder eingeben",
"scan": "Barcode scannen",
"scannedTitle": "Barcode gescannt",
"cancel": "Abbrechen",
"submit": "Artikel hinzufügen",
"nameRequiredTitle": "Medikamentenname erforderlich",
@@ -1546,6 +1559,11 @@
"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.",
@@ -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.",
@@ -686,6 +695,10 @@
"location": "Location",
"locationPlaceholder": "e.g. A3",
"expires": "Expires",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Scan or type a code",
"scan": "Scan barcode",
"scannedTitle": "Barcode scanned",
"cancel": "Cancel",
"submit": "Add item",
"nameRequiredTitle": "Medication name required",
@@ -1546,6 +1559,11 @@
"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.",
@@ -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.",
@@ -686,6 +695,10 @@
"location": "Emplacement",
"locationPlaceholder": "ex. A3",
"expires": "Expire le",
"barcode": "Code-barres / NDC",
"barcodePlaceholder": "Scanner ou saisir un code",
"scan": "Scanner le code-barres",
"scannedTitle": "Code-barres scanné",
"cancel": "Annuler",
"submit": "Ajouter l'article",
"nameRequiredTitle": "Nom du médicament requis",
@@ -1546,6 +1559,11 @@
"moreActions": "Plus dactions"
},
"patientForm": {
"close": "Fermer",
"tabs": {
"add": "Ajouter",
"show": "Afficher"
},
"editTitle": "Modifier le dossier",
"createTitle": "Ajouter un patient",
"editDescription": "Mettez à jour le dossier de {{name}} et ajoutez de nouvelles données.",
@@ -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.",
@@ -686,6 +695,10 @@
"location": "Goobta",
"locationPlaceholder": "tusaale A3",
"expires": "Dhacaya",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Iskaan garee ama qor koodh",
"scan": "Iskaan garee barcode",
"scannedTitle": "Barcode la iskaan gareeyay",
"cancel": "Jooji",
"submit": "Ku dar shay",
"nameRequiredTitle": "Magaca daawada ayaa loo baahan yahay",
@@ -1546,6 +1559,11 @@
"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.",
+2
View File
@@ -11,6 +11,7 @@ export type InventoryItem = {
stockQuantity: number;
reorderThreshold: number;
location: string;
barcode: string | null;
expiresAt: string | null;
notes: string | null;
createdAt: string;
@@ -26,6 +27,7 @@ export type InventoryInput = {
stockQuantity?: number;
reorderThreshold?: number;
location?: string;
barcode?: string | null;
expiresAt?: string | null;
notes?: string | null;
};
+48 -2
View File
@@ -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",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.14.2",
"version": "0.16.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.14.2",
"version": "0.16.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"