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>
This commit is contained in:
Khalid Abdi
2026-07-06 22:07:27 +03:00
parent 01dbc07e92
commit 36461a5498
31 changed files with 9885 additions and 5 deletions
+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,7 @@ import {
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { ClinicLocationSection } from "@/components/settings/settings-location";
import {
getNetworkEnabled,
getSigningKey,
@@ -205,6 +206,8 @@ export function SigningPanel() {
</SettingsCard>
</SettingsSection>
<ClinicLocationSection />
<SettingsSection
description={t("settings.signing.identityDescription")}
title={t("settings.signing.identityTitle")}
@@ -0,0 +1,169 @@
"use client";
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);
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;
};
}, []);
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>
<Button
className={cn("rounded-lg", whiteButton)}
disabled={loading || saving}
onClick={save}
type="button"
>
{saving
? t("settings.location.saving")
: t("settings.location.save")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
);
}
+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,24 @@
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
"error": "يرجى المحاولة مرة أخرى."
},
"location": {
"title": "موقع العيادة",
"description": "عنوان عيادتك وإحداثيات الخريطة. تظهر للمرضى في تطبيق المحفظة.",
"address": "العنوان",
"addressPlaceholder": "عنوان الشارع",
"city": "المدينة",
"country": "الدولة",
"latitude": "خط العرض",
"longitude": "خط الطول",
"coordinatesHint": "الإحداثيات اختيارية — تُستخدم لعرض عيادتك على الخريطة.",
"save": "حفظ الموقع",
"saving": "جارٍ الحفظ…",
"savedTitle": "تم حفظ الموقع",
"savedBody": "تم تحديث موقع عيادتك.",
"invalidCoords": "يجب أن يكون خطا العرض والطول أرقامًا.",
"errorTitle": "تعذّر حفظ الموقع",
"error": "يرجى المحاولة مرة أخرى."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
"active": "نشط",
@@ -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,24 @@
"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."
},
"signing": {
"keyTitle": "Signierschlüssel",
"active": "Aktiv",
@@ -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,24 @@
"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."
},
"signing": {
"keyTitle": "Signing key",
"active": "Active",
@@ -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,24 @@
"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."
},
"signing": {
"keyTitle": "Clé de signature",
"active": "Active",
@@ -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,24 @@
"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."
},
"signing": {
"keyTitle": "Furaha saxiixa",
"active": "Firfircoon",
+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.2",
"version": "0.9.0",
"private": true,
"scripts": {
"dev": "next dev",