Files
TX/artifacts/tx-os/src/pages/protocol.tsx
T
Replit Agent a1acbc8017 Task #737: fix reversed ص/م (AM/PM) in Arabic booking time ranges
- Root cause: time-range containers forced dir="ltr" while the strings mix
  Latin digits with Arabic ص/م, so the bidi algorithm flipped the segments
  (showed "م - 1:30 م 12:30" instead of "12:30 م – 1:30 م").
- artifacts/tx-os/src/pages/protocol.tsx:
  - fmtTimeOnly now uses "ar-u-nu-latn" in Arabic (Latin digits kept) and
    is rendered in the natural RTL flow.
  - Removed dir="ltr" from: bookings list time cell, day-grid booking block
    time, and details dialog time range.
- fmtSlotRange / fmtSlotLabel callsites already use locale-aware dir (rtl
  in Arabic) — unchanged. English display unchanged.
- tsc clean; architect review passed.
2026-07-09 09:36:45 +00:00

4931 lines
168 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoute } from "wouter";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
LayoutDashboard,
DoorOpen,
CalendarClock,
Handshake,
Gift,
Warehouse,
HandHeart,
ChevronDown,
BarChart3,
ScrollText,
Plus,
Pencil,
Trash2,
Check,
X,
Ban,
CalendarDays,
List as ListIcon,
Search as SearchIcon,
Link2,
Copy,
ExternalLink,
Upload,
Loader2,
Download,
Image as ImageIcon,
Settings as SettingsIcon,
Clock3,
Menu as MenuIcon,
} from "lucide-react";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip as RechartsTooltip,
CartesianGrid,
AreaChart,
Area,
PieChart,
Pie,
Cell,
Legend,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AppDock } from "@/components/app-dock";
import {
AttendeeTableEditor,
emptyAttendeeRow,
syncAttendeeRows,
type EditableAttendeeRow,
} from "@/components/attendee-table-editor";
import { Calendar } from "@/components/ui/calendar";
import { useNow } from "@/components/clock";
import { useToast } from "@/hooks/use-toast";
import { apiJson, ApiError } from "@/lib/api-json";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { cn } from "@/lib/utils";
const API = `${import.meta.env.BASE_URL}api`;
// ---------------------------------------------------------------------------
// Types (mirror of the /api/protocol/* response shapes)
// ---------------------------------------------------------------------------
type Capabilities = {
canRead: boolean;
canRequest: boolean;
canMutate: boolean;
canApprove: boolean;
canManageRooms: boolean;
canViewAudit: boolean;
};
type Room = {
id: number;
nameAr: string;
nameEn: string;
capacity: number | null;
location: string | null;
isActive: boolean;
sortOrder: number;
};
type Slot = {
id: number;
startTime: string; // "HH:mm" Riyadh wall clock
durationMinutes: number;
daysOfWeek: number[]; // 0=Sunday .. 6=Saturday (Riyadh calendar)
isActive: boolean;
sortOrder: number;
};
// Minimal shape returned by /protocol/booking-slots/active (readable by any
// protocol user; the full Slot list is admin-only).
type ActiveSlotInfo = {
id: number;
startTime: string;
durationMinutes: number;
daysOfWeek: number[];
};
// Weekday (0=Sunday .. 6=Saturday) of a plain "YYYY-MM-DD" calendar day.
function ymdWeekday(ymd: string): number {
return new Date(`${ymd}T00:00:00Z`).getUTCDay();
}
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
// Saudi work week: Sunday..Thursday.
const WORKDAYS = [0, 1, 2, 3, 4];
function fmtSlotDays(
days: number[],
t: (k: string) => string,
isAr: boolean,
): string {
if (ALL_DAYS.every((d) => days.includes(d))) {
return t("protocol.slots.allDays");
}
return [...days]
.sort((a, b) => a - b)
.map((d) => t(`protocol.slots.dayNames.${d}`))
.join(isAr ? "، " : ", ");
}
type BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
type MeetingType = "internal" | "external";
type KeyAttendee = {
name: string;
position: string;
side: "internal" | "external";
};
type Booking = {
id: number;
roomId: number;
title: string;
requesterName: string | null;
requesterPhone: string | null;
requesterOrg: string | null;
attendeeCount: number | null;
department: string | null;
meetingType: MeetingType;
purpose: string | null;
keyAttendees: KeyAttendee[] | null;
bookingReference: string | null;
source: "internal" | "public";
startsAt: string;
endsAt: string;
status: BookingStatus;
notes: string | null;
rejectionReason: string | null;
};
type ExternalMeeting = {
id: number;
title: string;
partyName: string | null;
location: string | null;
startsAt: string;
endsAt: string | null;
status: "pending" | "approved" | "rejected" | "completed" | "cancelled";
notes: string | null;
rejectionReason: string | null;
};
type GiftKind = "gift" | "shield";
type Gift = {
id: number;
nameAr: string;
nameEn: string;
kind: GiftKind;
descriptionAr: string | null;
descriptionEn: string | null;
quantityInStock: number;
isActive: boolean;
};
type IssueStatus = "pending" | "approved" | "rejected" | "issued";
type GiftIssue = {
id: number;
giftId: number;
recipientName: string;
occasion: string | null;
quantity: number;
status: IssueStatus;
notes: string | null;
rejectionReason: string | null;
};
type Dashboard = {
activeRooms: number;
roomsAvailableNow: number;
pendingBookings: number;
todayBookings: number;
upcomingExternalMeetings: number;
giftCatalogCount: number;
totalGiftStock: number;
pendingGiftIssues: number;
giftsIssuedThisMonth: number;
giftsIssuedThisMonthQty: number;
};
type ReportRow = {
bookingsByRoom: Array<{
roomId: number;
roomNameAr: string;
roomNameEn: string;
total: number;
approved: number;
pending: number;
rejected: number;
cancelled: number;
}>;
issuesByGift: Array<{
giftId: number;
giftNameAr: string;
giftNameEn: string;
kind: GiftKind;
totalRequests: number;
issued: number;
issuedQty: number;
pending: number;
rejected: number;
}>;
externalMeetings: {
total: number;
pending: number;
approved: number;
rejected: number;
completed: number;
cancelled: number;
};
};
type TabKey =
| "dashboard"
| "roomsDashboard"
| "rooms"
| "bookings"
| "bookingSettings"
| "external"
| "gifts"
| "issues"
| "photos"
| "reports"
| "audit";
// URL slugs for each tab so pages are deep-linkable under /protocol/*.
const TAB_SLUGS: Record<TabKey, string> = {
dashboard: "dashboard",
roomsDashboard: "rooms-dashboard",
rooms: "rooms",
bookings: "bookings",
bookingSettings: "booking-settings",
external: "external-meetings",
gifts: "gifts",
issues: "gift-issues",
photos: "photos",
reports: "reports",
audit: "audit",
};
const SLUG_TABS: Record<string, TabKey> = Object.fromEntries(
Object.entries(TAB_SLUGS).map(([k, v]) => [v, k as TabKey]),
) as Record<string, TabKey>;
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function toLocalInput(iso: string): string {
// Convert an ISO string into a value for <input type="datetime-local">.
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
function fromLocalInput(v: string): string {
return new Date(v).toISOString();
}
const STATUS_PILL: Record<string, string> = {
pending: "bg-amber-100 text-amber-800",
approved: "bg-emerald-100 text-emerald-700",
issued: "bg-emerald-100 text-emerald-700",
rejected: "bg-rose-100 text-rose-700",
cancelled: "bg-slate-200 text-slate-600",
scheduled: "bg-sky-100 text-sky-700",
completed: "bg-emerald-100 text-emerald-700",
};
// ---- Day time-grid (Google-Calendar style) constants & helpers ----
const GRID_HOUR_START = 7; // 7 AM
const GRID_HOUR_END = 22; // 10 PM
const GRID_PX_PER_HOUR = 56;
// Block fill + inline-start accent border, colored by booking status.
const BOOKING_BLOCK_STYLE: Record<string, string> = {
pending: "bg-amber-100 border-amber-400 text-amber-900",
approved: "bg-emerald-100 border-emerald-500 text-emerald-900",
issued: "bg-emerald-100 border-emerald-500 text-emerald-900",
rejected: "bg-rose-100 border-rose-400 text-rose-900",
cancelled: "bg-slate-100 border-slate-300 text-slate-500",
scheduled: "bg-sky-100 border-sky-400 text-sky-900",
completed: "bg-emerald-100 border-emerald-500 text-emerald-900",
};
// --- Booking slots (أوقات الحجز) helpers. Slot times are Riyadh wall-clock
// "HH:mm"; Riyadh has no DST so a fixed +03:00 offset is always correct.
const riyadhTimeFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Riyadh",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
function riyadhHHmm(iso: string): string {
return riyadhTimeFmt.format(new Date(iso));
}
function riyadhYmd(iso: string): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Riyadh" }).format(
new Date(iso),
);
}
function slotToIso(date: string, startTime: string, plusMinutes = 0): string {
const start = new Date(`${date}T${startTime}:00+03:00`);
return new Date(start.getTime() + plusMinutes * 60_000).toISOString();
}
function fmtSlotRange(
startTime: string,
durationMinutes: number,
isAr: boolean,
): string {
const [h, m] = startTime.split(":").map(Number);
const endTotal = h * 60 + m + durationMinutes;
const eh = Math.floor(endTotal / 60) % 24;
const em = endTotal % 60;
const pad = (n: number) => String(n).padStart(2, "0");
const to12 = (hh: number, mm: number) => {
const period = hh < 12 ? (isAr ? "ص" : "AM") : isAr ? "م" : "PM";
let h12 = hh % 12;
if (h12 === 0) h12 = 12;
return `${h12}:${pad(mm)} ${period}`;
};
return `${to12(h, m)} ${to12(eh, em)}`;
}
function sameYmd(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function minutesOfDay(iso: string): number {
const d = new Date(iso);
return d.getHours() * 60 + d.getMinutes();
}
type PlacedBooking = {
b: Booking;
s: number; // start minutes-of-day
e: number; // end minutes-of-day (>= s + 15)
col: number;
cols: number;
};
// Assign side-by-side columns to overlapping bookings (interval
// partitioning), clustering chained overlaps so each cluster shares a
// column count. View-only layout — no drag/resize.
function layoutDayBookings(items: Booking[]): PlacedBooking[] {
const evs = items
.map((b) => {
const s = minutesOfDay(b.startsAt);
const e = Math.max(minutesOfDay(b.endsAt), s + 15);
return { b, s, e, col: 0, cols: 1 };
})
.sort((a, b) => a.s - b.s || a.e - b.e);
const out: PlacedBooking[] = [];
let i = 0;
while (i < evs.length) {
let j = i + 1;
let clusterEnd = evs[i].e;
const cluster = [evs[i]];
while (j < evs.length && evs[j].s < clusterEnd) {
cluster.push(evs[j]);
clusterEnd = Math.max(clusterEnd, evs[j].e);
j++;
}
const colEnds: number[] = [];
for (const ev of cluster) {
let placed = false;
for (let c = 0; c < colEnds.length; c++) {
if (colEnds[c] <= ev.s) {
ev.col = c;
colEnds[c] = ev.e;
placed = true;
break;
}
}
if (!placed) {
ev.col = colEnds.length;
colEnds.push(ev.e);
}
}
const cols = colEnds.length;
for (const ev of cluster) {
ev.cols = cols;
out.push(ev);
}
i = j;
}
return out;
}
function BookingDayGrid({
bookings,
selectedDay,
now,
isAr,
roomName,
emptyText,
scrollSignal = 0,
onSelect,
onSlotClick,
}: {
bookings: Booking[];
selectedDay: Date;
now: Date;
isAr: boolean;
roomName: (id: number) => string;
emptyText: string;
scrollSignal?: number;
onSelect?: (b: Booking) => void;
onSlotClick?: (start: Date) => void;
}) {
const hours: number[] = [];
for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h);
const dayItems = bookings.filter((b) =>
sameYmd(new Date(b.startsAt), selectedDay),
);
const placed = layoutDayBookings(dayItems);
const isToday = sameYmd(now, selectedDay);
const nowMin = now.getHours() * 60 + now.getMinutes();
const showNow =
isToday && nowMin >= GRID_HOUR_START * 60 && nowMin <= GRID_HOUR_END * 60;
const nowTop = ((nowMin - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR;
const gridHeight = (GRID_HOUR_END - GRID_HOUR_START) * GRID_PX_PER_HOUR;
const hourLabel = (h: number) => {
const d = new Date();
d.setHours(h, 0, 0, 0);
return new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", {
hour: "numeric",
hour12: true,
}).format(d);
};
const timeLabel = (iso: string) =>
new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(new Date(iso));
// The grid scrolls inside its own fixed-height container so we can jump
// straight to the red "now" line without touching the page scroll. When
// the selected day is today, position the line ~1/3 from the top of the
// viewport; for other days start at the top.
const scrollRef = useRef<HTMLDivElement | null>(null);
const dayKey = `${selectedDay.getFullYear()}-${selectedDay.getMonth()}-${selectedDay.getDate()}`;
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (showNow) {
el.scrollTop = Math.max(0, nowTop - el.clientHeight / 3);
} else {
el.scrollTop = 0;
}
// Re-run only when the day changes (or the now-line appears/disappears),
// not on every minute tick — so we never fight the user's scrolling.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dayKey, showNow, scrollSignal]);
return (
<div>
{dayItems.length === 0 && (
<div className="mb-2 text-center text-xs text-slate-400">
{emptyText}
</div>
)}
<div
ref={scrollRef}
className="max-h-[60vh] overflow-y-auto overscroll-contain"
>
<div className="flex" style={{ height: gridHeight + 8 }}>
<div className="relative w-14 shrink-0" style={{ height: gridHeight }}>
{hours.map((h) => (
<div
key={h}
className="absolute inset-x-0 -translate-y-1/2 pe-1 text-end text-[10px] text-slate-400"
style={{ top: (h - GRID_HOUR_START) * GRID_PX_PER_HOUR }}
>
{hourLabel(h)}
</div>
))}
</div>
<div
className={cn(
"relative flex-1 border-s border-slate-200",
onSlotClick && "cursor-pointer",
)}
style={{ height: gridHeight }}
onClick={(e) => {
if (!onSlotClick) return;
// Only clicks on the empty grid itself — booking blocks stop
// propagation in their own handler.
const rect = e.currentTarget.getBoundingClientRect();
const offsetY = e.clientY - rect.top;
const rawMin =
GRID_HOUR_START * 60 + (offsetY / GRID_PX_PER_HOUR) * 60;
// Snap down to the nearest 30 minutes, keep a full hour inside
// the visible grid range.
const snapped = Math.floor(rawMin / 30) * 30;
const startMin = Math.min(
Math.max(snapped, GRID_HOUR_START * 60),
GRID_HOUR_END * 60 - 60,
);
const start = new Date(selectedDay);
start.setHours(Math.floor(startMin / 60), startMin % 60, 0, 0);
onSlotClick(start);
}}
>
{hours.map((h) => (
<div
key={h}
className="absolute inset-x-0 border-t border-slate-100"
style={{ top: (h - GRID_HOUR_START) * GRID_PX_PER_HOUR }}
/>
))}
{placed.map((ev) => {
const topM = Math.max(ev.s, GRID_HOUR_START * 60);
const botM = Math.min(ev.e, GRID_HOUR_END * 60);
// Skip bookings with no visible intersection with the grid range.
if (botM <= topM) return null;
const top = ((topM - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR;
const height = Math.max(
((botM - topM) / 60) * GRID_PX_PER_HOUR,
22,
);
const widthPct = 100 / ev.cols;
return (
<button
key={ev.b.id}
type="button"
onClick={(e) => {
e.stopPropagation();
onSelect?.(ev.b);
}}
className={cn(
"absolute overflow-hidden rounded-md border-s-4 px-1.5 py-0.5 text-start text-[11px] leading-tight shadow-sm transition-shadow",
"cursor-pointer hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500",
BOOKING_BLOCK_STYLE[ev.b.status] ??
"bg-slate-100 border-slate-300 text-slate-700",
)}
style={{
top,
height,
insetInlineStart: `calc(${ev.col * widthPct}% + 2px)`,
width: `calc(${widthPct}% - 4px)`,
}}
title={`${ev.b.title}${roomName(ev.b.roomId)}`}
aria-label={`${ev.b.title}${roomName(ev.b.roomId)}${timeLabel(ev.b.startsAt)} ${timeLabel(ev.b.endsAt)}`}
data-testid={`calendar-booking-${ev.b.id}`}
>
<div className="truncate font-medium">{ev.b.title}</div>
<div className="truncate opacity-80">
{roomName(ev.b.roomId)}
</div>
{height >= 44 && (
<div className="truncate opacity-70">
{timeLabel(ev.b.startsAt)} {timeLabel(ev.b.endsAt)}
</div>
)}
</button>
);
})}
{showNow && (
<div
className="pointer-events-none absolute inset-x-0 z-10"
style={{ top: nowTop }}
>
<div className="relative border-t-2 border-red-500">
<div
className="absolute -top-[5px] h-2.5 w-2.5 rounded-full bg-red-500"
style={{ insetInlineStart: -5 }}
/>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default function ProtocolPage() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const isAr = lang === "ar";
const [, setLocation] = useLocation();
const { toast } = useToast();
const qc = useQueryClient();
const [matchTab, tabParams] = useRoute("/protocol/:tab");
const tab: TabKey =
(matchTab && SLUG_TABS[tabParams?.tab ?? ""]) || "dashboard";
const setTab = (k: TabKey) => setLocation(`/protocol/${TAB_SLUGS[k]}`);
const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar);
const caps = useQuery<Capabilities>({
queryKey: ["protocol", "me"],
queryFn: () => apiJson<Capabilities>(`${API}/protocol/me`),
});
const rooms = useQuery<Room[]>({
queryKey: ["protocol", "rooms"],
queryFn: () => apiJson<Room[]>(`${API}/protocol/rooms`),
});
// Active booking slots (أوقات الحجز) — minimal read available to every
// protocol user; the booking dialog restricts times to these whenever at
// least one is configured. The full admin list lives in SlotsAdminSection
// (requires rooms-manage permission).
const slots = useQuery<ActiveSlotInfo[]>({
queryKey: ["protocol", "slots"],
queryFn: () =>
apiJson<ActiveSlotInfo[]>(`${API}/protocol/booking-slots/active`),
});
const [bookingSearch, setBookingSearch] = useState("");
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
useEffect(() => {
const id = setTimeout(
() => setDebouncedBookingSearch(bookingSearch.trim()),
300,
);
return () => clearTimeout(id);
}, [bookingSearch]);
const bookings = useQuery<Booking[]>({
queryKey: ["protocol", "bookings", debouncedBookingSearch],
queryFn: () =>
apiJson<Booking[]>(
`${API}/protocol/bookings${
debouncedBookingSearch
? `?q=${encodeURIComponent(debouncedBookingSearch)}`
: ""
}`,
),
});
const external = useQuery<ExternalMeeting[]>({
queryKey: ["protocol", "external"],
queryFn: () =>
apiJson<ExternalMeeting[]>(`${API}/protocol/external-meetings`),
});
const gifts = useQuery<Gift[]>({
queryKey: ["protocol", "gifts"],
queryFn: () => apiJson<Gift[]>(`${API}/protocol/gifts`),
});
const issues = useQuery<GiftIssue[]>({
queryKey: ["protocol", "issues"],
queryFn: () => apiJson<GiftIssue[]>(`${API}/protocol/gift-issues`),
});
const dashboard = useQuery<Dashboard>({
queryKey: ["protocol", "dashboard"],
queryFn: () => apiJson<Dashboard>(`${API}/protocol/dashboard`),
});
const reports = useQuery<ReportRow>({
queryKey: ["protocol", "reports"],
queryFn: () => apiJson<ReportRow>(`${API}/protocol/reports`),
enabled: tab === "reports",
});
const audit = useQuery<
Array<{
id: number;
action: string;
entityType: string;
entityId: number | null;
performedAt: string;
actorNameAr: string | null;
actorNameEn: string | null;
actorUsername: string | null;
}>
>({
queryKey: ["protocol", "audit"],
queryFn: () => apiJson(`${API}/protocol/audit`),
enabled: tab === "audit",
});
const c = caps.data;
const roomName = (id: number) => {
const r = rooms.data?.find((x) => x.id === id);
return r ? nameOf(r.nameAr, r.nameEn) : `#${id}`;
};
const giftName = (id: number) => {
const g = gifts.data?.find((x) => x.id === id);
return g ? nameOf(g.nameAr, g.nameEn) : `#${id}`;
};
const invalidate = (...keys: string[]) => {
for (const k of keys) qc.invalidateQueries({ queryKey: ["protocol", k] });
qc.invalidateQueries({ queryKey: ["protocol", "dashboard"] });
};
const fmtDateTime = (iso: string | null) => {
if (!iso) return "—";
return new Date(iso).toLocaleString(isAr ? "ar" : "en", {
dateStyle: "medium",
timeStyle: "short",
});
};
const onApiError = (e: unknown) => {
const msg =
e instanceof ApiError ? e.message : t("protocol.errors.generic");
toast({ title: msg, variant: "destructive" });
};
// ---- Booking dialog state ----
const [bookingDialog, setBookingDialog] = useState<{
open: boolean;
edit?: Booking;
day?: Date;
start?: Date;
}>({ open: false });
const [rejectTarget, setRejectTarget] = useState<{
kind: "booking" | "issue" | "external";
id: number;
} | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const [expandedBookings, setExpandedBookings] = useState<Set<number>>(
() => new Set(),
);
const toggleBookingExpanded = (id: number) =>
setExpandedBookings((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Bulk selection of bookings (list view, canMutate only).
const [selectedBookings, setSelectedBookings] = useState<Set<number>>(
() => new Set(),
);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const toggleBookingSelected = (id: number) =>
setSelectedBookings((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Clear selection whenever the visible list changes (search, view, tab).
useEffect(() => {
setSelectedBookings(new Set());
}, [debouncedBookingSearch, bookingsView, tab]);
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
// Bumped by the "اليوم" button to re-scroll the day grid to the now-line
// even when the selected day is already today.
const [todayScrollSignal, setTodayScrollSignal] = useState(0);
// Booking selected from the calendar day grid → read-only details dialog.
const [detailsBooking, setDetailsBooking] = useState<Booking | null>(null);
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
const now = useNow(60000);
const bookedDates = useMemo(() => {
const map = new Map<string, Date>();
for (const b of bookings.data ?? []) {
const d = new Date(b.startsAt);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
if (!map.has(key))
map.set(key, new Date(d.getFullYear(), d.getMonth(), d.getDate()));
}
return Array.from(map.values());
}, [bookings.data]);
const publicBookingUrl = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/protocol/request`;
const [linkCopied, setLinkCopied] = useState(false);
const linkCopiedTimer = useRef<number | null>(null);
const copyBookingLink = async () => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(publicBookingUrl);
} else {
const ta = document.createElement("textarea");
ta.value = publicBookingUrl;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} finally {
document.body.removeChild(ta);
}
if (!ok) throw new Error("execCommand copy failed");
}
setLinkCopied(true);
toast({ title: t("protocol.bookings.linkCopied") });
if (linkCopiedTimer.current) window.clearTimeout(linkCopiedTimer.current);
linkCopiedTimer.current = window.setTimeout(
() => setLinkCopied(false),
2000,
);
} catch {
toast({
title: t("protocol.bookings.linkCopyFailed"),
variant: "destructive",
});
}
};
const [deleteTarget, setDeleteTarget] = useState<
| { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number }
| null
>(null);
const [roomDialog, setRoomDialog] = useState<{
open: boolean;
edit?: Room;
}>({ open: false });
const [externalDialog, setExternalDialog] = useState<{
open: boolean;
edit?: ExternalMeeting;
}>({ open: false });
const [giftDialog, setGiftDialog] = useState<{
open: boolean;
edit?: Gift;
}>({ open: false });
const [issueDialog, setIssueDialog] = useState<{ open: boolean }>({
open: false,
});
const [giftsGroupOpen, setGiftsGroupOpen] = useState(false);
const [roomsGroupOpen, setRoomsGroupOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(true);
const bookingAction = useMutation({
mutationFn: (args: { id: number; action: "approve" | "cancel" }) =>
apiJson(`${API}/protocol/bookings/${args.id}/${args.action}`, {
method: "POST",
body: JSON.stringify({}),
}),
onSuccess: () => invalidate("bookings"),
onError: onApiError,
});
const rejectMut = useMutation({
mutationFn: (args: { kind: "booking" | "issue" | "external"; id: number }) => {
const path =
args.kind === "booking"
? "bookings"
: args.kind === "issue"
? "gift-issues"
: "external-meetings";
return apiJson(`${API}/protocol/${path}/${args.id}/reject`, {
method: "POST",
body: JSON.stringify({}),
});
},
onSuccess: () => {
setRejectTarget(null);
invalidate("bookings", "issues", "external");
},
onError: onApiError,
});
const externalApprove = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/external-meetings/${id}/approve`, {
method: "POST",
body: JSON.stringify({}),
}),
onSuccess: () => invalidate("external"),
onError: onApiError,
});
const issueApprove = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/gift-issues/${id}/approve`, {
method: "POST",
body: JSON.stringify({}),
}),
onSuccess: () => invalidate("issues", "gifts"),
onError: onApiError,
});
const deleteMut = useMutation({
mutationFn: (target: { kind: string; id: number }) => {
const path =
target.kind === "room"
? "rooms"
: target.kind === "booking"
? "bookings"
: target.kind === "external"
? "external-meetings"
: target.kind === "gift"
? "gifts"
: "gift-issues";
return apiJson(`${API}/protocol/${path}/${target.id}`, {
method: "DELETE",
});
},
onSuccess: () => {
setDeleteTarget(null);
invalidate("rooms", "bookings", "external", "gifts", "issues");
},
onError: (e) => {
setDeleteTarget(null);
onApiError(e);
},
});
const bulkDeleteMut = useMutation({
mutationFn: (ids: number[]) =>
apiJson<{ requested: number; deleted: number; deletedIds: number[] }>(
`${API}/protocol/bookings/bulk-delete`,
{ method: "POST", body: JSON.stringify({ ids }) },
),
onSuccess: (data, ids) => {
setBulkDeleteOpen(false);
setSelectedBookings(new Set());
if (data.deleted < ids.length) {
toast({
title: t("protocol.bookings.bulk.deletePartial", {
deleted: data.deleted,
total: ids.length,
}),
});
} else {
toast({
title: t("protocol.bookings.bulk.deleted", { count: data.deleted }),
});
}
invalidate("bookings");
},
onError: (e) => {
setBulkDeleteOpen(false);
onApiError(e);
},
});
const back = () => setLocation("/");
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
{ key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard },
{ key: "roomsDashboard", label: t("protocol.tabs.roomsDashboard"), icon: LayoutDashboard },
{ key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen },
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock },
...(c?.canManageRooms
? [
{
key: "bookingSettings" as TabKey,
label: t("protocol.tabs.bookingSettings"),
icon: SettingsIcon,
},
]
: []),
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake },
{ key: "gifts", label: t("protocol.tabs.gifts"), icon: Warehouse },
{ key: "issues", label: t("protocol.tabs.issues"), icon: Gift },
{ key: "photos", label: t("protocol.tabs.photos"), icon: ImageIcon },
{ key: "reports", label: t("protocol.tabs.reports"), icon: BarChart3 },
...(c?.canViewAudit
? [{ key: "audit" as TabKey, label: t("protocol.tabs.audit"), icon: ScrollText }]
: []),
];
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const bookingNumber = (b: Booking) => {
const digits = (b.bookingReference ?? "")
.replace(/[^\d-]/g, "")
.replace(/^-+|-+$/g, "");
return digits || String(b.id).padStart(4, "0");
};
const fmtDateOnly = (iso: string) =>
new Date(iso).toLocaleDateString(isAr ? "ar" : "en", {
dateStyle: "medium",
});
// Latin digits even in Arabic (ar-u-nu-latn) so times look like "12:30 م".
// Rendered inside RTL containers WITHOUT dir="ltr": forcing LTR on text
// that mixes Latin digits with Arabic ص/م makes the bidi algorithm flip
// the order (e.g. "م 1:30 م 12:30").
const fmtTimeOnly = (iso: string) =>
new Date(iso).toLocaleTimeString(isAr ? "ar-u-nu-latn" : "en", {
timeStyle: "short",
});
const renderBookingRow = (b: Booking, index: number) => {
const expanded = expandedBookings.has(b.id);
const bookingIsToday = sameYmd(new Date(b.startsAt), now);
const detailColSpan = c?.canMutate ? 9 : 8;
return (
<>
<TableRow
key={b.id}
onClick={() => toggleBookingExpanded(b.id)}
aria-expanded={expanded}
className={cn(
"cursor-pointer",
bookingIsToday
? "bg-emerald-50/60 hover:bg-emerald-50"
: "odd:bg-white even:bg-slate-50/60",
expanded &&
"[&>td]:border-t-2 [&>td]:border-t-slate-500 [&>td:first-child]:border-s-2 [&>td:first-child]:border-s-slate-500 [&>td:last-child]:border-e-2 [&>td:last-child]:border-e-slate-500",
)}
data-testid={`booking-row-${b.id}`}
>
{c?.canMutate && (
<TableCell
className="w-8 text-center align-middle"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="h-4 w-4 cursor-pointer accent-sky-600 align-middle"
checked={selectedBookings.has(b.id)}
onChange={() => toggleBookingSelected(b.id)}
aria-label={t("protocol.bookings.bulk.selectRow", {
number: bookingNumber(b),
})}
data-testid={`booking-select-${b.id}`}
/>
</TableCell>
)}
<TableCell className="w-10 text-center align-middle">
<span className="mx-auto flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
{index}
</span>
</TableCell>
<TableCell className="whitespace-nowrap text-center align-middle font-semibold text-sky-700">
<div className="flex flex-col items-center gap-0.5">
<span dir="ltr">{bookingNumber(b)}</span>
{bookingIsToday && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 font-normal">
{t("protocol.bookings.today")}
</span>
)}
</div>
</TableCell>
<TableCell className="min-w-0 text-center align-middle">
<div className="flex flex-col items-center gap-0.5">
<div className="font-medium text-slate-800 truncate max-w-[180px]">
{b.requesterName || "—"}
</div>
{b.source === "public" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-sky-100 text-sky-700">
{t("protocol.bookings.publicRequest")}
</span>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-center align-middle text-slate-600">
<div className="flex flex-col items-center gap-0.5 leading-tight">
<div>{fmtDateOnly(b.startsAt)}</div>
<div className="text-xs text-slate-400">
{fmtTimeOnly(b.startsAt)} {fmtTimeOnly(b.endsAt)}
</div>
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-center align-middle">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
b.meetingType === "external"
? "bg-rose-100 text-rose-700"
: "bg-slate-100 text-slate-600",
)}
>
{t(`protocol.bookings.meetingTypesShort.${b.meetingType}`)}
</span>
</TableCell>
<TableCell className="text-center align-middle text-slate-600">
<div className="flex flex-col items-center gap-0.5 leading-tight">
<div className="truncate max-w-[160px]">{roomName(b.roomId)}</div>
{b.attendeeCount != null && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.table.attendeesShort")}:{" "}
{b.attendeeCount}
</div>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-center align-middle">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
STATUS_PILL[b.status],
)}
>
{t(`protocol.status.${b.status}`)}
</span>
</TableCell>
<TableCell
className="whitespace-nowrap text-center align-middle"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-center gap-0.5">
{c?.canApprove && b.status === "pending" && (
<>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600"
title={t("protocol.actions.approve")}
aria-label={t("protocol.actions.approve")}
onClick={() =>
bookingAction.mutate({ id: b.id, action: "approve" })
}
>
<Check size={14} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-rose-500"
title={t("protocol.actions.reject")}
aria-label={t("protocol.actions.reject")}
onClick={() =>
setRejectTarget({ kind: "booking", id: b.id })
}
>
<X size={14} />
</Button>
</>
)}
{c?.canMutate &&
(b.status === "pending" || b.status === "approved") && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-slate-500"
title={t("protocol.actions.cancel")}
aria-label={t("protocol.actions.cancel")}
onClick={() =>
bookingAction.mutate({ id: b.id, action: "cancel" })
}
>
<Ban size={14} />
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={t("protocol.bookings.edit")}
aria-label={t("protocol.bookings.edit")}
onClick={() => setBookingDialog({ open: true, edit: b })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={() =>
setDeleteTarget({ kind: "booking", id: b.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
<ChevronDown
size={14}
className={cn(
"ms-1 text-slate-400 transition-transform cursor-pointer",
expanded && "rotate-180",
)}
onClick={() => toggleBookingExpanded(b.id)}
/>
</div>
</TableCell>
</TableRow>
{expanded && (
<TableRow key={`${b.id}-details`} className="bg-slate-50/80">
<TableCell
colSpan={detailColSpan}
className="border-x-2 border-b-2 border-slate-500 p-0"
>
<div className="overflow-hidden bg-white">
<div className="border-b border-slate-300 bg-slate-50 px-4 py-2.5 text-center">
<div className="text-sm font-bold text-slate-900">
{b.title}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 divide-x divide-x-reverse divide-slate-300 border-b border-slate-300">
{(
[
[t("protocol.bookings.requester"), b.requesterName],
[t("protocol.bookings.department"), b.department],
[
t("protocol.bookings.phone"),
b.requesterPhone ? (
<span dir="ltr">{b.requesterPhone}</span>
) : null,
],
...(b.meetingType === "external"
? [
[
t("protocol.bookings.entity"),
b.requesterOrg,
] as const,
]
: []),
[
t("protocol.bookings.meetingType"),
t(`protocol.bookings.meetingTypes.${b.meetingType}`),
],
[
t("protocol.bookings.attendeeCount"),
b.attendeeCount != null ? b.attendeeCount : null,
],
[
t("protocol.bookings.reference"),
b.bookingReference ? (
<span dir="ltr">{b.bookingReference}</span>
) : null,
],
[t("protocol.bookings.purpose"), b.purpose],
] as Array<[string, React.ReactNode]>
).map(([label, value], i) => (
<div
key={i}
className="border-b border-slate-300 px-4 py-2 text-center"
>
<div className="text-[11px] text-slate-400">
{label}
</div>
<div className="text-xs font-semibold text-slate-800 break-words">
{value != null && value !== "" ? value : "—"}
</div>
</div>
))}
</div>
<div>
<div className="px-4 pt-3 pb-2 text-center text-base font-bold text-slate-900">
{t("protocol.bookings.keyAttendees")}
</div>
{b.keyAttendees && b.keyAttendees.length > 0 ? (
<AttendeesTable
attendees={b.keyAttendees}
t={t}
flush
/>
) : (
<span className="block px-4 pb-2.5 text-xs text-slate-600">
</span>
)}
</div>
{b.status === "rejected" && b.rejectionReason && (
<div className="border-t border-rose-200 bg-rose-50 px-4 py-2">
<div className="text-[11px] font-semibold text-rose-600">
{t("protocol.bookings.details.rejectionReason")}
</div>
<div className="text-xs text-rose-700 break-words">
{b.rejectionReason}
</div>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
);
};
const renderBookingsTable = (list: Booking[]) => {
const selectedInList = list.filter((b) => selectedBookings.has(b.id));
const allSelected =
list.length > 0 && selectedInList.length === list.length;
const someSelected = selectedInList.length > 0 && !allSelected;
return (
<div className="space-y-2">
{c?.canMutate && selectedInList.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-sky-200 bg-sky-50 px-3 py-2">
<span className="text-sm font-medium text-sky-800">
{t("protocol.bookings.bulk.selectedCount", {
count: selectedInList.length,
})}
</span>
<div className="ms-auto flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedBookings(new Set())}
>
{t("protocol.bookings.bulk.clearSelection")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setBulkDeleteOpen(true)}
disabled={bulkDeleteMut.isPending}
data-testid="booking-bulk-delete"
>
<Trash2 size={14} className="me-1" />
{t("protocol.bookings.bulk.delete")}
</Button>
</div>
</div>
)}
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-100/80 hover:bg-slate-100/80">
{c?.canMutate && (
<TableHead className="w-8 text-center">
<input
type="checkbox"
className="h-4 w-4 cursor-pointer accent-sky-600 align-middle"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={() =>
setSelectedBookings(
allSelected
? new Set()
: new Set(list.map((b) => b.id)),
)
}
aria-label={t("protocol.bookings.bulk.selectAll")}
title={t("protocol.bookings.bulk.selectAll")}
data-testid="booking-select-all"
/>
</TableHead>
)}
<TableHead className="w-10 text-center">#</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.number")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.requester")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.date")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.type")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.roomAttendees")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.status")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("protocol.bookings.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((b, i) => (
<Fragment key={b.id}>{renderBookingRow(b, i + 1)}</Fragment>
))}
</TableBody>
</Table>
</div>
</div>
);
};
return (
<div className="min-h-screen bg-slate-50" dir={isAr ? "rtl" : "ltr"}>
<header className="sticky top-0 z-10 bg-white border-b border-slate-200">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={back} aria-label={t("common.back")}>
<BackIcon size={20} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setMenuOpen((v) => !v)}
aria-label={t("protocol.menuToggle")}
title={t("protocol.menuToggle")}
data-testid="protocol-menu-toggle"
>
<MenuIcon size={20} />
</Button>
<div className="flex items-center gap-2 min-w-0">
<div className="h-9 w-9 rounded-xl bg-[#0f1d3d] text-white flex items-center justify-center shrink-0">
<Handshake size={20} />
</div>
<h1 className="text-lg font-bold text-slate-800 truncate">
{t("protocol.title")}
</h1>
</div>
</div>
</header>
<div className="flex flex-col md:flex-row md:items-stretch">
{menuOpen && (
<aside className="md:w-56 shrink-0 bg-slate-100 border-b md:border-b-0 md:border-e border-slate-200 md:min-h-[calc(100vh-61px)]">
<nav className="flex md:flex-col gap-1 overflow-x-auto md:overflow-visible p-2 md:sticky md:top-[73px]">
{TABS.map((item) => {
const { key, label, icon: Icon } = item;
if (key === "issues") return null;
if (
key === "rooms" ||
key === "bookings" ||
key === "bookingSettings"
) {
return null;
}
if (key === "roomsDashboard") {
// "إدارة القاعات" collapsible group (same pattern as gifts).
const children = TABS.filter(
(x) =>
x.key === "roomsDashboard" ||
x.key === "rooms" ||
x.key === "bookings" ||
x.key === "bookingSettings",
);
const groupActive = children.some((x) => x.key === tab);
const open = roomsGroupOpen || groupActive;
return (
<div key="roomsGroup" className="flex md:flex-col gap-1">
<button
type="button"
onClick={() => setRoomsGroupOpen((v) => !v)}
aria-expanded={open}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm whitespace-nowrap transition-colors md:w-full md:justify-start",
groupActive
? "text-sky-600 font-medium"
: "text-slate-600 hover:bg-slate-200",
)}
>
<DoorOpen size={16} className="shrink-0" />
{t("protocol.tabs.roomsGroup")}
<ChevronDown
size={14}
className={cn(
"ms-auto shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
{open &&
children.map(({ key: ck, label: cl, icon: CIcon }) => (
<button
key={ck}
onClick={() => setTab(ck)}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm whitespace-nowrap transition-colors md:w-full md:justify-start md:ps-6",
tab === ck
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-200",
)}
>
<CIcon size={16} className="shrink-0" />
{cl}
</button>
))}
</div>
);
}
if (key === "gifts") {
const children = TABS.filter(
(x) => x.key === "gifts" || x.key === "issues",
);
const groupActive = children.some((x) => x.key === tab);
const open = giftsGroupOpen || groupActive;
return (
<div key="giftsGroup" className="flex md:flex-col gap-1">
<button
type="button"
onClick={() => setGiftsGroupOpen((v) => !v)}
aria-expanded={open}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm whitespace-nowrap transition-colors md:w-full md:justify-start",
groupActive
? "text-sky-600 font-medium"
: "text-slate-600 hover:bg-slate-200",
)}
>
<HandHeart size={16} className="shrink-0" />
{t("protocol.tabs.giftsGroup")}
<ChevronDown
size={14}
className={cn(
"ms-auto shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
{open &&
children.map(({ key: ck, label: cl, icon: CIcon }) => (
<button
key={ck}
onClick={() => setTab(ck)}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm whitespace-nowrap transition-colors md:w-full md:justify-start md:ps-6",
tab === ck
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-200",
)}
>
<CIcon size={16} className="shrink-0" />
{cl}
</button>
))}
</div>
);
}
return (
<button
key={key}
onClick={() => setTab(key)}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm whitespace-nowrap transition-colors md:w-full md:justify-start",
tab === key
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-200",
)}
>
<Icon size={16} className="shrink-0" />
{label}
</button>
);
})}
</nav>
</aside>
)}
<main className="flex-1 min-w-0 w-full max-w-6xl md:mx-auto px-4 py-5">
{tab === "dashboard" && (
<DashboardView data={dashboard.data} t={t} goTab={setTab} />
)}
{tab === "bookings" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.bookings")}
</h2>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
<button
type="button"
onClick={() => setBookingsView("list")}
className={cn(
"px-2.5 py-1.5 text-xs flex items-center gap-1",
bookingsView === "list"
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-100",
)}
>
<ListIcon size={14} />
{t("protocol.bookings.viewList")}
</button>
<button
type="button"
onClick={() => setBookingsView("calendar")}
className={cn(
"px-2.5 py-1.5 text-xs flex items-center gap-1",
bookingsView === "calendar"
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-100",
)}
>
<CalendarDays size={14} />
{t("protocol.bookings.viewCalendar")}
</button>
</div>
{c?.canRequest && (
<Button
size="sm"
onClick={() => setBookingDialog({ open: true })}
>
<Plus size={16} className="me-1" />
{t("protocol.bookings.new")}
</Button>
)}
</div>
</div>
<div className="rounded-lg border border-sky-200 bg-sky-50/60 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2">
<Link2 size={16} className="shrink-0 text-sky-600" />
<p className="text-xs font-medium text-slate-700">
{t("protocol.bookings.shareLinkTitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start sm:self-auto">
<Button
size="sm"
variant="outline"
onClick={() =>
window.open(publicBookingUrl, "_blank", "noopener,noreferrer")
}
>
<ExternalLink size={16} className="me-1" />
{t("protocol.bookings.openLink")}
</Button>
<Button size="sm" variant="outline" onClick={copyBookingLink}>
{linkCopied ? (
<Check size={16} className="me-1 text-emerald-600" />
) : (
<Copy size={16} className="me-1" />
)}
{linkCopied
? t("protocol.bookings.linkCopied")
: t("protocol.bookings.copyLink")}
</Button>
</div>
</div>
</div>
{bookingsView === "list" && (
<div className="relative">
<SearchIcon
size={16}
className="pointer-events-none absolute top-1/2 -translate-y-1/2 start-3 text-slate-400"
/>
<Input
value={bookingSearch}
onChange={(e) => setBookingSearch(e.target.value)}
placeholder={t("protocol.bookings.searchPlaceholder")}
className="ps-9"
/>
</div>
)}
{bookings.data?.length === 0 && !debouncedBookingSearch && (
<EmptyState text={t("protocol.bookings.empty")} />
)}
{bookingsView === "list" ? (
(() => {
const list = bookings.data ?? [];
if (debouncedBookingSearch && list.length === 0) {
return (
<EmptyState
text={t("protocol.bookings.searchEmpty")}
/>
);
}
if (list.length === 0) return null;
return renderBookingsTable(list);
})()
) : (
<div className="flex flex-col gap-4 lg:flex-row">
<div className="shrink-0 self-center lg:self-start">
<div className="rounded-xl border border-slate-200 bg-white p-2">
<Calendar
mode="single"
required
selected={selectedDay}
onSelect={(d) => d && setSelectedDay(d)}
month={calendarMonth}
onMonthChange={setCalendarMonth}
modifiers={{ booked: bookedDates }}
modifiersClassNames={{
booked:
"relative after:absolute after:bottom-1 after:start-1/2 after:-translate-x-1/2 after:h-1 after:w-1 after:rounded-full after:bg-sky-500",
}}
/>
</div>
</div>
<div className="min-w-0 flex-1 rounded-xl border border-slate-200 bg-white p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-slate-600">
{selectedDay.toLocaleDateString(isAr ? "ar" : "en", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
const today = new Date();
setSelectedDay(today);
setCalendarMonth(today);
setTodayScrollSignal((v) => v + 1);
}}
>
<CalendarDays size={14} className="me-1" />
{t("protocol.bookings.today")}
</Button>
{c?.canRequest && (
<Button
size="sm"
onClick={() =>
setBookingDialog({ open: true, day: selectedDay })
}
>
<Plus size={14} className="me-1" />
{t("protocol.bookings.book")}
</Button>
)}
</div>
</div>
<BookingDayGrid
bookings={bookings.data ?? []}
selectedDay={selectedDay}
now={now}
isAr={isAr}
roomName={roomName}
emptyText={t("protocol.bookings.emptyDay")}
scrollSignal={todayScrollSignal}
onSelect={(b) => setDetailsBooking(b)}
onSlotClick={
c?.canRequest
? (start) => setBookingDialog({ open: true, start })
: undefined
}
/>
</div>
</div>
)}
</section>
)}
{tab === "rooms" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.rooms")}
</h2>
{c?.canManageRooms && (
<Button size="sm" onClick={() => setRoomDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.rooms.new")}
</Button>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(rooms.data ?? []).map((r) => (
<div
key={r.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">
{nameOf(r.nameAr, r.nameEn)}
</div>
<div className="text-xs text-slate-500">
{r.capacity
? `${t("protocol.rooms.capacity")}: ${r.capacity}`
: ""}
</div>
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0",
r.isActive
? "bg-emerald-100 text-emerald-700"
: "bg-slate-200 text-slate-500",
)}
>
{r.isActive
? t("protocol.rooms.active")
: t("protocol.rooms.inactive")}
</span>
</div>
{c?.canManageRooms && (
<div className="flex gap-1.5 mt-2">
<Button
size="sm"
variant="ghost"
onClick={() => setRoomDialog({ open: true, edit: r })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteTarget({ kind: "room", id: r.id })}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</div>
)}
</div>
))}
{rooms.data?.length === 0 && (
<EmptyState text={t("protocol.rooms.empty")} />
)}
</div>
</section>
)}
{tab === "bookingSettings" && c?.canManageRooms && (
<section className="space-y-4">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.bookingSettings")}
</h2>
<SlotsAdminSection
isAr={isAr}
onChanged={() => invalidate("slots")}
onError={onApiError}
/>
</section>
)}
{tab === "roomsDashboard" && (
<RoomsDashboardView isAr={isAr} now={now} goTab={setTab} />
)}
{tab === "bookingSettings" && !c?.canManageRooms && (
<EmptyState text={t("protocol.roomsDash.noAccess")} />
)}
{tab === "external" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.external")}
</h2>
{c?.canMutate && (
<Button size="sm" onClick={() => setExternalDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.external.new")}
</Button>
)}
</div>
<div className="grid gap-2">
{(external.data ?? []).map((m) => (
<div
key={m.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">{m.title}</div>
<div className="text-sm text-slate-500">
{m.partyName ? `${m.partyName} · ` : ""}
{fmtDateTime(m.startsAt)}
</div>
{m.location && (
<div className="text-xs text-slate-400">{m.location}</div>
)}
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0",
STATUS_PILL[m.status],
)}
>
{t(`protocol.status.${m.status}`)}
</span>
</div>
{m.status === "rejected" && m.rejectionReason && (
<div className="text-xs text-rose-500 mt-1">
{t("protocol.actions.reject")}: {m.rejectionReason}
</div>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
{c?.canApprove && m.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
onClick={() => externalApprove.mutate(m.id)}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setRejectTarget({ kind: "external", id: m.id })
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setExternalDialog({ open: true, edit: m })
}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "external", id: m.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div>
))}
{external.data?.length === 0 && (
<EmptyState text={t("protocol.external.empty")} />
)}
</div>
</section>
)}
{tab === "gifts" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.gifts")}
</h2>
<div className="flex gap-2">
{c?.canMutate && (
<Button size="sm" onClick={() => setGiftDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.gifts.new")}
</Button>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(gifts.data ?? []).map((g) => (
<div
key={g.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">
{nameOf(g.nameAr, g.nameEn)}
</div>
<div className="text-xs text-slate-500">
{t(`protocol.giftKind.${g.kind}`)} ·{" "}
{t("protocol.gifts.stock")}: {g.quantityInStock}
</div>
</div>
<div className="flex gap-1 shrink-0">
{c?.canRequest && (
<Button
size="sm"
variant="outline"
onClick={() => setIssueDialog({ open: true })}
>
{t("protocol.issues.new")}
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setGiftDialog({ open: true, edit: g })
}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "gift", id: g.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div>
</div>
))}
{gifts.data?.length === 0 && (
<EmptyState text={t("protocol.gifts.empty")} />
)}
</div>
</section>
)}
{tab === "issues" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.issues")}
</h2>
{c?.canRequest && (
<Button size="sm" onClick={() => setIssueDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.issues.new")}
</Button>
)}
</div>
<div className="grid gap-2">
{(issues.data ?? []).map((it) => (
<div
key={it.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">
{giftName(it.giftId)} × {it.quantity}
</div>
<div className="text-sm text-slate-500">
{t("protocol.issues.recipient")}: {it.recipientName}
{it.occasion ? ` · ${it.occasion}` : ""}
</div>
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0",
STATUS_PILL[it.status],
)}
>
{t(`protocol.status.${it.status}`)}
</span>
</div>
{c?.canApprove && it.status === "pending" && (
<div className="flex gap-1.5 mt-2">
<Button
size="sm"
variant="outline"
onClick={() => issueApprove.mutate(it.id)}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approveIssue")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setRejectTarget({ kind: "issue", id: it.id })
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</div>
)}
</div>
))}
{issues.data?.length === 0 && (
<EmptyState text={t("protocol.issues.empty")} />
)}
</div>
</section>
)}
{tab === "photos" && (
<PhotosView
canMutate={!!c?.canMutate}
isAr={isAr}
externalMeetings={external.data ?? []}
onApiError={onApiError}
/>
)}
{tab === "reports" && (
<ReportsView data={reports.data} nameOf={nameOf} t={t} />
)}
{tab === "audit" && (
<section className="space-y-2">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.audit")}
</h2>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{(audit.data ?? []).map((a) => (
<div key={a.id} className="p-3 text-sm flex justify-between gap-2">
<span className="text-slate-700">
{t(`protocol.audit.${a.action}`, a.action)}
<span className="text-slate-400">
{" "}
· {a.entityType}
{a.entityId ? `#${a.entityId}` : ""}
</span>
</span>
<span className="text-slate-400 shrink-0">
{isAr ? a.actorNameAr : a.actorNameEn || a.actorUsername} ·{" "}
{fmtDateTime(a.performedAt)}
</span>
</div>
))}
{audit.data?.length === 0 && (
<EmptyState text={t("protocol.audit.empty")} />
)}
</div>
</section>
)}
</main>
</div>
{/* Dialogs */}
{bookingDialog.open && (
<BookingDialog
rooms={rooms.data ?? []}
slots={slots.data ?? []}
edit={bookingDialog.edit}
initialDay={bookingDialog.day}
initialStart={bookingDialog.start}
nameOf={nameOf}
onClose={() => setBookingDialog({ open: false })}
onSaved={() => {
setBookingDialog({ open: false });
invalidate("bookings");
}}
onError={onApiError}
/>
)}
{roomDialog.open && (
<RoomDialog
edit={roomDialog.edit}
onClose={() => setRoomDialog({ open: false })}
onSaved={() => {
setRoomDialog({ open: false });
invalidate("rooms");
}}
onError={onApiError}
/>
)}
{externalDialog.open && (
<ExternalDialog
edit={externalDialog.edit}
onClose={() => setExternalDialog({ open: false })}
onSaved={() => {
setExternalDialog({ open: false });
invalidate("external");
}}
onError={onApiError}
/>
)}
{giftDialog.open && (
<GiftDialog
edit={giftDialog.edit}
onClose={() => setGiftDialog({ open: false })}
onSaved={() => {
setGiftDialog({ open: false });
invalidate("gifts");
}}
onError={onApiError}
/>
)}
{issueDialog.open && (
<IssueDialog
gifts={(gifts.data ?? []).filter((g) => g.isActive)}
nameOf={nameOf}
onClose={() => setIssueDialog({ open: false })}
onSaved={() => {
setIssueDialog({ open: false });
invalidate("issues");
}}
onError={onApiError}
/>
)}
<AlertDialog
open={rejectTarget !== null}
onOpenChange={(o) => !o && setRejectTarget(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("protocol.actions.reject")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.confirmReject")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => rejectTarget && rejectMut.mutate(rejectTarget)}
>
{t("protocol.actions.reject")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(o) => !o && setDeleteTarget(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.confirmDelete")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deleteTarget && deleteMut.mutate(deleteTarget)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={bulkDeleteOpen}
onOpenChange={(o) => !o && setBulkDeleteOpen(false)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("protocol.bookings.bulk.deleteConfirmTitle", {
count: selectedBookings.size,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.bookings.bulk.deleteConfirmBody", {
count: selectedBookings.size,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() =>
selectedBookings.size > 0 &&
bulkDeleteMut.mutate(Array.from(selectedBookings))
}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={!!detailsBooking}
onOpenChange={(o) => !o && setDetailsBooking(null)}
>
<DialogContent
dir={isAr ? "rtl" : "ltr"}
className="max-h-[85vh] overflow-y-auto sm:max-w-lg"
>
{detailsBooking && (
<>
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2 text-start">
<span>{detailsBooking.title}</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-normal",
STATUS_PILL[detailsBooking.status],
)}
>
{t(`protocol.status.${detailsBooking.status}`)}
</span>
{detailsBooking.source === "public" && (
<span className="rounded-full bg-sky-100 px-2 py-0.5 text-xs font-normal text-sky-700">
{t("protocol.bookings.publicRequest")}
</span>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="rounded-lg border border-slate-200 bg-slate-50/60 p-3">
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<DetailRow
label={t("protocol.bookings.details.date")}
value={fmtDateOnly(detailsBooking.startsAt)}
/>
<DetailRow
label={t("protocol.bookings.details.time")}
value={
<span>
{fmtTimeOnly(detailsBooking.startsAt)} {" "}
{fmtTimeOnly(detailsBooking.endsAt)}
</span>
}
/>
<DetailRow
label={t("protocol.bookings.details.duration")}
value={formatDuration(
detailsBooking.startsAt,
detailsBooking.endsAt,
t,
)}
/>
<DetailRow
label={t("protocol.bookings.roomLabel")}
value={roomName(detailsBooking.roomId)}
/>
<DetailRow
label={t("protocol.bookings.reference")}
value={
detailsBooking.bookingReference ? (
<span dir="ltr">
{detailsBooking.bookingReference}
</span>
) : null
}
/>
<DetailRow
label={t("protocol.bookings.meetingType")}
value={t(
`protocol.bookings.meetingTypes.${detailsBooking.meetingType}`,
)}
/>
<DetailRow
label={t("protocol.bookings.details.source")}
value={t(
`protocol.bookings.details.sources.${detailsBooking.source}`,
)}
/>
</div>
</div>
<div>
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-400">
{t("protocol.bookings.details.requesterSection")}
</div>
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<DetailRow
label={t("protocol.bookings.requester")}
value={detailsBooking.requesterName}
/>
<DetailRow
label={t("protocol.bookings.phone")}
value={
detailsBooking.requesterPhone ? (
<span dir="ltr">
{detailsBooking.requesterPhone}
</span>
) : null
}
/>
{detailsBooking.meetingType === "external" && (
<DetailRow
label={t("protocol.bookings.entity")}
value={detailsBooking.requesterOrg}
/>
)}
<DetailRow
label={t("protocol.bookings.department")}
value={detailsBooking.department}
/>
<DetailRow
label={t("protocol.bookings.attendeeCount")}
value={
detailsBooking.attendeeCount != null
? detailsBooking.attendeeCount
: null
}
/>
</div>
</div>
{(detailsBooking.purpose ||
(detailsBooking.keyAttendees?.length ?? 0) > 0) && (
<div>
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-400">
{t("protocol.bookings.details.meetingSection")}
</div>
<div className="space-y-2">
<DetailRow
label={t("protocol.bookings.purpose")}
value={detailsBooking.purpose}
/>
{(detailsBooking.keyAttendees?.length ?? 0) > 0 && (
<div>
<div className="text-xs text-slate-400">
{t("protocol.bookings.keyAttendees")}
</div>
<div className="mt-1.5">
<AttendeesTable
attendees={detailsBooking.keyAttendees!}
t={t}
/>
</div>
</div>
)}
</div>
</div>
)}
{detailsBooking.notes && (
<div>
<div className="mb-1 text-xs text-slate-400">
{t("protocol.bookings.details.notes")}
</div>
<div className="whitespace-pre-wrap break-words text-slate-700">
{detailsBooking.notes}
</div>
</div>
)}
{detailsBooking.status === "rejected" &&
detailsBooking.rejectionReason && (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3">
<div className="mb-1 text-xs font-semibold text-rose-600">
{t("protocol.bookings.details.rejectionReason")}
</div>
<div className="whitespace-pre-wrap break-words text-rose-700">
{detailsBooking.rejectionReason}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDetailsBooking(null)}
>
{t("common.close")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<AppDock currentSlug="protocol" />
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-views & dialogs
// ---------------------------------------------------------------------------
function AttendeesTable({
attendees,
t,
flush = false,
}: {
attendees: KeyAttendee[];
t: (key: string) => string;
flush?: boolean;
}) {
return (
<div
className={cn(
"overflow-x-auto",
flush
? "border-t border-slate-300"
: "rounded-xl border border-slate-200 shadow-sm",
)}
>
<Table>
<TableHeader>
<TableRow
className={cn(
"border-b bg-slate-100/80 hover:bg-slate-100/80",
flush ? "border-slate-300" : "border-slate-200",
)}
>
<TableHead className="h-10 w-10 whitespace-nowrap px-4 text-center text-xs font-bold uppercase tracking-wide text-slate-600">
#
</TableHead>
{(["name", "position", "side"] as const).map((col) => (
<TableHead
key={col}
className="h-10 whitespace-nowrap px-4 text-center text-xs font-bold uppercase tracking-wide text-slate-600"
>
{t(`protocol.bookings.details.attendeeCols.${col}`)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{attendees.map((a, i) => (
<TableRow
key={i}
className={cn(
"border-b last:border-b-0 even:bg-slate-50/60",
flush ? "border-slate-200" : "border-slate-100",
)}
>
<TableCell className="w-10 px-4 py-2.5 text-center">
<span className="mx-auto flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
{i + 1}
</span>
</TableCell>
<TableCell className="px-4 py-2.5 text-center font-medium text-slate-700">
{a.name || "—"}
</TableCell>
<TableCell className="px-4 py-2.5 text-center text-slate-600">
{a.position || "—"}
</TableCell>
<TableCell className="px-4 py-2.5 text-center">
<span
className={cn(
"inline-block whitespace-nowrap rounded-full px-2.5 py-0.5 text-[11px] font-medium",
a.side === "external"
? "bg-rose-100 text-rose-700"
: "bg-sky-100 text-sky-700",
)}
>
{t(`protocol.bookings.attendeeSides.${a.side}`)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function DetailRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
if (value == null || value === "") return null;
return (
<div className="min-w-0">
<div className="text-xs text-slate-400">{label}</div>
<div className="min-w-0 break-words text-slate-700">{value}</div>
</div>
);
}
function formatDuration(
startIso: string,
endIso: string,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
const mins = Math.max(
0,
Math.round(
(new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000,
),
);
const h = Math.floor(mins / 60);
const m = mins % 60;
const parts: string[] = [];
if (h > 0) parts.push(t("protocol.bookings.details.durationHours", { count: h }));
if (m > 0 || h === 0)
parts.push(t("protocol.bookings.details.durationMinutes", { count: m }));
return parts.join(" ");
}
// Admin management of booking slots (أوقات الحجز) + the minimum lead-time
// setting for public requests. Shown in the rooms tab for canManageRooms.
function SlotsAdminSection({
isAr,
onChanged,
onError,
}: {
isAr: boolean;
onChanged: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const qc = useQueryClient();
const [newTime, setNewTime] = useState("");
const [newDuration, setNewDuration] = useState("30");
// Interval-generation form (Calendly-style weekly availability).
const [intFrom, setIntFrom] = useState("");
const [intTo, setIntTo] = useState("");
const [intDuration, setIntDuration] = useState("30");
const [intDays, setIntDays] = useState<number[]>(WORKDAYS);
const toggleDay = (d: number) =>
setIntDays((prev) =>
prev.includes(d)
? prev.filter((x) => x !== d)
: [...prev, d].sort((a, b) => a - b),
);
// Inline slot editing (dialog) — startTime / duration / days.
const [editing, setEditing] = useState<Slot | null>(null);
const [editTime, setEditTime] = useState("");
const [editDuration, setEditDuration] = useState("30");
const [editDays, setEditDays] = useState<number[]>([]);
const openEdit = (s: Slot) => {
setEditing(s);
setEditTime(s.startTime);
setEditDuration(String(s.durationMinutes));
setEditDays([...s.daysOfWeek]);
};
const toggleEditDay = (d: number) =>
setEditDays((prev) =>
prev.includes(d)
? prev.filter((x) => x !== d)
: [...prev, d].sort((a, b) => a - b),
);
// Full slot list (incl. inactive) — admin-only endpoint; this component is
// only rendered for canManageRooms.
const slotsQuery = useQuery<Slot[]>({
queryKey: ["protocol", "slots-admin"],
queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`),
});
const slots = slotsQuery.data ?? [];
const sortedSlots = [...slots].sort((a, b) =>
a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0,
);
const changed = () => {
qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] });
onChanged();
};
const settings = useQuery<{ minLeadMinutes: number }>({
queryKey: ["protocol", "settings"],
queryFn: () =>
apiJson<{ minLeadMinutes: number }>(`${API}/protocol/settings`),
});
const [leadInput, setLeadInput] = useState<string | null>(null);
const leadValue =
leadInput ??
(settings.data ? String(settings.data.minLeadMinutes) : "");
const addMut = useMutation({
mutationFn: () =>
apiJson(`${API}/protocol/booking-slots`, {
method: "POST",
body: JSON.stringify({
startTime: newTime,
durationMinutes: Number(newDuration),
}),
}),
onSuccess: () => {
setNewTime("");
changed();
},
onError,
});
const generateMut = useMutation({
mutationFn: () =>
apiJson<{ created: number; merged: number; skipped: number }>(
`${API}/protocol/booking-slots/generate`,
{
method: "POST",
body: JSON.stringify({
startTime: intFrom,
endTime: intTo,
durationMinutes: Number(intDuration),
daysOfWeek: intDays,
}),
},
),
onSuccess: (r) => {
toast({
title:
r.created === 0 && r.merged === 0
? t("protocol.slots.generatedNone")
: t("protocol.slots.generated", {
created: r.created,
merged: r.merged,
skipped: r.skipped,
}),
});
changed();
},
onError,
});
const toggleMut = useMutation({
mutationFn: (s: Slot) =>
apiJson(`${API}/protocol/booking-slots/${s.id}`, {
method: "PATCH",
body: JSON.stringify({ isActive: !s.isActive }),
}),
onSuccess: changed,
onError,
});
const editMut = useMutation({
mutationFn: (s: Slot) =>
apiJson(`${API}/protocol/booking-slots/${s.id}`, {
method: "PATCH",
body: JSON.stringify({
startTime: editTime,
durationMinutes: Number(editDuration),
daysOfWeek: editDays,
}),
}),
onSuccess: () => {
setEditing(null);
toast({ title: t("protocol.slots.editSaved") });
changed();
},
onError: (e: unknown) => {
if (e instanceof ApiError && e.status === 409) {
toast({
title: t("protocol.slots.duplicateSlot"),
variant: "destructive",
});
return;
}
onError(e);
},
});
const deleteMut = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }),
onSuccess: changed,
onError,
});
const leadMut = useMutation({
mutationFn: () =>
apiJson(`${API}/protocol/settings`, {
method: "PATCH",
body: JSON.stringify({ minLeadMinutes: Number(leadValue) }),
}),
onSuccess: () => {
setLeadInput(null);
settings.refetch();
toast({ title: t("protocol.slots.leadSaved") });
},
onError,
});
const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5;
const canSaveEdit =
/^\d{2}:\d{2}$/.test(editTime) &&
Number(editDuration) >= 5 &&
Number(editDuration) <= 480 &&
editDays.length > 0;
const canGenerate =
/^\d{2}:\d{2}$/.test(intFrom) &&
/^\d{2}:\d{2}$/.test(intTo) &&
intFrom < intTo &&
Number(intDuration) >= 5 &&
intDays.length > 0;
const leadDirty =
leadInput !== null &&
settings.data !== undefined &&
leadInput !== String(settings.data.minLeadMinutes) &&
/^\d+$/.test(leadInput);
return (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-3">
<div>
<h3 className="font-semibold text-slate-700">
{t("protocol.slots.title")}
</h3>
<p className="text-xs text-slate-500 mt-0.5">
{t("protocol.slots.hint")}
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<div>
<Label className="text-xs">{t("protocol.slots.startTime")}</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={newTime}
onChange={(e) => setNewTime(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">{t("protocol.slots.duration")}</Label>
<Input
type="number"
inputMode="numeric"
min={5}
max={480}
step={5}
className="w-24"
value={newDuration}
onChange={(e) => setNewDuration(e.target.value)}
/>
</div>
<Button
size="sm"
disabled={!canAdd || addMut.isPending}
onClick={() => addMut.mutate()}
>
<Plus size={16} className="me-1" />
{t("protocol.slots.add")}
</Button>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 space-y-2">
<div>
<h4 className="text-sm font-semibold text-slate-700">
{t("protocol.slots.intervalTitle")}
</h4>
<p className="text-xs text-slate-500 mt-0.5">
{t("protocol.slots.intervalHint")}
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<div>
<Label className="text-xs">
{t("protocol.slots.intervalFrom")}
</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={intFrom}
onChange={(e) => setIntFrom(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">{t("protocol.slots.intervalTo")}</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={intTo}
onChange={(e) => setIntTo(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">{t("protocol.slots.duration")}</Label>
<Input
type="number"
inputMode="numeric"
min={5}
max={480}
step={5}
className="w-24"
value={intDuration}
onChange={(e) => setIntDuration(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("protocol.slots.days")}</Label>
<div className="flex flex-wrap gap-1.5">
{ALL_DAYS.map((d) => (
<button
key={d}
type="button"
onClick={() => toggleDay(d)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
intDays.includes(d)
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300",
)}
>
{t(`protocol.slots.dayNames.${d}`)}
</button>
))}
</div>
<div className="flex gap-2">
<button
type="button"
className="text-xs text-sky-600 hover:underline"
onClick={() => setIntDays(WORKDAYS)}
>
{t("protocol.slots.weekdaysPreset")}
</button>
<button
type="button"
className="text-xs text-sky-600 hover:underline"
onClick={() => setIntDays(ALL_DAYS)}
>
{t("protocol.slots.allDaysPreset")}
</button>
</div>
{intDays.length === 0 && (
<p className="text-xs text-amber-600">
{t("protocol.slots.pickDay")}
</p>
)}
</div>
<Button
size="sm"
disabled={!canGenerate || generateMut.isPending}
onClick={() => generateMut.mutate()}
>
<Plus size={16} className="me-1" />
{t("protocol.slots.generate")}
</Button>
</div>
{slots.length === 0 ? (
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="h-8 w-10 px-2 text-center text-[11px] font-semibold text-slate-500">
#
</TableHead>
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
{t("protocol.slots.colTime")}
</TableHead>
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
{t("protocol.slots.duration")}
</TableHead>
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
{t("protocol.slots.days")}
</TableHead>
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
{t("protocol.slots.colStatus")}
</TableHead>
<TableHead className="h-8 w-24 px-2 text-center text-[11px] font-semibold text-slate-500">
{t("protocol.slots.colActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSlots.map((s, i) => (
<TableRow key={s.id} className="hover:bg-slate-50/60">
<TableCell className="px-2 py-1.5 text-center text-xs tabular-nums text-slate-400">
{i + 1}
</TableCell>
<TableCell
className="whitespace-nowrap px-2 py-1.5 text-xs font-medium text-slate-700"
dir={isAr ? "rtl" : "ltr"}
>
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
</TableCell>
<TableCell className="whitespace-nowrap px-2 py-1.5 text-xs text-slate-600">
{t("protocol.slots.durationValue", {
count: s.durationMinutes,
})}
</TableCell>
<TableCell className="px-2 py-1.5 text-[11px] text-slate-500">
{fmtSlotDays(s.daysOfWeek, t, isAr)}
</TableCell>
<TableCell className="px-2 py-1.5">
<span
className={cn(
"inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-[11px]",
s.isActive
? "bg-emerald-100 text-emerald-700"
: "bg-slate-200 text-slate-500",
)}
>
{s.isActive
? t("protocol.rooms.active")
: t("protocol.rooms.inactive")}
</span>
</TableCell>
<TableCell className="px-2 py-1">
<div className="flex items-center justify-center gap-0.5">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={t("protocol.slots.edit")}
onClick={() => openEdit(s)}
>
<Pencil size={13} className="text-slate-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={
s.isActive
? t("protocol.slots.deactivate")
: t("protocol.slots.activate")
}
onClick={() => toggleMut.mutate(s)}
>
{s.isActive ? <Ban size={13} /> : <Check size={13} />}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={t("common.delete")}
onClick={() => deleteMut.mutate(s.id)}
>
<Trash2 size={13} className="text-rose-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("protocol.slots.editTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-2">
<div>
<Label className="text-xs">
{t("protocol.slots.startTime")}
</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={editTime}
onChange={(e) => setEditTime(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">{t("protocol.slots.duration")}</Label>
<Input
type="number"
inputMode="numeric"
min={5}
max={480}
step={5}
className="w-24"
value={editDuration}
onChange={(e) => setEditDuration(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("protocol.slots.days")}</Label>
<div className="flex flex-wrap gap-1.5">
{ALL_DAYS.map((d) => (
<button
key={d}
type="button"
onClick={() => toggleEditDay(d)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
editDays.includes(d)
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300",
)}
>
{t(`protocol.slots.dayNames.${d}`)}
</button>
))}
</div>
{editDays.length === 0 && (
<p className="text-xs text-amber-600">
{t("protocol.slots.pickDay")}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
{t("common.cancel")}
</Button>
<Button
disabled={!canSaveEdit || editMut.isPending}
onClick={() => editing && editMut.mutate(editing)}
>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-2">
<h3 className="font-semibold text-slate-700">
{t("protocol.slots.leadTitle")}
</h3>
<p className="text-xs text-slate-500">{t("protocol.slots.leadHint")}</p>
<div className="flex items-end gap-2">
<div>
<Label className="text-xs">{t("protocol.slots.leadLabel")}</Label>
<Input
type="number"
inputMode="numeric"
min={0}
max={10080}
className="w-28"
value={leadValue}
onChange={(e) => setLeadInput(e.target.value)}
disabled={settings.isLoading}
/>
</div>
<Button
size="sm"
disabled={!leadDirty || leadMut.isPending}
onClick={() => leadMut.mutate()}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>
);
}
function DashboardView({
data,
t,
goTab,
}: {
data: Dashboard | undefined;
t: (k: string) => string;
goTab: (k: TabKey) => void;
}) {
const cards: Array<{ label: string; value?: number; tab: TabKey }> = [
{
label: t("protocol.dashboard.roomsAvailableNow"),
value: data?.roomsAvailableNow,
tab: "rooms",
},
{
label: t("protocol.dashboard.activeRooms"),
value: data?.activeRooms,
tab: "rooms",
},
{
label: t("protocol.dashboard.todayBookings"),
value: data?.todayBookings,
tab: "bookings",
},
{
label: t("protocol.dashboard.pendingBookings"),
value: data?.pendingBookings,
tab: "bookings",
},
{
label: t("protocol.dashboard.upcomingExternal"),
value: data?.upcomingExternalMeetings,
tab: "external",
},
{
label: t("protocol.dashboard.giftStock"),
value: data?.totalGiftStock,
tab: "gifts",
},
{
label: t("protocol.dashboard.pendingIssues"),
value: data?.pendingGiftIssues,
tab: "issues",
},
{
label: t("protocol.dashboard.giftsIssuedThisMonth"),
value: data?.giftsIssuedThisMonth,
tab: "issues",
},
];
return (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
{cards.map((c) => (
<button
key={c.label}
type="button"
onClick={() => goTab(c.tab)}
className="bg-white rounded-2xl border border-slate-200 p-4 text-start hover:border-sky-300 hover:shadow-sm transition"
>
<div className="text-3xl font-bold text-[#0f1d3d]">{c.value ?? "—"}</div>
<div className="text-sm text-slate-500 mt-1">{c.label}</div>
</button>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Rooms dashboard (لوحة بيانات القاعات) — recharts KPI + charts view.
// ---------------------------------------------------------------------------
type RoomsStats = {
rooms: { active: number; inactive: number };
todayBookings: number;
weekBookings: number;
pendingBookings: number;
nextBooking: {
id: number;
title: string;
roomNameAr: string;
roomNameEn: string;
startsAt: string;
endsAt: string;
status: BookingStatus;
} | null;
daily: Array<{ date: string; count: number }>;
byRoom: Array<{
roomId: number;
nameAr: string;
nameEn: string;
count: number;
}>;
byStatus: Array<{ status: BookingStatus; count: number }>;
};
const STATUS_CHART_COLORS: Record<string, string> = {
pending: "#f59e0b",
approved: "#10b981",
rejected: "#f43f5e",
cancelled: "#94a3b8",
};
function RoomsDashboardView({
isAr,
now,
goTab,
}: {
isAr: boolean;
now: Date;
goTab: (k: TabKey) => void;
}) {
const { t } = useTranslation();
const stats = useQuery<RoomsStats>({
queryKey: ["protocol", "rooms-stats"],
queryFn: () => apiJson<RoomsStats>(`${API}/protocol/rooms-stats`),
});
const d = stats.data;
const [range, setRange] = useState<14 | 30>(14);
const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar);
const locale = isAr ? "ar-u-nu-latn" : "en";
// Countdown to the next upcoming booking: "بعد X يوم" or "بعد X ساعة ودقيقة".
const countdown = useMemo(() => {
if (!d?.nextBooking) return null;
const ms = new Date(d.nextBooking.startsAt).getTime() - now.getTime();
if (ms <= 0) return t("protocol.roomsDash.startingNow");
const totalMinutes = Math.floor(ms / 60_000);
const days = Math.floor(totalMinutes / (24 * 60));
if (days >= 1) return t("protocol.roomsDash.inDays", { count: days });
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours >= 1) {
return t("protocol.roomsDash.inHoursMinutes", { hours, minutes });
}
return t("protocol.roomsDash.inMinutes", { count: minutes });
}, [d?.nextBooking, now, t]);
const dailyData = useMemo(() => {
const rows = d?.daily ?? [];
const window = rows.slice(-range);
return window.map((r) => ({
...r,
label: new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
}).format(new Date(`${r.date}T00:00:00+03:00`)),
}));
}, [d?.daily, range, locale]);
const roomData = useMemo(
() =>
(d?.byRoom ?? []).map((r) => ({
name: nameOf(r.nameAr, r.nameEn),
count: r.count,
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[d?.byRoom, isAr],
);
const statusData = useMemo(
() =>
(d?.byStatus ?? [])
.filter((s) => s.count > 0)
.map((s) => ({
name: t(`protocol.status.${s.status}`),
status: s.status,
value: s.count,
})),
[d?.byStatus, t],
);
const fmtNextTime = (iso: string) =>
new Date(iso).toLocaleString(isAr ? "ar" : "en", {
dateStyle: "medium",
timeStyle: "short",
});
const kpis: Array<{ label: string; value?: number; sub?: string; tab: TabKey }> = [
{
label: t("protocol.roomsDash.roomsCount"),
value: d ? d.rooms.active + d.rooms.inactive : undefined,
sub: d
? t("protocol.roomsDash.roomsBreakdown", {
active: d.rooms.active,
inactive: d.rooms.inactive,
})
: undefined,
tab: "rooms",
},
{
label: t("protocol.roomsDash.todayBookings"),
value: d?.todayBookings,
tab: "bookings",
},
{
label: t("protocol.roomsDash.weekBookings"),
value: d?.weekBookings,
tab: "bookings",
},
{
label: t("protocol.roomsDash.pendingBookings"),
value: d?.pendingBookings,
tab: "bookings",
},
];
return (
<section className="space-y-4">
<h2 className="font-semibold text-slate-700">
{t("protocol.roomsDash.title")}
</h2>
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
{kpis.map((k) => (
<button
key={k.label}
type="button"
onClick={() => goTab(k.tab)}
className="bg-white rounded-2xl border border-slate-200 p-4 text-start hover:border-sky-300 hover:shadow-sm transition"
>
<div className="text-3xl font-bold text-[#0f1d3d]">
{k.value ?? "—"}
</div>
<div className="text-sm text-slate-500 mt-1">{k.label}</div>
{k.sub && (
<div className="text-xs text-slate-400 mt-0.5">{k.sub}</div>
)}
</button>
))}
</div>
<div className="bg-gradient-to-l from-sky-500 to-sky-600 rounded-2xl px-3 py-2 text-white shadow-sm">
{d?.nextBooking ? (
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="flex items-center gap-2 min-w-0">
<Clock3 size={14} className="shrink-0 text-sky-100" />
<span className="text-xs text-sky-100 whitespace-nowrap">
{t("protocol.roomsDash.nextBooking")}
</span>
<span className="font-bold text-sm truncate">
{d.nextBooking.title}
</span>
<span className="text-xs text-sky-100 truncate">
{nameOf(d.nextBooking.roomNameAr, d.nextBooking.roomNameEn)}
{" · "}
{fmtNextTime(d.nextBooking.startsAt)}
</span>
</div>
<div className="rounded-lg bg-white/15 px-2.5 py-0.5 text-sm font-bold whitespace-nowrap">
{countdown}
</div>
</div>
) : (
<div className="flex items-center gap-2 text-sky-100 text-xs">
<Clock3 size={14} className="shrink-0" />
{t("protocol.roomsDash.nextBooking")}
<span className="text-white">
{stats.isLoading
? t("common.loading")
: t("protocol.roomsDash.noUpcoming")}
</span>
</div>
)}
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-slate-700 text-sm">
{t("protocol.roomsDash.bookingsOverTime")}
</h3>
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
{([14, 30] as const).map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
className={cn(
"px-2.5 py-1 text-xs",
range === r
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-100",
)}
>
{t("protocol.roomsDash.lastNDays", { count: r })}
</button>
))}
</div>
</div>
<div className="h-56" dir="ltr">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={dailyData} margin={{ top: 4, left: -20, right: 4 }}>
<defs>
<linearGradient id="roomsDashArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0ea5e9" stopOpacity={0.35} />
<stop offset="100%" stopColor="#0ea5e9" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "#64748b" }}
tickLine={false}
axisLine={{ stroke: "#e2e8f0" }}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "#64748b" }}
tickLine={false}
axisLine={false}
/>
<RechartsTooltip
formatter={(v: number) => [
v,
t("protocol.roomsDash.bookings"),
]}
labelStyle={{ color: "#334155" }}
/>
<Area
type="monotone"
dataKey="count"
stroke="#0ea5e9"
strokeWidth={2}
fill="url(#roomsDashArea)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<h3 className="font-semibold text-slate-700 text-sm mb-3">
{t("protocol.roomsDash.byRoom")}
</h3>
{roomData.length === 0 ? (
<EmptyState text={t("protocol.roomsDash.noData")} />
) : (
<div className="h-56" dir="ltr">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={roomData}
layout="vertical"
margin={{ top: 4, left: 8, right: 16 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e2e8f0"
horizontal={false}
/>
<XAxis
type="number"
allowDecimals={false}
tick={{ fontSize: 11, fill: "#64748b" }}
tickLine={false}
axisLine={{ stroke: "#e2e8f0" }}
/>
<YAxis
type="category"
dataKey="name"
width={110}
tick={{ fontSize: 11, fill: "#334155" }}
tickLine={false}
axisLine={false}
/>
<RechartsTooltip
formatter={(v: number) => [
v,
t("protocol.roomsDash.bookings"),
]}
/>
<Bar
dataKey="count"
fill="#0ea5e9"
radius={[0, 6, 6, 0]}
barSize={18}
/>
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<h3 className="font-semibold text-slate-700 text-sm mb-3">
{t("protocol.roomsDash.byStatus")}
</h3>
{statusData.length === 0 ? (
<EmptyState text={t("protocol.roomsDash.noData")} />
) : (
<div className="h-56" dir="ltr">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={statusData}
dataKey="value"
nameKey="name"
innerRadius={55}
outerRadius={80}
paddingAngle={2}
strokeWidth={0}
>
{statusData.map((s) => (
<Cell
key={s.status}
fill={STATUS_CHART_COLORS[s.status] ?? "#0ea5e9"}
/>
))}
</Pie>
<Legend
formatter={(value: string) => (
<span style={{ color: "#334155", fontSize: 12 }}>
{value}
</span>
)}
/>
<RechartsTooltip
formatter={(v: number) => [
v,
t("protocol.roomsDash.bookings"),
]}
/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
<p className="text-xs text-slate-400">
{t("protocol.roomsDash.windowHint")}
</p>
</section>
);
}
function ReportsView({
data,
nameOf,
t,
}: {
data: ReportRow | undefined;
nameOf: (ar: string, en: string) => string;
t: (k: string) => string;
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-semibold text-slate-700 mb-2">
{t("protocol.reports.byRoom")}
</h3>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="text-start p-2">{t("protocol.reports.room")}</th>
<th className="p-2">{t("protocol.reports.total")}</th>
<th className="p-2">{t("protocol.status.approved")}</th>
<th className="p-2">{t("protocol.status.pending")}</th>
</tr>
</thead>
<tbody>
{(data?.bookingsByRoom ?? []).map((r) => (
<tr key={r.roomId} className="border-t border-slate-100">
<td className="p-2 text-slate-700">
{nameOf(r.roomNameAr, r.roomNameEn)}
</td>
<td className="p-2 text-center">{r.total}</td>
<td className="p-2 text-center">{r.approved}</td>
<td className="p-2 text-center">{r.pending}</td>
</tr>
))}
{data?.bookingsByRoom.length === 0 && (
<tr>
<td colSpan={4} className="p-4 text-center text-slate-400">
{t("protocol.reports.empty")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<h3 className="font-semibold text-slate-700 mb-2">
{t("protocol.reports.byGift")}
</h3>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="text-start p-2">{t("protocol.reports.gift")}</th>
<th className="p-2">{t("protocol.reports.requests")}</th>
<th className="p-2">{t("protocol.reports.issued")}</th>
<th className="p-2">{t("protocol.reports.issuedQty")}</th>
</tr>
</thead>
<tbody>
{(data?.issuesByGift ?? []).map((r) => (
<tr key={r.giftId} className="border-t border-slate-100">
<td className="p-2 text-slate-700">
{nameOf(r.giftNameAr, r.giftNameEn)}
</td>
<td className="p-2 text-center">{r.totalRequests}</td>
<td className="p-2 text-center">{r.issued}</td>
<td className="p-2 text-center">{r.issuedQty}</td>
</tr>
))}
{data?.issuesByGift.length === 0 && (
<tr>
<td colSpan={4} className="p-4 text-center text-slate-400">
{t("protocol.reports.empty")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<h3 className="font-semibold text-slate-700 mb-2">
{t("protocol.reports.byExternal")}
</h3>
<div className="grid gap-2 grid-cols-3 sm:grid-cols-6">
{(
[
["total", "protocol.reports.total"],
["pending", "protocol.status.pending"],
["approved", "protocol.status.approved"],
["rejected", "protocol.status.rejected"],
["completed", "protocol.status.completed"],
["cancelled", "protocol.status.cancelled"],
] as const
).map(([key, label]) => (
<div
key={key}
className="bg-white rounded-xl border border-slate-200 p-3 text-center"
>
<div className="text-2xl font-bold text-sky-600">
{data?.externalMeetings?.[key] ?? "—"}
</div>
<div className="text-xs text-slate-500 mt-1">{t(label)}</div>
</div>
))}
</div>
</div>
</div>
);
}
function DialogShell({
title,
onClose,
children,
onSubmit,
submitLabel,
saving,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
onSubmit: () => void;
submitLabel: string;
saving: boolean;
}) {
const { i18n, t } = useTranslation();
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent
dir={i18n.language === "ar" ? "rtl" : "ltr"}
className="flex max-h-[85vh] flex-col overflow-hidden"
>
<DialogHeader className="shrink-0">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
{/* Long forms (e.g. the booking form) scroll here while the
title and the save/cancel buttons stay visible. */}
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-1 py-1 -mx-1">
{children}
</div>
<DialogFooter className="shrink-0 pt-3">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={saving}>
{submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function BookingDialog({
rooms,
slots,
edit,
initialDay,
initialStart,
nameOf,
onClose,
onSaved,
onError,
}: {
rooms: Room[];
// Active booking slots. When non-empty the dialog restricts times to the
// slot grid (same rule the server enforces); when empty it keeps the
// legacy free start/end inputs.
slots: ActiveSlotInfo[];
edit?: Booking;
initialDay?: Date;
initialStart?: Date;
nameOf: (ar: string, en: string) => string;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t, i18n } = useTranslation();
const isAr = i18n.language === "ar";
const dayAt = (hour: number) => {
if (!initialDay) return "";
const d = new Date(initialDay);
d.setHours(hour, 0, 0, 0);
return toLocalInput(d.toISOString());
};
const [title, setTitle] = useState(edit?.title ?? "");
const [roomId, setRoomId] = useState<string>(
edit ? String(edit.roomId) : rooms[0] ? String(rooms[0].id) : "",
);
const [requesterName, setRequesterName] = useState(edit?.requesterName ?? "");
const [startsAt, setStartsAt] = useState(() => {
if (edit) return toLocalInput(edit.startsAt);
if (initialStart) return toLocalInput(initialStart.toISOString());
return dayAt(9);
});
const [endsAt, setEndsAt] = useState(() => {
if (edit) return toLocalInput(edit.endsAt);
if (initialStart)
return toLocalInput(
new Date(initialStart.getTime() + 60 * 60 * 1000).toISOString(),
);
return dayAt(10);
});
const slotMode = slots.length > 0;
// Slot-mode state: a Riyadh calendar day + one of the active slots.
const [slotDate, setSlotDate] = useState(() => {
if (edit) return riyadhYmd(edit.startsAt);
if (initialStart) return riyadhYmd(initialStart.toISOString());
if (initialDay) return riyadhYmd(initialDay.toISOString());
return "";
});
const [slotId, setSlotId] = useState<string>(() => {
if (!slotMode) return "";
const wanted = edit
? riyadhHHmm(edit.startsAt)
: initialStart
? riyadhHHmm(initialStart.toISOString())
: "";
const match = slots.find((s) => s.startTime === wanted);
return match ? String(match.id) : "";
});
const selectedSlot = slots.find((s) => String(s.id) === slotId);
// Only slots offered on the chosen date's weekday (Riyadh calendar) can
// be picked — same rule the server enforces.
const daySlots = slotDate
? slots.filter((s) => s.daysOfWeek.includes(ymdWeekday(slotDate)))
: slots;
useEffect(() => {
if (slotId && !daySlots.some((s) => String(s.id) === slotId)) {
setSlotId("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotDate]);
const [notes, setNotes] = useState(edit?.notes ?? "");
const [requesterPhone, setRequesterPhone] = useState(
edit?.requesterPhone ?? "",
);
const [department, setDepartment] = useState(edit?.department ?? "");
const [meetingType, setMeetingType] = useState<MeetingType>(
edit?.meetingType ?? "internal",
);
const [requesterOrg, setRequesterOrg] = useState(edit?.requesterOrg ?? "");
const [attendeeCount, setAttendeeCount] = useState(
edit?.attendeeCount != null ? String(edit.attendeeCount) : "",
);
const [purpose, setPurpose] = useState(edit?.purpose ?? "");
// One numbered table of key attendees; each row carries its own side
// (من الهيئة / خارج الهيئة). Existing rows first, then padded with empty
// rows up to the expected attendee count.
const [attendees, setAttendees] = useState<EditableAttendeeRow[]>(() => {
const existing = (edit?.keyAttendees ?? []).map((a) => ({
name: a.name,
position: a.position,
side: a.side,
}));
const base = existing.length > 0 ? existing : [emptyAttendeeRow()];
return syncAttendeeRows(
base,
edit?.attendeeCount != null ? edit.attendeeCount : null,
);
});
useEffect(() => {
const n = attendeeCount.trim() === "" ? null : Number(attendeeCount);
setAttendees((rs) => syncAttendeeRows(rs, n));
}, [attendeeCount]);
const isExternal = meetingType === "external";
const mut = useMutation({
mutationFn: () => {
const keyAttendees = attendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: r.side,
}));
const body = {
roomId: Number(roomId),
title,
requesterName: requesterName || null,
requesterPhone: requesterPhone.trim() || null,
department: department.trim() || null,
meetingType,
requesterOrg: isExternal ? requesterOrg.trim() || null : null,
attendeeCount:
attendeeCount.trim() === "" ? null : Number(attendeeCount),
purpose: purpose.trim() || null,
keyAttendees,
startsAt:
slotMode && selectedSlot
? slotToIso(slotDate, selectedSlot.startTime)
: fromLocalInput(startsAt),
endsAt:
slotMode && selectedSlot
? slotToIso(
slotDate,
selectedSlot.startTime,
selectedSlot.durationMinutes,
)
: fromLocalInput(endsAt),
notes: notes || null,
};
return edit
? apiJson(`${API}/protocol/bookings/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/bookings`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.bookings.edit") : t("protocol.bookings.new")}
onClose={onClose}
onSubmit={() => {
// Slot mode: both a date and a slot are required; block the submit
// client-side instead of round-tripping to a server invalid_slot.
if (slotMode && (!slotDate || !selectedSlot)) return;
mut.mutate();
}}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.bookings.requester")}</Label>
<Input
value={requesterName}
onChange={(e) => setRequesterName(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.bookings.phone")}</Label>
<Input
type="tel"
inputMode="tel"
dir="ltr"
value={requesterPhone}
onChange={(e) => setRequesterPhone(e.target.value)}
placeholder="05xxxxxxxx"
/>
</div>
<div>
<Label>{t("protocol.bookings.roomLabel")}</Label>
<Select value={roomId} onValueChange={setRoomId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{rooms.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{nameOf(r.nameAr, r.nameEn)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t("protocol.bookings.department")}</Label>
<Input
value={department}
onChange={(e) => setDepartment(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.bookings.titleLabel")}</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.bookings.meetingType")}</Label>
<div className="mt-1 grid grid-cols-2 gap-2">
{(["internal", "external"] as const).map((opt) => (
<button
key={opt}
type="button"
onClick={() => setMeetingType(opt)}
className={cn(
"rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
meetingType === opt
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300",
)}
>
{t(`protocol.bookings.meetingTypes.${opt}`)}
</button>
))}
</div>
</div>
{isExternal && (
<div>
<Label>{t("protocol.bookings.entity")}</Label>
<Input
value={requesterOrg}
onChange={(e) => setRequesterOrg(e.target.value)}
/>
</div>
)}
<div>
<Label>{t("protocol.bookings.attendeeCount")}</Label>
<Input
type="number"
inputMode="numeric"
min={1}
value={attendeeCount}
onChange={(e) => setAttendeeCount(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.bookings.purpose")}</Label>
<Textarea
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={2}
/>
</div>
{slotMode ? (
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.slots.dateLabel")}</Label>
<Input
type="date"
value={slotDate}
onChange={(e) => setSlotDate(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.slots.slotLabel")}</Label>
<Select value={slotId} onValueChange={setSlotId}>
<SelectTrigger>
<SelectValue
placeholder={t("protocol.slots.pickSlot")}
/>
</SelectTrigger>
<SelectContent>
{daySlots.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
<span dir={isAr ? "rtl" : "ltr"}>
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{slotDate && daySlots.length === 0 && (
<p className="text-xs text-amber-600 mt-1">
{t("protocol.slots.noSlotsForDay")}
</p>
)}
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.bookings.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.bookings.endsAt")}</Label>
<Input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
required
/>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t("protocol.bookings.keyAttendees")}</Label>
<AttendeeTableEditor
rows={attendees}
setRows={setAttendees}
labels={{
name: t("protocol.bookings.form.name"),
position: t("protocol.bookings.form.position"),
side: t("protocol.bookings.form.side"),
sideInternal: t("protocol.bookings.attendeeSides.internal"),
sideExternal: t("protocol.bookings.attendeeSides.external"),
addRow: t("protocol.bookings.form.addRow"),
removeRow: t("protocol.bookings.form.removeRow"),
}}
/>
</div>
<div>
<Label>{t("protocol.notes")}</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</DialogShell>
);
}
function RoomDialog({
edit,
onClose,
onSaved,
onError,
}: {
edit?: Room;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [nameAr, setNameAr] = useState(edit?.nameAr ?? "");
const [nameEn, setNameEn] = useState(edit?.nameEn ?? "");
const [capacity, setCapacity] = useState(
edit?.capacity != null ? String(edit.capacity) : "",
);
const [location, setLocation] = useState(edit?.location ?? "");
const [isActive, setIsActive] = useState(edit?.isActive ?? true);
const mut = useMutation({
mutationFn: () => {
const body = {
nameAr,
nameEn,
capacity: capacity ? Number(capacity) : null,
location: location || null,
isActive,
};
return edit
? apiJson(`${API}/protocol/rooms/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/rooms`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.rooms.edit") : t("protocol.rooms.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.rooms.nameAr")}</Label>
<Input value={nameAr} onChange={(e) => setNameAr(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.rooms.nameEn")}</Label>
<Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.rooms.capacity")}</Label>
<Input
type="number"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.rooms.location")}</Label>
<Input value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
{t("protocol.rooms.isActive")}
</label>
</DialogShell>
);
}
function ExternalDialog({
edit,
onClose,
onSaved,
onError,
}: {
edit?: ExternalMeeting;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(edit?.title ?? "");
const [partyName, setPartyName] = useState(edit?.partyName ?? "");
const [location, setLocation] = useState(edit?.location ?? "");
const [startsAt, setStartsAt] = useState(
edit ? toLocalInput(edit.startsAt) : "",
);
const [status, setStatus] = useState<"completed" | "cancelled" | "">(
edit?.status === "completed" || edit?.status === "cancelled"
? edit.status
: "",
);
const [notes, setNotes] = useState(edit?.notes ?? "");
const mut = useMutation({
mutationFn: () => {
const body: Record<string, unknown> = {
title,
partyName: partyName || null,
location: location || null,
startsAt: fromLocalInput(startsAt),
notes: notes || null,
};
if (edit && status) body.status = status;
return edit
? apiJson(`${API}/protocol/external-meetings/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/external-meetings`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.external.edit") : t("protocol.external.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.external.titleLabel")}</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.external.party")}</Label>
<Input value={partyName} onChange={(e) => setPartyName(e.target.value)} />
</div>
<div>
<Label>{t("protocol.external.location")}</Label>
<Input value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.external.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
</div>
{edit && (
<div>
<Label>{t("protocol.statusLabel")}</Label>
<Select
value={status}
onValueChange={(v) =>
setStatus(v as "completed" | "cancelled")
}
>
<SelectTrigger>
<SelectValue placeholder={t("protocol.status.pending")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="completed">
{t("protocol.status.completed")}
</SelectItem>
<SelectItem value="cancelled">
{t("protocol.status.cancelled")}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div>
<Label>{t("protocol.notes")}</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</DialogShell>
);
}
function GiftDialog({
edit,
onClose,
onSaved,
onError,
}: {
edit?: Gift;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [nameAr, setNameAr] = useState(edit?.nameAr ?? "");
const [nameEn, setNameEn] = useState(edit?.nameEn ?? "");
const [kind, setKind] = useState<GiftKind>(edit?.kind ?? "gift");
const [quantityInStock, setQuantityInStock] = useState(
edit ? String(edit.quantityInStock) : "0",
);
const mut = useMutation({
mutationFn: () => {
const body = {
nameAr,
nameEn,
kind,
quantityInStock: Number(quantityInStock) || 0,
};
return edit
? apiJson(`${API}/protocol/gifts/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/gifts`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.gifts.edit") : t("protocol.gifts.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.gifts.nameAr")}</Label>
<Input value={nameAr} onChange={(e) => setNameAr(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.gifts.nameEn")}</Label>
<Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.gifts.kind")}</Label>
<Select value={kind} onValueChange={(v) => setKind(v as GiftKind)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gift">{t("protocol.giftKind.gift")}</SelectItem>
<SelectItem value="shield">
{t("protocol.giftKind.shield")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t("protocol.gifts.stock")}</Label>
<Input
type="number"
value={quantityInStock}
onChange={(e) => setQuantityInStock(e.target.value)}
/>
</div>
</div>
</DialogShell>
);
}
function IssueDialog({
gifts,
nameOf,
onClose,
onSaved,
onError,
}: {
gifts: Gift[];
nameOf: (ar: string, en: string) => string;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [giftId, setGiftId] = useState<string>(
gifts[0] ? String(gifts[0].id) : "",
);
const [recipientName, setRecipientName] = useState("");
const [occasion, setOccasion] = useState("");
const [quantity, setQuantity] = useState("1");
const mut = useMutation({
mutationFn: () =>
apiJson(`${API}/protocol/gift-issues`, {
method: "POST",
body: JSON.stringify({
giftId: Number(giftId),
recipientName,
occasion: occasion || null,
quantity: Number(quantity) || 1,
}),
}),
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={t("protocol.issues.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.issues.gift")}</Label>
<Select value={giftId} onValueChange={setGiftId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{gifts.map((g) => (
<SelectItem key={g.id} value={String(g.id)}>
{nameOf(g.nameAr, g.nameEn)} ({g.quantityInStock})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t("protocol.issues.recipient")}</Label>
<Input
value={recipientName}
onChange={(e) => setRecipientName(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.issues.occasion")}</Label>
<Input value={occasion} onChange={(e) => setOccasion(e.target.value)} />
</div>
<div>
<Label>{t("protocol.issues.quantity")}</Label>
<Input
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</div>
</div>
</DialogShell>
);
}
// ---------------------------------------------------------------------------
// Visit photos (تصوير) — album grid + album detail with upload gallery.
// ---------------------------------------------------------------------------
type Album = {
id: number;
name: string;
externalMeetingId: number | null;
meetingTitle: string | null;
createdBy: number | null;
createdAt: string;
updatedAt: string;
photoCount: number;
coverPath: string | null;
};
type Photo = {
id: number;
albumId: number;
objectPath: string;
sortOrder: number;
uploadedBy: number | null;
createdAt: string;
};
// Build the authorized image URL from a stored `/objects/<id>` path.
const photoSrc = (objectPath: string) => `${API}/storage${objectPath}`;
// Force-download URL for a single photo (attachment via ?download=1). The
// optional name base gets an extension appended server-side.
const photoDownloadSrc = (objectPath: string, name?: string) => {
const base = `${API}/storage${objectPath}?download=1`;
return name ? `${base}&name=${encodeURIComponent(name)}` : base;
};
// ZIP download URL for a whole album.
const albumDownloadSrc = (albumId: number) =>
`${API}/protocol/photo-albums/${albumId}/download`;
function PhotosView({
canMutate,
isAr,
externalMeetings,
onApiError,
}: {
canMutate: boolean;
isAr: boolean;
externalMeetings: ExternalMeeting[];
onApiError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [openAlbumId, setOpenAlbumId] = useState<number | null>(null);
const [albumDialog, setAlbumDialog] = useState<{
open: boolean;
edit?: Album;
}>({ open: false });
const [deleteAlbum, setDeleteAlbum] = useState<Album | null>(null);
const [deletePhoto, setDeletePhoto] = useState<Photo | null>(null);
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const albums = useQuery({
queryKey: ["protocol", "photo-albums"],
queryFn: () => apiJson<Album[]>(`${API}/protocol/photo-albums`),
});
const photos = useQuery({
queryKey: ["protocol", "photos", openAlbumId],
queryFn: () =>
apiJson<Photo[]>(`${API}/protocol/photo-albums/${openAlbumId}/photos`),
enabled: openAlbumId !== null,
});
const openAlbum =
openAlbumId !== null
? (albums.data ?? []).find((a) => a.id === openAlbumId)
: undefined;
const { uploadFile, isUploading, progress } = useUpload({
basePath: `${API}/storage`,
onError: onApiError,
});
const finalize = useMutation({
mutationFn: (objectPath: string) =>
apiJson(`${API}/protocol/photo-albums/${openAlbumId}/photos`, {
method: "POST",
body: JSON.stringify({ objectPath }),
}),
onError: onApiError,
});
const handleFiles = async (files: FileList | null) => {
if (!files || openAlbumId === null) return;
for (const file of Array.from(files)) {
const resp: UploadResponse | null = await uploadFile(file);
if (resp) await finalize.mutateAsync(resp.objectPath);
}
await qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
await qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
};
const delAlbumMut = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/photo-albums/${id}`, { method: "DELETE" }),
onSuccess: () => {
setDeleteAlbum(null);
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
},
onError: onApiError,
});
const delPhotoMut = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/photos/${id}`, { method: "DELETE" }),
onSuccess: () => {
setDeletePhoto(null);
qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
},
onError: onApiError,
});
// --- Album detail view -----------------------------------------------------
if (openAlbum) {
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Button
size="sm"
variant="ghost"
onClick={() => setOpenAlbumId(null)}
>
<BackIcon size={16} />
</Button>
<div className="min-w-0">
<h2 className="font-semibold text-slate-700 truncate">
{openAlbum.name}
</h2>
{openAlbum.meetingTitle && (
<div className="text-xs text-slate-400 truncate">
<Link2 size={11} className="inline me-1" />
{openAlbum.meetingTitle}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{(photos.data?.length ?? 0) > 0 ? (
<a
href={albumDownloadSrc(openAlbum.id)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm hover:bg-slate-50 transition"
>
<Download size={16} />
{t("protocol.photos.downloadAll")}
</a>
) : (
<span
aria-disabled="true"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-400 text-sm opacity-60 cursor-not-allowed pointer-events-none"
>
<Download size={16} />
{t("protocol.photos.downloadAll")}
</span>
)}
{canMutate && (
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
multiple
disabled={isUploading}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:opacity-90 transition">
{isUploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{isUploading
? `${t("protocol.photos.uploading")} ${progress}%`
: t("protocol.photos.addPhotos")}
</span>
</label>
)}
</div>
</div>
{photos.data && photos.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyPhotos")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{(photos.data ?? []).map((p) => (
<div
key={p.id}
className="group relative aspect-square rounded-xl overflow-hidden border border-slate-200 bg-slate-100"
>
<a href={photoSrc(p.objectPath)} target="_blank" rel="noreferrer">
<img
src={photoSrc(p.objectPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
</a>
<a
href={photoDownloadSrc(
p.objectPath,
`${openAlbum.name}-${p.sortOrder + 1}`,
)}
download
title={t("protocol.photos.download")}
aria-label={t("protocol.photos.download")}
className="absolute bottom-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Download size={14} />
</a>
{canMutate && (
<button
type="button"
onClick={() => setDeletePhoto(p)}
className="absolute top-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deletePhoto !== null}
onOpenChange={(o) => !o && setDeletePhoto(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeletePhoto")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deletePhoto && delPhotoMut.mutate(deletePhoto.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
// --- Album grid view -------------------------------------------------------
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.photos")}
</h2>
{canMutate && (
<Button size="sm" onClick={() => setAlbumDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.photos.newAlbum")}
</Button>
)}
</div>
{albums.data && albums.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyAlbums")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{(albums.data ?? []).map((a) => (
<div
key={a.id}
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
>
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full aspect-square bg-slate-100 relative"
>
{a.coverPath ? (
<img
src={photoSrc(a.coverPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-slate-300">
<ImageIcon size={32} />
</span>
)}
<span className="absolute bottom-1 end-1 text-[11px] px-1.5 py-0.5 rounded-full bg-black/55 text-white">
{a.photoCount}
</span>
</button>
<div className="p-2">
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full text-start font-medium text-sm text-slate-800 truncate"
>
{a.name}
</button>
{a.meetingTitle && (
<div className="text-[11px] text-slate-400 truncate">
<Link2 size={10} className="inline me-1" />
{a.meetingTitle}
</div>
)}
{canMutate && (
<div className="flex gap-1 mt-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setAlbumDialog({ open: true, edit: a })}
>
<Pencil size={13} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setDeleteAlbum(a)}
>
<Trash2 size={13} className="text-rose-500" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deleteAlbum !== null}
onOpenChange={(o) => !o && setDeleteAlbum(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeleteAlbum")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deleteAlbum && delAlbumMut.mutate(deleteAlbum.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
function AlbumDialog({
edit,
externalMeetings,
onClose,
onSaved,
onError,
}: {
edit?: Album;
externalMeetings: ExternalMeeting[];
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(edit?.name ?? "");
const [meetingId, setMeetingId] = useState<string>(
edit?.externalMeetingId != null ? String(edit.externalMeetingId) : "none",
);
const save = useMutation({
mutationFn: () => {
const payload = {
name: name.trim(),
externalMeetingId: meetingId === "none" ? null : Number(meetingId),
};
return edit
? apiJson(`${API}/protocol/photo-albums/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
: apiJson(`${API}/protocol/photo-albums`, {
method: "POST",
body: JSON.stringify(payload),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.photos.editAlbum") : t("protocol.photos.newAlbum")}
onClose={onClose}
onSubmit={() => save.mutate()}
submitLabel={t("common.save")}
saving={save.isPending}
>
<div>
<Label>{t("protocol.photos.albumName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.photos.linkMeeting")}</Label>
<Select value={meetingId} onValueChange={setMeetingId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("protocol.photos.noMeeting")}</SelectItem>
{externalMeetings.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogShell>
);
}