Task #694: Booking details dialog from calendar day-grid

- BookingDayGrid (artifacts/tx-os/src/pages/protocol.tsx): booking blocks are
  now <button type="button"> with onClick -> new optional onSelect prop,
  hover/focus-visible styles, aria-label (title/room/time) and
  data-testid="calendar-booking-{id}".
- ProtocolPage: new detailsBooking state; day grid passes onSelect; new
  read-only details Dialog (before AppDock) with:
  header = booking title + status pill + public-request badge;
  summary card = date, time range (LTR), duration, room, reference,
  meeting type; requester section = name, phone (LTR), entity (external
  only), department, attendee count; meeting section = purpose + key
  attendees with side badges; notes; rejection-reason box when rejected.
  Empty/null fields hidden via DetailRow helper.
- New helpers: DetailRow component, formatDuration(start,end,t) using
  pluralized i18n keys.
- i18n: protocol.bookings.details.* added to ar.json (full Arabic plural
  forms for hours/minutes) and en.json.
- View-only per task scope (no edit/approve/cancel in dialog).
- tsc --noEmit clean; both locale JSONs valid; architect review PASS.
This commit is contained in:
Replit Agent
2026-07-08 12:50:33 +00:00
parent e11510778f
commit 5fe978c0ca
3 changed files with 278 additions and 3 deletions
+21
View File
@@ -1787,6 +1787,27 @@
"meetingTypesShort": {
"internal": "داخلي",
"external": "خارجي"
},
"details": {
"date": "التاريخ",
"time": "الوقت",
"duration": "المدة",
"requesterSection": "بيانات مقدّم الطلب",
"meetingSection": "تفاصيل الاجتماع",
"notes": "ملاحظات",
"rejectionReason": "سبب الرفض",
"durationHours_zero": "{{count}} ساعة",
"durationHours_one": "ساعة واحدة",
"durationHours_two": "ساعتان",
"durationHours_few": "{{count}} ساعات",
"durationHours_many": "{{count}} ساعة",
"durationHours_other": "{{count}} ساعة",
"durationMinutes_zero": "{{count}} دقيقة",
"durationMinutes_one": "دقيقة واحدة",
"durationMinutes_two": "دقيقتان",
"durationMinutes_few": "{{count}} دقائق",
"durationMinutes_many": "{{count}} دقيقة",
"durationMinutes_other": "{{count}} دقيقة"
}
},
"external": {
+13
View File
@@ -1648,6 +1648,19 @@
"meetingTypesShort": {
"internal": "Internal",
"external": "External"
},
"details": {
"date": "Date",
"time": "Time",
"duration": "Duration",
"requesterSection": "Requester details",
"meetingSection": "Meeting details",
"notes": "Notes",
"rejectionReason": "Rejection reason",
"durationHours_one": "1 hour",
"durationHours_other": "{{count}} hours",
"durationMinutes_one": "1 minute",
"durationMinutes_other": "{{count}} minutes"
}
},
"external": {
+244 -3
View File
@@ -354,6 +354,7 @@ function BookingDayGrid({
roomName,
emptyText,
scrollSignal = 0,
onSelect,
}: {
bookings: Booking[];
selectedDay: Date;
@@ -362,6 +363,7 @@ function BookingDayGrid({
roomName: (id: number) => string;
emptyText: string;
scrollSignal?: number;
onSelect?: (b: Booking) => void;
}) {
const hours: number[] = [];
for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h);
@@ -458,10 +460,13 @@ function BookingDayGrid({
);
const widthPct = 100 / ev.cols;
return (
<div
<button
key={ev.b.id}
type="button"
onClick={() => onSelect?.(ev.b)}
className={cn(
"absolute overflow-hidden rounded-md border-s-4 px-1.5 py-0.5 text-[11px] leading-tight shadow-sm",
"absolute overflow-hidden rounded-md border-s-4 px-1.5 py-0.5 text-start text-[11px] leading-tight shadow-sm transition-shadow",
"cursor-pointer hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500",
BOOKING_BLOCK_STYLE[ev.b.status] ??
"bg-slate-100 border-slate-300 text-slate-700",
)}
@@ -472,6 +477,8 @@ function BookingDayGrid({
width: `calc(${widthPct}% - 4px)`,
}}
title={`${ev.b.title}${roomName(ev.b.roomId)}`}
aria-label={`${ev.b.title}${roomName(ev.b.roomId)}${timeLabel(ev.b.startsAt)} ${timeLabel(ev.b.endsAt)}`}
data-testid={`calendar-booking-${ev.b.id}`}
>
<div className="truncate font-medium">{ev.b.title}</div>
<div className="truncate opacity-80">
@@ -482,7 +489,7 @@ function BookingDayGrid({
{timeLabel(ev.b.startsAt)} {timeLabel(ev.b.endsAt)}
</div>
)}
</div>
</button>
);
})}
{showNow && (
@@ -657,6 +664,8 @@ export default function ProtocolPage() {
// Bumped by the "اليوم" button to re-scroll the day grid to the now-line
// even when the selected day is already today.
const [todayScrollSignal, setTodayScrollSignal] = useState(0);
// Booking selected from the calendar day grid → read-only details dialog.
const [detailsBooking, setDetailsBooking] = useState<Booking | null>(null);
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
const now = useNow(60000);
const bookedDates = useMemo(() => {
@@ -1500,6 +1509,7 @@ export default function ProtocolPage() {
roomName={roomName}
emptyText={t("protocol.bookings.emptyDay")}
scrollSignal={todayScrollSignal}
onSelect={(b) => setDetailsBooking(b)}
/>
</div>
</div>
@@ -2002,6 +2012,201 @@ export default function ProtocolPage() {
</AlertDialogContent>
</AlertDialog>
<Dialog
open={!!detailsBooking}
onOpenChange={(o) => !o && setDetailsBooking(null)}
>
<DialogContent
dir={isAr ? "rtl" : "ltr"}
className="max-h-[85vh] overflow-y-auto sm:max-w-lg"
>
{detailsBooking && (
<>
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2 text-start">
<span>{detailsBooking.title}</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-normal",
STATUS_PILL[detailsBooking.status],
)}
>
{t(`protocol.status.${detailsBooking.status}`)}
</span>
{detailsBooking.source === "public" && (
<span className="rounded-full bg-sky-100 px-2 py-0.5 text-xs font-normal text-sky-700">
{t("protocol.bookings.publicRequest")}
</span>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="rounded-lg border border-slate-200 bg-slate-50/60 p-3">
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<DetailRow
label={t("protocol.bookings.details.date")}
value={fmtDateOnly(detailsBooking.startsAt)}
/>
<DetailRow
label={t("protocol.bookings.details.time")}
value={
<span dir="ltr">
{fmtTimeOnly(detailsBooking.startsAt)} {" "}
{fmtTimeOnly(detailsBooking.endsAt)}
</span>
}
/>
<DetailRow
label={t("protocol.bookings.details.duration")}
value={formatDuration(
detailsBooking.startsAt,
detailsBooking.endsAt,
t,
)}
/>
<DetailRow
label={t("protocol.bookings.roomLabel")}
value={roomName(detailsBooking.roomId)}
/>
<DetailRow
label={t("protocol.bookings.reference")}
value={
detailsBooking.bookingReference ? (
<span dir="ltr">
{detailsBooking.bookingReference}
</span>
) : null
}
/>
<DetailRow
label={t("protocol.bookings.meetingType")}
value={t(
`protocol.bookings.meetingTypes.${detailsBooking.meetingType}`,
)}
/>
</div>
</div>
<div>
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-400">
{t("protocol.bookings.details.requesterSection")}
</div>
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<DetailRow
label={t("protocol.bookings.requester")}
value={detailsBooking.requesterName}
/>
<DetailRow
label={t("protocol.bookings.phone")}
value={
detailsBooking.requesterPhone ? (
<span dir="ltr">
{detailsBooking.requesterPhone}
</span>
) : null
}
/>
{detailsBooking.meetingType === "external" && (
<DetailRow
label={t("protocol.bookings.entity")}
value={detailsBooking.requesterOrg}
/>
)}
<DetailRow
label={t("protocol.bookings.department")}
value={detailsBooking.department}
/>
<DetailRow
label={t("protocol.bookings.attendeeCount")}
value={
detailsBooking.attendeeCount != null
? detailsBooking.attendeeCount
: null
}
/>
</div>
</div>
{(detailsBooking.purpose ||
(detailsBooking.keyAttendees?.length ?? 0) > 0) && (
<div>
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-400">
{t("protocol.bookings.details.meetingSection")}
</div>
<div className="space-y-2">
<DetailRow
label={t("protocol.bookings.purpose")}
value={detailsBooking.purpose}
/>
{(detailsBooking.keyAttendees?.length ?? 0) > 0 && (
<div>
<div className="text-xs text-slate-400">
{t("protocol.bookings.keyAttendees")}
</div>
<ul className="mt-1 space-y-1">
{detailsBooking.keyAttendees!.map((a, i) => (
<li
key={i}
className="flex flex-wrap items-center gap-1.5 text-slate-700"
>
<span className="font-medium">{a.name}</span>
{a.position && (
<span className="text-slate-500">
{a.position}
</span>
)}
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">
{t(
`protocol.bookings.attendeeSides.${a.side}`,
)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{detailsBooking.notes && (
<div>
<div className="mb-1 text-xs text-slate-400">
{t("protocol.bookings.details.notes")}
</div>
<div className="whitespace-pre-wrap break-words text-slate-700">
{detailsBooking.notes}
</div>
</div>
)}
{detailsBooking.status === "rejected" &&
detailsBooking.rejectionReason && (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3">
<div className="mb-1 text-xs font-semibold text-rose-600">
{t("protocol.bookings.details.rejectionReason")}
</div>
<div className="whitespace-pre-wrap break-words text-rose-700">
{detailsBooking.rejectionReason}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDetailsBooking(null)}
>
{t("common.close")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<AppDock currentSlug="protocol" />
</div>
);
@@ -2010,6 +2215,42 @@ export default function ProtocolPage() {
// ---------------------------------------------------------------------------
// Sub-views & dialogs
// ---------------------------------------------------------------------------
function DetailRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
if (value == null || value === "") return null;
return (
<div className="min-w-0">
<div className="text-xs text-slate-400">{label}</div>
<div className="min-w-0 break-words text-slate-700">{value}</div>
</div>
);
}
function formatDuration(
startIso: string,
endIso: string,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
const mins = Math.max(
0,
Math.round(
(new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000,
),
);
const h = Math.floor(mins / 60);
const m = mins % 60;
const parts: string[] = [];
if (h > 0) parts.push(t("protocol.bookings.details.durationHours", { count: h }));
if (m > 0 || h === 0)
parts.push(t("protocol.bookings.details.durationMinutes", { count: m }));
return parts.join(" ");
}
function EmptyState({ text }: { text: string }) {
return (
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>