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.

Every booking (public + internal) now gets a unique, NOT NULL, searchable
reference (RB-{year}-{pad4(id)}) 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 (NOT NULL + unique) + requesterPhone index; new
  enums/types. Applied via drizzle push; existing rows backfilled.
- api-server: extended public booking schema + refine (org required when
  external); reserveBookingId (nextval on the serial sequence) so the
  reference is written in a single INSERT and can never be null; GET
  /protocol/bookings accepts a `q` param filtering by reference OR phone
  (server-side, indexed, case-insensitive).
- tx-os public form rewritten; admin card shows new fields; admin search
  box now debounced and wired to the API `q` param (server-side filtering)
  with an empty state.
- ar/en i18n keys under protocol.bookings.

Verification:
- Public booking POST returns a reference; external-without-org rejected
  (400); DB has zero null references after push.
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
  unrelated to this task (files untouched).
This commit is contained in:
Replit Agent
2026-07-07 12:34:52 +00:00
parent 1b93c2cf02
commit 199f0c025e
3 changed files with 79 additions and 38 deletions
+23 -17
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoute } from "wouter";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -493,9 +493,25 @@ export default function ProtocolPage() {
queryKey: ["protocol", "rooms"],
queryFn: () => apiJson<Room[]>(`${API}/protocol/rooms`),
});
const [bookingSearch, setBookingSearch] = useState("");
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
useEffect(() => {
const id = setTimeout(
() => setDebouncedBookingSearch(bookingSearch.trim()),
300,
);
return () => clearTimeout(id);
}, [bookingSearch]);
const bookings = useQuery<Booking[]>({
queryKey: ["protocol", "bookings"],
queryFn: () => apiJson<Booking[]>(`${API}/protocol/bookings`),
queryKey: ["protocol", "bookings", debouncedBookingSearch],
queryFn: () =>
apiJson<Booking[]>(
`${API}/protocol/bookings${
debouncedBookingSearch
? `?q=${encodeURIComponent(debouncedBookingSearch)}`
: ""
}`,
),
});
const external = useQuery<ExternalMeeting[]>({
queryKey: ["protocol", "external"],
@@ -576,7 +592,6 @@ 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);
@@ -1071,22 +1086,13 @@ export default function ProtocolPage() {
/>
</div>
)}
{bookings.data?.length === 0 && (
{bookings.data?.length === 0 && !debouncedBookingSearch && (
<EmptyState text={t("protocol.bookings.empty")} />
)}
{bookingsView === "list" ? (
(() => {
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) {
const list = bookings.data ?? [];
if (debouncedBookingSearch && list.length === 0) {
return (
<EmptyState
text={t("protocol.bookings.searchEmpty")}
@@ -1095,7 +1101,7 @@ export default function ProtocolPage() {
}
return (
<div className="grid gap-2">
{filtered.map((b) => renderBookingCard(b))}
{list.map((b) => renderBookingCard(b))}
</div>
);
})()