mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +00:00
frontend: chat/meetings/messages/settings UX fixes
- settings: keep tab nav on its own row so "Developers" no longer wraps - chat: show the "Veil active" chip once per conversation, not every turn - chat: record cards only offer "Click for more" when they have detail - chat: chat-history panel (pill + sheet) top-left of the AI chat with "Start new chat"; rename sidebar "New chat" -> "Ask temetro" (Sparkles) - meetings: disable past dates, add an Upcoming Meetings list, and use the Empty component for empty days; scheduler can be pre-targeted via ?with - messages: add a call button left of each inbox row -> Meetings (?with) - toast: add a dismiss (x) button; call invites now ring 30s with "Accept" Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PanelLeft, Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { type MouseEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetPanel,
|
||||||
|
SheetPopup,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
deleteThread,
|
||||||
|
listThreads,
|
||||||
|
THREADS_CHANGED_EVENT,
|
||||||
|
type ThreadSummary,
|
||||||
|
} from "@/lib/ai-chat-history";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// The pill (panel toggle + search) that sits top-left of the AI chat, next to
|
||||||
|
// the sidebar. Opens a sheet listing saved chats with a "Start new chat" button
|
||||||
|
// — so chat history is reachable from inside the chat, not just the sidebar.
|
||||||
|
export function ChatHistoryPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const activeThread = searchParams.get("thread");
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [threads, setThreads] = useState<ThreadSummary[]>([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
listThreads()
|
||||||
|
.then(setThreads)
|
||||||
|
.catch(() => {
|
||||||
|
/* not signed in / no clinic — show nothing */
|
||||||
|
});
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
window.addEventListener(THREADS_CHANGED_EVENT, refresh);
|
||||||
|
return () => window.removeEventListener(THREADS_CHANGED_EVENT, refresh);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return threads;
|
||||||
|
return threads.filter((x) => x.title.toLowerCase().includes(q));
|
||||||
|
}, [threads, query]);
|
||||||
|
|
||||||
|
const go = (href: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (event: MouseEvent, id: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setThreads((prev) => prev.filter((x) => x.id !== id));
|
||||||
|
await deleteThread(id).catch(() => {
|
||||||
|
/* ignore */
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-0.5 rounded-full border bg-card/40 p-0.5">
|
||||||
|
<button
|
||||||
|
aria-label={t("chat.history.open")}
|
||||||
|
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<PanelLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={t("chat.history.search")}
|
||||||
|
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Search className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetPopup side="left">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{t("chat.history.title")}</SheetTitle>
|
||||||
|
<SheetDescription className="sr-only">
|
||||||
|
{t("chat.history.open")}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<SheetPanel className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
|
<Button className="w-full justify-start" onClick={() => go("/")}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t("chat.history.startNew")}
|
||||||
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="-translate-y-1/2 absolute top-1/2 left-2.5 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-8"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("chat.history.search")}
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
|
||||||
|
{t("chat.history.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((thread) => {
|
||||||
|
const active = activeThread === thread.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||||
|
active
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
key={thread.id}
|
||||||
|
onClick={() => go(`/?thread=${thread.id}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{thread.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-label={t("chat.history.delete")}
|
||||||
|
className="shrink-0 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||||
|
onClick={(event) => remove(event, thread.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetPanel>
|
||||||
|
</SheetPopup>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
import { ActionPreviewCard } from "@/components/chat/action-preview-card";
|
import { ActionPreviewCard } from "@/components/chat/action-preview-card";
|
||||||
import { AnalyticsCard } from "@/components/chat/analytics-card";
|
import { AnalyticsCard } from "@/components/chat/analytics-card";
|
||||||
import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card";
|
import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card";
|
||||||
|
import { ChatHistoryPanel } from "@/components/chat/chat-history-panel";
|
||||||
import { ChatInput } from "@/components/chat/chat-input";
|
import { ChatInput } from "@/components/chat/chat-input";
|
||||||
import { ClinicCard } from "@/components/chat/clinic-card";
|
import { ClinicCard } from "@/components/chat/clinic-card";
|
||||||
import { InventoryListCard } from "@/components/chat/inventory-list-card";
|
import { InventoryListCard } from "@/components/chat/inventory-list-card";
|
||||||
@@ -468,6 +469,12 @@ export function ChatPanel() {
|
|||||||
</Queue>
|
</Queue>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// Veil runs once per conversation, so the "Veil active" chip should only show
|
||||||
|
// on the first assistant message that carries a veilNotice — not every turn.
|
||||||
|
const firstVeilMessageId = messages.find((m) =>
|
||||||
|
m.parts.some((p) => p.type === "data-veilNotice"),
|
||||||
|
)?.id;
|
||||||
|
|
||||||
// Render one assistant/user message: a Chain-of-Thought trace built from any
|
// Render one assistant/user message: a Chain-of-Thought trace built from any
|
||||||
// `data-step` parts, then the rest of the parts (text + record cards) in order.
|
// `data-step` parts, then the rest of the parts (text + record cards) in order.
|
||||||
const renderMessage = (message: TemetroUIMessage, isLast: boolean) => {
|
const renderMessage = (message: TemetroUIMessage, isLast: boolean) => {
|
||||||
@@ -670,6 +677,8 @@ export function ChatPanel() {
|
|||||||
return <AnalyticsCard data={part.data} key={key} />;
|
return <AnalyticsCard data={part.data} key={key} />;
|
||||||
}
|
}
|
||||||
if (part.type === "data-veilNotice") {
|
if (part.type === "data-veilNotice") {
|
||||||
|
// Only the first veilNotice in the whole conversation renders.
|
||||||
|
if (message.id !== firstVeilMessageId) return null;
|
||||||
return (
|
return (
|
||||||
<Badge className="gap-1 self-start" key={key} variant="secondary">
|
<Badge className="gap-1 self-start" key={key} variant="secondary">
|
||||||
<ShieldCheck className="size-3" />
|
<ShieldCheck className="size-3" />
|
||||||
@@ -701,7 +710,11 @@ export function ChatPanel() {
|
|||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-8">
|
<div className="relative flex flex-1 flex-col overflow-y-auto">
|
||||||
|
<div className="flex items-center px-4 pt-3">
|
||||||
|
<ChatHistoryPanel />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center px-4 py-8">
|
||||||
<div className="flex w-full max-w-3xl shrink-0 flex-col items-center gap-10">
|
<div className="flex w-full max-w-3xl shrink-0 flex-col items-center gap-10">
|
||||||
<h1 className="text-center font-semibold text-3xl text-balance tracking-tight sm:text-4xl">
|
<h1 className="text-center font-semibold text-3xl text-balance tracking-tight sm:text-4xl">
|
||||||
{t("chat.heading")}
|
{t("chat.heading")}
|
||||||
@@ -717,12 +730,16 @@ export function ChatPanel() {
|
|||||||
</Suggestions>
|
</Suggestions>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center px-4 pt-3">
|
||||||
|
<ChatHistoryPanel />
|
||||||
|
</div>
|
||||||
<Conversation>
|
<Conversation>
|
||||||
<ConversationContent className="mx-auto w-full max-w-3xl">
|
<ConversationContent className="mx-auto w-full max-w-3xl">
|
||||||
{messages.map((message, i) =>
|
{messages.map((message, i) =>
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
|||||||
const rowCard =
|
const rowCard =
|
||||||
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||||
|
|
||||||
|
// Same footprint as `rowCard` but with no clickable affordance — used when a
|
||||||
|
// card has nothing extra to reveal, so it shouldn't promise "Click for more".
|
||||||
|
const rowCardStatic = "w-72 shrink-0 gap-0 text-left";
|
||||||
|
|
||||||
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
|
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
|
||||||
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
|
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
|
||||||
const compactCard =
|
const compactCard =
|
||||||
@@ -156,18 +160,26 @@ function AlertBadges({ alerts }: { alerts: string[] }) {
|
|||||||
|
|
||||||
// A compact card that previews `children` and opens a roomier dialog of `detail`
|
// A compact card that previews `children` and opens a roomier dialog of `detail`
|
||||||
// on click. A muted "Click for more" footer signals the card is expandable.
|
// on click. A muted "Click for more" footer signals the card is expandable.
|
||||||
|
// When `expandable` is false (the card holds nothing beyond its preview), it
|
||||||
|
// renders as a plain, non-clickable card with no footer — so empty sections
|
||||||
|
// don't misleadingly promise more.
|
||||||
function ExpandableCard({
|
function ExpandableCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
detail,
|
detail,
|
||||||
children,
|
children,
|
||||||
|
expandable = true,
|
||||||
}: {
|
}: {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
detail: ReactNode;
|
detail: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
expandable?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
if (!expandable) {
|
||||||
|
return <Card className={cn(rowCardStatic, compactCard)}>{children}</Card>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
@@ -283,9 +295,18 @@ function VitalsCard({ patient }: { patient: Patient }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasVitals = Boolean(
|
||||||
|
vitals.bp ||
|
||||||
|
vitals.hr ||
|
||||||
|
vitals.temp ||
|
||||||
|
vitals.spo2 ||
|
||||||
|
patient.vitalsTrend.points.length,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableCard
|
<ExpandableCard
|
||||||
description={t("patientCard.vitals.taken", { at: vitals.takenAt })}
|
description={t("patientCard.vitals.taken", { at: vitals.takenAt })}
|
||||||
|
expandable={hasVitals}
|
||||||
detail={
|
detail={
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{vitalsGrid("gap-y-3")}
|
{vitalsGrid("gap-y-3")}
|
||||||
@@ -326,6 +347,7 @@ function LabsCard({ patient }: { patient: Patient }) {
|
|||||||
description={t("patientCard.labs.asOf", {
|
description={t("patientCard.labs.asOf", {
|
||||||
at: patient.labs[0]?.takenAt ?? "—",
|
at: patient.labs[0]?.takenAt ?? "—",
|
||||||
})}
|
})}
|
||||||
|
expandable={patient.labs.length > 0}
|
||||||
detail={
|
detail={
|
||||||
patient.labs.length === 0 ? (
|
patient.labs.length === 0 ? (
|
||||||
<Empty>{t("patientCard.labs.empty")}</Empty>
|
<Empty>{t("patientCard.labs.empty")}</Empty>
|
||||||
@@ -385,6 +407,7 @@ function MedicationsCard({ patient }: { patient: Patient }) {
|
|||||||
description={t("patientCard.medications.active", {
|
description={t("patientCard.medications.active", {
|
||||||
count: patient.medications.length,
|
count: patient.medications.length,
|
||||||
})}
|
})}
|
||||||
|
expandable={patient.medications.length > 0}
|
||||||
detail={list}
|
detail={list}
|
||||||
title={t("patientCard.medications.title")}
|
title={t("patientCard.medications.title")}
|
||||||
>
|
>
|
||||||
@@ -421,6 +444,7 @@ function ProblemsCard({ patient }: { patient: Patient }) {
|
|||||||
description={t("patientCard.problems.active", {
|
description={t("patientCard.problems.active", {
|
||||||
count: patient.problems.length,
|
count: patient.problems.length,
|
||||||
})}
|
})}
|
||||||
|
expandable={patient.problems.length > 0}
|
||||||
detail={list}
|
detail={list}
|
||||||
title={t("patientCard.problems.title")}
|
title={t("patientCard.problems.title")}
|
||||||
>
|
>
|
||||||
@@ -476,6 +500,7 @@ function AllergiesCard({ patient }: { patient: Patient }) {
|
|||||||
return (
|
return (
|
||||||
<ExpandableCard
|
<ExpandableCard
|
||||||
detail={<AllergiesList patient={patient} />}
|
detail={<AllergiesList patient={patient} />}
|
||||||
|
expandable={patient.allergies.length > 0 || patient.alerts.length > 0}
|
||||||
title={t("patientCard.allergies.title")}
|
title={t("patientCard.allergies.title")}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -532,6 +557,7 @@ function VisitsCard({ patient }: { patient: Patient }) {
|
|||||||
description={t("patientCard.visits.recent", {
|
description={t("patientCard.visits.recent", {
|
||||||
count: patient.encounters.length,
|
count: patient.encounters.length,
|
||||||
})}
|
})}
|
||||||
|
expandable={patient.encounters.length > 0}
|
||||||
detail={<VisitsList patient={patient} />}
|
detail={<VisitsList patient={patient} />}
|
||||||
title={t("patientCard.visits.title")}
|
title={t("patientCard.visits.title")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ export function MeetingsView() {
|
|||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const deepLinkRoom = searchParams.get("room");
|
const deepLinkRoom = searchParams.get("room");
|
||||||
|
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
|
||||||
|
// pre-targeted at that person so the user can connect with them.
|
||||||
|
const deepLinkWith = searchParams.get("with");
|
||||||
const openedDeepLink = useRef<string | null>(null);
|
const openedDeepLink = useRef<string | null>(null);
|
||||||
|
const openedWith = useRef<string | null>(null);
|
||||||
|
|
||||||
const [tab, setTab] = useState<Tab>("rooms");
|
const [tab, setTab] = useState<Tab>("rooms");
|
||||||
|
|
||||||
@@ -108,6 +112,14 @@ export function MeetingsView() {
|
|||||||
setActiveRoom(room);
|
setActiveRoom(room);
|
||||||
}, [deepLinkRoom, rooms]);
|
}, [deepLinkRoom, rooms]);
|
||||||
|
|
||||||
|
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deepLinkWith || openedWith.current === deepLinkWith) return;
|
||||||
|
openedWith.current = deepLinkWith;
|
||||||
|
setTab("calendar");
|
||||||
|
setScheduleOpen(true);
|
||||||
|
}, [deepLinkWith]);
|
||||||
|
|
||||||
const createRoom = async (event: FormEvent) => {
|
const createRoom = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
@@ -138,6 +150,23 @@ export function MeetingsView() {
|
|||||||
[events, selectedDay],
|
[events, selectedDay],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Midnight today — used to disable past calendar dates and filter "upcoming".
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
// Next few meetings from today onward, soonest first.
|
||||||
|
const upcoming = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
return events
|
||||||
|
.filter((e) => new Date(`${e.date}T${e.time}`) >= now)
|
||||||
|
.sort((a, b) =>
|
||||||
|
`${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`),
|
||||||
|
)
|
||||||
|
.slice(0, 4);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col gap-3 p-4">
|
<div className="flex h-full w-full flex-col gap-3 p-4">
|
||||||
{/* Header: Rooms / Calendar tabs */}
|
{/* Header: Rooms / Calendar tabs */}
|
||||||
@@ -263,8 +292,9 @@ export function MeetingsView() {
|
|||||||
) : (
|
) : (
|
||||||
// Calendar tab
|
// Calendar tab
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div className="flex shrink-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3">
|
<div className="flex shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border bg-card/30 p-3">
|
||||||
<Calendar
|
<Calendar
|
||||||
|
disabled={{ before: today }}
|
||||||
mode="single"
|
mode="single"
|
||||||
modifiers={{ hasMeeting: meetingDays }}
|
modifiers={{ hasMeeting: meetingDays }}
|
||||||
modifiersClassNames={{
|
modifiersClassNames={{
|
||||||
@@ -279,6 +309,39 @@ export function MeetingsView() {
|
|||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
{t("meetings.schedule.cta")}
|
{t("meetings.schedule.cta")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-col gap-1.5">
|
||||||
|
<span className="px-1 font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
{t("meetings.upcoming.title")}
|
||||||
|
</span>
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<p className="px-1 py-2 text-muted-foreground text-xs">
|
||||||
|
{t("meetings.upcoming.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{upcoming.map((e) => (
|
||||||
|
<button
|
||||||
|
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
|
||||||
|
key={e.id}
|
||||||
|
onClick={() => setSelectedDay(new Date(`${e.date}T00:00:00`))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium text-foreground text-sm">
|
||||||
|
{e.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{new Date(`${e.date}T00:00:00`).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{ month: "short", day: "numeric" },
|
||||||
|
)}{" "}
|
||||||
|
· {e.time}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
||||||
@@ -293,9 +356,27 @@ export function MeetingsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
|
||||||
{dayEvents.length === 0 ? (
|
{dayEvents.length === 0 ? (
|
||||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
|
<Empty className="border-0">
|
||||||
{t("meetings.calendarEmpty")}
|
<EmptyHeader>
|
||||||
</p>
|
<EmptyMedia variant="icon">
|
||||||
|
<CalendarDays />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>{t("meetings.calendarEmpty")}</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
{t("meetings.calendarEmptyHint")}
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button
|
||||||
|
onClick={() => setScheduleOpen(true)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t("meetings.schedule.cta")}
|
||||||
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
dayEvents.map((e) => (
|
dayEvents.map((e) => (
|
||||||
<div
|
<div
|
||||||
@@ -356,6 +437,7 @@ export function MeetingsView() {
|
|||||||
|
|
||||||
<ScheduleMeetingDialog
|
<ScheduleMeetingDialog
|
||||||
defaultDate={keyOf(selectedDay)}
|
defaultDate={keyOf(selectedDay)}
|
||||||
|
defaultParticipants={deepLinkWith ? [deepLinkWith] : undefined}
|
||||||
onCreated={loadEvents}
|
onCreated={loadEvents}
|
||||||
onOpenChange={setScheduleOpen}
|
onOpenChange={setScheduleOpen}
|
||||||
open={scheduleOpen}
|
open={scheduleOpen}
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ export function ScheduleMeetingDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
|
defaultParticipants,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
defaultDate?: string; // YYYY-MM-DD
|
defaultDate?: string; // YYYY-MM-DD
|
||||||
|
defaultParticipants?: string[]; // member ids to preselect
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -57,11 +59,11 @@ export function ScheduleMeetingDialog({
|
|||||||
setTitle("");
|
setTitle("");
|
||||||
setDate(defaultDate ?? "");
|
setDate(defaultDate ?? "");
|
||||||
setTime("09:00");
|
setTime("09:00");
|
||||||
setPicked(new Set());
|
setPicked(new Set(defaultParticipants ?? []));
|
||||||
listClinicMembers()
|
listClinicMembers()
|
||||||
.then(setMembers)
|
.then(setMembers)
|
||||||
.catch(() => setMembers([]));
|
.catch(() => setMembers([]));
|
||||||
}, [open, defaultDate]);
|
}, [open, defaultDate, defaultParticipants]);
|
||||||
|
|
||||||
const toggle = (id: string) =>
|
const toggle = (id: string) =>
|
||||||
setPicked((prev) => {
|
setPicked((prev) => {
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ export function useCallInvites() {
|
|||||||
const onInvite = ({ roomId, roomName, fromName }: CallInvite) => {
|
const onInvite = ({ roomId, roomName, fromName }: CallInvite) => {
|
||||||
toastManager.add({
|
toastManager.add({
|
||||||
type: "info",
|
type: "info",
|
||||||
|
// Ring long enough for the callee to react; the toast's "x" declines it.
|
||||||
|
timeout: 30_000,
|
||||||
title: t("meetings.invite.toastTitle", { name: fromName }),
|
title: t("meetings.invite.toastTitle", { name: fromName }),
|
||||||
description: roomName,
|
description: roomName,
|
||||||
actionProps: {
|
actionProps: {
|
||||||
children: t("meetings.invite.join"),
|
// "Accept" drops the user straight into the caller's room.
|
||||||
|
children: t("meetings.invite.accept"),
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
router.push(
|
router.push(
|
||||||
`/messages/meetings?room=${encodeURIComponent(roomId)}`,
|
`/messages/meetings?room=${encodeURIComponent(roomId)}`,
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
SendHorizonal,
|
SendHorizonal,
|
||||||
|
Video,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
@@ -146,6 +147,8 @@ export function MessagesView() {
|
|||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const myId = session?.user?.id ?? "";
|
const myId = session?.user?.id ?? "";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// Deep link from a notification: /messages?conversation=<id>.
|
// Deep link from a notification: /messages?conversation=<id>.
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const deepLinkConversation = searchParams.get("conversation");
|
const deepLinkConversation = searchParams.get("conversation");
|
||||||
@@ -455,16 +458,36 @@ export function MessagesView() {
|
|||||||
) : (
|
) : (
|
||||||
visible.map((c) => {
|
visible.map((c) => {
|
||||||
const last = c.lastMessage;
|
const last = c.lastMessage;
|
||||||
|
const otherId = c.isGroup
|
||||||
|
? ""
|
||||||
|
: (c.participants.find((p) => p.id !== myId)?.id ?? "");
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent/50",
|
"flex w-full items-center gap-1 rounded-lg pr-2 transition-colors hover:bg-accent/50",
|
||||||
selected?.id === c.id && "bg-accent hover:bg-accent",
|
selected?.id === c.id && "bg-accent hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => open(c.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
aria-label={t("messages.startCall", { name: c.name })}
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
otherId
|
||||||
|
? `/messages/meetings?with=${encodeURIComponent(otherId)}`
|
||||||
|
: "/messages/meetings",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Video className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pr-1 text-left"
|
||||||
|
onClick={() => open(c.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<Avatar className="size-9 shrink-0">
|
<Avatar className="size-9 shrink-0">
|
||||||
<AvatarFallback>{initials(c.name)}</AvatarFallback>
|
<AvatarFallback>{initials(c.name)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -510,7 +533,8 @@ export function MessagesView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ export function SettingsView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-3xl px-6 py-10">
|
<div className="mx-auto w-full max-w-3xl px-6 py-10">
|
||||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
{t(`settings.tabs.${activeTab}`)}
|
{t(`settings.tabs.${activeTab}`)}
|
||||||
</h1>
|
</h1>
|
||||||
<nav className="flex flex-wrap items-center gap-1">
|
<nav className="-mx-1 flex flex-nowrap items-center gap-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{visibleTabs.map((item) => (
|
{visibleTabs.map((item) => (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg px-3 py-1.5 text-sm transition-colors",
|
"shrink-0 rounded-lg px-3 py-1.5 text-sm transition-colors",
|
||||||
activeTab === item.id
|
activeTab === item.id
|
||||||
? "bg-muted text-foreground"
|
? "bg-muted text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -163,14 +164,23 @@ function Toasts({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{toast.actionProps && (
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Toast.Action
|
{toast.actionProps && (
|
||||||
className={buttonVariants({ size: "xs" })}
|
<Toast.Action
|
||||||
data-slot="toast-action"
|
className={buttonVariants({ size: "xs" })}
|
||||||
|
data-slot="toast-action"
|
||||||
|
>
|
||||||
|
{toast.actionProps.children}
|
||||||
|
</Toast.Action>
|
||||||
|
)}
|
||||||
|
<Toast.Close
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
data-slot="toast-close"
|
||||||
>
|
>
|
||||||
{toast.actionProps.children}
|
<XIcon className="size-4" />
|
||||||
</Toast.Action>
|
</Toast.Close>
|
||||||
)}
|
</div>
|
||||||
</Toast.Content>
|
</Toast.Content>
|
||||||
</Toast.Root>
|
</Toast.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"newChat": "New chat",
|
"newChat": "Ask temetro",
|
||||||
"patients": "Patients",
|
"patients": "Patients",
|
||||||
"appointments": "Appointments",
|
"appointments": "Appointments",
|
||||||
"invoices": "Invoices",
|
"invoices": "Invoices",
|
||||||
@@ -749,7 +749,9 @@
|
|||||||
"ring": "Ring",
|
"ring": "Ring",
|
||||||
"invited": "Invited",
|
"invited": "Invited",
|
||||||
"join": "Join",
|
"join": "Join",
|
||||||
"toastTitle": "{{name}} invited you to a call"
|
"toastTitle": "{{name}} invited you to a call",
|
||||||
|
"accept": "Accept",
|
||||||
|
"decline": "Decline"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"cta": "Schedule meeting",
|
"cta": "Schedule meeting",
|
||||||
@@ -764,6 +766,11 @@
|
|||||||
"saving": "Scheduling…",
|
"saving": "Scheduling…",
|
||||||
"failedTitle": "Couldn't schedule meeting",
|
"failedTitle": "Couldn't schedule meeting",
|
||||||
"failedBody": "Please try again."
|
"failedBody": "Please try again."
|
||||||
|
},
|
||||||
|
"calendarEmptyHint": "Schedule a meeting to see it on this day.",
|
||||||
|
"upcoming": {
|
||||||
|
"title": "Upcoming",
|
||||||
|
"empty": "No upcoming meetings."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
@@ -816,7 +823,8 @@
|
|||||||
"apptProvider": "Provider",
|
"apptProvider": "Provider",
|
||||||
"apptPatient": "Patient",
|
"apptPatient": "Patient",
|
||||||
"apptStatus": "Status"
|
"apptStatus": "Status"
|
||||||
}
|
},
|
||||||
|
"startCall": "Start a call with {{name}}"
|
||||||
},
|
},
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
@@ -1020,7 +1028,10 @@
|
|||||||
"title": "Chats",
|
"title": "Chats",
|
||||||
"untitled": "New chat",
|
"untitled": "New chat",
|
||||||
"empty": "No saved chats yet.",
|
"empty": "No saved chats yet.",
|
||||||
"delete": "Delete chat"
|
"delete": "Delete chat",
|
||||||
|
"open": "Chat history",
|
||||||
|
"search": "Search chats",
|
||||||
|
"startNew": "Start new chat"
|
||||||
},
|
},
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"schedule": "Show today's schedule",
|
"schedule": "Show today's schedule",
|
||||||
|
|||||||
+2
-2
@@ -10,9 +10,9 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
Pill,
|
Pill,
|
||||||
Plus,
|
|
||||||
Receipt,
|
Receipt,
|
||||||
Settings,
|
Settings,
|
||||||
|
Sparkles,
|
||||||
Users,
|
Users,
|
||||||
Video,
|
Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -54,7 +54,7 @@ export const navItems: NavItem[] = [
|
|||||||
{
|
{
|
||||||
id: "new-chat",
|
id: "new-chat",
|
||||||
labelKey: "nav.newChat",
|
labelKey: "nav.newChat",
|
||||||
icon: Plus,
|
icon: Sparkles,
|
||||||
link: "/",
|
link: "/",
|
||||||
access: "clinical",
|
access: "clinical",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user