mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07cfd5d335 | |||
| c5812511b5 | |||
| 196a22bffd | |||
| 531cbbad7f | |||
| 65975177cf |
+104
-28
@@ -3,6 +3,11 @@
|
||||
# Trigger: push a semver tag, e.g.
|
||||
# git tag v0.1.0 && git push origin v0.1.0
|
||||
#
|
||||
# Multi-arch (amd64 + arm64) is built on NATIVE runners — amd64 on ubuntu-24.04,
|
||||
# arm64 on ubuntu-24.04-arm — and merged into a manifest. We do NOT emulate arm64
|
||||
# with QEMU anymore: a runner/QEMU update started crashing `npm ci` under
|
||||
# emulation ("illegal instruction") and hanging the build for hours.
|
||||
#
|
||||
# The frontend image bakes NO API URL — it resolves the backend from the host
|
||||
# the browser uses at runtime — so one published image works for every clinic.
|
||||
#
|
||||
@@ -18,27 +23,30 @@ on:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write # create the GitHub Release (the update check reads this)
|
||||
|
||||
env:
|
||||
REGISTRY_NAMESPACE: khalidxv
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
# One build per (image × platform) on the platform's native runner, pushed to
|
||||
# Docker Hub by digest (no tag yet). The merge job stitches the per-arch
|
||||
# digests into a single tagged multi-arch manifest.
|
||||
build:
|
||||
name: Build ${{ matrix.image }} (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image: [backend, frontend]
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive version from tag
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# QEMU lets the amd64 runner emulate arm64 so the images below build for
|
||||
# both platforms (Intel + Apple Silicon self-hosters).
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -49,25 +57,93 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build & push backend
|
||||
- name: Build & push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:latest
|
||||
context: ./${{ matrix.image }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: false
|
||||
outputs: type=image,name=${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Build & push frontend
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
|
||||
name: digests-${{ matrix.image }}-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Combine the per-arch digests for each image into one multi-arch manifest and
|
||||
# tag it (X.Y.Z + latest).
|
||||
merge:
|
||||
name: Merge ${{ matrix.image }} manifest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image: [backend, frontend]
|
||||
steps:
|
||||
- name: Derive version from tag
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-${{ matrix.image }}-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t "$IMAGE:$VERSION" \
|
||||
-t "$IMAGE:latest" \
|
||||
$(printf "$IMAGE@sha256:%s " *)
|
||||
|
||||
- name: Inspect
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY_NAMESPACE }}/temetro-${{ matrix.image }}
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
|
||||
# Both images are published — now cut the GitHub Release with the changelog.
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: merge
|
||||
permissions:
|
||||
contents: write # create the GitHub Release (the update check reads this)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive version from tag
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Pull this version's section out of CHANGELOG.md so the release has real,
|
||||
# human-written notes (the auto "Full Changelog" link is still appended
|
||||
|
||||
@@ -7,6 +7,24 @@ 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
|
||||
|
||||
@@ -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)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ScanLine } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useState } from "react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BarcodeScanner } from "@/components/scan/barcode-scanner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -56,7 +60,6 @@ export function AddInventoryDialog({
|
||||
// Lot/batch parsed from a GS1 scan. No dedicated field, so it rides along in
|
||||
// notes (surfaced on the item detail).
|
||||
const [notes, setNotes] = useState("");
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setName("");
|
||||
@@ -73,18 +76,68 @@ export function AddInventoryDialog({
|
||||
|
||||
// A scanned medication barcode: store the code, and when it's a GS1
|
||||
// DataMatrix, auto-fill expiry (AI 17) and lot (AI 10, -> notes).
|
||||
const handleScan = (raw: string) => {
|
||||
setScannerOpen(false);
|
||||
const gs1 = parseGs1(raw);
|
||||
if (gs1) {
|
||||
setBarcode(gs1.gtin ?? raw);
|
||||
if (gs1.expiry) setExpiresAt(gs1.expiry);
|
||||
if (gs1.lot) setNotes((n) => n || `Lot: ${gs1.lot}`);
|
||||
} else {
|
||||
setBarcode(raw.trim());
|
||||
}
|
||||
notify.success(t("inventory.dialog.scannedTitle"), gs1?.gtin ?? raw.trim());
|
||||
};
|
||||
const 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();
|
||||
@@ -114,7 +167,6 @@ export function AddInventoryDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
onOpenChange={(o) => {
|
||||
onOpenChange(o);
|
||||
@@ -204,23 +256,24 @@ export function AddInventoryDialog({
|
||||
</div>
|
||||
|
||||
<Field label={t("inventory.dialog.barcode")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
onChange={(event) => setBarcode(event.target.value)}
|
||||
placeholder={t("inventory.dialog.barcodePlaceholder")}
|
||||
value={barcode}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("inventory.dialog.scan")}
|
||||
onClick={() => setScannerOpen(true)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ScanLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -235,11 +288,5 @@ export function AddInventoryDialog({
|
||||
</form>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
<BarcodeScanner
|
||||
onDetected={handleScan}
|
||||
onOpenChange={setScannerOpen}
|
||||
open={scannerOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -713,7 +713,7 @@
|
||||
"expires": "تنتهي الصلاحية",
|
||||
"barcode": "الباركود / NDC",
|
||||
"barcodePlaceholder": "امسح أو اكتب رمزًا",
|
||||
"scan": "مسح الباركود",
|
||||
"scanHint": "وصّل ماسح باركود USB وامسح الدواء — أو اكتب الرمز.",
|
||||
"scannedTitle": "تم مسح الباركود",
|
||||
"cancel": "إلغاء",
|
||||
"submit": "إضافة عنصر",
|
||||
|
||||
@@ -697,7 +697,7 @@
|
||||
"expires": "Verfällt",
|
||||
"barcode": "Barcode / NDC",
|
||||
"barcodePlaceholder": "Code scannen oder eingeben",
|
||||
"scan": "Barcode scannen",
|
||||
"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",
|
||||
|
||||
@@ -697,7 +697,7 @@
|
||||
"expires": "Expires",
|
||||
"barcode": "Barcode / NDC",
|
||||
"barcodePlaceholder": "Scan or type a code",
|
||||
"scan": "Scan barcode",
|
||||
"scanHint": "Connect a USB barcode scanner and scan the medication — or type the code.",
|
||||
"scannedTitle": "Barcode scanned",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Add item",
|
||||
|
||||
@@ -697,7 +697,7 @@
|
||||
"expires": "Expire le",
|
||||
"barcode": "Code-barres / NDC",
|
||||
"barcodePlaceholder": "Scanner ou saisir un code",
|
||||
"scan": "Scanner le code-barres",
|
||||
"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",
|
||||
|
||||
@@ -697,7 +697,7 @@
|
||||
"expires": "Dhacaya",
|
||||
"barcode": "Barcode / NDC",
|
||||
"barcodePlaceholder": "Iskaan garee ama qor koodh",
|
||||
"scan": "Iskaan garee barcode",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user