Update bookings view to use a table layout

Replaces the card-based list view with a table component in `protocol.tsx`. Adds new translation keys for table headers and meeting types in `ar.json` and `en.json`. Formats booking references to display only numeric characters and dashes.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: e1793d98-fbd5-4c99-99a0-e01d493482cc
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/ychh3tH
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
Replit Agent
2026-07-08 11:12:41 +00:00
parent 3a20a334bd
commit 79a9e5236d
3 changed files with 247 additions and 123 deletions
+15 -2
View File
@@ -1741,13 +1741,26 @@
"endsAt": "إلى",
"viewList": "قائمة",
"viewCalendar": "تقويم",
"today": "اليوم",
"book": "حجز",
"emptyDay": "لا توجد حجوزات في هذا اليوم.",
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
"copyLink": "نسخ الرابط",
"linkCopied": "تم نسخ الرابط",
"linkCopyFailed": "تعذّر نسخ الرابط"
"linkCopyFailed": "تعذّر نسخ الرابط",
"table": {
"number": "رقم الحجز",
"requester": "مقدم الطلب",
"date": "تاريخ الحجز",
"type": "نوع الاجتماع",
"roomAttendees": "القاعة وعدد الحضور",
"status": "الحالة",
"actions": "الإجراء",
"attendeesShort": "الحضور"
},
"meetingTypesShort": {
"internal": "داخلي",
"external": "خارجي"
}
},
"external": {
"new": "لقاء جديد",
+15 -2
View File
@@ -1614,13 +1614,26 @@
"endsAt": "To",
"viewList": "List",
"viewCalendar": "Calendar",
"today": "Today",
"book": "Book",
"emptyDay": "No bookings on this day.",
"shareLinkTitle": "Public booking link (share with guests)",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"linkCopyFailed": "Could not copy the link"
"linkCopyFailed": "Could not copy the link",
"table": {
"number": "Booking No.",
"requester": "Requester",
"date": "Booking date",
"type": "Meeting type",
"roomAttendees": "Room & attendees",
"status": "Status",
"actions": "Actions",
"attendeesShort": "Attendees"
},
"meetingTypesShort": {
"internal": "Internal",
"external": "External"
}
},
"external": {
"new": "New meeting",
+217 -119
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoute } from "wouter";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -58,6 +58,14 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AppDock } from "@/components/app-dock";
import { Calendar } from "@/components/ui/calendar";
import { useNow } from "@/components/clock";
@@ -763,56 +771,85 @@ export default function ProtocolPage() {
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const renderBookingCard = (b: Booking, index: number) => {
const bookingNumber = (b: Booking) => {
const digits = (b.bookingReference ?? "")
.replace(/[^\d-]/g, "")
.replace(/^-+|-+$/g, "");
return digits || String(b.id).padStart(4, "0");
};
const fmtDateOnly = (iso: string) =>
new Date(iso).toLocaleDateString(isAr ? "ar" : "en", {
dateStyle: "medium",
});
const fmtTimeOnly = (iso: string) =>
new Date(iso).toLocaleTimeString(isAr ? "ar" : "en", {
timeStyle: "short",
});
const renderBookingRow = (b: Booking) => {
const expanded = expandedBookings.has(b.id);
const bookingIsToday = sameYmd(new Date(b.startsAt), now);
const detailColSpan = 7;
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"
<>
<TableRow
key={b.id}
onClick={() => toggleBookingExpanded(b.id)}
aria-expanded={expanded}
className="w-full flex items-start justify-between gap-2 text-start"
className={cn(
"cursor-pointer",
bookingIsToday
? "bg-emerald-50/60 hover:bg-emerald-50"
: "odd:bg-white even:bg-slate-50/60",
)}
data-testid={`booking-row-${b.id}`}
>
<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>
<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>
)}
<TableCell className="whitespace-nowrap font-semibold text-sky-700">
<span dir="ltr">{bookingNumber(b)}</span>
{bookingIsToday && (
<span className="ms-2 text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700">
{t("protocol.bookings.today")}
</span>
)}
</TableCell>
<TableCell className="min-w-0">
<div className="font-medium text-slate-800 truncate max-w-[180px]">
{b.requesterName || "—"}
</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">
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-sky-100 text-sky-700">
{t("protocol.bookings.publicRequest")}
</span>
)}
</TableCell>
<TableCell className="whitespace-nowrap text-slate-600">
<div>{fmtDateOnly(b.startsAt)}</div>
<div className="text-xs text-slate-400" dir="ltr">
{fmtTimeOnly(b.startsAt)} {fmtTimeOnly(b.endsAt)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
b.meetingType === "external"
? "bg-violet-100 text-violet-700"
: "bg-slate-100 text-slate-600",
)}
>
{t(`protocol.bookings.meetingTypesShort.${b.meetingType}`)}
</span>
</TableCell>
<TableCell className="text-slate-600">
<div className="truncate max-w-[160px]">{roomName(b.roomId)}</div>
{b.attendeeCount != null && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.table.attendeesShort")}: {b.attendeeCount}
</div>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
@@ -821,93 +858,37 @@ export default function ProtocolPage() {
>
{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">
</TableCell>
<TableCell
className="whitespace-nowrap"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-0.5">
{c?.canApprove && b.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600"
title={t("protocol.actions.approve")}
aria-label={t("protocol.actions.approve")}
onClick={() =>
bookingAction.mutate({ id: b.id, action: "approve" })
}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
<Check size={14} />
</Button>
<Button
size="sm"
variant="outline"
variant="ghost"
className="h-7 w-7 p-0 text-rose-500"
title={t("protocol.actions.reject")}
aria-label={t("protocol.actions.reject")}
onClick={() =>
setRejectTarget({ kind: "booking", id: b.id })
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
<X size={14} />
</Button>
</>
)}
@@ -916,6 +897,7 @@ export default function ProtocolPage() {
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-slate-500"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "cancel" })
}
@@ -928,6 +910,9 @@ export default function ProtocolPage() {
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={t("protocol.bookings.edit")}
aria-label={t("protocol.bookings.edit")}
onClick={() => setBookingDialog({ open: true, edit: b })}
>
<Pencil size={14} />
@@ -935,6 +920,9 @@ export default function ProtocolPage() {
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={() =>
setDeleteTarget({ kind: "booking", id: b.id })
}
@@ -943,13 +931,126 @@ export default function ProtocolPage() {
</Button>
</>
)}
<ChevronDown
size={14}
className={cn(
"ms-1 text-slate-400 transition-transform cursor-pointer",
expanded && "rotate-180",
)}
onClick={() => toggleBookingExpanded(b.id)}
/>
</div>
</div>
</TableCell>
</TableRow>
{expanded && (
<TableRow key={`${b.id}-details`} className="bg-slate-50/80">
<TableCell colSpan={detailColSpan} className="py-3">
<div className="space-y-0.5">
<div className="text-xs text-slate-500 font-medium">
{b.title}
</div>
{b.bookingReference && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.reference")}:{" "}
<span dir="ltr">{b.bookingReference}</span>
</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>
)}
{b.status === "rejected" && b.rejectionReason && (
<div className="text-xs text-rose-500">
{b.rejectionReason}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</div>
</>
);
};
const renderBookingsTable = (list: Booking[]) => (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-100/80 hover:bg-slate-100/80">
<TableHead className="text-start whitespace-nowrap">
{t("protocol.bookings.table.number")}
</TableHead>
<TableHead className="text-start whitespace-nowrap">
{t("protocol.bookings.table.requester")}
</TableHead>
<TableHead className="text-start whitespace-nowrap">
{t("protocol.bookings.table.date")}
</TableHead>
<TableHead className="text-start whitespace-nowrap">
{t("protocol.bookings.table.type")}
</TableHead>
<TableHead className="text-start whitespace-nowrap">
{t("protocol.bookings.table.roomAttendees")}
</TableHead>
<TableHead className="text-start whitespace-nowrap">
{t("protocol.bookings.table.status")}
</TableHead>
<TableHead className="text-start whitespace-nowrap">
{t("protocol.bookings.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((b) => (
<Fragment key={b.id}>{renderBookingRow(b)}</Fragment>
))}
</TableBody>
</Table>
</div>
);
return (
<div className="min-h-screen bg-slate-50" dir={isAr ? "rtl" : "ltr"}>
<header className="sticky top-0 z-10 bg-white border-b border-slate-200">
@@ -1153,11 +1254,8 @@ export default function ProtocolPage() {
/>
);
}
return (
<div className="grid gap-2">
{list.map((b, i) => renderBookingCard(b, i + 1))}
</div>
);
if (list.length === 0) return null;
return renderBookingsTable(list);
})()
) : (
<div className="flex flex-col gap-4 lg:flex-row">