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:
@@ -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": "الفريق والموظفون",
|
||||||
|
|||||||
@@ -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 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": {
|
"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",
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user