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:
@@ -1718,6 +1718,7 @@
|
||||
"phone": "الهاتف",
|
||||
"attendeeCount": "العدد المتوقع للحضور",
|
||||
"publicRequest": "طلب عام",
|
||||
"today": "اليوم",
|
||||
"reference": "رقم الحجز",
|
||||
"department": "الإدارة",
|
||||
"entity": "اسم الجهة",
|
||||
|
||||
@@ -1591,6 +1591,7 @@
|
||||
"phone": "Phone",
|
||||
"attendeeCount": "Expected attendees",
|
||||
"publicRequest": "Public request",
|
||||
"today": "Today",
|
||||
"reference": "Booking ref",
|
||||
"department": "Department",
|
||||
"entity": "Entity",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user