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

1869 lines
59 KiB
TypeScript
Raw Normal View History

import { useMemo, 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,
} 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 { 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"
| "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",
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 [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: "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>
{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 === "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>
);
}