024a455ab3
Dashboard & API bug fixes for owner-reported breakage: - MenuController validators (PosValidators): NameEn was required but the dashboard sends null when blank, so every manual menu-item create failed and category create failed 100% (the form never sends nameEn). Now optional. - DemoDataBanner: only showed when a cafe was exactly empty, so showcase-seeded cafes (2-3 cats / 3-5 items) could never trigger the one-click seed. Widened gate to sparse menus (<5 cats && <10 items) and added a clear "nothing to add" message when already populated. - client.ts: added one-time JWT refresh-and-retry on 401 (shared in-flight promise) before bouncing to /login. Expired access tokens silently broke ticket list, add-table, and other reads. - Surface API errors as toasts on menu + table mutations (were swallowed silently, so failures looked like "nothing happens"). - Admin blog editor: saving an edit dropped IsPublished (defaulted false, silently unpublishing the post on every save); now persisted with a toggle. Also hoisted the inner Field component to module scope - it was remounting every input on each keystroke and dropping focus. - Admin integrations: replaced raw radio gateway selector with a styled RadioDot matching the iOS toggles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
557 lines
20 KiB
TypeScript
557 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useTranslations } from "next-intl";
|
|
import * as signalR from "@microsoft/signalr";
|
|
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
|
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
|
import { notify } from "@/lib/notify";
|
|
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|
import {
|
|
apiGet,
|
|
apiGetBlob,
|
|
apiPatch,
|
|
apiPost,
|
|
ApiClientError,
|
|
openBlobInNewTab,
|
|
resolveMediaUrl,
|
|
} from "@/lib/api/client";
|
|
import {
|
|
createBranchTable,
|
|
deleteBranchTable,
|
|
fetchCafeTableBoard,
|
|
patchBranchTable,
|
|
setTableCleaning,
|
|
} from "@/lib/api/branch-tables";
|
|
import { useBranchStore } from "@/lib/stores/branch.store";
|
|
import type { TableBoardItem } from "@/lib/api/types";
|
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
|
import { formatCurrency, formatNumber } from "@/lib/format";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { LabeledField } from "@/components/ui/labeled-field";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { useConfirm } from "@/components/providers/confirm-provider";
|
|
import { Alert } from "@/components/ui/alert";
|
|
|
|
const statusStyles: Record<TableBoardItem["status"], string> = {
|
|
Free: "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/30",
|
|
Busy: "bg-blue-50 text-[#0C447C] border-blue-200",
|
|
Reserved: "bg-amber-50 text-[#BA7517] border-amber-200",
|
|
Cleaning: "bg-slate-100 text-slate-600 border-slate-300",
|
|
};
|
|
|
|
export function TablesScreen() {
|
|
const t = useTranslations("tables");
|
|
const tCommon = useTranslations("common");
|
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
|
const role = useAuthStore((s) => s.user?.role);
|
|
const branchId = useBranchStore((s) => s.branchId);
|
|
const queryClient = useQueryClient();
|
|
const confirmDialog = useConfirm();
|
|
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [number, setNumber] = useState("");
|
|
const [capacity, setCapacity] = useState("4");
|
|
const [floor, setFloor] = useState("");
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editNumber, setEditNumber] = useState("");
|
|
const [editCapacity, setEditCapacity] = useState("");
|
|
const [editFloor, setEditFloor] = useState("");
|
|
const [editImageUrl, setEditImageUrl] = useState("");
|
|
const [editVideoUrl, setEditVideoUrl] = useState("");
|
|
|
|
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
|
|
const canManage = role === "Owner" || role === "Manager";
|
|
|
|
const { data: tables = [], isLoading } = useQuery({
|
|
queryKey: ["tables-board", cafeId, branchId, "manage"],
|
|
queryFn: () => fetchCafeTableBoard(cafeId!, branchId),
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
const refresh = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
|
}, [queryClient, cafeId]);
|
|
|
|
useEffect(() => {
|
|
if (!cafeId) return;
|
|
const token =
|
|
typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null;
|
|
const connection = new signalR.HubConnectionBuilder()
|
|
.withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
|
|
.withAutomaticReconnect()
|
|
.build();
|
|
connection
|
|
.start()
|
|
.then(() => connection.invoke("JoinCafe", cafeId))
|
|
.catch(() => undefined);
|
|
connection.on("TableStatusChanged", () => refresh());
|
|
connection.on("OrderCreated", () => refresh());
|
|
connection.on("OrderStatusChanged", () => refresh());
|
|
return () => {
|
|
void connection.stop();
|
|
};
|
|
}, [cafeId, apiBase, refresh]);
|
|
|
|
const createTable = useMutation({
|
|
mutationFn: async () => {
|
|
const cap = parseInt(capacity, 10);
|
|
if (branchId) {
|
|
await createBranchTable(cafeId!, branchId, {
|
|
number,
|
|
capacity: cap,
|
|
floor: floor || null,
|
|
});
|
|
return;
|
|
}
|
|
await apiPost(`/api/cafes/${cafeId}/tables`, {
|
|
number,
|
|
capacity: cap,
|
|
floor: floor || null,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
setShowForm(false);
|
|
setNumber("");
|
|
setFloor("");
|
|
setActionMessage(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => {
|
|
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
|
setActionMessage(msg);
|
|
notify.error(msg);
|
|
},
|
|
});
|
|
|
|
const setCleaning = useMutation({
|
|
mutationFn: ({
|
|
table,
|
|
isCleaning,
|
|
}: {
|
|
table: TableBoardItem;
|
|
isCleaning: boolean;
|
|
}) => setTableCleaning(cafeId!, table.id, isCleaning, branchId ?? table.branchId),
|
|
onSuccess: () => {
|
|
setActionMessage(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => {
|
|
setActionMessage(err instanceof ApiClientError ? err.message : t("cleaningError"));
|
|
},
|
|
});
|
|
|
|
const deleteTable = useMutation({
|
|
mutationFn: (table: TableBoardItem) =>
|
|
deleteBranchTable(cafeId!, table.branchId, table.id),
|
|
onSuccess: () => {
|
|
setActionMessage(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => {
|
|
if (err instanceof ApiClientError && err.code === "TABLE_HAS_OPEN_ORDER") {
|
|
setActionMessage(t("tableHasOpenOrder"));
|
|
return;
|
|
}
|
|
setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError"));
|
|
},
|
|
});
|
|
|
|
const patchTable = useMutation({
|
|
mutationFn: ({
|
|
table,
|
|
body,
|
|
}: {
|
|
table: TableBoardItem;
|
|
body: {
|
|
number?: string;
|
|
capacity?: number;
|
|
floor?: string | null;
|
|
imageUrl?: string;
|
|
videoUrl?: string;
|
|
isActive?: boolean;
|
|
};
|
|
}) => {
|
|
if (branchId) {
|
|
return patchBranchTable(cafeId!, branchId, table.id, body);
|
|
}
|
|
return apiPatch(`/api/cafes/${cafeId}/tables/${table.id}`, body);
|
|
},
|
|
onSuccess: () => {
|
|
setEditingId(null);
|
|
setActionMessage(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => {
|
|
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
|
setActionMessage(msg);
|
|
notify.error(msg);
|
|
},
|
|
});
|
|
|
|
const startEdit = (table: TableBoardItem) => {
|
|
setEditingId(table.id);
|
|
setEditNumber(table.number);
|
|
setEditCapacity(String(table.capacity));
|
|
setEditFloor(table.floor ?? "");
|
|
setEditImageUrl(table.imageUrl ?? "");
|
|
setEditVideoUrl(table.videoUrl ?? "");
|
|
};
|
|
|
|
const mediaField = (url: string) => (url.trim() === "" ? "" : url);
|
|
|
|
const openQr = async (tableId: string) => {
|
|
try {
|
|
const blob = await apiGetBlob(`/api/cafes/${cafeId}/tables/${tableId}/qr`);
|
|
openBlobInNewTab(blob);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
const copyQrUrl = async (url: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
notify.success(t("qrUrlCopied"));
|
|
} catch {
|
|
notify.error(t("qrUrlCopyFailed"));
|
|
}
|
|
};
|
|
|
|
if (!cafeId) return null;
|
|
|
|
return (
|
|
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
|
|
<PageHeader
|
|
title={t("title")}
|
|
subtitle={t("floorPlan")}
|
|
action={
|
|
canManage ? (
|
|
<Button
|
|
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
|
|
onClick={() => setShowForm((v) => !v)}
|
|
>
|
|
<Plus className="ms-2 h-4 w-4" />
|
|
{t("addTable")}
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
{showForm && (
|
|
<Card className="rounded-xl border border-border/80 bg-card">
|
|
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
|
|
<LabeledField label={t("number")} htmlFor="table-number">
|
|
<Input
|
|
id="table-number"
|
|
value={number}
|
|
onChange={(e) => setNumber(e.target.value)}
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("capacity")} htmlFor="table-capacity">
|
|
<Input
|
|
id="table-capacity"
|
|
type="number"
|
|
value={capacity}
|
|
onChange={(e) => setCapacity(e.target.value)}
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("floor")} htmlFor="table-floor">
|
|
<Input
|
|
id="table-floor"
|
|
value={floor}
|
|
onChange={(e) => setFloor(e.target.value)}
|
|
/>
|
|
</LabeledField>
|
|
<div className="flex gap-2 sm:col-span-3">
|
|
<Button
|
|
disabled={!number.trim() || createTable.isPending}
|
|
onClick={() => createTable.mutate()}
|
|
>
|
|
{tCommon("save")}
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setShowForm(false)}>
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{editingId && (
|
|
<Card className="rounded-xl border border-[#0F6E56]/30 bg-card">
|
|
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
|
|
<p className="text-sm font-medium sm:col-span-3">{t("editTable")}</p>
|
|
<LabeledField label={t("number")} htmlFor="edit-table-number">
|
|
<Input
|
|
id="edit-table-number"
|
|
value={editNumber}
|
|
onChange={(e) => setEditNumber(e.target.value)}
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("capacity")} htmlFor="edit-table-capacity">
|
|
<Input
|
|
id="edit-table-capacity"
|
|
type="number"
|
|
value={editCapacity}
|
|
onChange={(e) => setEditCapacity(e.target.value)}
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("floor")} htmlFor="edit-table-floor">
|
|
<Input
|
|
id="edit-table-floor"
|
|
value={editFloor}
|
|
onChange={(e) => setEditFloor(e.target.value)}
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("media")} className="sm:col-span-3">
|
|
<MediaPairUpload
|
|
cafeId={cafeId}
|
|
kind="table"
|
|
imageUrl={editImageUrl}
|
|
videoUrl={editVideoUrl}
|
|
onImageChange={(url) => setEditImageUrl(url ?? "")}
|
|
onVideoChange={(url) => setEditVideoUrl(url ?? "")}
|
|
/>
|
|
</LabeledField>
|
|
<div className="flex gap-2 sm:col-span-3">
|
|
<Button
|
|
disabled={!editNumber.trim() || patchTable.isPending}
|
|
onClick={() => {
|
|
const editing = tables.find((tbl) => tbl.id === editingId);
|
|
if (!editing) return;
|
|
patchTable.mutate({
|
|
table: editing,
|
|
body: {
|
|
number: editNumber,
|
|
capacity: parseInt(editCapacity, 10),
|
|
floor: editFloor || null,
|
|
imageUrl: mediaField(editImageUrl),
|
|
videoUrl: mediaField(editVideoUrl),
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
{t("saveTable")}
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setEditingId(null)}>
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
|
) : tables.length === 0 ? (
|
|
<div className="space-y-3">
|
|
<DemoDataBanner
|
|
invalidateKeys={[
|
|
["tables-board", cafeId],
|
|
["menu-categories", cafeId],
|
|
["menu-items-all", cafeId],
|
|
["inventory", cafeId],
|
|
]}
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{branchId ? t("emptyBranch") : t("empty")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{actionMessage ? (
|
|
<Alert variant="info" onDismiss={() => setActionMessage(null)}>
|
|
{actionMessage}
|
|
</Alert>
|
|
) : null}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{tables.map((table) => (
|
|
<Card
|
|
key={table.id}
|
|
className={cn(
|
|
"rounded-xl border border-border/80 bg-card transition-colors hover:border-[#0F6E56]"
|
|
)}
|
|
>
|
|
<CardContent className="space-y-3 pt-6">
|
|
{(table.imageUrl || table.videoUrl) && (
|
|
<div className="relative aspect-[16/9] overflow-hidden rounded-lg bg-muted">
|
|
{resolveMediaUrl(table.imageUrl) ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveMediaUrl(table.imageUrl)!}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="h-full min-h-[80px] bg-muted/50" />
|
|
)}
|
|
{table.videoUrl ? (
|
|
<span className="absolute bottom-2 start-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white">
|
|
<Video className="h-3 w-3" />
|
|
Video
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<p className="text-base font-medium">
|
|
{t("tableLabel", { number: table.number })}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{t("meta", {
|
|
capacity: formatNumber(table.capacity),
|
|
floor: table.floor ?? "—",
|
|
})}
|
|
</p>
|
|
{!table.isActive ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="mt-1 text-[10px] text-muted-foreground"
|
|
>
|
|
{t("inactive")}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<Badge className={cn("border", statusStyles[table.status])}>
|
|
<span className="me-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
|
{t(`status.${table.status}`)}
|
|
</Badge>
|
|
</div>
|
|
|
|
{table.currentOrder && table.status === "Busy" && (
|
|
<p className="text-sm text-[#0F6E56]">
|
|
{table.currentOrder.guestLabel ?? t("activeOrder")} —{" "}
|
|
{formatCurrency(table.currentOrder.total)}
|
|
</p>
|
|
)}
|
|
{table.currentOrder && table.status === "Reserved" && (
|
|
<p className="text-sm text-[#BA7517]">
|
|
{table.currentOrder.guestLabel} — {t("reserved")}
|
|
</p>
|
|
)}
|
|
|
|
{table.qrCodeUrl ? (
|
|
<div className="rounded-lg border border-border/80 bg-muted/20 px-2.5 py-2">
|
|
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
|
{t("qrMenuUrl")}
|
|
</p>
|
|
<a
|
|
href={table.qrCodeUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-1 block break-all text-xs font-medium text-[#0F6E56] hover:underline"
|
|
dir="ltr"
|
|
>
|
|
{table.qrCodeUrl}
|
|
</a>
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" asChild>
|
|
<a href={table.qrCodeUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="h-3 w-3" />
|
|
{t("openQrUrl")}
|
|
</a>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 gap-1 px-2 text-xs"
|
|
type="button"
|
|
onClick={() => void copyQrUrl(table.qrCodeUrl)}
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
{t("copyQrUrl")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{canManage ? (
|
|
<Button size="sm" variant="outline" onClick={() => startEdit(table)}>
|
|
<Pencil className="ms-1 h-3.5 w-3.5" />
|
|
{t("edit")}
|
|
</Button>
|
|
) : null}
|
|
<Button size="sm" variant="outline" onClick={() => openQr(table.id)}>
|
|
<QrCode className="ms-1 h-3.5 w-3.5" />
|
|
{t("printQr")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setCleaning.mutate({
|
|
table,
|
|
isCleaning: !(table.isCleaning ?? table.status === "Cleaning"),
|
|
})
|
|
}
|
|
>
|
|
{(table.isCleaning ?? table.status === "Cleaning")
|
|
? t("markReady")
|
|
: t("markCleaning")}
|
|
</Button>
|
|
{canManage ? (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-[#A32D2D]"
|
|
disabled={deleteTable.isPending}
|
|
onClick={async () => {
|
|
const ok = await confirmDialog({
|
|
description: t("deleteTableConfirm"),
|
|
variant: "destructive",
|
|
confirmLabel: tCommon("delete"),
|
|
});
|
|
if (!ok) return;
|
|
deleteTable.mutate(table);
|
|
}}
|
|
>
|
|
<Trash2 className="ms-1 h-3.5 w-3.5" />
|
|
{t("deleteTable")}
|
|
</Button>
|
|
) : null}
|
|
{canManage && table.isActive ? (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-[#A32D2D]"
|
|
onClick={() =>
|
|
patchTable.mutate({ table, body: { isActive: false } })
|
|
}
|
|
>
|
|
{t("deactivate")}
|
|
</Button>
|
|
) : canManage && !table.isActive ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
patchTable.mutate({ table, body: { isActive: true } })
|
|
}
|
|
>
|
|
{t("reactivate")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">{t("reprintHint")}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|