92 Commits

Author SHA1 Message Date
Riydx0 da000a07d9 Merge origin/main before push
# Conflicts:
#	artifacts/api-server/src/routes/protocol.ts
#	artifacts/tx-os/src/locales/ar.json
#	artifacts/tx-os/src/locales/en.json
#	artifacts/tx-os/src/pages/protocol.tsx
2026-07-08 14:16:15 +00:00
Replit Agent c81487b6ba Task #703: تمكين التمرير داخل نافذة الحجز الطويلة
- DialogShell (artifacts/tx-os/src/pages/protocol.tsx) now caps the dialog at
  max-h-[85vh] with a flex column layout: the title (DialogHeader) and the
  save/cancel footer stay always visible, while the form fields scroll in an
  inner overflow-y-auto area (overscroll-contain).
- Applies to all DialogShell users (booking, room, gift dialogs); short forms
  are unaffected since the container only scrolls when content exceeds 85vh.
- RTL unaffected (dir attribute preserved).
- Verified: tsc clean for tx-os; architect review run.
- Architect flagged a potential grid-vs-flex display conflict in DialogContent,
  but cn() uses tailwind-merge, so the later "flex" class deterministically
  replaces the base "grid" — no change needed.
2026-07-08 13:46:08 +00:00
Replit Agent a9eaf2719f Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 46f19c55-44cb-4c1a-bc1e-481868cc60bf
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/9DP8Avu
Replit-Helium-Checkpoint-Created: true
2026-07-08 13:44:05 +00:00
Replit Agent 89f29e2169 Task #701: توحيد نموذج الحجز الداخلي مع نموذج الرابط العام
- Extended internal BookingDialog (artifacts/tx-os/src/pages/protocol.tsx) to match
  the public booking form: requester name, phone, room, department, meeting title,
  meeting type toggle (داخلي / مع جهة خارجية), conditional entity name (external only),
  expected attendee count, purpose, from/to, key attendees in two columns
  (من الهيئة / خارج الهيئة) with add/remove name+position rows, and notes.
- Edit mode prefills all new fields from the booking; click-to-book initialStart
  behavior unchanged.
- api-server (artifacts/api-server/src/routes/protocol.ts): bookingCreateSchema and
  bookingPatchSchema now accept requesterPhone, requesterOrg, department, meetingType,
  purpose, keyAttendees; POST/PATCH /protocol/bookings persist them. requesterOrg is
  nulled whenever effective meetingType is internal (create and patch).
- Added i18n keys protocol.bookings.form.{name,position,addRow,removeRow} (ar/en);
  all other labels reuse existing protocol.bookings keys.
- Verified: tsc clean for changed files (push.ts / executive-meetings.ts errors are
  pre-existing and unrelated); API workflow restarted and serving; architect review PASS.
- Note: entity name (اسم الجهة) is not enforced as required for external meetings on
  the internal endpoint (unlike the strict public endpoint) — internal staff keep
  flexibility; can be tightened later if requested.
2026-07-08 13:40:20 +00:00
Replit Agent 088e17bf85 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 6a5eb766-023e-4a1b-8a34-df4bdbd061c6
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
2026-07-08 13:34:18 +00:00
Replit Agent 0fc96b544b Task #699: الحجز بالنقر على وقت فارغ في تقويم اليوم
- BookingDayGrid (artifacts/tx-os/src/pages/protocol.tsx) now accepts
  onSlotClick?: (start: Date) => void. Clicking the empty grid background
  computes the time from click Y (getBoundingClientRect + clientY), snaps
  down to the nearest 30 minutes, clamps within grid hours (7–22, keeping
  a full hour inside), and calls onSlotClick with the resulting Date on
  the selected day. Cursor becomes pointer when handler is set.
- Booking blocks now stopPropagation on click so existing behavior
  (opening the details dialog) is unchanged and never double-triggers.
- ProtocolPage passes onSlotClick only when capabilities.canRequest,
  opening the existing BookingDialog (same form as the "حجز +" button)
  with the new start prefilled.
- BookingDialog: new optional initialStart prop; startsAt = initialStart,
  endsAt = initialStart + 1h (falls back to prior 9–10 defaults).
- Verified: tsc --noEmit clean in artifacts/tx-os; architect review Pass.
2026-07-08 13:27:35 +00:00
Replit Agent 8105c487ef Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 7321f9dc-f48b-4e6d-8d9a-bba842875c43
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
2026-07-08 13:24:58 +00:00
Replit Agent 49f71389b3 Task #697: جدول حضور احترافي متوسّط + جدول في الصف الموسّع
- Extracted shared AttendeesTable component in artifacts/tx-os/src/pages/protocol.tsx
  (columns: name / position / side) with centered headers and cells, distinct
  header background (bg-slate-100/80), comfortable row heights (py-2.5),
  zebra striping (even:bg-slate-50/60), rounded border + shadow, side pill
  colored by side (sky=internal, violet=external), overflow-x-auto for small
  screens. Center alignment is RTL/LTR-safe.
- Booking details dialog now uses AttendeesTable (replaced inline table).
- Expanded row in bookings list now renders keyAttendees as the same table
  (replaced dash-separated <ul> lines), capped at max-w-2xl.
- No i18n changes needed (reuses protocol.bookings.details.attendeeCols.*).
- Verified: tsc --noEmit clean in artifacts/tx-os; architect review passed.
2026-07-08 13:15:04 +00:00
Replit Agent a7acdb216f Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ba27b261-4a26-4cd1-b252-bc7e5886c5d5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
2026-07-08 13:11:19 +00:00
Replit Agent 3802f785a1 Task #696: Key attendees as a table in booking details dialog
- artifacts/tx-os/src/pages/protocol.tsx: replaced the dash-separated
  <ul> list of keyAttendees in the booking details dialog with a shadcn
  Table (already imported) with 3 columns: Name, Position/Title, Side.
  Headers use text-start for correct RTL alignment; empty name/position
  cells render an em dash; side rendered as the existing pill using
  protocol.bookings.attendeeSides.*; table wrapped in rounded border +
  overflow-x-auto so it stays responsive inside the dialog.
- i18n: added protocol.bookings.details.attendeeCols.{name,position,side}
  to ar.json (الاسم/الصفة/الجهة) and en.json (Name/Title/Side).
- Scope limited to the details dialog; the expanded-row attendee display
  elsewhere is unchanged (per task out-of-scope).
- Verified: both locale JSONs valid, tsc --noEmit clean, architect review
  PASS.
- No follow-ups proposed: pending Task #695 already covers e2e testing of
  the details dialog contents.
2026-07-08 13:06:18 +00:00
Replit Agent a62aabf465 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b860798d-1b52-4c35-b92e-335742115bc2
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
2026-07-08 13:04:20 +00:00
Replit Agent ea5055de85 Task #694: Booking details dialog from calendar day-grid
- BookingDayGrid (artifacts/tx-os/src/pages/protocol.tsx): booking blocks are
  now <button type="button"> with onClick -> new optional onSelect prop,
  hover/focus-visible styles, aria-label (title/room/time) and
  data-testid="calendar-booking-{id}".
- ProtocolPage: new detailsBooking state; day grid passes onSelect; new
  read-only details Dialog (before AppDock) with:
  header = booking title + status pill + public-request badge;
  summary card = date, time range (LTR), duration, room, reference,
  meeting type; requester section = name, phone (LTR), entity (external
  only), department, attendee count; meeting section = purpose + key
  attendees with side badges; notes; rejection-reason box when rejected.
  Empty/null fields hidden via DetailRow helper.
- New helpers: DetailRow component, formatDuration(start,end,t) using
  pluralized i18n keys.
- i18n: protocol.bookings.details.* added to ar.json (full Arabic plural
  forms for hours/minutes) and en.json.
- View-only per task scope (no edit/approve/cancel in dialog).
- tsc --noEmit clean; both locale JSONs valid; architect review PASS.
- Added explicit 'Request source' row (internal/public) in summary card per review; keys protocol.bookings.details.source/sources.* in ar/en.
2026-07-08 12:52:13 +00:00
Replit Agent 5fe978c0ca Task #694: Booking details dialog from calendar day-grid
- BookingDayGrid (artifacts/tx-os/src/pages/protocol.tsx): booking blocks are
  now <button type="button"> with onClick -> new optional onSelect prop,
  hover/focus-visible styles, aria-label (title/room/time) and
  data-testid="calendar-booking-{id}".
- ProtocolPage: new detailsBooking state; day grid passes onSelect; new
  read-only details Dialog (before AppDock) with:
  header = booking title + status pill + public-request badge;
  summary card = date, time range (LTR), duration, room, reference,
  meeting type; requester section = name, phone (LTR), entity (external
  only), department, attendee count; meeting section = purpose + key
  attendees with side badges; notes; rejection-reason box when rejected.
  Empty/null fields hidden via DetailRow helper.
- New helpers: DetailRow component, formatDuration(start,end,t) using
  pluralized i18n keys.
- i18n: protocol.bookings.details.* added to ar.json (full Arabic plural
  forms for hours/minutes) and en.json.
- View-only per task scope (no edit/approve/cancel in dialog).
- tsc --noEmit clean; both locale JSONs valid; architect review PASS.
2026-07-08 12:50:33 +00:00
Replit Agent e11510778f Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: d6cdadfc-5863-4793-a19b-afbbc982aa0b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
2026-07-08 12:46:25 +00:00
Replit Agent 9184a0c223 Task #692: Auto-scroll bookings day grid to current time (red now-line)
- BookingDayGrid (artifacts/tx-os/src/pages/protocol.tsx) now renders inside
  its own scroll container (max-h-[60vh] overflow-y-auto overscroll-contain).
- New scrollRef + useEffect: when the selected day is today and the now-line
  is within grid hours (showNow), sets scrollTop = nowTop - clientHeight/3
  so the red line sits ~1/3 from the top; otherwise resets scroll to top.
  Effect deps: [dayKey, showNow, scrollSignal] — not re-run on minute ticks,
  so it never fights user scrolling.
- "اليوم" button bumps a todayScrollSignal counter passed as optional
  scrollSignal prop, re-scrolling even when the selected day is already today.
- Removed the previous outer max-h-[70vh] wrapper around BookingDayGrid to
  avoid nested scrollbars.
- No changes to booking block layout, statuses, or list view. RTL-safe
  (logical properties untouched).
