Task #670: revamp public room-booking form (protocol)

Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.

Each booking now gets an auto-generated searchable reference
(RB-{year}-{pad4}) shown on the success screen; staff can search the
admin bookings list by reference OR phone.

Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
  bookingReference (unique) + requesterPhone index; new enums/types.
  Applied via drizzle push; backfilled existing 4 rows.
- api-server: extended public booking schema + refine (org required when
  external), makeBookingReference, public/internal POST generate+return
  bookingReference.
- tx-os public form rewritten; admin card shows new fields + client-side
  search with empty state.
- ar/en i18n keys under protocol.bookings.

Notes:
- Pre-existing push.ts typecheck errors are unrelated to this task.
- Architect review: PASS, no blocking issues.
This commit is contained in:
Replit Agent
2026-07-07 12:26:19 +00:00
parent 553df28571
commit 1b93c2cf02
6 changed files with 524 additions and 73 deletions
+89 -5
View File
@@ -22,6 +22,7 @@ import {
X,
CalendarDays,
List as ListIcon,
Search as SearchIcon,
Link2,
Copy,
Upload,
@@ -90,6 +91,12 @@ type Room = {
};
type BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
type MeetingType = "internal" | "external";
type KeyAttendee = {
name: string;
position: string;
side: "internal" | "external";
};
type Booking = {
id: number;
roomId: number;
@@ -98,6 +105,11 @@ type Booking = {
requesterPhone: string | null;
requesterOrg: string | null;
attendeeCount: number | null;
department: string | null;
meetingType: MeetingType;
purpose: string | null;
keyAttendees: KeyAttendee[] | null;
bookingReference: string | null;
source: "internal" | "public";
startsAt: string;
endsAt: string;
@@ -564,6 +576,7 @@ export default function ProtocolPage() {
id: number;
} | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const [bookingSearch, setBookingSearch] = useState("");
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
const now = useNow(60000);
@@ -737,14 +750,29 @@ export default function ProtocolPage() {
{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.requesterOrg && (
{b.department && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.org")}: {b.requesterOrg}
{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 && (
@@ -758,6 +786,27 @@ export default function ProtocolPage() {
{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" && (
@@ -1008,13 +1057,48 @@ export default function ProtocolPage() {
</Button>
</div>
</div>
{bookingsView === "list" && (
<div className="relative">
<SearchIcon
size={16}
className="pointer-events-none absolute top-1/2 -translate-y-1/2 start-3 text-slate-400"
/>
<Input
value={bookingSearch}
onChange={(e) => setBookingSearch(e.target.value)}
placeholder={t("protocol.bookings.searchPlaceholder")}
className="ps-9"
/>
</div>
)}
{bookings.data?.length === 0 && (
<EmptyState text={t("protocol.bookings.empty")} />
)}
{bookingsView === "list" ? (
<div className="grid gap-2">
{(bookings.data ?? []).map((b) => renderBookingCard(b))}
</div>
(() => {
const q = bookingSearch.trim().toLowerCase();
const filtered = q
? (bookings.data ?? []).filter(
(b) =>
(b.bookingReference ?? "")
.toLowerCase()
.includes(q) ||
(b.requesterPhone ?? "").toLowerCase().includes(q),
)
: (bookings.data ?? []);
if (q && filtered.length === 0) {
return (
<EmptyState
text={t("protocol.bookings.searchEmpty")}
/>
);
}
return (
<div className="grid gap-2">
{filtered.map((b) => renderBookingCard(b))}
</div>
);
})()
) : (
<div className="flex flex-col gap-4 lg:flex-row">
<div className="shrink-0 self-center lg:self-start">