frontend: settings tabs content + care-team dialog redesign

- Records tab: data sources, import/export, retention (replaces placeholder)
- Developers tab: API base URL, access-tokens empty state, resources
- Signing tab: how-it-works steps + backup key entries
- Team member dialog: header band, per-resource permission rows with icons
  and action chips, COSS Select for role change
- Care team: hint that clicking a member shows their permissions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-10 20:13:49 +03:00
parent 470230591c
commit 5312f914ac
7 changed files with 351 additions and 68 deletions
@@ -1,6 +1,12 @@
"use client";
import { Trash2 } from "lucide-react";
import {
CalendarDays,
ListChecks,
Pill,
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -17,11 +23,26 @@ import {
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ROLE_LABELS } from "@/lib/access";
import { authClient } from "@/lib/auth-client";
import { PROVISIONABLE_ROLES, rolePermissionSummary } from "@/lib/roles";
import { notify } from "@/lib/toast";
// Icon shown next to each permission resource row.
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
patient: <Users className="size-4" />,
appointment: <CalendarDays className="size-4" />,
prescription: <Pill className="size-4" />,
task: <ListChecks className="size-4" />,
};
// One row of /api/staff — shared with the Care Team panel.
export type StaffMember = {
id: string;
@@ -32,9 +53,6 @@ export type StaffMember = {
username: string | null;
};
const selectClass =
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
function roleLabel(role?: string | null): string {
if (!role) return ROLE_LABELS.member;
return (ROLE_LABELS as Record<string, string>)[role] ?? role;
@@ -128,18 +146,18 @@ export function EmployeeDetailDialog({
</DialogHeader>
<DialogPanel className="flex flex-col gap-5">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>
<div className="flex items-center gap-4 rounded-2xl border bg-card/30 p-4">
<Avatar className="size-12 rounded-xl">
<AvatarFallback className="rounded-xl bg-muted text-base font-medium">
{initials(member?.name, member?.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
<p className="truncate text-base font-semibold tracking-tight">
{member?.name || member?.email || member?.userId}
</p>
{secondary && (
<p className="truncate text-xs text-muted-foreground">
<p className="truncate text-sm text-muted-foreground">
{secondary}
</p>
)}
@@ -153,23 +171,33 @@ export function EmployeeDetailDialog({
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("settings.careTeam.employee.permissions")}
</span>
<div className="flex flex-col gap-1.5 rounded-2xl border bg-card/30 px-3 py-2.5">
<div className="divide-y divide-border rounded-2xl border bg-card/30">
{summary.map(({ resource, actions }) => (
<div
className="flex items-center justify-between gap-3"
className="flex items-center justify-between gap-3 px-3 py-2.5"
key={resource}
>
<span className="text-sm text-foreground">
<span className="flex items-center gap-2.5 text-sm text-foreground">
<span className="flex size-7 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{RESOURCE_ICONS[resource]}
</span>
{t(`settings.careTeam.employee.resources.${resource}`)}
</span>
<span className="text-xs text-muted-foreground">
{actions.length === 0
? t("settings.careTeam.employee.noAccess")
: actions
.map((a) =>
t(`settings.careTeam.employee.actions.${a}`),
)
.join(" · ")}
<span className="flex flex-wrap justify-end gap-1">
{actions.length === 0 ? (
<Badge
className="text-muted-foreground"
variant="outline"
>
{t("settings.careTeam.employee.noAccess")}
</Badge>
) : (
actions.map((a) => (
<Badge key={a} variant="secondary">
{t(`settings.careTeam.employee.actions.${a}`)}
</Badge>
))
)}
</span>
</div>
))}
@@ -182,18 +210,28 @@ export function EmployeeDetailDialog({
{t("settings.careTeam.employee.changeRole")}
</span>
<div className="flex items-center gap-2">
<select
aria-label={t("settings.careTeam.employee.changeRole")}
className={selectClass}
onChange={(e) => setRole(e.target.value)}
<Select
items={roleOptions.map((r) => ({
value: r,
label: roleLabel(r),
}))}
onValueChange={(value) => setRole(value as string)}
value={role}
>
{roleOptions.map((r) => (
<option key={r} value={r}>
{roleLabel(r)}
</option>
))}
</select>
<SelectTrigger
aria-label={t("settings.careTeam.employee.changeRole")}
className="flex-1"
>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{roleOptions.map((r) => (
<SelectItem key={r} value={r}>
{roleLabel(r)}
</SelectItem>
))}
</SelectPopup>
</Select>
<Button
disabled={saving || role === member?.role}
onClick={changeRole}
@@ -54,6 +54,46 @@ export function SigningPanel() {
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.signing.howItWorksDescription")}
title={t("settings.signing.howItWorksTitle")}
>
<SettingsCard className="divide-y divide-border">
{(["sign", "approve", "verify"] as const).map((step, index) => (
<div className="flex items-start gap-3 px-4 py-3.5" key={step}>
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
{index + 1}
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t(`settings.signing.steps.${step}`)}
</p>
<p className="text-sm text-muted-foreground">
{t(`settings.signing.steps.${step}Desc`)}
</p>
</div>
</div>
))}
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.signing.backupDescription")}
title={t("settings.signing.backupTitle")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.signing.backupLabel")}
</p>
<p className="text-sm text-muted-foreground">
{t("settings.signing.backupDesc")}
</p>
</div>
<Badge variant="secondary">{t("settings.signing.comingSoon")}</Badge>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.signing.signedRecordsDescription")}
title={t("settings.signing.signedRecordsTitle")}
@@ -1,6 +1,6 @@
"use client";
import { UserPlus } from "lucide-react";
import { Info, UserPlus } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -117,7 +117,11 @@ export function CareTeamPanel() {
)}
{canManage && (
<div className="flex justify-end">
<div className="flex items-center justify-between gap-4">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="size-3.5 shrink-0" />
{t("settings.careTeam.clickHint")}
</p>
<Button onClick={() => setAdding(true)} type="button">
<UserPlus className="size-4" />
{t("settings.careTeam.addMember")}
@@ -0,0 +1,74 @@
"use client";
import { KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CopyField,
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { API_BASE_URL } from "@/lib/api-client";
export function DevelopersPanel() {
const { t } = useTranslation();
return (
<>
<SettingsSection
description={t("settings.developers.apiDescription")}
title={t("settings.developers.apiTitle")}
>
<SettingsCard className="space-y-6 p-5">
<CopyField
description={t("settings.developers.baseUrlDescription")}
label={t("settings.developers.baseUrlLabel")}
value={API_BASE_URL}
/>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.developers.authLabel")}
</p>
<p className="text-sm text-muted-foreground">
{t("settings.developers.authDescription")}
</p>
</div>
</SettingsCard>
</SettingsSection>
<SettingsSection
action={
<Badge variant="secondary">
{t("settings.developers.comingSoon")}
</Badge>
}
description={t("settings.developers.tokensDescription")}
title={t("settings.developers.tokensTitle")}
>
<SettingsCard className="flex flex-col items-center justify-center gap-3 p-10">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<KeyRound className="size-5" />
</div>
<p className="text-sm text-muted-foreground">
{t("settings.developers.noTokens")}
</p>
<Button disabled size="sm" variant="outline">
{t("settings.developers.generateToken")}
</Button>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.developers.resourcesDescription")}
title={t("settings.developers.resourcesTitle")}
>
<SettingsCard className="p-5">
<p className="text-sm text-muted-foreground">
{t("settings.developers.resourcesBody")}
</p>
</SettingsCard>
</SettingsSection>
</>
);
}
@@ -0,0 +1,109 @@
"use client";
import { Database, Download, Import, Smartphone } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
// A single titled row inside a records card: icon + title/description on the
// left, a status badge or action on the right.
function RecordRow({
icon,
title,
description,
right,
}: {
icon: React.ReactNode;
title: string;
description: string;
right: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 px-4 py-3.5">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{icon}
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
<div className="shrink-0">{right}</div>
</div>
);
}
export function RecordsPanel() {
const { t } = useTranslation();
const comingSoon = (
<Badge variant="secondary">{t("settings.records.comingSoon")}</Badge>
);
return (
<>
<SettingsSection
description={t("settings.records.sourcesDescription")}
title={t("settings.records.sourcesTitle")}
>
<SettingsCard className="divide-y divide-border">
<RecordRow
description={t("settings.records.clinicDbDesc")}
icon={<Database className="size-4" />}
right={
<Badge className="bg-emerald-500/15 text-emerald-400">
{t("settings.records.connected")}
</Badge>
}
title={t("settings.records.clinicDb")}
/>
<RecordRow
description={t("settings.records.patientStorageDesc")}
icon={<Smartphone className="size-4" />}
right={comingSoon}
title={t("settings.records.patientStorage")}
/>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.records.importExportDescription")}
title={t("settings.records.importExportTitle")}
>
<SettingsCard className="divide-y divide-border">
<RecordRow
description={t("settings.records.exportDesc")}
icon={<Download className="size-4" />}
right={
<Button disabled size="sm" variant="outline">
{t("settings.records.export")}
</Button>
}
title={t("settings.records.export")}
/>
<RecordRow
description={t("settings.records.importDesc")}
icon={<Import className="size-4" />}
right={comingSoon}
title={t("settings.records.import")}
/>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.records.retentionDescription")}
title={t("settings.records.retentionTitle")}
>
<SettingsCard className="p-5">
<p className="text-sm text-muted-foreground">
{t("settings.records.retentionBody")}
</p>
</SettingsCard>
</SettingsSection>
</>
);
}
+4 -33
View File
@@ -4,13 +4,11 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { SigningPanel } from "@/components/settings/settings-billing";
import { CareTeamPanel } from "@/components/settings/settings-care-team";
import { DevelopersPanel } from "@/components/settings/settings-developers";
import { ProfilePanel } from "@/components/settings/settings-preferences";
import { RecordsPanel } from "@/components/settings/settings-records";
import { useActiveRole } from "@/lib/roles";
const TABS = [
@@ -23,23 +21,6 @@ const TABS = [
type Tab = (typeof TABS)[number]["id"];
function PlaceholderPanel({
title,
description,
}: {
title: string;
description: string;
}) {
const { t } = useTranslation();
return (
<SettingsSection description={description} title={title}>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">{t("settings.empty")}</p>
</SettingsCard>
</SettingsSection>
);
}
export function SettingsView() {
const { t } = useTranslation();
const role = useActiveRole();
@@ -78,20 +59,10 @@ export function SettingsView() {
<div className="mt-10 space-y-12">
{activeTab === "profile" && <ProfilePanel />}
{activeTab === "records" && (
<PlaceholderPanel
description={t("settings.records.description")}
title={t("settings.tabs.records")}
/>
)}
{activeTab === "records" && <RecordsPanel />}
{activeTab === "signing" && <SigningPanel />}
{activeTab === "careTeam" && <CareTeamPanel />}
{activeTab === "developers" && (
<PlaceholderPanel
description={t("settings.developers.description")}
title={t("settings.tabs.developers")}
/>
)}
{activeTab === "developers" && <DevelopersPanel />}
</div>
</div>
);
+50 -3
View File
@@ -697,10 +697,41 @@
"copy": "Copy",
"copied": "Copied",
"records": {
"description": "How patient records are sourced, stored, and displayed"
"description": "How patient records are sourced, stored, and displayed",
"comingSoon": "Coming soon",
"connected": "Connected",
"sourcesTitle": "Data sources",
"sourcesDescription": "Where the patient records shown in temetro come from",
"clinicDb": "Clinic database",
"clinicDbDesc": "Patient records are stored in your clinic's own database and scoped to this clinic.",
"patientStorage": "Patient-owned storage",
"patientStorageDesc": "Read and write records directly on the patient's device once the companion app ships.",
"importExportTitle": "Import & export",
"importExportDescription": "Move records in and out of temetro",
"export": "Export records",
"exportDesc": "Download every patient record in this clinic as a single archive.",
"import": "Import an existing database",
"importDesc": "Connect an existing patient database and browse it in the same card UI.",
"retentionTitle": "Retention",
"retentionDescription": "How long records are kept",
"retentionBody": "Records are kept for as long as your clinic exists. Deleting a patient permanently removes their chart from the clinic database, and the activity log keeps an audit trail of every change — who made it and when."
},
"developers": {
"description": "Access tokens for the temetro API"
"description": "Access tokens for the temetro API",
"comingSoon": "Coming soon",
"apiTitle": "API access",
"apiDescription": "Build on the same REST API the temetro app uses",
"baseUrlLabel": "API base URL",
"baseUrlDescription": "All endpoints live under this origin (e.g. /api/patients)",
"authLabel": "Authentication",
"authDescription": "Requests authenticate with your temetro session cookie. Scoped personal access tokens for server-to-server use are planned.",
"tokensTitle": "Personal access tokens",
"tokensDescription": "Long-lived tokens for scripts and integrations",
"noTokens": "No tokens yet.",
"generateToken": "Generate token",
"resourcesTitle": "Resources",
"resourcesDescription": "Where to learn more",
"resourcesBody": "The API reference — covering patients, appointments, prescriptions, tasks, messaging, activity and analytics — lives in the project documentation under docs/api. temetro is open source, so the route handlers in backend/src/routes are the authoritative spec."
},
"profile": {
"sectionTitle": "Clinician profile",
@@ -776,6 +807,7 @@
"loadError": "Could not load the care team.",
"loading": "Loading care team…",
"you": "(you)",
"clickHint": "Tip: click a team member to see their permissions and manage their role.",
"addMember": "Add team member",
"removeMember": "Remove member",
"employee": {
@@ -855,7 +887,22 @@
"fingerprintDescription": "Share or publish this fingerprint so patients can trust your changes",
"signedRecordsTitle": "Signed records",
"signedRecordsDescription": "Changes you've signed that are waiting on the patient's approval",
"noPending": "No pending signatures"
"noPending": "No pending signatures",
"comingSoon": "Coming soon",
"howItWorksTitle": "How signing works",
"howItWorksDescription": "The flow behind patient-approved records",
"steps": {
"sign": "You sign every change",
"signDesc": "Each edit you make to a patient record is signed with your private key before it's written.",
"approve": "The patient approves it",
"approveDesc": "Signed changes stay pending until the patient accepts them in the companion app.",
"verify": "Anyone can verify",
"verifyDesc": "Your public key fingerprint lets patients and other clinics verify a change really came from you."
},
"backupTitle": "Backup key",
"backupDescription": "Recover your signing identity if you lose this device",
"backupLabel": "Recovery phrase",
"backupDesc": "Export an encrypted backup of your signing key to restore it on a new device."
}
}
}