- tsc --noEmit clean for tx-os; architect review addressed (60vh per spec).
2026-07-08 12:43:22 +00:00
Replit Agent c8be955b64 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 71e93092-50ee-4ad4-8084-dc6ab3f8bf76
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
2026-07-08 12:40:54 +00:00
Replit Agent 6f47e3ee71 Add functionality to select and delete multiple bookings
Implements bulk booking deletion with API endpoint, UI elements for selection, and updated localization files.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b10c2dde-049d-45ea-9f02-7d538e07d5de
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/EHswFlf
Replit-Helium-Checkpoint-Created: true
2026-07-08 12:36:17 +00:00
Replit Agent 4c770fc49f Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 373ab23e-44fc-4e5d-aa97-2e9a57b9e433
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
2026-07-08 12:30:15 +00:00
Replit Agent 14a02e978f Improve table alignment and action button presentation
Updates table cell alignment to center content, replaces "Cancel" action with a Ban icon, and adjusts header alignment for a more professional and consistent UI.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 7586707e-fb86-49e4-96d4-0802eb9f8d87
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
2026-07-08 12:27:54 +00:00
Replit Agent 896993da73 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a30ab4f7-3b15-4737-b37d-9452711461e5
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
2026-07-08 12:25:27 +00:00
Replit Agent 2ac960b69a Update booking display with index numbers and structured details
Refactors the `ProtocolPage` component to include an index column for bookings and restructures the booking detail view into a fixed grid layout.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 9637de15-465f-4172-bea0-f0b45a37bd23
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
2026-07-08 12:19:27 +00:00
Replit Agent 1b263a9f05 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: bba99012-85e7-4a4e-9780-0b007f67499d
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
2026-07-08 12:15:56 +00:00
Replit Agent 79a9e5236d 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
2026-07-08 11:12:41 +00:00
Replit Agent 3a20a334bd Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ce4771fc-f082-462d-954a-91c8cadbf2c7
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
2026-07-08 11:08:47 +00:00
Riyadh 459999a9e2 Preserve meeting metadata when duplicating and fix switch alignment in RTL
Update executive meeting duplication to maintain `isExternal` and `rowColor` properties, and adjust switch component's RTL translation for correct alignment.
2026-07-08 10:06:53 +00:00
Replit Agent c54a6972ec Preserve meeting metadata when duplicating and fix switch alignment in RTL
Update executive meeting duplication to maintain `isExternal` and `rowColor` properties, and adjust switch component's RTL translation for correct alignment.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: f7213345-78e8-4ed4-a72e-0e4b9bc6e00f
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
2026-07-08 10:06:53 +00:00
Riyadh 5a3d7464d9 Transitioned from Plan to Build mode 2026-07-08 08:32:24 +00:00
Replit Agent f829e1fac6 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: f4f13f6b-7ba5-4e00-b88f-162970fdea20
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/9OolNGV
Replit-Helium-Checkpoint-Created: true
2026-07-08 08:32:24 +00:00
Riyadh bd62b79d0c Collapsible booking cards in Protocol → Bookings list (Task #676)
Original task: make the protocol booking-requests LIST cards collapsed by
default, showing a compact summary and expanding full details only when opened;
add numbering, show the booking reference, and highlight today's bookings green.

Changes (artifacts/tx-os/src/pages/protocol.tsx):
- renderBookingCard(b, index) now renders a collapsible card. Collapsed shows
  only: sequence number (1-based over the current/filtered list), title,
  room · date-time range, booking reference, status/public pills, and a chevron.
- Summary header is a real <button type="button"> with onClick toggle and
  aria-expanded for keyboard accessibility.
- Expanded reveals the previously-shown detail fields (requester, department,
  meeting type, entity, phone, attendee count, purpose, key attendees) and the
  action buttons (approve/reject/cancel/edit/delete) — no functionality lost.
- Bookings starting today (sameYmd(startsAt, now)) get a green card
  (emerald border/bg/ring) plus an "اليوم"/"Today" badge.
- Added expandedBookings Set state + toggleBookingExpanded near booking state.
- List map updated to pass 1-based index.

i18n: added protocol.bookings.today ("اليوم" / "Today") to ar.json and en.json.

Scope: list view only; calendar view, public request form, and other tabs
unchanged. Client-only "today" derivation, no backend changes.

Verification: tsc --noEmit passes; architect code review PASSED (no critical
issues). Screenshot skipped — protocol page requires auth.
2026-07-07 14:08:51 +00:00
Replit Agent 0808d8bdeb Collapsible booking cards in Protocol → Bookings list (Task #676)
Original task: make the protocol booking-requests LIST cards collapsed by
default, showing a compact summary and expanding full details only when opened;
add numbering, show the booking reference, and highlight today's bookings green.

Changes (artifacts/tx-os/src/pages/protocol.tsx):
- renderBookingCard(b, index) now renders a collapsible card. Collapsed shows
  only: sequence number (1-based over the current/filtered list), title,
  room · date-time range, booking reference, status/public pills, and a chevron.
- Summary header is a real <button type="button"> with onClick toggle and
  aria-expanded for keyboard accessibility.
- Expanded reveals the previously-shown detail fields (requester, department,
  meeting type, entity, phone, attendee count, purpose, key attendees) and the
  action buttons (approve/reject/cancel/edit/delete) — no functionality lost.
- Bookings starting today (sameYmd(startsAt, now)) get a green card
  (emerald border/bg/ring) plus an "اليوم"/"Today" badge.
- Added expandedBookings Set state + toggleBookingExpanded near booking state.
- List map updated to pass 1-based index.

i18n: added protocol.bookings.today ("اليوم" / "Today") to ar.json and en.json.

Scope: list view only; calendar view, public request form, and other tabs
unchanged. Client-only "today" derivation, no backend changes.

Verification: tsc --noEmit passes; architect code review PASSED (no critical
issues). Screenshot skipped — protocol page requires auth.
2026-07-07 14:08:51 +00:00
Riyadh 6746b42ab9 Transitioned from Plan to Build mode 2026-07-07 14:05:29 +00:00
Replit Agent 15ca31e2b1 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 9ab44388-c1e9-4bc5-8e27-0bf7b7c3a08a
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/2yCHgdy
Replit-Helium-Checkpoint-Created: true
2026-07-07 14:05:29 +00:00
Riyadh 8e526d7365 Fix Arabic RTL calendar in public booking date pickers (Task #674)
Problem: In the public room-booking form (protocol-request.tsx), the date
picker calendar showed English weekday headers and a scrambled day grid under
RTL. Root cause: the shared Calendar (react-day-picker v9) was rendered without
`locale` or `dir`, so weekdays stayed English and its flex week rows reversed
visually under CSS RTL.

Changes (artifacts/tx-os/src/components/ui/calendar.tsx):
- Pass date-fns Arabic locale (`ar`) + `dir="rtl"` when Arabic is active, else
  `enUS` + `dir="ltr"`; placed before `{...props}` so callers can still override.
- Added `formatCaption` and `formatDay` formatters (alongside existing
  `formatMonthDropdown`) using Intl `numberingSystem: "latn"` to keep Western
  digits, matching the app-wide AR_LATN convention (Arabic locale would
  otherwise emit Arabic-Indic numerals).
- `isArabic()` now resolves from `document.documentElement.lang`/`dir` first,
  falling back to `i18n.language`.

Deviation from plan: after code review, changed language detection from
i18n.language-only to DOM-first. Reason: the booking page forces Arabic by
setting documentElement.lang/dir without changing i18n.language, so a guest with
saved English would otherwise get an English/LTR calendar on that page.

Out of scope (per user): no time-picker or من/إلى changes.

Verified: `tsc --noEmit` passes for @workspace/tx-os. English calendar
unaffected (default enUS/LTR).
2026-07-07 14:00:17 +00:00
Replit Agent c93e747ff1 Fix Arabic RTL calendar in public booking date pickers (Task #674)
Problem: In the public room-booking form (protocol-request.tsx), the date
picker calendar showed English weekday headers and a scrambled day grid under
RTL. Root cause: the shared Calendar (react-day-picker v9) was rendered without
`locale` or `dir`, so weekdays stayed English and its flex week rows reversed
visually under CSS RTL.

Changes (artifacts/tx-os/src/components/ui/calendar.tsx):
- Pass date-fns Arabic locale (`ar`) + `dir="rtl"` when Arabic is active, else
  `enUS` + `dir="ltr"`; placed before `{...props}` so callers can still override.
- Added `formatCaption` and `formatDay` formatters (alongside existing
  `formatMonthDropdown`) using Intl `numberingSystem: "latn"` to keep Western
  digits, matching the app-wide AR_LATN convention (Arabic locale would
  otherwise emit Arabic-Indic numerals).
- `isArabic()` now resolves from `document.documentElement.lang`/`dir` first,
  falling back to `i18n.language`.

Deviation from plan: after code review, changed language detection from
i18n.language-only to DOM-first. Reason: the booking page forces Arabic by
setting documentElement.lang/dir without changing i18n.language, so a guest with
saved English would otherwise get an English/LTR calendar on that page.

Out of scope (per user): no time-picker or من/إلى changes.

Verified: `tsc --noEmit` passes for @workspace/tx-os. English calendar
unaffected (default enUS/LTR).
2026-07-07 14:00:17 +00:00
Riyadh 6cca0de595 Transitioned from Plan to Build mode 2026-07-07 13:55:40 +00:00
Replit Agent cb58db43e1 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: aaf0ef05-30ab-4b24-a45c-6f250f8b4024
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/9p2nH8u
Replit-Helium-Checkpoint-Created: true
2026-07-07 13:55:40 +00:00
Riyadh 1000ed3a4f Task #673: rename booking nav labels (Arabic)
Renamed two Arabic sidebar tab labels in the protocol (room-booking)
module in artifacts/tx-os/src/locales/ar.json:
- protocol.tabs.bookings: "حجوزات القاعات" → "حجز القاعات"
- protocol.tabs.external: "اللقاءات الخارجية" → "اجتماعات خارجية"

English translations left unchanged. The chart label
"اللقاءات الخارجية حسب الحالة" was left as-is per the task's out-of-scope
note. Text-only change, no logic touched.
2026-07-07 12:50:29 +00:00
Replit Agent 3398af3340 Task #673: rename booking nav labels (Arabic)
Renamed two Arabic sidebar tab labels in the protocol (room-booking)
module in artifacts/tx-os/src/locales/ar.json:
- protocol.tabs.bookings: "حجوزات القاعات" → "حجز القاعات"
- protocol.tabs.external: "اللقاءات الخارجية" → "اجتماعات خارجية"

English translations left unchanged. The chart label
"اللقاءات الخارجية حسب الحالة" was left as-is per the task's out-of-scope
note. Text-only change, no logic touched.
2026-07-07 12:50:29 +00:00
Riyadh aafd0fee4b Transitioned from Plan to Build mode 2026-07-07 12:49:56 +00:00
Replit Agent 43345b3ddb Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 09578e26-4b53-4025-ab2d-40f5594d9e11
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/9p2nH8u
Replit-Helium-Checkpoint-Created: true
2026-07-07 12:49:56 +00:00
Riyadh 96f9f8150e 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.

Deploy safety / backfill:
- lib/db pre-push-cleanup (runs before every drizzle push, dev + deploy)
  now backfills booking_reference for any pre-existing rows
  (RB-{year(created_at)}-{pad4(id)}) BEFORE the NOT NULL + unique
  constraint is enforced, so pushing the constraint on a populated table
  can never fail. Idempotent: only updates NULL rows.

Verification:
- Public booking POST returns a reference; external-without-org rejected
  (400); DB has zero null references after push. Re-running push logs
  "No protocol_room_bookings booking_reference to backfill" (idempotent).
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
  unrelated to this task (files untouched).
2026-07-07 12:38:03 +00:00
Replit Agent b38e2f9a90 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.

Deploy safety / backfill:
- lib/db pre-push-cleanup (runs before every drizzle push, dev + deploy)
  now backfills booking_reference for any pre-existing rows
  (RB-{year(created_at)}-{pad4(id)}) BEFORE the NOT NULL + unique
  constraint is enforced, so pushing the constraint on a populated table
  can never fail. Idempotent: only updates NULL rows.

Verification:
- Public booking POST returns a reference; external-without-org rejected
  (400); DB has zero null references after push. Re-running push logs
  "No protocol_room_bookings booking_reference to backfill" (idempotent).
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
  unrelated to this task (files untouched).
2026-07-07 12:38:03 +00:00
Riyadh dd2816a1a9 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).
2026-07-07 12:34:52 +00:00
Replit Agent 199f0c025e 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).
2026-07-07 12:34:52 +00:00
Riyadh 3ff1338c7e 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.
2026-07-07 12:26:19 +00:00
Replit Agent 1b93c2cf02 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.
2026-07-07 12:26:19 +00:00
Riyadh e167b3ef45 Transitioned from Plan to Build mode 2026-07-07 12:15:51 +00:00
Replit Agent 553df28571 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: f9b81002-cb22-4085-8440-b6583bc66c0c
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE
Replit-Helium-Checkpoint-Created: true
2026-07-07 12:15:51 +00:00
Riyadh 97f0b9217d Task #668: Rename Protocol side-menu items (bilingual AR/EN)
Relabeled three Protocol (العلاقات العامة والمراسم) menu items to match real usage:
- photos: "تصوير" -> "الصور" / "Photos"
- gifts (item catalog where أصناف are added): "الإهداءات" -> "المستودع" / "Warehouse"
- issues (giving gifts out): "طلبات الصرف" -> "الإهداءات" / "Dedications"

Swapped menu icons to match new meanings: gifts->Warehouse, issues->Gift,
photos->ImageIcon. Removed now-unused imports (PackageCheck, Camera).

Grouped المستودع + الإهداءات under one expandable submenu (parent label
"الهدايا"/"Gifts", new i18n key protocol.tabs.giftsGroup, HandHeart icon,
ChevronDown toggle). Group auto-expands when a child tab is active; state
giftsGroupOpen. RTL-aware (ms-auto chevron, md:ps-6 child indent).

Aligned in-screen wording with new names in both locales:
dashboard.pendingIssues, issues.new, issues.empty reworded from
issuance/صرف to dedication/إهداء.

Deviation: submenu grouping and parent-group naming ("الهدايا") were an
interpretation of the user's "sub-menu" request (user_query was unavailable
during planning). Kept scope tight: remaining secondary "issuance/صرف"
strings in reports/actions/dashboard stats (e.g. giftsIssuedThisMonth,
actions.issued, reports.byGift) were left unchanged and proposed as a
follow-up.

Unchanged (deliberately): internal tab keys (gifts/issues/photos) and URL
slugs (gifts, gift-issues, photos) so deep links keep working. No feature,
API, or data changes.

Verified: pnpm --filter @workspace/tx-os run typecheck passes; architect
code review passed after issues icon corrected to Gift.
2026-07-07 11:58:20 +00:00
Replit Agent 2c244ae6e1 Task #668: Rename Protocol side-menu items (bilingual AR/EN)
Relabeled three Protocol (العلاقات العامة والمراسم) menu items to match real usage:
- photos: "تصوير" -> "الصور" / "Photos"
- gifts (item catalog where أصناف are added): "الإهداءات" -> "المستودع" / "Warehouse"
- issues (giving gifts out): "طلبات الصرف" -> "الإهداءات" / "Dedications"

Swapped menu icons to match new meanings: gifts->Warehouse, issues->Gift,
photos->ImageIcon. Removed now-unused imports (PackageCheck, Camera).

Grouped المستودع + الإهداءات under one expandable submenu (parent label
"الهدايا"/"Gifts", new i18n key protocol.tabs.giftsGroup, HandHeart icon,
ChevronDown toggle). Group auto-expands when a child tab is active; state
giftsGroupOpen. RTL-aware (ms-auto chevron, md:ps-6 child indent).

Aligned in-screen wording with new names in both locales:
dashboard.pendingIssues, issues.new, issues.empty reworded from
issuance/صرف to dedication/إهداء.

Deviation: submenu grouping and parent-group naming ("الهدايا") were an
interpretation of the user's "sub-menu" request (user_query was unavailable
during planning). Kept scope tight: remaining secondary "issuance/صرف"
strings in reports/actions/dashboard stats (e.g. giftsIssuedThisMonth,
actions.issued, reports.byGift) were left unchanged and proposed as a
follow-up.

