Compare commits

..

3 Commits

Author SHA1 Message Date
Khalid Abdi ab17978b5c feat: portal doctor booking, Arabic RTL fix, wallet portal QR (v0.10.0)
backend: public GET /api/portal/:clinic/doctors and /availability, and
thread a chosen provider into portal bookings (conflict check unchanged).

frontend: fix site-wide Arabic RTL — anchor the sidebar right for RTL and
mirror the Switch thumb. Add a Patient Portal section (open/copy/QR) to
Settings → Signing and a "Use my current location" GPS button to the clinic
location editor. New i18n keys across all five locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:31:20 +03:00
Khalid Abdi 36461a5498 feat: patient blood type & phone + clinic location setting (v0.9.0)
Patient record:
- Add `bloodType` and `phone` to the patient model (schema, canonical types on
  both backend + frontend, zod validation). `phone` is a demographic field
  (reception can read/write); `bloodType` is clinical PHI, redacted for the
  reception role. Surface both in the record sheet, chat summary card, and the
  add/edit patient form. Migration 0033.

Clinic location:
- New org-scoped `clinic_settings` table (address/city/country + optional
  lat/long), service, and routes: GET /api/clinic/settings (any clinician) and
  PUT /api/clinic/location (owner/admin). Edited in Settings → Signing → Clinic
  location. Consumed later by the wallet app. Migration 0034.

i18n:
- Translate all new keys into every shipped locale (en/de/fr/ar/so) and document
  the "translate into every locale" rule in frontend/CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:07:27 +03:00
