feat(print): separate kitchen & bar printers via print stations UI
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
The print engine already routed items to per-station printers (MenuCategory → KitchenStation.PrinterIp, falling back to the branch kitchen printer) and prints the customer receipt to the receipt printer — but there was no UI to set it up. This exposes it: - Settings → "Kitchen & bar printers": create/edit/delete print stations, each with its own printer IP/port, with a per-station test print (gated by ManageKitchenStations). - Menu category editor: a "Print station" dropdown to route each category to a station (food → Kitchen, drinks → Bar); no station = branch kitchen printer. Result: kitchen and bar tickets print on separate printers, while the customer factor/receipt keeps printing on the receipt printer. fa/en/ar strings added. No backend/migration changes — purely wiring the existing capability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||
import { fetchKitchenStations } from "@/lib/api/kitchen-stations";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -57,6 +58,7 @@ interface MenuCategory {
|
||||
iconStyle?: string;
|
||||
imageUrl?: string;
|
||||
isActive: boolean;
|
||||
kitchenStationId?: string | null;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@@ -89,6 +91,7 @@ interface CatForm {
|
||||
icon: string;
|
||||
iconPreset: CategoryIconSelection;
|
||||
imageUrl: string;
|
||||
kitchenStationId: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -118,6 +121,7 @@ const defaultCatForm: CatForm = {
|
||||
icon: "",
|
||||
iconPreset: { iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE },
|
||||
imageUrl: "",
|
||||
kitchenStationId: "",
|
||||
};
|
||||
|
||||
// ─── Toggle Switch ────────────────────────────────────────────────────────────
|
||||
@@ -242,6 +246,12 @@ export function MenuAdminScreen() {
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: stations = [] } = useQuery({
|
||||
queryKey: ["kitchen-stations", cafeId],
|
||||
queryFn: () => fetchKitchenStations(cafeId!),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const categoryNameById = useMemo(
|
||||
() => buildCategoryNameMap(categories),
|
||||
[categories]
|
||||
@@ -353,6 +363,7 @@ export function MenuAdminScreen() {
|
||||
iconPresetId: catForm.iconPreset.iconPresetId,
|
||||
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : null,
|
||||
imageUrl: catForm.imageUrl.trim() || null,
|
||||
kitchenStationId: catForm.kitchenStationId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCatModalOpen(false);
|
||||
@@ -369,6 +380,7 @@ export function MenuAdminScreen() {
|
||||
iconPresetId: catForm.iconPreset.iconPresetId ?? "",
|
||||
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : "",
|
||||
imageUrl: mediaField(catForm.imageUrl),
|
||||
kitchenStationId: catForm.kitchenStationId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCatModalOpen(false);
|
||||
@@ -421,6 +433,7 @@ export function MenuAdminScreen() {
|
||||
DEFAULT_CATEGORY_ICON_STYLE,
|
||||
},
|
||||
imageUrl: cat.imageUrl ?? "",
|
||||
kitchenStationId: cat.kitchenStationId ?? "",
|
||||
});
|
||||
setCatModalOpen(true);
|
||||
};
|
||||
@@ -1012,6 +1025,26 @@ export function MenuAdminScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
{stations.length > 0 ? (
|
||||
<LabeledField label={t("printStation")} htmlFor="modal-cat-station">
|
||||
<select
|
||||
id="modal-cat-station"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={catForm.kitchenStationId}
|
||||
onChange={(e) =>
|
||||
setCatForm((f) => ({ ...f, kitchenStationId: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">{t("printStationNone")}</option>
|
||||
{stations.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
|
||||
{editingCategory ? (
|
||||
<Can permission="DeleteMenuItem">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CafePublicProfilePanel } from "@/components/discover/cafe-public-profil
|
||||
import { SettingsShopPanel } from "@/components/settings/settings-shop-panel";
|
||||
import { SettingsTerminalsPanel } from "@/components/settings/settings-terminals-panel";
|
||||
import { SettingsPrinterPanel } from "@/components/settings/settings-printer-panel";
|
||||
import { SettingsStationsPanel } from "@/components/settings/settings-stations-panel";
|
||||
import { SettingsPrintTestPanel } from "@/components/settings/settings-print-test-panel";
|
||||
import { CustomRolesPanel } from "@/components/settings/custom-roles-panel";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ const LEAF_PAGE_TITLE: Record<SettingsLeafId, string> = {
|
||||
"shop-notifications": "nav.shopNotifications",
|
||||
"shop-discover": "nav.shopDiscover",
|
||||
"printer-config": "nav.printerSettings",
|
||||
"printer-stations": "nav.printerStations",
|
||||
"print-test": "nav.printTest",
|
||||
"team-custom-roles": "nav.customRoles",
|
||||
};
|
||||
@@ -103,6 +105,10 @@ export function SettingsScreen() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeLeaf === "printer-stations" ? (
|
||||
<SettingsStationsPanel cafeId={cafeId} />
|
||||
) : null}
|
||||
|
||||
{activeLeaf === "print-test" ? (
|
||||
<SettingsPrintTestPanel
|
||||
cafeId={cafeId}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Pencil, Trash2, Printer, Utensils } from "lucide-react";
|
||||
import {
|
||||
fetchKitchenStations,
|
||||
createKitchenStation,
|
||||
updateKitchenStation,
|
||||
deleteKitchenStation,
|
||||
type KitchenStation,
|
||||
} from "@/lib/api/kitchen-stations";
|
||||
import { testPrinter, printErrorMessage } from "@/lib/api/print";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Can } from "@/components/auth/can";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type Editing = KitchenStation | "new" | null;
|
||||
|
||||
function StationForm({
|
||||
cafeId,
|
||||
station,
|
||||
onDone,
|
||||
}: {
|
||||
cafeId: string;
|
||||
station?: KitchenStation;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const t = useTranslations("print");
|
||||
const tCommon = useTranslations("common");
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(station?.name ?? "");
|
||||
const [ip, setIp] = useState(station?.printerIp ?? "");
|
||||
const [port, setPort] = useState(String(station?.printerPort ?? 9100));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
printerIp: ip.trim() || null,
|
||||
printerPort: parseInt(port, 10) || 9100,
|
||||
};
|
||||
return station
|
||||
? updateKitchenStation(cafeId, station.id, body)
|
||||
: createKitchenStation(cafeId, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["kitchen-stations", cafeId] });
|
||||
onDone();
|
||||
},
|
||||
onError: () => notify.error(t("stations.saveError")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border/80 bg-muted/20 p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<LabeledField label={t("stations.name")} htmlFor="station-name">
|
||||
<Input
|
||||
id="station-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("stations.namePlaceholder")}
|
||||
autoFocus
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("stations.printerIp")} htmlFor="station-ip">
|
||||
<Input
|
||||
id="station-ip"
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
placeholder="192.168.1.102"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="station-port">
|
||||
<Input
|
||||
id="station-port"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onDone} disabled={save.isPending}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending || !name.trim()}
|
||||
>
|
||||
{save.isPending ? tCommon("saving") : tCommon("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsStationsPanel({ cafeId }: { cafeId: string }) {
|
||||
const t = useTranslations("print");
|
||||
const tCommon = useTranslations("common");
|
||||
const confirm = useConfirm();
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState<Editing>(null);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: stations = [], isLoading } = useQuery({
|
||||
queryKey: ["kitchen-stations", cafeId],
|
||||
queryFn: () => fetchKitchenStations(cafeId),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => deleteKitchenStation(cafeId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["kitchen-stations", cafeId] }),
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: (s: KitchenStation) => testPrinter(cafeId, s.printerIp!, s.printerPort),
|
||||
onSuccess: () => setTestMsg(t("testSent")),
|
||||
onError: (err) => setTestMsg(printErrorMessage(err, t)),
|
||||
});
|
||||
|
||||
const handleDelete = async (s: KitchenStation) => {
|
||||
const ok = await confirm({
|
||||
description: t("stations.deleteConfirm", { name: s.name }),
|
||||
variant: "destructive",
|
||||
confirmLabel: tCommon("confirm"),
|
||||
});
|
||||
if (ok) remove.mutate(s.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0 px-6 pb-4 pt-6">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<Utensils className="size-4 text-[#0F6E56]" />
|
||||
{t("stations.title")}
|
||||
</CardTitle>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t("stations.subtitle")}</p>
|
||||
</div>
|
||||
{editing === null ? (
|
||||
<Can permission="ManageKitchenStations">
|
||||
<Button size="sm" className="shrink-0 gap-1.5" onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" />
|
||||
{t("stations.add")}
|
||||
</Button>
|
||||
</Can>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-6 pb-6 pt-0">
|
||||
<p className="rounded-lg border border-border/80 bg-muted/30 px-4 py-2.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t("stations.help")}
|
||||
</p>
|
||||
|
||||
{testMsg ? (
|
||||
<p className="rounded-lg border border-border/80 bg-muted/40 px-4 py-2.5 text-xs">
|
||||
{testMsg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{editing === "new" ? (
|
||||
<StationForm cafeId={cafeId} onDone={() => setEditing(null)} />
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
) : stations.length === 0 && editing === null ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t("stations.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{stations.map((s) =>
|
||||
editing !== "new" && typeof editing === "object" && editing?.id === s.id ? (
|
||||
<li key={s.id}>
|
||||
<StationForm cafeId={cafeId} station={s} onDone={() => setEditing(null)} />
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-border/80 p-3"
|
||||
>
|
||||
<Printer className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{s.name}</p>
|
||||
<p className="text-xs text-muted-foreground" dir="ltr">
|
||||
{s.printerIp ? `${s.printerIp}:${s.printerPort}` : t("stations.noPrinter")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{t("stations.categoryCount", { count: s.categoryCount })}
|
||||
</span>
|
||||
{s.printerIp ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={test.isPending}
|
||||
onClick={() => {
|
||||
setTestMsg(null);
|
||||
test.mutate(s);
|
||||
}}
|
||||
>
|
||||
{t("stations.test")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Can permission="ManageKitchenStations">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditing(s)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission="ManageKitchenStations">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(s)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</Can>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type SettingsLeafId =
|
||||
| "shop-notifications"
|
||||
| "shop-discover"
|
||||
| "printer-config"
|
||||
| "printer-stations"
|
||||
| "print-test"
|
||||
| "team-custom-roles";
|
||||
|
||||
@@ -31,6 +32,7 @@ export const SETTINGS_NAV: SettingsNavGroup[] = [
|
||||
labelKey: "nav.printer",
|
||||
children: [
|
||||
{ id: "printer-config", labelKey: "nav.printerSettings" },
|
||||
{ id: "printer-stations", labelKey: "nav.printerStations" },
|
||||
{ id: "print-test", labelKey: "nav.printTest" },
|
||||
],
|
||||
},
|
||||
@@ -46,7 +48,8 @@ export const SETTINGS_NAV: SettingsNavGroup[] = [
|
||||
export const DEFAULT_SETTINGS_LEAF: SettingsLeafId = "shop-general";
|
||||
|
||||
export function groupForLeaf(leaf: SettingsLeafId): SettingsGroupId {
|
||||
if (leaf === "printer-config" || leaf === "print-test") return "printer";
|
||||
if (leaf === "printer-config" || leaf === "printer-stations" || leaf === "print-test")
|
||||
return "printer";
|
||||
if (leaf === "team-custom-roles") return "team";
|
||||
return "shop";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* A print/prep station (e.g. Kitchen, Bar). Each station can have its own
|
||||
* thermal printer; menu categories are routed to a station so their items print
|
||||
* on that station's printer. Items in categories with no station fall back to the
|
||||
* branch kitchen printer. See backend KitchenStation + PrintKitchenTicketAsync.
|
||||
*/
|
||||
export interface KitchenStation {
|
||||
id: string;
|
||||
branchId?: string | null;
|
||||
name: string;
|
||||
printerIp?: string | null;
|
||||
printerPort: number;
|
||||
sortOrder: number;
|
||||
categoryCount: number;
|
||||
}
|
||||
|
||||
export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]> {
|
||||
return apiGet<KitchenStation[]>(`/api/cafes/${cafeId}/kitchen-stations`);
|
||||
}
|
||||
|
||||
export function createKitchenStation(
|
||||
cafeId: string,
|
||||
body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number }
|
||||
): Promise<KitchenStation> {
|
||||
return apiPost<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations`, body);
|
||||
}
|
||||
|
||||
export function updateKitchenStation(
|
||||
cafeId: string,
|
||||
id: string,
|
||||
body: { name?: string; printerIp?: string | null; printerPort?: number | null; sortOrder?: number | null }
|
||||
): Promise<KitchenStation> {
|
||||
return apiPatch<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations/${id}`, body);
|
||||
}
|
||||
|
||||
export function deleteKitchenStation(cafeId: string, id: string): Promise<void> {
|
||||
return apiDelete(`/api/cafes/${cafeId}/kitchen-stations/${id}`);
|
||||
}
|
||||
Reference in New Issue
Block a user