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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user