Files
temetro/frontend/components/messages/messages-view.tsx
T
Khalid Abdi 00152eb3cb Redesign Calendar + Messages, fix favicon, add date picker, Tasks & Activity
- Appointments now carry a date; the Calendar button opens a Google-Calendar
  style month grid (color-coded event chips, month nav, Today, click a day for
  its list). Today/Upcoming sections derive from dates.
- Add-appointment dialog gains a date picker (Popover + Calendar).
- Messages rebuilt as chat threads: an input-based composer that appends to a
  two-way timeline (no more toast covering the field), and the unread count is
  now a filter toggle.
- Favicon: the brand logo now shows in the tab. Removed the stale default
  app/favicon.ico (which shadowed it) and added app/icon.png + app/apple-icon.png
  generated from the logo; dropped the redundant metadata.icons.
- Sidebar: swapped Notes <-> Messages order (both kept).
- New pages: Activity (signed-change audit timeline with hash chips + approval
  status) and Tasks (two-pane care-team to-do with add dialog).
- Prescriptions: adding a medication now surfaces mock drug-interaction and
  allergy warnings against the patient's record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:18:48 +03:00

316 lines
9.4 KiB
TypeScript

"use client";
import { Mail, SendHorizonal } from "lucide-react";
import { type FormEvent, useMemo, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
// All conversations here are mock/placeholder data — there is no messaging
// backend. They illustrate the inbox + chat-thread timeline layout.
type ChatMessage = {
id: string;
direction: "in" | "out";
text: string;
time: string;
};
type Conversation = {
id: string;
name: string;
initials: string;
role: string;
unread: boolean;
messages: ChatMessage[];
};
const seed: Conversation[] = [
{
id: "1",
name: "Dr. Stein",
initials: "DS",
role: "Endocrinology",
unread: true,
messages: [
{
id: "1a",
direction: "in",
text: "The lab results for Amina Yusuf are back — lipid panel and HbA1c are both in range.",
time: "10:24",
},
{
id: "1b",
direction: "in",
text: "Want me to adjust her plan before the follow-up?",
time: "10:25",
},
],
},
{
id: "2",
name: "Dr. Okafor",
initials: "DO",
role: "Family medicine",
unread: true,
messages: [
{
id: "2a",
direction: "out",
text: "Sent over Daniel Mensah's intake notes — can you take a look?",
time: "08:50",
},
{
id: "2b",
direction: "in",
text: "Thanks! Added him to tomorrow at 10:00. Were his prior records imported?",
time: "09:12",
},
],
},
{
id: "3",
name: "Care team",
initials: "CT",
role: "Clinic-wide",
unread: false,
messages: [
{
id: "3a",
direction: "in",
text: "Seasonal vaccine stock has been replenished — slots are open all week.",
time: "Yesterday",
},
{
id: "3b",
direction: "out",
text: "Great, I'll direct eligible patients to the front desk.",
time: "Yesterday",
},
],
},
{
id: "4",
name: "Reception",
initials: "RC",
role: "Front desk",
unread: false,
messages: [
{
id: "4a",
direction: "in",
text: "Two Friday afternoon appointments were moved to next Monday at the patients' request.",
time: "Mon",
},
],
},
];
const now = () =>
new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export function MessagesView() {
const [conversations, setConversations] = useState<Conversation[]>(seed);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [draft, setDraft] = useState("");
const unreadCount = conversations.filter((c) => c.unread).length;
const selected = conversations.find((c) => c.id === selectedId) ?? null;
const visible = useMemo(
() => (showUnreadOnly ? conversations.filter((c) => c.unread) : conversations),
[conversations, showUnreadOnly],
);
const open = (id: string) => {
setSelectedId(id);
setDraft("");
setConversations((prev) =>
prev.map((c) => (c.id === id ? { ...c, unread: false } : c)),
);
};
const send = (event: FormEvent) => {
event.preventDefault();
const text = draft.trim();
if (!(text && selected)) return;
const message: ChatMessage = {
id: `${selected.id}-${Date.now()}`,
direction: "out",
text,
time: now(),
};
setConversations((prev) =>
prev.map((c) =>
c.id === selected.id
? { ...c, messages: [...c.messages, message] }
: c,
),
);
setDraft("");
};
return (
<div className="flex h-full w-full gap-4 p-4">
{/* Left: conversation list */}
<aside className="flex w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
<h1 className="font-semibold text-base tracking-tight">Inbox</h1>
<Button
aria-pressed={showUnreadOnly}
onClick={() => setShowUnreadOnly((v) => !v)}
size="sm"
type="button"
variant={showUnreadOnly ? "secondary" : "ghost"}
>
Unread · {unreadCount}
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{visible.length === 0 ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
No unread messages.
</p>
) : (
visible.map((c) => {
const last = c.messages.at(-1);
return (
<button
className={cn(
"flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent",
selected?.id === c.id && "bg-accent",
)}
key={c.id}
onClick={() => open(c.id)}
type="button"
>
<div className="flex w-full items-center gap-2">
{c.unread && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
<span
className={cn(
"min-w-0 flex-1 truncate text-sm",
c.unread
? "font-semibold text-foreground"
: "font-medium text-foreground",
)}
>
{c.name}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{last?.time}
</span>
</div>
<span className="w-full truncate text-muted-foreground text-xs">
{last?.direction === "out" && "You: "}
{last?.text}
</span>
</button>
);
})
)}
</div>
</aside>
{/* Right: conversation timeline or empty state */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{selected ? (
<div className="flex h-full flex-col gap-4">
<div className="flex items-center gap-3 rounded-2xl border bg-card/30 p-4">
<Avatar className="size-9">
<AvatarFallback>{selected.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
{selected.role}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
<div className="flex flex-col gap-3">
{selected.messages.map((m) => (
<div
className={cn(
"flex flex-col gap-1",
m.direction === "out" ? "items-end" : "items-start",
)}
key={m.id}
>
<div
className={cn(
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
m.direction === "out"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
>
{m.text}
</div>
<span className="px-1 text-muted-foreground text-[11px]">
{m.time}
</span>
</div>
))}
</div>
</div>
<form
className="flex items-center gap-2 rounded-2xl border bg-card/30 p-2"
onSubmit={send}
>
<Input
aria-label="Message"
className="border-0 bg-transparent shadow-none before:hidden"
onChange={(e) => setDraft(e.target.value)}
placeholder={`Message ${selected.name}`}
value={draft}
/>
<Button
aria-label="Send"
disabled={!draft.trim()}
size="icon"
type="submit"
>
<SendHorizonal className="size-4" />
</Button>
</form>
</div>
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Mail />
</EmptyMedia>
<EmptyTitle>No conversation selected</EmptyTitle>
<EmptyDescription>
Choose a conversation from the inbox to read and reply.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)}
</div>
</div>
);
}