mirror of
https://github.com/temetro/temetro.git
synced 2026-08-26 10:27:10 +00:00
backend: add per-clinic staff specialty (Care Team -> patient sheet)
New staff_profile table (org + user, unique) holding a clinician's clinical specialty. GET /api/staff and /api/staff/providers now include specialty; new PATCH /api/staff/:userId (member:update) upserts it. Migration 0023. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -626,11 +626,11 @@ export function MessagesView() {
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-2 rounded-2xl border bg-card/30 p-2"
|
||||
className="flex flex-col rounded-3xl border border-input bg-background shadow-sm transition-colors focus-within:border-ring/60 focus-within:ring-2 focus-within:ring-ring/20"
|
||||
onSubmit={send}
|
||||
>
|
||||
{pending.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-3">
|
||||
{pending.map((att, i) => (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-foreground text-xs"
|
||||
@@ -658,52 +658,64 @@ export function MessagesView() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The attach control is a native <label> wrapping the file
|
||||
input, so the browser opens the picker on a trusted click.
|
||||
A programmatic `inputRef.click()` (the old menu item) gets
|
||||
dropped by user-activation gating once the menu closes —
|
||||
which is why attaching silently failed and no chip appeared. */}
|
||||
<label
|
||||
aria-label={t("messages.attach.file")}
|
||||
className={cn(
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
uploading && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
aria-label={t("messages.attach.appointment")}
|
||||
disabled={uploading}
|
||||
onClick={openApptPicker}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CalendarClock className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={
|
||||
uploading
|
||||
? t("messages.attach.uploading")
|
||||
: t("messages.messagePlaceholder", { name: selected.name })
|
||||
<textarea
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="field-sizing-content block max-h-40 min-h-11 w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends; Shift+Enter inserts a newline.
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
value={draft}
|
||||
/>
|
||||
}}
|
||||
placeholder={
|
||||
uploading
|
||||
? t("messages.attach.uploading")
|
||||
: t("messages.messagePlaceholder", { name: selected.name })
|
||||
}
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-2 pb-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* The attach control is a native <label> wrapping the file
|
||||
input, so the browser opens the picker on a trusted click.
|
||||
A programmatic `inputRef.click()` (the old menu item) gets
|
||||
dropped by user-activation gating once the menu closes —
|
||||
which is why attaching silently failed and no chip appeared. */}
|
||||
<label
|
||||
aria-label={t("messages.attach.file")}
|
||||
className={cn(
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
uploading && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
aria-label={t("messages.attach.appointment")}
|
||||
className="rounded-full"
|
||||
disabled={uploading}
|
||||
onClick={openApptPicker}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CalendarClock className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={t("messages.send")}
|
||||
className="size-9 shrink-0 rounded-full"
|
||||
disabled={!draft.trim() && pending.length === 0}
|
||||
size="icon"
|
||||
type="submit"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRight, Network, Pencil, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/invoices";
|
||||
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
|
||||
import type { Prescription } from "@/lib/prescriptions";
|
||||
import { listProviders, type Provider, specialtyLabel } from "@/lib/staff";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// A record "file" surfaced both in the graph and the sheet's clickable list.
|
||||
@@ -132,6 +133,24 @@ export function PatientDetail({
|
||||
// The record "file" opened in a detail dialog from the records list.
|
||||
const [openFile, setOpenFile] = useState<RecordFile | null>(null);
|
||||
|
||||
// Resolve the responsible clinician's specialty (set by an admin in Care
|
||||
// Team) to show alongside the primary-care provider.
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
useEffect(() => {
|
||||
if (!patient.primaryProviderId) return;
|
||||
let active = true;
|
||||
listProviders()
|
||||
.then((p) => active && setProviders(p))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [patient.primaryProviderId]);
|
||||
const providerSpecialty = specialtyLabel(
|
||||
t,
|
||||
providers.find((p) => p.userId === patient.primaryProviderId)?.specialty,
|
||||
);
|
||||
|
||||
// The same problems + visits the graph plots, as a clickable list.
|
||||
const files: RecordFile[] = [
|
||||
...patient.problems.map((p, i) => ({
|
||||
@@ -216,7 +235,9 @@ export function PatientDetail({
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<Stat
|
||||
label={t("patientCard.summary.primaryCare")}
|
||||
value={patient.pcp}
|
||||
value={
|
||||
providerSpecialty ? `${patient.pcp} · ${providerSpecialty}` : patient.pcp
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.lastSeen")}
|
||||
|
||||
@@ -34,8 +34,12 @@ import {
|
||||
import { ROLE_LABELS } from "@/lib/access";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { PROVISIONABLE_ROLES, rolePermissionSummary } from "@/lib/roles";
|
||||
import { SPECIALTIES, specialtyLabel, updateStaffSpecialty } from "@/lib/staff";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
// Roles that can carry a clinical specialty (i.e. treat patients).
|
||||
const PROVIDER_ROLES = new Set(["owner", "admin", "doctor", "member"]);
|
||||
|
||||
// Icon shown next to each permission resource row.
|
||||
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
patient: <Users className="size-4" />,
|
||||
@@ -53,6 +57,7 @@ export type StaffMember = {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
username: string | null;
|
||||
specialty: string | null;
|
||||
};
|
||||
|
||||
function roleLabel(role?: string | null): string {
|
||||
@@ -98,10 +103,13 @@ export function EmployeeDetailDialog({
|
||||
const { t } = useTranslation();
|
||||
const [role, setRole] = useState<string>(member?.role ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [specialty, setSpecialty] = useState<string>(member?.specialty ?? "");
|
||||
const [savingSpecialty, setSavingSpecialty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRole(member?.role ?? "");
|
||||
}, [member?.id, member?.role]);
|
||||
setSpecialty(member?.specialty ?? "");
|
||||
}, [member?.id, member?.role, member?.specialty]);
|
||||
|
||||
const summary = rolePermissionSummary(member?.role);
|
||||
const secondary = member?.username ? `@${member.username}` : member?.email;
|
||||
@@ -137,6 +145,39 @@ export function EmployeeDetailDialog({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const saveSpecialty = async () => {
|
||||
if (!member || savingSpecialty) return;
|
||||
const next = specialty || null;
|
||||
if (next === (member.specialty ?? null)) return;
|
||||
setSavingSpecialty(true);
|
||||
try {
|
||||
await updateStaffSpecialty(member.userId, next);
|
||||
notify.success(
|
||||
t("settings.careTeam.employee.specialtyUpdatedTitle"),
|
||||
t("settings.careTeam.employee.specialtyUpdatedBody", {
|
||||
name: member.name ?? member.email ?? "",
|
||||
}),
|
||||
);
|
||||
onChanged();
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.careTeam.employee.specialtyFailedTitle"),
|
||||
t("settings.careTeam.employee.specialtyFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSavingSpecialty(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showSpecialty = PROVIDER_ROLES.has(member?.role ?? "");
|
||||
const specialtyOptions = [
|
||||
{ value: "", label: t("settings.careTeam.employee.noSpecialty") },
|
||||
...SPECIALTIES.map((s) => ({
|
||||
value: s,
|
||||
label: t(`settings.careTeam.specialties.${s}`),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
@@ -163,6 +204,11 @@ export function EmployeeDetailDialog({
|
||||
{secondary}
|
||||
</p>
|
||||
)}
|
||||
{showSpecialty && specialtyLabel(t, member?.specialty) && (
|
||||
<p className="truncate text-primary text-xs">
|
||||
{specialtyLabel(t, member?.specialty)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{roleLabel(member?.role)}
|
||||
@@ -246,6 +292,47 @@ export function EmployeeDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && showSpecialty && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("settings.careTeam.employee.specialty")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
items={specialtyOptions}
|
||||
onValueChange={(value) => setSpecialty(value as string)}
|
||||
value={specialty}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("settings.careTeam.employee.specialty")}
|
||||
className="flex-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{specialtyOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={
|
||||
savingSpecialty ||
|
||||
(specialty || null) === (member?.specialty ?? null)
|
||||
}
|
||||
onClick={saveSpecialty}
|
||||
type="button"
|
||||
>
|
||||
{savingSpecialty
|
||||
? t("settings.careTeam.employee.saving")
|
||||
: t("settings.careTeam.employee.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EmployeeDetailDialog,
|
||||
type StaffMember,
|
||||
} from "@/components/settings/employee-detail-dialog";
|
||||
import { specialtyLabel } from "@/lib/staff";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
@@ -162,6 +163,11 @@ export function CareTeamPanel() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{specialtyLabel(t, m.specialty) && (
|
||||
<Badge variant="outline">
|
||||
{specialtyLabel(t, m.specialty)}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{roleLabel(m.role)}
|
||||
</Badge>
|
||||
|
||||
@@ -1506,6 +1506,22 @@
|
||||
"clickHint": "Tip: click a team member to see their permissions and manage their role.",
|
||||
"addMember": "Add team member",
|
||||
"removeMember": "Remove member",
|
||||
"specialties": {
|
||||
"general": "General practitioner",
|
||||
"orthopedics": "Orthopedist",
|
||||
"dentistry": "Dentist",
|
||||
"cardiology": "Cardiologist",
|
||||
"pediatrics": "Pediatrician",
|
||||
"dermatology": "Dermatologist",
|
||||
"neurology": "Neurologist",
|
||||
"obgyn": "OB-GYN",
|
||||
"ophthalmology": "Ophthalmologist",
|
||||
"ent": "ENT specialist",
|
||||
"psychiatry": "Psychiatrist",
|
||||
"radiology": "Radiologist",
|
||||
"anesthesiology": "Anesthesiologist",
|
||||
"surgery": "Surgeon"
|
||||
},
|
||||
"employee": {
|
||||
"title": "Team member",
|
||||
"description": "View this member's access, change their role, or remove them.",
|
||||
@@ -1520,6 +1536,12 @@
|
||||
"roleUpdatedBody": "{{name}} is now {{role}}.",
|
||||
"roleFailedTitle": "Couldn't update role",
|
||||
"roleFailedBody": "Please try again.",
|
||||
"specialty": "Specialty",
|
||||
"noSpecialty": "No specialty",
|
||||
"specialtyUpdatedTitle": "Specialty updated",
|
||||
"specialtyUpdatedBody": "Updated {{name}}'s specialty.",
|
||||
"specialtyFailedTitle": "Couldn't update specialty",
|
||||
"specialtyFailedBody": "Please try again.",
|
||||
"resources": {
|
||||
"patient": "Patients",
|
||||
"appointment": "Appointments",
|
||||
|
||||
@@ -7,8 +7,54 @@ export type Provider = {
|
||||
userId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
// Admin-set clinical specialty (e.g. "Orthopedist"); null when unset.
|
||||
specialty?: string | null;
|
||||
};
|
||||
|
||||
export function listProviders(): Promise<Provider[]> {
|
||||
return apiFetch<Provider[]>("/api/staff/providers");
|
||||
}
|
||||
|
||||
// Update a member's clinical specialty (owner/admin only). Pass null to clear.
|
||||
export function updateStaffSpecialty(
|
||||
userId: string,
|
||||
specialty: string | null,
|
||||
): Promise<{ userId: string; specialty: string | null }> {
|
||||
return apiFetch(`/api/staff/${encodeURIComponent(userId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ specialty }),
|
||||
});
|
||||
}
|
||||
|
||||
// Curated list of clinical specialties an admin can assign to a doctor. Stored
|
||||
// as the plain string; values double as the i18n key suffix
|
||||
// (settings.careTeam.specialties.*).
|
||||
// Human label for a stored specialty key (settings.careTeam.specialties.*),
|
||||
// falling back to the raw value for any legacy/free-text entries.
|
||||
export function specialtyLabel(
|
||||
t: (key: string) => string,
|
||||
specialty?: string | null,
|
||||
): string | null {
|
||||
if (!specialty) return null;
|
||||
const key = `settings.careTeam.specialties.${specialty}`;
|
||||
const label = t(key);
|
||||
return label === key ? specialty : label;
|
||||
}
|
||||
|
||||
export const SPECIALTIES = [
|
||||
"general",
|
||||
"orthopedics",
|
||||
"dentistry",
|
||||
"cardiology",
|
||||
"pediatrics",
|
||||
"dermatology",
|
||||
"neurology",
|
||||
"obgyn",
|
||||
"ophthalmology",
|
||||
"ent",
|
||||
"psychiatry",
|
||||
"radiology",
|
||||
"anesthesiology",
|
||||
"surgery",
|
||||
] as const;
|
||||
export type Specialty = (typeof SPECIALTIES)[number];
|
||||
|
||||
Reference in New Issue
Block a user