Khalid Abdi 01dbc07e92 fix: default RELAY_URL to the hosted relay (v0.8.2)
The default http://localhost:8080 silently failed for clinics that joined
the network without setting RELAY_URL — inside Docker localhost is the
container itself, so the hub connection never reached the relay (endless
"relay unreachable" retries) and pairing QRs encoded an unreachable
localhost. Default to https://network.temetro.com so "Join Temetro Network"
works out of the box; self-hosters running their own relay still override
it. Also (re)ensure the hub is connected before pre-registering a pairing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:50:46 +03:00
39 changed files with 10238 additions and 16 deletions
+61 -1
View File
@@ -7,7 +7,67 @@ for how releases are cut and published.
## [Unreleased]
## [0.8.1] — 2026-07-05
## [0.10.0] — 2026-07-07
### Added
- **Patient Portal doctor picker & availability.** The portal now lists the clinic's doctors and
books against a chosen provider, showing only free slots. New public endpoints
`GET /api/portal/:clinic/doctors` and `GET /api/portal/:clinic/availability?provider=&date=`
(display-safe fields only), and `POST /api/portal/:clinic/appointments` accepts an optional
`provider` (`backend/src/routes/portal.ts`). The existing 409 conflict check stays authoritative.
- **Patient Portal links in Settings.** Settings → Signing → Patient Portal adds **open**, **copy
link**, and **QR code** actions (`components/settings/settings-portal.tsx`); the QR carries the
backend base (`?api=`) so the patient wallet app can book natively when it scans it.
- **Clinic location "Use my current location".** The location editor fills map coordinates from the
browser's geolocation (`components/settings/settings-location.tsx`).
- **Wallet app native Patient Portal.** Scanning a clinic's portal QR opens a native booking screen
(doctor list → free-slot picker → confirm) in the patient wallet app.
### Fixed
- **Arabic (RTL) layout.** The sidebar now anchors to the **right** for RTL locales and the toggle
switch mirrors correctly, instead of leaving the shell misaligned
(`components/sidebar-02/app-sidebar.tsx`, `components/ui/switch.tsx`).
- **Wallet app:** record-card **bottom sheet no longer freezes** the app (dropped the per-frame
animated blur overlay for HeroUI's built-in overlay); **Reset wallet** now confirms in a native
HeroUI dialog with Liquid Glass actions; fixed the **white edge flash** on screen transitions in
dark mode; the home/onboarding/register **logo** is now the Temetro mark.
### Changed
- New i18n keys (`settings.portal.*`, geolocation strings) are translated into **all** shipped
locales (en, de, fr, ar, so).
## [0.9.0] — 2026-07-06
### Added
- **Patient blood type & phone number.** The patient record now carries a `bloodType` (e.g. `O+`)
and a `phone` number. Both are shown in the record sheet and chat summary card and are editable in
the add/edit patient form. `phone` is a demographic/contact field (visible to and editable by the
**reception** role); `bloodType` is treated as clinical PHI and is **redacted for reception** (like
allergies/vitals). New columns `patients.phone` / `patients.blood_type` (migration `0033`).
- **Clinic location setting.** A new org-scoped `clinic_settings` table (migration `0034`) stores the
clinic's address (address / city / country) plus optional map coordinates (latitude / longitude),
set in **Settings → Signing → Clinic location** (owner/admin only). New endpoints
`GET /api/clinic/settings` (any clinician) and `PUT /api/clinic/location` (owner/admin). This will
be surfaced in the patient wallet app to show a clinic's location.
### Changed
- New i18n keys for the above are translated into **all** shipped locales (en, de, fr, ar, so), per
the coverage rule now documented in `frontend/CLAUDE.md`.
## [0.8.2] — 2026-07-05
### Fixed
- **`RELAY_URL` now defaults to the hosted relay** (`https://network.temetro.com`) instead of
`http://localhost:8080`. The old default silently failed for anyone who joined the network without
explicitly setting `RELAY_URL` — the backend's hub connection could never reach the relay (inside
Docker `localhost` is the container itself), so it never authenticated and QR pairing generated a
QR pointing at an unreachable `localhost`. Self-hosters running their own relay still override
`RELAY_URL`. Updated `.env.example` accordingly.
### Changed
- Generating a pairing QR (`POST /api/patients/wallet/pair`) now ensures the clinic's relay hub is
connected before pre-registering the request, so the routing is set up even if the connection was
opened lazily.
### Fixed
- **QR "scan to connect" pairing** was broken by the multi-clinic relay routing (v0.8.0): pairing
+5 -1
View File
@@ -40,7 +40,11 @@ FRONTEND_PORT=3000
# /hub with its own Ed25519 signing key, so no shared secret is needed.
# RELAY_TOKEN is OPTIONAL/LEGACY — set it only for a private relay that also
# gates on a shared token (then use the SAME value here and on the relay).
RELAY_URL=http://localhost:8080
# Defaults to the hosted relay (https://network.temetro.com) when unset, so
# "Join Temetro Network" works out of the box; set RELAY_URL only to point at
# your own relay. Do NOT use http://localhost — inside Docker that's the
# container itself and the relay connection will silently fail.
RELAY_URL=https://network.temetro.com
RELAY_TOKEN=
# (Legacy, pre-relay self-hosting.) A phone-reachable URL for the QR when NOT
@@ -0,0 +1,2 @@
ALTER TABLE "patients" ADD COLUMN "phone" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "patients" ADD COLUMN "blood_type" text DEFAULT '' NOT NULL;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "clinic_settings" (
"organization_id" text PRIMARY KEY NOT NULL,
"address" text DEFAULT '' NOT NULL,
"city" text DEFAULT '' NOT NULL,
"country" text DEFAULT '' NOT NULL,
"latitude" double precision,
"longitude" double precision,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "clinic_settings" ADD CONSTRAINT "clinic_settings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -232,6 +232,20 @@
"when": 1783263738631,
"tag": "0032_closed_dakota_north",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1783362745730,
"tag": "0033_ambitious_reavers",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1783363217049,
"tag": "0034_chunky_blacklash",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.8.1",
"version": "0.10.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+25
View File
@@ -0,0 +1,25 @@
import { doublePrecision, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// Per-clinic (organization) settings. Currently holds the clinic's physical
// location — a free-text address plus optional map coordinates — set in
// Settings → Location by an owner/admin and surfaced to patients in the wallet
// app later (e.g. a map pin for a clinic that shared a record). One row per org
// (PK = organizationId), mirroring `clinic_signing_keys`.
export const clinicSettings = pgTable("clinic_settings", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
address: text("address").notNull().default(""),
city: text("city").notNull().default(""),
country: text("country").notNull().default(""),
// Optional map coordinates (WGS84). Null until the clinic sets them.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+1
View File
@@ -20,6 +20,7 @@ export * from "./integrations.js";
export * from "./staff-profile.js";
export * from "./meetings.js";
export * from "./signing.js";
export * from "./clinic-settings.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
export * from "./fhir-keys.js";
+5
View File
@@ -36,6 +36,11 @@ export const patients = pgTable(
pcp: text("pcp").notNull(),
status: text("status").$type<PatientStatus>().notNull(),
initials: text("initials").notNull(),
// Contact + clinical demographics. `phone` is a contact/registration field
// (reception may read/write it); `bloodType` is clinical (redacted for the
// reception role, like allergies/vitals).
phone: text("phone").notNull().default(""),
bloodType: text("blood_type").notNull().default(""),
alerts: jsonb("alerts").$type<string[]>().notNull(),
vitalsBp: text("vitals_bp").notNull(),
vitalsHr: text("vitals_hr").notNull(),
+5 -1
View File
@@ -35,7 +35,11 @@ const schema = z.object({
// its own Ed25519 signing key, so no shared secret is needed. RELAY_TOKEN is
// now *optional/legacy* — set it only for a private relay that also gates on a
// shared token (must then match the relay's RELAY_TOKEN).
RELAY_URL: z.string().min(1).default("http://localhost:8080"),
//
// Defaults to the hosted relay so "Join Temetro Network" works out of the box;
// override only when running your own relay. (A `localhost` default silently
// fails inside Docker, where localhost is the container itself.)
RELAY_URL: z.string().min(1).default("https://network.temetro.com"),
RELAY_TOKEN: z.string().default(""),
// Public, device-reachable URL of this backend's wallet relay, baked into the
// QR a patient scans. Optional — when unset we derive it from the request host
+2
View File
@@ -16,6 +16,7 @@ import { analyticsRouter } from "./routes/analytics.js";
import { attachmentsRouter } from "./routes/attachments.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { clinicRouter } from "./routes/clinic.js";
import { conversationsRouter } from "./routes/conversations.js";
import { dispensesRouter } from "./routes/dispenses.js";
import { fhirRouter } from "./routes/fhir.js";
@@ -90,6 +91,7 @@ app.use("/api/network", networkRouter);
app.use("/api/patients/wallet", patientsWalletRouter);
app.use("/api/patients", patientsRouter);
app.use("/api/signing", signingRouter);
app.use("/api/clinic", clinicRouter);
app.use("/api/attachments", attachmentsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
+4
View File
@@ -105,6 +105,10 @@ export const patientInputSchema = z
),
status: z.enum(["active", "inpatient", "discharged"]).default("active"),
initials: z.string().trim().max(4).default(""),
phone: z.string().trim().max(30).default(""),
bloodType: z
.enum(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-", ""])
.default(""),
allergies: z.array(allergySchema).default([]),
alerts: z.array(z.string()).default([]),
medications: z.array(medicationSchema).default([]),
+61
View File
@@ -0,0 +1,61 @@
import { Router } from "express";
import { z } from "zod";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as clinicSettings from "../services/clinic-settings.js";
export const clinicRouter = Router();
clinicRouter.use(requireAuth, requireOrg);
// The clinic's settings (currently just its location). Readable by any
// clinician so the app/UI can display the clinic address.
clinicRouter.get(
"/settings",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await clinicSettings.getClinicSettings(req.organizationId!));
} catch (err) {
next(err);
}
},
);
// Set the clinic's location — owner/admin only (gated on the org-update
// statement, same as signing-key rotation / network toggle).
const locationSchema = z.object({
address: z.string().trim().max(200).default(""),
city: z.string().trim().max(120).default(""),
country: z.string().trim().max(120).default(""),
latitude: z.number().min(-90).max(90).nullable().default(null),
longitude: z.number().min(-180).max(180).nullable().default(null),
});
clinicRouter.put(
"/location",
requirePermission({ organization: ["update"] }),
async (req, res, next) => {
try {
const location = locationSchema.parse(req.body);
const view = await clinicSettings.setClinicLocation(
req.organizationId!,
location,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Updated the clinic location",
entityType: "settings",
});
res.json(view);
} catch (err) {
next(err);
}
},
);
+4 -1
View File
@@ -17,7 +17,7 @@ import {
} from "../middleware/auth.js";
import { emitToWallet } from "../realtime.js";
import { recordActivity } from "../services/activity.js";
import { expectResponse } from "../services/relay-client.js";
import { connectOrg, expectResponse } from "../services/relay-client.js";
import * as patientService from "../services/patients.js";
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
import { getNetworkEnabled } from "../services/signing.js";
@@ -95,6 +95,9 @@ patientsWalletRouter.post(
);
// No wallet number to `wallet:send` to yet, so pre-register the request id
// with the relay so the scanning device's response routes back to us.
// Ensure the hub is (re)connected first; if it's still mid-handshake the
// on-auth re-registration of pending requests will catch this one.
await connectOrg(req.organizationId!);
expectResponse(req.organizationId!, view.id);
res.status(201).json({
...view,
+72 -3
View File
@@ -1,9 +1,10 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { Router, type Request } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { organization } from "../db/schema/auth.js";
import { member, organization, user } from "../db/schema/auth.js";
import { staffProfile } from "../db/schema/staff-profile.js";
import { appointmentInputSchema } from "../lib/appointment-validation.js";
import { HttpError } from "../lib/http-error.js";
import { initialsFromName } from "../lib/initials.js";
@@ -12,6 +13,10 @@ import { recordActivity } from "../services/activity.js";
import { createAppointment, listAppointments } from "../services/appointments.js";
import { createPatient, getPatient } from "../services/patients.js";
// Clinical-capable roles that can be a patient's provider (mirrors
// staff.ts PROVIDER_ROLES). Department roles (reception, pharmacy, lab) excluded.
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the
// waiting room). Scoped by the clinic slug in the URL — there is no session.
//
@@ -45,12 +50,76 @@ portalRouter.get("/:clinic", async (req, res, next) => {
}
});
// GET /api/portal/:clinic/doctors — public list of the clinic's providers so a
// patient can pick who to see. Returns only display-safe fields (name +
// specialty); no ids, emails, or usernames leave this unauthenticated surface.
portalRouter.get("/:clinic/doctors", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const rows = await db
.select({ name: user.name, specialty: staffProfile.specialty })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.leftJoin(
staffProfile,
and(
eq(staffProfile.userId, member.userId),
eq(staffProfile.organizationId, member.organizationId),
),
)
.where(
and(
eq(member.organizationId, clinic.id),
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
),
)
.orderBy(asc(user.name));
res.json(rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null })));
} catch (err) {
next(err);
}
});
const availabilitySchema = z.object({
provider: z.string().trim().max(200).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
});
// GET /api/portal/:clinic/availability?provider=&date= — the taken time slots
// for a provider on a given day, so the kiosk can render only free slots. The
// filter mirrors the booking conflict check (an empty-provider appointment
// blocks the slot clinic-wide). Booking still re-checks server-side (409).
portalRouter.get("/:clinic/availability", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const q = availabilitySchema.parse({
provider: req.query.provider,
date: req.query.date,
});
const provider = q.provider ?? "";
const taken = (await listAppointments(clinic.id))
.filter(
(a) =>
a.status !== "cancelled" &&
a.date === q.date &&
(!provider || !a.provider || a.provider === provider),
)
.map((a) => a.time);
res.json({ date: q.date, provider, taken: [...new Set(taken)].sort() });
} catch (err) {
next(err);
}
});
const bookingSchema = z.object({
fileNumber: z.string().trim().min(1, "A file number is required.").max(64),
name: z.string().trim().min(1, "Your name is required.").max(200),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
type: z.string().trim().max(120).optional(),
// Chosen provider (doctor name) from the portal's doctor picker; falls back
// to the patient's PCP when omitted.
provider: z.string().trim().max(200).optional(),
});
const newPatientSchema = z.object({
@@ -114,7 +183,7 @@ portalRouter.post("/:clinic/appointments", async (req, res, next) => {
date: body.date,
time: body.time,
type: body.type || "Self-service booking",
provider: patient.pcp || "",
provider: body.provider || patient.pcp || "",
status: "confirmed",
source: "manual",
});
+82
View File
@@ -0,0 +1,82 @@
import { eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { clinicSettings } from "../db/schema/clinic-settings.js";
export type ClinicLocation = {
address: string;
city: string;
country: string;
latitude: number | null;
longitude: number | null;
};
export type ClinicSettingsView = {
location: ClinicLocation;
};
const EMPTY_LOCATION: ClinicLocation = {
address: "",
city: "",
country: "",
latitude: null,
longitude: null,
};
type ClinicSettingsRow = typeof clinicSettings.$inferSelect;
function toView(row: ClinicSettingsRow | undefined): ClinicSettingsView {
if (!row) return { location: { ...EMPTY_LOCATION } };
return {
location: {
address: row.address,
city: row.city,
country: row.country,
latitude: row.latitude,
longitude: row.longitude,
},
};
}
// Read a clinic's settings. Returns empty defaults when no row exists yet, so
// the panel always renders.
export async function getClinicSettings(
orgId: string,
): Promise<ClinicSettingsView> {
const [row] = await db
.select()
.from(clinicSettings)
.where(eq(clinicSettings.organizationId, orgId))
.limit(1);
return toView(row);
}
// Upsert the clinic's location (address + optional coordinates).
export async function setClinicLocation(
orgId: string,
location: ClinicLocation,
): Promise<ClinicSettingsView> {
const values = {
organizationId: orgId,
address: location.address,
city: location.city,
country: location.country,
latitude: location.latitude,
longitude: location.longitude,
};
const [row] = await db
.insert(clinicSettings)
.values(values)
.onConflictDoUpdate({
target: clinicSettings.organizationId,
set: {
address: values.address,
city: values.city,
country: values.country,
latitude: values.latitude,
longitude: values.longitude,
},
})
.returning();
return toView(row);
}
+9
View File
@@ -53,6 +53,8 @@ function toPatient(row: PatientRow, children: Children): Patient {
primaryProviderId: row.primaryProviderId,
status: row.status,
initials: row.initials,
phone: row.phone,
bloodType: row.bloodType,
allergies: children.allergies,
alerts: row.alerts,
medications: children.medications,
@@ -82,6 +84,8 @@ const EMPTY_TREND: Trend = { label: "", unit: "", points: [] };
function redactClinical(patient: Patient): Patient {
return {
...patient,
// bloodType is clinical PHI; phone is a demographic/contact field and stays.
bloodType: "",
allergies: [],
alerts: [],
medications: [],
@@ -116,6 +120,8 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string)
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
bloodType: input.bloodType,
alerts: input.alerts,
vitalsBp: input.vitals.bp,
vitalsHr: input.vitals.hr,
@@ -147,6 +153,8 @@ function demographicColumns(
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
bloodType: "",
source: input.source,
alerts: [] as string[],
vitalsBp: "",
@@ -172,6 +180,7 @@ function demographicUpdateColumns(input: PatientInput) {
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
};
}
+3 -1
View File
@@ -49,7 +49,9 @@ export function expectResponse(orgId: string, requestId: string): void {
}
// Open (and authenticate) a hub connection for a clinic, if not already open.
// Idempotent — safe to call on startup and again when an org joins the network.
// Idempotent — safe to call on startup, when an org joins the network, and
// before generating a pairing QR. The socket auto-reconnects on its own, so an
// existing entry is left as-is.
export async function connectOrg(orgId: string): Promise<void> {
if (hubs.has(orgId)) return;
+2
View File
@@ -61,6 +61,8 @@ export type Patient = {
primaryProviderId?: string | null; // user id of the responsible clinician
status: PatientStatus;
initials: string; // for AvatarFallback
phone?: string; // contact number (demographic; visible to reception)
bloodType?: string; // e.g. "O+"; clinical — redacted for reception
allergies: Allergy[];
alerts: string[];
medications: Medication[];
+8 -2
View File
@@ -104,12 +104,18 @@ white/6%), so layered surfaces stay close in lightness.
## i18n
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, English resources in
`lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, resources in
`lib/i18n/locales/<lng>/translation.json`). `components/i18n-provider.tsx` wraps the app in
`app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`)
in **client** components. To add a language, drop a `locales/<lng>/translation.json` and register it
in `resources`/`supportedLngs` in `config.ts`.
> **Translate into EVERY locale, not just English.** The app ships multiple languages
> (`lib/i18n/locales/`: currently `en`, `de`, `fr`, `ar`, `so`). Whenever you add or rename a
> translation key, add it to **all** `locales/*/translation.json` files with a real translation for
> each language (not the English string copied over) — leaving a key in only `en/` ships a broken UI
> in the others. Keep the nested structure identical across every locale file.
**Coverage:** essentially all user-facing strings are now keyed (every app page + its dialogs/sheets,
auth pages, settings panels, the sidebar/user menu, chat input, patient cards/detail/form, messages,
notifications, notes). Keys are grouped by feature (`appointments.*`, `patientCard.*`, `messages.*`,
@@ -243,6 +243,11 @@ function SummaryCard({
label={t("patientCard.summary.allergies")}
value={patient.allergies.length || t("patientCard.summary.none")}
/>
<Stat
label={t("patientCard.summary.bloodType")}
value={patient.bloodType || "—"}
/>
<Stat label={t("patientCard.summary.phone")} value={patient.phone || "—"} />
</div>
<AlertBadges alerts={patient.alerts} />
{onEdit ? (
@@ -243,6 +243,8 @@ export function PatientFormDialog({
// per-doctor visibility), not free text. `providerId` is the selected user id.
const [providers, setProviders] = useState<Provider[]>([]);
const [providerId, setProviderId] = useState(patient?.primaryProviderId ?? "");
const [phone, setPhone] = useState(patient?.phone ?? "");
const [bloodType, setBloodType] = useState(patient?.bloodType ?? "");
const [bp, setBp] = useState(patient?.vitals.bp ?? "");
const [hr, setHr] = useState(patient?.vitals.hr ?? "");
const [temp, setTemp] = useState(patient?.vitals.temp ?? "");
@@ -310,6 +312,8 @@ export function PatientFormDialog({
primaryProviderId: providerId || null,
status,
initials: initialsFromName(name),
phone: phone.trim(),
bloodType,
allergies: allergies.filter((a) => a.substance.trim()),
alerts: patient?.alerts ?? [],
medications: medications.filter((m) => m.name.trim()),
@@ -515,6 +519,31 @@ export function PatientFormDialog({
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t("patientForm.phone")}>
<Input
inputMode="tel"
onChange={(event) => setPhone(event.target.value)}
placeholder={t("patientForm.phonePlaceholder")}
value={phone}
/>
</Field>
<Field label={t("patientForm.bloodType")}>
<select
className={controlClass}
onChange={(event) => setBloodType(event.target.value)}
value={bloodType}
>
<option value="">{t("patientForm.bloodTypeUnknown")}</option>
{["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"].map((bt) => (
<option key={bt} value={bt}>
{bt}
</option>
))}
</select>
</Field>
</div>
{showClinical && (
<>
<div className="flex flex-col gap-1.5">
@@ -359,6 +359,14 @@ export function PatientDetail({
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
<Stat
label={t("patientCard.summary.bloodType")}
value={patient.bloodType || "—"}
/>
<Stat
label={t("patientCard.summary.phone")}
value={patient.phone || "—"}
/>
</div>
</Section>
@@ -13,6 +13,8 @@ import {
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { ClinicLocationSection } from "@/components/settings/settings-location";
import { PatientPortalSection } from "@/components/settings/settings-portal";
import {
getNetworkEnabled,
getSigningKey,
@@ -205,6 +207,10 @@ export function SigningPanel() {
</SettingsCard>
</SettingsSection>
<PatientPortalSection />
<ClinicLocationSection />
<SettingsSection
description={t("settings.signing.identityDescription")}
title={t("settings.signing.identityTitle")}
@@ -0,0 +1,211 @@
"use client";
import { LocateFixed } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
FieldLabel,
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { cn } from "@/lib/utils";
import { getClinicSettings, saveClinicLocation } from "@/lib/clinic";
import { notify } from "@/lib/toast";
// Parse a coordinate input into a number or null (empty ⇒ null). Returns
// `false` when the string is present but not a finite number, so we can flag it.
function parseCoord(value: string): number | null | false {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : false;
}
// Clinic location editor (owner/admin only — mounted inside the Signing panel).
// Persists the clinic's address + optional map coordinates so the wallet app can
// display the clinic location later.
export function ClinicLocationSection() {
const { t } = useTranslation();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [country, setCountry] = useState("");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
useEffect(() => {
let active = true;
getClinicSettings()
.then((settings) => {
if (!active) return;
const loc = settings.location;
setAddress(loc.address);
setCity(loc.city);
setCountry(loc.country);
setLatitude(loc.latitude === null ? "" : String(loc.latitude));
setLongitude(loc.longitude === null ? "" : String(loc.longitude));
})
.catch(() => {
/* keep empty defaults */
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
// Fill the coordinates from the browser's geolocation (the clinician runs this
// on a device at the clinic). Client-only — no backend or map service.
const useMyLocation = () => {
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.geoUnsupported"),
);
return;
}
setLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6));
setLocating(false);
},
() => {
setLocating(false);
notify.error(
t("settings.location.errorTitle"),
t("settings.location.geoError"),
);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
};
const save = async () => {
const lat = parseCoord(latitude);
const lng = parseCoord(longitude);
if (lat === false || lng === false) {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.invalidCoords"),
);
return;
}
setSaving(true);
try {
await saveClinicLocation({
address: address.trim(),
city: city.trim(),
country: country.trim(),
latitude: lat,
longitude: lng,
});
notify.success(
t("settings.location.savedTitle"),
t("settings.location.savedBody"),
);
} catch {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.error"),
);
} finally {
setSaving(false);
}
};
return (
<SettingsSection
description={t("settings.location.description")}
title={t("settings.location.title")}
>
<SettingsCard className="flex flex-col gap-4 p-5">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.address")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setAddress(e.target.value)}
placeholder={t("settings.location.addressPlaceholder")}
value={address}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.city")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setCity(e.target.value)}
value={city}
/>
</label>
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.country")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setCountry(e.target.value)}
value={country}
/>
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.latitude")}</FieldLabel>
<Input
disabled={loading}
inputMode="decimal"
onChange={(e) => setLatitude(e.target.value)}
placeholder="e.g. 2.0469"
value={latitude}
/>
</label>
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.longitude")}</FieldLabel>
<Input
disabled={loading}
inputMode="decimal"
onChange={(e) => setLongitude(e.target.value)}
placeholder="e.g. 45.3182"
value={longitude}
/>
</label>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.location.coordinatesHint")}
</p>
<div className="flex flex-wrap gap-2">
<Button
className={cn("rounded-lg", whiteButton)}
disabled={loading || saving}
onClick={save}
type="button"
>
{saving
? t("settings.location.saving")
: t("settings.location.save")}
</Button>
<Button
className="rounded-lg"
disabled={loading || locating}
onClick={useMyLocation}
type="button"
variant="outline"
>
<LocateFixed className="size-4" />
{locating
? t("settings.location.locating")
: t("settings.location.useMyLocation")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
);
}
@@ -0,0 +1,99 @@
"use client";
import { ExternalLink, QrCode } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import {
CopyField,
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { authClient } from "@/lib/auth-client";
import { resolveBackendUrl } from "@/lib/backend-url";
import { cn } from "@/lib/utils";
// Patient Portal section (Settings → Signing): surfaces the clinic's public
// portal link so patients can open it, copy it, or scan a QR. The portal lives
// at /portal/<org-slug>; the QR also carries the backend base (`?api=`) so the
// patient wallet app can reach the JSON API when it scans the same code.
export function PatientPortalSection() {
const { t } = useTranslation();
const { data: activeOrg } = authClient.useActiveOrganization();
const [qrOpen, setQrOpen] = useState(false);
const slug = activeOrg?.slug;
const origin = typeof window !== "undefined" ? window.location.origin : "";
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
const qrUrl = slug
? `${portalUrl}?api=${encodeURIComponent(resolveBackendUrl())}`
: "";
return (
<SettingsSection
description={t("settings.portal.description")}
title={t("settings.portal.title")}
>
<SettingsCard className="flex flex-col gap-4 p-5">
<CopyField
description={t("settings.portal.linkDescription")}
label={t("settings.portal.linkLabel")}
value={portalUrl || "—"}
/>
<div className="flex flex-wrap gap-2">
<Button
className={cn("rounded-lg", whiteButton)}
disabled={!portalUrl}
onClick={() => window.open(portalUrl, "_blank", "noopener")}
type="button"
>
<ExternalLink className="size-4" />
{t("settings.portal.open")}
</Button>
<Button
className="rounded-lg"
disabled={!qrUrl}
onClick={() => setQrOpen(true)}
type="button"
variant="outline"
>
<QrCode className="size-4" />
{t("settings.portal.showQr")}
</Button>
</div>
</SettingsCard>
<Dialog onOpenChange={setQrOpen} open={qrOpen}>
<DialogPopup className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t("settings.portal.qrTitle")}</DialogTitle>
<DialogDescription>
{t("settings.portal.qrDescription")}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col items-center gap-3 pb-2">
{qrUrl ? (
<div className="rounded-2xl bg-white p-4">
<QRCodeSvg value={qrUrl} size={220} />
</div>
) : null}
<p className="break-all text-center text-sm text-muted-foreground">
{portalUrl}
</p>
</DialogPanel>
</DialogPopup>
</Dialog>
</SettingsSection>
);
}
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useAiAccess } from "@/lib/ai-policy";
import { dirFor } from "@/lib/i18n/config";
import { useActiveRole, visibleNavItems } from "@/lib/roles";
import { motion } from "framer-motion";
import Image from "next/image";
@@ -27,7 +28,10 @@ import { useCallInvites } from "@/components/meetings/use-call-invites";
export function DashboardSidebar() {
const { state } = useSidebar();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Anchor the sidebar to the right for RTL locales (Arabic) so the whole shell
// mirrors instead of leaving the fixed sidebar pinned physically left.
const side = dirFor(i18n.language) === "rtl" ? "right" : "left";
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const isCollapsed = state === "collapsed";
@@ -54,7 +58,7 @@ export function DashboardSidebar() {
}));
return (
<Sidebar variant="inset" collapsible="icon">
<Sidebar variant="inset" collapsible="icon" side={side}>
<SidebarHeader
className={cn(
"flex md:pt-3.5",
+1 -1
View File
@@ -19,7 +19,7 @@ export function Switch({
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block aspect-square h-full origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] data-checked:translate-x-[calc(var(--thumb-size)-4px)]",
"pointer-events-none block aspect-square h-full ltr:origin-left rtl:origin-right in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] ltr:data-checked:translate-x-[calc(var(--thumb-size)-4px)] rtl:data-checked:-translate-x-[calc(var(--thumb-size)-4px)]",
)}
data-slot="switch-thumb"
/>
+33
View File
@@ -0,0 +1,33 @@
// Client for clinic-level (organization) settings — currently the clinic's
// location (Settings → Signing → Location). Calls the backend over the shared
// fetch wrapper (session cookie sent automatically).
import { apiFetch } from "@/lib/api-client";
export type ClinicLocation = {
address: string;
city: string;
country: string;
latitude: number | null;
longitude: number | null;
};
export type ClinicSettings = {
location: ClinicLocation;
};
// The clinic's settings. Readable by any clinician; returns empty defaults when
// nothing has been set yet.
export async function getClinicSettings(): Promise<ClinicSettings> {
return apiFetch<ClinicSettings>("/api/clinic/settings");
}
// Save the clinic's location (owner/admin only). Returns the updated settings.
export async function saveClinicLocation(
location: ClinicLocation,
): Promise<ClinicSettings> {
return apiFetch<ClinicSettings>("/api/clinic/location", {
method: "PUT",
body: JSON.stringify(location),
});
}
@@ -1463,6 +1463,8 @@
"allergies": "الحساسيات",
"activeMeds": "الأدوية النشطة",
"openProblems": "المشكلات المفتوحة",
"bloodType": "فصيلة الدم",
"phone": "الهاتف",
"none": "لا شيء",
"editRecord": "تعديل السجل"
},
@@ -1565,6 +1567,10 @@
"primaryCare": "الرعاية الأساسية",
"primaryCarePlaceholder": "مثال: د. لينا أورتيز",
"primaryCareUnassigned": "غير مُعيّن",
"phone": "الهاتف",
"phonePlaceholder": "مثال: +1 555 010 2938",
"bloodType": "فصيلة الدم",
"bloodTypeUnknown": "غير معروف",
"currentVitals": "العلامات الحيوية الحالية",
"bp": "ضغط الدم",
"hr": "معدل ضربات القلب",
@@ -2011,6 +2017,28 @@
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
"error": "يرجى المحاولة مرة أخرى."
},
"location": {
"title": "موقع العيادة",
"description": "عنوان عيادتك وإحداثيات الخريطة. تظهر للمرضى في تطبيق المحفظة.",
"address": "العنوان",
"addressPlaceholder": "عنوان الشارع",
"city": "المدينة",
"country": "الدولة",
"latitude": "خط العرض",
"longitude": "خط الطول",
"coordinatesHint": "الإحداثيات اختيارية — تُستخدم لعرض عيادتك على الخريطة.",
"save": "حفظ الموقع",
"saving": "جارٍ الحفظ…",
"savedTitle": "تم حفظ الموقع",
"savedBody": "تم تحديث موقع عيادتك.",
"invalidCoords": "يجب أن يكون خطا العرض والطول أرقامًا.",
"errorTitle": "تعذّر حفظ الموقع",
"error": "يرجى المحاولة مرة أخرى.",
"useMyLocation": "استخدام موقعي الحالي",
"locating": "جارٍ تحديد الموقع…",
"geoUnsupported": "الموقع غير متاح على هذا الجهاز.",
"geoError": "تعذّر الحصول على موقعك. تحقّق من الأذونات وحاول مرة أخرى."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
"active": "نشط",
@@ -2121,6 +2149,16 @@
"savedBody": "تم تحديث تهيئة الذكاء الاصطناعي الخاصة بك.",
"saveFailedTitle": "تعذّر الحفظ",
"saveFailedBody": "فشل حفظ إعدادات الذكاء الاصطناعي. يرجى المحاولة مرة أخرى."
},
"portal": {
"title": "بوابة المريض",
"description": "شارك بوابة الحجز العامة لعيادتك مع المرضى — يمكنهم رؤية أطبائك وحجز المواعيد.",
"linkLabel": "رابط البوابة",
"linkDescription": "الصفحة العامة التي يفتحها المرضى للحجز في عيادتك.",
"open": "فتح البوابة",
"showQr": "عرض رمز QR",
"qrTitle": "رمز QR لبوابة المريض",
"qrDescription": "يمسح المرضى هذا لفتح بوابة عيادتك — أو يمسحونه في تطبيق محفظة Temetro للحجز."
}
},
"portal": {
@@ -1443,6 +1443,8 @@
"allergies": "Allergien",
"activeMeds": "Aktive Medikamente",
"openProblems": "Offene Probleme",
"bloodType": "Blutgruppe",
"phone": "Telefon",
"none": "Keine",
"editRecord": "Datensatz bearbeiten"
},
@@ -1545,6 +1547,10 @@
"primaryCare": "Primärversorgung",
"primaryCarePlaceholder": "z. B. Dr. Lena Ortiz",
"primaryCareUnassigned": "Nicht zugewiesen",
"phone": "Telefon",
"phonePlaceholder": "z. B. +1 555 010 2938",
"bloodType": "Blutgruppe",
"bloodTypeUnknown": "Unbekannt",
"currentVitals": "Aktuelle Vitalwerte",
"bp": "Blutdruck",
"hr": "Herzfrequenz",
@@ -1991,6 +1997,28 @@
"errorTitle": "Netzwerkzugriff konnte nicht aktualisiert werden",
"error": "Bitte versuchen Sie es erneut."
},
"location": {
"title": "Standort der Klinik",
"description": "Adresse und Kartenkoordinaten Ihrer Klinik. Wird Patienten in der Wallet-App angezeigt.",
"address": "Adresse",
"addressPlaceholder": "Straße und Hausnummer",
"city": "Stadt",
"country": "Land",
"latitude": "Breitengrad",
"longitude": "Längengrad",
"coordinatesHint": "Koordinaten sind optional um Ihre Klinik auf einer Karte anzuzeigen.",
"save": "Standort speichern",
"saving": "Wird gespeichert …",
"savedTitle": "Standort gespeichert",
"savedBody": "Der Standort Ihrer Klinik wurde aktualisiert.",
"invalidCoords": "Breiten- und Längengrad müssen Zahlen sein.",
"errorTitle": "Standort konnte nicht gespeichert werden",
"error": "Bitte versuchen Sie es erneut.",
"useMyLocation": "Aktuellen Standort verwenden",
"locating": "Standort wird ermittelt …",
"geoUnsupported": "Standort ist auf diesem Gerät nicht verfügbar.",
"geoError": "Standort konnte nicht ermittelt werden. Bitte Berechtigungen prüfen und erneut versuchen."
},
"signing": {
"keyTitle": "Signierschlüssel",
"active": "Aktiv",
@@ -2101,6 +2129,16 @@
"savedBody": "Ihre KI-Konfiguration wurde aktualisiert.",
"saveFailedTitle": "Speichern fehlgeschlagen",
"saveFailedBody": "Das Speichern Ihrer KI-Einstellungen ist fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"portal": {
"title": "Patientenportal",
"description": "Teilen Sie das öffentliche Buchungsportal Ihrer Klinik mit Patienten sie können Ihre Ärzte sehen und Termine buchen.",
"linkLabel": "Portal-Link",
"linkDescription": "Die öffentliche Seite, die Patienten zum Buchen bei Ihrer Klinik öffnen.",
"open": "Portal öffnen",
"showQr": "QR-Code anzeigen",
"qrTitle": "Patientenportal-QR",
"qrDescription": "Patienten scannen dies, um das Portal Ihrer Klinik zu öffnen oder scannen es in der Temetro-Wallet-App, um zu buchen."
}
},
"portal": {
@@ -1443,6 +1443,8 @@
"allergies": "Allergies",
"activeMeds": "Active meds",
"openProblems": "Open problems",
"bloodType": "Blood type",
"phone": "Phone",
"none": "None",
"editRecord": "Edit record"
},
@@ -1545,6 +1547,10 @@
"primaryCare": "Primary care",
"primaryCarePlaceholder": "e.g. Dr. Lena Ortiz",
"primaryCareUnassigned": "Unassigned",
"phone": "Phone",
"phonePlaceholder": "e.g. +1 555 010 2938",
"bloodType": "Blood type",
"bloodTypeUnknown": "Unknown",
"currentVitals": "Current vitals",
"bp": "Blood pressure",
"hr": "Heart rate",
@@ -1991,6 +1997,28 @@
"errorTitle": "Couldn't update network access",
"error": "Please try again."
},
"location": {
"title": "Clinic location",
"description": "Your clinic's address and map coordinates. Shown to patients in the wallet app.",
"address": "Address",
"addressPlaceholder": "Street address",
"city": "City",
"country": "Country",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Coordinates are optional — used to show your clinic on a map.",
"save": "Save location",
"saving": "Saving…",
"savedTitle": "Location saved",
"savedBody": "Your clinic location has been updated.",
"invalidCoords": "Latitude and longitude must be numbers.",
"errorTitle": "Couldn't save location",
"error": "Please try again.",
"useMyLocation": "Use my current location",
"locating": "Locating…",
"geoUnsupported": "Location isn't available on this device.",
"geoError": "Couldn't get your location. Check permissions and try again."
},
"signing": {
"keyTitle": "Signing key",
"active": "Active",
@@ -2101,6 +2129,16 @@
"savedBody": "Your AI configuration has been updated.",
"saveFailedTitle": "Could not save",
"saveFailedBody": "Saving your AI settings failed. Please try again."
},
"portal": {
"title": "Patient Portal",
"description": "Share your clinic's public booking portal with patients — they can view your doctors and book appointments.",
"linkLabel": "Portal link",
"linkDescription": "The public page patients open to book with your clinic.",
"open": "Open portal",
"showQr": "Show QR code",
"qrTitle": "Patient Portal QR",
"qrDescription": "Patients scan this to open your clinic's portal — or scan it in the Temetro wallet app to book."
}
},
"portal": {
@@ -1443,6 +1443,8 @@
"allergies": "Allergies",
"activeMeds": "Médicaments actifs",
"openProblems": "Problèmes ouverts",
"bloodType": "Groupe sanguin",
"phone": "Téléphone",
"none": "Aucun",
"editRecord": "Modifier le dossier"
},
@@ -1545,6 +1547,10 @@
"primaryCare": "Praticien référent",
"primaryCarePlaceholder": "ex. Dr Lena Ortiz",
"primaryCareUnassigned": "Non attribué",
"phone": "Téléphone",
"phonePlaceholder": "p. ex. +1 555 010 2938",
"bloodType": "Groupe sanguin",
"bloodTypeUnknown": "Inconnu",
"currentVitals": "Signes vitaux actuels",
"bp": "Tension artérielle",
"hr": "Fréquence cardiaque",
@@ -1991,6 +1997,28 @@
"errorTitle": "Impossible de mettre à jour l'accès au réseau",
"error": "Veuillez réessayer."
},
"location": {
"title": "Emplacement de la clinique",
"description": "L'adresse et les coordonnées cartographiques de votre clinique. Affichées aux patients dans l'application wallet.",
"address": "Adresse",
"addressPlaceholder": "Adresse postale",
"city": "Ville",
"country": "Pays",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Les coordonnées sont facultatives — utilisées pour afficher votre clinique sur une carte.",
"save": "Enregistrer l'emplacement",
"saving": "Enregistrement…",
"savedTitle": "Emplacement enregistré",
"savedBody": "L'emplacement de votre clinique a été mis à jour.",
"invalidCoords": "La latitude et la longitude doivent être des nombres.",
"errorTitle": "Impossible d'enregistrer l'emplacement",
"error": "Veuillez réessayer.",
"useMyLocation": "Utiliser ma position actuelle",
"locating": "Localisation…",
"geoUnsupported": "La localisation n'est pas disponible sur cet appareil.",
"geoError": "Impossible d'obtenir votre position. Vérifiez les autorisations et réessayez."
},
"signing": {
"keyTitle": "Clé de signature",
"active": "Active",
@@ -2101,6 +2129,16 @@
"savedBody": "Votre configuration d'IA a été mise à jour.",
"saveFailedTitle": "Impossible d'enregistrer",
"saveFailedBody": "L'enregistrement de vos paramètres d'IA a échoué. Veuillez réessayer."
},
"portal": {
"title": "Portail patient",
"description": "Partagez le portail de réservation public de votre clinique avec les patients — ils peuvent voir vos médecins et prendre rendez-vous.",
"linkLabel": "Lien du portail",
"linkDescription": "La page publique que les patients ouvrent pour réserver avec votre clinique.",
"open": "Ouvrir le portail",
"showQr": "Afficher le QR code",
"qrTitle": "QR du portail patient",
"qrDescription": "Les patients le scannent pour ouvrir le portail de votre clinique — ou le scannent dans l'application Temetro pour réserver."
}
},
"portal": {
@@ -1443,6 +1443,8 @@
"allergies": "Xasaasiyadaha",
"activeMeds": "Daawooyin firfircoon",
"openProblems": "Dhibaatooyin furan",
"bloodType": "Nooca dhiigga",
"phone": "Taleefanka",
"none": "Midna",
"editRecord": "Wax ka beddel diiwaanka"
},
@@ -1545,6 +1547,10 @@
"primaryCare": "Daryeelka aasaasiga ah",
"primaryCarePlaceholder": "tusaale Dr. Lena Ortiz",
"primaryCareUnassigned": "Aan la qoondayn",
"phone": "Taleefanka",
"phonePlaceholder": "tusaale: +1 555 010 2938",
"bloodType": "Nooca dhiigga",
"bloodTypeUnknown": "Aan la garanayn",
"currentVitals": "Calaamadaha muhiimka ah ee hadda",
"bp": "Cadaadiska dhiigga",
"hr": "Garaaca wadnaha",
@@ -1991,6 +1997,28 @@
"errorTitle": "Lama cusboonaysiin karin gelitaanka shabakadda",
"error": "Fadlan isku day mar kale."
},
"location": {
"title": "Goobta rugta caafimaadka",
"description": "Cinwaanka iyo isutagga khariidadda rugtaada. Waxaa loo tusayaa bukaannada app-ka wallet-ka.",
"address": "Cinwaanka",
"addressPlaceholder": "Cinwaanka waddada",
"city": "Magaalada",
"country": "Dalka",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Isutaggu waa ikhtiyaari — waxaa loo isticmaalaa in rugtaada lagu tuso khariidad.",
"save": "Kaydi goobta",
"saving": "Waa la kaydinayaa…",
"savedTitle": "Goobta waa la kaydiyay",
"savedBody": "Goobta rugtaada waa la cusboonaysiiyay.",
"invalidCoords": "Latitude iyo longitude waa inay noqdaan tirooyin.",
"errorTitle": "Lama kaydin karin goobta",
"error": "Fadlan mar kale isku day.",
"useMyLocation": "Isticmaal goobtayda hadda",
"locating": "Waa la helayaa goobta…",
"geoUnsupported": "Goobta laguma heli karo qalabkan.",
"geoError": "Lama heli karin goobtaada. Hubi oggolaanshaha oo isku day mar kale."
},
"signing": {
"keyTitle": "Furaha saxiixa",
"active": "Firfircoon",
@@ -2101,6 +2129,16 @@
"savedBody": "Qaabaynta AI-gaaga waa la cusbooneysiiyay.",
"saveFailedTitle": "Lama kaydin karin",
"saveFailedBody": "Kaydinta dejinta AI waa fashilantay. Fadlan isku day mar kale."
},
"portal": {
"title": "Boggaga Bukaanka",
"description": "La wadaag bukaannada boggaga ballanqaadka guud ee rugtaada — waxay arki karaan dhakhtarradaada oo ballan qaadan karaan.",
"linkLabel": "Linkiga boggaga",
"linkDescription": "Bogga guud ee bukaannadu furaan si ay ballan ugu qaataan rugtaada.",
"open": "Fur boggaga",
"showQr": "Muuji koodhka QR",
"qrTitle": "Koodhka QR ee Boggaga Bukaanka",
"qrDescription": "Bukaannadu waxay tan sawiraan si ay u furaan boggaga rugtaada — ama ku sawir abka Temetro wallet si aad ballan u qaadato."
}
},
"portal": {
+2
View File
@@ -66,6 +66,8 @@ export type Patient = {
primaryProviderId?: string | null; // user id of the responsible clinician
status: "active" | "inpatient" | "discharged";
initials: string; // for AvatarFallback
phone?: string; // contact number (demographic; visible to reception)
bloodType?: string; // e.g. "O+"; clinical — redacted for reception
allergies: Allergy[];
alerts: string[];
medications: Medication[];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.8.1",
"version": "0.10.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.8.1",
"version": "0.10.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"