Unchanged (deliberately): internal tab keys (gifts/issues/photos) and URL
slugs (gifts, gift-issues, photos) so deep links keep working. No feature,
API, or data changes.

Verified: pnpm --filter @workspace/tx-os run typecheck passes; architect
code review passed after issues icon corrected to Gift.
2026-07-07 11:58:20 +00:00
Riyadh bd50a93db5 Transitioned from Plan to Build mode 2026-07-07 11:52:01 +00:00
Replit Agent 164c9224c0 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: cfe5d87d-403c-4df1-89f7-8a24cc059901
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE
Replit-Helium-Checkpoint-Created: true
2026-07-07 11:52:01 +00:00
Riyadh 049d01def5 Task #666: Today & Book buttons in room-booking calendar view
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets both selectedDay and a new
  controlled calendarMonth state to new Date(), so the month picker, day
  time-grid, and red now-line all snap to today. The Calendar is now
  controlled via month/onMonthChange (defaultMonth is initial-only and did
  not re-navigate after mount).
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
  the existing toolbar new-booking button. Opens the new-booking dialog
  pre-filled with the currently selected day.

Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
  bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
  local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
  ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
  Arabic RTL.

No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
2026-07-07 10:36:01 +00:00
Replit Agent ee3cf1178e Task #666: Today & Book buttons in room-booking calendar view
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets both selectedDay and a new
  controlled calendarMonth state to new Date(), so the month picker, day
  time-grid, and red now-line all snap to today. The Calendar is now
  controlled via month/onMonthChange (defaultMonth is initial-only and did
  not re-navigate after mount).
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
  the existing toolbar new-booking button. Opens the new-booking dialog
  pre-filled with the currently selected day.

Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
  bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
  local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
  ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
  Arabic RTL.

No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
2026-07-07 10:36:01 +00:00
Riyadh a8f4693fce Task #666: Today & Book buttons in room-booking calendar view
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets selectedDay to new Date(), so
  the month picker, day time-grid, and red now-line all snap to today.
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
  the existing toolbar new-booking button. Opens the new-booking dialog
  pre-filled with the currently selected day.

Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
  bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
  local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
  ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
  Arabic RTL.

No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
2026-07-07 10:34:16 +00:00
Replit Agent 644eacc0c4 Task #666: Today & Book buttons in room-booking calendar view
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets selectedDay to new Date(), so
  the month picker, day time-grid, and red now-line all snap to today.
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
  the existing toolbar new-booking button. Opens the new-booking dialog
  pre-filled with the currently selected day.

Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
  bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
  local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
  ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
  Arabic RTL.

No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
2026-07-07 10:34:16 +00:00
Riyadh 2bea68c92d Transitioned from Plan to Build mode 2026-07-07 10:31:59 +00:00
Replit Agent e958a09219 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 38a7c563-0eff-4293-ab86-44737ed711e3
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE
Replit-Helium-Checkpoint-Created: true
2026-07-07 10:31:59 +00:00
Riyadh 83f07c7a22 Task #662: Google-Calendar-style room booking calendar view
Replaced the Protocol bookings "Calendar" view (previously an agenda list
grouped by day) with a two-panel Google-Calendar layout in
artifacts/tx-os/src/pages/protocol.tsx:
- LEFT: a mini month day-picker (reuses components/ui/calendar.tsx,
  react-day-picker single mode) driving a selectedDay state. Days that
  have bookings get a small sky dot via a `booked` modifier.
- RIGHT: a single-day vertical time-grid (BookingDayGrid) over working
  hours 07:00–22:00 (56px/hour). Bookings render as absolutely-positioned
  blocks: top = start offset, height = duration (min 22px), colored by
  status (BOOKING_BLOCK_STYLE) with an inline-start accent border and a
  title/room/time label.
- Overlapping bookings are laid out side-by-side via interval-partition
  column assignment (layoutDayBookings), chained overlaps share a column
  count.
- A live RED current-time line + red dot (useNow, 60s) shows only when the
  selected day is today and within grid hours.

Details:
- View-only (no drag/create); single-day grid only. No backend changes.
- RTL/i18n: logical props (insetInlineStart, border-s) so it mirrors in
  Arabic; added protocol.bookings.emptyDay to ar.json + en.json.
- Blocks with no visible intersection with the grid range are skipped
  (fixes a phantom-block bug for bookings fully before 7am / after 10pm,
  found in code review).
- Removed the now-unused groupBookingsByDay helper.
- List toggle preserved. tx-os typecheck passes.
2026-07-07 10:27:15 +00:00
Replit Agent f46199ac5f Task #662: Google-Calendar-style room booking calendar view
Replaced the Protocol bookings "Calendar" view (previously an agenda list
grouped by day) with a two-panel Google-Calendar layout in
artifacts/tx-os/src/pages/protocol.tsx:
- LEFT: a mini month day-picker (reuses components/ui/calendar.tsx,
  react-day-picker single mode) driving a selectedDay state. Days that
  have bookings get a small sky dot via a `booked` modifier.
- RIGHT: a single-day vertical time-grid (BookingDayGrid) over working
  hours 07:00–22:00 (56px/hour). Bookings render as absolutely-positioned
  blocks: top = start offset, height = duration (min 22px), colored by
  status (BOOKING_BLOCK_STYLE) with an inline-start accent border and a
  title/room/time label.
- Overlapping bookings are laid out side-by-side via interval-partition
  column assignment (layoutDayBookings), chained overlaps share a column
  count.
- A live RED current-time line + red dot (useNow, 60s) shows only when the
  selected day is today and within grid hours.

Details:
- View-only (no drag/create); single-day grid only. No backend changes.
- RTL/i18n: logical props (insetInlineStart, border-s) so it mirrors in
  Arabic; added protocol.bookings.emptyDay to ar.json + en.json.
- Blocks with no visible intersection with the grid range are skipped
  (fixes a phantom-block bug for bookings fully before 7am / after 10pm,
  found in code review).
