Task #649: complete Public Relations & Protocol module (fix 8 review gaps)
Addresses the 8 spec gaps that blocked prior completion, all additive and scoped to the protocol_* module / /protocol routes. executive-meetings untouched. Backend (api-server/routes/protocol.ts, lib/db schema, seed): - External meetings create as "pending" (status dropped from create schema); added approve + reject endpoints with state-conditional WHERE status='pending' and 409 on contention; patch status enum restricted to completed/cancelled. - Added approvedBy/approvedAt/rejectionReason columns + status index; default status now "pending". - Booking reject and gift-issue reject made state-conditional (409 on 0 rows). - Dashboard: roomsAvailableNow (NOT EXISTS), giftsIssuedThisMonth/Qty; upcomingExternal filtered to future pending|approved. - Reports: added externalMeetings summary counts by status. - Seed: room names corrected (AR + EN). Frontend (tx-os/pages/protocol.tsx, App.tsx, locales): - URL-synced tabs under /protocol/:tab (slug<->tab maps). - Dedicated Rooms tab (moved out of Gifts); RoomDialog isActive toggle. - External approve/reject buttons + rejectionReason display; ExternalDialog create omits status, edit limited to completed/cancelled. - Dashboard cards clickable to navigate; reports external section. - Bookings list/calendar (grouped-by-day) view toggle. - New i18n keys in ar.json + en.json. Verification: tx-os + protocol.ts typecheck clean; drizzle push + re-seed done; both workflows restarted; /api/protocol/me returns 401 unauth; architect re-review approved. Pre-existing typecheck errors in executive-meetings.ts and push.ts are unrelated and were not touched. Note: had to rebuild lib/db declarations (tsc --build --force) so api-server project references picked up the new schema columns.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useRoute } from "wouter";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
Check,
|
||||
X,
|
||||
PackageCheck,
|
||||
CalendarDays,
|
||||
List as ListIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -96,8 +98,9 @@ type ExternalMeeting = {
|
||||
location: string | null;
|
||||
startsAt: string;
|
||||
endsAt: string | null;
|
||||
status: "scheduled" | "completed" | "cancelled";
|
||||
status: "pending" | "approved" | "rejected" | "completed" | "cancelled";
|
||||
notes: string | null;
|
||||
rejectionReason: string | null;
|
||||
};
|
||||
|
||||
type GiftKind = "gift" | "shield";
|
||||
@@ -126,12 +129,15 @@ type GiftIssue = {
|
||||
|
||||
type Dashboard = {
|
||||
activeRooms: number;
|
||||
roomsAvailableNow: number;
|
||||
pendingBookings: number;
|
||||
todayBookings: number;
|
||||
upcomingExternalMeetings: number;
|
||||
giftCatalogCount: number;
|
||||
totalGiftStock: number;
|
||||
pendingGiftIssues: number;
|
||||
giftsIssuedThisMonth: number;
|
||||
giftsIssuedThisMonthQty: number;
|
||||
};
|
||||
|
||||
type ReportRow = {
|
||||
@@ -156,10 +162,19 @@ type ReportRow = {
|
||||
pending: number;
|
||||
rejected: number;
|
||||
}>;
|
||||
externalMeetings: {
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
};
|
||||
};
|
||||
|
||||
type TabKey =
|
||||
| "dashboard"
|
||||
| "rooms"
|
||||
| "bookings"
|
||||
| "external"
|
||||
| "gifts"
|
||||
@@ -167,6 +182,21 @@ type TabKey =
|
||||
| "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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -182,6 +212,36 @@ 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",
|
||||
@@ -199,7 +259,10 @@ export default function ProtocolPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState<TabKey>("dashboard");
|
||||
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);
|
||||
|
||||
@@ -290,9 +353,10 @@ export default function ProtocolPage() {
|
||||
edit?: Booking;
|
||||
}>({ open: false });
|
||||
const [rejectTarget, setRejectTarget] = useState<{
|
||||
kind: "booking" | "issue";
|
||||
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
|
||||
@@ -323,19 +387,33 @@ export default function ProtocolPage() {
|
||||
onError: onApiError,
|
||||
});
|
||||
const rejectMut = useMutation({
|
||||
mutationFn: (args: { kind: "booking" | "issue"; id: number }) =>
|
||||
apiJson(
|
||||
`${API}/protocol/${
|
||||
args.kind === "booking" ? "bookings" : "gift-issues"
|
||||
}/${args.id}/reject`,
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
),
|
||||
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");
|
||||
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`, {
|
||||
@@ -375,7 +453,8 @@ export default function ProtocolPage() {
|
||||
|
||||
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
|
||||
{ key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard },
|
||||
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: DoorOpen },
|
||||
{ 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 },
|
||||
@@ -387,6 +466,90 @@ export default function ProtocolPage() {
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full shrink-0",
|
||||
STATUS_PILL[b.status],
|
||||
)}
|
||||
>
|
||||
{t(`protocol.status.${b.status}`)}
|
||||
</span>
|
||||
</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">
|
||||
@@ -424,7 +587,7 @@ export default function ProtocolPage() {
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-5">
|
||||
{tab === "dashboard" && (
|
||||
<DashboardView data={dashboard.data} t={t} />
|
||||
<DashboardView data={dashboard.data} t={t} goTab={setTab} />
|
||||
)}
|
||||
|
||||
{tab === "bookings" && (
|
||||
@@ -433,108 +596,135 @@ export default function ProtocolPage() {
|
||||
<h2 className="font-semibold text-slate-700">
|
||||
{t("protocol.tabs.bookings")}
|
||||
</h2>
|
||||
{c?.canRequest && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setBookingDialog({ open: true })}
|
||||
>
|
||||
<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.bookings.new")}
|
||||
{t("protocol.rooms.new")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{(bookings.data ?? []).map((b) => (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(rooms.data ?? []).map((r) => (
|
||||
<div
|
||||
key={b.id}
|
||||
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">{b.title}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} –{" "}
|
||||
{fmtDateTime(b.endsAt)}
|
||||
<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>
|
||||
{b.requesterName && (
|
||||
<div className="text-xs text-slate-400">
|
||||
{t("protocol.bookings.requester")}: {b.requesterName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full shrink-0",
|
||||
STATUS_PILL[b.status],
|
||||
r.isActive
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{t(`protocol.status.${b.status}`)}
|
||||
{r.isActive
|
||||
? t("protocol.rooms.active")
|
||||
: t("protocol.rooms.inactive")}
|
||||
</span>
|
||||
</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>
|
||||
{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>
|
||||
))}
|
||||
{bookings.data?.length === 0 && (
|
||||
<EmptyState text={t("protocol.bookings.empty")} />
|
||||
{rooms.data?.length === 0 && (
|
||||
<EmptyState text={t("protocol.rooms.empty")} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -579,28 +769,57 @@ export default function ProtocolPage() {
|
||||
{t(`protocol.status.${m.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
{c?.canMutate && (
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<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>
|
||||
{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 && (
|
||||
@@ -617,16 +836,6 @@ export default function ProtocolPage() {
|
||||
{t("protocol.tabs.gifts")}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{c?.canManageRooms && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRoomDialog({ open: true })}
|
||||
>
|
||||
<DoorOpen size={16} className="me-1" />
|
||||
{t("protocol.rooms.new")}
|
||||
</Button>
|
||||
)}
|
||||
{c?.canMutate && (
|
||||
<Button size="sm" onClick={() => setGiftDialog({ open: true })}>
|
||||
<Plus size={16} className="me-1" />
|
||||
@@ -636,46 +845,6 @@ export default function ProtocolPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c?.canManageRooms && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-3">
|
||||
<div className="text-sm font-semibold text-slate-600 mb-2">
|
||||
{t("protocol.rooms.title")}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{(rooms.data ?? []).map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-slate-700">
|
||||
{nameOf(r.nameAr, r.nameEn)}
|
||||
{r.capacity ? ` · ${r.capacity}` : ""}
|
||||
{!r.isActive ? ` · ${t("protocol.rooms.inactive")}` : ""}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(gifts.data ?? []).map((g) => (
|
||||
<div
|
||||
@@ -960,40 +1129,66 @@ function EmptyState({ text }: { text: string }) {
|
||||
function DashboardView({
|
||||
data,
|
||||
t,
|
||||
goTab,
|
||||
}: {
|
||||
data: Dashboard | undefined;
|
||||
t: (k: string) => string;
|
||||
goTab: (k: TabKey) => void;
|
||||
}) {
|
||||
const cards = [
|
||||
{ label: t("protocol.dashboard.activeRooms"), value: data?.activeRooms },
|
||||
{ label: t("protocol.dashboard.todayBookings"), value: data?.todayBookings },
|
||||
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) => (
|
||||
<div
|
||||
<button
|
||||
key={c.label}
|
||||
className="bg-white rounded-2xl border border-slate-200 p-4"
|
||||
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>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1083,6 +1278,34 @@ function ReportsView({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1262,6 +1485,7 @@ function RoomDialog({
|
||||
edit?.capacity != null ? String(edit.capacity) : "",
|
||||
);
|
||||
const [location, setLocation] = useState(edit?.location ?? "");
|
||||
const [isActive, setIsActive] = useState(edit?.isActive ?? true);
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -1270,6 +1494,7 @@ function RoomDialog({
|
||||
nameEn,
|
||||
capacity: capacity ? Number(capacity) : null,
|
||||
location: location || null,
|
||||
isActive,
|
||||
};
|
||||
return edit
|
||||
? apiJson(`${API}/protocol/rooms/${edit.id}`, {
|
||||
@@ -1315,6 +1540,15 @@ function RoomDialog({
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1337,21 +1571,23 @@ function ExternalDialog({
|
||||
const [startsAt, setStartsAt] = useState(
|
||||
edit ? toLocalInput(edit.startsAt) : "",
|
||||
);
|
||||
const [status, setStatus] = useState<ExternalMeeting["status"]>(
|
||||
edit?.status ?? "scheduled",
|
||||
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 = {
|
||||
const body: Record<string, unknown> = {
|
||||
title,
|
||||
partyName: partyName || null,
|
||||
location: location || null,
|
||||
startsAt: fromLocalInput(startsAt),
|
||||
status,
|
||||
notes: notes || null,
|
||||
};
|
||||
if (edit && status) body.status = status;
|
||||
return edit
|
||||
? apiJson(`${API}/protocol/external-meetings/${edit.id}`, {
|
||||
method: "PATCH",
|
||||
@@ -1396,28 +1632,29 @@ function ExternalDialog({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("protocol.statusLabel")}</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as ExternalMeeting["status"])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">
|
||||
{t("protocol.status.scheduled")}
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
{t("protocol.status.completed")}
|
||||
</SelectItem>
|
||||
<SelectItem value="cancelled">
|
||||
{t("protocol.status.cancelled")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user