frontend: fix message file attach + make shared appointments clickable

- replace the attach "+" menu (whose programmatic fileInput.click() was dropped
  by user-activation gating, so files never attached and no chip appeared) with
  a native <label> paperclip + a direct appointment button
- shared-appointment cards are now clickable for both parties, opening an
  AppointmentDetailDialog with the full snapshot (when/type/provider/patient/status)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-17 19:11:41 +03:00
parent 9503716eb3
commit 96bda1b902
3 changed files with 151 additions and 59 deletions
@@ -0,0 +1,86 @@
"use client";
import { CalendarClock } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import type { MessageAttachment } from "@/lib/messages";
// The appointment data carried inside a shared-appointment message. It's a
// point-in-time snapshot, so it survives the appointment later being edited or
// deleted.
type AppointmentSnapshot = Extract<
MessageAttachment,
{ kind: "appointment" }
>["appointment"];
function Row({ label, value }: { label: string; value: ReactNode }) {
if (!value) return null;
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-muted-foreground text-sm">{label}</dt>
<dd className="text-right text-foreground text-sm">{value}</dd>
</div>
);
}
// A read-only view of a shared appointment, opened by clicking the card in the
// thread. Works the same for the sender and the recipient.
export function AppointmentDetailDialog({
appointment,
open,
onOpenChange,
}: {
appointment: AppointmentSnapshot;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const a = appointment;
const statusLabel = a.status
? t(`appointments.status.${a.status}`, { defaultValue: a.status })
: null;
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CalendarClock className="size-4 text-muted-foreground" />
{t("messages.attach.apptDetailTitle")}
</DialogTitle>
<DialogDescription>{a.name}</DialogDescription>
</DialogHeader>
<DialogPanel>
<dl className="flex flex-col gap-2.5">
<Row
label={t("messages.attach.apptWhen")}
value={[a.date, a.time].filter(Boolean).join(" · ")}
/>
<Row label={t("messages.attach.apptType")} value={a.type} />
<Row label={t("messages.attach.apptProvider")} value={a.provider} />
<Row
label={t("messages.attach.apptPatient")}
value={a.fileNumber ? `#${a.fileNumber}` : null}
/>
<Row
label={t("messages.attach.apptStatus")}
value={
statusLabel ? <Badge variant="outline">{statusLabel}</Badge> : null
}
/>
</dl>
</DialogPanel>
</DialogPopup>
</Dialog>
);
}
+58 -58
View File
@@ -5,6 +5,7 @@ import {
Download,
FileText,
Mail,
Paperclip,
Plus,
Search,
SendHorizonal,
@@ -21,6 +22,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { AppointmentDetailDialog } from "@/components/messages/appointment-detail-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
@@ -40,7 +42,6 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
import { type Appointment, listAppointments } from "@/lib/appointments";
import { authClient } from "@/lib/auth-client";
import {
@@ -90,6 +91,7 @@ const GROUP_WINDOW_MS = 5 * 60 * 1000;
// shared-appointment card. Alignment (left/right) comes from the parent column.
function SentAttachment({ att }: { att: MessageAttachment }) {
const { t } = useTranslation();
const [apptOpen, setApptOpen] = useState(false);
if (att.kind === "file") {
return (
<button
@@ -109,21 +111,32 @@ function SentAttachment({ att }: { att: MessageAttachment }) {
}
const a = att.appointment;
return (
<div className="max-w-[75%] rounded-2xl border bg-card p-3 text-sm">
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<CalendarClock className="size-3.5" />
{t("messages.attach.apptCardLabel")}
</div>
<p className="mt-1 font-medium text-foreground">{a.name}</p>
<p className="text-muted-foreground text-xs">
{[a.date, a.time].filter(Boolean).join(" · ")}
</p>
{[a.type, a.provider].filter(Boolean).length > 0 && (
<>
<button
className="max-w-[75%] rounded-2xl border bg-card p-3 text-left text-sm transition-colors hover:bg-accent"
onClick={() => setApptOpen(true)}
type="button"
>
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<CalendarClock className="size-3.5" />
{t("messages.attach.apptCardLabel")}
</div>
<p className="mt-1 font-medium text-foreground">{a.name}</p>
<p className="text-muted-foreground text-xs">
{[a.type, a.provider].filter(Boolean).join(" · ")}
{[a.date, a.time].filter(Boolean).join(" · ")}
</p>
)}
</div>
{[a.type, a.provider].filter(Boolean).length > 0 && (
<p className="text-muted-foreground text-xs">
{[a.type, a.provider].filter(Boolean).join(" · ")}
</p>
)}
</button>
<AppointmentDetailDialog
appointment={a}
onOpenChange={setApptOpen}
open={apptOpen}
/>
</>
);
}
@@ -629,51 +642,38 @@ export function MessagesView() {
</div>
)}
<div className="flex items-center gap-2">
{/* Visually hidden (not `display:none`) so the programmatic
`.click()` reliably opens the picker across browsers. */}
<input
{/* 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="sr-only"
multiple
onChange={onPickFiles}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<Menu>
<MenuTrigger
render={
<Button
aria-label={t("messages.attach.menu")}
disabled={uploading}
size="icon"
type="button"
variant="ghost"
/>
}
>
<Plus className="size-4" />
</MenuTrigger>
<MenuPopup align="start" side="top">
{/* Defer past the menu's close/unmount so the file picker
and the appointment dialog aren't swallowed by the
closing overlay's focus handling. */}
<MenuItem
onClick={() =>
requestAnimationFrame(() => fileInputRef.current?.click())
}
>
<FileText className="size-4 text-muted-foreground" />
{t("messages.attach.file")}
</MenuItem>
<MenuItem
onClick={() => requestAnimationFrame(openApptPicker)}
>
<CalendarClock className="size-4 text-muted-foreground" />
{t("messages.attach.appointment")}
</MenuItem>
</MenuPopup>
</Menu>
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"
@@ -705,7 +705,13 @@
"apptSearchPlaceholder": "Patient name…",
"apptNoMatches": "No appointments match.",
"apptEmpty": "No appointments yet.",
"apptCardLabel": "Appointment"
"apptCardLabel": "Appointment",
"apptDetailTitle": "Appointment details",
"apptWhen": "When",
"apptType": "Type",
"apptProvider": "Provider",
"apptPatient": "Patient",
"apptStatus": "Status"
}
},
"analysis": {