feat(print): dashboard UI for print servers + auto-discovered printer pickers
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m44s
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m44s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
: request.PosDeviceIp.Trim();
|
: request.PosDeviceIp.Trim();
|
||||||
if (request.PosDevicePort.HasValue)
|
if (request.PosDevicePort.HasValue)
|
||||||
branch.PosDevicePort = request.PosDevicePort.Value;
|
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;
|
branch.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
b.ReceiptFooter,
|
b.ReceiptFooter,
|
||||||
b.WifiPassword,
|
b.WifiPassword,
|
||||||
b.PosDeviceIp,
|
b.PosDeviceIp,
|
||||||
b.PosDevicePort);
|
b.PosDevicePort,
|
||||||
|
b.ReceiptPrintDeviceId,
|
||||||
|
b.KitchenPrintDeviceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ public class PrintAgentsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IPrintAgentRegistry _registry;
|
private readonly IPrintAgentRegistry _registry;
|
||||||
|
private readonly IPrinterService _printer;
|
||||||
|
|
||||||
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry)
|
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry, IPrinterService printer)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_registry = registry;
|
_registry = registry;
|
||||||
|
_printer = printer;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -101,6 +103,20 @@ public class PrintAgentsController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<object>(true, null));
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Send a test page to a discovered printer through its agent.</summary>
|
||||||
|
[HttpPost("devices/{deviceId}/test")]
|
||||||
|
public async Task<IActionResult> 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<object>(true, null))
|
||||||
|
: BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed.")));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
|
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
for (var attempt = 0; attempt < 8; attempt++)
|
for (var attempt = 0; attempt < 8; attempt++)
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ public record KitchenStationDto(
|
|||||||
string? PrinterIp,
|
string? PrinterIp,
|
||||||
int PrinterPort,
|
int PrinterPort,
|
||||||
int SortOrder,
|
int SortOrder,
|
||||||
int CategoryCount);
|
int CategoryCount,
|
||||||
|
string? PrintDeviceId);
|
||||||
|
|
||||||
public record CreateKitchenStationRequest(
|
public record CreateKitchenStationRequest(
|
||||||
string Name,
|
string Name,
|
||||||
string? BranchId,
|
string? BranchId,
|
||||||
string? PrinterIp,
|
string? PrinterIp,
|
||||||
int PrinterPort = 9100,
|
int PrinterPort = 9100,
|
||||||
int SortOrder = 0);
|
int SortOrder = 0,
|
||||||
|
string? PrintDeviceId = null);
|
||||||
|
|
||||||
public record UpdateKitchenStationRequest(
|
public record UpdateKitchenStationRequest(
|
||||||
string? Name,
|
string? Name,
|
||||||
string? BranchId,
|
string? BranchId,
|
||||||
string? PrinterIp,
|
string? PrinterIp,
|
||||||
int? PrinterPort,
|
int? PrinterPort,
|
||||||
int? SortOrder);
|
int? SortOrder,
|
||||||
|
string? PrintDeviceId);
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ public record BranchPrintSettingsDto(
|
|||||||
string? ReceiptFooter,
|
string? ReceiptFooter,
|
||||||
string? WifiPassword,
|
string? WifiPassword,
|
||||||
string? PosDeviceIp,
|
string? PosDeviceIp,
|
||||||
int? PosDevicePort);
|
int? PosDevicePort,
|
||||||
|
string? ReceiptPrintDeviceId,
|
||||||
|
string? KitchenPrintDeviceId);
|
||||||
|
|
||||||
public record PatchBranchPrintSettingsRequest(
|
public record PatchBranchPrintSettingsRequest(
|
||||||
string? ReceiptPrinterIp,
|
string? ReceiptPrinterIp,
|
||||||
@@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest(
|
|||||||
string? ReceiptFooter,
|
string? ReceiptFooter,
|
||||||
string? WifiPassword,
|
string? WifiPassword,
|
||||||
string? PosDeviceIp,
|
string? PosDeviceIp,
|
||||||
int? PosDevicePort);
|
int? PosDevicePort,
|
||||||
|
string? ReceiptPrintDeviceId,
|
||||||
|
string? KitchenPrintDeviceId);
|
||||||
|
|
||||||
public record PosPaymentRequest(string OrderId, decimal Amount);
|
public record PosPaymentRequest(string OrderId, decimal Amount);
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
s.PrinterIp,
|
s.PrinterIp,
|
||||||
s.PrinterPort,
|
s.PrinterPort,
|
||||||
s.SortOrder,
|
s.SortOrder,
|
||||||
|
s.PrintDeviceId,
|
||||||
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
|
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
|
||||||
})
|
})
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return stations.Select(s => new KitchenStationDto(
|
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<KitchenStationDto?> CreateAsync(
|
public async Task<KitchenStationDto?> CreateAsync(
|
||||||
@@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
|
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
|
||||||
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
|
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
|
||||||
|
PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId,
|
||||||
SortOrder = request.SortOrder
|
SortOrder = request.SortOrder
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
|
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
|
||||||
if (request.PrinterPort.HasValue)
|
if (request.PrinterPort.HasValue)
|
||||||
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100;
|
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)
|
if (request.SortOrder.HasValue)
|
||||||
entity.SortOrder = request.SortOrder.Value;
|
entity.SortOrder = request.SortOrder.Value;
|
||||||
|
|
||||||
@@ -137,12 +141,13 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
x.PrinterIp,
|
x.PrinterIp,
|
||||||
x.PrinterPort,
|
x.PrinterPort,
|
||||||
x.SortOrder,
|
x.SortOrder,
|
||||||
|
x.PrintDeviceId,
|
||||||
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
|
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
return s is null
|
return s is null
|
||||||
? 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,24 @@
|
|||||||
"empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.",
|
"empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.",
|
||||||
"deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.",
|
"deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.",
|
||||||
"saveError": "تعذّر حفظ المحطة."
|
"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": {
|
"receipt": {
|
||||||
|
|||||||
@@ -370,6 +370,24 @@
|
|||||||
"empty": "No stations yet. Add Kitchen and Bar to print their items separately.",
|
"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.",
|
"deleteConfirm": "Delete station “{name}”? Its categories will fall back to the kitchen printer.",
|
||||||
"saveError": "Failed to save the station."
|
"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": {
|
"receipt": {
|
||||||
|
|||||||
@@ -370,6 +370,24 @@
|
|||||||
"empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتمهایشان جدا چاپ شود.",
|
"empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتمهایشان جدا چاپ شود.",
|
||||||
"deleteConfirm": "ایستگاه «{name}» حذف شود؟ دستههای آن به پرینتر آشپزخانه برمیگردند.",
|
"deleteConfirm": "ایستگاه «{name}» حذف شود؟ دستههای آن به پرینتر آشپزخانه برمیگردند.",
|
||||||
"saveError": "ذخیرهٔ ایستگاه ناموفق بود."
|
"saveError": "ذخیرهٔ ایستگاه ناموفق بود."
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"title": "پرینتسرورها (شناسایی خودکار پرینتر)",
|
||||||
|
"hint": "روی کامپیوتر صندوق، برنامهٔ «پرینتسرور میزی» را نصب کنید تا پرینترهای USB و شبکه بهصورت خودکار شناسایی شوند و چاپ از طریق آن انجام شود.",
|
||||||
|
"add": "افزودن پرینتسرور",
|
||||||
|
"pairingTitle": "این کد را در برنامهٔ پرینتسرور روی کامپیوتر صندوق وارد کنید:",
|
||||||
|
"pairingSteps": "برنامهٔ «پرینتسرور میزی» را روی همان کامپیوتری که به پرینترها وصل است نصب و اجرا کنید، سپس این کد را وارد کنید. کد تا ۱۵ دقیقه معتبر است.",
|
||||||
|
"empty": "هنوز پرینتسروری متصل نشده است.",
|
||||||
|
"online": "آنلاین",
|
||||||
|
"offline": "آفلاین",
|
||||||
|
"noDevices": "در حال یافتن پرینترها…",
|
||||||
|
"test": "تست",
|
||||||
|
"receiptVia": "پرینتر رسید (از پرینتسرور)",
|
||||||
|
"kitchenVia": "پرینتر آشپزخانه (از پرینتسرور)",
|
||||||
|
"viaServer": "پرینتر (از پرینتسرور)",
|
||||||
|
"useIpInstead": "— استفاده از IP دستی —",
|
||||||
|
"revokeConfirm": "حذف پرینتسرور «{name}»؟ پس از آن دیگر نمیتواند چاپ کند.",
|
||||||
|
"codeError": "ایجاد کد ناموفق بود."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"receipt": {
|
"receipt": {
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { useTranslations } from "next-intl";
|
||||||
|
import { Server, Wifi, WifiOff, Trash2, Plus, Loader2 } from "lucide-react";
|
||||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
|
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||||
|
import { notify } from "@/lib/notify";
|
||||||
|
|
||||||
type BranchPrintSettings = {
|
type BranchPrintSettings = {
|
||||||
branchId: string;
|
branchId: string;
|
||||||
@@ -22,6 +34,8 @@ type BranchPrintSettings = {
|
|||||||
wifiPassword?: string | null;
|
wifiPassword?: string | null;
|
||||||
posDeviceIp?: string | null;
|
posDeviceIp?: string | null;
|
||||||
posDevicePort?: number | null;
|
posDevicePort?: number | null;
|
||||||
|
receiptPrintDeviceId?: string | null;
|
||||||
|
kitchenPrintDeviceId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsPrinterPanelProps = {
|
type SettingsPrinterPanelProps = {
|
||||||
@@ -46,6 +60,15 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
|||||||
const [wifiPassword, setWifiPassword] = useState("");
|
const [wifiPassword, setWifiPassword] = useState("");
|
||||||
const [posDeviceIp, setPosDeviceIp] = useState("");
|
const [posDeviceIp, setPosDeviceIp] = useState("");
|
||||||
const [posDevicePort, setPosDevicePort] = useState("8088");
|
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({
|
const { data: branches = [], isLoading: branchesLoading } = useQuery({
|
||||||
queryKey: ["branches", cafeId],
|
queryKey: ["branches", cafeId],
|
||||||
@@ -77,6 +100,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
|||||||
setWifiPassword(settings.wifiPassword ?? "");
|
setWifiPassword(settings.wifiPassword ?? "");
|
||||||
setPosDeviceIp(settings.posDeviceIp ?? "");
|
setPosDeviceIp(settings.posDeviceIp ?? "");
|
||||||
setPosDevicePort(String(settings.posDevicePort ?? 8088));
|
setPosDevicePort(String(settings.posDevicePort ?? 8088));
|
||||||
|
setReceiptDeviceId(settings.receiptPrintDeviceId ?? "");
|
||||||
|
setKitchenDeviceId(settings.kitchenPrintDeviceId ?? "");
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
@@ -95,6 +120,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
|||||||
wifiPassword: wifiPassword.trim() || null,
|
wifiPassword: wifiPassword.trim() || null,
|
||||||
posDeviceIp: posDeviceIp.trim() || null,
|
posDeviceIp: posDeviceIp.trim() || null,
|
||||||
posDevicePort: parseInt(posDevicePort, 10) || 8088,
|
posDevicePort: parseInt(posDevicePort, 10) || 8088,
|
||||||
|
receiptPrintDeviceId: receiptDeviceId || null,
|
||||||
|
kitchenPrintDeviceId: kitchenDeviceId || null,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -103,6 +130,37 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [pairing, setPairing] = useState<PairingCode | null>(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) {
|
if (branchesLoading) {
|
||||||
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
|
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
|
||||||
}
|
}
|
||||||
@@ -134,6 +192,127 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Print servers — auto-discovered printers via the local agent */}
|
||||||
|
<section className="space-y-3 rounded-lg border border-border/80 bg-muted/20 p-4 sm:p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Server className="size-4 text-[#0F6E56]" />
|
||||||
|
{t("agents.title")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">{t("agents.hint")}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 gap-1.5"
|
||||||
|
disabled={createCode.isPending}
|
||||||
|
onClick={() => createCode.mutate()}
|
||||||
|
>
|
||||||
|
{createCode.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||||
|
{t("agents.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pairing ? (
|
||||||
|
<div className="rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE]/50 p-3 text-sm dark:bg-[#0F6E56]/10">
|
||||||
|
<p className="font-medium">{t("agents.pairingTitle")}</p>
|
||||||
|
<p className="my-2 text-center font-mono text-2xl font-bold tracking-[0.3em] text-[#0F6E56]">
|
||||||
|
{pairing.code}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">{t("agents.pairingSteps")}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<p className="py-2 text-center text-xs text-muted-foreground">{t("agents.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{agents.map((a) => (
|
||||||
|
<li key={a.id} className="rounded-lg border border-border/70 bg-background p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{a.online ? (
|
||||||
|
<Wifi className="size-4 shrink-0 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{a.name}</span>
|
||||||
|
<span className={a.online ? "text-[11px] text-emerald-600" : "text-[11px] text-muted-foreground"}>
|
||||||
|
{a.online ? t("agents.online") : t("agents.offline")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
disabled={revoke.isPending}
|
||||||
|
onClick={() => handleRevoke(a.id, a.name)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{a.devices.length > 0 ? (
|
||||||
|
<ul className="mt-2 space-y-1 ps-6">
|
||||||
|
{a.devices.map((d) => (
|
||||||
|
<li key={d.id} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||||
|
{d.displayName} <span className="opacity-60">({d.kind})</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-[11px]"
|
||||||
|
disabled={!a.online || testDevice.isPending}
|
||||||
|
onClick={() => testDevice.mutate(d.id)}
|
||||||
|
>
|
||||||
|
{t("agents.test")}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : a.online ? (
|
||||||
|
<p className="mt-1 ps-6 text-[11px] text-muted-foreground">{t("agents.noDevices")}</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{devices.length > 0 ? (
|
||||||
|
<div className="grid gap-4 border-t border-border/60 pt-3 sm:grid-cols-2">
|
||||||
|
<LabeledField label={t("agents.receiptVia")} htmlFor="receipt-device">
|
||||||
|
<select
|
||||||
|
id="receipt-device"
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={receiptDeviceId}
|
||||||
|
onChange={(e) => setReceiptDeviceId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("agents.useIpInstead")}</option>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("agents.kitchenVia")} htmlFor="kitchen-device">
|
||||||
|
<select
|
||||||
|
id="kitchen-device"
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={kitchenDeviceId}
|
||||||
|
onChange={(e) => setKitchenDeviceId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("agents.useIpInstead")}</option>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</LabeledField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<LabeledField label={t("receiptPrinter")} htmlFor="receipt-ip">
|
<LabeledField label={t("receiptPrinter")} htmlFor="receipt-ip">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type KitchenStation,
|
type KitchenStation,
|
||||||
} from "@/lib/api/kitchen-stations";
|
} from "@/lib/api/kitchen-stations";
|
||||||
import { testPrinter, printErrorMessage } from "@/lib/api/print";
|
import { testPrinter, printErrorMessage } from "@/lib/api/print";
|
||||||
|
import { listPrintAgents, deviceOptions } from "@/lib/api/print-agents";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -37,6 +38,14 @@ function StationForm({
|
|||||||
const [name, setName] = useState(station?.name ?? "");
|
const [name, setName] = useState(station?.name ?? "");
|
||||||
const [ip, setIp] = useState(station?.printerIp ?? "");
|
const [ip, setIp] = useState(station?.printerIp ?? "");
|
||||||
const [port, setPort] = useState(String(station?.printerPort ?? 9100));
|
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({
|
const save = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -44,6 +53,7 @@ function StationForm({
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
printerIp: ip.trim() || null,
|
printerIp: ip.trim() || null,
|
||||||
printerPort: parseInt(port, 10) || 9100,
|
printerPort: parseInt(port, 10) || 9100,
|
||||||
|
printDeviceId: deviceId || null,
|
||||||
};
|
};
|
||||||
return station
|
return station
|
||||||
? updateKitchenStation(cafeId, station.id, body)
|
? updateKitchenStation(cafeId, station.id, body)
|
||||||
@@ -88,6 +98,23 @@ function StationForm({
|
|||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
</div>
|
</div>
|
||||||
|
{devices.length > 0 ? (
|
||||||
|
<LabeledField label={t("agents.viaServer")} htmlFor="station-device">
|
||||||
|
<select
|
||||||
|
id="station-device"
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => setDeviceId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("agents.useIpInstead")}</option>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</LabeledField>
|
||||||
|
) : null}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={onDone} disabled={save.isPending}>
|
<Button variant="ghost" onClick={onDone} disabled={save.isPending}>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface KitchenStation {
|
|||||||
printerPort: number;
|
printerPort: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
categoryCount: number;
|
categoryCount: number;
|
||||||
|
printDeviceId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]> {
|
export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]> {
|
||||||
@@ -22,7 +23,7 @@ export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]>
|
|||||||
|
|
||||||
export function createKitchenStation(
|
export function createKitchenStation(
|
||||||
cafeId: string,
|
cafeId: string,
|
||||||
body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number }
|
body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number; printDeviceId?: string | null }
|
||||||
): Promise<KitchenStation> {
|
): Promise<KitchenStation> {
|
||||||
return apiPost<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations`, body);
|
return apiPost<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations`, body);
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,7 @@ export function createKitchenStation(
|
|||||||
export function updateKitchenStation(
|
export function updateKitchenStation(
|
||||||
cafeId: string,
|
cafeId: string,
|
||||||
id: string,
|
id: string,
|
||||||
body: { name?: string; printerIp?: string | null; printerPort?: number | null; sortOrder?: number | null }
|
body: { name?: string; printerIp?: string | null; printerPort?: number | null; sortOrder?: number | null; printDeviceId?: string | null }
|
||||||
): Promise<KitchenStation> {
|
): Promise<KitchenStation> {
|
||||||
return apiPatch<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations/${id}`, body);
|
return apiPatch<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations/${id}`, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { apiGet, apiPost, apiDelete } from "@/lib/api/client";
|
||||||
|
|
||||||
|
/** A printer discovered & reported by a local print agent. */
|
||||||
|
export interface PrintAgentDevice {
|
||||||
|
id: string;
|
||||||
|
systemName: string;
|
||||||
|
displayName: string;
|
||||||
|
kind: string; // "usb" | "network" | "other"
|
||||||
|
lastSeenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A local print bridge (cash-PC app) paired to the café. */
|
||||||
|
export interface PrintAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
branchId?: string | null;
|
||||||
|
online: boolean;
|
||||||
|
paired: boolean;
|
||||||
|
lastSeenAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
devices: PrintAgentDevice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingCode {
|
||||||
|
agentId: string;
|
||||||
|
code: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A flattened device option for a printer dropdown. */
|
||||||
|
export interface DeviceOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPrintAgents(cafeId: string): Promise<PrintAgent[]> {
|
||||||
|
return apiGet<PrintAgent[]>(`/api/cafes/${cafeId}/print-agents`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPairingCode(cafeId: string, name?: string): Promise<PairingCode> {
|
||||||
|
return apiPost<PairingCode>(`/api/cafes/${cafeId}/print-agents/pairing-code`, {
|
||||||
|
name: name?.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokePrintAgent(cafeId: string, id: string): Promise<void> {
|
||||||
|
return apiDelete(`/api/cafes/${cafeId}/print-agents/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testPrintDevice(cafeId: string, deviceId: string): Promise<unknown> {
|
||||||
|
return apiPost(`/api/cafes/${cafeId}/print-agents/devices/${deviceId}/test`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every device across all agents, for a printer-picker dropdown. */
|
||||||
|
export function deviceOptions(agents: PrintAgent[]): DeviceOption[] {
|
||||||
|
return agents.flatMap((a) =>
|
||||||
|
a.devices.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
label: `${a.name} · ${d.displayName}`,
|
||||||
|
kind: d.kind,
|
||||||
|
online: a.online,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user