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
@@ -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";
}