mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user