- Removed the now-unused groupBookingsByDay helper.
- List toggle preserved. tx-os typecheck passes.
2026-07-07 10:27:15 +00:00
Riyadh 42d8ae13ad Transitioned from Plan to Build mode 2026-07-07 10:20:17 +00:00
Replit Agent a324c42b1f Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: c344102b-84a9-44b9-a9a2-d118fa26a941
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE
Replit-Helium-Checkpoint-Created: true
2026-07-07 10:20:17 +00:00
Riyadh 5bfe56f18c Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
  types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
  Content-Disposition: attachment (with optional ?name base + derived ext);
  default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
  photos as a ZIP (guarded by requireProtocolAccess), named from the album;
  unreadable objects are skipped rather than failing the archive. Empty album
  returns 404.
- Added archiver@8 dependency (+@types/archiver dev).

Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header; rendered disabled
  (not hidden) when the album has no photos.
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:37:15 +00:00
Riyadh a36adb5e9e Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
  types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
  Content-Disposition: attachment (with optional ?name base + derived ext);
  default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
  photos as a ZIP (guarded by requireProtocolAccess), named from the album;
  unreadable objects are skipped rather than failing the archive. Empty album
  returns 404.
- Added archiver@8 dependency (+@types/archiver dev).

Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header (hidden when empty).
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:34:06 +00:00
Riyadh 22af2e8848 Transitioned from Plan to Build mode 2026-07-07 09:25:28 +00:00
Riyadh 224e69f111 Add functionality to manage visit photos and albums for protocols
Introduce new database tables for photo albums and photos, add API endpoints for CRUD operations on albums and photos, and implement frontend components for managing photo albums.
2026-07-07 09:14:07 +00:00
Riyadh 05586fc997 Transitioned from Plan to Build mode 2026-07-07 08:58:54 +00:00
Riyadh 36c1dee00a Make external meeting titles display in red
Update the styling of the MeetingRow component to conditionally apply red text color to the meeting title when the `isExternal` property is true.
2026-07-07 08:24:00 +00:00
Riyadh 1d7ae96b9c Update tab name for dedications in protocol app
Rename "Gifts & Shields" to "Dedications" in English and "الهدايا والدروع" to "الإهداءات" in Arabic within the protocol app's locale files.
2026-07-07 08:06:53 +00:00
Riyadh e0d85ba12c feat(protocol): shareable public booking link bar in Room Bookings
Task #655: staff need a one-click way to copy and share the public
(no-login) room-booking link with guests.

- Add a link bar at the top of the "حجوزات القاعات" (Room Bookings)
  section showing the absolute public URL, with a "نسخ الرابط" copy
  button that gives toast + a temporary "تم النسخ" confirmation state.
- URL is built from window.location.origin + BASE_URL (trailing slash
  stripped) + "/protocol/request", so it works on root and subpath
  deploys and when pasted into a browser/WhatsApp.
- Clipboard uses navigator.clipboard with a textarea+execCommand
  fallback; fallback now checks execCommand's return and reports failure
  (destructive toast) instead of falsely showing success. Textarea is
  removed in a finally block. Copied-state timer stored in a ref and
  cleared on repeat clicks to avoid a reset race.
- URL text forced dir="ltr" inside the RTL layout; new i18n keys
  (shareLinkTitle, copyLink, linkCopied, linkCopyFailed) added under
  protocol.bookings in both ar.json and en.json.

Verified: tsc typecheck passes; /protocol serves 200; module transforms
cleanly via Vite; architect review issues (fallback false-success,
timer race) fixed.
2026-07-06 12:20:17 +00:00
Riyadh f89ef64315 Transitioned from Plan to Build mode 2026-07-06 12:16:35 +00:00
Riyadh c676f51f13 Add professional Arabic date and time picker to booking form
Introduce a custom DateTimePicker component for improved date and time selection in the protocol request form, replacing native inputs with an Arabic-localized, RTL-compatible interface.
2026-07-06 11:40:36 +00:00
Riyadh 74327027c3 Add attendee count field to booking requests and display it
Updates API schemas and database to include attendee count for bookings, modifies the public request form to accept this new optional field, and displays the attendee count on the booking details.
2026-07-06 11:33:58 +00:00
Riyadh 50030ea29f Add public interface for submitting room booking requests
Introduces a new public-facing API endpoint and UI for submitting room booking requests without authentication, including rate limiting and input validation.
2026-07-06 11:18:14 +00:00
Riyadh 637f4410b2 Transitioned from Plan to Build mode 2026-07-06 11:05:30 +00:00
Riyadh ec7b1b7af2 Move navigation to a vertical sidebar on the right for better layout
Restructures the page layout to move the navigation tabs from a horizontal bar to a vertical sidebar on the right, enhancing RTL support.
2026-07-06 10:13:41 +00:00
Riyadh 159a044c24 Task #649: Public Relations & Protocol module (bookings, external meetings, gifts)
Separate additive module, fully independent from executive-meetings. All tables
prefixed protocol_, routes under /protocol, Arabic-first RTL UI.

This session: addressed code-review rejection by switching protocol
authorization from hardcoded role-name checks to scoped permission checks.
- auth.ts: requireProtocolAccess now guards on the "protocol.access" permission
  (same permission that gates the home-screen tile via app_permissions), so
  backend read access aligns with tile visibility.
- protocol.ts: replaced role-name helpers with makeRequirePerm + PROTOCOL_PERMS
  map (protocol.access / request / mutate / approve / rooms.manage / audit.read).
  Booking auto-approve and /protocol/me capabilities now derive from permissions.
- seed.ts: seeds all 6 scoped permissions and maps them to admin + the 5 protocol
  roles (access:6, request:5, mutate:4, approve:3, audit:3, rooms:2 role grants).

Verified: typecheck clean (tx-os, api-server protocol/auth, scripts), seed applied,
DB mappings confirmed, /api/protocol/me returns 401 unauth. Architect review PASS.

Deviations: none from spec. Frontend already consumed capability flags from /me,
so no frontend changes were needed for the permission switch.
2026-07-06 09:54:30 +00:00
Riyadh 2f84bd2c6f Task #649: complete Public Relations & Protocol module (fix 8 review gaps)
Addresses the 8 spec gaps that blocked prior completion, all additive and
scoped to the protocol_* module / /protocol routes. executive-meetings untouched.

Backend (api-server/routes/protocol.ts, lib/db schema, seed):
- External meetings create as "pending" (status dropped from create schema);
  added approve + reject endpoints with state-conditional WHERE status='pending'
  and 409 on contention; patch status enum restricted to completed/cancelled.
- Added approvedBy/approvedAt/rejectionReason columns + status index; default
  status now "pending".
- Booking reject and gift-issue reject made state-conditional (409 on 0 rows).
- Dashboard: roomsAvailableNow (NOT EXISTS), giftsIssuedThisMonth/Qty;
  upcomingExternal filtered to future pending|approved.
- Reports: added externalMeetings summary counts by status.
- Seed: room names corrected (AR + EN).

Frontend (tx-os/pages/protocol.tsx, App.tsx, locales):
- URL-synced tabs under /protocol/:tab (slug<->tab maps).
- Dedicated Rooms tab (moved out of Gifts); RoomDialog isActive toggle.
- External approve/reject buttons + rejectionReason display; ExternalDialog
  create omits status, edit limited to completed/cancelled.
- Dashboard cards clickable to navigate; reports external section.
- Bookings list/calendar (grouped-by-day) view toggle.
- New i18n keys in ar.json + en.json.

Verification: tx-os + protocol.ts typecheck clean; drizzle push + re-seed done;
both workflows restarted; /api/protocol/me returns 401 unauth; architect
re-review approved. Pre-existing typecheck errors in executive-meetings.ts and
push.ts are unrelated and were not touched.

