mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75502ceb60 | |||
| b285158fec | |||
| 0754ce96a4 | |||
| bac3e7a1d0 | |||
| b9d4d4f458 | |||
| aa535f08c4 | |||
| 000591f87f | |||
| 7312d15d67 | |||
| e8f3ed9ffe | |||
| 7c92c03eed | |||
| 5961d969b1 | |||
| fa566ced86 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -6,5 +6,15 @@ dist
|
||||
*.log
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
docker-compose.tunnel.yml
|
||||
README.md
|
||||
CLAUDE.md
|
||||
# Deploy manifests for other targets — not needed inside the image.
|
||||
fly.toml
|
||||
railway.json
|
||||
render.yaml
|
||||
# Tracked, but only the runtime migrator (dist/migrate.js) runs in the image.
|
||||
drizzle.config.ts
|
||||
.env.example
|
||||
|
||||
+7
-1
@@ -19,7 +19,13 @@ RUN npm ci --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/drizzle ./drizzle
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# Strip CR before chmod. .gitattributes keeps this file LF on fresh clones, but
|
||||
# a Windows checkout made before that existed still has CRLF on disk, and a
|
||||
# "#!/bin/sh\r" shebang fails at container start with a "no such file or
|
||||
# directory" error that names the file it just copied in. Cheap to make the
|
||||
# image self-healing rather than rely on every contributor re-cloning.
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
EXPOSE 4000
|
||||
# The entrypoint auto-generates any missing secrets, then we apply migrations
|
||||
# and start the API.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "inventory" ADD COLUMN "barcode" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
||||
"when": 1783530491321,
|
||||
"tag": "0035_slippery_retro_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1784406410448,
|
||||
"tag": "0036_aspiring_expediter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.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.",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -21,6 +21,7 @@ function toInventoryItem(row: InventoryRow): InventoryItem {
|
||||
stockQuantity: row.stockQuantity,
|
||||
reorderThreshold: row.reorderThreshold,
|
||||
location: row.location,
|
||||
barcode: row.barcode,
|
||||
expiresAt: row.expiresAt,
|
||||
notes: row.notes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -38,6 +39,7 @@ function columns(orgId: string, input: InventoryInput, createdBy?: string) {
|
||||
stockQuantity: input.stockQuantity,
|
||||
reorderThreshold: input.reorderThreshold,
|
||||
location: input.location,
|
||||
barcode: input.barcode ?? null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
notes: input.notes ?? null,
|
||||
...(createdBy ? { createdBy } : {}),
|
||||
|
||||
@@ -40,7 +40,17 @@ export function sendToWallet(
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
|
||||
const hub = hubs.get(orgId);
|
||||
if (!hub) {
|
||||
// No live hub means the push is dropped on the floor. The row still sits
|
||||
// pending and `wallet:online` replays it once a device reconnects, but say
|
||||
// so — a silent no-op here looks exactly like a wallet that ignored us.
|
||||
console.warn(
|
||||
`Temetro Network: no hub connection for clinic ${orgId}; "${event}" not sent (queued for wallet:online replay).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
hub.emit("wallet:send", { walletNumber, event, data });
|
||||
}
|
||||
|
||||
// Tell the relay to expect a device response for `requestId` and route it back
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -11,6 +11,7 @@ export type InventoryItem = {
|
||||
stockQuantity: number;
|
||||
reorderThreshold: number;
|
||||
location: string;
|
||||
barcode: string | null;
|
||||
expiresAt: string | null; // YYYY-MM-DD
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -7,3 +7,6 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
tsconfig.tsbuildinfo
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -78,17 +79,23 @@ export function ChatInput({
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
|
||||
// first client render agree (button starts disabled, enabled by the effect).
|
||||
const [speechSupported, setSpeechSupported] = useState(false);
|
||||
// Voice dictation (Web Speech API). Read through useSyncExternalStore so SSR
|
||||
// markup and the first client render agree: the server snapshot is `false`
|
||||
// (no Speech API to detect), and the client re-reads on hydration. Support
|
||||
// never changes for the life of the page, so the subscription is a no-op.
|
||||
const speechSupported = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => getSpeechRecognition() !== null,
|
||||
() => false,
|
||||
);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
// The textarea contents when dictation started; transcript is appended to it.
|
||||
const dictationBaseRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
setSpeechSupported(getSpeechRecognition() !== null);
|
||||
return () => recognitionRef.current?.stop();
|
||||
const recognition = recognitionRef;
|
||||
return () => recognition.current?.stop();
|
||||
}, []);
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
|
||||
@@ -139,8 +139,14 @@ export function ChatPanel() {
|
||||
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
|
||||
// saved to the server after each exchange so history survives reloads.
|
||||
const [threadId, setThreadId] = useState<string>(() => nanoid());
|
||||
// Mirrored into a ref for the transport's fetch closure, which is memoized and
|
||||
// must not be rebuilt per thread. Written in an effect rather than during
|
||||
// render: a render can be thrown away or replayed, and mutating a ref there
|
||||
// makes it observable — the write has to happen once the render is committed.
|
||||
const threadIdRef = useRef(threadId);
|
||||
threadIdRef.current = threadId;
|
||||
useEffect(() => {
|
||||
threadIdRef.current = threadId;
|
||||
}, [threadId]);
|
||||
// Skip the auto-save that would otherwise fire right after loading a thread
|
||||
// (which would needlessly bump it to the top of the history).
|
||||
const justLoadedRef = useRef(false);
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -59,7 +59,6 @@ export function MeetingsView() {
|
||||
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
|
||||
// pre-targeted at that person so the user can connect with them.
|
||||
const deepLinkWith = searchParams.get("with");
|
||||
const openedDeepLink = useRef<string | null>(null);
|
||||
const openedWith = useRef<string | null>(null);
|
||||
|
||||
const [tab, setTab] = useState<Tab>("rooms");
|
||||
@@ -104,16 +103,18 @@ export function MeetingsView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-join a room deep-linked from an invite (?room=).
|
||||
useEffect(() => {
|
||||
if (!deepLinkRoom || rooms.length === 0) return;
|
||||
if (openedDeepLink.current === deepLinkRoom) return;
|
||||
// Auto-join a room deep-linked from an invite (?room=). Resolved during
|
||||
// render once the room list arrives, so the room opens on the same paint the
|
||||
// list does instead of flashing the default tab first.
|
||||
const [openedDeepLink, setOpenedDeepLink] = useState<string | null>(null);
|
||||
if (deepLinkRoom && rooms.length > 0 && openedDeepLink !== deepLinkRoom) {
|
||||
const room = rooms.find((r) => r.id === deepLinkRoom);
|
||||
if (!room) return;
|
||||
openedDeepLink.current = deepLinkRoom;
|
||||
setTab("rooms");
|
||||
setActiveRoom(room);
|
||||
}, [deepLinkRoom, rooms]);
|
||||
if (room) {
|
||||
setOpenedDeepLink(deepLinkRoom);
|
||||
setTab("rooms");
|
||||
setActiveRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -54,12 +54,21 @@ export function ScheduleMeetingDialog({
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Re-seed the form each time the dialog opens. Adjusted during render so it
|
||||
// never paints the previous meeting's title or participants.
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setTitle("");
|
||||
setDate(defaultDate ?? "");
|
||||
setTime("09:00");
|
||||
setPicked(new Set(defaultParticipants ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle("");
|
||||
setDate(defaultDate ?? "");
|
||||
setTime("09:00");
|
||||
setPicked(new Set(defaultParticipants ?? []));
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
|
||||
@@ -8,11 +8,17 @@ import { useEffect, useState } from "react";
|
||||
export function useSpeaking(stream: MediaStream | null): boolean {
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
|
||||
// Stop reporting "speaking" the moment the stream goes away or changes, so a
|
||||
// muted tile can't keep a stale ring. Adjusted during render rather than in
|
||||
// the effect below, which is only for driving the Web Audio graph.
|
||||
const [prevStream, setPrevStream] = useState(stream);
|
||||
if (prevStream !== stream) {
|
||||
setPrevStream(stream);
|
||||
setSpeaking(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || stream.getAudioTracks().length === 0) {
|
||||
setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
if (!stream || stream.getAudioTracks().length === 0) return;
|
||||
let ctx: AudioContext | null = null;
|
||||
let raf = 0;
|
||||
try {
|
||||
|
||||
@@ -245,10 +245,15 @@ export function MessagesView() {
|
||||
const [appts, setAppts] = useState<Appointment[]>([]);
|
||||
const [apptQuery, setApptQuery] = useState("");
|
||||
|
||||
// Refs so the socket handler (registered once) reads current values.
|
||||
// Refs so the socket handler (registered once) reads current values. Written
|
||||
// in an effect rather than during render: a render can be thrown away or
|
||||
// replayed, and mutating a ref there makes it observable — the write has to
|
||||
// happen once the render is committed.
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const myIdRef = useRef<string>("");
|
||||
myIdRef.current = myId;
|
||||
useEffect(() => {
|
||||
myIdRef.current = myId;
|
||||
}, [myId]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -354,7 +359,6 @@ export function MessagesView() {
|
||||
openedDeepLink.current = deepLinkConversation;
|
||||
open(deepLinkConversation);
|
||||
// `open` is stable enough for this one-shot; deps intentionally minimal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deepLinkConversation, conversations]);
|
||||
|
||||
const send = (event: FormEvent) => {
|
||||
|
||||
@@ -61,6 +61,9 @@ export function NotesView() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// `t` intentionally omitted: it is only read to build a failure message,
|
||||
// and re-fetching on a language change would be pointless.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const startNew = () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, QrCode, Smartphone, X } from "lucide-react";
|
||||
import { Check, Loader2, QrCode, ScanLine, Smartphone, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import QRCodeSvg from "react-qr-code";
|
||||
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { BarcodeScanner } from "@/components/scan/barcode-scanner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -63,6 +64,15 @@ export function ImportFromWalletDialog({
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<Mode>("number");
|
||||
const [walletNumber, setWalletNumber] = useState("");
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
|
||||
// Scanning the wallet code shown on the patient's phone (QR or 2D barcode)
|
||||
// drops its wallet number straight into the field.
|
||||
const handleScan = (value: string) => {
|
||||
setScanOpen(false);
|
||||
setMode("number");
|
||||
setWalletNumber(value.trim());
|
||||
};
|
||||
const [temporary, setTemporary] = useState(false);
|
||||
const [durationHours, setDurationHours] = useState<number>(24);
|
||||
const [phase, setPhase] = useState<Phase>("form");
|
||||
@@ -79,8 +89,12 @@ export function ImportFromWalletDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// Reset everything whenever the dialog is (re)opened.
|
||||
useEffect(() => {
|
||||
// Reset everything whenever the dialog is (re)opened. Adjusted during render
|
||||
// so a reopened dialog never flashes the previous import's wallet number or
|
||||
// state before clearing.
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setMode("number");
|
||||
setWalletNumber("");
|
||||
@@ -92,8 +106,10 @@ export function ImportFromWalletDialog({
|
||||
setPairUri(null);
|
||||
setReviewOpen(false);
|
||||
}
|
||||
return stopPolling;
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
// Stop polling when the dialog closes or unmounts.
|
||||
useEffect(() => stopPolling, [open]);
|
||||
|
||||
// Poll the request until the patient approves/denies on their device.
|
||||
useEffect(() => {
|
||||
@@ -296,13 +312,25 @@ export function ImportFromWalletDialog({
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("patients.importApp.walletLabel")}
|
||||
</span>
|
||||
<Input
|
||||
autoFocus
|
||||
disabled={phase === "requesting"}
|
||||
onChange={(e) => setWalletNumber(e.target.value)}
|
||||
placeholder={t("patients.importApp.walletPlaceholder")}
|
||||
value={walletNumber}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
disabled={phase === "requesting"}
|
||||
onChange={(e) => setWalletNumber(e.target.value)}
|
||||
placeholder={t("patients.importApp.walletPlaceholder")}
|
||||
value={walletNumber}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("patients.importApp.scan")}
|
||||
disabled={phase === "requesting"}
|
||||
onClick={() => setScanOpen(true)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ScanLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -405,6 +433,14 @@ export function ImportFromWalletDialog({
|
||||
patient={request.draft}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<BarcodeScanner
|
||||
description={t("patients.importApp.scanHint")}
|
||||
onDetected={handleScan}
|
||||
onOpenChange={setScanOpen}
|
||||
open={scanOpen}
|
||||
title={t("patients.importApp.scan")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,14 +102,29 @@ export function PatientDetailSheet({
|
||||
// Bumped on open so the editor remounts with the latest patient data.
|
||||
const [editKey, setEditKey] = useState(0);
|
||||
|
||||
// Clear the previous chart the moment a different one is opened, so the sheet
|
||||
// can't show one patient's data under another's name while the fetch is in
|
||||
// flight. Adjusted during render rather than in the effects below, which are
|
||||
// only for the fetching itself. Keyed on the role too, so losing clinical
|
||||
// access drops the wallet state instead of leaving the "Push update" button
|
||||
// lit from the previous render.
|
||||
const chart = open ? `${fileNumber ?? ""}|${role ?? ""}` : null;
|
||||
const [prevChart, setPrevChart] = useState<string | null>(null);
|
||||
if (prevChart !== chart) {
|
||||
setPrevChart(chart);
|
||||
setWalletLinked(false);
|
||||
if (chart) {
|
||||
setStatus("loading");
|
||||
setPatient(null);
|
||||
setPrescriptions([]);
|
||||
setAppointments([]);
|
||||
setInvoices([]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !fileNumber) return;
|
||||
let active = true;
|
||||
setStatus("loading");
|
||||
setPatient(null);
|
||||
setPrescriptions([]);
|
||||
setAppointments([]);
|
||||
setInvoices([]);
|
||||
getPatient(fileNumber)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
@@ -138,9 +153,10 @@ export function PatientDetailSheet({
|
||||
|
||||
// Whether this patient is wallet-linked (drives the "Push update" button).
|
||||
// Separate from the main load so it re-checks once the role resolves without
|
||||
// refetching the record. Only clinicians can push.
|
||||
// refetching the record. Only clinicians can push. The reset rides on the
|
||||
// same render-phase clear as the chart above, so the button can't stay lit
|
||||
// from the previous patient.
|
||||
useEffect(() => {
|
||||
setWalletLinked(false);
|
||||
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
|
||||
let active = true;
|
||||
getWalletLink(fileNumber)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -125,7 +125,10 @@ interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
function TimelineIndicator({
|
||||
asChild = false,
|
||||
// Accepted for API parity but unused: COSS/Base UI composes with `render`,
|
||||
// not `asChild`. Destructured so it never lands on the DOM node.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
asChild: _asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
|
||||
@@ -28,15 +28,21 @@ export function useWalletSync(fileNumber: string | null | undefined) {
|
||||
const [update, setUpdate] = useState<WalletUpdate | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Drop the previous patient's link state as soon as the selection changes,
|
||||
// adjusted during render: `linked` gates the wallet step, so carrying it over
|
||||
// for even one render would offer to push another patient's record.
|
||||
const [prevFileNumber, setPrevFileNumber] = useState(fileNumber);
|
||||
if (prevFileNumber !== fileNumber) {
|
||||
setPrevFileNumber(fileNumber);
|
||||
setLinked(false);
|
||||
setChecking(Boolean(fileNumber));
|
||||
}
|
||||
|
||||
// Resolve link status whenever the chosen patient changes. A 404 simply means
|
||||
// "not wallet-backed", so failures collapse to `linked = false`.
|
||||
useEffect(() => {
|
||||
if (!fileNumber) {
|
||||
setLinked(false);
|
||||
return;
|
||||
}
|
||||
if (!fileNumber) return;
|
||||
let active = true;
|
||||
setChecking(true);
|
||||
getWalletLink(fileNumber)
|
||||
.then(() => {
|
||||
if (active) setLinked(true);
|
||||
|
||||
@@ -12,6 +12,26 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
|
||||
// Vendored component libraries we don't hand-maintain. Both are pulled in
|
||||
// from upstream registries and re-linting them just reports upstream's
|
||||
// style back at us — `react-hooks/refs` and `set-state-in-effect` alone
|
||||
// account for most of it, and "fixing" them means diverging from upstream
|
||||
// and eating the conflicts on every update.
|
||||
//
|
||||
// ai-elements additionally has pre-existing Base UI type drift already
|
||||
// carved out via typescript.ignoreBuildErrors in next.config.ts; this is
|
||||
// the lint half of that same carve-out. See CLAUDE.md.
|
||||
"components/ai-elements/**",
|
||||
"components/charts/**",
|
||||
|
||||
// Registry components, added/updated via `npx shadcn add` rather than
|
||||
// hand-written. Two carry upstream patterns the compiler rules dislike
|
||||
// (carousel publishes its api to the parent from an effect; the sidebar
|
||||
// skeleton picks a random width), and editing them here would be undone by
|
||||
// the next registry update.
|
||||
"components/ui/carousel.tsx",
|
||||
"components/ui/sidebar.tsx",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,18 +2,23 @@ import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
function subscribe(onChange: () => void) {
|
||||
const mql = window.matchMedia(QUERY)
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}
|
||||
|
||||
// `useSyncExternalStore` rather than an effect that seeds state on mount: the
|
||||
// viewport is an external store, and reading it through the subscription keeps
|
||||
// the value correct on the first paint instead of rendering one frame at the
|
||||
// wrong breakpoint. The server snapshot is `false` (desktop-first) because
|
||||
// there's no viewport to measure during SSR.
|
||||
export function useIsMobile() {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(QUERY).matches,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 d’actions"
|
||||
},
|
||||
"patientForm": {
|
||||
"close": "Fermer",
|
||||
"tabs": {
|
||||
"add": "Ajouter",
|
||||
"show": "Afficher"
|
||||
},
|
||||
"editTitle": "Modifier le dossier",
|
||||
"createTitle": "Ajouter un patient",
|
||||
"editDescription": "Mettez à jour le dossier de {{name}} et ajoutez de nouvelles données.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -11,6 +11,7 @@ export type InventoryItem = {
|
||||
stockQuantity: number;
|
||||
reorderThreshold: number;
|
||||
location: string;
|
||||
barcode: string | null;
|
||||
expiresAt: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
@@ -26,6 +27,7 @@ export type InventoryInput = {
|
||||
stockQuantity?: number;
|
||||
reorderThreshold?: number;
|
||||
location?: string;
|
||||
barcode?: string | null;
|
||||
expiresAt?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
Generated
+48
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.15.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.206",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
@@ -32,6 +32,7 @@
|
||||
"@visx/scale": "^4.0.1-alpha.0",
|
||||
"@visx/shape": "^4.0.1-alpha.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"@zxing/browser": "^0.2.1",
|
||||
"ai": "^6.0.193",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"better-auth": "^1.6.13",
|
||||
@@ -6255,6 +6256,41 @@
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/browser": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz",
|
||||
"integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "^0.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@zxing/library": "^0.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/library": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
|
||||
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ts-custom-error": "^3.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 24.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@zxing/text-encoding": "~0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zxing/text-encoding": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
||||
"license": "(Unlicense OR Apache-2.0)",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -16002,6 +16038,16 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-custom-error": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-dedent": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
|
||||
|
||||
@@ -1,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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.14.2",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user