a1acbc8017
- 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.
4931 lines
168 KiB
TypeScript
4931 lines
168 KiB
TypeScript
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>
|
||
);
|
||
}
|