Note: had to rebuild lib/db declarations (tsc --build --force) so api-server
project references picked up the new schema columns.
2026-07-06 09:46:12 +00:00
Riyadh 874d49c439 Add Public Relations & Protocol module (Task #649)
New, fully separate app module "العلاقات العامة والمراسم / Public
Relations and Protocol". Does not touch executive-meetings; all new
tables are prefixed protocol_ and are additive-only, routes under
/protocol.

Includes:
- 6 protocol_ tables (rooms, room bookings, external meetings, gifts,
  gift issues, audit log) in lib/db.
- API routes + role-scoped middleware (protocol_super_admin, pr_manager,
  officer, requester, viewer) with requireProtocolAccess gating.
- Room-booking conflict prevention (overlap rule newStart<existingEnd
  AND newEnd>existingStart vs pending/approved) with AR error message.
- Gifts/shields (دروع) registry + issuance with stock tracking.
- Bilingual AR/EN frontend page (tabs + dialogs) at /protocol, app tile,
  i18n keys, and seed data (roles, permission, default rooms).

Concurrency/security hardening from code review:
- All role guards now validate users.isActive (inactive-user bypass fix).
- Per-room SELECT ... FOR UPDATE lock serializes booking check-then-write.
- Approve paths re-read inside the transaction and use state-conditional
  updates (WHERE ... AND status='pending' RETURNING).
- Gift issuance claims the issue conditionally, then does an atomic
  conditional stock decrement; failure rolls back the claim.

Verified: drizzle push (6 tables), seed, frontend + api-server typecheck
(only pre-existing errors in untouched files), unauth gating returns 401,
conflict predicate confirmed. Architect review PASS.
2026-07-06 09:25:40 +00:00
Riyadh 7ee4c0858a Center meeting and attendee information within table cells
Apply 'text-center' class directly to the table data (td) elements for meeting and attendee columns to ensure consistent alignment with the time column.
2026-07-05 12:37:08 +00:00
Riyadh 7f81eb01b2 Center meeting details and attendee information horizontally
Update `artifacts/tx-os/src/pages/executive-meetings.tsx` to apply horizontal centering to meeting titles, locations, attendee group headers, attendee names, and merge placeholders for improved visual consistency.
2026-06-04 08:02:19 +00:00
Riyadh bb3ac7cc1c Merge Gitea; keep Replit newer work 2026-06-03 20:24:13 +00:00
Riyadh 34f969178e Create a script to easily sync local work with the Gitea server
Add a new shell script to automate fetching, merging, and pushing changes to the Gitea remote repository.
2026-06-03 20:14:00 +00:00
Riyadh 92de2e374e Transitioned from Plan to Build mode 2026-06-03 20:02:36 +00:00
Riyadh f189399fbf Transitioned from Plan to Build mode 2026-06-02 07:00:27 +00:00
Riyadh 487304344d Task #646: Keep external meeting text navy, not red
Problem: When a meeting was flagged external, the row was tinted red
(pink bg + red number box — desired) but the title cell text and the
attendee list text were also forced to red (#b91c1c), which the user
did not want.

Change (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Meeting title cell: always use navy text (#0B1E3F) instead of switching
  to red on meeting.isExternal.
- Attendee list <ul>: always use navy text (#0B1E3F) instead of switching
  to red on externalText.
- Removed the now-dead `externalText` prop end-to-end (call-site value,
  type declaration, and destructured default) to keep typecheck clean.

Unchanged: the red row tint, red number box, and all external-meeting
behavior (e.g. auto-postpone rules). Internal/virtual meetings unaffected.

Verified: tx-os typecheck passes; HMR applied with no console errors.
2026-06-02 06:57:42 +00:00
Riyadh 9821f7af1c Transitioned from Plan to Build mode 2026-06-02 06:56:14 +00:00
Riyadh 348db18bcd Improve notification deep-linking and add Arabic meeting reminders
Enhance notification handling to preserve deep-link parameters, add relatedId and relatedType to notifications for better association, and implement Arabic pluralization for meeting reminders.
2026-06-01 14:48:26 +00:00
Riyadh 3c1b3107e3 Transitioned from Plan to Build mode 2026-06-01 14:30:10 +00:00
Riyadh 0c35fbe395 #641 Schedule cell polish: alignment + external text colour
- Attendees column no longer centres its content. AttendeeFlow's <ul>
  switched from justify-center to justify-start; AttendeeGroup header
  and subheading rows (both committed and pending) switched from
  text-center to text-start. Restores cell-start alignment (right in
  AR, left in EN) the user expected on fresh inline-add and on
  existing rows.
- External meetings now render the meeting title cell text and the
  attendee names in red (#b91c1c), matching the row tint family, in
  addition to the existing red background. Implemented by:
  * Conditional text-[#b91c1c] / text-[#0B1E3F] on the meeting <td>
    based on meeting.isExternal.
  * New optional `externalText` prop on AttendeeFlowSharedProps,
    threaded from AttendeesCell (derived from meeting.isExternal) and
    applied as a colour class on the AttendeeFlow root <ul> so all
    attendee names inherit red. Group headers and subheadings keep
    their neutral navy palette per scope.
- Non-external rows and the cancelled/highlighted white-on-red # cell
  are untouched. Print/PDF path reuses the same renderer so the red
  carries through automatically.
- No schema/API/migration changes. TS clean.

Files: artifacts/tx-os/src/pages/executive-meetings.tsx
2026-05-25 12:54:19 +00:00
Riyadh 6f74fa1045 #641 Schedule cell polish: alignment + external text colour
- Attendees column no longer centres its content. AttendeeFlow's <ul>
  switched from justify-center to justify-start; AttendeeGroup header
  and subheading rows (both committed and pending) switched from
  text-center to text-start. Restores cell-start alignment (right in
  AR, left in EN) the user expected on fresh inline-add and on
  existing rows.
- External meetings now render the meeting title cell text and the
  attendee names in red (#b91c1c), matching the row tint family, in
  addition to the existing red background. Implemented by:
  * Conditional text-[#b91c1c] / text-[#0B1E3F] on the meeting <td>
    based on meeting.isExternal.
  * New optional `externalText` prop on AttendeeFlowSharedProps,
    threaded from AttendeesCell (derived from meeting.isExternal) and
    applied as a colour class on the AttendeeFlow root <ul> so all
    attendee names inherit red. Group headers and subheadings keep
    their neutral navy palette per scope.
- Non-external rows and the cancelled/highlighted white-on-red # cell
  are untouched. Print/PDF path reuses the same renderer so the red
  carries through automatically.
- No schema/API/migration changes. TS clean.

Files: artifacts/tx-os/src/pages/executive-meetings.tsx
2026-05-25 12:51:46 +00:00
Riyadh 776fd8460e Transitioned from Plan to Build mode 2026-05-25 12:45:26 +00:00
11 changed files with 2315 additions and 257 deletions
@@ -2210,6 +2210,18 @@ router.post(
isHighlighted: 0,
notes: stripTagsToPlainTextOrNull(source.notes),
attachments: source.attachments,
// #678: Carry the external-meeting flag onto the copy. Omitting it
// silently reset duplicates/repeats to "internal" (default false),
// losing the red tint + no-auto-postpone behavior of the source.
isExternal: source.isExternal,
// Same class of bug: rowColor is a shared editorial signal about
// the meeting (urgent / VIP), and the merge overlay is user
// content — both should survive a duplicate. mergeText is
// re-sanitized like the other rich-text fields above.
rowColor: source.rowColor,
mergeStartColumn: source.mergeStartColumn,
mergeEndColumn: source.mergeEndColumn,
mergeText: source.mergeText ? sanitizeRichText(source.mergeText) : source.mergeText,
createdBy: userId,
updatedBy: userId,
};
+189 -6
View File
@@ -1,6 +1,19 @@
import { Router, type IRouter } from "express";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import { and, asc, desc, eq, gte, inArray, lt, lte, ne, sql } from "drizzle-orm";
import {
and,
asc,
desc,
eq,
gte,
ilike,
inArray,
lt,
lte,
ne,
or,
sql,
} from "drizzle-orm";
import { z, type ZodType } from "zod";
import { db } from "@workspace/db";
import {
@@ -165,12 +178,28 @@ const roomPatchSchema = roomCreateSchema.partial().refine(
{ message: "no fields to update" },
);
// A single "key attendee" (أبرز الحضور) row.
const keyAttendeeSchema = z.object({
name: z.string().trim().min(1).max(300),
position: z.string().trim().max(300).default(""),
side: z.enum(["internal", "external"]),
});
const keyAttendeesSchema = z.array(keyAttendeeSchema).max(100);
const bookingCreateSchema = z
.object({
roomId: z.number().int().positive(),
title: z.string().trim().min(1).max(500),
requesterName: z.string().trim().max(300).nullable().optional(),
requesterPhone: z.string().trim().max(40).nullable().optional(),
// The external entity name (اسم الجهة); only meaningful when
// meetingType is "external".
requesterOrg: z.string().trim().max(300).nullable().optional(),
department: z.string().trim().max(300).nullable().optional(),
meetingType: z.enum(["internal", "external"]).default("internal"),
purpose: z.string().trim().max(5000).nullable().optional(),
attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
keyAttendees: keyAttendeesSchema.optional().default([]),
startsAt: isoDateTime,
endsAt: isoDateTime,
notes: z.string().trim().max(5000).nullable().optional(),
@@ -185,7 +214,13 @@ const bookingPatchSchema = z
roomId: z.number().int().positive().optional(),
title: z.string().trim().min(1).max(500).optional(),
requesterName: z.string().trim().max(300).nullable().optional(),
requesterPhone: z.string().trim().max(40).nullable().optional(),
requesterOrg: z.string().trim().max(300).nullable().optional(),
department: z.string().trim().max(300).nullable().optional(),
meetingType: z.enum(["internal", "external"]).optional(),
purpose: z.string().trim().max(5000).nullable().optional(),
attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
keyAttendees: keyAttendeesSchema.optional(),
startsAt: isoDateTime.optional(),
endsAt: isoDateTime.optional(),
notes: z.string().trim().max(5000).nullable().optional(),
@@ -201,8 +236,14 @@ const publicBookingRequestSchema = z
title: z.string().trim().min(1).max(500),
requesterName: z.string().trim().min(1).max(300),
requesterPhone: z.string().trim().min(3).max(40),
// The external entity name (اسم الجهة). Required when meetingType is
// "external"; ignored/null for internal meetings.
requesterOrg: z.string().trim().max(300).nullable().optional(),
department: z.string().trim().max(300).nullable().optional(),
meetingType: z.enum(["internal", "external"]).default("internal"),
purpose: z.string().trim().max(5000).nullable().optional(),
attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
keyAttendees: keyAttendeesSchema.optional().default([]),
startsAt: isoDateTime,
endsAt: isoDateTime,
notes: z.string().trim().max(5000).nullable().optional(),
@@ -214,7 +255,18 @@ const publicBookingRequestSchema = z
.refine((v) => new Date(v.startsAt) < new Date(v.endsAt), {
message: "startsAt must be before endsAt",
path: ["endsAt"],
});
})
// When the meeting is with an external entity, its name (اسم الجهة) is
// required.
.refine(
(v) =>
v.meetingType !== "external" ||
(typeof v.requesterOrg === "string" && v.requesterOrg.trim().length > 0),
{
message: "requesterOrg is required for external meetings",
path: ["requesterOrg"],
},
);
const rejectSchema = z.object({
reason: z.string().trim().max(1000).nullable().optional(),
@@ -323,6 +375,28 @@ async function lockRoomRow(
return rows.length > 0;
}
// Build a human-friendly booking reference (رقم الحجز) from a row id and its
// creation date, e.g. "RB-2026-0042". Deterministic + unique because the id
// is unique.
function makeBookingReference(id: number, when: Date): string {
return `RB-${when.getFullYear()}-${String(id).padStart(4, "0")}`;
}
// Reserve the next booking id from the table's serial sequence so we can
// compute the booking reference and store it in the same INSERT (the column
// is NOT NULL). Advancing the sequence here keeps it in sync with the
// explicit id we insert.
async function reserveBookingId(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
): Promise<number> {
const result = await tx.execute(
sql`SELECT nextval(pg_get_serial_sequence('protocol_room_bookings', 'id')) AS id`,
);
const rows = (result as unknown as { rows: Array<{ id: number | string }> })
.rows;
return Number(rows[0].id);
}
async function hasBookingConflict(
executor: DbExecutor,
opts: {
@@ -462,15 +536,27 @@ router.post(
) {
throw new Error("__conflict__");
}
const isExternal = body.meetingType === "external";
const bookingId = await reserveBookingId(tx);
const [row] = await tx
.insert(protocolRoomBookingsTable)
.values({
id: bookingId,
roomId: body.roomId,
title: body.title,
requesterName: body.requesterName,
requesterPhone: body.requesterPhone,
requesterOrg: body.requesterOrg ?? null,
requesterOrg: isExternal ? (body.requesterOrg ?? null) : null,
department: body.department ?? null,
meetingType: body.meetingType,
purpose: body.purpose ?? null,
attendeeCount: body.attendeeCount ?? null,
keyAttendees: (body.keyAttendees ?? []).map((a) => ({
name: a.name,
position: a.position ?? "",
side: a.side,
})),
bookingReference: makeBookingReference(bookingId, new Date()),
startsAt,
endsAt,
status: "pending",
@@ -488,9 +574,12 @@ router.post(
});
return row;
});
// Deliberately return only an acknowledgement — never the stored row
// (which could leak internal ids / other data) to an anonymous caller.
res.status(201).json({ ok: true, id: created.id });
// Deliberately return only an acknowledgement plus the booking
// reference — never the full stored row (which could leak internal ids
// / other data) to an anonymous caller.
res
.status(201)
.json({ ok: true, id: created.id, bookingReference: created.bookingReference });
} catch (e) {
if (e instanceof Error && e.message === "__conflict__") {
res
@@ -644,6 +733,18 @@ router.get(
if (typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw))) {
conds.push(lte(protocolRoomBookingsTable.startsAt, new Date(toRaw)));
}
// Free-text staff search by booking reference (رقم الحجز) OR requester
// phone. Both are indexed; matched case-insensitively as a substring.
const qRaw = req.query.q;
if (typeof qRaw === "string" && qRaw.trim() !== "") {
const term = `%${qRaw.trim()}%`;
conds.push(
or(
ilike(protocolRoomBookingsTable.bookingReference, term),
ilike(protocolRoomBookingsTable.requesterPhone, term),
)!,
);
}
const rows = await db
.select()
.from(protocolRoomBookingsTable)
@@ -694,13 +795,27 @@ router.post(
) {
throw new Error("__conflict__");
}
const bookingId = await reserveBookingId(tx);
const [row] = await tx
.insert(protocolRoomBookingsTable)
.values({
id: bookingId,
roomId: body.roomId,
title: body.title,
requesterName: body.requesterName ?? null,
requesterPhone: body.requesterPhone ?? null,
requesterOrg:
body.meetingType === "external" ? (body.requesterOrg ?? null) : null,
department: body.department ?? null,
meetingType: body.meetingType,
purpose: body.purpose ?? null,
attendeeCount: body.attendeeCount ?? null,
keyAttendees: (body.keyAttendees ?? []).map((a) => ({
name: a.name,
position: a.position ?? "",
side: a.side,
})),
bookingReference: makeBookingReference(bookingId, new Date()),
startsAt,
endsAt,
status: canApprove ? "approved" : "pending",
@@ -787,9 +902,38 @@ router.patch(
...(body.requesterName !== undefined
? { requesterName: body.requesterName }
: {}),
...(body.requesterPhone !== undefined
? { requesterPhone: body.requesterPhone }
: {}),
...(body.meetingType !== undefined
? { meetingType: body.meetingType }
: {}),
...(body.requesterOrg !== undefined || body.meetingType !== undefined
? {
requesterOrg:
(body.meetingType ?? existing.meetingType) === "external"
? (body.requesterOrg !== undefined
? body.requesterOrg
: existing.requesterOrg)
: null,
}
: {}),
...(body.department !== undefined
? { department: body.department }
: {}),
...(body.purpose !== undefined ? { purpose: body.purpose } : {}),
...(body.attendeeCount !== undefined
? { attendeeCount: body.attendeeCount }
: {}),
...(body.keyAttendees !== undefined
? {
keyAttendees: body.keyAttendees.map((a) => ({
name: a.name,
position: a.position ?? "",
side: a.side,
})),
}
: {}),
startsAt,
endsAt,
...(body.notes !== undefined ? { notes: body.notes } : {}),
@@ -1014,6 +1158,45 @@ router.delete(
},
);
// Bulk delete. One transaction: fetch existing rows (for audit), delete them
// all, and write one audit row per deleted booking. Ids that don't exist are
// simply skipped and reported back so the client can toast "deleted X of Y".
const bulkDeleteSchema = z.object({
ids: z.array(z.number().int().positive()).min(1).max(500),
});
router.post(
"/protocol/bookings/bulk-delete",
requireMutate,
async (req: Request, res: Response) => {
const body = parseBody(res, bulkDeleteSchema, req.body);
if (!body) return;
const ids = Array.from(new Set(body.ids));
const deletedIds = await db.transaction(async (tx) => {
const existing = await tx
.select()
.from(protocolRoomBookingsTable)
.where(inArray(protocolRoomBookingsTable.id, ids));
if (existing.length === 0) return [] as number[];
const foundIds = existing.map((r) => r.id);
await tx
.delete(protocolRoomBookingsTable)
.where(inArray(protocolRoomBookingsTable.id, foundIds));
for (const row of existing) {
await logAudit(tx, {
action: "booking.delete",
entityType: "booking",
entityId: row.id,
oldValue: row,
performedBy: req.session.userId!,
});
}
return foundIds;
});
res.json({ requested: ids.length, deleted: deletedIds.length, deletedIds });
},
);
// ---------------------------------------------------------------------------
// External meetings
// ---------------------------------------------------------------------------
@@ -409,6 +409,36 @@ test("Meetings: POST /duplicate clones a meeting onto another date", async () =>
assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
});
// #678: duplicating/repeating must preserve the external-meeting flag
// (and the shared row colour) instead of silently resetting the copy
// to a normal internal meeting.
test("Meetings: POST /duplicate preserves isExternal and rowColor on the copy", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "خارجي",
titleEn: "External",
meetingDate: today,
isExternal: true,
attendees: [],
});
assert.equal(create.status, 201);
const original = await create.json();
created.meetingIds.push(original.id);
assert.equal(original.isExternal, true, "source must be external");
assert.equal(original.rowColor, "red", "external source is force-tinted red");
const dup = await api(
adminCookie,
"POST",
`/api/executive-meetings/${original.id}/duplicate`,
{ targetDate: tomorrow },
);
assert.equal(dup.status, 201);
const copy = await dup.json();
created.meetingIds.push(copy.id);
assert.equal(copy.isExternal, true, "duplicate must stay external (#678)");
assert.equal(copy.rowColor, "red", "duplicate keeps the red row colour");
});
// #189: plain-text sanitization for location / meetingUrl / notes.
test("Sanitization: POST strips HTML from location, meetingUrl, and notes", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
+24 -1
View File
@@ -7,16 +7,33 @@ import {
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { ar as dfAr, enUS as dfEn } from "date-fns/locale"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import i18n from "@/i18n"
function isArabic(): boolean {
// The public booking page forces Arabic by setting documentElement.lang/dir
// without changing i18n.language, so prefer the DOM signal (i18n keeps it in
// sync elsewhere) and fall back to the i18n language.
if (typeof document !== "undefined") {
const el = document.documentElement
if (el.lang) return el.lang.toLowerCase().startsWith("ar")
if (el.dir) return el.dir === "rtl"
}
return i18n.language === "ar"
}
function activeLocaleLatn(): string {
const lang = i18n.language === "ar" ? "ar" : "en-US"
const lang = isArabic() ? "ar" : "en-US"
return `${lang}-u-nu-latn`
}
function activeDfLocale() {
return isArabic() ? dfAr : dfEn
}
function Calendar({
className,
classNames,
@@ -41,9 +58,15 @@ function Calendar({
className
)}
captionLayout={captionLayout}
locale={activeDfLocale()}
dir={isArabic() ? "rtl" : "ltr"}
formatters={{
formatMonthDropdown: (date) =>
new Intl.DateTimeFormat(activeLocaleLatn(), { month: "short", numberingSystem: "latn" }).format(date),
formatCaption: (date) =>
new Intl.DateTimeFormat(activeLocaleLatn(), { month: "long", year: "numeric", numberingSystem: "latn" }).format(date),
formatDay: (date) =>
new Intl.DateTimeFormat(activeLocaleLatn(), { day: "numeric", numberingSystem: "latn" }).format(date),
...formatters,
}}
classNames={{
+6 -1
View File
@@ -17,7 +17,12 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:ltr:translate-x-4 data-[state=checked]:rtl:-translate-x-4 data-[state=unchecked]:ltr:translate-x-0 data-[state=unchecked]:rtl:translate-x-4"
// #682: in RTL the flex container already places the resting
// (unchecked) thumb at the track's start edge, so unchecked must
// be translate-x-0 in BOTH directions. The old rtl:translate-x-4
// pushed the resting thumb sideways, making OFF look like ON in
// Arabic. Checked travel: +x in LTR, -x in RTL.
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 data-[state=checked]:ltr:translate-x-4 data-[state=checked]:rtl:-translate-x-4"
)}
/>
</SwitchPrimitives.Root>
+108 -10
View File
@@ -1667,11 +1667,12 @@
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
"tabs": {
"dashboard": "لوحة المعلومات",
"bookings": "حجوزات القاعات",
"external": "اللقاءات الخارجية",
"gifts": "الإهداءات",
"issues": "طلبات الصرف",
"photos": "تصوير",
"bookings": "حجز القاعات",
"external": "اجتماعات خارجية",
"giftsGroup": "الهدايا",
"gifts": "المستودع",
"issues": "الإهداءات",
"photos": "الصور",
"reports": "التقارير",
"audit": "سجل النشاط",
"rooms": "القاعات"
@@ -1704,7 +1705,7 @@
"pendingBookings": "حجوزات بانتظار الاعتماد",
"upcomingExternal": "لقاءات خارجية قادمة",
"giftStock": "إجمالي مخزون الهدايا",
"pendingIssues": "طلبات صرف معلّقة",
"pendingIssues": "إهداءات معلّقة",
"roomsAvailableNow": "القاعات المتاحة الآن",
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
},
@@ -1715,18 +1716,115 @@
"requester": "مقدّم الطلب",
"org": "الجهة",
"phone": "الهاتف",
"attendeeCount": "عدد الحضور",
"attendeeCount": "العدد المتوقع للحضور",
"publicRequest": "طلب عام",
"today": "اليوم",
"bulk": {
"selectAll": "تحديد الكل",
"selectRow": "تحديد الحجز {{number}}",
"clearSelection": "إلغاء التحديد",
"selectedCount_one": "تم تحديد حجز واحد",
"selectedCount_two": "تم تحديد حجزين",
"selectedCount_few": "تم تحديد {{count}} حجوزات",
"selectedCount_many": "تم تحديد {{count}} حجزاً",
"selectedCount_other": "تم تحديد {{count}} حجز",
"delete": "حذف",
"deleteConfirmTitle_one": "حذف حجز واحد؟",
"deleteConfirmTitle_two": "حذف حجزين؟",
"deleteConfirmTitle_few": "حذف {{count}} حجوزات؟",
"deleteConfirmTitle_many": "حذف {{count}} حجزاً؟",
"deleteConfirmTitle_other": "حذف {{count}} حجز؟",
"deleteConfirmBody_one": "سيتم حذف حجز واحد نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_two": "سيتم حذف حجزين نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_few": "سيتم حذف {{count}} حجوزات نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_many": "سيتم حذف {{count}} حجزاً نهائياً. لا يمكن التراجع.",
"deleteConfirmBody_other": "سيتم حذف {{count}} حجز نهائياً. لا يمكن التراجع.",
"deleted_one": "تم حذف حجز واحد",
"deleted_two": "تم حذف حجزين",
"deleted_few": "تم حذف {{count}} حجوزات",
"deleted_many": "تم حذف {{count}} حجزاً",
"deleted_other": "تم حذف {{count}} حجز",
"deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي"
},
"reference": "رقم الحجز",
"department": "الإدارة",
"entity": "اسم الجهة",
"purpose": "الغرض من الاجتماع",
"meetingType": "نوع الاجتماع",
"meetingTypes": {
"internal": "اجتماع داخلي",
"external": "اجتماع مع جهة خارجية"
},
"keyAttendees": "أبرز الحضور",
"attendeeSides": {
"internal": "من الهيئة",
"external": "خارج الهيئة"
},
"searchPlaceholder": "ابحث برقم الحجز أو الهاتف…",
"searchEmpty": "لا توجد حجوزات مطابقة.",
"roomLabel": "القاعة",
"titleLabel": "عنوان الحجز",
"startsAt": "من",
"endsAt": "إلى",
"viewList": "قائمة",
"viewCalendar": "تقويم",
"book": "حجز",
"emptyDay": "لا توجد حجوزات في هذا اليوم.",
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
"copyLink": "نسخ الرابط",
"linkCopied": "تم نسخ الرابط",
"linkCopyFailed": "تعذّر نسخ الرابط"
"linkCopyFailed": "تعذّر نسخ الرابط",
"table": {
"number": "رقم الحجز",
"requester": "مقدم الطلب",
"date": "تاريخ الحجز",
"type": "نوع الاجتماع",
"roomAttendees": "القاعة وعدد الحضور",
"status": "الحالة",
"actions": "الإجراء",
"attendeesShort": "الحضور"
},
"meetingTypesShort": {
"internal": "داخلي",
"external": "خارجي"
},
"details": {
"date": "التاريخ",
"time": "الوقت",
"duration": "المدة",
"requesterSection": "بيانات مقدّم الطلب",
"meetingSection": "تفاصيل الاجتماع",
"notes": "ملاحظات",
"rejectionReason": "سبب الرفض",
"attendeeCols": {
"name": "الاسم",
"position": "الصفة",
"side": "الجهة"
},
"source": "مصدر الطلب",
"sources": {
"internal": "داخلي",
"public": "طلب عام"
},
"durationHours_zero": "{{count}} ساعة",
"durationHours_one": "ساعة واحدة",
"durationHours_two": "ساعتان",
"durationHours_few": "{{count}} ساعات",
"durationHours_many": "{{count}} ساعة",
"durationHours_other": "{{count}} ساعة",
"durationMinutes_zero": "{{count}} دقيقة",
"durationMinutes_one": "دقيقة واحدة",
"durationMinutes_two": "دقيقتان",
"durationMinutes_few": "{{count}} دقائق",
"durationMinutes_many": "{{count}} دقيقة",
"durationMinutes_other": "{{count}} دقيقة"
},
"form": {
"name": "الاسم",
"position": "المنصب",
"addRow": "إضافة صف",
"removeRow": "حذف الصف"
}
},
"external": {
"new": "لقاء جديد",
@@ -1760,8 +1858,8 @@
"empty": "لا توجد قاعات."
},
"issues": {
"new": "طلب صرف",
"empty": "لا توجد طلبات صرف.",
"new": "إهداء جديد",
"empty": "لا توجد إهداءات.",
"recipient": "المستفيد",
"gift": "الصنف",
"occasion": "المناسبة",
+85 -7
View File
@@ -1542,8 +1542,9 @@
"dashboard": "Dashboard",
"bookings": "Room Bookings",
"external": "External Meetings",
"gifts": "Dedications",
"issues": "Issuance Requests",
"giftsGroup": "Gifts",
"gifts": "Warehouse",
"issues": "Dedications",
"photos": "Photos",
"reports": "Reports",
"audit": "Activity Log",
@@ -1577,7 +1578,7 @@
"pendingBookings": "Bookings awaiting approval",
"upcomingExternal": "Upcoming external meetings",
"giftStock": "Total gift stock",
"pendingIssues": "Pending issuance requests",
"pendingIssues": "Pending dedications",
"roomsAvailableNow": "Rooms available now",
"giftsIssuedThisMonth": "Gifts issued this month"
},
@@ -1588,18 +1589,95 @@
"requester": "Requester",
"org": "Organization",
"phone": "Phone",
"attendeeCount": "Attendees",
"attendeeCount": "Expected attendees",
"publicRequest": "Public request",
"today": "Today",
"bulk": {
"selectAll": "Select all",
"selectRow": "Select booking {{number}}",
"clearSelection": "Clear selection",
"selectedCount_one": "1 booking selected",
"selectedCount_other": "{{count}} bookings selected",
"delete": "Delete",
"deleteConfirmTitle_one": "Delete 1 booking?",
"deleteConfirmTitle_other": "Delete {{count}} bookings?",
"deleteConfirmBody_one": "This will permanently delete 1 booking. This cannot be undone.",
"deleteConfirmBody_other": "This will permanently delete {{count}} bookings. This cannot be undone.",
"deleted_one": "1 booking deleted",
"deleted_other": "{{count}} bookings deleted",
"deletePartial": "Deleted {{deleted}} of {{total}}; the rest could not be deleted"
},
"reference": "Booking ref",
"department": "Department",
"entity": "Entity",
"purpose": "Meeting purpose",
"meetingType": "Meeting type",
"meetingTypes": {
"internal": "Internal meeting",
"external": "Meeting with external entity"
},
"keyAttendees": "Key attendees",
"attendeeSides": {
"internal": "From the authority",
"external": "External"
},
"searchPlaceholder": "Search by booking ref or phone…",
"searchEmpty": "No matching bookings.",
"roomLabel": "Room",
"titleLabel": "Booking title",
"startsAt": "From",
"endsAt": "To",
"viewList": "List",
"viewCalendar": "Calendar",
"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"
},
"details": {
"date": "Date",
"time": "Time",
"duration": "Duration",
"requesterSection": "Requester details",
"meetingSection": "Meeting details",
"notes": "Notes",
"rejectionReason": "Rejection reason",
"attendeeCols": {
"name": "Name",
"position": "Title",
"side": "Side"
},
"source": "Request source",
"sources": {
"internal": "Internal",
"public": "Public request"
},
"durationHours_one": "1 hour",
"durationHours_other": "{{count}} hours",
"durationMinutes_one": "1 minute",
"durationMinutes_other": "{{count}} minutes"
},
"form": {
"name": "Name",
"position": "Position",
"addRow": "Add row",
"removeRow": "Remove row"
}
},
"external": {
"new": "New meeting",
@@ -1633,8 +1711,8 @@
"empty": "No rooms."
},
"issues": {
"new": "Issuance request",
"empty": "No issuance requests yet.",
"new": "New dedication",
"empty": "No dedications yet.",
"recipient": "Recipient",
"gift": "Item",
"occasion": "Occasion",
+291 -53
View File
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, CheckCircle2, Loader2 } from "lucide-react";
import {
CalendarClock,
CheckCircle2,
Loader2,
Plus,
X,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -18,12 +25,19 @@ const API = `${import.meta.env.BASE_URL}api`;
type PublicRoom = { id: number; nameAr: string; nameEn: string };
type MeetingType = "internal" | "external";
type AttendeeSide = "internal" | "external";
type AttendeeRow = { name: string; position: string };
type FormState = {
requesterName: string;
title: string;
requesterPhone: string;
department: string;
meetingType: MeetingType;
requesterOrg: string;
attendeeCount: string;
purpose: string;
roomId: string;
startsAt: string;
endsAt: string;
@@ -35,8 +49,11 @@ const EMPTY: FormState = {
requesterName: "",
title: "",
requesterPhone: "",
department: "",
meetingType: "internal",
requesterOrg: "",
attendeeCount: "",
purpose: "",
roomId: "",
startsAt: "",
endsAt: "",
@@ -44,6 +61,8 @@ const EMPTY: FormState = {
website: "",
};
const EMPTY_ROW: AttendeeRow = { name: "", position: "" };
function localToIso(v: string): string {
return new Date(v).toISOString();
}
@@ -74,13 +93,51 @@ export default function ProtocolRequestPage() {
});
const [form, setForm] = useState<FormState>(EMPTY);
// Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal)
// and خارج الهيئة (external). Each row is a name + position.
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([
{ ...EMPTY_ROW },
]);
const [externalAttendees, setExternalAttendees] = useState<AttendeeRow[]>([
{ ...EMPTY_ROW },
]);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [reference, setReference] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const updateRow = (
side: AttendeeSide,
idx: number,
field: keyof AttendeeRow,
value: string,
) => {
const setter =
side === "internal" ? setInternalAttendees : setExternalAttendees;
setter((rows) =>
rows.map((r, i) => (i === idx ? { ...r, [field]: value } : r)),
);
};
const addRow = (side: AttendeeSide) => {
const setter =
side === "internal" ? setInternalAttendees : setExternalAttendees;
setter((rows) => [...rows, { ...EMPTY_ROW }]);
};
const removeRow = (side: AttendeeSide, idx: number) => {
const setter =
side === "internal" ? setInternalAttendees : setExternalAttendees;
setter((rows) =>
rows.length <= 1 ? [{ ...EMPTY_ROW }] : rows.filter((_, i) => i !== idx),
);
};
const isExternal = form.meetingType === "external";
const canSubmit = useMemo(() => {
return (
form.requesterName.trim().length > 0 &&
@@ -89,9 +146,10 @@ export default function ProtocolRequestPage() {
form.roomId !== "" &&
form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) < new Date(form.endsAt)
new Date(form.startsAt) < new Date(form.endsAt) &&
(!isExternal || form.requesterOrg.trim().length > 0)
);
}, [form]);
}, [form, isExternal]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -99,6 +157,22 @@ export default function ProtocolRequestPage() {
if (!canSubmit || submitting) return;
setSubmitting(true);
try {
const keyAttendees = [
...internalAttendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: "internal" as const,
})),
...externalAttendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: "external" as const,
})),
];
const res = await fetch(`${API}/protocol/public/booking-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -107,11 +181,15 @@ export default function ProtocolRequestPage() {
title: form.title.trim(),
requesterName: form.requesterName.trim(),
requesterPhone: form.requesterPhone.trim(),
requesterOrg: form.requesterOrg.trim() || null,
department: form.department.trim() || null,
meetingType: form.meetingType,
requesterOrg: isExternal ? form.requesterOrg.trim() : null,
purpose: form.purpose.trim() || null,
attendeeCount:
form.attendeeCount.trim() === ""
? null
: Number(form.attendeeCount),
keyAttendees,
startsAt: localToIso(form.startsAt),
endsAt: localToIso(form.endsAt),
notes: form.notes.trim() || null,
@@ -119,6 +197,14 @@ export default function ProtocolRequestPage() {
}),
});
if (res.ok) {
try {
const data = await res.json();
if (data?.bookingReference && typeof data.bookingReference === "string") {
setReference(data.bookingReference);
}
} catch {
// no reference in body — still a success
}
setDone(true);
return;
}
@@ -140,6 +226,14 @@ export default function ProtocolRequestPage() {
}
};
const resetForm = () => {
setForm(EMPTY);
setInternalAttendees([{ ...EMPTY_ROW }]);
setExternalAttendees([{ ...EMPTY_ROW }]);
setReference(null);
setDone(false);
};
if (done) {
return (
<div
@@ -155,14 +249,21 @@ export default function ProtocolRequestPage() {
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
المُدخل لتأكيد الموعد.
</p>
<Button
className="mt-6"
variant="outline"
onClick={() => {
setForm(EMPTY);
setDone(false);
}}
>
{reference && (
<div className="mt-5 rounded-xl bg-sky-50 border border-sky-200 px-4 py-4">
<p className="text-sm text-slate-500 mb-1">رقم الحجز</p>
<p
className="text-2xl font-bold tracking-wider text-sky-700"
dir="ltr"
>
{reference}
</p>
<p className="text-xs text-slate-500 mt-2">
احتفظ بهذا الرقم لمتابعة حالة طلبك.
</p>
</div>
)}
<Button className="mt-6" variant="outline" onClick={resetForm}>
إرسال طلب آخر
</Button>
</div>
@@ -205,7 +306,7 @@ export default function ProtocolRequestPage() {
<div className="space-y-2">
<Label htmlFor="requesterName">
الاسم الكامل <span className="text-rose-500">*</span>
الاسم <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterName"
@@ -216,19 +317,6 @@ export default function ProtocolRequestPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">
عنوان الاجتماع <span className="text-rose-500">*</span>
</Label>
<Input
id="title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="موضوع أو عنوان الاجتماع"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterPhone">
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
@@ -246,37 +334,11 @@ export default function ProtocolRequestPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterOrg">الجهة / المؤسسة</Label>
<Input
id="requesterOrg"
value={form.requesterOrg}
onChange={(e) => set("requesterOrg", e.target.value)}
placeholder="اسم الجهة أو المؤسسة (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendeeCount">عدد الحضور</Label>
<Input
id="attendeeCount"
type="number"
inputMode="numeric"
min={1}
value={form.attendeeCount}
onChange={(e) => set("attendeeCount", e.target.value)}
placeholder="العدد المتوقع للحضور (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room">
القاعة <span className="text-rose-500">*</span>
</Label>
<Select
value={form.roomId}
onValueChange={(v) => set("roomId", v)}
>
<Select value={form.roomId} onValueChange={(v) => set("roomId", v)}>
<SelectTrigger id="room">
<SelectValue
placeholder={
@@ -299,6 +361,95 @@ export default function ProtocolRequestPage() {
)}
</div>
<div className="space-y-2">
<Label htmlFor="department">الإدارة</Label>
<Input
id="department"
value={form.department}
onChange={(e) => set("department", e.target.value)}
placeholder="الإدارة أو القسم (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">
عنوان الاجتماع <span className="text-rose-500">*</span>
</Label>
<Input
id="title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="موضوع أو عنوان الاجتماع"
required
/>
</div>
<div className="space-y-2">
<Label>
نوع الاجتماع <span className="text-rose-500">*</span>
</Label>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "internal", label: "اجتماع داخلي" },
{ value: "external", label: "اجتماع مع جهة خارجية" },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => set("meetingType", opt.value)}
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
form.meetingType === opt.value
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{isExternal && (
<div className="space-y-2">
<Label htmlFor="requesterOrg">
اسم الجهة <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterOrg"
value={form.requesterOrg}
onChange={(e) => set("requesterOrg", e.target.value)}
placeholder="اسم الجهة الخارجية"
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="attendeeCount">العدد المتوقع للحضور</Label>
<Input
id="attendeeCount"
type="number"
inputMode="numeric"
min={1}
value={form.attendeeCount}
onChange={(e) => set("attendeeCount", e.target.value)}
placeholder="العدد المتوقع للحضور (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="purpose">الغرض من الاجتماع</Label>
<Textarea
id="purpose"
value={form.purpose}
onChange={(e) => set("purpose", e.target.value)}
placeholder="اشرح الغرض من الاجتماع (اختياري)"
rows={2}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
@@ -332,6 +483,30 @@ export default function ProtocolRequestPage() {
</p>
)}
<div className="space-y-3">
<Label>أبرز الحضور</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AttendeeColumn
title="من الهيئة"
rows={internalAttendees}
onChange={(idx, field, value) =>
updateRow("internal", idx, field, value)
}
onAdd={() => addRow("internal")}
onRemove={(idx) => removeRow("internal", idx)}
/>
<AttendeeColumn
title="خارج الهيئة"
rows={externalAttendees}
onChange={(idx, field, value) =>
updateRow("external", idx, field, value)
}
onAdd={() => addRow("external")}
onRemove={(idx) => removeRow("external", idx)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">ملاحظات</Label>
<Textarea
@@ -343,6 +518,13 @@ export default function ProtocolRequestPage() {
/>
</div>
<div className="flex items-start gap-2 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
للإحاطة: في حال الحاجة للقاعة سيتم إلغاء الاجتماع أو تعديل موقعه.
</span>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
{error}
@@ -362,3 +544,59 @@ export default function ProtocolRequestPage() {
</div>
);
}
function AttendeeColumn({
title,
rows,
onChange,
onAdd,
onRemove,
}: {
title: string;
rows: AttendeeRow[];
onChange: (idx: number, field: keyof AttendeeRow, value: string) => void;
onAdd: () => void;
onRemove: (idx: number) => void;
}) {
return (
<div className="rounded-lg border border-slate-200 p-3 space-y-3">
<p className="text-sm font-semibold text-slate-700">{title}</p>
<div className="space-y-3">
{rows.map((row, idx) => (
<div key={idx} className="space-y-2">
<div className="flex items-center gap-2">
<Input
value={row.name}
onChange={(e) => onChange(idx, "name", e.target.value)}
placeholder="الاسم"
/>
{rows.length > 1 && (
<button
type="button"
onClick={() => onRemove(idx)}
className="shrink-0 text-slate-400 hover:text-rose-500 p-1"
aria-label="حذف الصف"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<Input
value={row.position}
onChange={(e) => onChange(idx, "position", e.target.value)}
placeholder="المنصب"
/>
</div>
))}
</div>
<button
type="button"
onClick={onAdd}
className="flex items-center gap-1 text-sm font-medium text-sky-600 hover:text-sky-700"
>
<Plus className="w-4 h-4" />
إضافة صف
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -81,6 +81,48 @@ async function main(): Promise<void> {
);
}
// Backfill booking references BEFORE the schema push enforces the
// NOT NULL + unique constraint on protocol_room_bookings.booking_reference.
// Without this, pushing the constraint against a populated table (any
// pre-existing booking predates the column) would fail. Reference format
// mirrors makeBookingReference() in the API: RB-{year}-{pad4(id)}, which
// is unique because id is unique. Idempotent: only touches NULL rows.
if (await tableExists(client, "protocol_room_bookings")) {
const hasColumn = await client.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'protocol_room_bookings'
AND column_name = 'booking_reference'`,
);
if ((hasColumn.rowCount ?? 0) > 0) {
const backfilled = await client.query(
`UPDATE protocol_room_bookings
SET booking_reference =
'RB-' || EXTRACT(YEAR FROM created_at)::int
|| '-' || LPAD(id::text, 4, '0')
WHERE booking_reference IS NULL`,
);
const filled = backfilled.rowCount ?? 0;
if (filled > 0) {
console.log(
`[pre-push] Backfilled ${filled} protocol_room_bookings booking_reference value(s).`,
);
} else {
console.log(
"[pre-push] No protocol_room_bookings booking_reference to backfill.",
);
}
} else {
console.log(
"[pre-push] protocol_room_bookings.booking_reference column not present yet — skipping backfill.",
);
}
} else {
console.log(
"[pre-push] protocol_room_bookings table not present yet — skipping booking_reference backfill.",
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
+44 -2
View File
@@ -8,6 +8,7 @@ import {
jsonb,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
@@ -35,6 +36,25 @@ export const PROTOCOL_BOOKING_STATUSES = [
export type ProtocolBookingStatus =
(typeof PROTOCOL_BOOKING_STATUSES)[number];
// A room booking is either an internal meeting or a meeting with an external
// entity. When external, the requester's organization (requesterOrg / اسم
// الجهة) is required.
export const PROTOCOL_MEETING_TYPES = ["internal", "external"] as const;
export type ProtocolMeetingType = (typeof PROTOCOL_MEETING_TYPES)[number];
// Which "side" a key attendee belongs to: from the authority (من الهيئة) or
// from outside it (خارج الهيئة).
export const PROTOCOL_ATTENDEE_SIDES = ["internal", "external"] as const;
export type ProtocolAttendeeSide = (typeof PROTOCOL_ATTENDEE_SIDES)[number];
// A notable attendee ("أبرز الحضور") captured on the booking request. Stored
// as a JSON array on the booking row.
export type ProtocolKeyAttendee = {
name: string;
position: string;
side: ProtocolAttendeeSide;
};
export const PROTOCOL_ISSUE_STATUSES = [
"pending",
"approved",
@@ -94,9 +114,27 @@ export const protocolRoomBookingsTable = pgTable(
// The requester's organization / entity (الجهة). Optional even for
// public requests; always null for internal bookings unless set.
requesterOrg: varchar("requester_org", { length: 300 }),
// Expected number of attendees (عدد الحضور). Optional; supplied by the
// guest on a public request or by staff on an internal booking.
// Expected number of attendees (العدد المتوقع للحضور). Optional; supplied
// by the guest on a public request or by staff on an internal booking.
attendeeCount: integer("attendee_count"),
// The requester's department / administration (الإدارة). Optional.
department: varchar("department", { length: 300 }),
// Whether the meeting is internal or with an external entity. When
// "external", requesterOrg (اسم الجهة) is required. Defaults to internal.
meetingType: varchar("meeting_type", { length: 20 })
.notNull()
.default("internal"),
// Free-text purpose of the meeting (الغرض من الاجتماع). Optional.
purpose: text("purpose"),
// Notable attendees (أبرز الحضور): array of { name, position, side }.
keyAttendees: jsonb("key_attendees")
.$type<ProtocolKeyAttendee[]>()
.notNull()
.default([]),
// Human-friendly, unique booking reference (رقم الحجز) shown to the
// requester on success and searchable by staff. Generated on insert and
// never null — every booking (public or internal) has one.
bookingReference: varchar("booking_reference", { length: 40 }).notNull(),
// Provenance marker. "internal" for bookings created by authenticated
// protocol staff through the app; "public" for guest requests coming in
// via the no-login booking-request link. Defaults to "internal" so all
@@ -128,6 +166,10 @@ export const protocolRoomBookingsTable = pgTable(
roomIdx: index("protocol_bookings_room_idx").on(t.roomId),
startIdx: index("protocol_bookings_start_idx").on(t.startsAt),
statusIdx: index("protocol_bookings_status_idx").on(t.status),
referenceIdx: uniqueIndex("protocol_bookings_reference_idx").on(
t.bookingReference,
),
phoneIdx: index("protocol_bookings_phone_idx").on(t.requesterPhone),
}),
);