Files
TX/artifacts/tx-os/src/pages/protocol.tsx
T

2425 lines
78 KiB
TypeScript
Raw Normal View History

import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoute } from "wouter";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
LayoutDashboard,
DoorOpen,
CalendarClock,
Handshake,
Gift,
BarChart3,
ScrollText,
Plus,
Pencil,
Trash2,
Check,
X,
PackageCheck,
CalendarDays,
List as ListIcon,
Link2,
Copy,
Camera,
Upload,
Loader2,
Download,
Image as ImageIcon,
} 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";
import { AppDock } from "@/components/app-dock";
import { useToast } from "@/hooks/use-toast";
import { apiJson, ApiError } from "@/lib/api-json";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { cn } from "@/lib/utils";
const API = `${import.meta.env.BASE_URL}api`;
// ---------------------------------------------------------------------------
// Types (mirror of the /api/protocol/* response shapes)
// ---------------------------------------------------------------------------
type Capabilities = {
canRead: boolean;
canRequest: boolean;
canMutate: boolean;
canApprove: boolean;
canManageRooms: boolean;
canViewAudit: boolean;
};
type Room = {
id: number;
nameAr: string;
nameEn: string;
capacity: number | null;
location: string | null;
isActive: boolean;
sortOrder: number;
};
type BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
type Booking = {
id: number;
roomId: number;
title: string;
requesterName: string | null;
requesterPhone: string | null;
requesterOrg: string | null;
attendeeCount: number | null;
source: "internal" | "public";
startsAt: string;
endsAt: string;
status: BookingStatus;
notes: string | null;
rejectionReason: string | null;
};
type ExternalMeeting = {
id: number;
title: string;
partyName: string | null;
location: string | null;
startsAt: string;
endsAt: string | null;
status: "pending" | "approved" | "rejected" | "completed" | "cancelled";
notes: string | null;
rejectionReason: string | null;
};
type GiftKind = "gift" | "shield";
type Gift = {
id: number;
nameAr: string;
nameEn: string;
kind: GiftKind;
descriptionAr: string | null;
descriptionEn: string | null;
quantityInStock: number;
isActive: boolean;
};
type IssueStatus = "pending" | "approved" | "rejected" | "issued";
type GiftIssue = {
id: number;
giftId: number;
recipientName: string;
occasion: string | null;
quantity: number;
status: IssueStatus;
notes: string | null;
rejectionReason: string | null;
};
type Dashboard = {
activeRooms: number;
roomsAvailableNow: number;
pendingBookings: number;
todayBookings: number;
upcomingExternalMeetings: number;
giftCatalogCount: number;
totalGiftStock: number;
pendingGiftIssues: number;
giftsIssuedThisMonth: number;
giftsIssuedThisMonthQty: number;
};
type ReportRow = {
bookingsByRoom: Array<{
roomId: number;
roomNameAr: string;
roomNameEn: string;
total: number;
approved: number;
pending: number;
rejected: number;
cancelled: number;
}>;
issuesByGift: Array<{
giftId: number;
giftNameAr: string;
giftNameEn: string;
kind: GiftKind;
totalRequests: number;
issued: number;
issuedQty: number;
pending: number;
rejected: number;
}>;
externalMeetings: {
total: number;
pending: number;
approved: number;
rejected: number;
completed: number;
cancelled: number;
};
};
type TabKey =
| "dashboard"
| "rooms"
| "bookings"
| "external"
| "gifts"
| "issues"
| "photos"
| "reports"
| "audit";
// URL slugs for each tab so pages are deep-linkable under /protocol/*.
const TAB_SLUGS: Record<TabKey, string> = {
dashboard: "dashboard",
rooms: "rooms",
bookings: "bookings",
external: "external-meetings",
gifts: "gifts",
issues: "gift-issues",
photos: "photos",
reports: "reports",
audit: "audit",
};
const SLUG_TABS: Record<string, TabKey> = Object.fromEntries(
Object.entries(TAB_SLUGS).map(([k, v]) => [v, k as TabKey]),
) as Record<string, TabKey>;
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function toLocalInput(iso: string): string {
// Convert an ISO string into a value for <input type="datetime-local">.
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
function fromLocalInput(v: string): string {
return new Date(v).toISOString();
}
// Group bookings by calendar day (ascending) for the calendar view.
function groupBookingsByDay(
items: Booking[],
isAr: boolean,
): Array<{ key: string; label: string; items: Booking[] }> {
const map = new Map<string, Booking[]>();
for (const b of items) {
const d = new Date(b.startsAt);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
2,
"0",
)}-${String(d.getDate()).padStart(2, "0")}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(b);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, group]) => ({
key,
label: new Date(`${key}T00:00:00`).toLocaleDateString(
isAr ? "ar" : "en",
{ weekday: "long", year: "numeric", month: "long", day: "numeric" },
),
items: group.sort(
(a, b) =>
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
),
}));
}
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",
};
export default function ProtocolPage() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const isAr = lang === "ar";
const [, setLocation] = useLocation();
const { toast } = useToast();
const qc = useQueryClient();
const [matchTab, tabParams] = useRoute("/protocol/:tab");
const tab: TabKey =
(matchTab && SLUG_TABS[tabParams?.tab ?? ""]) || "dashboard";
const setTab = (k: TabKey) => setLocation(`/protocol/${TAB_SLUGS[k]}`);
const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar);
const caps = useQuery<Capabilities>({
queryKey: ["protocol", "me"],
queryFn: () => apiJson<Capabilities>(`${API}/protocol/me`),
});
const rooms = useQuery<Room[]>({
queryKey: ["protocol", "rooms"],
queryFn: () => apiJson<Room[]>(`${API}/protocol/rooms`),
});
const bookings = useQuery<Booking[]>({
queryKey: ["protocol", "bookings"],
queryFn: () => apiJson<Booking[]>(`${API}/protocol/bookings`),
});
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;
}>({ open: false });
const [rejectTarget, setRejectTarget] = useState<{
kind: "booking" | "issue" | "external";
id: number;
} | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const publicBookingUrl = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/protocol/request`;
const [linkCopied, setLinkCopied] = useState(false);
const linkCopiedTimer = useRef<number | null>(null);
const copyBookingLink = async () => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(publicBookingUrl);
} else {
const ta = document.createElement("textarea");
ta.value = publicBookingUrl;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} finally {
document.body.removeChild(ta);
}
if (!ok) throw new Error("execCommand copy failed");
}
setLinkCopied(true);
toast({ title: t("protocol.bookings.linkCopied") });
if (linkCopiedTimer.current) window.clearTimeout(linkCopiedTimer.current);
linkCopiedTimer.current = window.setTimeout(
() => setLinkCopied(false),
2000,
);
} catch {
toast({
title: t("protocol.bookings.linkCopyFailed"),
variant: "destructive",
});
}
};
const [deleteTarget, setDeleteTarget] = useState<
| { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number }
| null
>(null);
const [roomDialog, setRoomDialog] = useState<{
open: boolean;
edit?: Room;
}>({ open: false });
const [externalDialog, setExternalDialog] = useState<{
open: boolean;
edit?: ExternalMeeting;
}>({ open: false });
const [giftDialog, setGiftDialog] = useState<{
open: boolean;
edit?: Gift;
}>({ open: false });
const [issueDialog, setIssueDialog] = useState<{ open: boolean }>({
open: false,
});
const bookingAction = useMutation({
mutationFn: (args: { id: number; action: "approve" | "cancel" }) =>
apiJson(`${API}/protocol/bookings/${args.id}/${args.action}`, {
method: "POST",
body: JSON.stringify({}),
}),
onSuccess: () => invalidate("bookings"),
onError: onApiError,
});
const rejectMut = useMutation({
mutationFn: (args: { kind: "booking" | "issue" | "external"; id: number }) => {
const path =
args.kind === "booking"
? "bookings"
: args.kind === "issue"
? "gift-issues"
: "external-meetings";
return apiJson(`${API}/protocol/${path}/${args.id}/reject`, {
method: "POST",
body: JSON.stringify({}),
});
},
onSuccess: () => {
setRejectTarget(null);
invalidate("bookings", "issues", "external");
},
onError: onApiError,
});
const externalApprove = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/external-meetings/${id}/approve`, {
method: "POST",
body: JSON.stringify({}),
}),
onSuccess: () => invalidate("external"),
onError: onApiError,
});
const issueApprove = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/gift-issues/${id}/approve`, {
method: "POST",
body: JSON.stringify({}),
}),
onSuccess: () => invalidate("issues", "gifts"),
onError: onApiError,
});
const deleteMut = useMutation({
mutationFn: (target: { kind: string; id: number }) => {
const path =
target.kind === "room"
? "rooms"
: target.kind === "booking"
? "bookings"
: target.kind === "external"
? "external-meetings"
: target.kind === "gift"
? "gifts"
: "gift-issues";
return apiJson(`${API}/protocol/${path}/${target.id}`, {
method: "DELETE",
});
},
onSuccess: () => {
setDeleteTarget(null);
invalidate("rooms", "bookings", "external", "gifts", "issues");
},
onError: (e) => {
setDeleteTarget(null);
onApiError(e);
},
});
const back = () => setLocation("/");
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
{ key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard },
{ key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen },
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock },
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake },
{ key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift },
{ key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck },
{ key: "photos", label: t("protocol.tabs.photos"), icon: Camera },
{ key: "reports", label: t("protocol.tabs.reports"), icon: BarChart3 },
...(c?.canViewAudit
? [{ key: "audit" as TabKey, label: t("protocol.tabs.audit"), icon: ScrollText }]
: []),
];
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const renderBookingCard = (b: Booking) => (
<div
key={b.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">{b.title}</div>
<div className="text-sm text-slate-500">
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} {" "}
{fmtDateTime(b.endsAt)}
</div>
{b.requesterName && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.requester")}: {b.requesterName}
</div>
)}
{b.requesterOrg && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.org")}: {b.requesterOrg}
</div>
)}
{b.requesterPhone && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.phone")}:{" "}
<span dir="ltr">{b.requesterPhone}</span>
</div>
)}
{b.attendeeCount != null && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.attendeeCount")}: {b.attendeeCount}
</div>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{b.source === "public" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-100 text-sky-700">
{t("protocol.bookings.publicRequest")}
</span>
)}
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
STATUS_PILL[b.status],
)}
>
{t(`protocol.status.${b.status}`)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{c?.canApprove && b.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "approve" })
}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRejectTarget({ kind: "booking", id: b.id })}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate &&
(b.status === "pending" || b.status === "approved") && (
<Button
size="sm"
variant="ghost"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "cancel" })
}
>
{t("protocol.actions.cancel")}
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setBookingDialog({ open: true, edit: b })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteTarget({ kind: "booking", id: b.id })}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div>
);
return (
<div className="min-h-screen bg-slate-50" dir={isAr ? "rtl" : "ltr"}>
<header className="sticky top-0 z-10 bg-white border-b border-slate-200">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={back} aria-label={t("common.back")}>
<BackIcon size={20} />
</Button>
<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>
<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">
{TABS.map(({ key, label, icon: Icon }) => (
<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>
))}
</nav>
</aside>
<main className="flex-1 min-w-0">
{tab === "dashboard" && (
<DashboardView data={dashboard.data} t={t} goTab={setTab} />
)}
{tab === "bookings" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.bookings")}
</h2>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
<button
type="button"
onClick={() => setBookingsView("list")}
className={cn(
"px-2.5 py-1.5 text-xs flex items-center gap-1",
bookingsView === "list"
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-100",
)}
>
<ListIcon size={14} />
{t("protocol.bookings.viewList")}
</button>
<button
type="button"
onClick={() => setBookingsView("calendar")}
className={cn(
"px-2.5 py-1.5 text-xs flex items-center gap-1",
bookingsView === "calendar"
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-100",
)}
>
<CalendarDays size={14} />
{t("protocol.bookings.viewCalendar")}
</button>
</div>
{c?.canRequest && (
<Button
size="sm"
onClick={() => setBookingDialog({ open: true })}
>
<Plus size={16} className="me-1" />
{t("protocol.bookings.new")}
</Button>
)}
</div>
</div>
<div className="rounded-lg border border-sky-200 bg-sky-50/60 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2">
<Link2 size={16} className="shrink-0 text-sky-600" />
<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>
{bookings.data?.length === 0 && (
<EmptyState text={t("protocol.bookings.empty")} />
)}
{bookingsView === "list" ? (
<div className="grid gap-2">
{(bookings.data ?? []).map((b) => renderBookingCard(b))}
</div>
) : (
<div className="space-y-4">
{groupBookingsByDay(bookings.data ?? [], isAr).map((group) => (
<div key={group.key}>
<div className="text-sm font-semibold text-slate-600 mb-2 sticky top-16 bg-slate-50 py-1">
{group.label}
</div>
<div className="grid gap-2">
{group.items.map((b) => renderBookingCard(b))}
</div>
</div>
))}
</div>
)}
</section>
)}
{tab === "rooms" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.rooms")}
</h2>
{c?.canManageRooms && (
<Button size="sm" onClick={() => setRoomDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.rooms.new")}
</Button>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(rooms.data ?? []).map((r) => (
<div
key={r.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">
{nameOf(r.nameAr, r.nameEn)}
</div>
<div className="text-xs text-slate-500">
{r.capacity
? `${t("protocol.rooms.capacity")}: ${r.capacity}`
: ""}
</div>
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0",
r.isActive
? "bg-emerald-100 text-emerald-700"
: "bg-slate-200 text-slate-500",
)}
>
{r.isActive
? t("protocol.rooms.active")
: t("protocol.rooms.inactive")}
</span>
</div>
{c?.canManageRooms && (
<div className="flex gap-1.5 mt-2">
<Button
size="sm"
variant="ghost"
onClick={() => setRoomDialog({ open: true, edit: r })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteTarget({ kind: "room", id: r.id })}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</div>
)}
</div>
))}
{rooms.data?.length === 0 && (
<EmptyState text={t("protocol.rooms.empty")} />
)}
</div>
</section>
)}
{tab === "external" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.external")}
</h2>
{c?.canMutate && (
<Button size="sm" onClick={() => setExternalDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.external.new")}
</Button>
)}
</div>
<div className="grid gap-2">
{(external.data ?? []).map((m) => (
<div
key={m.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">{m.title}</div>
<div className="text-sm text-slate-500">
{m.partyName ? `${m.partyName} · ` : ""}
{fmtDateTime(m.startsAt)}
</div>
{m.location && (
<div className="text-xs text-slate-400">{m.location}</div>
)}
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0",
STATUS_PILL[m.status],
)}
>
{t(`protocol.status.${m.status}`)}
</span>
</div>
{m.status === "rejected" && m.rejectionReason && (
<div className="text-xs text-rose-500 mt-1">
{t("protocol.actions.reject")}: {m.rejectionReason}
</div>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
{c?.canApprove && m.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
onClick={() => externalApprove.mutate(m.id)}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setRejectTarget({ kind: "external", id: m.id })
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setExternalDialog({ open: true, edit: m })
}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "external", id: m.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div>
))}
{external.data?.length === 0 && (
<EmptyState text={t("protocol.external.empty")} />
)}
</div>
</section>
)}
{tab === "gifts" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.gifts")}
</h2>
<div className="flex gap-2">
{c?.canMutate && (
<Button size="sm" onClick={() => setGiftDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.gifts.new")}
</Button>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(gifts.data ?? []).map((g) => (
<div
key={g.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">
{nameOf(g.nameAr, g.nameEn)}
</div>
<div className="text-xs text-slate-500">
{t(`protocol.giftKind.${g.kind}`)} ·{" "}
{t("protocol.gifts.stock")}: {g.quantityInStock}
</div>
</div>
<div className="flex gap-1 shrink-0">
{c?.canRequest && (
<Button
size="sm"
variant="outline"
onClick={() => setIssueDialog({ open: true })}
>
{t("protocol.issues.new")}
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setGiftDialog({ open: true, edit: g })
}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "gift", id: g.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div>
</div>
))}
{gifts.data?.length === 0 && (
<EmptyState text={t("protocol.gifts.empty")} />
)}
</div>
</section>
)}
{tab === "issues" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.issues")}
</h2>
{c?.canRequest && (
<Button size="sm" onClick={() => setIssueDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.issues.new")}
</Button>
)}
</div>
<div className="grid gap-2">
{(issues.data ?? []).map((it) => (
<div
key={it.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">
{giftName(it.giftId)} × {it.quantity}
</div>
<div className="text-sm text-slate-500">
{t("protocol.issues.recipient")}: {it.recipientName}
{it.occasion ? ` · ${it.occasion}` : ""}
</div>
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0",
STATUS_PILL[it.status],
)}
>
{t(`protocol.status.${it.status}`)}
</span>
</div>
{c?.canApprove && it.status === "pending" && (
<div className="flex gap-1.5 mt-2">
<Button
size="sm"
variant="outline"
onClick={() => issueApprove.mutate(it.id)}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approveIssue")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setRejectTarget({ kind: "issue", id: it.id })
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</div>
)}
</div>
))}
{issues.data?.length === 0 && (
<EmptyState text={t("protocol.issues.empty")} />
)}
</div>
</section>
)}
{tab === "photos" && (
<PhotosView
canMutate={!!c?.canMutate}
isAr={isAr}
externalMeetings={external.data ?? []}
onApiError={onApiError}
/>
)}
{tab === "reports" && (
<ReportsView data={reports.data} nameOf={nameOf} t={t} />
)}
{tab === "audit" && (
<section className="space-y-2">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.audit")}
</h2>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{(audit.data ?? []).map((a) => (
<div key={a.id} className="p-3 text-sm flex justify-between gap-2">
<span className="text-slate-700">
{t(`protocol.audit.${a.action}`, a.action)}
<span className="text-slate-400">
{" "}
· {a.entityType}
{a.entityId ? `#${a.entityId}` : ""}
</span>
</span>
<span className="text-slate-400 shrink-0">
{isAr ? a.actorNameAr : a.actorNameEn || a.actorUsername} ·{" "}
{fmtDateTime(a.performedAt)}
</span>
</div>
))}
{audit.data?.length === 0 && (
<EmptyState text={t("protocol.audit.empty")} />
)}
</div>
</section>
)}
</main>
</div>
{/* Dialogs */}
{bookingDialog.open && (
<BookingDialog
rooms={rooms.data ?? []}
edit={bookingDialog.edit}
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>
<AppDock currentSlug="protocol" />
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-views & dialogs
// ---------------------------------------------------------------------------
function EmptyState({ text }: { text: string }) {
return (
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>
);
}
function DashboardView({
data,
t,
goTab,
}: {
data: Dashboard | undefined;
t: (k: string) => string;
goTab: (k: TabKey) => void;
}) {
const cards: Array<{ label: string; value?: number; tab: TabKey }> = [
{
label: t("protocol.dashboard.roomsAvailableNow"),
value: data?.roomsAvailableNow,
tab: "rooms",
},
{
label: t("protocol.dashboard.activeRooms"),
value: data?.activeRooms,
tab: "rooms",
},
{
label: t("protocol.dashboard.todayBookings"),
value: data?.todayBookings,
tab: "bookings",
},
{
label: t("protocol.dashboard.pendingBookings"),
value: data?.pendingBookings,
tab: "bookings",
},
{
label: t("protocol.dashboard.upcomingExternal"),
value: data?.upcomingExternalMeetings,
tab: "external",
},
{
label: t("protocol.dashboard.giftStock"),
value: data?.totalGiftStock,
tab: "gifts",
},
{
label: t("protocol.dashboard.pendingIssues"),
value: data?.pendingGiftIssues,
tab: "issues",
},
{
label: t("protocol.dashboard.giftsIssuedThisMonth"),
value: data?.giftsIssuedThisMonth,
tab: "issues",
},
];
return (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
{cards.map((c) => (
<button
key={c.label}
type="button"
onClick={() => goTab(c.tab)}
className="bg-white rounded-2xl border border-slate-200 p-4 text-start hover:border-sky-300 hover:shadow-sm transition"
>
<div className="text-3xl font-bold text-sky-600">{c.value ?? "—"}</div>
<div className="text-sm text-slate-500 mt-1">{c.label}</div>
</button>
))}
</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>
<div>
<h3 className="font-semibold text-slate-700 mb-2">
{t("protocol.reports.byExternal")}
</h3>
<div className="grid gap-2 grid-cols-3 sm:grid-cols-6">
{(
[
["total", "protocol.reports.total"],
["pending", "protocol.status.pending"],
["approved", "protocol.status.approved"],
["rejected", "protocol.status.rejected"],
["completed", "protocol.status.completed"],
["cancelled", "protocol.status.cancelled"],
] as const
).map(([key, label]) => (
<div
key={key}
className="bg-white rounded-xl border border-slate-200 p-3 text-center"
>
<div className="text-2xl font-bold text-sky-600">
{data?.externalMeetings?.[key] ?? "—"}
</div>
<div className="text-xs text-slate-500 mt-1">{t(label)}</div>
</div>
))}
</div>
</div>
</div>
);
}
function DialogShell({
title,
onClose,
children,
onSubmit,
submitLabel,
saving,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
onSubmit: () => void;
submitLabel: string;
saving: boolean;
}) {
const { i18n, t } = useTranslation();
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent dir={i18n.language === "ar" ? "rtl" : "ltr"}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
{children}
<DialogFooter>
<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,
edit,
nameOf,
onClose,
onSaved,
onError,
}: {
rooms: Room[];
edit?: Booking;
nameOf: (ar: string, en: string) => string;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(edit?.title ?? "");
const [roomId, setRoomId] = useState<string>(
edit ? String(edit.roomId) : rooms[0] ? String(rooms[0].id) : "",
);
const [requesterName, setRequesterName] = useState(edit?.requesterName ?? "");
const [startsAt, setStartsAt] = useState(
edit ? toLocalInput(edit.startsAt) : "",
);
const [endsAt, setEndsAt] = useState(edit ? toLocalInput(edit.endsAt) : "");
const [notes, setNotes] = useState(edit?.notes ?? "");
const mut = useMutation({
mutationFn: () => {
const body = {
roomId: Number(roomId),
title,
requesterName: requesterName || null,
startsAt: fromLocalInput(startsAt),
endsAt: fromLocalInput(endsAt),
notes: notes || null,
};
return edit
? apiJson(`${API}/protocol/bookings/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/bookings`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.bookings.edit") : t("protocol.bookings.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.bookings.roomLabel")}</Label>
<Select value={roomId} onValueChange={setRoomId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{rooms.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{nameOf(r.nameAr, r.nameEn)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t("protocol.bookings.titleLabel")}</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.bookings.requester")}</Label>
<Input
value={requesterName}
onChange={(e) => setRequesterName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.bookings.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.bookings.endsAt")}</Label>
<Input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
required
/>
</div>
</div>
<div>
<Label>{t("protocol.notes")}</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</DialogShell>
);
}
function RoomDialog({
edit,
onClose,
onSaved,
onError,
}: {
edit?: Room;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [nameAr, setNameAr] = useState(edit?.nameAr ?? "");
const [nameEn, setNameEn] = useState(edit?.nameEn ?? "");
const [capacity, setCapacity] = useState(
edit?.capacity != null ? String(edit.capacity) : "",
);
const [location, setLocation] = useState(edit?.location ?? "");
const [isActive, setIsActive] = useState(edit?.isActive ?? true);
const mut = useMutation({
mutationFn: () => {
const body = {
nameAr,
nameEn,
capacity: capacity ? Number(capacity) : null,
location: location || null,
isActive,
};
return edit
? apiJson(`${API}/protocol/rooms/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/rooms`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.rooms.edit") : t("protocol.rooms.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.rooms.nameAr")}</Label>
<Input value={nameAr} onChange={(e) => setNameAr(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.rooms.nameEn")}</Label>
<Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.rooms.capacity")}</Label>
<Input
type="number"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.rooms.location")}</Label>
<Input value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
{t("protocol.rooms.isActive")}
</label>
</DialogShell>
);
}
function ExternalDialog({
edit,
onClose,
onSaved,
onError,
}: {
edit?: ExternalMeeting;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(edit?.title ?? "");
const [partyName, setPartyName] = useState(edit?.partyName ?? "");
const [location, setLocation] = useState(edit?.location ?? "");
const [startsAt, setStartsAt] = useState(
edit ? toLocalInput(edit.startsAt) : "",
);
const [status, setStatus] = useState<"completed" | "cancelled" | "">(
edit?.status === "completed" || edit?.status === "cancelled"
? edit.status
: "",
);
const [notes, setNotes] = useState(edit?.notes ?? "");
const mut = useMutation({
mutationFn: () => {
const body: Record<string, unknown> = {
title,
partyName: partyName || null,
location: location || null,
startsAt: fromLocalInput(startsAt),
notes: notes || null,
};
if (edit && status) body.status = status;
return edit
? apiJson(`${API}/protocol/external-meetings/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/external-meetings`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.external.edit") : t("protocol.external.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.external.titleLabel")}</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.external.party")}</Label>
<Input value={partyName} onChange={(e) => setPartyName(e.target.value)} />
</div>
<div>
<Label>{t("protocol.external.location")}</Label>
<Input value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.external.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
</div>
{edit && (
<div>
<Label>{t("protocol.statusLabel")}</Label>
<Select
value={status}
onValueChange={(v) =>
setStatus(v as "completed" | "cancelled")
}
>
<SelectTrigger>
<SelectValue placeholder={t("protocol.status.pending")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="completed">
{t("protocol.status.completed")}
</SelectItem>
<SelectItem value="cancelled">
{t("protocol.status.cancelled")}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div>
<Label>{t("protocol.notes")}</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</DialogShell>
);
}
function GiftDialog({
edit,
onClose,
onSaved,
onError,
}: {
edit?: Gift;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [nameAr, setNameAr] = useState(edit?.nameAr ?? "");
const [nameEn, setNameEn] = useState(edit?.nameEn ?? "");
const [kind, setKind] = useState<GiftKind>(edit?.kind ?? "gift");
const [quantityInStock, setQuantityInStock] = useState(
edit ? String(edit.quantityInStock) : "0",
);
const mut = useMutation({
mutationFn: () => {
const body = {
nameAr,
nameEn,
kind,
quantityInStock: Number(quantityInStock) || 0,
};
return edit
? apiJson(`${API}/protocol/gifts/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(body),
})
: apiJson(`${API}/protocol/gifts`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.gifts.edit") : t("protocol.gifts.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.gifts.nameAr")}</Label>
<Input value={nameAr} onChange={(e) => setNameAr(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.gifts.nameEn")}</Label>
<Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.gifts.kind")}</Label>
<Select value={kind} onValueChange={(v) => setKind(v as GiftKind)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gift">{t("protocol.giftKind.gift")}</SelectItem>
<SelectItem value="shield">
{t("protocol.giftKind.shield")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t("protocol.gifts.stock")}</Label>
<Input
type="number"
value={quantityInStock}
onChange={(e) => setQuantityInStock(e.target.value)}
/>
</div>
</div>
</DialogShell>
);
}
function IssueDialog({
gifts,
nameOf,
onClose,
onSaved,
onError,
}: {
gifts: Gift[];
nameOf: (ar: string, en: string) => string;
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [giftId, setGiftId] = useState<string>(
gifts[0] ? String(gifts[0].id) : "",
);
const [recipientName, setRecipientName] = useState("");
const [occasion, setOccasion] = useState("");
const [quantity, setQuantity] = useState("1");
const mut = useMutation({
mutationFn: () =>
apiJson(`${API}/protocol/gift-issues`, {
method: "POST",
body: JSON.stringify({
giftId: Number(giftId),
recipientName,
occasion: occasion || null,
quantity: Number(quantity) || 1,
}),
}),
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={t("protocol.issues.new")}
onClose={onClose}
onSubmit={() => mut.mutate()}
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.issues.gift")}</Label>
<Select value={giftId} onValueChange={setGiftId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{gifts.map((g) => (
<SelectItem key={g.id} value={String(g.id)}>
{nameOf(g.nameAr, g.nameEn)} ({g.quantityInStock})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t("protocol.issues.recipient")}</Label>
<Input
value={recipientName}
onChange={(e) => setRecipientName(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.issues.occasion")}</Label>
<Input value={occasion} onChange={(e) => setOccasion(e.target.value)} />
</div>
<div>
<Label>{t("protocol.issues.quantity")}</Label>
<Input
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</div>
</div>
</DialogShell>
);
}
// ---------------------------------------------------------------------------
// Visit photos (تصوير) — album grid + album detail with upload gallery.
// ---------------------------------------------------------------------------
type Album = {
id: number;
name: string;
externalMeetingId: number | null;
meetingTitle: string | null;
createdBy: number | null;
createdAt: string;
updatedAt: string;
photoCount: number;
coverPath: string | null;
};
type Photo = {
id: number;
albumId: number;
objectPath: string;
sortOrder: number;
uploadedBy: number | null;
createdAt: string;
};
// Build the authorized image URL from a stored `/objects/<id>` path.
const photoSrc = (objectPath: string) => `${API}/storage${objectPath}`;
// Force-download URL for a single photo (attachment via ?download=1). The
// optional name base gets an extension appended server-side.
const photoDownloadSrc = (objectPath: string, name?: string) => {
const base = `${API}/storage${objectPath}?download=1`;
return name ? `${base}&name=${encodeURIComponent(name)}` : base;
};
// ZIP download URL for a whole album.
const albumDownloadSrc = (albumId: number) =>
`${API}/protocol/photo-albums/${albumId}/download`;
function PhotosView({
canMutate,
isAr,
externalMeetings,
onApiError,
}: {
canMutate: boolean;
isAr: boolean;
externalMeetings: ExternalMeeting[];
onApiError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [openAlbumId, setOpenAlbumId] = useState<number | null>(null);
const [albumDialog, setAlbumDialog] = useState<{
open: boolean;
edit?: Album;
}>({ open: false });
const [deleteAlbum, setDeleteAlbum] = useState<Album | null>(null);
const [deletePhoto, setDeletePhoto] = useState<Photo | null>(null);
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const albums = useQuery({
queryKey: ["protocol", "photo-albums"],
queryFn: () => apiJson<Album[]>(`${API}/protocol/photo-albums`),
});
const photos = useQuery({
queryKey: ["protocol", "photos", openAlbumId],
queryFn: () =>
apiJson<Photo[]>(`${API}/protocol/photo-albums/${openAlbumId}/photos`),
enabled: openAlbumId !== null,
});
const openAlbum =
openAlbumId !== null
? (albums.data ?? []).find((a) => a.id === openAlbumId)
: undefined;
const { uploadFile, isUploading, progress } = useUpload({
basePath: `${API}/storage`,
onError: onApiError,
});
const finalize = useMutation({
mutationFn: (objectPath: string) =>
apiJson(`${API}/protocol/photo-albums/${openAlbumId}/photos`, {
method: "POST",
body: JSON.stringify({ objectPath }),
}),
onError: onApiError,
});
const handleFiles = async (files: FileList | null) => {
if (!files || openAlbumId === null) return;
for (const file of Array.from(files)) {
const resp: UploadResponse | null = await uploadFile(file);
if (resp) await finalize.mutateAsync(resp.objectPath);
}
await qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
await qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
};
const delAlbumMut = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/photo-albums/${id}`, { method: "DELETE" }),
onSuccess: () => {
setDeleteAlbum(null);
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
},
onError: onApiError,
});
const delPhotoMut = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/photos/${id}`, { method: "DELETE" }),
onSuccess: () => {
setDeletePhoto(null);
qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
},
onError: onApiError,
});
// --- Album detail view -----------------------------------------------------
if (openAlbum) {
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Button
size="sm"
variant="ghost"
onClick={() => setOpenAlbumId(null)}
>
<BackIcon size={16} />
</Button>
<div className="min-w-0">
<h2 className="font-semibold text-slate-700 truncate">
{openAlbum.name}
</h2>
{openAlbum.meetingTitle && (
<div className="text-xs text-slate-400 truncate">
<Link2 size={11} className="inline me-1" />
{openAlbum.meetingTitle}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{(photos.data?.length ?? 0) > 0 ? (
<a
href={albumDownloadSrc(openAlbum.id)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm hover:bg-slate-50 transition"
>
<Download size={16} />
{t("protocol.photos.downloadAll")}
</a>
) : (
<span
aria-disabled="true"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-400 text-sm opacity-60 cursor-not-allowed pointer-events-none"
>
<Download size={16} />
{t("protocol.photos.downloadAll")}
</span>
)}
{canMutate && (
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
multiple
disabled={isUploading}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:opacity-90 transition">
{isUploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{isUploading
? `${t("protocol.photos.uploading")} ${progress}%`
: t("protocol.photos.addPhotos")}
</span>
</label>
)}
</div>
</div>
{photos.data && photos.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyPhotos")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{(photos.data ?? []).map((p) => (
<div
key={p.id}
className="group relative aspect-square rounded-xl overflow-hidden border border-slate-200 bg-slate-100"
>
<a href={photoSrc(p.objectPath)} target="_blank" rel="noreferrer">
<img
src={photoSrc(p.objectPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
</a>
<a
href={photoDownloadSrc(
p.objectPath,
`${openAlbum.name}-${p.sortOrder + 1}`,
)}
download
title={t("protocol.photos.download")}
aria-label={t("protocol.photos.download")}
className="absolute bottom-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Download size={14} />
</a>
{canMutate && (
<button
type="button"
onClick={() => setDeletePhoto(p)}
className="absolute top-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deletePhoto !== null}
onOpenChange={(o) => !o && setDeletePhoto(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeletePhoto")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deletePhoto && delPhotoMut.mutate(deletePhoto.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
// --- Album grid view -------------------------------------------------------
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.photos")}
</h2>
{canMutate && (
<Button size="sm" onClick={() => setAlbumDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.photos.newAlbum")}
</Button>
)}
</div>
{albums.data && albums.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyAlbums")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{(albums.data ?? []).map((a) => (
<div
key={a.id}
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
>
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full aspect-square bg-slate-100 relative"
>
{a.coverPath ? (
<img
src={photoSrc(a.coverPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-slate-300">
<ImageIcon size={32} />
</span>
)}
<span className="absolute bottom-1 end-1 text-[11px] px-1.5 py-0.5 rounded-full bg-black/55 text-white">
{a.photoCount}
</span>
</button>
<div className="p-2">
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full text-start font-medium text-sm text-slate-800 truncate"
>
{a.name}
</button>
{a.meetingTitle && (
<div className="text-[11px] text-slate-400 truncate">
<Link2 size={10} className="inline me-1" />
{a.meetingTitle}
</div>
)}
{canMutate && (
<div className="flex gap-1 mt-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setAlbumDialog({ open: true, edit: a })}
>
<Pencil size={13} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setDeleteAlbum(a)}
>
<Trash2 size={13} className="text-rose-500" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deleteAlbum !== null}
onOpenChange={(o) => !o && setDeleteAlbum(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeleteAlbum")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deleteAlbum && delAlbumMut.mutate(deleteAlbum.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
function AlbumDialog({
edit,
externalMeetings,
onClose,
onSaved,
onError,
}: {
edit?: Album;
externalMeetings: ExternalMeeting[];
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(edit?.name ?? "");
const [meetingId, setMeetingId] = useState<string>(
edit?.externalMeetingId != null ? String(edit.externalMeetingId) : "none",
);
const save = useMutation({
mutationFn: () => {
const payload = {
name: name.trim(),
externalMeetingId: meetingId === "none" ? null : Number(meetingId),
};
return edit
? apiJson(`${API}/protocol/photo-albums/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
: apiJson(`${API}/protocol/photo-albums`, {
method: "POST",
body: JSON.stringify(payload),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.photos.editAlbum") : t("protocol.photos.newAlbum")}
onClose={onClose}
onSubmit={() => save.mutate()}
submitLabel={t("common.save")}
saving={save.isPending}
>
<div>
<Label>{t("protocol.photos.albumName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.photos.linkMeeting")}</Label>
<Select value={meetingId} onValueChange={setMeetingId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("protocol.photos.noMeeting")}</SelectItem>
{externalMeetings.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogShell>
);
}