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:
@@ -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": "لقاء جديد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user