2026-07-08 11:12:41 +00:00
|
|
|
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
import { useTranslation } from "react-i18next";
|
2026-07-06 09:46:12 +00:00
|
|
|
|
import { useLocation, useRoute } from "wouter";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
|
ArrowRight,
|
|
|
|
|
|
LayoutDashboard,
|
|
|
|
|
|
DoorOpen,
|
|
|
|
|
|
CalendarClock,
|
|
|
|
|
|
Handshake,
|
|
|
|
|
|
Gift,
|
2026-07-07 11:58:20 +00:00
|
|
|
|
Warehouse,
|
|
|
|
|
|
HandHeart,
|
|
|
|
|
|
ChevronDown,
|
2026-07-06 09:25:40 +00:00
|
|
|
|
BarChart3,
|
|
|
|
|
|
ScrollText,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Pencil,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Check,
|
|
|
|
|
|
X,
|
2026-07-08 12:27:54 +00:00
|
|
|
|
Ban,
|
2026-07-06 09:46:12 +00:00
|
|
|
|
CalendarDays,
|
|
|
|
|
|
List as ListIcon,
|
2026-07-07 12:26:19 +00:00
|
|
|
|
Search as SearchIcon,
|
2026-07-06 12:20:17 +00:00
|
|
|
|
Link2,
|
|
|
|
|
|
Copy,
|
2026-07-07 09:14:07 +00:00
|
|
|
|
Upload,
|
|
|
|
|
|
Loader2,
|
2026-07-07 09:34:06 +00:00
|
|
|
|
Download,
|
2026-07-07 09:14:07 +00:00
|
|
|
|
Image as ImageIcon,
|
2026-07-06 09:25:40 +00:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
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";
|
2026-07-08 11:12:41 +00:00
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
} from "@/components/ui/table";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
import { AppDock } from "@/components/app-dock";
|
2026-07-07 10:27:15 +00:00
|
|
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
|
|
|
|
import { useNow } from "@/components/clock";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
|
|
import { apiJson, ApiError } from "@/lib/api-json";
|
2026-07-07 09:14:07 +00:00
|
|
|
|
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-08 14:43:44 +00:00
|
|
|
|
type Slot = {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
startTime: string; // "HH:mm" Riyadh wall clock
|
|
|
|
|
|
durationMinutes: number;
|
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
|
sortOrder: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-08 14:48:11 +00:00
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
type BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
|
2026-07-07 12:26:19 +00:00
|
|
|
|
type MeetingType = "internal" | "external";
|
|
|
|
|
|
type KeyAttendee = {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
position: string;
|
|
|
|
|
|
side: "internal" | "external";
|
|
|
|
|
|
};
|
2026-07-06 09:25:40 +00:00
|
|
|
|
type Booking = {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
roomId: number;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
requesterName: string | null;
|
2026-07-06 11:18:14 +00:00
|
|
|
|
requesterPhone: string | null;
|
|
|
|
|
|
requesterOrg: string | null;
|
2026-07-06 11:33:58 +00:00
|
|
|
|
attendeeCount: number | null;
|
2026-07-07 12:26:19 +00:00
|
|
|
|
department: string | null;
|
|
|
|
|
|
meetingType: MeetingType;
|
|
|
|
|
|
purpose: string | null;
|
|
|
|
|
|
keyAttendees: KeyAttendee[] | null;
|
|
|
|
|
|
bookingReference: string | null;
|
2026-07-06 11:18:14 +00:00
|
|
|
|
source: "internal" | "public";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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;
|
2026-07-06 09:46:12 +00:00
|
|
|
|
status: "pending" | "approved" | "rejected" | "completed" | "cancelled";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
notes: string | null;
|
2026-07-06 09:46:12 +00:00
|
|
|
|
rejectionReason: string | null;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-06 09:46:12 +00:00
|
|
|
|
roomsAvailableNow: number;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
pendingBookings: number;
|
|
|
|
|
|
todayBookings: number;
|
|
|
|
|
|
upcomingExternalMeetings: number;
|
|
|
|
|
|
giftCatalogCount: number;
|
|
|
|
|
|
totalGiftStock: number;
|
|
|
|
|
|
pendingGiftIssues: number;
|
2026-07-06 09:46:12 +00:00
|
|
|
|
giftsIssuedThisMonth: number;
|
|
|
|
|
|
giftsIssuedThisMonthQty: number;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}>;
|
2026-07-06 09:46:12 +00:00
|
|
|
|
externalMeetings: {
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
pending: number;
|
|
|
|
|
|
approved: number;
|
|
|
|
|
|
rejected: number;
|
|
|
|
|
|
completed: number;
|
|
|
|
|
|
cancelled: number;
|
|
|
|
|
|
};
|
2026-07-06 09:25:40 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type TabKey =
|
|
|
|
|
|
| "dashboard"
|
2026-07-06 09:46:12 +00:00
|
|
|
|
| "rooms"
|
2026-07-06 09:25:40 +00:00
|
|
|
|
| "bookings"
|
|
|
|
|
|
| "external"
|
|
|
|
|
|
| "gifts"
|
|
|
|
|
|
| "issues"
|
2026-07-07 09:14:07 +00:00
|
|
|
|
| "photos"
|
2026-07-06 09:25:40 +00:00
|
|
|
|
| "reports"
|
|
|
|
|
|
| "audit";
|
|
|
|
|
|
|
2026-07-06 09:46:12 +00:00
|
|
|
|
// URL slugs for each tab so pages are deep-linkable under /protocol/*.
|
|
|
|
|
|
const TAB_SLUGS: Record<TabKey, string> = {
|
|
|
|
|
|
dashboard: "dashboard",
|
|
|
|
|
|
rooms: "rooms",
|
|
|
|
|
|
bookings: "bookings",
|
|
|
|
|
|
external: "external-meetings",
|
|
|
|
|
|
gifts: "gifts",
|
|
|
|
|
|
issues: "gift-issues",
|
2026-07-07 09:14:07 +00:00
|
|
|
|
photos: "photos",
|
2026-07-06 09:46:12 +00:00
|
|
|
|
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>;
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-07 10:27:15 +00:00
|
|
|
|
// ---- 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",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-08 14:43:44 +00:00
|
|
|
|
// --- 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)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 10:27:15 +00:00
|
|
|
|
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,
|
2026-07-08 12:43:22 +00:00
|
|
|
|
scrollSignal = 0,
|
2026-07-08 12:50:33 +00:00
|
|
|
|
onSelect,
|
2026-07-08 13:27:35 +00:00
|
|
|
|
onSlotClick,
|
2026-07-07 10:27:15 +00:00
|
|
|
|
}: {
|
|
|
|
|
|
bookings: Booking[];
|
|
|
|
|
|
selectedDay: Date;
|
|
|
|
|
|
now: Date;
|
|
|
|
|
|
isAr: boolean;
|
|
|
|
|
|
roomName: (id: number) => string;
|
|
|
|
|
|
emptyText: string;
|
2026-07-08 12:43:22 +00:00
|
|
|
|
scrollSignal?: number;
|
2026-07-08 12:50:33 +00:00
|
|
|
|
onSelect?: (b: Booking) => void;
|
2026-07-08 13:27:35 +00:00
|
|
|
|
onSlotClick?: (start: Date) => void;
|
2026-07-07 10:27:15 +00:00
|
|
|
|
}) {
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
2026-07-08 12:43:22 +00:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-07-07 10:27:15 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{dayItems.length === 0 && (
|
|
|
|
|
|
<div className="mb-2 text-center text-xs text-slate-400">
|
|
|
|
|
|
{emptyText}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-08 12:43:22 +00:00
|
|
|
|
<div
|
|
|
|
|
|
ref={scrollRef}
|
|
|
|
|
|
className="max-h-[60vh] overflow-y-auto overscroll-contain"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex" style={{ height: gridHeight + 8 }}>
|
2026-07-07 10:27:15 +00:00
|
|
|
|
<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
|
2026-07-08 13:27:35 +00:00
|
|
|
|
className={cn(
|
|
|
|
|
|
"relative flex-1 border-s border-slate-200",
|
|
|
|
|
|
onSlotClick && "cursor-pointer",
|
|
|
|
|
|
)}
|
2026-07-07 10:27:15 +00:00
|
|
|
|
style={{ height: gridHeight }}
|
2026-07-08 13:27:35 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}}
|
2026-07-07 10:27:15 +00:00
|
|
|
|
>
|
|
|
|
|
|
{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 (
|
2026-07-08 12:50:33 +00:00
|
|
|
|
<button
|
2026-07-07 10:27:15 +00:00
|
|
|
|
key={ev.b.id}
|
2026-07-08 12:50:33 +00:00
|
|
|
|
type="button"
|
2026-07-08 13:27:35 +00:00
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onSelect?.(ev.b);
|
|
|
|
|
|
}}
|
2026-07-07 10:27:15 +00:00
|
|
|
|
className={cn(
|
2026-07-08 12:50:33 +00:00
|
|
|
|
"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",
|
2026-07-07 10:27:15 +00:00
|
|
|
|
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)}`}
|
2026-07-08 12:50:33 +00:00
|
|
|
|
aria-label={`${ev.b.title} — ${roomName(ev.b.roomId)} — ${timeLabel(ev.b.startsAt)} – ${timeLabel(ev.b.endsAt)}`}
|
|
|
|
|
|
data-testid={`calendar-booking-${ev.b.id}`}
|
2026-07-07 10:27:15 +00:00
|
|
|
|
>
|
|
|
|
|
|
<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" dir="ltr">
|
|
|
|
|
|
{timeLabel(ev.b.startsAt)} – {timeLabel(ev.b.endsAt)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-08 12:50:33 +00:00
|
|
|
|
</button>
|
2026-07-07 10:27:15 +00:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{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>
|
2026-07-08 12:43:22 +00:00
|
|
|
|
</div>
|
2026-07-07 10:27:15 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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();
|
2026-07-06 09:46:12 +00:00
|
|
|
|
const [matchTab, tabParams] = useRoute("/protocol/:tab");
|
|
|
|
|
|
const tab: TabKey =
|
|
|
|
|
|
(matchTab && SLUG_TABS[tabParams?.tab ?? ""]) || "dashboard";
|
|
|
|
|
|
const setTab = (k: TabKey) => setLocation(`/protocol/${TAB_SLUGS[k]}`);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
|
|
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`),
|
|
|
|
|
|
});
|
2026-07-08 14:48:11 +00:00
|
|
|
|
// 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[]>({
|
2026-07-08 14:43:44 +00:00
|
|
|
|
queryKey: ["protocol", "slots"],
|
2026-07-08 14:48:11 +00:00
|
|
|
|
queryFn: () =>
|
|
|
|
|
|
apiJson<ActiveSlotInfo[]>(`${API}/protocol/booking-slots/active`),
|
2026-07-08 14:43:44 +00:00
|
|
|
|
});
|
2026-07-07 12:34:52 +00:00
|
|
|
|
const [bookingSearch, setBookingSearch] = useState("");
|
|
|
|
|
|
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const id = setTimeout(
|
|
|
|
|
|
() => setDebouncedBookingSearch(bookingSearch.trim()),
|
|
|
|
|
|
300,
|
|
|
|
|
|
);
|
|
|
|
|
|
return () => clearTimeout(id);
|
|
|
|
|
|
}, [bookingSearch]);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
const bookings = useQuery<Booking[]>({
|
2026-07-07 12:34:52 +00:00
|
|
|
|
queryKey: ["protocol", "bookings", debouncedBookingSearch],
|
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
|
apiJson<Booking[]>(
|
|
|
|
|
|
`${API}/protocol/bookings${
|
|
|
|
|
|
debouncedBookingSearch
|
|
|
|
|
|
? `?q=${encodeURIComponent(debouncedBookingSearch)}`
|
|
|
|
|
|
: ""
|
|
|
|
|
|
}`,
|
|
|
|
|
|
),
|
2026-07-06 09:25:40 +00:00
|
|
|
|
});
|
|
|
|
|
|
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;
|
2026-07-07 10:34:16 +00:00
|
|
|
|
day?: Date;
|
2026-07-08 13:27:35 +00:00
|
|
|
|
start?: Date;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
}>({ open: false });
|
|
|
|
|
|
const [rejectTarget, setRejectTarget] = useState<{
|
2026-07-06 09:46:12 +00:00
|
|
|
|
kind: "booking" | "issue" | "external";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
id: number;
|
|
|
|
|
|
} | null>(null);
|
2026-07-06 09:46:12 +00:00
|
|
|
|
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
|
2026-07-07 14:08:51 +00:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-07-08 12:36:17 +00:00
|
|
|
|
// 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]);
|
2026-07-07 10:27:15 +00:00
|
|
|
|
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
|
2026-07-08 12:43:22 +00:00
|
|
|
|
// 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);
|
2026-07-08 12:50:33 +00:00
|
|
|
|
// Booking selected from the calendar day grid → read-only details dialog.
|
|
|
|
|
|
const [detailsBooking, setDetailsBooking] = useState<Booking | null>(null);
|
2026-07-07 10:36:01 +00:00
|
|
|
|
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
|
2026-07-07 10:27:15 +00:00
|
|
|
|
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]);
|
2026-07-06 12:20:17 +00:00
|
|
|
|
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",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-07-07 11:58:20 +00:00
|
|
|
|
const [giftsGroupOpen, setGiftsGroupOpen] = useState(false);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
|
|
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({
|
2026-07-06 09:46:12 +00:00
|
|
|
|
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({}),
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-07-06 09:25:40 +00:00
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
setRejectTarget(null);
|
2026-07-06 09:46:12 +00:00
|
|
|
|
invalidate("bookings", "issues", "external");
|
2026-07-06 09:25:40 +00:00
|
|
|
|
},
|
|
|
|
|
|
onError: onApiError,
|
|
|
|
|
|
});
|
2026-07-06 09:46:12 +00:00
|
|
|
|
const externalApprove = useMutation({
|
|
|
|
|
|
mutationFn: (id: number) =>
|
|
|
|
|
|
apiJson(`${API}/protocol/external-meetings/${id}/approve`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => invalidate("external"),
|
|
|
|
|
|
onError: onApiError,
|
|
|
|
|
|
});
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-08 12:36:17 +00:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
const back = () => setLocation("/");
|
|
|
|
|
|
|
|
|
|
|
|
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
|
|
|
|
|
|
{ key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard },
|
2026-07-06 09:46:12 +00:00
|
|
|
|
{ key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen },
|
|
|
|
|
|
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock },
|
2026-07-06 09:25:40 +00:00
|
|
|
|
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake },
|
2026-07-07 11:58:20 +00:00
|
|
|
|
{ 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 },
|
2026-07-06 09:25:40 +00:00
|
|
|
|
{ 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;
|
|
|
|
|
|
|
2026-07-08 11:12:41 +00:00
|
|
|
|
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",
|
|
|
|
|
|
});
|
|
|
|
|
|
const fmtTimeOnly = (iso: string) =>
|
|
|
|
|
|
new Date(iso).toLocaleTimeString(isAr ? "ar" : "en", {
|
|
|
|
|
|
timeStyle: "short",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-08 12:19:27 +00:00
|
|
|
|
const renderBookingRow = (b: Booking, index: number) => {
|
2026-07-07 14:08:51 +00:00
|
|
|
|
const expanded = expandedBookings.has(b.id);
|
|
|
|
|
|
const bookingIsToday = sameYmd(new Date(b.startsAt), now);
|
2026-07-08 12:36:17 +00:00
|
|
|
|
const detailColSpan = c?.canMutate ? 9 : 8;
|
2026-07-07 14:08:51 +00:00
|
|
|
|
return (
|
2026-07-08 11:12:41 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
key={b.id}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
onClick={() => toggleBookingExpanded(b.id)}
|
|
|
|
|
|
aria-expanded={expanded}
|
2026-07-08 11:12:41 +00:00
|
|
|
|
className={cn(
|
|
|
|
|
|
"cursor-pointer",
|
|
|
|
|
|
bookingIsToday
|
|
|
|
|
|
? "bg-emerald-50/60 hover:bg-emerald-50"
|
|
|
|
|
|
: "odd:bg-white even:bg-slate-50/60",
|
|
|
|
|
|
)}
|
|
|
|
|
|
data-testid={`booking-row-${b.id}`}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
>
|
2026-07-08 12:36:17 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<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">
|
2026-07-08 12:19:27 +00:00
|
|
|
|
{index}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<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>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</TableCell>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-07-07 12:26:19 +00:00
|
|
|
|
</div>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</TableCell>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<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" dir="ltr">
|
|
|
|
|
|
{fmtTimeOnly(b.startsAt)} – {fmtTimeOnly(b.endsAt)}
|
|
|
|
|
|
</div>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableCell className="whitespace-nowrap text-center align-middle">
|
2026-07-07 14:08:51 +00:00
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"text-xs px-2 py-0.5 rounded-full",
|
2026-07-08 11:12:41 +00:00
|
|
|
|
b.meetingType === "external"
|
|
|
|
|
|
? "bg-violet-100 text-violet-700"
|
|
|
|
|
|
: "bg-slate-100 text-slate-600",
|
2026-07-07 14:08:51 +00:00
|
|
|
|
)}
|
2026-07-06 09:46:12 +00:00
|
|
|
|
>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t(`protocol.bookings.meetingTypesShort.${b.meetingType}`)}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
</span>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</TableCell>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<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>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</TableCell>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableCell className="whitespace-nowrap text-center align-middle">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"text-xs px-2 py-0.5 rounded-full",
|
|
|
|
|
|
STATUS_PILL[b.status],
|
2026-07-07 14:08:51 +00:00
|
|
|
|
)}
|
2026-07-08 11:12:41 +00:00
|
|
|
|
>
|
|
|
|
|
|
{t(`protocol.status.${b.status}`)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell
|
2026-07-08 12:27:54 +00:00
|
|
|
|
className="whitespace-nowrap text-center align-middle"
|
2026-07-08 11:12:41 +00:00
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<div className="flex items-center justify-center gap-0.5">
|
2026-07-07 14:08:51 +00:00
|
|
|
|
{c?.canApprove && b.status === "pending" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
2026-07-08 11:12:41 +00:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
className="h-7 w-7 p-0 text-emerald-600"
|
|
|
|
|
|
title={t("protocol.actions.approve")}
|
|
|
|
|
|
aria-label={t("protocol.actions.approve")}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
onClick={() =>
|
|
|
|
|
|
bookingAction.mutate({ id: b.id, action: "approve" })
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
<Check size={14} />
|
2026-07-07 14:08:51 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
2026-07-08 11:12:41 +00:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
className="h-7 w-7 p-0 text-rose-500"
|
|
|
|
|
|
title={t("protocol.actions.reject")}
|
|
|
|
|
|
aria-label={t("protocol.actions.reject")}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setRejectTarget({ kind: "booking", id: b.id })
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
<X size={14} />
|
2026-07-07 14:08:51 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{c?.canMutate &&
|
|
|
|
|
|
(b.status === "pending" || b.status === "approved") && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
2026-07-08 12:27:54 +00:00
|
|
|
|
className="h-7 w-7 p-0 text-slate-500"
|
|
|
|
|
|
title={t("protocol.actions.cancel")}
|
|
|
|
|
|
aria-label={t("protocol.actions.cancel")}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
onClick={() =>
|
|
|
|
|
|
bookingAction.mutate({ id: b.id, action: "cancel" })
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<Ban size={14} />
|
2026-07-07 14:08:51 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{c?.canMutate && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
2026-07-08 11:12:41 +00:00
|
|
|
|
className="h-7 w-7 p-0"
|
|
|
|
|
|
title={t("protocol.bookings.edit")}
|
|
|
|
|
|
aria-label={t("protocol.bookings.edit")}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
onClick={() => setBookingDialog({ open: true, edit: b })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Pencil size={14} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
2026-07-08 11:12:41 +00:00
|
|
|
|
className="h-7 w-7 p-0"
|
|
|
|
|
|
title={t("common.delete")}
|
|
|
|
|
|
aria-label={t("common.delete")}
|
2026-07-07 14:08:51 +00:00
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setDeleteTarget({ kind: "booking", id: b.id })
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} className="text-rose-500" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-08 11:12:41 +00:00
|
|
|
|
<ChevronDown
|
|
|
|
|
|
size={14}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"ms-1 text-slate-400 transition-transform cursor-pointer",
|
|
|
|
|
|
expanded && "rotate-180",
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={() => toggleBookingExpanded(b.id)}
|
|
|
|
|
|
/>
|
2026-07-07 14:08:51 +00:00
|
|
|
|
</div>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
{expanded && (
|
|
|
|
|
|
<TableRow key={`${b.id}-details`} className="bg-slate-50/80">
|
|
|
|
|
|
<TableCell colSpan={detailColSpan} className="py-3">
|
2026-07-08 12:19:27 +00:00
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<div className="text-sm text-slate-700 font-semibold">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{b.title}
|
|
|
|
|
|
</div>
|
2026-07-08 12:19:27 +00:00
|
|
|
|
<div className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3 text-xs">
|
|
|
|
|
|
{(
|
|
|
|
|
|
[
|
|
|
|
|
|
[t("protocol.bookings.requester"), b.requesterName],
|
|
|
|
|
|
[t("protocol.bookings.department"), b.department],
|
|
|
|
|
|
[
|
|
|
|
|
|
t("protocol.bookings.meetingType"),
|
|
|
|
|
|
t(`protocol.bookings.meetingTypes.${b.meetingType}`),
|
|
|
|
|
|
],
|
|
|
|
|
|
...(b.meetingType === "external"
|
|
|
|
|
|
? [
|
|
|
|
|
|
[
|
|
|
|
|
|
t("protocol.bookings.entity"),
|
|
|
|
|
|
b.requesterOrg,
|
|
|
|
|
|
] as const,
|
|
|
|
|
|
]
|
|
|
|
|
|
: []),
|
|
|
|
|
|
[
|
|
|
|
|
|
t("protocol.bookings.phone"),
|
|
|
|
|
|
b.requesterPhone ? (
|
|
|
|
|
|
<span dir="ltr">{b.requesterPhone}</span>
|
|
|
|
|
|
) : null,
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
t("protocol.bookings.attendeeCount"),
|
|
|
|
|
|
b.attendeeCount != null ? b.attendeeCount : null,
|
|
|
|
|
|
],
|
|
|
|
|
|
[t("protocol.bookings.purpose"), b.purpose],
|
|
|
|
|
|
[
|
|
|
|
|
|
t("protocol.bookings.reference"),
|
|
|
|
|
|
b.bookingReference ? (
|
|
|
|
|
|
<span dir="ltr">{b.bookingReference}</span>
|
|
|
|
|
|
) : null,
|
|
|
|
|
|
],
|
|
|
|
|
|
] as Array<[string, React.ReactNode]>
|
|
|
|
|
|
).map(([label, value], i) => (
|
|
|
|
|
|
<div key={i} className="flex gap-1">
|
|
|
|
|
|
<span className="text-slate-400 shrink-0">
|
|
|
|
|
|
{label}:
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-slate-600 min-w-0 break-words">
|
|
|
|
|
|
{value != null && value !== "" ? value : "—"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</div>
|
2026-07-08 12:19:27 +00:00
|
|
|
|
<div className="text-xs">
|
|
|
|
|
|
<span className="text-slate-400 font-medium">
|
|
|
|
|
|
{t("protocol.bookings.keyAttendees")}:
|
2026-07-08 13:15:04 +00:00
|
|
|
|
</span>
|
2026-07-08 12:19:27 +00:00
|
|
|
|
{b.keyAttendees && b.keyAttendees.length > 0 ? (
|
2026-07-08 13:15:04 +00:00
|
|
|
|
<div className="mt-1.5 max-w-2xl">
|
|
|
|
|
|
<AttendeesTable attendees={b.keyAttendees} t={t} />
|
|
|
|
|
|
</div>
|
2026-07-08 12:19:27 +00:00
|
|
|
|
) : (
|
2026-07-08 13:15:04 +00:00
|
|
|
|
<span className="ms-1 text-slate-600">—</span>
|
2026-07-08 12:19:27 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{b.status === "rejected" && b.rejectionReason && (
|
|
|
|
|
|
<div className="text-xs text-rose-500">
|
|
|
|
|
|
{b.rejectionReason}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
)}
|
2026-07-08 11:12:41 +00:00
|
|
|
|
</>
|
2026-07-07 14:08:51 +00:00
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-07-06 09:46:12 +00:00
|
|
|
|
|
2026-07-08 12:36:17 +00:00
|
|
|
|
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>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableHead className="text-center whitespace-nowrap">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t("protocol.bookings.table.number")}
|
|
|
|
|
|
</TableHead>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableHead className="text-center whitespace-nowrap">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t("protocol.bookings.table.requester")}
|
|
|
|
|
|
</TableHead>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableHead className="text-center whitespace-nowrap">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t("protocol.bookings.table.date")}
|
|
|
|
|
|
</TableHead>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableHead className="text-center whitespace-nowrap">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t("protocol.bookings.table.type")}
|
|
|
|
|
|
</TableHead>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableHead className="text-center whitespace-nowrap">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t("protocol.bookings.table.roomAttendees")}
|
|
|
|
|
|
</TableHead>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableHead className="text-center whitespace-nowrap">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t("protocol.bookings.table.status")}
|
|
|
|
|
|
</TableHead>
|
2026-07-08 12:27:54 +00:00
|
|
|
|
<TableHead className="text-center whitespace-nowrap">
|
2026-07-08 11:12:41 +00:00
|
|
|
|
{t("protocol.bookings.table.actions")}
|
|
|
|
|
|
</TableHead>
|
2026-07-08 12:36:17 +00:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{list.map((b, i) => (
|
|
|
|
|
|
<Fragment key={b.id}>{renderBookingRow(b, i + 1)}</Fragment>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-07-08 11:12:41 +00:00
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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">
|
2026-07-06 10:13:41 +00:00
|
|
|
|
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center gap-3">
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<Button variant="ghost" size="icon" onClick={back} aria-label={t("common.back")}>
|
|
|
|
|
|
<BackIcon size={20} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
|
|
|
|
<div className="h-9 w-9 rounded-xl bg-sky-500 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>
|
|
|
|
|
|
|
2026-07-06 10:13:41 +00:00
|
|
|
|
<div className="max-w-6xl mx-auto px-4 py-5 flex flex-col md:flex-row gap-4 md:gap-6">
|
|
|
|
|
|
<aside className="md:w-56 shrink-0">
|
|
|
|
|
|
<nav className="flex md:flex-col gap-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0 md:sticky md:top-20">
|
2026-07-07 11:58:20 +00:00
|
|
|
|
{TABS.map((item) => {
|
|
|
|
|
|
const { key, label, icon: Icon } = item;
|
|
|
|
|
|
if (key === "issues") return null;
|
|
|
|
|
|
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 rounded-lg text-sm whitespace-nowrap transition-colors md:w-full md:justify-start",
|
|
|
|
|
|
groupActive
|
|
|
|
|
|
? "text-sky-600 font-medium"
|
|
|
|
|
|
: "text-slate-600 hover:bg-slate-100",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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 rounded-lg 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-100",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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 rounded-lg 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-100",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon size={16} className="shrink-0" />
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-07-06 10:13:41 +00:00
|
|
|
|
</nav>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="flex-1 min-w-0">
|
2026-07-06 09:25:40 +00:00
|
|
|
|
{tab === "dashboard" && (
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<DashboardView data={dashboard.data} t={t} goTab={setTab} />
|
2026-07-06 09:25:40 +00:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{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>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<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>
|
2026-07-06 12:20:17 +00:00
|
|
|
|
<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" />
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="text-xs font-medium text-slate-700">
|
|
|
|
|
|
{t("protocol.bookings.shareLinkTitle")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
className="truncate text-xs text-slate-500 text-start"
|
|
|
|
|
|
title={publicBookingUrl}
|
|
|
|
|
|
>
|
|
|
|
|
|
{publicBookingUrl}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={copyBookingLink}
|
|
|
|
|
|
className="shrink-0 self-start sm:self-auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
{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>
|
2026-07-07 12:26:19 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-07 12:34:52 +00:00
|
|
|
|
{bookings.data?.length === 0 && !debouncedBookingSearch && (
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<EmptyState text={t("protocol.bookings.empty")} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{bookingsView === "list" ? (
|
2026-07-07 12:26:19 +00:00
|
|
|
|
(() => {
|
2026-07-07 12:34:52 +00:00
|
|
|
|
const list = bookings.data ?? [];
|
|
|
|
|
|
if (debouncedBookingSearch && list.length === 0) {
|
2026-07-07 12:26:19 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
text={t("protocol.bookings.searchEmpty")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-08 11:12:41 +00:00
|
|
|
|
if (list.length === 0) return null;
|
|
|
|
|
|
return renderBookingsTable(list);
|
2026-07-07 12:26:19 +00:00
|
|
|
|
})()
|
2026-07-06 09:46:12 +00:00
|
|
|
|
) : (
|
2026-07-07 10:27:15 +00:00
|
|
|
|
<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)}
|
2026-07-07 10:36:01 +00:00
|
|
|
|
month={calendarMonth}
|
|
|
|
|
|
onMonthChange={setCalendarMonth}
|
2026-07-07 10:27:15 +00:00
|
|
|
|
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",
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
</div>
|
2026-07-07 10:27:15 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 flex-1 rounded-xl border border-slate-200 bg-white p-3">
|
2026-07-07 10:34:16 +00:00
|
|
|
|
<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"
|
2026-07-07 10:36:01 +00:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
setSelectedDay(today);
|
|
|
|
|
|
setCalendarMonth(today);
|
2026-07-08 12:43:22 +00:00
|
|
|
|
setTodayScrollSignal((v) => v + 1);
|
2026-07-07 10:36:01 +00:00
|
|
|
|
}}
|
2026-07-07 10:34:16 +00:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2026-07-07 10:27:15 +00:00
|
|
|
|
</div>
|
2026-07-08 12:43:22 +00:00
|
|
|
|
<BookingDayGrid
|
|
|
|
|
|
bookings={bookings.data ?? []}
|
|
|
|
|
|
selectedDay={selectedDay}
|
|
|
|
|
|
now={now}
|
|
|
|
|
|
isAr={isAr}
|
|
|
|
|
|
roomName={roomName}
|
|
|
|
|
|
emptyText={t("protocol.bookings.emptyDay")}
|
|
|
|
|
|
scrollSignal={todayScrollSignal}
|
2026-07-08 12:50:33 +00:00
|
|
|
|
onSelect={(b) => setDetailsBooking(b)}
|
2026-07-08 13:27:35 +00:00
|
|
|
|
onSlotClick={
|
|
|
|
|
|
c?.canRequest
|
|
|
|
|
|
? (start) => setBookingDialog({ open: true, start })
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
2026-07-08 12:43:22 +00:00
|
|
|
|
/>
|
2026-07-07 10:27:15 +00:00
|
|
|
|
</div>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
</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 })}>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<Plus size={16} className="me-1" />
|
2026-07-06 09:46:12 +00:00
|
|
|
|
{t("protocol.rooms.new")}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
|
|
|
|
{(rooms.data ?? []).map((r) => (
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<div
|
2026-07-06 09:46:12 +00:00
|
|
|
|
key={r.id}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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">
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<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}`
|
|
|
|
|
|
: ""}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"text-xs px-2 py-0.5 rounded-full shrink-0",
|
2026-07-06 09:46:12 +00:00
|
|
|
|
r.isActive
|
|
|
|
|
|
? "bg-emerald-100 text-emerald-700"
|
|
|
|
|
|
: "bg-slate-200 text-slate-500",
|
2026-07-06 09:25:40 +00:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
{r.isActive
|
|
|
|
|
|
? t("protocol.rooms.active")
|
|
|
|
|
|
: t("protocol.rooms.inactive")}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-07-06 09:46:12 +00:00
|
|
|
|
{rooms.data?.length === 0 && (
|
|
|
|
|
|
<EmptyState text={t("protocol.rooms.empty")} />
|
2026-07-06 09:25:40 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-08 14:43:44 +00:00
|
|
|
|
{c?.canManageRooms && (
|
|
|
|
|
|
<SlotsAdminSection
|
|
|
|
|
|
isAr={isAr}
|
|
|
|
|
|
onChanged={() => invalidate("slots")}
|
|
|
|
|
|
onError={onApiError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{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>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
{m.status === "rejected" && m.rejectionReason && (
|
|
|
|
|
|
<div className="text-xs text-rose-500 mt-1">
|
|
|
|
|
|
{t("protocol.actions.reject")}: {m.rejectionReason}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<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>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-07 09:14:07 +00:00
|
|
|
|
{tab === "photos" && (
|
|
|
|
|
|
<PhotosView
|
|
|
|
|
|
canMutate={!!c?.canMutate}
|
|
|
|
|
|
isAr={isAr}
|
|
|
|
|
|
externalMeetings={external.data ?? []}
|
|
|
|
|
|
onApiError={onApiError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-06 10:13:41 +00:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Dialogs */}
|
|
|
|
|
|
{bookingDialog.open && (
|
|
|
|
|
|
<BookingDialog
|
|
|
|
|
|
rooms={rooms.data ?? []}
|
2026-07-08 14:48:11 +00:00
|
|
|
|
slots={slots.data ?? []}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
edit={bookingDialog.edit}
|
2026-07-07 10:34:16 +00:00
|
|
|
|
initialDay={bookingDialog.day}
|
2026-07-08 13:27:35 +00:00
|
|
|
|
initialStart={bookingDialog.start}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-07-08 12:36:17 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-07-08 12:50:33 +00:00
|
|
|
|
<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 dir="ltr">
|
|
|
|
|
|
{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}`,
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
2026-07-08 12:52:13 +00:00
|
|
|
|
<DetailRow
|
|
|
|
|
|
label={t("protocol.bookings.details.source")}
|
|
|
|
|
|
value={t(
|
|
|
|
|
|
`protocol.bookings.details.sources.${detailsBooking.source}`,
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
2026-07-08 12:50:33 +00:00
|
|
|
|
</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>
|
2026-07-08 13:15:04 +00:00
|
|
|
|
<div className="mt-1.5">
|
|
|
|
|
|
<AttendeesTable
|
|
|
|
|
|
attendees={detailsBooking.keyAttendees!}
|
|
|
|
|
|
t={t}
|
|
|
|
|
|
/>
|
2026-07-08 13:06:18 +00:00
|
|
|
|
</div>
|
2026-07-08 12:50:33 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<AppDock currentSlug="protocol" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Sub-views & dialogs
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-07-08 13:15:04 +00:00
|
|
|
|
function AttendeesTable({
|
|
|
|
|
|
attendees,
|
|
|
|
|
|
t,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
attendees: KeyAttendee[];
|
|
|
|
|
|
t: (key: string) => string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="overflow-x-auto rounded-xl border border-slate-200 shadow-sm">
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow className="border-b border-slate-200 bg-slate-100/80 hover:bg-slate-100/80">
|
|
|
|
|
|
{(["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="border-b border-slate-100 last:border-b-0 even:bg-slate-50/60"
|
|
|
|
|
|
>
|
|
|
|
|
|
<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-violet-100 text-violet-700"
|
|
|
|
|
|
: "bg-sky-100 text-sky-700",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t(`protocol.bookings.attendeeSides.${a.side}`)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 12:50:33 +00:00
|
|
|
|
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(" ");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 14:43:44 +00:00
|
|
|
|
// 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();
|
2026-07-08 14:48:11 +00:00
|
|
|
|
const qc = useQueryClient();
|
2026-07-08 14:43:44 +00:00
|
|
|
|
const [newTime, setNewTime] = useState("");
|
|
|
|
|
|
const [newDuration, setNewDuration] = useState("30");
|
|
|
|
|
|
|
2026-07-08 14:48:11 +00:00
|
|
|
|
// 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 changed = () => {
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] });
|
|
|
|
|
|
onChanged();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-08 14:43:44 +00:00
|
|
|
|
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("");
|
2026-07-08 14:48:11 +00:00
|
|
|
|
changed();
|
2026-07-08 14:43:44 +00:00
|
|
|
|
},
|
|
|
|
|
|
onError,
|
|
|
|
|
|
});
|
|
|
|
|
|
const toggleMut = useMutation({
|
|
|
|
|
|
mutationFn: (s: Slot) =>
|
|
|
|
|
|
apiJson(`${API}/protocol/booking-slots/${s.id}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
body: JSON.stringify({ isActive: !s.isActive }),
|
|
|
|
|
|
}),
|
2026-07-08 14:48:11 +00:00
|
|
|
|
onSuccess: changed,
|
2026-07-08 14:43:44 +00:00
|
|
|
|
onError,
|
|
|
|
|
|
});
|
|
|
|
|
|
const deleteMut = useMutation({
|
|
|
|
|
|
mutationFn: (id: number) =>
|
|
|
|
|
|
apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }),
|
2026-07-08 14:48:11 +00:00
|
|
|
|
onSuccess: changed,
|
2026-07-08 14:43:44 +00:00
|
|
|
|
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 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>
|
|
|
|
|
|
{slots.length === 0 ? (
|
|
|
|
|
|
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="grid gap-1.5 sm:grid-cols-2">
|
|
|
|
|
|
{slots.map((s) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 px-3 py-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2 min-w-0">
|
2026-07-08 17:41:09 +00:00
|
|
|
|
<span
|
|
|
|
|
|
className="text-sm font-medium text-slate-700"
|
|
|
|
|
|
dir={isAr ? "rtl" : "ltr"}
|
|
|
|
|
|
>
|
2026-07-08 14:43:44 +00:00
|
|
|
|
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"text-[11px] px-2 py-0.5 rounded-full shrink-0",
|
|
|
|
|
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-1 shrink-0">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
title={
|
|
|
|
|
|
s.isActive
|
|
|
|
|
|
? t("protocol.slots.deactivate")
|
|
|
|
|
|
: t("protocol.slots.activate")
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={() => toggleMut.mutate(s)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{s.isActive ? <Ban size={14} /> : <Check size={14} />}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={() => deleteMut.mutate(s.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} className="text-rose-500" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
function EmptyState({ text }: { text: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DashboardView({
|
|
|
|
|
|
data,
|
|
|
|
|
|
t,
|
2026-07-06 09:46:12 +00:00
|
|
|
|
goTab,
|
2026-07-06 09:25:40 +00:00
|
|
|
|
}: {
|
|
|
|
|
|
data: Dashboard | undefined;
|
|
|
|
|
|
t: (k: string) => string;
|
2026-07-06 09:46:12 +00:00
|
|
|
|
goTab: (k: TabKey) => void;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
}) {
|
2026-07-06 09:46:12 +00:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
2026-07-06 09:25:40 +00:00
|
|
|
|
{
|
|
|
|
|
|
label: t("protocol.dashboard.pendingBookings"),
|
|
|
|
|
|
value: data?.pendingBookings,
|
2026-07-06 09:46:12 +00:00
|
|
|
|
tab: "bookings",
|
2026-07-06 09:25:40 +00:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: t("protocol.dashboard.upcomingExternal"),
|
|
|
|
|
|
value: data?.upcomingExternalMeetings,
|
2026-07-06 09:46:12 +00:00
|
|
|
|
tab: "external",
|
2026-07-06 09:25:40 +00:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: t("protocol.dashboard.giftStock"),
|
|
|
|
|
|
value: data?.totalGiftStock,
|
2026-07-06 09:46:12 +00:00
|
|
|
|
tab: "gifts",
|
2026-07-06 09:25:40 +00:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: t("protocol.dashboard.pendingIssues"),
|
|
|
|
|
|
value: data?.pendingGiftIssues,
|
2026-07-06 09:46:12 +00:00
|
|
|
|
tab: "issues",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: t("protocol.dashboard.giftsIssuedThisMonth"),
|
|
|
|
|
|
value: data?.giftsIssuedThisMonth,
|
|
|
|
|
|
tab: "issues",
|
2026-07-06 09:25:40 +00:00
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
|
|
|
|
|
|
{cards.map((c) => (
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<button
|
2026-07-06 09:25:40 +00:00
|
|
|
|
key={c.label}
|
2026-07-06 09:46:12 +00:00
|
|
|
|
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"
|
2026-07-06 09:25:40 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div className="text-3xl font-bold text-sky-600">{c.value ?? "—"}</div>
|
|
|
|
|
|
<div className="text-sm text-slate-500 mt-1">{c.label}</div>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
</button>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</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()}>
|
2026-07-08 13:46:08 +00:00
|
|
|
|
<DialogContent
|
|
|
|
|
|
dir={i18n.language === "ar" ? "rtl" : "ltr"}
|
|
|
|
|
|
className="flex max-h-[85vh] flex-col overflow-hidden"
|
|
|
|
|
|
>
|
|
|
|
|
|
<DialogHeader className="shrink-0">
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<DialogTitle>{title}</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
<form
|
2026-07-08 13:46:08 +00:00
|
|
|
|
className="flex min-h-0 flex-1 flex-col"
|
2026-07-06 09:25:40 +00:00
|
|
|
|
onSubmit={(e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
onSubmit();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-07-08 13:46:08 +00:00
|
|
|
|
{/* 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">
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<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,
|
2026-07-08 14:43:44 +00:00
|
|
|
|
slots,
|
2026-07-06 09:25:40 +00:00
|
|
|
|
edit,
|
2026-07-07 10:34:16 +00:00
|
|
|
|
initialDay,
|
2026-07-08 13:27:35 +00:00
|
|
|
|
initialStart,
|
2026-07-06 09:25:40 +00:00
|
|
|
|
nameOf,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onSaved,
|
|
|
|
|
|
onError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
rooms: Room[];
|
2026-07-08 14:43:44 +00:00
|
|
|
|
// 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.
|
2026-07-08 14:48:11 +00:00
|
|
|
|
slots: ActiveSlotInfo[];
|
2026-07-06 09:25:40 +00:00
|
|
|
|
edit?: Booking;
|
2026-07-07 10:34:16 +00:00
|
|
|
|
initialDay?: Date;
|
2026-07-08 13:27:35 +00:00
|
|
|
|
initialStart?: Date;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
nameOf: (ar: string, en: string) => string;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onSaved: () => void;
|
|
|
|
|
|
onError: (e: unknown) => void;
|
|
|
|
|
|
}) {
|
2026-07-08 14:43:44 +00:00
|
|
|
|
const { t, i18n } = useTranslation();
|
|
|
|
|
|
const isAr = i18n.language === "ar";
|
2026-07-07 10:34:16 +00:00
|
|
|
|
const dayAt = (hour: number) => {
|
|
|
|
|
|
if (!initialDay) return "";
|
|
|
|
|
|
const d = new Date(initialDay);
|
|
|
|
|
|
d.setHours(hour, 0, 0, 0);
|
|
|
|
|
|
return toLocalInput(d.toISOString());
|
|
|
|
|
|
};
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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 ?? "");
|
2026-07-08 13:27:35 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-07-08 14:43:44 +00:00
|
|
|
|
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);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
const [notes, setNotes] = useState(edit?.notes ?? "");
|
2026-07-08 13:40:20 +00:00
|
|
|
|
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 ?? "");
|
|
|
|
|
|
const emptyRow = { name: "", position: "" };
|
|
|
|
|
|
const initialRows = (side: "internal" | "external") => {
|
|
|
|
|
|
const rows = (edit?.keyAttendees ?? [])
|
|
|
|
|
|
.filter((a) => a.side === side)
|
|
|
|
|
|
.map((a) => ({ name: a.name, position: a.position }));
|
|
|
|
|
|
return rows.length > 0 ? rows : [{ ...emptyRow }];
|
|
|
|
|
|
};
|
|
|
|
|
|
const [internalAttendees, setInternalAttendees] = useState(() =>
|
|
|
|
|
|
initialRows("internal"),
|
|
|
|
|
|
);
|
|
|
|
|
|
const [externalAttendees, setExternalAttendees] = useState(() =>
|
|
|
|
|
|
initialRows("external"),
|
|
|
|
|
|
);
|
|
|
|
|
|
const isExternal = meetingType === "external";
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
|
|
const mut = useMutation({
|
|
|
|
|
|
mutationFn: () => {
|
2026-07-08 13:40:20 +00:00
|
|
|
|
const keyAttendees = [
|
|
|
|
|
|
...internalAttendees
|
|
|
|
|
|
.filter((r) => r.name.trim() !== "")
|
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
|
name: r.name.trim(),
|
|
|
|
|
|
position: r.position.trim(),
|
|
|
|
|
|
side: "internal" as const,
|
|
|
|
|
|
})),
|
|
|
|
|
|
...externalAttendees
|
|
|
|
|
|
.filter((r) => r.name.trim() !== "")
|
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
|
name: r.name.trim(),
|
|
|
|
|
|
position: r.position.trim(),
|
|
|
|
|
|
side: "external" as const,
|
|
|
|
|
|
})),
|
|
|
|
|
|
];
|
2026-07-06 09:25:40 +00:00
|
|
|
|
const body = {
|
|
|
|
|
|
roomId: Number(roomId),
|
|
|
|
|
|
title,
|
|
|
|
|
|
requesterName: requesterName || null,
|
2026-07-08 13:40:20 +00:00
|
|
|
|
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,
|
2026-07-08 14:43:44 +00:00
|
|
|
|
startsAt:
|
|
|
|
|
|
slotMode && selectedSlot
|
|
|
|
|
|
? slotToIso(slotDate, selectedSlot.startTime)
|
|
|
|
|
|
: fromLocalInput(startsAt),
|
|
|
|
|
|
endsAt:
|
|
|
|
|
|
slotMode && selectedSlot
|
|
|
|
|
|
? slotToIso(
|
|
|
|
|
|
slotDate,
|
|
|
|
|
|
selectedSlot.startTime,
|
|
|
|
|
|
selectedSlot.durationMinutes,
|
|
|
|
|
|
)
|
|
|
|
|
|
: fromLocalInput(endsAt),
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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}
|
2026-07-08 14:43:44 +00:00
|
|
|
|
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();
|
|
|
|
|
|
}}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
submitLabel={t("common.save")}
|
|
|
|
|
|
saving={mut.isPending}
|
|
|
|
|
|
>
|
2026-07-08 13:40:20 +00:00
|
|
|
|
<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>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<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>
|
2026-07-08 13:40:20 +00:00
|
|
|
|
<div>
|
|
|
|
|
|
<Label>{t("protocol.bookings.department")}</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={department}
|
|
|
|
|
|
onChange={(e) => setDepartment(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<div>
|
|
|
|
|
|
<Label>{t("protocol.bookings.titleLabel")}</Label>
|
|
|
|
|
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-07-08 13:40:20 +00:00
|
|
|
|
<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>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<Input
|
2026-07-08 13:40:20 +00:00
|
|
|
|
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}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-08 14:43:44 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
{slots.map((s) => (
|
|
|
|
|
|
<SelectItem key={s.id} value={String(s.id)}>
|
2026-07-08 17:41:09 +00:00
|
|
|
|
<span dir={isAr ? "rtl" : "ltr"}>
|
2026-07-08 14:43:44 +00:00
|
|
|
|
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</div>
|
2026-07-08 14:43:44 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</div>
|
2026-07-08 14:43:44 +00:00
|
|
|
|
)}
|
2026-07-08 13:40:20 +00:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>{t("protocol.bookings.keyAttendees")}</Label>
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
|
|
|
|
<BookingAttendeeColumn
|
|
|
|
|
|
title={t("protocol.bookings.attendeeSides.internal")}
|
|
|
|
|
|
rows={internalAttendees}
|
|
|
|
|
|
setRows={setInternalAttendees}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<BookingAttendeeColumn
|
|
|
|
|
|
title={t("protocol.bookings.attendeeSides.external")}
|
|
|
|
|
|
rows={externalAttendees}
|
|
|
|
|
|
setRows={setExternalAttendees}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
<div>
|
|
|
|
|
|
<Label>{t("protocol.notes")}</Label>
|
|
|
|
|
|
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 13:40:20 +00:00
|
|
|
|
// One "أبرز الحضور" column (من الهيئة / خارج الهيئة) in the internal
|
|
|
|
|
|
// booking dialog: rows of name + position with add/remove.
|
|
|
|
|
|
function BookingAttendeeColumn({
|
|
|
|
|
|
title,
|
|
|
|
|
|
rows,
|
|
|
|
|
|
setRows,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
rows: Array<{ name: string; position: string }>;
|
|
|
|
|
|
setRows: React.Dispatch<
|
|
|
|
|
|
React.SetStateAction<Array<{ name: string; position: string }>>
|
|
|
|
|
|
>;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
|
|
|
|
|
<p className="text-sm font-semibold text-slate-700">{title}</p>
|
|
|
|
|
|
{rows.map((row, idx) => (
|
|
|
|
|
|
<div key={idx} className="space-y-1.5">
|
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={row.name}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setRows((rs) =>
|
|
|
|
|
|
rs.map((r, i) =>
|
|
|
|
|
|
i === idx ? { ...r, name: e.target.value } : r,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder={t("protocol.bookings.form.name")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{rows.length > 1 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setRows((rs) => rs.filter((_, i) => i !== idx))
|
|
|
|
|
|
}
|
|
|
|
|
|
className="shrink-0 p-1 text-slate-400 hover:text-rose-500"
|
|
|
|
|
|
aria-label={t("protocol.bookings.form.removeRow")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={row.position}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setRows((rs) =>
|
|
|
|
|
|
rs.map((r, i) =>
|
|
|
|
|
|
i === idx ? { ...r, position: e.target.value } : r,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder={t("protocol.bookings.form.position")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setRows((rs) => [...rs, { name: "", position: "" }])}
|
|
|
|
|
|
className="flex items-center gap-1 text-sm font-medium text-sky-600 hover:text-sky-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
{t("protocol.bookings.form.addRow")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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 ?? "");
|
2026-07-06 09:46:12 +00:00
|
|
|
|
const [isActive, setIsActive] = useState(edit?.isActive ?? true);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
|
|
const mut = useMutation({
|
|
|
|
|
|
mutationFn: () => {
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
nameAr,
|
|
|
|
|
|
nameEn,
|
|
|
|
|
|
capacity: capacity ? Number(capacity) : null,
|
|
|
|
|
|
location: location || null,
|
2026-07-06 09:46:12 +00:00
|
|
|
|
isActive,
|
2026-07-06 09:25:40 +00:00
|
|
|
|
};
|
|
|
|
|
|
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>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
<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>
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</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) : "",
|
|
|
|
|
|
);
|
2026-07-06 09:46:12 +00:00
|
|
|
|
const [status, setStatus] = useState<"completed" | "cancelled" | "">(
|
|
|
|
|
|
edit?.status === "completed" || edit?.status === "cancelled"
|
|
|
|
|
|
? edit.status
|
|
|
|
|
|
: "",
|
2026-07-06 09:25:40 +00:00
|
|
|
|
);
|
|
|
|
|
|
const [notes, setNotes] = useState(edit?.notes ?? "");
|
|
|
|
|
|
|
|
|
|
|
|
const mut = useMutation({
|
|
|
|
|
|
mutationFn: () => {
|
2026-07-06 09:46:12 +00:00
|
|
|
|
const body: Record<string, unknown> = {
|
2026-07-06 09:25:40 +00:00
|
|
|
|
title,
|
|
|
|
|
|
partyName: partyName || null,
|
|
|
|
|
|
location: location || null,
|
|
|
|
|
|
startsAt: fromLocalInput(startsAt),
|
|
|
|
|
|
notes: notes || null,
|
|
|
|
|
|
};
|
2026-07-06 09:46:12 +00:00
|
|
|
|
if (edit && status) body.status = status;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
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>
|
2026-07-06 09:46:12 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-06 09:25:40 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-07 09:14:07 +00:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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}`;
|
2026-07-07 09:34:06 +00:00
|
|
|
|
// 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`;
|
2026-07-07 09:14:07 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-07-07 09:34:06 +00:00
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
2026-07-07 09:37:15 +00:00
|
|
|
|
{(photos.data?.length ?? 0) > 0 ? (
|
2026-07-07 09:34:06 +00:00
|
|
|
|
<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>
|
2026-07-07 09:37:15 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
2026-07-07 09:34:06 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{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>
|
2026-07-07 09:14:07 +00:00
|
|
|
|
</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>
|
2026-07-07 09:34:06 +00:00
|
|
|
|
<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>
|
2026-07-07 09:14:07 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|