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

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:
soroush.asadi
2026-06-25 12:28:27 +03:30
parent 7d5af0c81b
commit 197f6f2d38
12 changed files with 377 additions and 12 deletions
@@ -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<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) {
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
}
@@ -134,6 +192,127 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
</p>
) : 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">
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("receiptPrinter")} htmlFor="receipt-ip">