e2564e2a66
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.
2425 lines
78 KiB
TypeScript
2425 lines
78 KiB
TypeScript
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>
|
||
);
|
||
}
|