Compare commits

...

10 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:13:34 +03:00
30 changed files with 5512 additions and 61 deletions
+104 -28
View File
@@ -3,6 +3,11 @@
# Trigger: push a semver tag, e.g.
# git tag v0.1.0 && git push origin v0.1.0
#
# Multi-arch (amd64 + arm64) is built on NATIVE runners — amd64 on ubuntu-24.04,
# arm64 on ubuntu-24.04-arm — and merged into a manifest. We do NOT emulate arm64
# with QEMU anymore: a runner/QEMU update started crashing `npm ci` under
# emulation ("illegal instruction") and hanging the build for hours.
#
# The frontend image bakes NO API URL — it resolves the backend from the host
# the browser uses at runtime — so one published image works for every clinic.
#
@@ -18,27 +23,30 @@ on:
tags:
- "v*.*.*"
permissions:
contents: write # create the GitHub Release (the update check reads this)
env:
REGISTRY_NAMESPACE: khalidxv
jobs:
publish:
runs-on: ubuntu-latest
# One build per (image × platform) on the platform's native runner, pushed to
# Docker Hub by digest (no tag yet). The merge job stitches the per-arch
# digests into a single tagged multi-arch manifest.
build:
name: Build ${{ matrix.image }} (${{ matrix.platform }})
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
image: [backend, frontend]
platform: [linux/amd64, linux/arm64]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# QEMU lets the amd64 runner emulate arm64 so the images below build for
# both platforms (Intel + Apple Silicon self-hosters).
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
@@ -49,25 +57,93 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & push backend
- name: Build & push by digest
id: build
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:latest
context: ./${{ matrix.image }}
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
- name: Build & push frontend
uses: docker/build-push-action@v6
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
context: ./frontend
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
name: digests-${{ matrix.image }}-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-arch digests for each image into one multi-arch manifest and
# tag it (X.Y.Z + latest).
merge:
name: Merge ${{ matrix.image }} manifest
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
image: [backend, frontend]
steps:
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-${{ matrix.image }}-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
VERSION: ${{ steps.meta.outputs.version }}
run: |
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \
$(printf "$IMAGE@sha256:%s " *)
- name: Inspect
env:
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
VERSION: ${{ steps.meta.outputs.version }}
run: docker buildx imagetools inspect "$IMAGE:$VERSION"
# Both images are published — now cut the GitHub Release with the changelog.
release:
name: GitHub Release
runs-on: ubuntu-latest
timeout-minutes: 10
needs: merge
permissions:
contents: write # create the GitHub Release (the update check reads this)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# Pull this version's section out of CHANGELOG.md so the release has real,
# human-written notes (the auto "Full Changelog" link is still appended
+38
View File
@@ -7,6 +7,44 @@ for how releases are cut and published.
## [Unreleased]
## [0.17.0] — 2026-07-19
### Changed
- **Pharmacy Add-item now uses a hardware barcode scanner.** The inventory Add-item dialog is
built for a USB barcode scanner connected to the computer (a keyboard wedge): scanning a
medication types the code as a fast keystroke burst ending in Enter, which is parsed and
auto-fills the Barcode/NDC, expiry (AI 17), and lot (AI 10) fields — no need to focus any input
first. The barcode field also parses on Enter for manual entry. This replaces the camera scanner
in this dialog (the camera scanner stays for importing a patient's wallet code)
(`frontend/components/pharmacy/add-inventory-dialog.tsx`).
- **Care team settings use Separated Panels.** The Care team section now renders as distinct
bordered panels on a muted tray, matching the Preferences and AI settings frames
(`frontend/components/settings/settings-care-team.tsx`).
### Fixed
- **Pointer cursor on Activity rows.** Hovering an entry in the Activity feed now shows the pointer
cursor (`frontend/components/activity/activity-view.tsx`).
## [0.16.0] — 2026-07-18
### Added
- **Show / Add tabs on the patient record dialog.** Editing a record now opens on a read-only
"Show" view (the existing `PatientDetail`), with an "Add" tab for the editable form and its Add
buttons. Saving from the Add tab still offers the existing "send to the patient's wallet" step
when the patient is wallet-linked (`frontend/components/chat/patient-form-dialog.tsx`).
- **Barcode scanning in the pharmacy.** The inventory Add-item dialog can now scan a medication
barcode with the camera (`@zxing/browser`, native `BarcodeDetector` where available) into a new
Barcode/NDC field, auto-filling expiry (AI 17) and lot (AI 10) from a GS1 DataMatrix. Adds a
nullable `inventory.barcode` column (migration `0036`).
- **Scan a patient's wallet code when importing.** "Import from a patient app" gains a Scan button
that reads the QR or 2D barcode shown in the patient's wallet app and fills the wallet number
(`frontend/components/patients/import-from-wallet-dialog.tsx`).
### Changed
- **Settings "separated panels" now match COSS exactly.** The opt-in `separated` settings frames
render a real COSS Frame — a muted tray holding distinct bordered panels spaced apart — instead
of a `gap-4` bolted onto the panel-fusing `CardFrame`. New `frontend/components/ui/frame.tsx`.
## [0.15.1] — 2026-07-17
### Fixed
+1 -1
View File
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Docker images](https://img.shields.io/badge/Docker%20Hub-khalidxv-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/u/khalidxv)
[![Changelog](https://img.shields.io/badge/changelog-0.14.0-success)](./CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/changelog-0.17.0-success)](./CHANGELOG.md)
![temetro AI chat](./.github/assets/screenshot-chat.png)
@@ -0,0 +1 @@
ALTER TABLE "inventory" ADD COLUMN "barcode" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -253,6 +253,13 @@
"when": 1783530491321,
"tag": "0035_slippery_retro_girl",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1784406410448,
"tag": "0036_aspiring_expediter",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.15.1",
"version": "0.17.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+2
View File
@@ -29,6 +29,8 @@ export const inventory = pgTable(
stockQuantity: integer("stock_quantity").notNull().default(0),
reorderThreshold: integer("reorder_threshold").notNull().default(0),
location: text("location").notNull().default(""),
// Scanned medication barcode / NDC (GTIN when read from a GS1 DataMatrix).
barcode: text("barcode"),
expiresAt: date("expires_at"),
notes: text("notes"),
createdBy: text("created_by").references(() => user.id, {
+1
View File
@@ -13,6 +13,7 @@ export const inventoryInputSchema = z.object({
stockQuantity: z.number().int().min(0).max(1_000_000).default(0),
reorderThreshold: z.number().int().min(0).max(1_000_000).default(0),
location: z.string().trim().max(200).default(""),
barcode: z.string().trim().max(120).nullish(),
expiresAt: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
+2
View File
@@ -21,6 +21,7 @@ function toInventoryItem(row: InventoryRow): InventoryItem {
stockQuantity: row.stockQuantity,
reorderThreshold: row.reorderThreshold,
location: row.location,
barcode: row.barcode,
expiresAt: row.expiresAt,
notes: row.notes,
createdAt: row.createdAt.toISOString(),
@@ -38,6 +39,7 @@ function columns(orgId: string, input: InventoryInput, createdBy?: string) {
stockQuantity: input.stockQuantity,
reorderThreshold: input.reorderThreshold,
location: input.location,
barcode: input.barcode ?? null,
expiresAt: input.expiresAt ?? null,
notes: input.notes ?? null,
...(createdBy ? { createdBy } : {}),
+1
View File
@@ -11,6 +11,7 @@ export type InventoryItem = {
stockQuantity: number;
reorderThreshold: number;
location: string;
barcode: string | null;
expiresAt: string | null; // YYYY-MM-DD
notes: string | null;
createdAt: string;
@@ -212,7 +212,7 @@ export function ActivityView() {
<button
className={cn(
"-mx-2 flex-1 rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
"-mx-2 flex-1 cursor-pointer rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
isLast ? "pb-1" : "mb-5",
)}
onClick={() => setSelected(entry)}
@@ -23,6 +23,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
import { PatientDetail } from "@/components/patients/patient-detail";
import { ROLE_LABELS } from "@/lib/access";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
@@ -239,6 +241,11 @@ export function PatientFormDialog({
isEdit && patient ? patient.fileNumber : generateFileNumber()
);
const [step, setStep] = useState<"form" | "wallet">("form");
// Editing an existing record shows Show/Add tabs: "Show" (default) is the
// read-only record; "Add" is the editable form with the Add buttons. Create
// and import-review keep the plain form (nothing to show yet).
const showTabs = isEdit && !isReview && Boolean(patient);
const [tab, setTab] = useState<"add" | "show">(showTabs ? "show" : "add");
// Only edits to an existing (non-review) record can sync to a wallet — a newly
// created patient has no wallet, and review mode stages an import.
@@ -453,10 +460,27 @@ export function PatientFormDialog({
/>
) : (
<form className="contents" onSubmit={handleSubmit}>
{showTabs && (
<div className="px-1 pt-1">
<Tabs
onValueChange={(value) => setTab(value as "add" | "show")}
value={tab}
>
<TabsList className="w-full">
<TabsTab value="add">{t("patientForm.tabs.add")}</TabsTab>
<TabsTab value="show">{t("patientForm.tabs.show")}</TabsTab>
</TabsList>
</Tabs>
</div>
)}
<DialogPanel
scrollFade={false}
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
>
{showTabs && tab === "show" && patient ? (
<PatientDetail patient={patient} />
) : (
<>
<Field label={t("patientForm.fileNumber")}>
<div className="flex items-center gap-2">
<Input
@@ -780,6 +804,8 @@ export function PatientFormDialog({
)}
<StagedFilesField onChange={setFiles} value={files} />
</>
)}
</DialogPanel>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
@@ -787,17 +813,19 @@ export function PatientFormDialog({
<p className="text-sm text-destructive sm:me-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
{t("patientForm.cancel")}
{tab === "show" ? t("patientForm.close") : t("patientForm.cancel")}
</DialogClose>
<Button disabled={!name.trim() || submitting} type="submit">
{submitting
? t("patientForm.saving")
: isReview
? t("patientForm.saveDraft")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
{tab !== "show" && (
<Button disabled={!name.trim() || submitting} type="submit">
{submitting
? t("patientForm.saving")
: isReview
? t("patientForm.saveDraft")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
)}
</DialogFooter>
</form>
)}
@@ -1,11 +1,12 @@
"use client";
import { Check, Loader2, QrCode, Smartphone, X } from "lucide-react";
import { Check, Loader2, QrCode, ScanLine, Smartphone, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { BarcodeScanner } from "@/components/scan/barcode-scanner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -63,6 +64,15 @@ export function ImportFromWalletDialog({
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("number");
const [walletNumber, setWalletNumber] = useState("");
const [scanOpen, setScanOpen] = useState(false);
// Scanning the wallet code shown on the patient's phone (QR or 2D barcode)
// drops its wallet number straight into the field.
const handleScan = (value: string) => {
setScanOpen(false);
setMode("number");
setWalletNumber(value.trim());
};
const [temporary, setTemporary] = useState(false);
const [durationHours, setDurationHours] = useState<number>(24);
const [phase, setPhase] = useState<Phase>("form");
@@ -302,13 +312,25 @@ export function ImportFromWalletDialog({
<span className="text-xs text-muted-foreground">
{t("patients.importApp.walletLabel")}
</span>
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
<div className="flex items-center gap-2">
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
<Button
aria-label={t("patients.importApp.scan")}
disabled={phase === "requesting"}
onClick={() => setScanOpen(true)}
size="icon"
type="button"
variant="outline"
>
<ScanLine className="size-4" />
</Button>
</div>
</label>
) : (
<p className="text-sm text-muted-foreground">
@@ -411,6 +433,14 @@ export function ImportFromWalletDialog({
patient={request.draft}
/>
) : null}
<BarcodeScanner
description={t("patients.importApp.scanHint")}
onDetected={handleScan}
onOpenChange={setScanOpen}
open={scanOpen}
title={t("patients.importApp.scan")}
/>
</>
);
}
@@ -1,6 +1,12 @@
"use client";
import { type FormEvent, type ReactNode, useState } from "react";
import {
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -15,6 +21,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { parseGs1 } from "@/lib/gs1";
import type { InventoryInput } from "@/lib/inventory";
import { notify } from "@/lib/toast";
@@ -48,7 +55,11 @@ export function AddInventoryDialog({
const [stockQuantity, setStockQuantity] = useState("0");
const [reorderThreshold, setReorderThreshold] = useState("0");
const [location, setLocation] = useState("");
const [barcode, setBarcode] = useState("");
const [expiresAt, setExpiresAt] = useState("");
// Lot/batch parsed from a GS1 scan. No dedicated field, so it rides along in
// notes (surfaced on the item detail).
const [notes, setNotes] = useState("");
const reset = () => {
setName("");
@@ -58,9 +69,76 @@ export function AddInventoryDialog({
setStockQuantity("0");
setReorderThreshold("0");
setLocation("");
setBarcode("");
setExpiresAt("");
setNotes("");
};
// A scanned medication barcode: store the code, and when it's a GS1
// DataMatrix, auto-fill expiry (AI 17) and lot (AI 10, -> notes).
const handleScan = useCallback(
(raw: string) => {
const gs1 = parseGs1(raw);
if (gs1) {
setBarcode(gs1.gtin ?? raw);
if (gs1.expiry) setExpiresAt(gs1.expiry);
if (gs1.lot) setNotes((n) => n || `Lot: ${gs1.lot}`);
} else {
setBarcode(raw.trim());
}
notify.success(
t("inventory.dialog.scannedTitle"),
gs1?.gtin ?? raw.trim(),
);
},
[t],
);
// Hardware barcode scanners are keyboard wedges: they "type" the code as a
// fast burst of keystrokes terminated by Enter. While the dialog is open we
// watch keydowns globally and, when a fast burst ends in Enter, treat the
// buffer as a scan — so the pharmacist can just scan a medication without
// first focusing any field. The timing gate (keys < 50ms apart) keeps
// ordinary typing out of the buffer; once a burst is detected we swallow the
// characters so they don't leak into the focused input.
useEffect(() => {
if (!open) return;
let buffer = "";
let lastTime = 0;
const MAX_GAP_MS = 50;
const MIN_LEN = 6;
const onKeyDown = (event: KeyboardEvent) => {
const now = Date.now();
const fast = now - lastTime <= MAX_GAP_MS;
lastTime = now;
if (event.key === "Enter") {
if (fast && buffer.length >= MIN_LEN) {
event.preventDefault();
event.stopPropagation();
handleScan(buffer);
}
buffer = "";
return;
}
if (
event.key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
buffer = fast ? buffer + event.key : event.key;
if (fast && buffer.length >= 2) event.preventDefault();
} else {
buffer = "";
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [open, handleScan]);
const submit = (event: FormEvent) => {
event.preventDefault();
const trimmed = name.trim();
@@ -79,7 +157,9 @@ export function AddInventoryDialog({
stockQuantity: Number.parseInt(stockQuantity, 10) || 0,
reorderThreshold: Number.parseInt(reorderThreshold, 10) || 0,
location: location.trim(),
barcode: barcode.trim() || null,
expiresAt: expiresAt || null,
notes: notes.trim() || null,
});
notify.success(t("inventory.dialog.addedTitle"), trimmed);
reset();
@@ -174,6 +254,27 @@ export function AddInventoryDialog({
/>
</Field>
</div>
<Field label={t("inventory.dialog.barcode")}>
<Input
inputMode="numeric"
onChange={(event) => setBarcode(event.target.value)}
// A hardware scanner sends Enter after the code; parse it here
// (instead of submitting the form) so a scan into this field is
// handled like the global wedge burst.
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (barcode.trim()) handleScan(barcode);
}
}}
placeholder={t("inventory.dialog.barcodePlaceholder")}
value={barcode}
/>
<span className="text-muted-foreground text-xs">
{t("inventory.dialog.scanHint")}
</span>
</Field>
</DialogPanel>
<DialogFooter>
@@ -109,6 +109,10 @@ export function InventoryDetailDialog({
label={t("inventory.dialog.expires")}
value={item.expiresAt || "—"}
/>
<Row
label={t("inventory.dialog.barcode")}
value={item.barcode || "—"}
/>
{item.notes ? (
<div className="flex flex-col gap-1 py-2">
<span className="text-muted-foreground text-sm">
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
// Camera barcode/QR scanner in a dialog. Decoding uses @zxing/browser
// (lazy-loaded so it stays out of the initial bundle), which reads 1D barcodes
// (EAN/UPC/Code128) and 2D codes (QR, GS1 DataMatrix, PDF417) — the mix found
// on medication packaging and patient wallet codes. Emits the decoded string
// once, then the caller closes the dialog.
export function BarcodeScanner({
open,
onOpenChange,
onDetected,
title,
description,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onDetected: (value: string) => void;
title?: string;
description?: string;
}) {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
// Keep the latest onDetected without restarting the camera on every render.
const onDetectedRef = useRef(onDetected);
onDetectedRef.current = onDetected;
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let stopped = false;
let controls: { stop: () => void } | null = null;
setError(null);
(async () => {
const video = videoRef.current;
if (!video) return;
try {
const { BrowserMultiFormatReader } = await import("@zxing/browser");
const reader = new BrowserMultiFormatReader();
controls = await reader.decodeFromConstraints(
{ video: { facingMode: "environment" } },
video,
(result) => {
if (result && !stopped) {
stopped = true;
controls?.stop();
onDetectedRef.current(result.getText());
}
},
);
// The dialog may have closed while getUserMedia was resolving.
if (stopped) controls.stop();
} catch (err) {
setError(
err instanceof DOMException && err.name === "NotAllowedError"
? t("scan.permissionDenied")
: t("scan.unavailable"),
);
}
})();
return () => {
stopped = true;
controls?.stop();
};
}, [open, t]);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title ?? t("scan.title")}</DialogTitle>
<DialogDescription>
{description ?? t("scan.description")}
</DialogDescription>
</DialogHeader>
<DialogPanel>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<div className="relative aspect-video overflow-hidden rounded-2xl border bg-black">
{/* biome-ignore lint/a11y/useMediaCaption: live camera preview */}
<video
className="size-full object-cover"
muted
playsInline
ref={videoRef}
/>
<div className="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70" />
</div>
)}
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("scan.cancel")}
</DialogClose>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -136,6 +136,7 @@ export function CareTeamPanel({
return (
<SettingsSection
description={t("settings.careTeam.description")}
separated
title={t("settings.careTeam.title")}
>
{error && (
@@ -157,7 +158,7 @@ export function CareTeamPanel({
</SettingsCard>
)}
<SettingsCard className="divide-y divide-border">
<SettingsCard className="divide-y divide-border overflow-hidden p-0">
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">
{t("settings.careTeam.loading")}
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { createContext, useContext, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -13,9 +13,15 @@ import {
CardFrameHeader,
CardFrameTitle,
} from "@/components/ui/card";
import { Frame, FramePanel } from "@/components/ui/frame";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
// Whether the enclosing frame is a COSS "Separated Panels" tray. When true,
// SettingsCard/ToggleRow render as a FramePanel (a distinct bordered card on the
// muted tray) instead of a fused CardFrame Card.
const SeparatedFrameContext = createContext(false);
// A settings section rendered inside the COSS "frame" surface: a titled header
// above one or more cards.
//
@@ -43,15 +49,36 @@ export function SettingsFrame({
children: ReactNode;
className?: string;
/**
* COSS "Separated Panels": add a 1rem gap so sibling panels read as distinct
* cards instead of one flush joined list. The `gap-4` matches CardFrame's
* built-in `--clip-top/--clip-bottom: -1rem`, which keeps each panel's rounded
* corners clipping correctly across the gap. Leave off for a joined list.
* COSS "Separated Panels": render a muted Frame tray whose children are
* distinct bordered FramePanels spaced apart (the real coss.com/ui/frame
* look), instead of the fused CardFrame surface. Leave off for a joined list.
*/
separated?: boolean;
}) {
if (separated) {
return (
<Frame className={className}>
<div
className="flex items-start justify-between gap-4 px-4 py-3"
data-slot="frame-header"
>
<div className="flex flex-col gap-0.5">
<p className="text-base font-semibold">{title}</p>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
<SeparatedFrameContext.Provider value={true}>
{children}
</SeparatedFrameContext.Provider>
</Frame>
);
}
return (
<CardFrame className={cn(separated && "gap-4", className)}>
<CardFrame className={className}>
<CardFrameHeader className="border-b border-border/60">
<CardFrameTitle className="text-base">{title}</CardFrameTitle>
{description ? (
@@ -105,6 +132,16 @@ export function SettingsCard({
className?: string;
children: ReactNode;
}) {
const separated = useContext(SeparatedFrameContext);
if (separated) {
// A distinct panel on the Frame tray. `flex flex-col` mirrors Card's default
// layout; callers that need a row (ToggleRow) override with `flex-row`.
return (
<FramePanel className={cn("flex flex-col", className)}>
{children}
</FramePanel>
);
}
return <Card className={className}>{children}</Card>;
}
+94
View File
@@ -0,0 +1,94 @@
import type React from "react";
import { cn } from "@/lib/utils";
// COSS "Frame" primitive (coss.com/ui/docs/components/frame). A Frame is a muted
// tray that holds one or more FramePanels; multiple panels are "Separated
// Panels" — spaced apart with `mt-1` by default (via the adjacent-sibling
// selector below), each a self-contained bordered card. This is the opposite of
// `CardFrame` in card.tsx, which fuses its cards into one flush surface.
export function Frame({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"relative flex flex-col rounded-2xl bg-muted/72 p-1",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
className,
)}
data-slot="frame"
{...props}
/>
);
}
export function FramePanel({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"relative rounded-xl border bg-background bg-clip-padding p-5 shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
className,
)}
data-slot="frame-panel"
{...props}
/>
);
}
export function FrameHeader({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("flex flex-col px-5 py-4", className)}
data-slot="frame-header"
{...props}
/>
);
}
export function FrameTitle({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("font-semibold text-sm", className)}
data-slot="frame-title"
{...props}
/>
);
}
export function FrameDescription({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-description"
{...props}
/>
);
}
export function FrameFooter({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("px-5 py-4", className)}
data-slot="frame-footer"
{...props}
/>
);
}
+85
View File
@@ -0,0 +1,85 @@
// Minimal GS1 Application Identifier parser for medication barcodes (the GS1
// DataMatrix printed on drug packaging). We only care about the AIs a pharmacy
// add-item flow can use: 01 GTIN, 17 expiry, 10 lot/batch, 21 serial. Variable
// -length fields are terminated by the FNC1/GS separator (ASCII 29); the AIs
// below have fixed lengths per the GS1 General Specifications.
export type Gs1Fields = {
gtin?: string;
/** AI 17 normalised to YYYY-MM-DD. */
expiry?: string;
lot?: string;
serial?: string;
};
const GS = "\x1d";
// value length (excluding the 2-digit AI) for the fixed-length AIs we handle.
const FIXED_LEN: Record<string, number> = {
"00": 18,
"01": 14,
"02": 14,
"11": 6,
"12": 6,
"13": 6,
"15": 6,
"16": 6,
"17": 6,
"20": 2,
};
function yymmddToIso(v: string): string | undefined {
if (!/^\d{6}$/.test(v)) return undefined;
const year = 2000 + Number(v.slice(0, 2));
const month = Number(v.slice(2, 4));
let day = Number(v.slice(4, 6));
if (month < 1 || month > 12) return undefined;
// GS1 allows DD=00 to mean "end of the month".
if (day === 0) day = new Date(year, month, 0).getDate();
const pad = (n: number) => String(n).padStart(2, "0");
return `${year}-${pad(month)}-${pad(day)}`;
}
// Parse a scanned string as GS1. Returns null when it isn't GS1-structured (a
// plain EAN-13 / Code128 barcode), so callers can fall back to the raw value.
export function parseGs1(raw: string): Gs1Fields | null {
if (!raw) return null;
let s = raw;
// Strip a leading symbology identifier (e.g. "]d2", "]C1", "]e0").
if (s.startsWith("]")) s = s.slice(3);
if (s.startsWith(GS)) s = s.slice(1);
// GS1 medication codes lead with (01) GTIN; bail early otherwise so a plain
// numeric barcode isn't mis-parsed.
if (!s.startsWith("01")) return null;
const out: Gs1Fields = {};
let i = 0;
let matched = false;
while (i + 2 <= s.length) {
const ai = s.slice(i, i + 2);
i += 2;
const fixed = FIXED_LEN[ai];
let value: string;
if (fixed != null) {
value = s.slice(i, i + fixed);
i += fixed;
} else {
const gsIdx = s.indexOf(GS, i);
if (gsIdx === -1) {
value = s.slice(i);
i = s.length;
} else {
value = s.slice(i, gsIdx);
i = gsIdx + 1;
}
}
if (s[i] === GS) i += 1; // consume a separator trailing a fixed field
if (!value) break;
matched = true;
if (ai === "01") out.gtin = value;
else if (ai === "17") out.expiry = yymmddToIso(value);
else if (ai === "10") out.lot = value;
else if (ai === "21") out.serial = value;
}
return matched ? out : null;
}
@@ -1,4 +1,11 @@
{
"scan": {
"title": "مسح رمز",
"description": "وجّه الكاميرا نحو باركود أو رمز QR.",
"cancel": "إلغاء",
"permissionDenied": "تم رفض الوصول إلى الكاميرا. فعّله للمسح.",
"unavailable": "المسح غير متاح على هذا الجهاز."
},
"scribe": {
"recordVisit": "تسجيل الزيارة",
"title": "كاتب الزيارة",
@@ -295,6 +302,8 @@
"qrCaption": "امسح هذا في تطبيق المريض",
"generateQr": "عرض رمز QR",
"walletLabel": "رقم محفظة المريض",
"scan": "امسح رمز المريض",
"scanHint": "امسح رمز QR أو الباركود الظاهر في تطبيق محفظة المريض.",
"walletPlaceholder": "tmw_…",
"tempLabel": "مشاركة مؤقتة",
"tempHint": "يُحذف السجل تلقائيًا من هذه العيادة عند انتهاء فترة المشاركة.",
@@ -702,6 +711,10 @@
"location": "الموقع",
"locationPlaceholder": "مثال: A3",
"expires": "تنتهي الصلاحية",
"barcode": "الباركود / NDC",
"barcodePlaceholder": "امسح أو اكتب رمزًا",
"scanHint": "وصّل ماسح باركود USB وامسح الدواء — أو اكتب الرمز.",
"scannedTitle": "تم مسح الباركود",
"cancel": "إلغاء",
"submit": "إضافة عنصر",
"nameRequiredTitle": "اسم الدواء مطلوب",
@@ -1566,6 +1579,11 @@
"moreActions": "إجراءات إضافية"
},
"patientForm": {
"close": "إغلاق",
"tabs": {
"add": "إضافة",
"show": "عرض"
},
"editTitle": "تعديل السجل",
"createTitle": "إضافة مريض",
"editDescription": "حدّث سجل {{name}} وأضف بيانات جديدة.",
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Code scannen",
"description": "Richte die Kamera auf einen Barcode oder QR-Code.",
"cancel": "Abbrechen",
"permissionDenied": "Kamerazugriff verweigert. Aktiviere ihn zum Scannen.",
"unavailable": "Scannen ist auf diesem Gerät nicht verfügbar."
},
"scribe": {
"recordVisit": "Besuch aufnehmen",
"title": "Besuchs-Schreiber",
@@ -291,6 +298,8 @@
"qrCaption": "Dies in der Patienten-App scannen",
"generateQr": "QR-Code anzeigen",
"walletLabel": "Patienten-Wallet-Nummer",
"scan": "Code des Patienten scannen",
"scanHint": "Scanne den QR- oder Barcode aus der Wallet-App des Patienten.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Temporär teilen",
"tempHint": "Die Akte wird automatisch aus dieser Klinik gelöscht, wenn das Freigabefenster endet.",
@@ -686,6 +695,10 @@
"location": "Standort",
"locationPlaceholder": "z. B. A3",
"expires": "Verfällt",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Code scannen oder eingeben",
"scanHint": "Schließen Sie einen USB-Barcode-Scanner an und scannen Sie das Medikament oder geben Sie den Code ein.",
"scannedTitle": "Barcode gescannt",
"cancel": "Abbrechen",
"submit": "Artikel hinzufügen",
"nameRequiredTitle": "Medikamentenname erforderlich",
@@ -1546,6 +1559,11 @@
"moreActions": "Weitere Aktionen"
},
"patientForm": {
"close": "Schließen",
"tabs": {
"add": "Hinzufügen",
"show": "Anzeigen"
},
"editTitle": "Datensatz bearbeiten",
"createTitle": "Patient hinzufügen",
"editDescription": "Aktualisieren Sie die Akte von {{name}} und fügen Sie neue Daten hinzu.",
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Scan a code",
"description": "Point the camera at a barcode or QR code.",
"cancel": "Cancel",
"permissionDenied": "Camera access was denied. Enable it to scan.",
"unavailable": "Scanning isn't available on this device."
},
"scribe": {
"recordVisit": "Record visit",
"title": "Visit scribe",
@@ -291,6 +298,8 @@
"qrCaption": "Scan this in the patient app",
"generateQr": "Show QR code",
"walletLabel": "Patient wallet number",
"scan": "Scan patient's code",
"scanHint": "Scan the QR or barcode shown in the patient's wallet app.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Share temporarily",
"tempHint": "The record is automatically deleted from this clinic when the share window ends.",
@@ -686,6 +695,10 @@
"location": "Location",
"locationPlaceholder": "e.g. A3",
"expires": "Expires",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Scan or type a code",
"scanHint": "Connect a USB barcode scanner and scan the medication — or type the code.",
"scannedTitle": "Barcode scanned",
"cancel": "Cancel",
"submit": "Add item",
"nameRequiredTitle": "Medication name required",
@@ -1546,6 +1559,11 @@
"moreActions": "More actions"
},
"patientForm": {
"close": "Close",
"tabs": {
"add": "Add",
"show": "Show"
},
"editTitle": "Edit record",
"createTitle": "Add patient",
"editDescription": "Update {{name}}'s chart and add new data.",
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Scanner un code",
"description": "Dirigez la caméra vers un code-barres ou un QR code.",
"cancel": "Annuler",
"permissionDenied": "Accès à la caméra refusé. Activez-le pour scanner.",
"unavailable": "Le scan n'est pas disponible sur cet appareil."
},
"scribe": {
"recordVisit": "Enregistrer la visite",
"title": "Scribe de visite",
@@ -291,6 +298,8 @@
"qrCaption": "Scannez ceci dans l'application patient",
"generateQr": "Afficher le code QR",
"walletLabel": "Numéro de portefeuille du patient",
"scan": "Scanner le code du patient",
"scanHint": "Scannez le QR code ou le code-barres affiché dans l'app portefeuille du patient.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Partager temporairement",
"tempHint": "Le dossier est automatiquement supprimé de cette clinique à la fin de la période de partage.",
@@ -686,6 +695,10 @@
"location": "Emplacement",
"locationPlaceholder": "ex. A3",
"expires": "Expire le",
"barcode": "Code-barres / NDC",
"barcodePlaceholder": "Scanner ou saisir un code",
"scanHint": "Branchez un lecteur de code-barres USB et scannez le médicament — ou saisissez le code.",
"scannedTitle": "Code-barres scanné",
"cancel": "Annuler",
"submit": "Ajouter l'article",
"nameRequiredTitle": "Nom du médicament requis",
@@ -1546,6 +1559,11 @@
"moreActions": "Plus dactions"
},
"patientForm": {
"close": "Fermer",
"tabs": {
"add": "Ajouter",
"show": "Afficher"
},
"editTitle": "Modifier le dossier",
"createTitle": "Ajouter un patient",
"editDescription": "Mettez à jour le dossier de {{name}} et ajoutez de nouvelles données.",
@@ -1,4 +1,11 @@
{
"scan": {
"title": "Iskaan garee koodh",
"description": "U jeedi kamarada barcode ama koodh QR.",
"cancel": "Jooji",
"permissionDenied": "Gelitaanka kamarada waa la diiday. Daar si aad u iskaan gariso.",
"unavailable": "Iskaanku kuma shaqeeyo qalabkan."
},
"scribe": {
"recordVisit": "Duub booqasho",
"title": "Qoraaga booqashada",
@@ -291,6 +298,8 @@
"qrCaption": "Ku sawir tan app-ka bukaanka",
"generateQr": "Tus koodhka QR",
"walletLabel": "Lambarka wallet-ka bukaanka",
"scan": "Iskaan garee koodhka bukaanka",
"scanHint": "Iskaan garee koodhka QR ama barcode ee ka muuqda abka wallet-ka bukaanka.",
"walletPlaceholder": "tmw_…",
"tempLabel": "Wadaag si ku meel gaar ah",
"tempHint": "Diiwaanka si toos ah ayaa looga tirtirayaa rugtan marka daaqadda wadaagista dhammaato.",
@@ -686,6 +695,10 @@
"location": "Goobta",
"locationPlaceholder": "tusaale A3",
"expires": "Dhacaya",
"barcode": "Barcode / NDC",
"barcodePlaceholder": "Iskaan garee ama qor koodh",
"scanHint": "Ku xir iskaanka barcode-ka USB oo iskaan garee daawada — ama qor koodhka.",
"scannedTitle": "Barcode la iskaan gareeyay",
"cancel": "Jooji",
"submit": "Ku dar shay",
"nameRequiredTitle": "Magaca daawada ayaa loo baahan yahay",
@@ -1546,6 +1559,11 @@
"moreActions": "Ficillo dheeraad ah"
},
"patientForm": {
"close": "Xir",
"tabs": {
"add": "Ku dar",
"show": "Muuji"
},
"editTitle": "Wax ka beddel diiwaanka",
"createTitle": "Ku dar bukaan",
"editDescription": "Cusbooneysii diiwaanka {{name}} oo ku dar xog cusub.",
+2
View File
@@ -11,6 +11,7 @@ export type InventoryItem = {
stockQuantity: number;
reorderThreshold: number;
location: string;
barcode: string | null;
expiresAt: string | null;
notes: string | null;
createdAt: string;
@@ -26,6 +27,7 @@ export type InventoryInput = {
stockQuantity?: number;
reorderThreshold?: number;
location?: string;
barcode?: string | null;
expiresAt?: string | null;
notes?: string | null;
};
+48 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.12.0",
"version": "0.15.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.12.0",
"version": "0.15.1",
"dependencies": {
"@ai-sdk/react": "^3.0.206",
"@base-ui/react": "^1.5.0",
@@ -32,6 +32,7 @@
"@visx/scale": "^4.0.1-alpha.0",
"@visx/shape": "^4.0.1-alpha.0",
"@xyflow/react": "^12.10.2",
"@zxing/browser": "^0.2.1",
"ai": "^6.0.193",
"ansi-to-react": "^6.2.6",
"better-auth": "^1.6.13",
@@ -6255,6 +6256,41 @@
"d3-zoom": "^3.0.0"
}
},
"node_modules/@zxing/browser": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz",
"integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.23.0"
}
},
"node_modules/@zxing/library": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -16002,6 +16038,16 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ts-dedent": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.15.1",
"version": "0.17.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -34,6 +34,7 @@
"@visx/scale": "^4.0.1-alpha.0",
"@visx/shape": "^4.0.1-alpha.0",
"@xyflow/react": "^12.10.2",
"@zxing/browser": "^0.2.1",
"ai": "^6.0.193",
"ansi-to-react": "^6.2.6",
"better-auth": "^1.6.13",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.15.1",
"version": "0.17.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"