Collapsible booking cards in Protocol → Bookings list (Task #676)

Original task: make the protocol booking-requests LIST cards collapsed by
default, showing a compact summary and expanding full details only when opened;
add numbering, show the booking reference, and highlight today's bookings green.

Changes (artifacts/tx-os/src/pages/protocol.tsx):
- renderBookingCard(b, index) now renders a collapsible card. Collapsed shows
  only: sequence number (1-based over the current/filtered list), title,
  room · date-time range, booking reference, status/public pills, and a chevron.
- Summary header is a real <button type="button"> with onClick toggle and
  aria-expanded for keyboard accessibility.
- Expanded reveals the previously-shown detail fields (requester, department,
  meeting type, entity, phone, attendee count, purpose, key attendees) and the
  action buttons (approve/reject/cancel/edit/delete) — no functionality lost.
- Bookings starting today (sameYmd(startsAt, now)) get a green card
  (emerald border/bg/ring) plus an "اليوم"/"Today" badge.
- Added expandedBookings Set state + toggleBookingExpanded near booking state.
- List map updated to pass 1-based index.

i18n: added protocol.bookings.today ("اليوم" / "Today") to ar.json and en.json.

Scope: list view only; calendar view, public request form, and other tabs
unchanged. Client-only "today" derivation, no backend changes.

Verification: tsc --noEmit passes; architect code review PASSED (no critical
issues). Screenshot skipped — protocol page requires auth.
This commit is contained in:
Replit Agent
2026-07-07 14:08:51 +00:00
parent 15ca31e2b1
commit 0808d8bdeb
3 changed files with 194 additions and 138 deletions
+1
View File
@@ -1718,6 +1718,7 @@
"phone": "الهاتف",
"attendeeCount": "العدد المتوقع للحضور",
"publicRequest": "طلب عام",
"today": "اليوم",
"reference": "رقم الحجز",
"department": "الإدارة",
"entity": "اسم الجهة",
+1
View File
@@ -1591,6 +1591,7 @@
"phone": "Phone",
"attendeeCount": "Expected attendees",
"publicRequest": "Public request",
"today": "Today",
"reference": "Booking ref",
"department": "Department",
"entity": "Entity",
+192 -138
View File
@@ -592,6 +592,16 @@ export default function ProtocolPage() {
id: number;
} | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const [expandedBookings, setExpandedBookings] = useState<Set<number>>(
() => new Set(),
);
const toggleBookingExpanded = (id: number) =>
setExpandedBookings((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
const now = useNow(60000);
@@ -753,148 +763,192 @@ export default function ProtocolPage() {
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const renderBookingCard = (b: Booking) => (
<div
key={b.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">{b.title}</div>
<div className="text-sm text-slate-500">
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} {" "}
{fmtDateTime(b.endsAt)}
</div>
{b.bookingReference && (
<div className="text-xs font-medium text-sky-600">
{t("protocol.bookings.reference")}:{" "}
<span dir="ltr">{b.bookingReference}</span>
</div>
)}
{b.requesterName && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.requester")}: {b.requesterName}
</div>
)}
{b.department && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.department")}: {b.department}
</div>
)}
<div className="text-xs text-slate-400">
{t("protocol.bookings.meetingType")}:{" "}
{t(`protocol.bookings.meetingTypes.${b.meetingType}`)}
</div>
{b.meetingType === "external" && b.requesterOrg && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.entity")}: {b.requesterOrg}
</div>
)}
{b.requesterPhone && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.phone")}:{" "}
<span dir="ltr">{b.requesterPhone}</span>
</div>
)}
{b.attendeeCount != null && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.attendeeCount")}: {b.attendeeCount}
</div>
)}
{b.purpose && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.purpose")}: {b.purpose}
</div>
)}
{b.keyAttendees && b.keyAttendees.length > 0 && (
<div className="text-xs text-slate-400 mt-1">
<span className="font-medium">
{t("protocol.bookings.keyAttendees")}:
</span>
<ul className="mt-0.5 space-y-0.5">
{b.keyAttendees.map((a, i) => (
<li key={i}>
{a.name}
{a.position ? `${a.position}` : ""} (
{t(`protocol.bookings.attendeeSides.${a.side}`)})
</li>
))}
</ul>
</div>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{b.source === "public" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-100 text-sky-700">
{t("protocol.bookings.publicRequest")}
const renderBookingCard = (b: Booking, index: number) => {
const expanded = expandedBookings.has(b.id);
const bookingIsToday = sameYmd(new Date(b.startsAt), now);
return (
<div
key={b.id}
className={cn(
"rounded-xl border p-3",
bookingIsToday
? "border-emerald-400 bg-emerald-50/60 ring-1 ring-emerald-200"
: "bg-white border-slate-200",
)}
>
<button
type="button"
onClick={() => toggleBookingExpanded(b.id)}
aria-expanded={expanded}
className="w-full flex items-start justify-between gap-2 text-start"
>
<div className="flex items-start gap-2 min-w-0">
<span className="shrink-0 mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
{index}
</span>
)}
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
STATUS_PILL[b.status],
<div className="min-w-0">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="truncate">{b.title}</span>
{bookingIsToday && (
<span className="shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700">
{t("protocol.bookings.today")}
</span>
)}
</div>
<div className="text-sm text-slate-500">
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} {" "}
{fmtDateTime(b.endsAt)}
</div>
{b.bookingReference && (
<div className="text-xs font-medium text-sky-600">
{t("protocol.bookings.reference")}:{" "}
<span dir="ltr">{b.bookingReference}</span>
</div>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{b.source === "public" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-100 text-sky-700">
{t("protocol.bookings.publicRequest")}
</span>
)}
>
{t(`protocol.status.${b.status}`)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{c?.canApprove && b.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "approve" })
}
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
STATUS_PILL[b.status],
)}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRejectTarget({ kind: "booking", id: b.id })}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate &&
(b.status === "pending" || b.status === "approved") && (
<Button
size="sm"
variant="ghost"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "cancel" })
}
>
{t("protocol.actions.cancel")}
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setBookingDialog({ open: true, edit: b })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteTarget({ kind: "booking", id: b.id })}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
{t(`protocol.status.${b.status}`)}
</span>
<ChevronDown
size={16}
className={cn(
"text-slate-400 transition-transform",
expanded && "rotate-180",
)}
/>
</div>
</button>
{expanded && (
<div className="mt-2 border-t border-slate-100 pt-2">
<div className="space-y-0.5">
{b.requesterName && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.requester")}: {b.requesterName}
</div>
)}
{b.department && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.department")}: {b.department}
</div>
)}
<div className="text-xs text-slate-400">
{t("protocol.bookings.meetingType")}:{" "}
{t(`protocol.bookings.meetingTypes.${b.meetingType}`)}
</div>
{b.meetingType === "external" && b.requesterOrg && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.entity")}: {b.requesterOrg}
</div>
)}
{b.requesterPhone && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.phone")}:{" "}
<span dir="ltr">{b.requesterPhone}</span>
</div>
)}
{b.attendeeCount != null && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.attendeeCount")}: {b.attendeeCount}
</div>
)}
{b.purpose && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.purpose")}: {b.purpose}
</div>
)}
{b.keyAttendees && b.keyAttendees.length > 0 && (
<div className="text-xs text-slate-400 mt-1">
<span className="font-medium">
{t("protocol.bookings.keyAttendees")}:
</span>
<ul className="mt-0.5 space-y-0.5">
{b.keyAttendees.map((a, i) => (
<li key={i}>
{a.name}
{a.position ? `${a.position}` : ""} (
{t(`protocol.bookings.attendeeSides.${a.side}`)})
</li>
))}
</ul>
</div>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{c?.canApprove && b.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "approve" })
}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setRejectTarget({ kind: "booking", id: b.id })
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate &&
(b.status === "pending" || b.status === "approved") && (
<Button
size="sm"
variant="ghost"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "cancel" })
}
>
{t("protocol.actions.cancel")}
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setBookingDialog({ open: true, edit: b })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "booking", id: b.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
);
};
return (
<div className="min-h-screen bg-slate-50" dir={isAr ? "rtl" : "ltr"}>
@@ -1101,7 +1155,7 @@ export default function ProtocolPage() {
}
return (
<div className="grid gap-2">
{list.map((b) => renderBookingCard(b))}
{list.map((b, i) => renderBookingCard(b, i + 1))}
</div>
);
})()