mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
frontend: Obsidian-style record graph + pop-out dialog + records list
The record graph was a cramped React Flow box wedged inside a sheet section. Redesign it Obsidian-style — round nodes with the label underneath, a soft glow, and a hover-focus that highlights a node plus its neighbours while dimming the rest. Move it out of the inline section: the sheet now shows an "Open graph" button (closes the sheet, opens the graph in a large dialog) and a clickable list of the patient's records (problems + visits); clicking a record opens a detail dialog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
// An Obsidian-style knowledge graph of one patient's record: the patient sits
|
||||
// at the centre, their problems (illnesses) and encounters (visits) orbit it,
|
||||
// and a visit links to a problem when the visit references it. Layout is a
|
||||
// d3-force simulation computed once; rendering + pan/zoom/drag is React Flow.
|
||||
// d3-force simulation computed once; rendering + pan/zoom is React Flow. Nodes
|
||||
// are round "dots" with the label underneath, and hovering a node highlights it
|
||||
// and its neighbours while dimming the rest (the Obsidian focus effect).
|
||||
|
||||
import {
|
||||
Background,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
type SimulationLinkDatum,
|
||||
type SimulationNodeDatum,
|
||||
} from "d3-force";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Patient } from "@/lib/patients";
|
||||
@@ -41,29 +43,64 @@ type SimNode = SimulationNodeDatum & {
|
||||
};
|
||||
type SimLink = SimulationLinkDatum<SimNode>;
|
||||
|
||||
type RecordNodeData = { label: string; sub?: string; kind: Kind };
|
||||
|
||||
const kindClass: Record<Kind, string> = {
|
||||
patient: "bg-primary text-primary-foreground border-primary px-4 py-3 text-sm",
|
||||
problem: "bg-destructive/12 text-foreground border-destructive/50",
|
||||
visit: "bg-muted text-foreground border-border",
|
||||
type RecordNodeData = {
|
||||
label: string;
|
||||
sub?: string;
|
||||
kind: Kind;
|
||||
// Hover focus: "active" = this node or a neighbour, "dim" = unrelated, null =
|
||||
// nothing hovered (everything at full strength).
|
||||
focus: "active" | "dim" | null;
|
||||
};
|
||||
|
||||
// A pill node with hidden connection handles so edges meet it cleanly.
|
||||
// Dot size + colour per kind. The patient is the biggest, brightest hub.
|
||||
const dotClass: Record<Kind, string> = {
|
||||
patient: "size-5 bg-primary shadow-[0_0_16px_2px] shadow-primary/40",
|
||||
problem: "size-3.5 bg-destructive shadow-[0_0_12px_1px] shadow-destructive/30",
|
||||
visit: "size-3 bg-foreground/55",
|
||||
};
|
||||
|
||||
// A round node with the label below it and hidden connection handles so edges
|
||||
// meet the dot's centre cleanly.
|
||||
function RecordNode({ data }: NodeProps) {
|
||||
const { label, sub, kind } = data as RecordNodeData;
|
||||
const { label, sub, kind, focus } = data as RecordNodeData;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border px-3 py-2 text-center text-xs shadow-sm",
|
||||
kindClass[kind],
|
||||
kind === "patient" && "font-semibold",
|
||||
"relative flex flex-col items-center transition-opacity duration-200",
|
||||
focus === "dim" ? "opacity-25" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
<Handle className="!opacity-0" position={Position.Top} type="target" />
|
||||
<div className="max-w-36 truncate font-medium">{label}</div>
|
||||
{sub ? <div className="max-w-36 truncate opacity-70">{sub}</div> : null}
|
||||
<Handle className="!opacity-0" position={Position.Bottom} type="source" />
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full ring-1 ring-background/60 transition-transform duration-200",
|
||||
dotClass[kind],
|
||||
focus === "active" && "scale-125",
|
||||
)}
|
||||
>
|
||||
<Handle className="!opacity-0" position={Position.Top} type="target" />
|
||||
<Handle
|
||||
className="!opacity-0"
|
||||
position={Position.Bottom}
|
||||
type="source"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-full mt-1.5 flex max-w-28 flex-col items-center text-center">
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-28 truncate text-[11px] leading-tight",
|
||||
kind === "patient"
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-foreground/90",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{sub ? (
|
||||
<span className="max-w-28 truncate text-[10px] text-muted-foreground">
|
||||
{sub}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,27 +112,30 @@ const nodeTypes = { record: RecordNode };
|
||||
// the clustered "hub" look.
|
||||
function buildGraph(patient: Patient): {
|
||||
nodes: SimNode[];
|
||||
edges: { source: string; target: string }[];
|
||||
edges: { id: string; source: string; target: string }[];
|
||||
} {
|
||||
const nodes: SimNode[] = [
|
||||
{ id: "patient", kind: "patient", label: patient.name },
|
||||
];
|
||||
const edges: { source: string; target: string }[] = [];
|
||||
const edges: { id: string; source: string; target: string }[] = [];
|
||||
let edgeSeq = 0;
|
||||
const link = (source: string, target: string) =>
|
||||
edges.push({ id: `e-${edgeSeq++}`, source, target });
|
||||
|
||||
patient.problems.forEach((p, i) => {
|
||||
const id = `prob-${i}`;
|
||||
nodes.push({ id, kind: "problem", label: p.label, sub: p.since });
|
||||
edges.push({ source: "patient", target: id });
|
||||
link("patient", id);
|
||||
});
|
||||
|
||||
patient.encounters.forEach((e, i) => {
|
||||
const id = `enc-${i}`;
|
||||
nodes.push({ id, kind: "visit", label: e.type, sub: e.date });
|
||||
edges.push({ source: "patient", target: id });
|
||||
link("patient", id);
|
||||
const hay = `${e.type} ${e.summary}`.toLowerCase();
|
||||
patient.problems.forEach((p, pi) => {
|
||||
if (p.label && hay.includes(p.label.toLowerCase())) {
|
||||
edges.push({ source: id, target: `prob-${pi}` });
|
||||
link(id, `prob-${pi}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -104,7 +144,10 @@ function buildGraph(patient: Patient): {
|
||||
}
|
||||
|
||||
// Run the force simulation to completion so positions are stable on first paint.
|
||||
function layout(nodes: SimNode[], edges: { source: string; target: string }[]) {
|
||||
function layout(
|
||||
nodes: SimNode[],
|
||||
edges: { source: string; target: string }[],
|
||||
) {
|
||||
const links: SimLink[] = edges.map((e) => ({ ...e }));
|
||||
forceSimulation(nodes)
|
||||
.force("charge", forceManyBody().strength(-340))
|
||||
@@ -112,11 +155,11 @@ function layout(nodes: SimNode[], edges: { source: string; target: string }[]) {
|
||||
"link",
|
||||
forceLink<SimNode, SimLink>(links)
|
||||
.id((d) => d.id)
|
||||
.distance(96)
|
||||
.strength(0.45),
|
||||
.distance(110)
|
||||
.strength(0.5),
|
||||
)
|
||||
.force("center", forceCenter(240, 170))
|
||||
.force("collide", forceCollide(50))
|
||||
.force("collide", forceCollide(56))
|
||||
.stop()
|
||||
.tick(320);
|
||||
}
|
||||
@@ -129,50 +172,86 @@ export function RecordGraph({
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The node the pointer is over; drives the focus highlight.
|
||||
const [hover, setHover] = useState<string | null>(null);
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
// Stable base layout (positions + adjacency), computed once per patient.
|
||||
const base = useMemo(() => {
|
||||
const g = buildGraph(patient);
|
||||
layout(g.nodes, g.edges);
|
||||
const rfNodes: Node[] = g.nodes.map((n) => ({
|
||||
// Adjacency for the hover focus: a node is "active" when it is hovered or
|
||||
// directly linked to the hovered node.
|
||||
const neighbours = new Map<string, Set<string>>();
|
||||
for (const n of g.nodes) neighbours.set(n.id, new Set([n.id]));
|
||||
for (const e of g.edges) {
|
||||
neighbours.get(e.source)?.add(e.target);
|
||||
neighbours.get(e.target)?.add(e.source);
|
||||
}
|
||||
return { nodes: g.nodes, edges: g.edges, neighbours };
|
||||
}, [patient]);
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
const active = hover ? base.neighbours.get(hover) : null;
|
||||
return base.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "record",
|
||||
position: { x: n.x ?? 0, y: n.y ?? 0 },
|
||||
data: { label: n.label, sub: n.sub, kind: n.kind },
|
||||
data: {
|
||||
label: n.label,
|
||||
sub: n.sub,
|
||||
kind: n.kind,
|
||||
focus: active ? (active.has(n.id) ? "active" : "dim") : null,
|
||||
} satisfies RecordNodeData,
|
||||
}));
|
||||
const rfEdges: Edge[] = g.edges.map((e, i) => ({
|
||||
id: `e-${i}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: { stroke: "var(--border)", strokeWidth: 1.5 },
|
||||
}));
|
||||
return { nodes: rfNodes, edges: rfEdges };
|
||||
}, [patient]);
|
||||
}, [base, hover]);
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
return base.edges.map((e) => {
|
||||
const touches = !hover || e.source === hover || e.target === hover;
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: {
|
||||
stroke: touches && hover ? "var(--primary)" : "var(--border)",
|
||||
strokeWidth: touches && hover ? 1.75 : 1.25,
|
||||
opacity: hover && !touches ? 0.12 : 0.7,
|
||||
transition: "stroke 0.2s, opacity 0.2s",
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [base, hover]);
|
||||
|
||||
if (patient.problems.length === 0 && patient.encounters.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">{t("patientCard.graph.empty")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("patientCard.graph.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-80 w-full overflow-hidden rounded-2xl border bg-card/30",
|
||||
"h-80 w-full overflow-hidden rounded-2xl border bg-card/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactFlow
|
||||
edges={edges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={nodes}
|
||||
nodesConnectable={false}
|
||||
nodesDraggable={false}
|
||||
onNodeMouseEnter={(_, node) => setHover(node.id)}
|
||||
onNodeMouseLeave={() => setHover(null)}
|
||||
panOnScroll
|
||||
proOptions={{ hideAttribution: true }}
|
||||
zoomOnScroll={false}
|
||||
>
|
||||
<Background color="var(--border)" gap={20} />
|
||||
<Background color="var(--border)" gap={22} size={1.5} />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,17 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AiBadge } from "@/components/ai-badge";
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { RecordGraph } from "@/components/graph/record-graph";
|
||||
import { PatientDetail } from "@/components/patients/patient-detail";
|
||||
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetHeader,
|
||||
@@ -73,6 +81,8 @@ export function PatientDetailSheet({
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
// Graph popped out of the sheet into its own dialog (the sheet closes first).
|
||||
const [graphOpen, setGraphOpen] = useState(false);
|
||||
// Related records aggregated into the sheet for a 360° view.
|
||||
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
@@ -161,6 +171,10 @@ export function PatientDetailSheet({
|
||||
setEditKey((k) => k + 1);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
onOpenGraph={() => {
|
||||
onOpenChange(false);
|
||||
setGraphOpen(true);
|
||||
}}
|
||||
onTransfer={
|
||||
canTransfer ? () => setTransferOpen(true) : undefined
|
||||
}
|
||||
@@ -203,6 +217,25 @@ export function PatientDetailSheet({
|
||||
title={t("patients.delete.title")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{patient && (
|
||||
<Dialog onOpenChange={setGraphOpen} open={graphOpen}>
|
||||
<DialogPopup className="flex h-[80dvh] flex-col sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{t("patientCard.graph.title")} · {patient.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("patientCard.graph.hint")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RecordGraph
|
||||
className="h-full min-h-0 flex-1 border-0 bg-transparent"
|
||||
patient={patient}
|
||||
/>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowLeftRight, Network, Pencil, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
import { RecordGraph } from "@/components/graph/record-graph";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { Appointment } from "@/lib/appointments";
|
||||
import {
|
||||
formatMoney,
|
||||
@@ -17,6 +26,16 @@ import {
|
||||
} from "@/lib/invoices";
|
||||
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
|
||||
import type { Prescription } from "@/lib/prescriptions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// A record "file" surfaced both in the graph and the sheet's clickable list.
|
||||
type RecordFile = {
|
||||
id: string;
|
||||
kind: "problem" | "visit";
|
||||
title: string;
|
||||
sub: string;
|
||||
rows: { label: string; value: string }[];
|
||||
};
|
||||
|
||||
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
||||
|
||||
@@ -91,6 +110,7 @@ export function PatientDetail({
|
||||
onEdit,
|
||||
onTransfer,
|
||||
onDelete,
|
||||
onOpenGraph,
|
||||
prescriptions,
|
||||
appointments,
|
||||
invoices,
|
||||
@@ -99,6 +119,8 @@ export function PatientDetail({
|
||||
onEdit?: () => void;
|
||||
onTransfer?: () => void;
|
||||
onDelete?: () => void;
|
||||
// Pops the record graph out into its own dialog (closing this sheet).
|
||||
onOpenGraph?: () => void;
|
||||
prescriptions?: Prescription[];
|
||||
appointments?: Appointment[];
|
||||
invoices?: Invoice[];
|
||||
@@ -106,6 +128,30 @@ export function PatientDetail({
|
||||
const { t } = useTranslation();
|
||||
const sex = t(`patientCard.sex.${patient.sex}`);
|
||||
const idLine = `${patient.age} · ${sex} · MRN ${patient.fileNumber}`;
|
||||
// The record "file" opened in a detail dialog from the records list.
|
||||
const [openFile, setOpenFile] = useState<RecordFile | null>(null);
|
||||
|
||||
// The same problems + visits the graph plots, as a clickable list.
|
||||
const files: RecordFile[] = [
|
||||
...patient.problems.map((p, i) => ({
|
||||
id: `prob-${i}`,
|
||||
kind: "problem" as const,
|
||||
title: p.label,
|
||||
sub: p.since,
|
||||
rows: [{ label: t("patientCard.graph.fields.since"), value: p.since }],
|
||||
})),
|
||||
...patient.encounters.map((e, i) => ({
|
||||
id: `enc-${i}`,
|
||||
kind: "visit" as const,
|
||||
title: e.type,
|
||||
sub: e.date,
|
||||
rows: [
|
||||
{ label: t("patientCard.graph.fields.date"), value: e.date },
|
||||
{ label: t("patientCard.graph.fields.provider"), value: e.provider },
|
||||
{ label: t("patientCard.graph.fields.summary"), value: e.summary },
|
||||
].filter((r) => r.value),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -187,10 +233,56 @@ export function PatientDetail({
|
||||
</Section>
|
||||
|
||||
<Section title={t("patientCard.graph.title")}>
|
||||
<p className="mb-3 text-muted-foreground text-xs">
|
||||
{t("patientCard.graph.hint")}
|
||||
</p>
|
||||
<RecordGraph patient={patient} />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("patientCard.graph.hint")}
|
||||
</p>
|
||||
{onOpenGraph && (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={onOpenGraph}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Network className="size-4" />
|
||||
{t("patientCard.graph.open")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{files.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("patientCard.graph.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-card/30">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
key={file.id}
|
||||
onClick={() => setOpenFile(file)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2.5 shrink-0 rounded-full",
|
||||
file.kind === "problem"
|
||||
? "bg-destructive"
|
||||
: "bg-foreground/55",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
||||
{file.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{file.sub}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("patientCard.vitals.title")}>
|
||||
@@ -416,6 +508,49 @@ export function PatientDetail({
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setOpenFile(null);
|
||||
}}
|
||||
open={openFile !== null}
|
||||
>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2.5 shrink-0 rounded-full",
|
||||
openFile?.kind === "problem"
|
||||
? "bg-destructive"
|
||||
: "bg-foreground/55",
|
||||
)}
|
||||
/>
|
||||
{openFile?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{openFile
|
||||
? t(`patientCard.graph.kind.${openFile.kind}`)
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-3">
|
||||
{openFile?.rows.map((row) => (
|
||||
<div className="flex flex-col gap-0.5" key={row.label}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="text-foreground text-sm">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</DialogPanel>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("patientCard.graph.close")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1111,8 +1111,20 @@
|
||||
},
|
||||
"graph": {
|
||||
"title": "Record graph",
|
||||
"hint": "How this patient's problems and visits connect. Drag nodes; scroll to pan.",
|
||||
"empty": "Not enough record data to graph yet."
|
||||
"hint": "Hover a node to focus its connections. Scroll to pan, pinch to zoom.",
|
||||
"empty": "Not enough record data to graph yet.",
|
||||
"open": "Open graph",
|
||||
"close": "Close",
|
||||
"kind": {
|
||||
"problem": "Problem",
|
||||
"visit": "Visit"
|
||||
},
|
||||
"fields": {
|
||||
"since": "Since",
|
||||
"date": "Date",
|
||||
"provider": "Provider",
|
||||
"summary": "Summary"
|
||||
}
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments",
|
||||
|
||||
Reference in New Issue
Block a user