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:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user