Files
TX/artifacts/tx-os/src/pages/protocol.tsx
T
Replit Agent e2564e2a66 Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

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

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

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:37:15 +00:00

2425 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
);
}