From 197f6f2d38dc34d6560588376ca2ce96eeba2f45 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 12:28:27 +0330 Subject: [PATCH] feat(print): dashboard UI for print servers + auto-discovered printer pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 (final). Settings → Printers now has a "Print servers" section: add a print server (issues a one-time pairing code with steps), see each agent's online status, its auto-discovered printers, test any of them, and revoke. Receipt, kitchen and per-station printers can now be picked from a dropdown of discovered devices instead of typing an IP (manual IP stays as fallback). Wires the device mappings through the branch print-settings + kitchen-station DTOs/services and adds the device-test endpoint. fa/en/ar strings added. Completes the cloud↔LAN print-agent feature (entities/hub → routing → agent → UI). Remaining polish: agent system-tray + run-at-login + installer, optional LAN scan. Co-Authored-By: Claude Opus 4.8 --- .../BranchPrintSettingsController.cs | 12 +- .../Controllers/PrintAgentsController.cs | 18 +- .../Models/Kitchen/KitchenStationDtos.cs | 9 +- src/Meezi.API/Models/Printing/PrintDtos.cs | 8 +- .../Services/KitchenStationService.cs | 9 +- web/dashboard/messages/ar.json | 18 ++ web/dashboard/messages/en.json | 18 ++ web/dashboard/messages/fa.json | 18 ++ .../settings/settings-printer-panel.tsx | 181 +++++++++++++++++- .../settings/settings-stations-panel.tsx | 27 +++ web/dashboard/src/lib/api/kitchen-stations.ts | 5 +- web/dashboard/src/lib/api/print-agents.ts | 66 +++++++ 12 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 web/dashboard/src/lib/api/print-agents.ts diff --git a/src/Meezi.API/Controllers/BranchPrintSettingsController.cs b/src/Meezi.API/Controllers/BranchPrintSettingsController.cs index 484c945..9bfd53a 100644 --- a/src/Meezi.API/Controllers/BranchPrintSettingsController.cs +++ b/src/Meezi.API/Controllers/BranchPrintSettingsController.cs @@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase : request.PosDeviceIp.Trim(); if (request.PosDevicePort.HasValue) branch.PosDevicePort = request.PosDevicePort.Value; + if (request.ReceiptPrintDeviceId is not null) + branch.ReceiptPrintDeviceId = string.IsNullOrWhiteSpace(request.ReceiptPrintDeviceId) + ? null + : request.ReceiptPrintDeviceId; + if (request.KitchenPrintDeviceId is not null) + branch.KitchenPrintDeviceId = string.IsNullOrWhiteSpace(request.KitchenPrintDeviceId) + ? null + : request.KitchenPrintDeviceId; branch.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); @@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase b.ReceiptFooter, b.WifiPassword, b.PosDeviceIp, - b.PosDevicePort); + b.PosDevicePort, + b.ReceiptPrintDeviceId, + b.KitchenPrintDeviceId); } diff --git a/src/Meezi.API/Controllers/PrintAgentsController.cs b/src/Meezi.API/Controllers/PrintAgentsController.cs index 76643e6..941fdb2 100644 --- a/src/Meezi.API/Controllers/PrintAgentsController.cs +++ b/src/Meezi.API/Controllers/PrintAgentsController.cs @@ -18,11 +18,13 @@ public class PrintAgentsController : CafeApiControllerBase { private readonly AppDbContext _db; private readonly IPrintAgentRegistry _registry; + private readonly IPrinterService _printer; - public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry) + public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry, IPrinterService printer) { _db = db; _registry = registry; + _printer = printer; } [HttpGet] @@ -101,6 +103,20 @@ public class PrintAgentsController : CafeApiControllerBase return Ok(new ApiResponse(true, null)); } + /// Send a test page to a discovered printer through its agent. + [HttpPost("devices/{deviceId}/test")] + public async Task TestDevice(string cafeId, string deviceId, ITenantContext tenant, CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied; + + var result = await _printer.TestPrintDeviceAsync(cafeId, deviceId, ct); + return result.Success + ? Ok(new ApiResponse(true, null)) + : BadRequest(new ApiResponse(false, null, + new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed."))); + } + private async Task GenerateUniqueCodeAsync(CancellationToken ct) { for (var attempt = 0; attempt < 8; attempt++) diff --git a/src/Meezi.API/Models/Kitchen/KitchenStationDtos.cs b/src/Meezi.API/Models/Kitchen/KitchenStationDtos.cs index 603e426..8745ada 100644 --- a/src/Meezi.API/Models/Kitchen/KitchenStationDtos.cs +++ b/src/Meezi.API/Models/Kitchen/KitchenStationDtos.cs @@ -7,18 +7,21 @@ public record KitchenStationDto( string? PrinterIp, int PrinterPort, int SortOrder, - int CategoryCount); + int CategoryCount, + string? PrintDeviceId); public record CreateKitchenStationRequest( string Name, string? BranchId, string? PrinterIp, int PrinterPort = 9100, - int SortOrder = 0); + int SortOrder = 0, + string? PrintDeviceId = null); public record UpdateKitchenStationRequest( string? Name, string? BranchId, string? PrinterIp, int? PrinterPort, - int? SortOrder); + int? SortOrder, + string? PrintDeviceId); diff --git a/src/Meezi.API/Models/Printing/PrintDtos.cs b/src/Meezi.API/Models/Printing/PrintDtos.cs index 6d52d64..eeaaf61 100644 --- a/src/Meezi.API/Models/Printing/PrintDtos.cs +++ b/src/Meezi.API/Models/Printing/PrintDtos.cs @@ -12,7 +12,9 @@ public record BranchPrintSettingsDto( string? ReceiptFooter, string? WifiPassword, string? PosDeviceIp, - int? PosDevicePort); + int? PosDevicePort, + string? ReceiptPrintDeviceId, + string? KitchenPrintDeviceId); public record PatchBranchPrintSettingsRequest( string? ReceiptPrinterIp, @@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest( string? ReceiptFooter, string? WifiPassword, string? PosDeviceIp, - int? PosDevicePort); + int? PosDevicePort, + string? ReceiptPrintDeviceId, + string? KitchenPrintDeviceId); public record PosPaymentRequest(string OrderId, decimal Amount); diff --git a/src/Meezi.API/Services/KitchenStationService.cs b/src/Meezi.API/Services/KitchenStationService.cs index 7f62f8a..fcf4e46 100644 --- a/src/Meezi.API/Services/KitchenStationService.cs +++ b/src/Meezi.API/Services/KitchenStationService.cs @@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService s.PrinterIp, s.PrinterPort, s.SortOrder, + s.PrintDeviceId, CategoryCount = s.Categories.Count(c => c.DeletedAt == null) }) .ToListAsync(ct); return stations.Select(s => new KitchenStationDto( - s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount)).ToList(); + s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId)).ToList(); } public async Task CreateAsync( @@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService Name = request.Name.Trim(), PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(), PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100, + PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId, SortOrder = request.SortOrder }; @@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(); if (request.PrinterPort.HasValue) entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100; + if (request.PrintDeviceId is not null) + entity.PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId; if (request.SortOrder.HasValue) entity.SortOrder = request.SortOrder.Value; @@ -137,12 +141,13 @@ public class KitchenStationService : IKitchenStationService x.PrinterIp, x.PrinterPort, x.SortOrder, + x.PrintDeviceId, CategoryCount = x.Categories.Count(c => c.DeletedAt == null) }) .FirstOrDefaultAsync(ct); return s is null ? null - : new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount); + : new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId); } } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 5a1efa0..02ec266 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -351,6 +351,24 @@ "empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.", "deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.", "saveError": "تعذّر حفظ المحطة." + }, + "agents": { + "title": "خوادم الطباعة (اكتشاف تلقائي)", + "hint": "ثبّت وكيل طباعة Meezi على جهاز الكاشير لاكتشاف طابعات USB والشبكة تلقائياً والطباعة عبره.", + "add": "إضافة خادم طباعة", + "pairingTitle": "أدخل هذا الرمز في وكيل الطباعة على جهاز الكاشير:", + "pairingSteps": "ثبّت وشغّل وكيل طباعة Meezi على الجهاز المتصل بالطابعات ثم أدخل هذا الرمز. صالح لمدة 15 دقيقة.", + "empty": "لا يوجد خادم طباعة متصل بعد.", + "online": "متصل", + "offline": "غير متصل", + "noDevices": "جارٍ اكتشاف الطابعات…", + "test": "اختبار", + "receiptVia": "طابعة الإيصال (عبر الخادم)", + "kitchenVia": "طابعة المطبخ (عبر الخادم)", + "viaServer": "الطابعة (عبر الخادم)", + "useIpInstead": "— استخدام IP يدوي —", + "revokeConfirm": "إزالة خادم الطباعة «{name}»؟ لن يتمكن من الطباعة بعد ذلك.", + "codeError": "تعذّر إنشاء الرمز." } }, "receipt": { diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 7a96238..33f512f 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -370,6 +370,24 @@ "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." + }, + "agents": { + "title": "Print servers (auto-discovery)", + "hint": "Install the Meezi print agent on the cash PC to auto-detect its USB & network printers and print through it.", + "add": "Add print server", + "pairingTitle": "Enter this code in the print agent on the cash PC:", + "pairingSteps": "Install and run the Meezi Print Agent on the PC connected to the printers, then enter this code. It is valid for 15 minutes.", + "empty": "No print server connected yet.", + "online": "Online", + "offline": "Offline", + "noDevices": "Discovering printers…", + "test": "Test", + "receiptVia": "Receipt printer (via server)", + "kitchenVia": "Kitchen printer (via server)", + "viaServer": "Printer (via server)", + "useIpInstead": "— Use manual IP —", + "revokeConfirm": "Remove print server “{name}”? It will no longer be able to print.", + "codeError": "Could not create code." } }, "receipt": { diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 9385c30..1c30885 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -370,6 +370,24 @@ "empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتم‌هایشان جدا چاپ شود.", "deleteConfirm": "ایستگاه «{name}» حذف شود؟ دسته‌های آن به پرینتر آشپزخانه برمی‌گردند.", "saveError": "ذخیرهٔ ایستگاه ناموفق بود." + }, + "agents": { + "title": "پرینت‌سرورها (شناسایی خودکار پرینتر)", + "hint": "روی کامپیوتر صندوق، برنامهٔ «پرینت‌سرور میزی» را نصب کنید تا پرینترهای USB و شبکه به‌صورت خودکار شناسایی شوند و چاپ از طریق آن انجام شود.", + "add": "افزودن پرینت‌سرور", + "pairingTitle": "این کد را در برنامهٔ پرینت‌سرور روی کامپیوتر صندوق وارد کنید:", + "pairingSteps": "برنامهٔ «پرینت‌سرور میزی» را روی همان کامپیوتری که به پرینترها وصل است نصب و اجرا کنید، سپس این کد را وارد کنید. کد تا ۱۵ دقیقه معتبر است.", + "empty": "هنوز پرینت‌سروری متصل نشده است.", + "online": "آنلاین", + "offline": "آفلاین", + "noDevices": "در حال یافتن پرینترها…", + "test": "تست", + "receiptVia": "پرینتر رسید (از پرینت‌سرور)", + "kitchenVia": "پرینتر آشپزخانه (از پرینت‌سرور)", + "viaServer": "پرینتر (از پرینت‌سرور)", + "useIpInstead": "— استفاده از IP دستی —", + "revokeConfirm": "حذف پرینت‌سرور «{name}»؟ پس از آن دیگر نمی‌تواند چاپ کند.", + "codeError": "ایجاد کد ناموفق بود." } }, "receipt": { diff --git a/web/dashboard/src/components/settings/settings-printer-panel.tsx b/web/dashboard/src/components/settings/settings-printer-panel.tsx index 8a1ba27..7fef936 100644 --- a/web/dashboard/src/components/settings/settings-printer-panel.tsx +++ b/web/dashboard/src/components/settings/settings-printer-panel.tsx @@ -1,13 +1,25 @@ "use client"; import { useEffect, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; +import { Server, Wifi, WifiOff, Trash2, Plus, Loader2 } from "lucide-react"; import { apiGet, apiPatch } from "@/lib/api/client"; +import { + listPrintAgents, + createPairingCode, + revokePrintAgent, + testPrintDevice, + deviceOptions, + type PairingCode, +} from "@/lib/api/print-agents"; +import { 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 { useConfirm } from "@/components/providers/confirm-provider"; +import { notify } from "@/lib/notify"; type BranchPrintSettings = { branchId: string; @@ -22,6 +34,8 @@ type BranchPrintSettings = { wifiPassword?: string | null; posDeviceIp?: string | null; posDevicePort?: number | null; + receiptPrintDeviceId?: string | null; + kitchenPrintDeviceId?: string | null; }; type SettingsPrinterPanelProps = { @@ -46,6 +60,15 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte const [wifiPassword, setWifiPassword] = useState(""); const [posDeviceIp, setPosDeviceIp] = useState(""); const [posDevicePort, setPosDevicePort] = useState("8088"); + const [receiptDeviceId, setReceiptDeviceId] = useState(""); + const [kitchenDeviceId, setKitchenDeviceId] = useState(""); + + const { data: agents = [] } = useQuery({ + queryKey: ["print-agents", cafeId], + queryFn: () => listPrintAgents(cafeId), + enabled: !!cafeId, + }); + const devices = deviceOptions(agents); const { data: branches = [], isLoading: branchesLoading } = useQuery({ queryKey: ["branches", cafeId], @@ -77,6 +100,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte setWifiPassword(settings.wifiPassword ?? ""); setPosDeviceIp(settings.posDeviceIp ?? ""); setPosDevicePort(String(settings.posDevicePort ?? 8088)); + setReceiptDeviceId(settings.receiptPrintDeviceId ?? ""); + setKitchenDeviceId(settings.kitchenPrintDeviceId ?? ""); }, [settings]); const save = useMutation({ @@ -95,6 +120,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte wifiPassword: wifiPassword.trim() || null, posDeviceIp: posDeviceIp.trim() || null, posDevicePort: parseInt(posDevicePort, 10) || 8088, + receiptPrintDeviceId: receiptDeviceId || null, + kitchenPrintDeviceId: kitchenDeviceId || null, } ), onSuccess: () => { @@ -103,6 +130,37 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte }, }); + const qc = useQueryClient(); + const confirm = useConfirm(); + const [pairing, setPairing] = useState(null); + + const createCode = useMutation({ + mutationFn: () => createPairingCode(cafeId), + onSuccess: (c) => { + setPairing(c); + void qc.invalidateQueries({ queryKey: ["print-agents", cafeId] }); + }, + onError: () => notify.error(t("agents.codeError")), + }); + const revoke = useMutation({ + mutationFn: (id: string) => revokePrintAgent(cafeId, id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["print-agents", cafeId] }), + }); + const testDevice = useMutation({ + mutationFn: (deviceId: string) => testPrintDevice(cafeId, deviceId), + onSuccess: () => notify.success(t("testSent")), + onError: (err) => notify.error(printErrorMessage(err, t)), + }); + + const handleRevoke = async (id: string, name: string) => { + const ok = await confirm({ + description: t("agents.revokeConfirm", { name }), + variant: "destructive", + confirmLabel: tCommon("confirm"), + }); + if (ok) revoke.mutate(id); + }; + if (branchesLoading) { return

