mirror of
https://github.com/temetro/temetro.git
synced 2026-08-05 16:37:42 +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 { AnalyticsCard } from "@/components/chat/analytics-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 { ClinicCard } from "@/components/chat/clinic-card";
|
||||
import { InventoryListCard } from "@/components/chat/inventory-list-card";
|
||||
@@ -468,6 +469,12 @@ export function ChatPanel() {
|
||||
</Queue>
|
||||
) : 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
|
||||
// `data-step` parts, then the rest of the parts (text + record cards) in order.
|
||||
const renderMessage = (message: TemetroUIMessage, isLast: boolean) => {
|
||||
@@ -670,6 +677,8 @@ export function ChatPanel() {
|
||||
return <AnalyticsCard data={part.data} key={key} />;
|
||||
}
|
||||
if (part.type === "data-veilNotice") {
|
||||
// Only the first veilNotice in the whole conversation renders.
|
||||
if (message.id !== firstVeilMessageId) return null;
|
||||
return (
|
||||
<Badge className="gap-1 self-start" key={key} variant="secondary">
|
||||
<ShieldCheck className="size-3" />
|
||||
@@ -701,7 +710,11 @@ export function ChatPanel() {
|
||||
|
||||
if (messages.length === 0) {
|
||||
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">
|
||||
<h1 className="text-center font-semibold text-3xl text-balance tracking-tight sm:text-4xl">
|
||||
{t("chat.heading")}
|
||||
@@ -717,12 +730,16 @@ export function ChatPanel() {
|
||||
</Suggestions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center px-4 pt-3">
|
||||
<ChatHistoryPanel />
|
||||
</div>
|
||||
<Conversation>
|
||||
<ConversationContent className="mx-auto w-full max-w-3xl">
|
||||
{messages.map((message, i) =>
|
||||
|
||||
@@ -66,6 +66,10 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
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";
|
||||
|
||||
// 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
|
||||
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
|
||||
const compactCard =
|
||||
@@ -156,18 +160,26 @@ function AlertBadges({ alerts }: { alerts: string[] }) {
|
||||
|
||||
// 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.
|
||||
// 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({
|
||||
title,
|
||||
description,
|
||||
detail,
|
||||
children,
|
||||
expandable = true,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
detail: ReactNode;
|
||||
children: ReactNode;
|
||||
expandable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!expandable) {
|
||||
return <Card className={cn(rowCardStatic, compactCard)}>{children}</Card>;
|
||||
}
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
@@ -283,9 +295,18 @@ function VitalsCard({ patient }: { patient: Patient }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const hasVitals = Boolean(
|
||||
vitals.bp ||
|
||||
vitals.hr ||
|
||||
vitals.temp ||
|
||||
vitals.spo2 ||
|
||||
patient.vitalsTrend.points.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={t("patientCard.vitals.taken", { at: vitals.takenAt })}
|
||||
expandable={hasVitals}
|
||||
detail={
|
||||
<div className="flex flex-col gap-4">
|
||||
{vitalsGrid("gap-y-3")}
|
||||
@@ -326,6 +347,7 @@ function LabsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.labs.asOf", {
|
||||
at: patient.labs[0]?.takenAt ?? "—",
|
||||
})}
|
||||
expandable={patient.labs.length > 0}
|
||||
detail={
|
||||
patient.labs.length === 0 ? (
|
||||
<Empty>{t("patientCard.labs.empty")}</Empty>
|
||||
@@ -385,6 +407,7 @@ function MedicationsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.medications.active", {
|
||||
count: patient.medications.length,
|
||||
})}
|
||||
expandable={patient.medications.length > 0}
|
||||
detail={list}
|
||||
title={t("patientCard.medications.title")}
|
||||
>
|
||||
@@ -421,6 +444,7 @@ function ProblemsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.problems.active", {
|
||||
count: patient.problems.length,
|
||||
})}
|
||||
expandable={patient.problems.length > 0}
|
||||
detail={list}
|
||||
title={t("patientCard.problems.title")}
|
||||
>
|
||||
@@ -476,6 +500,7 @@ function AllergiesCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<ExpandableCard
|
||||
detail={<AllergiesList patient={patient} />}
|
||||
expandable={patient.allergies.length > 0 || patient.alerts.length > 0}
|
||||
title={t("patientCard.allergies.title")}
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -532,6 +557,7 @@ function VisitsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.visits.recent", {
|
||||
count: patient.encounters.length,
|
||||
})}
|
||||
expandable={patient.encounters.length > 0}
|
||||
detail={<VisitsList patient={patient} />}
|
||||
title={t("patientCard.visits.title")}
|
||||
>
|
||||
|
||||
@@ -55,7 +55,11 @@ export function MeetingsView() {
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
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 openedWith = useRef<string | null>(null);
|
||||
|
||||
const [tab, setTab] = useState<Tab>("rooms");
|
||||
|
||||
@@ -108,6 +112,14 @@ export function MeetingsView() {
|
||||
setActiveRoom(room);
|
||||
}, [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) => {
|
||||
event.preventDefault();
|
||||
const name = newName.trim();
|
||||
@@ -138,6 +150,23 @@ export function MeetingsView() {
|
||||
[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 (
|
||||
<div className="flex h-full w-full flex-col gap-3 p-4">
|
||||
{/* Header: Rooms / Calendar tabs */}
|
||||
@@ -263,8 +292,9 @@ export function MeetingsView() {
|
||||
) : (
|
||||
// Calendar tab
|
||||
<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
|
||||
disabled={{ before: today }}
|
||||
mode="single"
|
||||
modifiers={{ hasMeeting: meetingDays }}
|
||||
modifiersClassNames={{
|
||||
@@ -279,6 +309,39 @@ export function MeetingsView() {
|
||||
<Plus className="size-4" />
|
||||
{t("meetings.schedule.cta")}
|
||||
</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 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 className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
|
||||
{dayEvents.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
|
||||
{t("meetings.calendarEmpty")}
|
||||
</p>
|
||||
<Empty className="border-0">
|
||||
<EmptyHeader>
|
||||
<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) => (
|
||||
<div
|
||||
@@ -356,6 +437,7 @@ export function MeetingsView() {
|
||||
|
||||
<ScheduleMeetingDialog
|
||||
defaultDate={keyOf(selectedDay)}
|
||||
defaultParticipants={deepLinkWith ? [deepLinkWith] : undefined}
|
||||
onCreated={loadEvents}
|
||||
onOpenChange={setScheduleOpen}
|
||||
open={scheduleOpen}
|
||||
|
||||
@@ -34,11 +34,13 @@ export function ScheduleMeetingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultDate,
|
||||
defaultParticipants,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultDate?: string; // YYYY-MM-DD
|
||||
defaultParticipants?: string[]; // member ids to preselect
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -57,11 +59,11 @@ export function ScheduleMeetingDialog({
|
||||
setTitle("");
|
||||
setDate(defaultDate ?? "");
|
||||
setTime("09:00");
|
||||
setPicked(new Set());
|
||||
setPicked(new Set(defaultParticipants ?? []));
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
}, [open, defaultDate]);
|
||||
}, [open, defaultDate, defaultParticipants]);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
|
||||
@@ -21,10 +21,13 @@ export function useCallInvites() {
|
||||
const onInvite = ({ roomId, roomName, fromName }: CallInvite) => {
|
||||
toastManager.add({
|
||||
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 }),
|
||||
description: roomName,
|
||||
actionProps: {
|
||||
children: t("meetings.invite.join"),
|
||||
// "Accept" drops the user straight into the caller's room.
|
||||
children: t("meetings.invite.accept"),
|
||||
onClick: () =>
|
||||
router.push(
|
||||
`/messages/meetings?room=${encodeURIComponent(roomId)}`,
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
SendHorizonal,
|
||||
Video,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
@@ -146,6 +147,8 @@ export function MessagesView() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Deep link from a notification: /messages?conversation=<id>.
|
||||
const searchParams = useSearchParams();
|
||||
const deepLinkConversation = searchParams.get("conversation");
|
||||
@@ -455,16 +458,36 @@ export function MessagesView() {
|
||||
) : (
|
||||
visible.map((c) => {
|
||||
const last = c.lastMessage;
|
||||
const otherId = c.isGroup
|
||||
? ""
|
||||
: (c.participants.find((p) => p.id !== myId)?.id ?? "");
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
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",
|
||||
)}
|
||||
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">
|
||||
<AvatarFallback>{initials(c.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -510,7 +533,8 @@ export function MessagesView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -43,15 +43,15 @@ export function SettingsView() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{t(`settings.tabs.${activeTab}`)}
|
||||
</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) => (
|
||||
<button
|
||||
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
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
InfoIcon,
|
||||
LoaderCircleIcon,
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -163,14 +164,23 @@ function Toasts({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{toast.actionProps && (
|
||||
<Toast.Action
|
||||
className={buttonVariants({ size: "xs" })}
|
||||
data-slot="toast-action"
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{toast.actionProps && (
|
||||
<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}
|
||||
</Toast.Action>
|
||||
)}
|
||||
<XIcon className="size-4" />
|
||||
</Toast.Close>
|
||||
</div>
|
||||
</Toast.Content>
|
||||
</Toast.Root>
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"newChat": "New chat",
|
||||
"newChat": "Ask temetro",
|
||||
"patients": "Patients",
|
||||
"appointments": "Appointments",
|
||||
"invoices": "Invoices",
|
||||
@@ -749,7 +749,9 @@
|
||||
"ring": "Ring",
|
||||
"invited": "Invited",
|
||||
"join": "Join",
|
||||
"toastTitle": "{{name}} invited you to a call"
|
||||
"toastTitle": "{{name}} invited you to a call",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline"
|
||||
},
|
||||
"schedule": {
|
||||
"cta": "Schedule meeting",
|
||||
@@ -764,6 +766,11 @@
|
||||
"saving": "Scheduling…",
|
||||
"failedTitle": "Couldn't schedule meeting",
|
||||
"failedBody": "Please try again."
|
||||
},
|
||||
"calendarEmptyHint": "Schedule a meeting to see it on this day.",
|
||||
"upcoming": {
|
||||
"title": "Upcoming",
|
||||
"empty": "No upcoming meetings."
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -816,7 +823,8 @@
|
||||
"apptProvider": "Provider",
|
||||
"apptPatient": "Patient",
|
||||
"apptStatus": "Status"
|
||||
}
|
||||
},
|
||||
"startCall": "Start a call with {{name}}"
|
||||
},
|
||||
"analysis": {
|
||||
"title": "Overview",
|
||||
@@ -1020,7 +1028,10 @@
|
||||
"title": "Chats",
|
||||
"untitled": "New chat",
|
||||
"empty": "No saved chats yet.",
|
||||
"delete": "Delete chat"
|
||||
"delete": "Delete chat",
|
||||
"open": "Chat history",
|
||||
"search": "Search chats",
|
||||
"startNew": "Start new chat"
|
||||
},
|
||||
"suggestions": {
|
||||
"schedule": "Show today's schedule",
|
||||
|
||||
+2
-2
@@ -10,9 +10,9 @@ import {
|
||||
Mail,
|
||||
NotebookPen,
|
||||
Pill,
|
||||
Plus,
|
||||
Receipt,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Users,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
@@ -54,7 +54,7 @@ export const navItems: NavItem[] = [
|
||||
{
|
||||
id: "new-chat",
|
||||
labelKey: "nav.newChat",
|
||||
icon: Plus,
|
||||
icon: Sparkles,
|
||||
link: "/",
|
||||
access: "clinical",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user