mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 12:18:56 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7312d15d67 | |||
| e8f3ed9ffe | |||
| 7c92c03eed | |||
| 5961d969b1 | |||
| fa566ced86 | |||
| 62a5f39683 | |||
| 6127a0ac42 | |||
| 352ca65838 | |||
| 44d653ffbd | |||
| 76da310766 | |||
| c326e9f794 | |||
| b299501ab2 | |||
| 17209a2cf1 | |||
| bd8fdfadda |
@@ -0,0 +1,32 @@
|
||||
# Normalize line endings so the repo builds the same on Windows, macOS and Linux.
|
||||
#
|
||||
# Git for Windows installs with core.autocrlf=true by default, which rewrites
|
||||
# checked-out files to CRLF. That is harmless for source we only ever compile,
|
||||
# but fatal for anything the Docker images execute: a shell script whose shebang
|
||||
# becomes "#!/bin/sh\r" fails at container start with
|
||||
# exec /usr/local/bin/docker-entrypoint.sh: no such file or directory
|
||||
# which names the file it just copied in and reads like the file is missing.
|
||||
* text=auto
|
||||
|
||||
# Scripts that run inside a Linux container must stay LF regardless of platform.
|
||||
*.sh text eol=lf
|
||||
docker-entrypoint.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
.dockerignore text eol=lf
|
||||
|
||||
# Lockfiles: keep LF so they don't churn across platforms.
|
||||
package-lock.json text eol=lf
|
||||
|
||||
# Binary assets — never touch these.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.pdf binary
|
||||
+116
@@ -7,6 +7,122 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.15.0] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- **Prescriptions pushed to a wallet now actually arrive.** `createPrescription` writes to the
|
||||
`prescriptions` table, but the sealed bundle only carried `patient.medications`, which comes from
|
||||
the separate `patient_medications` table and never grows when a prescription is written. The
|
||||
patient approved a bundle identical to what they already had, so nothing appeared in the app's
|
||||
Prescriptions section — the "New prescription: X" line in `changes` is display text, not data.
|
||||
The bundle now ships `prescriptions` alongside the appointments and invoices that were already
|
||||
handled this way, keeping prescriber/status/dates (`backend/src/services/wallet-updates.ts`).
|
||||
- **No more bogus "this clinic's key changed" warnings.** Update events now carry a stable
|
||||
`clinicId`. Wallets pinned a clinic's signing key against its display *name*, which is mutable
|
||||
and falls back to the literal "A clinic" for unnamed orgs — so two unnamed clinics collided on
|
||||
one pin and the second always warned.
|
||||
- **Silent drops on the wallet-push path are now logged.** `sendToWallet` no-ops when an org has no
|
||||
live relay hub, and `applyUpdateResponse` returned `null` without a word when a response arrived
|
||||
unsigned or already resolved. From the clinic side both looked exactly like a patient who never
|
||||
tapped Approve.
|
||||
- **Windows checkouts no longer break the backend image.** The repo had no `.gitattributes`, so Git
|
||||
for Windows' default `core.autocrlf=true` rewrote `docker-entrypoint.sh` to CRLF; the container
|
||||
then died with `exec /usr/local/bin/docker-entrypoint.sh: no such file or directory`. Line
|
||||
endings are pinned, and the image strips CR before `chmod` so existing checkouts are fixed too.
|
||||
- **Settings frames use the COSS default padding.** `CardFramePanel` (added in 0.14.0) isn't part of
|
||||
the COSS registry: its `p-5` inset content twice over, and wrapping the body made every card a
|
||||
grandchild of `CardFrame`, so the frame's direct-child selectors never applied and each card drew
|
||||
its own border — a box inside a box. Body padding now sits on the card, where COSS puts it.
|
||||
- **99 lint errors cleared.** `npm run lint` had been failing (the build hides it via
|
||||
`eslint.ignoreDuringBuilds`). Vendored `components/charts` and `components/ai-elements` are now
|
||||
ignored; the 31 in our own code are fixed. Two were real bugs: the employee dialog could keep a
|
||||
typed password when switching to another member, and the wallet-sync hook could carry a patient's
|
||||
`linked` state to a newly-selected patient, briefly offering to push a record to someone else's
|
||||
wallet.
|
||||
|
||||
### Changed
|
||||
- The clinic→wallet update event carries `clinicId`, and the sealed bundle carries `prescriptions`.
|
||||
Both are additive; older wallets ignore them. See the
|
||||
[signing API docs](https://docs.temetro.com/docs/api/signing#clinic--wallet-record-updates).
|
||||
|
||||
## [0.14.2] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- **Docker images build on ARM64 again.** Next 16's default Turbopack production build has no native
|
||||
bindings for `linux/arm64` in the Alpine image, so `docker compose up --build` failed with
|
||||
"Turbopack is not supported on this platform". The frontend now builds with Webpack
|
||||
(`next build --webpack`), which builds on every architecture (`frontend/package.json`).
|
||||
|
||||
## [0.14.1] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- **"Send to wallet" now works from every dialog, not just the patient sheet.** The wallet step in
|
||||
the appointment, invoice, prescription, patient-edit, and scribe dialogs was gated on a wallet-link
|
||||
check that resolved asynchronously; if the clinician saved before it resolved (or it briefly
|
||||
failed), the dialog silently closed without pushing. The dialogs now await the link check before
|
||||
deciding, and an empty change summary can no longer be sent
|
||||
(`frontend/components/wallet/use-wallet-sync.ts`, `wallet-sync-step.tsx`).
|
||||
|
||||
### Changed
|
||||
- **Settings sections composed from `CardFrame` primitives.** Each settings section now builds on
|
||||
`CardFrameHeader`/`CardFrameTitle`/`CardFrameDescription` + a new `CardFramePanel` body, and the
|
||||
per-row `SettingsCard` renders a real `Card`, giving a consistent framed surface
|
||||
(`frontend/components/ui/card.tsx`, `frontend/components/settings/settings-parts.tsx`).
|
||||
|
||||
## [0.14.0] — 2026-07-13
|
||||
|
||||
### Changed
|
||||
- **Patients page filter moved to its own row.** The status filter left the toolbar and now sits on a
|
||||
dedicated **"Filter"** row directly above the table, so the header stays a clean title + search +
|
||||
"Add patient" + `⋯` cluster (`frontend/components/patients/patients-view.tsx`).
|
||||
- **Patient detail sheet header.** The patient name, status badge, and the `⋯` menu now share one
|
||||
left-aligned row, and the standalone **Edit** button moved to be the first item inside the `⋯` menu
|
||||
(`frontend/components/patients/patient-detail.tsx`).
|
||||
- **Settings redesigned with the COSS frame surface.** Every settings section now renders in a
|
||||
`CardFrame` (header + body) via a new `SettingsFrame` part, replacing the flat `SettingsCard` divs
|
||||
(`frontend/components/settings/settings-parts.tsx`, `settings-ai.tsx`).
|
||||
|
||||
### Added
|
||||
- **AI Mode: Automatic and Off.** Settings → AI gains two modes beyond API / Local: **Automatic**
|
||||
(use a cloud API key when set, else fall back to local Ollama) and **Off** (assistant disabled).
|
||||
Automatic is the new default, so a fresh install shows the setup banner until a provider is wired
|
||||
(`frontend/lib/ai-settings.ts`, `backend/src/services/ai/{config,provider}.ts`, `types/ai.ts`).
|
||||
|
||||
### Fixed
|
||||
- **AI setup banner now appears when no provider is configured.** Previously the defaulted Ollama URL
|
||||
counted as "configured," so the "connect an AI model" banner never showed; it now reflects the
|
||||
actual mode (`frontend/components/chat/ai-setup-notice.tsx`).
|
||||
- **Chat cards no longer silently vanish.** An unrecognized/renamed streamed data part now renders a
|
||||
small placeholder instead of nothing (`frontend/components/chat/chat-panel.tsx`).
|
||||
|
||||
### Wallet app
|
||||
- **Documents/files reach the wallet.** Clinic record-update pushes now include attachment metadata,
|
||||
which the wallet folds into the record so the **Documents** tile counts them and the Documents
|
||||
screen lists them (`backend/src/services/wallet-updates.ts`, `temetro-app` types + home/documents).
|
||||
- **Failed pushes are no longer swallowed.** A record update that fails signature verification or
|
||||
decoding is now logged (and a "couldn't verify" notice is raised) instead of disappearing silently.
|
||||
- **i18next translation.** The wallet app now uses `i18next` + `react-i18next` + `expo-localization`
|
||||
(English shipped; other locales can be added later), with the home, record-update inbox, navigation
|
||||
titles, and the visits/prescriptions-adjacent detail screens extracted to translation keys.
|
||||
|
||||
## [0.13.1] — 2026-07-13
|
||||
|
||||
### Changed
|
||||
- **New Invoice checkboxes.** The Back-date / Due-date toggles now use the COSS `Checkbox`
|
||||
(`frontend/components/ui/checkbox.tsx`) instead of raw, misshapen native checkboxes.
|
||||
- **Patients page toolbar & table.** The list is now a COSS **Table in a `CardFrame`**
|
||||
(`frontend/components/ui/table.tsx`), and the secondary "Import from a patient app" action moved
|
||||
into a `⋯` overflow menu so the toolbar keeps a single primary "Add patient" CTA.
|
||||
- **Patient detail sheet header.** The five action buttons plus delete collapse into a primary
|
||||
**Edit** button and a `⋯ More` menu (Download summary, Record visit, Transfer, Push to wallet, and
|
||||
a destructive Delete).
|
||||
|
||||
### Wallet app
|
||||
- **Home quick actions open sheets.** "Share record" now opens a bottom sheet with a scannable **QR**
|
||||
of the wallet number (`react-native-qrcode-svg`); "My wallet" opens a bottom sheet with copyable
|
||||
wallet number / fingerprint / algorithm; and the duplicate "Notifications" action (the header
|
||||
already has a bell) is replaced with **Scan**.
|
||||
|
||||
## [0.13.0] — 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://hub.docker.com/u/khalidxv)
|
||||
[](./CHANGELOG.md)
|
||||
[](./CHANGELOG.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -6,5 +6,15 @@ dist
|
||||
*.log
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
docker-compose.tunnel.yml
|
||||
README.md
|
||||
CLAUDE.md
|
||||
# Deploy manifests for other targets — not needed inside the image.
|
||||
fly.toml
|
||||
railway.json
|
||||
render.yaml
|
||||
# Tracked, but only the runtime migrator (dist/migrate.js) runs in the image.
|
||||
drizzle.config.ts
|
||||
.env.example
|
||||
|
||||
+7
-1
@@ -19,7 +19,13 @@ RUN npm ci --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/drizzle ./drizzle
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# Strip CR before chmod. .gitattributes keeps this file LF on fresh clones, but
|
||||
# a Windows checkout made before that existed still has CRLF on disk, and a
|
||||
# "#!/bin/sh\r" shebang fails at container start with a "no such file or
|
||||
# directory" error that names the file it just copied in. Cheap to make the
|
||||
# image self-healing rather than rely on every contributor re-cloning.
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
EXPOSE 4000
|
||||
# The entrypoint auto-generates any missing secrets, then we apply migrations
|
||||
# and start the API.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
// is the plaintext key for the *currently selected* provider — it is encrypted
|
||||
// before storage and never echoed back.
|
||||
export const aiConfigInputSchema = z.object({
|
||||
mode: z.enum(["api", "local"]).optional(),
|
||||
mode: z.enum(["api", "local", "auto", "off"]).optional(),
|
||||
provider: z.enum(["openai", "anthropic", "gemini"]).optional(),
|
||||
ollamaBaseUrl: z.string().url().optional(),
|
||||
ollamaModel: z.string().min(1).max(120).optional(),
|
||||
|
||||
@@ -208,6 +208,12 @@ chatRouter.post("/", async (req, res, next) => {
|
||||
}
|
||||
|
||||
const settings = await getAiSettings(req.user!.id);
|
||||
if (settings.mode === "off") {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"The AI assistant is turned off. Turn it on in Settings → AI.",
|
||||
);
|
||||
}
|
||||
const modelId = requestedModel || settings.defaultModel;
|
||||
const resolved = resolveModel(settings, modelId);
|
||||
const veil = createVeil(settings.veilLevel, resolved.isExternal);
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
type AiSettingsRow = typeof userAiSettings.$inferSelect;
|
||||
|
||||
const DEFAULTS: Omit<AiSettingsRow, "userId" | "updatedAt"> = {
|
||||
mode: "local",
|
||||
// Default to auto: use a cloud key if the user adds one, else local Ollama.
|
||||
// A fresh user with nothing configured then sees the setup banner.
|
||||
mode: "auto",
|
||||
provider: "anthropic",
|
||||
ollamaBaseUrl: DEFAULT_OLLAMA_BASE_URL,
|
||||
ollamaModel: "llama3.1",
|
||||
|
||||
@@ -69,8 +69,30 @@ export function resolveModel(
|
||||
settings: AiSettingsRow,
|
||||
requestedModelId: string,
|
||||
): ResolvedModel {
|
||||
// Local mode (or the local sentinel) → Ollama's OpenAI-compatible endpoint.
|
||||
if (settings.mode === "local" || requestedModelId === OLLAMA_SENTINEL) {
|
||||
// Off → the assistant is disabled. Guard here in case a request slips past the
|
||||
// route-level short-circuit.
|
||||
if (settings.mode === "off") {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"The AI assistant is turned off. Turn it on in Settings → AI.",
|
||||
);
|
||||
}
|
||||
|
||||
const requested = providerForModel(requestedModelId);
|
||||
// In api/auto mode, find a cloud provider that actually has a key. Auto with
|
||||
// no key (and api mode's remaining fall-through) drops to local Ollama below.
|
||||
const provider =
|
||||
settings.mode === "api" || settings.mode === "auto"
|
||||
? chooseProvider(settings, requested)
|
||||
: null;
|
||||
|
||||
// Local when: explicit local mode, the local sentinel was picked, or auto with
|
||||
// no configured cloud key. → Ollama's OpenAI-compatible endpoint.
|
||||
if (
|
||||
settings.mode === "local" ||
|
||||
requestedModelId === OLLAMA_SENTINEL ||
|
||||
(settings.mode === "auto" && !provider)
|
||||
) {
|
||||
const ollama = createOpenAICompatible({
|
||||
name: "ollama",
|
||||
baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`,
|
||||
@@ -82,11 +104,7 @@ export function resolveModel(
|
||||
};
|
||||
}
|
||||
|
||||
// API mode. Pick a provider that actually has a key, falling back gracefully
|
||||
// so a configured key (e.g. Gemini) is used even if the picked model belongs
|
||||
// to a different, unconfigured provider.
|
||||
const requested = providerForModel(requestedModelId);
|
||||
const provider = chooseProvider(settings, requested);
|
||||
// API mode with a picked cloud model but no matching/any key.
|
||||
if (!provider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
|
||||
@@ -40,7 +40,17 @@ export function sendToWallet(
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
|
||||
const hub = hubs.get(orgId);
|
||||
if (!hub) {
|
||||
// No live hub means the push is dropped on the floor. The row still sits
|
||||
// pending and `wallet:online` replays it once a device reconnects, but say
|
||||
// so — a silent no-op here looks exactly like a wallet that ignored us.
|
||||
console.warn(
|
||||
`Temetro Network: no hub connection for clinic ${orgId}; "${event}" not sent (queued for wallet:online replay).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
hub.emit("wallet:send", { walletNumber, event, data });
|
||||
}
|
||||
|
||||
// Tell the relay to expect a device response for `requestId` and route it back
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
} from "../lib/wallet-crypto.js";
|
||||
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
|
||||
import { listAppointments } from "./appointments.js";
|
||||
import { listAttachments } from "./attachments.js";
|
||||
import { listInvoices } from "./invoices.js";
|
||||
import { getPatient } from "./patients.js";
|
||||
import { listPrescriptions } from "./prescriptions.js";
|
||||
import { signWithClinicKey } from "./signing.js";
|
||||
|
||||
type UpdateRow = typeof walletRecordUpdates.$inferSelect;
|
||||
@@ -24,8 +26,15 @@ type UpdateRow = typeof walletRecordUpdates.$inferSelect;
|
||||
// The payload the relay pushes to a wallet. `sealed` is the encrypted patient
|
||||
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
|
||||
// provenance (TOFU pin) before applying.
|
||||
//
|
||||
// `clinicId` is the stable org id. The wallet pins a clinic's signing key
|
||||
// against it — pinning against the mutable `clinicName` meant every unnamed org
|
||||
// collided on the "A clinic" fallback and tripped a bogus "key changed" warning.
|
||||
// It also doubles as the relay routing id the wallet needs to fetch document
|
||||
// bytes over the portal.
|
||||
export type WalletUpdateEvent = {
|
||||
requestId: string;
|
||||
clinicId: string;
|
||||
clinicName: string;
|
||||
sealed: string;
|
||||
signature: string;
|
||||
@@ -119,22 +128,49 @@ export async function createRecordUpdate(
|
||||
const patient = await getPatient(orgId, fileNumber);
|
||||
if (!patient) throw new HttpError(404, "Patient not found.");
|
||||
|
||||
// Appointments and invoices live in their own tables (not on the Patient
|
||||
// snapshot), so pull the ones for this patient and ship them alongside — the
|
||||
// wallet has no other way to see them and they'd otherwise silently vanish.
|
||||
const [orgAppointments, orgInvoices] = await Promise.all([
|
||||
listAppointments(orgId),
|
||||
listInvoices(orgId),
|
||||
]);
|
||||
// Appointments, invoices and prescriptions live in their own tables (not on
|
||||
// the Patient snapshot), so pull the ones for this patient and ship them
|
||||
// alongside — the wallet has no other way to see them and they'd otherwise
|
||||
// silently vanish. `patient.medications` comes from `patient_medications`,
|
||||
// which `createPrescription` never writes to, so a prescription that isn't
|
||||
// shipped here would leave the approved bundle identical to what the wallet
|
||||
// already had.
|
||||
// Attachments (files/documents) are shipped as metadata; the bytes stay on the
|
||||
// clinic and the wallet fetches them on demand over the portal `result-file`
|
||||
// action, keyed by the same attachment id shipped here.
|
||||
const [orgAppointments, orgInvoices, orgPrescriptions, attachmentRows] =
|
||||
await Promise.all([
|
||||
listAppointments(orgId),
|
||||
listInvoices(orgId),
|
||||
listPrescriptions(orgId),
|
||||
listAttachments(orgId, fileNumber),
|
||||
]);
|
||||
const appointments = orgAppointments.filter(
|
||||
(a) => a.fileNumber === fileNumber,
|
||||
);
|
||||
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
|
||||
const prescriptions = orgPrescriptions.filter(
|
||||
(p) => p.fileNumber === fileNumber,
|
||||
);
|
||||
const documents = attachmentRows.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
sizeBytes: a.sizeBytes,
|
||||
createdAt: a.createdAt,
|
||||
}));
|
||||
|
||||
// The wallet opens this, verifies the signature over the same bytes, then
|
||||
// replaces its on-device record with `patient` (+ appointments/invoices).
|
||||
// replaces its on-device record with `patient` (+ the sibling-table lists).
|
||||
const bundle = utf8ToBytes(
|
||||
JSON.stringify({ patient, appointments, invoices, changes }),
|
||||
JSON.stringify({
|
||||
patient,
|
||||
appointments,
|
||||
invoices,
|
||||
prescriptions,
|
||||
documents,
|
||||
changes,
|
||||
}),
|
||||
);
|
||||
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
|
||||
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
|
||||
@@ -165,6 +201,7 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
|
||||
.where(eq(organization.id, row.organizationId));
|
||||
return {
|
||||
requestId: row.id,
|
||||
clinicId: row.organizationId,
|
||||
clinicName: org?.name ?? "A clinic",
|
||||
sealed: row.payloadSealed,
|
||||
signature: row.clinicSignature,
|
||||
@@ -223,9 +260,24 @@ export async function applyUpdateResponse(
|
||||
.select()
|
||||
.from(walletRecordUpdates)
|
||||
.where(eq(walletRecordUpdates.id, requestId));
|
||||
if (!row || row.resolvedAt) return null;
|
||||
if (row.walletNumber !== walletNumber.trim()) return null;
|
||||
if (!signatureHex) return null;
|
||||
// These all mean the patient tapped Approve and nothing happened, so say so —
|
||||
// silently returning null here is indistinguishable from a lost connection.
|
||||
if (!row) {
|
||||
console.warn(`Wallet update ${requestId}: no such update row.`);
|
||||
return null;
|
||||
}
|
||||
if (row.resolvedAt) {
|
||||
console.warn(`Wallet update ${requestId}: already resolved.`);
|
||||
return null;
|
||||
}
|
||||
if (row.walletNumber !== walletNumber.trim()) {
|
||||
console.warn(`Wallet update ${requestId}: wallet number did not match.`);
|
||||
return null;
|
||||
}
|
||||
if (!signatureHex) {
|
||||
console.warn(`Wallet update ${requestId}: response carried no signature.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicKey = decodeWalletNumber(walletNumber);
|
||||
const message = utf8ToBytes(`${decision}:${requestId}`);
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
// AI panel. The chat agent reads these to decide which provider/model to call
|
||||
// and how strict the Veil de-identification safeguard should be.
|
||||
|
||||
// Two inference modes: a user-provided cloud API key, or a local Ollama model.
|
||||
export type AiMode = "api" | "local";
|
||||
// Inference modes:
|
||||
// api — a user-provided cloud API key
|
||||
// local — a local Ollama model
|
||||
// auto — auto-pick: use a cloud key when one is set, else fall back to local
|
||||
// off — the assistant is disabled
|
||||
export type AiMode = "api" | "local" | "auto" | "off";
|
||||
|
||||
// The three supported cloud providers for API-key mode.
|
||||
export type ApiProvider = "openai" | "anthropic" | "gemini";
|
||||
|
||||
@@ -7,3 +7,6 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
tsconfig.tsbuildinfo
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -195,7 +195,7 @@ export function AddAppointmentDialog({
|
||||
t("appointments.dialog.addedTitle"),
|
||||
`${selected.name} · ${time}`,
|
||||
);
|
||||
if (sync.linked) {
|
||||
if (await sync.ensureLinked()) {
|
||||
setWalletSummary(
|
||||
t("walletSync.summary.appointment", { date: keyOf(date), time }),
|
||||
);
|
||||
|
||||
@@ -87,15 +87,18 @@ export function AppointmentDetailSheet({
|
||||
const [status, setStatus] = useState<AppointmentStatus>("confirmed");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Seed the form from the selected appointment whenever it changes.
|
||||
useEffect(() => {
|
||||
if (!appt) return;
|
||||
// Seed the form from the selected appointment whenever it changes. Adjusted
|
||||
// during render rather than in an effect, so the sheet never paints one frame
|
||||
// of the previous appointment's values.
|
||||
const [prevAppt, setPrevAppt] = useState(appt);
|
||||
if (appt && prevAppt !== appt) {
|
||||
setPrevAppt(appt);
|
||||
setDate(new Date(`${appt.date}T00:00:00`));
|
||||
setTime(appt.time);
|
||||
setType(appt.type);
|
||||
setProvider(appt.provider);
|
||||
setStatus(appt.status);
|
||||
}, [appt]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
@@ -72,13 +72,22 @@ export function CalendarDialog({
|
||||
});
|
||||
const [selectedKey, setSelectedKey] = useState<string>(startKey);
|
||||
|
||||
// When opened via a deep-link date, jump the view to that month/day.
|
||||
useEffect(() => {
|
||||
if (!open || !initialDate || !/^\d{4}-\d{2}-\d{2}$/.test(initialDate)) return;
|
||||
const d = parseKey(initialDate);
|
||||
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||
setSelectedKey(initialDate);
|
||||
}, [open, initialDate]);
|
||||
// When opened via a deep-link date, jump the view to that month/day. Adjusted
|
||||
// during render rather than in an effect, so the calendar never paints the
|
||||
// current month before jumping to the linked one.
|
||||
const jumpTo =
|
||||
open && initialDate && /^\d{4}-\d{2}-\d{2}$/.test(initialDate)
|
||||
? initialDate
|
||||
: null;
|
||||
const [prevJumpTo, setPrevJumpTo] = useState<string | null>(null);
|
||||
if (prevJumpTo !== jumpTo) {
|
||||
setPrevJumpTo(jumpTo);
|
||||
if (jumpTo) {
|
||||
const d = parseKey(jumpTo);
|
||||
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||
setSelectedKey(jumpTo);
|
||||
}
|
||||
}
|
||||
|
||||
const byDate = useMemo(() => {
|
||||
const map = new Map<string, Appointment[]>();
|
||||
|
||||
@@ -14,9 +14,13 @@ import { getAiConfig } from "@/lib/ai-settings";
|
||||
// message just fails silently, so the banner spells out the missing setup and
|
||||
// links straight to AI settings. Rendered in both the empty and active chat
|
||||
// states so a user mid-conversation with no provider still sees why replies fail.
|
||||
// Which advisory (if any) to show: the setup nudge (no provider wired), or a
|
||||
// notice that the assistant has been switched off.
|
||||
type Notice = "setup" | "off" | null;
|
||||
|
||||
export function AiSetupNotice() {
|
||||
const { t } = useTranslation();
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,22 +28,34 @@ export function AiSetupNotice() {
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (!active) return;
|
||||
// Configured = an API key for any provider, or a local Ollama endpoint.
|
||||
if (cfg.mode === "off") {
|
||||
setNotice("off");
|
||||
return;
|
||||
}
|
||||
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
|
||||
const hasLocal =
|
||||
cfg.mode === "local" && cfg.ollamaBaseUrl.trim().length > 0;
|
||||
setNeedsSetup(!(hasApiKey || hasLocal));
|
||||
// Whether the assistant is actually wired up for the chosen mode:
|
||||
// - api / auto → needs a cloud API key (auto silently falls back to
|
||||
// local, but with no key we nudge the user to finish
|
||||
// setup rather than depend on an unverified Ollama).
|
||||
// - local → needs a non-empty Ollama endpoint (opted in).
|
||||
const configured =
|
||||
cfg.mode === "local"
|
||||
? cfg.ollamaBaseUrl.trim().length > 0
|
||||
: hasApiKey;
|
||||
setNotice(configured ? null : "setup");
|
||||
})
|
||||
.catch(() => {
|
||||
// If we can't read the config, don't nag — the chat still works.
|
||||
if (active) setNeedsSetup(false);
|
||||
if (active) setNotice(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!needsSetup || dismissed) return null;
|
||||
if (!notice || dismissed) return null;
|
||||
|
||||
const isOff = notice === "off";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -48,10 +64,14 @@ export function AiSetupNotice() {
|
||||
>
|
||||
<TriangleAlert className="size-4 shrink-0 text-warning" />
|
||||
<p className="min-w-0 flex-1 truncate text-foreground">
|
||||
<span className="font-medium">{t("chat.setupNotice.title")}</span>
|
||||
<span className="font-medium">
|
||||
{isOff
|
||||
? t("chat.setupNotice.offTitle")
|
||||
: t("chat.setupNotice.title")}
|
||||
</span>
|
||||
<span className="text-muted-foreground max-sm:hidden">
|
||||
{" — "}
|
||||
{t("chat.setupNotice.body")}
|
||||
{isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -78,17 +79,23 @@ export function ChatInput({
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
|
||||
// first client render agree (button starts disabled, enabled by the effect).
|
||||
const [speechSupported, setSpeechSupported] = useState(false);
|
||||
// Voice dictation (Web Speech API). Read through useSyncExternalStore so SSR
|
||||
// markup and the first client render agree: the server snapshot is `false`
|
||||
// (no Speech API to detect), and the client re-reads on hydration. Support
|
||||
// never changes for the life of the page, so the subscription is a no-op.
|
||||
const speechSupported = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => getSpeechRecognition() !== null,
|
||||
() => false,
|
||||
);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
// The textarea contents when dictation started; transcript is appended to it.
|
||||
const dictationBaseRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
setSpeechSupported(getSpeechRecognition() !== null);
|
||||
return () => recognitionRef.current?.stop();
|
||||
const recognition = recognitionRef;
|
||||
return () => recognition.current?.stop();
|
||||
}, []);
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
|
||||
@@ -139,8 +139,14 @@ export function ChatPanel() {
|
||||
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
|
||||
// saved to the server after each exchange so history survives reloads.
|
||||
const [threadId, setThreadId] = useState<string>(() => nanoid());
|
||||
// Mirrored into a ref for the transport's fetch closure, which is memoized and
|
||||
// must not be rebuilt per thread. Written in an effect rather than during
|
||||
// render: a render can be thrown away or replayed, and mutating a ref there
|
||||
// makes it observable — the write has to happen once the render is committed.
|
||||
const threadIdRef = useRef(threadId);
|
||||
threadIdRef.current = threadId;
|
||||
useEffect(() => {
|
||||
threadIdRef.current = threadId;
|
||||
}, [threadId]);
|
||||
// Skip the auto-save that would otherwise fire right after loading a thread
|
||||
// (which would needlessly bump it to the top of the history).
|
||||
const justLoadedRef = useRef(false);
|
||||
@@ -188,7 +194,17 @@ export function ChatPanel() {
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return;
|
||||
setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel);
|
||||
// Seed the model to match the configured mode. In "auto" we prefer the
|
||||
// user's cloud default when a key exists, else fall back to the local
|
||||
// sentinel (mirrors the backend's provider resolution).
|
||||
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
|
||||
const seeded =
|
||||
cfg.mode === "local"
|
||||
? "ollama"
|
||||
: cfg.mode === "auto" && !hasApiKey
|
||||
? "ollama"
|
||||
: cfg.defaultModel;
|
||||
setModel(seeded);
|
||||
setEffort(cfg.defaultEffort);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -204,9 +220,11 @@ export function ChatPanel() {
|
||||
// stays visible until acknowledged and isn't duplicated. Reset the dismissed
|
||||
// flag whenever a fresh error arrives.
|
||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||
useEffect(() => {
|
||||
const [prevError, setPrevError] = useState(error);
|
||||
if (prevError !== error) {
|
||||
setPrevError(error);
|
||||
if (error) setErrorDismissed(false);
|
||||
}, [error]);
|
||||
}
|
||||
|
||||
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
|
||||
|
||||
@@ -291,9 +309,16 @@ export function ChatPanel() {
|
||||
);
|
||||
|
||||
// Drain the queue one message at a time whenever the chat returns to idle.
|
||||
//
|
||||
// This is the shape the rule exists to catch, but it's the shape we want: the
|
||||
// trigger is the transport going idle — an external async system — and the
|
||||
// dequeue has to happen in the same tick we hand the message to `send`, or a
|
||||
// re-render could drain it twice. There's no render-phase equivalent, since
|
||||
// `send` is a side effect.
|
||||
useEffect(() => {
|
||||
if (status !== "ready" || pendingConsent || queued.length === 0) return;
|
||||
const [next, ...rest] = queued;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setQueued(rest);
|
||||
if (next) void send(next.text, next.files);
|
||||
}, [status, pendingConsent, queued, send]);
|
||||
@@ -328,6 +353,10 @@ export function ChatPanel() {
|
||||
|
||||
// Open a saved thread from `/?thread=<id>` (sidebar history); a bare `/` starts
|
||||
// a fresh chat. Driven by the URL so the sidebar links and "New chat" work.
|
||||
//
|
||||
// Stays an effect: the thread branch is an async fetch, and the fresh-chat
|
||||
// branch mints an id with nanoid(), which is impure and so can't be adjusted
|
||||
// during render — moving it there would just trade this for a purity error.
|
||||
const requestedThread = searchParams.get("thread");
|
||||
useEffect(() => {
|
||||
if (requestedThread) {
|
||||
@@ -357,6 +386,7 @@ export function ChatPanel() {
|
||||
};
|
||||
}
|
||||
// No ?thread → fresh chat (e.g. after "New chat").
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setThreadId(nanoid());
|
||||
setMessages([]);
|
||||
}, [requestedThread, setMessages]);
|
||||
@@ -687,6 +717,22 @@ export function ChatPanel() {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// Fallback: a structured card the client doesn't recognize (e.g. a
|
||||
// data part added or renamed on the backend). Surface a small
|
||||
// placeholder so a written-but-unhandled result is never silently
|
||||
// invisible. `data-step`/`data-source` intentionally render nothing
|
||||
// here (they're consumed above), so skip them.
|
||||
if (
|
||||
part.type.startsWith("data-") &&
|
||||
part.type !== "data-step" &&
|
||||
part.type !== "data-source"
|
||||
) {
|
||||
return (
|
||||
<Badge className="self-start" key={key} variant="outline">
|
||||
{t("chat.card.unsupported")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
|
||||
@@ -396,7 +396,7 @@ export function PatientFormDialog({
|
||||
t("patientForm.updatedTitle"),
|
||||
t("patientForm.updatedBody", { name: saved.name }),
|
||||
);
|
||||
if (sync.linked) {
|
||||
if (await sync.ensureLinked()) {
|
||||
setStep("wallet");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// array fields (invoice line items, inventory items) edited as add/remove rows.
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -196,10 +196,14 @@ export function RecordEditDialog({
|
||||
const schema = EDIT_SCHEMAS[kind];
|
||||
const [draft, setDraft] = useState<Rec>(record);
|
||||
|
||||
// Re-seed the draft whenever a fresh record is opened for editing.
|
||||
useEffect(() => {
|
||||
if (open) setDraft(record);
|
||||
}, [open, record]);
|
||||
// Re-seed the draft whenever a fresh record is opened for editing. Adjusted
|
||||
// during render so the form never paints the previous record's values.
|
||||
const [prevSeed, setPrevSeed] = useState<Rec | null>(null);
|
||||
const seed = open ? record : null;
|
||||
if (prevSeed !== seed) {
|
||||
setPrevSeed(seed);
|
||||
if (seed) setDraft(seed);
|
||||
}
|
||||
|
||||
const setField = (key: string, value: unknown) =>
|
||||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
|
||||
@@ -75,7 +75,7 @@ const dotClass: Record<Kind, string> = {
|
||||
// A round node with the label below it and hidden connection handles so edges
|
||||
// meet the dot's centre cleanly. Memoized; it re-renders only when the hovered
|
||||
// id changes (via context), never via React Flow re-measuring.
|
||||
const RecordNode = memo(function RecordNode({ id, data }: NodeProps) {
|
||||
const RecordNode = memo(function RecordNode({ data }: NodeProps) {
|
||||
const { label, sub, kind, family } = data as RecordNodeData;
|
||||
const hovered = useContext(HoverContext);
|
||||
const focus: "active" | "dim" | null =
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Split,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AiBadge } from "@/components/ai-badge";
|
||||
@@ -67,9 +67,14 @@ export function InvoiceDetailSheet({
|
||||
const [count, setCount] = useState(3);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Collapse the "show more" list back when a different invoice is shown.
|
||||
// Adjusted during render so the sheet never paints the previous invoice's
|
||||
// expanded state.
|
||||
const [prevInvoiceId, setPrevInvoiceId] = useState(invoice?.id);
|
||||
if (prevInvoiceId !== invoice?.id) {
|
||||
setPrevInvoiceId(invoice?.id);
|
||||
setCount(3);
|
||||
}, [invoice?.id]);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -171,10 +172,14 @@ export function InvoiceFormDialog({
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
// Seed the form when opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === "edit" && invoice) {
|
||||
// Seed the form when opening. Adjusted during render rather than in an
|
||||
// effect, so the dialog never paints one frame of the previous invoice.
|
||||
// `false` means closed, so reopening on the same invoice re-seeds.
|
||||
const [prevSeed, setPrevSeed] = useState<Invoice | null | false>(false);
|
||||
const seed = open ? (invoice ?? null) : false;
|
||||
if (prevSeed !== seed) {
|
||||
setPrevSeed(seed);
|
||||
if (open && mode === "edit" && invoice) {
|
||||
setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`));
|
||||
// Existing invoices legitimately carry past issue dates.
|
||||
setAllowBackdate(true);
|
||||
@@ -185,7 +190,7 @@ export function InvoiceFormDialog({
|
||||
setLineItems(
|
||||
invoice.lineItems.length ? invoice.lineItems : [emptyLine()],
|
||||
);
|
||||
} else {
|
||||
} else if (open) {
|
||||
setSelected(null);
|
||||
setIssuedAt(new Date());
|
||||
setAllowBackdate(false);
|
||||
@@ -195,7 +200,7 @@ export function InvoiceFormDialog({
|
||||
setNotes("");
|
||||
setLineItems([emptyLine()]);
|
||||
}
|
||||
}, [open, mode, invoice]);
|
||||
}
|
||||
|
||||
// Load patients lazily for the create combobox.
|
||||
useEffect(() => {
|
||||
@@ -272,7 +277,7 @@ export function InvoiceFormDialog({
|
||||
})
|
||||
: await createInvoice(payload);
|
||||
onSaved(saved);
|
||||
if (sync.linked) {
|
||||
if (await sync.ensureLinked()) {
|
||||
setWalletSummary(
|
||||
mode === "edit"
|
||||
? t("walletSync.summary.invoiceUpdated", { number: saved.number })
|
||||
@@ -367,11 +372,14 @@ export function InvoiceFormDialog({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
|
||||
{t("invoices.dialog.issued")}
|
||||
<label className="flex items-center gap-1 text-[11px]">
|
||||
<input
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-[11px]"
|
||||
htmlFor="invoice-backdate"
|
||||
>
|
||||
<Checkbox
|
||||
checked={allowBackdate}
|
||||
onChange={(e) => setAllowBackdate(e.target.checked)}
|
||||
type="checkbox"
|
||||
id="invoice-backdate"
|
||||
onCheckedChange={(checked) => setAllowBackdate(checked)}
|
||||
/>
|
||||
{t("invoices.dialog.backdate")}
|
||||
</label>
|
||||
@@ -385,11 +393,10 @@ export function InvoiceFormDialog({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between text-muted-foreground text-xs">
|
||||
{t("invoices.dialog.due")}
|
||||
<input
|
||||
<Checkbox
|
||||
aria-label={t("invoices.dialog.due")}
|
||||
checked={hasDue}
|
||||
onChange={(e) => setHasDue(e.target.checked)}
|
||||
type="checkbox"
|
||||
onCheckedChange={(checked) => setHasDue(checked)}
|
||||
/>
|
||||
</span>
|
||||
{hasDue ? (
|
||||
|
||||
@@ -76,7 +76,6 @@ function VideoTile({
|
||||
speaking ? "border-success" : "border-transparent",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
autoPlay
|
||||
className={cn("size-full object-cover", !showVideo && "invisible")}
|
||||
|
||||
@@ -59,7 +59,6 @@ export function MeetingsView() {
|
||||
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
|
||||
// pre-targeted at that person so the user can connect with them.
|
||||
const deepLinkWith = searchParams.get("with");
|
||||
const openedDeepLink = useRef<string | null>(null);
|
||||
const openedWith = useRef<string | null>(null);
|
||||
|
||||
const [tab, setTab] = useState<Tab>("rooms");
|
||||
@@ -104,16 +103,18 @@ export function MeetingsView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-join a room deep-linked from an invite (?room=).
|
||||
useEffect(() => {
|
||||
if (!deepLinkRoom || rooms.length === 0) return;
|
||||
if (openedDeepLink.current === deepLinkRoom) return;
|
||||
// Auto-join a room deep-linked from an invite (?room=). Resolved during
|
||||
// render once the room list arrives, so the room opens on the same paint the
|
||||
// list does instead of flashing the default tab first.
|
||||
const [openedDeepLink, setOpenedDeepLink] = useState<string | null>(null);
|
||||
if (deepLinkRoom && rooms.length > 0 && openedDeepLink !== deepLinkRoom) {
|
||||
const room = rooms.find((r) => r.id === deepLinkRoom);
|
||||
if (!room) return;
|
||||
openedDeepLink.current = deepLinkRoom;
|
||||
setTab("rooms");
|
||||
setActiveRoom(room);
|
||||
}, [deepLinkRoom, rooms]);
|
||||
if (room) {
|
||||
setOpenedDeepLink(deepLinkRoom);
|
||||
setTab("rooms");
|
||||
setActiveRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -54,12 +54,21 @@ export function ScheduleMeetingDialog({
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Re-seed the form each time the dialog opens. Adjusted during render so it
|
||||
// never paints the previous meeting's title or participants.
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setTitle("");
|
||||
setDate(defaultDate ?? "");
|
||||
setTime("09:00");
|
||||
setPicked(new Set(defaultParticipants ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle("");
|
||||
setDate(defaultDate ?? "");
|
||||
setTime("09:00");
|
||||
setPicked(new Set(defaultParticipants ?? []));
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
|
||||
@@ -8,11 +8,17 @@ import { useEffect, useState } from "react";
|
||||
export function useSpeaking(stream: MediaStream | null): boolean {
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
|
||||
// Stop reporting "speaking" the moment the stream goes away or changes, so a
|
||||
// muted tile can't keep a stale ring. Adjusted during render rather than in
|
||||
// the effect below, which is only for driving the Web Audio graph.
|
||||
const [prevStream, setPrevStream] = useState(stream);
|
||||
if (prevStream !== stream) {
|
||||
setPrevStream(stream);
|
||||
setSpeaking(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || stream.getAudioTracks().length === 0) {
|
||||
setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
if (!stream || stream.getAudioTracks().length === 0) return;
|
||||
let ctx: AudioContext | null = null;
|
||||
let raf = 0;
|
||||
try {
|
||||
|
||||
@@ -245,10 +245,15 @@ export function MessagesView() {
|
||||
const [appts, setAppts] = useState<Appointment[]>([]);
|
||||
const [apptQuery, setApptQuery] = useState("");
|
||||
|
||||
// Refs so the socket handler (registered once) reads current values.
|
||||
// Refs so the socket handler (registered once) reads current values. Written
|
||||
// in an effect rather than during render: a render can be thrown away or
|
||||
// replayed, and mutating a ref there makes it observable — the write has to
|
||||
// happen once the render is committed.
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const myIdRef = useRef<string>("");
|
||||
myIdRef.current = myId;
|
||||
useEffect(() => {
|
||||
myIdRef.current = myId;
|
||||
}, [myId]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -354,7 +359,6 @@ export function MessagesView() {
|
||||
openedDeepLink.current = deepLinkConversation;
|
||||
open(deepLinkConversation);
|
||||
// `open` is stable enough for this one-shot; deps intentionally minimal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deepLinkConversation, conversations]);
|
||||
|
||||
const send = (event: FormEvent) => {
|
||||
|
||||
@@ -61,6 +61,9 @@ export function NotesView() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// `t` intentionally omitted: it is only read to build a failure message,
|
||||
// and re-fetching on a language change would be pointless.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const startNew = () => {
|
||||
|
||||
@@ -79,8 +79,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 +96,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(() => {
|
||||
|
||||
@@ -102,14 +102,29 @@ export function PatientDetailSheet({
|
||||
// Bumped on open so the editor remounts with the latest patient data.
|
||||
const [editKey, setEditKey] = useState(0);
|
||||
|
||||
// Clear the previous chart the moment a different one is opened, so the sheet
|
||||
// can't show one patient's data under another's name while the fetch is in
|
||||
// flight. Adjusted during render rather than in the effects below, which are
|
||||
// only for the fetching itself. Keyed on the role too, so losing clinical
|
||||
// access drops the wallet state instead of leaving the "Push update" button
|
||||
// lit from the previous render.
|
||||
const chart = open ? `${fileNumber ?? ""}|${role ?? ""}` : null;
|
||||
const [prevChart, setPrevChart] = useState<string | null>(null);
|
||||
if (prevChart !== chart) {
|
||||
setPrevChart(chart);
|
||||
setWalletLinked(false);
|
||||
if (chart) {
|
||||
setStatus("loading");
|
||||
setPatient(null);
|
||||
setPrescriptions([]);
|
||||
setAppointments([]);
|
||||
setInvoices([]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !fileNumber) return;
|
||||
let active = true;
|
||||
setStatus("loading");
|
||||
setPatient(null);
|
||||
setPrescriptions([]);
|
||||
setAppointments([]);
|
||||
setInvoices([]);
|
||||
getPatient(fileNumber)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
@@ -138,9 +153,10 @@ export function PatientDetailSheet({
|
||||
|
||||
// Whether this patient is wallet-linked (drives the "Push update" button).
|
||||
// Separate from the main load so it re-checks once the role resolves without
|
||||
// refetching the record. Only clinicians can push.
|
||||
// refetching the record. Only clinicians can push. The reset rides on the
|
||||
// same render-phase clear as the chart above, so the button can't stay lit
|
||||
// from the previous patient.
|
||||
useEffect(() => {
|
||||
setWalletLinked(false);
|
||||
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
|
||||
let active = true;
|
||||
getWalletLink(fileNumber)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type LucideIcon,
|
||||
ListTodo,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Pencil,
|
||||
@@ -29,6 +30,13 @@ import {
|
||||
import { printPatientSummary } from "@/lib/patient-pdf";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuPopup,
|
||||
MenuSeparator,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -295,6 +303,8 @@ export function PatientDetail({
|
||||
<AvatarFallback>{patient.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{/* Name, status, and the overflow menu share one left-aligned row;
|
||||
Edit now lives inside the menu rather than as a separate button. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-base text-foreground">
|
||||
{patient.name}
|
||||
@@ -302,6 +312,59 @@ export function PatientDetail({
|
||||
<Badge variant={statusVariant[patient.status]}>
|
||||
{t(`patients.status.${patient.status}`)}
|
||||
</Badge>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patientCard.moreActions")}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="start">
|
||||
{onEdit && (
|
||||
<MenuItem onClick={onEdit}>
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.edit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => printPatientSummary(patient, t)}>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</MenuItem>
|
||||
{onScribe && (
|
||||
<MenuItem onClick={onScribe}>
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onTransfer && (
|
||||
<MenuItem onClick={onTransfer}>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<MenuItem onClick={onWalletPush}>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onClick={onDelete} variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("patients.delete.action")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">{idLine}</span>
|
||||
{patient.alerts.length > 0 && (
|
||||
@@ -315,64 +378,6 @@ export function PatientDetail({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions — their own wrapping row beneath the identity. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => printPatientSummary(patient, t)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</Button>
|
||||
{onTransfer && (
|
||||
<Button
|
||||
onClick={onTransfer}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onScribe && (
|
||||
<Button onClick={onScribe} size="sm" type="button" variant="outline">
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.edit")}
|
||||
</Button>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<Button
|
||||
onClick={onWalletPush}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
aria-label={t("patients.delete.action")}
|
||||
className="ms-auto"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section title={t("patientCard.overview")}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Search, Smartphone } from "lucide-react";
|
||||
import { MoreHorizontal, Plus, Search, Smartphone } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,8 +11,15 @@ import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet
|
||||
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardFrame } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ListPagination } from "@/components/ui/list-pagination";
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuPopup,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
@@ -20,6 +27,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { listPatients, type Patient } from "@/lib/patients";
|
||||
|
||||
type StatusFilter = "all" | Patient["status"];
|
||||
@@ -61,9 +76,10 @@ export function PatientsView() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Runs once on mount, and `loading` already starts true, so there's nothing
|
||||
// to flip synchronously here.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
listPatients()
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
@@ -82,6 +98,9 @@ export function PatientsView() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// `t` intentionally omitted: it is only read to build a failure message,
|
||||
// and re-fetching on a language change would be pointless.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -158,38 +177,6 @@ export function PatientsView() {
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter((value ?? "all") as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("patients.filterStatus")}
|
||||
className="w-full sm:w-40"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{STATUS_FILTERS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value === "all"
|
||||
? t("patients.allStatuses")
|
||||
: t(`patients.status.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setImportOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => {
|
||||
@@ -201,58 +188,119 @@ export function PatientsView() {
|
||||
<Plus className="size-4" />
|
||||
{t("patients.add")}
|
||||
</Button>
|
||||
{/* Secondary actions tuck into an overflow menu so the toolbar keeps a
|
||||
single primary CTA. */}
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patients.moreActions")}
|
||||
className="rounded-full"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="end">
|
||||
<MenuItem onClick={() => setImportOpen(true)}>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</MenuItem>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-border border-b text-start text-xs text-muted-foreground uppercase">
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
{/* Filter sits on its own row just above the table so the toolbar stays a
|
||||
clean title + search + primary actions cluster. */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("patients.filterLabel")}
|
||||
</span>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter((value ?? "all") as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("patients.filterStatus")}
|
||||
className="w-40"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{STATUS_FILTERS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value === "all"
|
||||
? t("patients.allStatuses")
|
||||
: t(`patients.status.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<CardFrame className="mt-4 w-full">
|
||||
<Table variant="card">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="ps-4 text-xs uppercase">
|
||||
{t("patients.columns.name")}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.mrn")}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.ageSex")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.status")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.lastSeen")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="pe-4 text-xs uppercase">
|
||||
{t("patients.columns.allergies")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-muted-foreground"
|
||||
colSpan={6}
|
||||
>
|
||||
{t("patients.loading")}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : loadError ? (
|
||||
<tr>
|
||||
<td className="px-4 py-10 text-center text-destructive" colSpan={6}>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-destructive"
|
||||
colSpan={6}
|
||||
>
|
||||
{loadError}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : patients.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-muted-foreground"
|
||||
colSpan={6}
|
||||
>
|
||||
{t("patients.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageRows.map((p) => (
|
||||
<tr
|
||||
className="cursor-pointer border-border/50 border-b transition-colors last:border-0 hover:bg-accent/50"
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={p.fileNumber}
|
||||
onClick={() => open(p.fileNumber)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -264,7 +312,7 @@ export function PatientsView() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
<TableCell className="ps-4 py-3 font-medium text-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
{p.name}
|
||||
<AiBadge source={p.source} />
|
||||
@@ -274,30 +322,30 @@ export function PatientsView() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.fileNumber}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.age} · {p.sex}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<Badge variant={statusVariant[p.status]}>
|
||||
{t(`patients.status.${p.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.encounters[0]?.date ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="pe-4 py-3 text-muted-foreground">
|
||||
{p.allergies.length || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardFrame>
|
||||
|
||||
{!loading && !loadError ? (
|
||||
<ListPagination
|
||||
|
||||
@@ -233,7 +233,7 @@ export function ScribeDialog({
|
||||
const updated = await saveNote(patient.fileNumber, draft);
|
||||
notify.success(t("scribe.saved.title"), patient.name);
|
||||
onSaved(updated);
|
||||
if (sync.linked) {
|
||||
if (await sync.ensureLinked()) {
|
||||
setPhase("review");
|
||||
setWalletStep(true);
|
||||
} else {
|
||||
|
||||
@@ -44,10 +44,19 @@ export function TransferPatientDialog({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Re-seed the form each time the dialog opens. Adjusted during render so it
|
||||
// never paints the previous patient's provider.
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setProviderId(patient.primaryProviderId ?? "");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProviderId(patient.primaryProviderId ?? "");
|
||||
setError(null);
|
||||
let active = true;
|
||||
listProviders()
|
||||
.then((list) => active && setProviders(list))
|
||||
|
||||
@@ -221,9 +221,19 @@ export function AddPrescriptionDialog({
|
||||
.slice(0, 6);
|
||||
}, [inventory, medication]);
|
||||
|
||||
// Keep the highlighted option in range as the result lists change.
|
||||
useEffect(() => setActiveIndex(0), [query]);
|
||||
useEffect(() => setMedIndex(0), [medication]);
|
||||
// Keep the highlighted option in range as the result lists change. Adjusted
|
||||
// during render rather than in an effect: React re-runs this render before
|
||||
// committing, so the list never paints with a stale highlight.
|
||||
const [prevQuery, setPrevQuery] = useState(query);
|
||||
if (prevQuery !== query) {
|
||||
setPrevQuery(query);
|
||||
setActiveIndex(0);
|
||||
}
|
||||
const [prevMedication, setPrevMedication] = useState(medication);
|
||||
if (prevMedication !== medication) {
|
||||
setPrevMedication(medication);
|
||||
setMedIndex(0);
|
||||
}
|
||||
|
||||
const pickPatient = (p: Patient) => {
|
||||
setSelected(p);
|
||||
@@ -347,7 +357,7 @@ export function AddPrescriptionDialog({
|
||||
t("prescriptions.dialog.addedTitle"),
|
||||
`${medication.trim()} · ${selected.name}`,
|
||||
);
|
||||
if (sync.linked) {
|
||||
if (await sync.ensureLinked()) {
|
||||
setWalletSummary(
|
||||
t("walletSync.summary.prescription", { drug: medication.trim() }),
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -115,12 +115,19 @@ export function EmployeeDetailDialog({
|
||||
const [confirmPw, setConfirmPw] = useState("");
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-seed when a different member (or their role/specialty) is shown, and
|
||||
// always clear the password fields so they can't carry across. Adjusted
|
||||
// during render rather than in an effect, so the dialog never paints another
|
||||
// member's values — and a typed password never survives a switch.
|
||||
const seed = `${member?.id ?? ""}|${member?.role ?? ""}|${member?.specialty ?? ""}`;
|
||||
const [prevSeed, setPrevSeed] = useState(seed);
|
||||
if (prevSeed !== seed) {
|
||||
setPrevSeed(seed);
|
||||
setRole(member?.role ?? "");
|
||||
setSpecialty(member?.specialty ?? "");
|
||||
setNewPw("");
|
||||
setConfirmPw("");
|
||||
}, [member?.id, member?.role, member?.specialty]);
|
||||
}
|
||||
|
||||
const summary = rolePermissionSummary(member?.role);
|
||||
const secondary = member?.username ? `@${member.username}` : member?.email;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import {
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
SettingsFrame,
|
||||
ToggleRow,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import {
|
||||
@@ -41,7 +41,7 @@ const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
|
||||
const VEIL_LEVELS: VeilLevel[] = ["full", "names", "off"];
|
||||
|
||||
const DEFAULTS: AiConfig = {
|
||||
mode: "local",
|
||||
mode: "auto",
|
||||
provider: "anthropic",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
ollamaModel: "llama3.1",
|
||||
@@ -196,15 +196,25 @@ export function AIPanel() {
|
||||
|
||||
const keyIsSet = config.apiKeySet[config.provider];
|
||||
|
||||
// Which mode-hint copy to show under the Mode select.
|
||||
const modeHintSuffix =
|
||||
config.mode === "api"
|
||||
? "Api"
|
||||
: config.mode === "local"
|
||||
? "Local"
|
||||
: config.mode === "off"
|
||||
? "Off"
|
||||
: "Auto";
|
||||
|
||||
return (
|
||||
<>
|
||||
{policy ? (
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.availability.description")}
|
||||
title={t("settings.ai.availability.title")}
|
||||
>
|
||||
{isAdmin ? (
|
||||
<div className="space-y-3">
|
||||
<>
|
||||
<ToggleRow
|
||||
checked={policy.aiEnabled}
|
||||
description={t("settings.ai.availability.enabledHint")}
|
||||
@@ -228,7 +238,7 @@ export function AIPanel() {
|
||||
/>
|
||||
) : null}
|
||||
{policyDirty ? (
|
||||
<div className="flex justify-end">
|
||||
<SettingsCard className="flex flex-row justify-end p-4">
|
||||
<Button
|
||||
disabled={savingPolicy}
|
||||
onClick={savePolicy}
|
||||
@@ -238,11 +248,11 @@ export function AIPanel() {
|
||||
? t("settings.ai.saving")
|
||||
: t("settings.ai.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsCard className="px-4 py-3.5">
|
||||
<SettingsCard className="p-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{policy.aiEnabled
|
||||
? policy.disabledForEmployees
|
||||
@@ -252,46 +262,59 @@ export function AIPanel() {
|
||||
</p>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
) : null}
|
||||
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.modeDescription")}
|
||||
title={t("settings.ai.modeTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("mode", value as AiMode)}
|
||||
value={config.mode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
|
||||
<SelectItem value="local">
|
||||
{t("settings.ai.modeLocal")}
|
||||
</SelectItem>
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "api"
|
||||
? t("settings.ai.modeApiHint")
|
||||
: t("settings.ai.modeLocalHint")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCard className="space-y-1.5 p-5">
|
||||
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("mode", value as AiMode)}
|
||||
value={config.mode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
<SelectItem value="auto">
|
||||
{t("settings.ai.modeAuto")}
|
||||
</SelectItem>
|
||||
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
|
||||
<SelectItem value="local">
|
||||
{t("settings.ai.modeLocal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="off">{t("settings.ai.modeOff")}</SelectItem>
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`settings.ai.mode${modeHintSuffix}Hint`)}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
|
||||
{config.mode === "api" ? (
|
||||
<SettingsSection
|
||||
{config.mode === "off" ? (
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.offDescription")}
|
||||
title={t("settings.ai.offTitle")}
|
||||
>
|
||||
<SettingsCard className="p-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.ai.offNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</SettingsFrame>
|
||||
) : config.mode !== "local" ? (
|
||||
// API and Automatic both configure a cloud provider (Automatic falls
|
||||
// back to local Ollama when no key is set).
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.providerDescription")}
|
||||
title={t("settings.ai.providerTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
|
||||
<Select
|
||||
@@ -378,14 +401,14 @@ export function AIPanel() {
|
||||
</Select>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
) : (
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.localDescription")}
|
||||
title={t("settings.ai.localTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.ollamaBaseUrl")}</FieldLabel>
|
||||
<Input
|
||||
@@ -415,39 +438,41 @@ export function AIPanel() {
|
||||
: t("settings.ai.testConnection")}
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.ai.veilDescription")}
|
||||
title={t("settings.ai.veilTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-4 p-5">
|
||||
{config.mode !== "off" ? (
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.veilDescription")}
|
||||
title={t("settings.ai.veilTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-4 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
|
||||
value={config.veilLevel}
|
||||
>
|
||||
<SelectTrigger className="sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{VEIL_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{t(`settings.ai.veilLevels.${level}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "local"
|
||||
? t("settings.ai.veilLocalNote")
|
||||
: t("settings.ai.veilApiNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
|
||||
value={config.veilLevel}
|
||||
>
|
||||
<SelectTrigger className="sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{VEIL_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{t(`settings.ai.veilLevels.${level}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "local"
|
||||
? t("settings.ai.veilLocalNote")
|
||||
: t("settings.ai.veilApiNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</SettingsFrame>
|
||||
) : null}
|
||||
|
||||
{dirty ? (
|
||||
<div className="sticky bottom-4 z-10">
|
||||
|
||||
@@ -176,7 +176,7 @@ export function SigningPanel() {
|
||||
description={t("settings.network.description")}
|
||||
title={t("settings.network.title")}
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-between gap-4 p-5">
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
@@ -251,7 +251,7 @@ export function SigningPanel() {
|
||||
description={t("settings.signing.backupDescription")}
|
||||
title={t("settings.signing.backupTitle")}
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-between gap-4 p-4">
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.signing.backupLabel")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Info, UserPlus } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AddStaffDialog } from "@/components/settings/add-staff-dialog";
|
||||
@@ -64,36 +64,47 @@ export function CareTeamPanel({
|
||||
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch<StaffMember[]>("/api/staff");
|
||||
setMembers(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// The effect owns the fetch; `reload()` just bumps a key to re-run it. That
|
||||
// keeps every setState inside a promise callback rather than running
|
||||
// synchronously when the effect fires, and it drops the exhaustive-deps
|
||||
// suppression the old useCallback needed.
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const reload = () => setReloadKey((k) => k + 1);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
let active = true;
|
||||
apiFetch<StaffMember[]>("/api/staff")
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
setMembers(data);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!active) return;
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// `t` is intentionally omitted: re-fetching the staff list on a language
|
||||
// change would be pointless, and the message is only read on failure.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadKey]);
|
||||
|
||||
// Deep-link: open a specific member (e.g. from a password-reset system card).
|
||||
const appliedDeepLink = useRef(false);
|
||||
useEffect(() => {
|
||||
if (appliedDeepLink.current || !initialMemberId || members.length === 0)
|
||||
return;
|
||||
// Resolved during render once the list arrives, so the member's dialog opens
|
||||
// on the same paint the list does.
|
||||
const [appliedDeepLink, setAppliedDeepLink] = useState(false);
|
||||
if (!appliedDeepLink && initialMemberId && members.length > 0) {
|
||||
setAppliedDeepLink(true);
|
||||
const target = members.find((m) => m.userId === initialMemberId);
|
||||
if (target) {
|
||||
setSelected(target);
|
||||
appliedDeepLink.current = true;
|
||||
}
|
||||
}, [initialMemberId, members]);
|
||||
if (target) setSelected(target);
|
||||
}
|
||||
|
||||
const myRole = members.find((m) => m.userId === session?.user?.id)?.role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
@@ -119,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);
|
||||
|
||||
@@ -5,9 +5,59 @@ import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardFrame,
|
||||
CardFrameAction,
|
||||
CardFrameDescription,
|
||||
CardFrameHeader,
|
||||
CardFrameTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// A settings section rendered inside the COSS "frame" surface: a titled header
|
||||
// above one or more cards.
|
||||
//
|
||||
// Children are rendered as *direct* children of CardFrame on purpose. CardFrame
|
||||
// styles its cards through direct-child selectors (`*:data-[slot=card]:-m-px`,
|
||||
// the clip-path, `rounded-t/b-xl`, `shadow-none`, `before:hidden`) — it pulls
|
||||
// each card out by a pixel so it sits flush inside the frame's own border. Put
|
||||
// anything between the frame and the card, even an unstyled div, and every one
|
||||
// of those selectors stops matching: the card keeps its own border and shadow
|
||||
// and you get a box inside a box.
|
||||
//
|
||||
// So: no padding here. Body padding belongs on the Card (`<SettingsCard
|
||||
// className="p-5">`), which is where COSS puts it.
|
||||
export function SettingsFrame({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<CardFrame className={className}>
|
||||
<CardFrameHeader className="border-b border-border/60">
|
||||
<CardFrameTitle className="text-base">{title}</CardFrameTitle>
|
||||
{description ? (
|
||||
<CardFrameDescription>{description}</CardFrameDescription>
|
||||
) : null}
|
||||
{action ? <CardFrameAction>{action}</CardFrameAction> : null}
|
||||
</CardFrameHeader>
|
||||
{children}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// Back-compat wrapper: existing panels compose with SettingsSection, which
|
||||
// renders through the COSS frame surface so the whole settings page shares one
|
||||
// framed look.
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
@@ -20,21 +70,17 @@ export function SettingsSection({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
<SettingsFrame action={action} description={description} title={title}>
|
||||
{children}
|
||||
</section>
|
||||
</SettingsFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// A card surface used inside settings panels. Rendering a real COSS `Card` (with
|
||||
// `data-slot="card"`) keeps settings cards consistent with the rest of the app
|
||||
// and lets them pick up the frame's card treatment. `Card` is `flex flex-col`,
|
||||
// so row layouts must pass `flex-row` in their className. It carries no padding
|
||||
// of its own — pass `p-5` (the settings default) or a `divide-y` list.
|
||||
export function SettingsCard({
|
||||
className,
|
||||
children,
|
||||
@@ -42,11 +88,7 @@ export function SettingsCard({
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl border border-border bg-card/30", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <Card className={className}>{children}</Card>;
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
@@ -64,7 +106,7 @@ export function ToggleRow({
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 px-4 py-3.5">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? (
|
||||
|
||||
@@ -39,12 +39,18 @@ export function PatientPortalSection() {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
|
||||
|
||||
// Drop the previous clinic's QR as soon as the slug changes, rather than
|
||||
// leaving it on screen until the new one arrives. Adjusted during render, so
|
||||
// switching clinics never shows the old clinic's pairing code.
|
||||
const [prevSlug, setPrevSlug] = useState(slug);
|
||||
if (prevSlug !== slug) {
|
||||
setPrevSlug(slug);
|
||||
setQrUri("");
|
||||
}
|
||||
|
||||
// Fetch the relay-based pairing descriptor for the QR (non-secret).
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setQrUri("");
|
||||
return;
|
||||
}
|
||||
if (!slug) return;
|
||||
let active = true;
|
||||
getPortalLink(slug)
|
||||
.then((link) => {
|
||||
|
||||
@@ -113,13 +113,17 @@ export function ProfilePanel() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Seed the display name from the session once it loads.
|
||||
useEffect(() => {
|
||||
// Seed the display name from the session once it loads. Adjusted during
|
||||
// render so the field isn't briefly empty after the session resolves. Still
|
||||
// `prev ||`, so it never clobbers something the clinician has typed.
|
||||
const [prevUserName, setPrevUserName] = useState(user?.name);
|
||||
if (prevUserName !== user?.name) {
|
||||
setPrevUserName(user?.name);
|
||||
if (user?.name) {
|
||||
setName((prev) => prev || user.name);
|
||||
setBaselineName((prev) => prev || user.name);
|
||||
}
|
||||
}, [user?.name]);
|
||||
}
|
||||
|
||||
const setPref = (key: string, value: boolean | string) =>
|
||||
setPrefs((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -337,45 +341,41 @@ export function ProfilePanel() {
|
||||
description={t("settings.profile.patientNotificationsDescription")}
|
||||
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")}
|
||||
title={t("settings.profile.accountNotifications")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{accountNotifications.map((item) => (
|
||||
<ToggleRow
|
||||
checked={Boolean(prefs[`notif.${item.titleKey}`])}
|
||||
description={t(`settings.profile.notif.${item.descKey}`)}
|
||||
key={item.titleKey}
|
||||
onCheckedChange={(checked) =>
|
||||
setPref(`notif.${item.titleKey}`, checked)
|
||||
}
|
||||
title={t(`settings.profile.notif.${item.titleKey}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{accountNotifications.map((item) => (
|
||||
<ToggleRow
|
||||
checked={Boolean(prefs[`notif.${item.titleKey}`])}
|
||||
description={t(`settings.profile.notif.${item.descKey}`)}
|
||||
key={item.titleKey}
|
||||
onCheckedChange={(checked) =>
|
||||
setPref(`notif.${item.titleKey}`, checked)
|
||||
}
|
||||
title={t(`settings.profile.notif.${item.titleKey}`)}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.profile.dangerZoneDescription")}
|
||||
title={t("settings.profile.dangerZone")}
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-between gap-4 p-4">
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.profile.deleteAccount")}
|
||||
|
||||
@@ -103,18 +103,26 @@ function AddTaskDialog({
|
||||
const [priority, setPriority] = useState<Priority>("medium");
|
||||
const [status, setStatus] = useState<TaskStatus>(initialStatus);
|
||||
|
||||
// Re-seed the column when the dialog is opened from a specific column, and
|
||||
// load the clinic's providers so a task can be assigned to a specific person.
|
||||
// Re-seed the column when the dialog is opened from a specific column.
|
||||
// Adjusted during render rather than in an effect, so the select never paints
|
||||
// with the previous column's value.
|
||||
const [prevSeed, setPrevSeed] = useState<string | null>(null);
|
||||
const seed = open ? initialStatus : null;
|
||||
if (prevSeed !== seed) {
|
||||
setPrevSeed(seed);
|
||||
if (seed) setStatus(seed);
|
||||
}
|
||||
|
||||
// Load the clinic's providers so a task can be assigned to a specific person.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStatus(initialStatus);
|
||||
listProviders()
|
||||
.then((list) => {
|
||||
setProviders(list);
|
||||
setProviderId((id) => id || (list[0]?.userId ?? ""));
|
||||
})
|
||||
.catch(() => setProviders([]));
|
||||
}, [open, initialStatus]);
|
||||
}, [open]);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: CheckboxPrimitive.Root.Props): React.ReactElement {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
"relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-[.25rem] border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none ring-ring transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[3px] not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-background aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 data-disabled:cursor-not-allowed data-disabled:opacity-64 sm:size-4 dark:not-data-checked:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
|
||||
className,
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className="absolute -inset-px flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground"
|
||||
data-slot="checkbox-indicator"
|
||||
render={(
|
||||
props: React.ComponentProps<"span">,
|
||||
state: CheckboxPrimitive.Indicator.State,
|
||||
) => (
|
||||
<span {...props}>
|
||||
{state.indeterminate ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-3.5 sm:size-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5.252 12h13.496" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-3.5 sm:size-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { CheckboxPrimitive };
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TableVariant = "default" | "card";
|
||||
|
||||
export type TableProps = React.ComponentProps<"table"> & {
|
||||
variant?: TableVariant;
|
||||
render?: useRender.ComponentProps<"div">["render"];
|
||||
};
|
||||
|
||||
export function Table({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: TableProps): React.ReactElement {
|
||||
const defaultProps = {
|
||||
children: (
|
||||
<table
|
||||
className={cn(
|
||||
"w-full caption-bottom in-data-[variant=card]:border-separate in-data-[variant=card]:border-spacing-0 text-sm",
|
||||
className,
|
||||
)}
|
||||
data-slot="table"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
className: "relative w-full overflow-x-auto",
|
||||
"data-slot": "table-container",
|
||||
"data-variant": variant,
|
||||
};
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(defaultProps, {}),
|
||||
render,
|
||||
});
|
||||
}
|
||||
|
||||
export function TableHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"thead">): React.ReactElement {
|
||||
return (
|
||||
<thead
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
data-slot="table-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableBody({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tbody">): React.ReactElement {
|
||||
return (
|
||||
<tbody
|
||||
className={cn(
|
||||
"relative in-data-[variant=card]:rounded-xl in-data-[variant=card]:shadow-xs/5 before:pointer-events-none before:absolute before:inset-px not-in-data-[variant=card]:before:hidden before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/8%)] [&_tr:last-child]:border-0 in-data-[variant=card]:*:[tr]:border-0 in-data-[variant=card]:*:[tr]:*:[td]:border-b in-data-[variant=card]:*:[tr]:*:[td]:bg-card in-data-[variant=card]:*:[tr]:first:*:[td]:first:rounded-ss-xl in-data-[variant=card]:*:[tr]:*:[td]:first:border-s in-data-[variant=card]:*:[tr]:first:*:[td]:border-t in-data-[variant=card]:*:[tr]:last:*:[td]:last:rounded-ee-xl in-data-[variant=card]:*:[tr]:*:[td]:last:border-e in-data-[variant=card]:*:[tr]:first:*:[td]:last:rounded-se-xl in-data-[variant=card]:*:[tr]:last:*:[td]:first:rounded-es-xl in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_4%)] dark:in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_4%)] dark:in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)]",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-body"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tfoot">): React.ReactElement {
|
||||
return (
|
||||
<tfoot
|
||||
className={cn(
|
||||
"border-t in-data-[variant=card]:border-none bg-transparent not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] font-medium dark:not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)] [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tr">): React.ReactElement {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"relative border-b not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-black)_2%)] not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-black)_4%)] dark:not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-white)_4%)] dark:not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-white)_2%)]",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-row"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"th">): React.ReactElement {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-10 whitespace-nowrap px-2.5 text-left align-middle font-medium text-muted-foreground leading-none has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-head"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCell({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"td">): React.ReactElement {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"whitespace-nowrap bg-clip-padding p-2.5 in-data-[slot=table-footer]:py-3.5 align-middle leading-none in-data-[variant=card]:first:ps-[calc(--spacing(2.5)-1px)] in-data-[variant=card]:last:pe-[calc(--spacing(2.5)-1px)] has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-cell"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">): React.ReactElement {
|
||||
return (
|
||||
<caption
|
||||
className={cn(
|
||||
"in-data-[variant=card]:my-4 mt-4 text-muted-foreground text-sm",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-caption"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -125,7 +125,10 @@ interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
function TimelineIndicator({
|
||||
asChild = false,
|
||||
// Accepted for API parity but unused: COSS/Base UI composes with `render`,
|
||||
// not `asChild`. Destructured so it never lands on the DOM node.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
asChild: _asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
|
||||
@@ -28,15 +28,21 @@ export function useWalletSync(fileNumber: string | null | undefined) {
|
||||
const [update, setUpdate] = useState<WalletUpdate | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Drop the previous patient's link state as soon as the selection changes,
|
||||
// adjusted during render: `linked` gates the wallet step, so carrying it over
|
||||
// for even one render would offer to push another patient's record.
|
||||
const [prevFileNumber, setPrevFileNumber] = useState(fileNumber);
|
||||
if (prevFileNumber !== fileNumber) {
|
||||
setPrevFileNumber(fileNumber);
|
||||
setLinked(false);
|
||||
setChecking(Boolean(fileNumber));
|
||||
}
|
||||
|
||||
// Resolve link status whenever the chosen patient changes. A 404 simply means
|
||||
// "not wallet-backed", so failures collapse to `linked = false`.
|
||||
useEffect(() => {
|
||||
if (!fileNumber) {
|
||||
setLinked(false);
|
||||
return;
|
||||
}
|
||||
if (!fileNumber) return;
|
||||
let active = true;
|
||||
setChecking(true);
|
||||
getWalletLink(fileNumber)
|
||||
.then(() => {
|
||||
if (active) setLinked(true);
|
||||
@@ -74,13 +80,43 @@ export function useWalletSync(fileNumber: string | null | undefined) {
|
||||
};
|
||||
}, [state, update]);
|
||||
|
||||
// Authoritatively resolve link status at submit time. The `linked` state above
|
||||
// is populated asynchronously by the effect, so a fast save (or a transient
|
||||
// failure that collapsed it to false) can leave a wallet-backed patient looking
|
||||
// unlinked. Callers await this before deciding whether to show the wallet step,
|
||||
// so the decision is never made on an unresolved check.
|
||||
const ensureLinked = useCallback(async (): Promise<boolean> => {
|
||||
if (!fileNumber) {
|
||||
setLinked(false);
|
||||
return false;
|
||||
}
|
||||
setChecking(true);
|
||||
try {
|
||||
await getWalletLink(fileNumber);
|
||||
setLinked(true);
|
||||
return true;
|
||||
} catch {
|
||||
setLinked(false);
|
||||
return false;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [fileNumber]);
|
||||
|
||||
const push = useCallback(
|
||||
async (changes: string[]) => {
|
||||
if (!fileNumber) return;
|
||||
// Never push an empty/whitespace change set — the backend rejects it (400).
|
||||
const clean = changes.map((c) => c.trim()).filter(Boolean);
|
||||
if (clean.length === 0) {
|
||||
setError("generic");
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setState("pending");
|
||||
try {
|
||||
const created = await pushWalletUpdate({ fileNumber, changes });
|
||||
const created = await pushWalletUpdate({ fileNumber, changes: clean });
|
||||
setUpdate(created);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "generic");
|
||||
@@ -96,7 +132,7 @@ export function useWalletSync(fileNumber: string | null | undefined) {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { linked, checking, state, update, error, push, reset };
|
||||
return { linked, checking, state, update, error, ensureLinked, push, reset };
|
||||
}
|
||||
|
||||
export type UseWalletSync = ReturnType<typeof useWalletSync>;
|
||||
|
||||
@@ -60,6 +60,9 @@ export function WalletSyncStep({
|
||||
const { t } = useTranslation();
|
||||
const { state, update, error, push } = sync;
|
||||
const status = update?.status ?? "pending";
|
||||
// The summary is the human-readable change the patient approves. Guard against
|
||||
// an empty one (the backend 400s on empty changes) with a translated fallback.
|
||||
const changeSummary = summary.trim() || t("walletSync.summaryFallback");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -79,7 +82,7 @@ export function WalletSyncStep({
|
||||
{t("walletSync.changesLabel")}
|
||||
</span>
|
||||
<div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm">
|
||||
{summary}
|
||||
{changeSummary}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
@@ -119,7 +122,7 @@ export function WalletSyncStep({
|
||||
<Button onClick={onDone} type="button" variant="outline">
|
||||
{t("walletSync.skip")}
|
||||
</Button>
|
||||
<Button onClick={() => push([summary])} type="button">
|
||||
<Button onClick={() => push([changeSummary])} type="button">
|
||||
<Send className="size-4" />
|
||||
{t("walletSync.send")}
|
||||
</Button>
|
||||
|
||||
@@ -12,6 +12,26 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
|
||||
// Vendored component libraries we don't hand-maintain. Both are pulled in
|
||||
// from upstream registries and re-linting them just reports upstream's
|
||||
// style back at us — `react-hooks/refs` and `set-state-in-effect` alone
|
||||
// account for most of it, and "fixing" them means diverging from upstream
|
||||
// and eating the conflicts on every update.
|
||||
//
|
||||
// ai-elements additionally has pre-existing Base UI type drift already
|
||||
// carved out via typescript.ignoreBuildErrors in next.config.ts; this is
|
||||
// the lint half of that same carve-out. See CLAUDE.md.
|
||||
"components/ai-elements/**",
|
||||
"components/charts/**",
|
||||
|
||||
// Registry components, added/updated via `npx shadcn add` rather than
|
||||
// hand-written. Two carry upstream patterns the compiler rules dislike
|
||||
// (carousel publishes its api to the parent from an effect; the sidebar
|
||||
// skeleton picks a random width), and editing them here would be undone by
|
||||
// the next registry update.
|
||||
"components/ui/carousel.tsx",
|
||||
"components/ui/sidebar.tsx",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,18 +2,23 @@ import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
function subscribe(onChange: () => void) {
|
||||
const mql = window.matchMedia(QUERY)
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}
|
||||
|
||||
// `useSyncExternalStore` rather than an effect that seeds state on mount: the
|
||||
// viewport is an external store, and reading it through the subscription keeps
|
||||
// the value correct on the first paint instead of rendering one frame at the
|
||||
// wrong breakpoint. The server snapshot is `false` (desktop-first) because
|
||||
// there's no viewport to measure during SSR.
|
||||
export function useIsMobile() {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(QUERY).matches,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { Effort } from "@/lib/ai-models";
|
||||
// saved to /api/ai/config. Provider API keys are write-only: they are never
|
||||
// returned, only `apiKeySet` reports which providers have a stored key.
|
||||
|
||||
export type AiMode = "api" | "local";
|
||||
// "auto" auto-picks a cloud provider when a key is set, else falls back to local
|
||||
// Ollama; "off" disables the assistant entirely.
|
||||
export type AiMode = "api" | "local" | "auto" | "off";
|
||||
export type ApiProvider = "openai" | "anthropic" | "gemini";
|
||||
export type VeilLevel = "off" | "names" | "full";
|
||||
|
||||
|
||||
@@ -376,8 +376,10 @@
|
||||
"failedTitle": "تعذّر حذف المريض",
|
||||
"failedBody": "يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"filterLabel": "تصفية",
|
||||
"filterStatus": "تصفية حسب الحالة",
|
||||
"allStatuses": "كل الحالات"
|
||||
"allStatuses": "كل الحالات",
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "المواعيد والجدول",
|
||||
@@ -1148,7 +1150,12 @@
|
||||
"title": "اربط نموذج ذكاء اصطناعي للبدء",
|
||||
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
|
||||
"action": "فتح إعدادات الذكاء الاصطناعي",
|
||||
"dismiss": "تجاهل"
|
||||
"dismiss": "تجاهل",
|
||||
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
|
||||
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "بطاقة غير مدعومة"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
|
||||
@@ -1555,7 +1562,8 @@
|
||||
"mild": "خفيف",
|
||||
"moderate": "متوسط",
|
||||
"severe": "شديد"
|
||||
}
|
||||
},
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "تعديل السجل",
|
||||
@@ -2109,6 +2117,13 @@
|
||||
"modeLocal": "نموذج محلي (Ollama)",
|
||||
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
|
||||
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
|
||||
"modeAuto": "تلقائي",
|
||||
"modeOff": "إيقاف",
|
||||
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
|
||||
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
|
||||
"offTitle": "المساعد مُوقف",
|
||||
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
|
||||
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
|
||||
"providerTitle": "المزوّد ومفتاح API",
|
||||
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
|
||||
"provider": "المزوّد",
|
||||
@@ -2242,6 +2257,7 @@
|
||||
"prescription": "وصفة جديدة: {{drug}}",
|
||||
"demographics": "تم تحديث البيانات الديموغرافية",
|
||||
"note": "ملاحظة سريرية جديدة"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "تم تحديث السجل"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +364,10 @@
|
||||
"failedTitle": "Patient konnte nicht gelöscht werden",
|
||||
"failedBody": "Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Nach Status filtern",
|
||||
"allStatuses": "Alle Status"
|
||||
"allStatuses": "Alle Status",
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Termine & Zeitplan",
|
||||
@@ -1132,7 +1134,12 @@
|
||||
"title": "Verbinden Sie ein KI-Modell, um zu beginnen",
|
||||
"body": "Es ist noch kein KI-Anbieter eingerichtet. Fügen Sie einen API-Schlüssel hinzu oder verweisen Sie temetro auf ein lokales Ollama-Modell, damit der Assistent antworten kann.",
|
||||
"action": "KI-Einstellungen öffnen",
|
||||
"dismiss": "Ausblenden"
|
||||
"dismiss": "Ausblenden",
|
||||
"offTitle": "Der KI-Assistent ist ausgeschaltet",
|
||||
"offBody": "KI ist unter Einstellungen → KI auf Aus gestellt. Schalten Sie sie ein, um den Assistenten zu nutzen."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Nicht unterstützte Karte"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Fragen Sie etwas oder tippen Sie /patient 10293",
|
||||
@@ -1535,7 +1542,8 @@
|
||||
"mild": "Leicht",
|
||||
"moderate": "Mäßig",
|
||||
"severe": "Schwer"
|
||||
}
|
||||
},
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Datensatz bearbeiten",
|
||||
@@ -2089,6 +2097,13 @@
|
||||
"modeLocal": "Lokales Modell (Ollama)",
|
||||
"modeApiHint": "Anfragen gehen an Ihren gewählten Anbieter. Patientenkennungen werden von Veil anonymisiert, bevor sie das System verlassen.",
|
||||
"modeLocalHint": "Anfragen laufen gegen ein Modell auf Ihrer eigenen Infrastruktur. Keine Patientendaten verlassen die Klinik.",
|
||||
"modeAuto": "Automatisch",
|
||||
"modeOff": "Aus",
|
||||
"modeAutoHint": "Verwendet einen Cloud-API-Schlüssel, sofern vorhanden, andernfalls Ihr lokales Ollama-Modell.",
|
||||
"modeOffHint": "Der KI-Assistent ist deaktiviert. Es werden keine Anfragen gesendet.",
|
||||
"offTitle": "Assistent aus",
|
||||
"offDescription": "Der KI-Assistent ist für Ihr Konto ausgeschaltet.",
|
||||
"offNote": "Wählen Sie oben Automatisch, Cloud-API-Schlüssel oder Lokales Modell, um den Assistenten zu aktivieren.",
|
||||
"providerTitle": "Anbieter & API-Schlüssel",
|
||||
"providerDescription": "Ihr Schlüssel wird verschlüsselt gespeichert und nie wieder angezeigt. Sie können einen Schlüssel pro Anbieter speichern und zwischen ihnen wechseln.",
|
||||
"provider": "Anbieter",
|
||||
@@ -2222,6 +2237,7 @@
|
||||
"prescription": "Neues Rezept: {{drug}}",
|
||||
"demographics": "Stammdaten aktualisiert",
|
||||
"note": "Neue klinische Notiz"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Datensatz aktualisiert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +364,10 @@
|
||||
"failedTitle": "Couldn't delete patient",
|
||||
"failedBody": "Please try again."
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Filter by status",
|
||||
"allStatuses": "All statuses"
|
||||
"allStatuses": "All statuses",
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments & Schedule",
|
||||
@@ -1132,7 +1134,12 @@
|
||||
"title": "Connect an AI model to get started",
|
||||
"body": "No AI provider is set up yet. Add an API key or point temetro at a local Ollama model so the assistant can answer.",
|
||||
"action": "Open AI settings",
|
||||
"dismiss": "Dismiss"
|
||||
"dismiss": "Dismiss",
|
||||
"offTitle": "The AI assistant is turned off",
|
||||
"offBody": "AI is set to Off in Settings → AI. Turn it on to use the assistant."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Unsupported card"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Ask anything, or type /patient 10293",
|
||||
@@ -1535,7 +1542,8 @@
|
||||
"mild": "Mild",
|
||||
"moderate": "Moderate",
|
||||
"severe": "Severe"
|
||||
}
|
||||
},
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Edit record",
|
||||
@@ -2087,8 +2095,15 @@
|
||||
"mode": "Mode",
|
||||
"modeApi": "Cloud API key",
|
||||
"modeLocal": "Local model (Ollama)",
|
||||
"modeAuto": "Automatic",
|
||||
"modeOff": "Off",
|
||||
"modeApiHint": "Requests go to your chosen provider. Patient identifiers are de-identified by Veil before they leave.",
|
||||
"modeLocalHint": "Requests run against a model on your own infrastructure. No patient data leaves the clinic.",
|
||||
"modeAutoHint": "Uses a cloud API key when one is set, otherwise falls back to your local Ollama model.",
|
||||
"modeOffHint": "The AI assistant is disabled. No requests are sent.",
|
||||
"offTitle": "Assistant off",
|
||||
"offDescription": "The AI assistant is turned off for your account.",
|
||||
"offNote": "Choose Automatic, Cloud API key, or Local model above to enable the assistant.",
|
||||
"providerTitle": "Provider & API key",
|
||||
"providerDescription": "Your key is encrypted at rest and never shown again. You can store a key per provider and switch between them.",
|
||||
"provider": "Provider",
|
||||
@@ -2222,6 +2237,7 @@
|
||||
"prescription": "New prescription: {{drug}}",
|
||||
"demographics": "Demographics updated",
|
||||
"note": "New clinical note"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Record updated"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +364,10 @@
|
||||
"failedTitle": "Impossible de supprimer le patient",
|
||||
"failedBody": "Veuillez réessayer."
|
||||
},
|
||||
"filterLabel": "Filtre",
|
||||
"filterStatus": "Filtrer par statut",
|
||||
"allStatuses": "Tous les statuts"
|
||||
"allStatuses": "Tous les statuts",
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Rendez-vous & Planning",
|
||||
@@ -1132,7 +1134,12 @@
|
||||
"title": "Connectez un modèle d'IA pour commencer",
|
||||
"body": "Aucun fournisseur d'IA n'est encore configuré. Ajoutez une clé API ou pointez temetro vers un modèle Ollama local pour que l'assistant puisse répondre.",
|
||||
"action": "Ouvrir les paramètres d'IA",
|
||||
"dismiss": "Ignorer"
|
||||
"dismiss": "Ignorer",
|
||||
"offTitle": "L'assistant IA est désactivé",
|
||||
"offBody": "L'IA est réglée sur Désactivée dans Paramètres → IA. Activez-la pour utiliser l'assistant."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Carte non prise en charge"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Demandez n'importe quoi, ou tapez /patient 10293",
|
||||
@@ -1535,7 +1542,8 @@
|
||||
"mild": "Légère",
|
||||
"moderate": "Modérée",
|
||||
"severe": "Sévère"
|
||||
}
|
||||
},
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Modifier le dossier",
|
||||
@@ -2089,6 +2097,13 @@
|
||||
"modeLocal": "Modèle local (Ollama)",
|
||||
"modeApiHint": "Les requêtes vont vers le fournisseur que vous avez choisi. Les identifiants des patients sont dé-identifiés par Veil avant leur départ.",
|
||||
"modeLocalHint": "Les requêtes s'exécutent sur un modèle de votre propre infrastructure. Aucune donnée patient ne quitte la clinique.",
|
||||
"modeAuto": "Automatique",
|
||||
"modeOff": "Désactivé",
|
||||
"modeAutoHint": "Utilise une clé API cloud si elle est définie, sinon revient à votre modèle Ollama local.",
|
||||
"modeOffHint": "L'assistant IA est désactivé. Aucune requête n'est envoyée.",
|
||||
"offTitle": "Assistant désactivé",
|
||||
"offDescription": "L'assistant IA est désactivé pour votre compte.",
|
||||
"offNote": "Choisissez Automatique, Clé API cloud ou Modèle local ci-dessus pour activer l'assistant.",
|
||||
"providerTitle": "Fournisseur & clé API",
|
||||
"providerDescription": "Votre clé est chiffrée au repos et n'est plus jamais affichée. Vous pouvez stocker une clé par fournisseur et basculer entre elles.",
|
||||
"provider": "Fournisseur",
|
||||
@@ -2222,6 +2237,7 @@
|
||||
"prescription": "Nouvelle ordonnance : {{drug}}",
|
||||
"demographics": "Données démographiques mises à jour",
|
||||
"note": "Nouvelle note clinique"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Dossier mis à jour"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +364,10 @@
|
||||
"failedTitle": "Bukaanka lama tirtiri karin",
|
||||
"failedBody": "Fadlan isku day mar kale."
|
||||
},
|
||||
"filterLabel": "Kala sooc",
|
||||
"filterStatus": "Ku kala sooc xaaladda",
|
||||
"allStatuses": "Dhammaan xaaladaha"
|
||||
"allStatuses": "Dhammaan xaaladaha",
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Ballamaha & Jadwalka",
|
||||
@@ -1132,7 +1134,12 @@
|
||||
"title": "Ku xir moodeel AI si aad u bilowdo",
|
||||
"body": "Weli bixiye AI lama dejin. Ku dar furaha API ama ku tilmaam temetro moodeel Ollama oo maxalli ah si kaaliyuhu u jawaabo.",
|
||||
"action": "Fur dejinta AI",
|
||||
"dismiss": "Rid"
|
||||
"dismiss": "Rid",
|
||||
"offTitle": "Kaaliyaha AI waa la damiyay",
|
||||
"offBody": "AI waxaa loo dejiyay Damin gudaha Settings → AI. Shid si aad u isticmaasho kaaliyaha."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Kaadh aan la taageerin"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Waydii wax kasta, ama qor /patient 10293",
|
||||
@@ -1535,7 +1542,8 @@
|
||||
"mild": "Fudud",
|
||||
"moderate": "Dhexdhexaad",
|
||||
"severe": "Daran"
|
||||
}
|
||||
},
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Wax ka beddel diiwaanka",
|
||||
@@ -2089,6 +2097,13 @@
|
||||
"modeLocal": "Moodeel maxalli ah (Ollama)",
|
||||
"modeApiHint": "Codsiyada waxay u socdaan bixiyahaaga aad dooratay. Aqoonsiyada bukaanka waxaa qariya Veil ka hor inta aysan bixin.",
|
||||
"modeLocalHint": "Codsiyada waxay ku socdaan moodeel kaabayaashaada ah. Xog bukaan kama baxdo rugta.",
|
||||
"modeAuto": "Toos ah",
|
||||
"modeOff": "Damin",
|
||||
"modeAutoHint": "Wuxuu isticmaalaa furaha API-ga daruuraha marka la dejiyo, haddii kale wuxuu ku noqdaa moodelkaaga Ollama ee gudaha.",
|
||||
"modeOffHint": "Kaaliyaha AI waa la damiyay. Wax codsi ah lama dirayo.",
|
||||
"offTitle": "Kaaliyuhu waa damsan yahay",
|
||||
"offDescription": "Kaaliyaha AI waa laga damiyay akoonkaaga.",
|
||||
"offNote": "Dooro Toos ah, Furaha API daruuraha, ama Moodel Gudaha kor si aad u shidid kaaliyaha.",
|
||||
"providerTitle": "Bixiye & furaha API",
|
||||
"providerDescription": "Furahaaga waa la shifraa marka la kaydiyo mana dib loo muujiyo. Waxaad kaydin kartaa fur bixiye kasta oo aad kala beddeli kartaa.",
|
||||
"provider": "Bixiye",
|
||||
@@ -2222,6 +2237,7 @@
|
||||
"prescription": "Rijeeto cusub: {{drug}}",
|
||||
"demographics": "Xogta bukaanka la cusboonaysiiyay",
|
||||
"note": "Qoraal caafimaad cusub"
|
||||
}
|
||||
},
|
||||
"summaryFallback": "Diiwaanka waa la cusboonaysiiyay"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"check-locales": "node scripts/check-locales.mjs"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user