{tCommon("loading")}

; } @@ -134,6 +192,127 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte

) : null} + {/* Print servers — auto-discovered printers via the local agent */} +
+
+
+

+ + {t("agents.title")} +

+

{t("agents.hint")}

+
+ +
+ + {pairing ? ( +
+

{t("agents.pairingTitle")}

+

+ {pairing.code} +

+

{t("agents.pairingSteps")}

+
+ ) : null} + + {agents.length === 0 ? ( +

{t("agents.empty")}

+ ) : ( +
    + {agents.map((a) => ( +
  • +
    + {a.online ? ( + + ) : ( + + )} + {a.name} + + {a.online ? t("agents.online") : t("agents.offline")} + + +
    + {a.devices.length > 0 ? ( +
      + {a.devices.map((d) => ( +
    • + + {d.displayName} ({d.kind}) + + +
    • + ))} +
    + ) : a.online ? ( +

    {t("agents.noDevices")}

    + ) : null} +
  • + ))} +
+ )} + + {devices.length > 0 ? ( +
+ + + + + + +
+ ) : null} +
+
diff --git a/web/dashboard/src/components/settings/settings-stations-panel.tsx b/web/dashboard/src/components/settings/settings-stations-panel.tsx index 74696c0..750dc15 100644 --- a/web/dashboard/src/components/settings/settings-stations-panel.tsx +++ b/web/dashboard/src/components/settings/settings-stations-panel.tsx @@ -12,6 +12,7 @@ import { type KitchenStation, } from "@/lib/api/kitchen-stations"; import { testPrinter, printErrorMessage } from "@/lib/api/print"; +import { listPrintAgents, deviceOptions } from "@/lib/api/print-agents"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -37,6 +38,14 @@ function StationForm({ const [name, setName] = useState(station?.name ?? ""); const [ip, setIp] = useState(station?.printerIp ?? ""); const [port, setPort] = useState(String(station?.printerPort ?? 9100)); + const [deviceId, setDeviceId] = useState(station?.printDeviceId ?? ""); + + const { data: agents = [] } = useQuery({ + queryKey: ["print-agents", cafeId], + queryFn: () => listPrintAgents(cafeId), + enabled: !!cafeId, + }); + const devices = deviceOptions(agents); const save = useMutation({ mutationFn: () => { @@ -44,6 +53,7 @@ function StationForm({ name: name.trim(), printerIp: ip.trim() || null, printerPort: parseInt(port, 10) || 9100, + printDeviceId: deviceId || null, }; return station ? updateKitchenStation(cafeId, station.id, body) @@ -88,6 +98,23 @@ function StationForm({ />
+ {devices.length > 0 ? ( + + + + ) : null}