From fb6a20eaa14c45279b56f95158cde5e5ba5262d8 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 21 Jun 2026 09:56:14 +0330 Subject: [PATCH] feat(print): separate kitchen & bar printers via print stations UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/dashboard/messages/ar.json | 21 +- web/dashboard/messages/en.json | 21 +- web/dashboard/messages/fa.json | 21 +- .../src/components/menu/menu-admin-screen.tsx | 33 +++ .../components/settings/settings-screen.tsx | 6 + .../settings/settings-stations-panel.tsx | 243 ++++++++++++++++++ .../src/components/settings/settings-types.ts | 5 +- web/dashboard/src/lib/api/kitchen-stations.ts | 40 +++ 8 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 web/dashboard/src/components/settings/settings-stations-panel.tsx create mode 100644 web/dashboard/src/lib/api/kitchen-stations.ts diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index b9276b3..b80c392 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -329,7 +329,23 @@ "configurePrinters": "فتح إعدادات الطابعة", "posDeviceSection": "جهاز نقطة البيع (بطاقة)", "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": { "table": "الطاولة", @@ -873,6 +889,8 @@ "newItem": "صنف جديد", "newCategory": "فئة جديدة", "editCategoryTitle": "تعديل الفئة", + "printStation": "محطة الطباعة", + "printStationNone": "طابعة المطبخ (افتراضي)", "close": "إغلاق", "saving": "جاري الحفظ…", "model3d": "نموذج ثلاثي الأبعاد", @@ -1203,6 +1221,7 @@ "shopNotifications": "الإشعارات والصوت", "printer": "الطابعة", "printerSettings": "إعدادات الطابعة", + "printerStations": "طابعات المطبخ والبار", "printTest": "صفحة اختبار الطباعة", "shopDiscover": "اكتشاف و AI", "team": "الفريق والموظفون", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 3ccd5aa..aac6b77 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -348,7 +348,23 @@ "configurePrinters": "Open printer settings", "posDeviceSection": "Card POS terminal", "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 category’s 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": { "table": "Table", @@ -907,6 +923,8 @@ "newItem": "New item", "newCategory": "New category", "editCategoryTitle": "Edit category", + "printStation": "Print station", + "printStationNone": "Kitchen printer (default)", "close": "Close", "saving": "Saving…", "model3d": "3D model", @@ -1275,6 +1293,7 @@ "shopNotifications": "Notifications & sound", "printer": "Printer", "printerSettings": "Printer settings", + "printerStations": "Kitchen & bar printers", "printTest": "Print test page", "shopDiscover": "Discover & AI", "team": "Team & Staff", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index aab94db..724a771 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -348,7 +348,23 @@ "configurePrinters": "رفتن به تنظیمات پرینتر", "posDeviceSection": "دستگاه پوز (کارتخوان)", "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": { "table": "میز", @@ -907,6 +923,8 @@ "newItem": "آیتم جدید", "newCategory": "دسته جدید", "editCategoryTitle": "ویرایش دسته", + "printStation": "ایستگاه چاپ", + "printStationNone": "پرینتر آشپزخانه (پیش‌فرض)", "close": "بستن", "saving": "در حال ذخیره…", "model3d": "مدل سه‌بعدی", @@ -1276,6 +1294,7 @@ "shopNotifications": "اعلان‌ها و صدا", "printer": "پرینتر", "printerSettings": "تنظیمات پرینتر", + "printerStations": "پرینتر آشپزخانه و بار", "printTest": "صفحه تست چاپ", "shopDiscover": "کشف و AI", "team": "تیم و کارمندان", diff --git a/web/dashboard/src/components/menu/menu-admin-screen.tsx b/web/dashboard/src/components/menu/menu-admin-screen.tsx index 38f54ef..a5cd4fa 100644 --- a/web/dashboard/src/components/menu/menu-admin-screen.tsx +++ b/web/dashboard/src/components/menu/menu-admin-screen.tsx @@ -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 ? ( + + + + ) : null} +
{editingCategory ? ( diff --git a/web/dashboard/src/components/settings/settings-screen.tsx b/web/dashboard/src/components/settings/settings-screen.tsx index b8495e0..09a2716 100644 --- a/web/dashboard/src/components/settings/settings-screen.tsx +++ b/web/dashboard/src/components/settings/settings-screen.tsx @@ -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 = { "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" ? ( + + ) : null} + {activeLeaf === "print-test" ? ( 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 ( +
+
+ + setName(e.target.value)} + placeholder={t("stations.namePlaceholder")} + autoFocus + /> + + + setIp(e.target.value)} + placeholder="192.168.1.102" + dir="ltr" + className="text-end" + /> + + + setPort(e.target.value)} + dir="ltr" + className="text-end" + /> + +
+
+ + +
+
+ ); +} + +export function SettingsStationsPanel({ cafeId }: { cafeId: string }) { + const t = useTranslations("print"); + const tCommon = useTranslations("common"); + const confirm = useConfirm(); + const qc = useQueryClient(); + const [editing, setEditing] = useState(null); + const [testMsg, setTestMsg] = useState(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 ( + + +
+ + + {t("stations.title")} + +

{t("stations.subtitle")}

+
+ {editing === null ? ( + + + + ) : null} +
+ +

+ {t("stations.help")} +

+ + {testMsg ? ( +

+ {testMsg} +

+ ) : null} + + {editing === "new" ? ( + setEditing(null)} /> + ) : null} + + {isLoading ? ( +

{tCommon("loading")}

+ ) : stations.length === 0 && editing === null ? ( +

{t("stations.empty")}

+ ) : ( +
    + {stations.map((s) => + editing !== "new" && typeof editing === "object" && editing?.id === s.id ? ( +
  • + setEditing(null)} /> +
  • + ) : ( +
  • + +
    +

    {s.name}

    +

    + {s.printerIp ? `${s.printerIp}:${s.printerPort}` : t("stations.noPrinter")} +

    +
    + + {t("stations.categoryCount", { count: s.categoryCount })} + + {s.printerIp ? ( + + ) : null} + + + + + + +
  • + ) + )} +
+ )} +
+
+ ); +} diff --git a/web/dashboard/src/components/settings/settings-types.ts b/web/dashboard/src/components/settings/settings-types.ts index b836dcb..c6dee9e 100644 --- a/web/dashboard/src/components/settings/settings-types.ts +++ b/web/dashboard/src/components/settings/settings-types.ts @@ -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"; } diff --git a/web/dashboard/src/lib/api/kitchen-stations.ts b/web/dashboard/src/lib/api/kitchen-stations.ts new file mode 100644 index 0000000..8893e6c --- /dev/null +++ b/web/dashboard/src/lib/api/kitchen-stations.ts @@ -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 { + return apiGet(`/api/cafes/${cafeId}/kitchen-stations`); +} + +export function createKitchenStation( + cafeId: string, + body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number } +): Promise { + return apiPost(`/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 { + return apiPatch(`/api/cafes/${cafeId}/kitchen-stations/${id}`, body); +} + +export function deleteKitchenStation(cafeId: string, id: string): Promise { + return apiDelete(`/api/cafes/${cafeId}/kitchen-stations/${id}`); +}