mirror of
https://github.com/temetro/temetro.git
synced 2026-08-07 09:23:11 +00:00
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:
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user