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

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:
soroush.asadi
2026-06-21 09:56:14 +03:30
parent 97bd63015f
commit fb6a20eaa1
8 changed files with 386 additions and 4 deletions
+20 -1
View File
@@ -329,7 +329,23 @@
"configurePrinters": "فتح إعدادات الطابعة", "configurePrinters": "فتح إعدادات الطابعة",
"posDeviceSection": "جهاز نقطة البيع (بطاقة)", "posDeviceSection": "جهاز نقطة البيع (بطاقة)",
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.", "posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
"posDeviceIp": "عنوان IP لجهاز نقطة البيع" "posDeviceIp": "عنوان IP لجهاز نقطة البيع",
"testSent": "تم إرسال الاختبار إلى الطابعة.",
"stations": {
"title": "محطات طباعة المطبخ والبار",
"subtitle": "امنح كل قسم تحضير طابعته الخاصة ووجّه فئات القائمة إليها.",
"help": "أنشئ محطة (مثل المطبخ أو البار) بطابعتها الخاصة، ثم من «القائمة» اختر محطة الطباعة لكل فئة — الطعام ← المطبخ، المشروبات ← البار. أصناف الفئات بدون محطة تُطبع على طابعة مطبخ الفرع. أما فاتورة العميل فتُطبع دائمًا على طابعة الفواتير.",
"add": "إضافة محطة",
"name": "اسم المحطة",
"namePlaceholder": "مثل المطبخ، البار",
"printerIp": "IP الطابعة",
"noPrinter": "بدون طابعة — تُستخدم طابعة المطبخ",
"categoryCount": "{count} فئات",
"test": "اختبار",
"empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.",
"deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.",
"saveError": "تعذّر حفظ المحطة."
}
}, },
"receipt": { "receipt": {
"table": "الطاولة", "table": "الطاولة",
@@ -873,6 +889,8 @@
"newItem": "صنف جديد", "newItem": "صنف جديد",
"newCategory": "فئة جديدة", "newCategory": "فئة جديدة",
"editCategoryTitle": "تعديل الفئة", "editCategoryTitle": "تعديل الفئة",
"printStation": "محطة الطباعة",
"printStationNone": "طابعة المطبخ (افتراضي)",
"close": "إغلاق", "close": "إغلاق",
"saving": "جاري الحفظ…", "saving": "جاري الحفظ…",
"model3d": "نموذج ثلاثي الأبعاد", "model3d": "نموذج ثلاثي الأبعاد",
@@ -1203,6 +1221,7 @@
"shopNotifications": "الإشعارات والصوت", "shopNotifications": "الإشعارات والصوت",
"printer": "الطابعة", "printer": "الطابعة",
"printerSettings": "إعدادات الطابعة", "printerSettings": "إعدادات الطابعة",
"printerStations": "طابعات المطبخ والبار",
"printTest": "صفحة اختبار الطباعة", "printTest": "صفحة اختبار الطباعة",
"shopDiscover": "اكتشاف و AI", "shopDiscover": "اكتشاف و AI",
"team": "الفريق والموظفون", "team": "الفريق والموظفون",
+20 -1
View File
@@ -348,7 +348,23 @@
"configurePrinters": "Open printer settings", "configurePrinters": "Open printer settings",
"posDeviceSection": "Card POS terminal", "posDeviceSection": "Card POS terminal",
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.", "posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
"posDeviceIp": "POS device IP address" "posDeviceIp": "POS device IP address",
"testSent": "Test sent to the printer.",
"stations": {
"title": "Kitchen & bar print stations",
"subtitle": "Give each prep area its own printer and route menu categories to it.",
"help": "Create a station (e.g. Kitchen, Bar) with its own printer, then in Menu set each categorys print station — food → Kitchen, drinks → Bar. Items in a category with no station fall back to the branch kitchen printer. The customer receipt always prints to the receipt printer.",
"add": "Add station",
"name": "Station name",
"namePlaceholder": "e.g. Kitchen, Bar",
"printerIp": "Printer IP",
"noPrinter": "No printer — uses the kitchen printer",
"categoryCount": "{count} categories",
"test": "Test",
"empty": "No stations yet. Add Kitchen and Bar to print their items separately.",
"deleteConfirm": "Delete station “{name}”? Its categories will fall back to the kitchen printer.",
"saveError": "Failed to save the station."
}
}, },
"receipt": { "receipt": {
"table": "Table", "table": "Table",
@@ -907,6 +923,8 @@
"newItem": "New item", "newItem": "New item",
"newCategory": "New category", "newCategory": "New category",
"editCategoryTitle": "Edit category", "editCategoryTitle": "Edit category",
"printStation": "Print station",
"printStationNone": "Kitchen printer (default)",
"close": "Close", "close": "Close",
"saving": "Saving…", "saving": "Saving…",
"model3d": "3D model", "model3d": "3D model",
@@ -1275,6 +1293,7 @@
"shopNotifications": "Notifications & sound", "shopNotifications": "Notifications & sound",
"printer": "Printer", "printer": "Printer",
"printerSettings": "Printer settings", "printerSettings": "Printer settings",
"printerStations": "Kitchen & bar printers",
"printTest": "Print test page", "printTest": "Print test page",
"shopDiscover": "Discover & AI", "shopDiscover": "Discover & AI",
"team": "Team & Staff", "team": "Team & Staff",
+20 -1
View File
@@ -348,7 +348,23 @@
"configurePrinters": "رفتن به تنظیمات پرینتر", "configurePrinters": "رفتن به تنظیمات پرینتر",
"posDeviceSection": "دستگاه پوز (کارتخوان)", "posDeviceSection": "دستگاه پوز (کارتخوان)",
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).", "posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).",
"posDeviceIp": "آدرس IP دستگاه پوز" "posDeviceIp": "آدرس IP دستگاه پوز",
"testSent": "تست به پرینتر ارسال شد.",
"stations": {
"title": "ایستگاه‌های چاپ آشپزخانه و بار",
"subtitle": "برای هر بخش آماده‌سازی یک پرینتر جدا بگذارید و دسته‌های منو را به آن وصل کنید.",
"help": "یک ایستگاه (مثلاً آشپزخانه یا بار) با پرینتر مخصوص خودش بسازید، سپس در «منو» برای هر دسته ایستگاه چاپ را انتخاب کنید — غذا ← آشپزخانه، نوشیدنی ← بار. آیتم‌های دسته‌هایی که ایستگاه ندارند روی پرینتر آشپزخانهٔ شعبه چاپ می‌شوند. فاکتور مشتری همیشه روی پرینتر فاکتور چاپ می‌شود.",
"add": "افزودن ایستگاه",
"name": "نام ایستگاه",
"namePlaceholder": "مثلاً آشپزخانه، بار",
"printerIp": "آی‌پی پرینتر",
"noPrinter": "بدون پرینتر — از پرینتر آشپزخانه استفاده می‌شود",
"categoryCount": "{count} دسته",
"test": "تست",
"empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتم‌هایشان جدا چاپ شود.",
"deleteConfirm": "ایستگاه «{name}» حذف شود؟ دسته‌های آن به پرینتر آشپزخانه برمی‌گردند.",
"saveError": "ذخیرهٔ ایستگاه ناموفق بود."
}
}, },
"receipt": { "receipt": {
"table": "میز", "table": "میز",
@@ -907,6 +923,8 @@
"newItem": "آیتم جدید", "newItem": "آیتم جدید",
"newCategory": "دسته جدید", "newCategory": "دسته جدید",
"editCategoryTitle": "ویرایش دسته", "editCategoryTitle": "ویرایش دسته",
"printStation": "ایستگاه چاپ",
"printStationNone": "پرینتر آشپزخانه (پیش‌فرض)",
"close": "بستن", "close": "بستن",
"saving": "در حال ذخیره…", "saving": "در حال ذخیره…",
"model3d": "مدل سه‌بعدی", "model3d": "مدل سه‌بعدی",
@@ -1276,6 +1294,7 @@
"shopNotifications": "اعلان‌ها و صدا", "shopNotifications": "اعلان‌ها و صدا",
"printer": "پرینتر", "printer": "پرینتر",
"printerSettings": "تنظیمات پرینتر", "printerSettings": "تنظیمات پرینتر",
"printerStations": "پرینتر آشپزخانه و بار",
"printTest": "صفحه تست چاپ", "printTest": "صفحه تست چاپ",
"shopDiscover": "کشف و AI", "shopDiscover": "کشف و AI",
"team": "تیم و کارمندان", "team": "تیم و کارمندان",
@@ -13,6 +13,7 @@ import { CategoryMediaFields } from "@/components/menu/category-media-fields";
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker"; import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets"; import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { fetchKitchenStations } from "@/lib/api/kitchen-stations";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -57,6 +58,7 @@ interface MenuCategory {
iconStyle?: string; iconStyle?: string;
imageUrl?: string; imageUrl?: string;
isActive: boolean; isActive: boolean;
kitchenStationId?: string | null;
} }
interface MenuItem { interface MenuItem {
@@ -89,6 +91,7 @@ interface CatForm {
icon: string; icon: string;
iconPreset: CategoryIconSelection; iconPreset: CategoryIconSelection;
imageUrl: string; imageUrl: string;
kitchenStationId: string;
} }
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -118,6 +121,7 @@ const defaultCatForm: CatForm = {
icon: "", icon: "",
iconPreset: { iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE }, iconPreset: { iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE },
imageUrl: "", imageUrl: "",
kitchenStationId: "",
}; };
// ─── Toggle Switch ──────────────────────────────────────────────────────────── // ─── Toggle Switch ────────────────────────────────────────────────────────────
@@ -242,6 +246,12 @@ export function MenuAdminScreen() {
enabled: !!cafeId, enabled: !!cafeId,
}); });
const { data: stations = [] } = useQuery({
queryKey: ["kitchen-stations", cafeId],
queryFn: () => fetchKitchenStations(cafeId!),
enabled: !!cafeId,
});
const categoryNameById = useMemo( const categoryNameById = useMemo(
() => buildCategoryNameMap(categories), () => buildCategoryNameMap(categories),
[categories] [categories]
@@ -353,6 +363,7 @@ export function MenuAdminScreen() {
iconPresetId: catForm.iconPreset.iconPresetId, iconPresetId: catForm.iconPreset.iconPresetId,
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : null, iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : null,
imageUrl: catForm.imageUrl.trim() || null, imageUrl: catForm.imageUrl.trim() || null,
kitchenStationId: catForm.kitchenStationId || null,
}), }),
onSuccess: () => { onSuccess: () => {
setCatModalOpen(false); setCatModalOpen(false);
@@ -369,6 +380,7 @@ export function MenuAdminScreen() {
iconPresetId: catForm.iconPreset.iconPresetId ?? "", iconPresetId: catForm.iconPreset.iconPresetId ?? "",
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : "", iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : "",
imageUrl: mediaField(catForm.imageUrl), imageUrl: mediaField(catForm.imageUrl),
kitchenStationId: catForm.kitchenStationId || null,
}), }),
onSuccess: () => { onSuccess: () => {
setCatModalOpen(false); setCatModalOpen(false);
@@ -421,6 +433,7 @@ export function MenuAdminScreen() {
DEFAULT_CATEGORY_ICON_STYLE, DEFAULT_CATEGORY_ICON_STYLE,
}, },
imageUrl: cat.imageUrl ?? "", imageUrl: cat.imageUrl ?? "",
kitchenStationId: cat.kitchenStationId ?? "",
}); });
setCatModalOpen(true); 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"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingCategory ? ( {editingCategory ? (
<Can permission="DeleteMenuItem"> <Can permission="DeleteMenuItem">
@@ -12,6 +12,7 @@ import { CafePublicProfilePanel } from "@/components/discover/cafe-public-profil
import { SettingsShopPanel } from "@/components/settings/settings-shop-panel"; import { SettingsShopPanel } from "@/components/settings/settings-shop-panel";
import { SettingsTerminalsPanel } from "@/components/settings/settings-terminals-panel"; import { SettingsTerminalsPanel } from "@/components/settings/settings-terminals-panel";
import { SettingsPrinterPanel } from "@/components/settings/settings-printer-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 { SettingsPrintTestPanel } from "@/components/settings/settings-print-test-panel";
import { CustomRolesPanel } from "@/components/settings/custom-roles-panel"; import { CustomRolesPanel } from "@/components/settings/custom-roles-panel";
import { import {
@@ -27,6 +28,7 @@ const LEAF_PAGE_TITLE: Record<SettingsLeafId, string> = {
"shop-notifications": "nav.shopNotifications", "shop-notifications": "nav.shopNotifications",
"shop-discover": "nav.shopDiscover", "shop-discover": "nav.shopDiscover",
"printer-config": "nav.printerSettings", "printer-config": "nav.printerSettings",
"printer-stations": "nav.printerStations",
"print-test": "nav.printTest", "print-test": "nav.printTest",
"team-custom-roles": "nav.customRoles", "team-custom-roles": "nav.customRoles",
}; };
@@ -103,6 +105,10 @@ export function SettingsScreen() {
/> />
) : null} ) : null}
{activeLeaf === "printer-stations" ? (
<SettingsStationsPanel cafeId={cafeId} />
) : null}
{activeLeaf === "print-test" ? ( {activeLeaf === "print-test" ? (
<SettingsPrintTestPanel <SettingsPrintTestPanel
cafeId={cafeId} 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-notifications"
| "shop-discover" | "shop-discover"
| "printer-config" | "printer-config"
| "printer-stations"
| "print-test" | "print-test"
| "team-custom-roles"; | "team-custom-roles";
@@ -31,6 +32,7 @@ export const SETTINGS_NAV: SettingsNavGroup[] = [
labelKey: "nav.printer", labelKey: "nav.printer",
children: [ children: [
{ id: "printer-config", labelKey: "nav.printerSettings" }, { id: "printer-config", labelKey: "nav.printerSettings" },
{ id: "printer-stations", labelKey: "nav.printerStations" },
{ id: "print-test", labelKey: "nav.printTest" }, { id: "print-test", labelKey: "nav.printTest" },
], ],
}, },
@@ -46,7 +48,8 @@ export const SETTINGS_NAV: SettingsNavGroup[] = [
export const DEFAULT_SETTINGS_LEAF: SettingsLeafId = "shop-general"; export const DEFAULT_SETTINGS_LEAF: SettingsLeafId = "shop-general";
export function groupForLeaf(leaf: SettingsLeafId): SettingsGroupId { 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"; if (leaf === "team-custom-roles") return "team";
return "shop"; 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}`);
}