From b0896dc777660b8efc57e243743537910ef84089 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 14:01:21 +0330 Subject: [PATCH] feat(pos): bridge the card terminal through the print agent + LAN auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card-terminal integration only ever worked when the API could reach the terminal's IP directly — impossible for the cloud deployment, where the terminal sits on the café LAN (the same wall the Print Agent already climbs for printers). And the terminal IP had to be typed by hand. Both fixed by reusing the agent. Cloud→LAN relay: - PrintAgentRegistry.SendPaymentAsync sends a PaymentRequest to the café's online agent and awaits its ack (PaymentResult on the hub); 95s window for the customer. - PosDeviceService now prefers an online agent (branch-matched, else any café agent) to relay POST /pay over the LAN, and falls back to the direct HTTP call only when no agent is connected (on-prem). Agent errors map back to POS_DEVICE_*. - Agent (Program.cs + PosTerminal.cs) handles PaymentRequest → POSTs the amount to the terminal's local http://ip:port/pay and reports approval/decline/timeout. Auto-detect: - Registry.ScanAsync + hub ReportScan; POST /print-agents/scan asks online agents to scan their /24 for given ports and merges the hosts found. - Agent NetworkScanner scans the LAN (:9100 printers, :8088 terminals) with a short per-host TCP probe. - Dashboard: a "تشخیص خودکار" (auto-detect) button on the POS-device, receipt and kitchen IP fields scans via the agent and fills the IP:port from a found host. Backend + agent build clean; dashboard tsc clean. NOTE: the agent app is not in CI — it must be rebuilt and redeployed on the café PC to gain these handlers. Co-Authored-By: Claude Opus 4.8 --- agent/Meezi.PrintAgent/NetworkScanner.cs | 106 ++++++++++++++++++ agent/Meezi.PrintAgent/PosTerminal.cs | 35 ++++++ agent/Meezi.PrintAgent/Program.cs | 21 ++++ .../Controllers/PrintAgentsController.cs | 30 +++++ src/Meezi.API/Hubs/PrintAgentHub.cs | 8 ++ .../Models/Printing/PrintAgentDtos.cs | 5 + src/Meezi.API/Services/PosDeviceService.cs | 52 ++++++++- .../Services/Printing/PrintAgentRegistry.cs | 87 ++++++++++++++ web/dashboard/messages/ar.json | 5 + web/dashboard/messages/en.json | 5 + web/dashboard/messages/fa.json | 5 + .../settings/settings-printer-panel.tsx | 101 ++++++++++++++++- web/dashboard/src/lib/api/print-agents.ts | 16 +++ 13 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 agent/Meezi.PrintAgent/NetworkScanner.cs create mode 100644 agent/Meezi.PrintAgent/PosTerminal.cs diff --git a/agent/Meezi.PrintAgent/NetworkScanner.cs b/agent/Meezi.PrintAgent/NetworkScanner.cs new file mode 100644 index 0000000..de091a2 --- /dev/null +++ b/agent/Meezi.PrintAgent/NetworkScanner.cs @@ -0,0 +1,106 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace Meezi.PrintAgent; + +/// A host on the café LAN answering on a probed port. Property names match +/// the cloud's DiscoveredDevice record so SignalR maps them across. +public record ScannedDevice(string Ip, int Port, string Kind); + +/// +/// Scans the agent PC's local /24 subnet(s) for hosts answering on the given TCP +/// ports — used to auto-find network printers (:9100) and card terminals (:8088) +/// so the café owner doesn't have to type IP addresses. +/// +public static class NetworkScanner +{ + private const int MaxConcurrency = 128; + private const int ConnectTimeoutMs = 300; + + public static async Task> ScanAsync(string portsCsv, CancellationToken ct) + { + var ports = portsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => int.TryParse(p, out var n) ? n : 0) + .Where(n => n is > 0 and <= 65535) + .Distinct() + .ToList(); + if (ports.Count == 0) ports = [9100, 8088]; + + var results = new ConcurrentBag(); + using var gate = new SemaphoreSlim(MaxConcurrency); + var tasks = new List(); + + foreach (var prefix in LocalSubnets()) + { + for (var host = 1; host <= 254; host++) + { + var ip = $"{prefix}.{host}"; + foreach (var port in ports) + { + await gate.WaitAsync(ct); + tasks.Add(Task.Run(async () => + { + try + { + if (await CanConnectAsync(ip, port)) + results.Add(new ScannedDevice(ip, port, Classify(port))); + } + finally { gate.Release(); } + }, ct)); + } + } + } + + await Task.WhenAll(tasks); + return results + .DistinctBy(d => $"{d.Ip}:{d.Port}") + .OrderBy(d => d.Ip) + .ThenBy(d => d.Port) + .ToList(); + } + + /// Distinct /24 prefixes of this PC's up, non-loopback IPv4 interfaces. + private static IEnumerable LocalSubnets() + { + var seen = new HashSet(); + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up) continue; + foreach (var ua in ni.GetIPProperties().UnicastAddresses) + { + if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue; + if (IPAddress.IsLoopback(ua.Address)) continue; + var b = ua.Address.GetAddressBytes(); + var prefix = $"{b[0]}.{b[1]}.{b[2]}"; + if (seen.Add(prefix)) yield return prefix; + } + } + } + + private static async Task CanConnectAsync(string ip, int port) + { + try + { + using var client = new TcpClient(); + var connect = client.ConnectAsync(ip, port); + var done = await Task.WhenAny(connect, Task.Delay(ConnectTimeoutMs)); + if (done != connect) return false; + await connect; // observe exceptions + return client.Connected; + } + catch + { + return false; + } + } + + private static string Classify(int port) => port switch + { + 9100 => "network-printer", + 8088 => "pos-terminal", + _ => "other", + }; +} diff --git a/agent/Meezi.PrintAgent/PosTerminal.cs b/agent/Meezi.PrintAgent/PosTerminal.cs new file mode 100644 index 0000000..e64210c --- /dev/null +++ b/agent/Meezi.PrintAgent/PosTerminal.cs @@ -0,0 +1,35 @@ +using System.Net.Http.Json; + +namespace Meezi.PrintAgent; + +/// +/// Relays a card-terminal payment on the café LAN. The cloud can't reach the +/// terminal's private IP, so it hands the agent the amount and the terminal's +/// ip:port; the agent POSTs to the terminal's local HTTP /pay endpoint and +/// reports back whether it was approved. +/// +public static class PosTerminal +{ + // A card payment blocks on the customer inserting/approving — allow plenty. + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(90) }; + + public static async Task<(bool Ok, string? Error)> SendPaymentAsync( + string ip, int port, long amount, string orderId, CancellationToken ct) + { + var url = $"http://{ip}:{port}/pay"; + try + { + using var resp = await Http.PostAsJsonAsync(url, new { amount, orderId }, ct); + if (resp.IsSuccessStatusCode) return (true, null); + return (false, $"POS_DEVICE_REJECTED:HTTP {(int)resp.StatusCode}"); + } + catch (TaskCanceledException) + { + return (false, "POS_DEVICE_TIMEOUT"); + } + catch (Exception ex) + { + return (false, $"POS_DEVICE_CONNECTION_FAILED:{ex.Message}"); + } + } +} diff --git a/agent/Meezi.PrintAgent/Program.cs b/agent/Meezi.PrintAgent/Program.cs index 7dfa2a1..2f396aa 100644 --- a/agent/Meezi.PrintAgent/Program.cs +++ b/agent/Meezi.PrintAgent/Program.cs @@ -73,6 +73,27 @@ static async Task RunAsync(AgentConfig config) try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ } }); + // Cloud → agent: relay a card-terminal payment to the terminal on the LAN. + connection.On("PaymentRequest", async (requestId, ip, port, amount, orderId) => + { + var (ok, err) = await PosTerminal.SendPaymentAsync(ip, port, amount, orderId, CancellationToken.None); + Console.WriteLine(ok + ? $"[pay] {amount} → {ip}:{port} ✓" + : $"[pay] {ip}:{port} ✗ {err}"); + try { await connection.InvokeAsync("PaymentResult", requestId, ok, err); } catch { /* ack best-effort */ } + }); + + // Cloud → agent: scan the LAN for hosts on the given ports (printers :9100, terminals :8088). + connection.On("ScanNetwork", async (requestId, ports) => + { + List found; + try { found = await NetworkScanner.ScanAsync(ports, CancellationToken.None); } + catch (Exception ex) { Console.WriteLine($"[scan] failed: {ex.Message}"); found = []; } + Console.WriteLine($"[scan] ports={ports} → {found.Count} host(s): " + + string.Join(", ", found.Select(d => $"{d.Ip}:{d.Port}"))); + try { await connection.InvokeAsync("ReportScan", requestId, found); } catch { /* best-effort */ } + }); + connection.Reconnected += async _ => { Console.WriteLine("[hub] reconnected"); diff --git a/src/Meezi.API/Controllers/PrintAgentsController.cs b/src/Meezi.API/Controllers/PrintAgentsController.cs index 941fdb2..ef44f56 100644 --- a/src/Meezi.API/Controllers/PrintAgentsController.cs +++ b/src/Meezi.API/Controllers/PrintAgentsController.cs @@ -117,6 +117,36 @@ public class PrintAgentsController : CafeApiControllerBase new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed."))); } + /// Ask the café's online agents to scan their LAN for devices (network + /// printers on :9100, card terminals on :8088) so the owner can pick instead of + /// typing an IP. Merges results across agents. + [HttpPost("scan")] + public async Task Scan( + string cafeId, + [FromBody] ScanRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied; + + var online = _registry.OnlineAgentIdsForCafe(cafeId); + if (online.Count == 0) + return BadRequest(new ApiResponse( + false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network."))); + + var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim(); + var merged = new Dictionary(); + foreach (var agentId in online) + { + foreach (var d in await _registry.ScanAsync(agentId, ports, ct)) + merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind); + } + + var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList(); + return Ok(new ApiResponse>(true, dtos)); + } + private async Task GenerateUniqueCodeAsync(CancellationToken ct) { for (var attempt = 0; attempt < 8; attempt++) diff --git a/src/Meezi.API/Hubs/PrintAgentHub.cs b/src/Meezi.API/Hubs/PrintAgentHub.cs index 4dc27fa..aaf0fdf 100644 --- a/src/Meezi.API/Hubs/PrintAgentHub.cs +++ b/src/Meezi.API/Hubs/PrintAgentHub.cs @@ -99,6 +99,14 @@ public class PrintAgentHub : Hub public void JobResult(string jobId, bool success, string? error) => _registry.CompleteJob(jobId, success, error); + /// Agent → cloud: result of a relayed card-terminal payment. + public void PaymentResult(string requestId, bool success, string? error) => + _registry.CompleteJob(requestId, success, error); + + /// Agent → cloud: hosts found by a LAN scan (network printers, card terminals). + public void ReportScan(string requestId, IReadOnlyList devices) => + _registry.CompleteScan(requestId, devices ?? []); + /// Agent → cloud: keep-alive so the dashboard can show an accurate "last seen". public async Task Heartbeat() { diff --git a/src/Meezi.API/Models/Printing/PrintAgentDtos.cs b/src/Meezi.API/Models/Printing/PrintAgentDtos.cs index c757a91..07d4293 100644 --- a/src/Meezi.API/Models/Printing/PrintAgentDtos.cs +++ b/src/Meezi.API/Models/Printing/PrintAgentDtos.cs @@ -24,3 +24,8 @@ public record PairingCodeResponse(string AgentId, string Code, DateTime ExpiresA public record ClaimAgentRequest(string Code, string? Name, string? MachineName); public record ClaimAgentResponse(string AgentId, string Token, string CafeId, string AgentName); + +/// Ask online agents to scan the LAN for the given comma-separated TCP ports. +public record ScanRequest(string? Ports); + +public record ScannedDeviceDto(string Ip, int Port, string Kind); diff --git a/src/Meezi.API/Services/PosDeviceService.cs b/src/Meezi.API/Services/PosDeviceService.cs index f62816f..d219a59 100644 --- a/src/Meezi.API/Services/PosDeviceService.cs +++ b/src/Meezi.API/Services/PosDeviceService.cs @@ -1,6 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; using Meezi.API.Models.Printing; +using Meezi.API.Services.Printing; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService private readonly AppDbContext _db; private readonly IHttpClientFactory _httpClientFactory; + private readonly IPrintAgentRegistry _agents; private readonly ILogger _logger; public PosDeviceService( AppDbContext db, IHttpClientFactory httpClientFactory, + IPrintAgentRegistry agents, ILogger logger) { _db = db; _httpClientFactory = httpClientFactory; + _agents = agents; _logger = logger; } @@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService if (order is null) return PosDeviceResult.Fail("ORDER_NOT_FOUND"); + var amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero); + var ip = branch.PosDeviceIp!.Trim(); + + // Prefer relaying through a local print agent on the café LAN — the cloud + // can't reach the terminal's private IP directly (same reason the agent + // exists for printers). Fall back to a direct call only on-prem / when no + // agent is connected. + var agentId = await ResolveOnlineAgentAsync(cafeId, branchId, ct); + if (agentId is not null) + { + var outcome = await _agents.SendPaymentAsync(agentId, ip, port, amount, request.OrderId, ct); + if (outcome.Success) + return PosDeviceResult.Ok(); + _logger.LogWarning("Agent-relayed POS payment failed ({Agent}): {Error}", agentId, outcome.Error); + return PosDeviceResult.Fail(MapAgentError(outcome.Error), outcome.Error); + } + var payload = new { - amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero), + amount, orderId = request.OrderId, branchId, }; - var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay"; + var url = $"http://{ip}:{port}/pay"; try { @@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message); } } + + /// The online agent best placed to reach this branch's terminal — one + /// bound to the branch if present, else any online agent of the café. + private async Task ResolveOnlineAgentAsync(string cafeId, string branchId, CancellationToken ct) + { + var online = _agents.OnlineAgentIdsForCafe(cafeId); + if (online.Count == 0) return null; + + var agents = await _db.PrintAgents + .AsNoTracking() + .Where(a => a.CafeId == cafeId && !a.Revoked && a.DeletedAt == null) + .ToListAsync(ct); + + return agents.FirstOrDefault(a => a.BranchId == branchId && online.Contains(a.Id))?.Id + ?? agents.FirstOrDefault(a => online.Contains(a.Id))?.Id; + } + + /// Normalize an agent-relay error string back to a POS_DEVICE_* code. + private static string MapAgentError(string? error) => error switch + { + null or "" => "POS_DEVICE_FAILED", + var e when e.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_TIMEOUT", + var e when e.StartsWith("POS_DEVICE_", StringComparison.Ordinal) => e.Split(':')[0], + var e when e.Contains("REJECT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_REJECTED", + var e when e.Contains("OFFLINE", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_CONNECTION_FAILED", + _ => "POS_DEVICE_CONNECTION_FAILED", + }; } diff --git a/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs b/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs index df5f5b6..a27ee9c 100644 --- a/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs +++ b/src/Meezi.API/Services/Printing/PrintAgentRegistry.cs @@ -7,6 +7,10 @@ namespace Meezi.API.Services.Printing; public record PrintJobRequest(string PrinterSystemName, byte[] Payload); public record PrintJobOutcome(bool Success, string? Error); +/// A host the agent found on the café LAN responding on a probed port +/// (a network printer on :9100, a card terminal on :8088, …). +public record DiscoveredDevice(string Ip, int Port, string Kind); + /// /// Tracks which print agents are currently connected (by SignalR connection) and /// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory: @@ -19,8 +23,19 @@ public interface IPrintAgentRegistry (string AgentId, string CafeId)? Resolve(string connectionId); bool IsOnline(string agentId); IReadOnlySet OnlineAgentIds(); + /// Online agents belonging to a café — used to pick a LAN bridge for a + /// card-terminal payment or a network scan. + IReadOnlySet OnlineAgentIdsForCafe(string cafeId); Task SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default); void CompleteJob(string jobId, bool success, string? error); + /// Relay a card-terminal payment through the agent on the café LAN; it + /// POSTs the amount to the terminal at ip:port and acks the approval result. + Task SendPaymentAsync( + string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default); + /// Ask the agent to scan its LAN for hosts answering on the given ports. + Task> ScanAsync( + string agentId, string ports, CancellationToken ct = default); + void CompleteScan(string requestId, IReadOnlyList devices); } public class PrintAgentRegistry : IPrintAgentRegistry @@ -29,6 +44,7 @@ public class PrintAgentRegistry : IPrintAgentRegistry private readonly ConcurrentDictionary _byConnection = new(); private readonly ConcurrentDictionary _agentConnection = new(); // agentId -> connectionId private readonly ConcurrentDictionary> _pending = new(); + private readonly ConcurrentDictionary>> _pendingScans = new(); public PrintAgentRegistry(IHubContext hub) => _hub = hub; @@ -54,6 +70,12 @@ public class PrintAgentRegistry : IPrintAgentRegistry public IReadOnlySet OnlineAgentIds() => _agentConnection.Keys.ToHashSet(); + public IReadOnlySet OnlineAgentIdsForCafe(string cafeId) => + _byConnection.Values + .Where(v => v.CafeId == cafeId) + .Select(v => v.AgentId) + .ToHashSet(); + public async Task SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default) { if (!_agentConnection.TryGetValue(agentId, out var connectionId)) @@ -87,4 +109,69 @@ public class PrintAgentRegistry : IPrintAgentRegistry if (_pending.TryGetValue(jobId, out var tcs)) tcs.TrySetResult(new PrintJobOutcome(success, error)); } + + public async Task SendPaymentAsync( + string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default) + { + if (!_agentConnection.TryGetValue(agentId, out var connectionId)) + return new PrintJobOutcome(false, "AGENT_OFFLINE"); + + var requestId = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[requestId] = tcs; + try + { + await _hub.Clients.Client(connectionId).SendAsync( + "PaymentRequest", requestId, ip, port, amount, orderId, ct); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + // Card payment waits on the customer at the terminal — give it the same + // headroom the direct path uses. + timeout.CancelAfter(TimeSpan.FromSeconds(95)); + using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "POS_DEVICE_TIMEOUT"))); + return await tcs.Task; + } + catch (Exception ex) + { + return new PrintJobOutcome(false, ex.Message); + } + finally + { + _pending.TryRemove(requestId, out _); + } + } + + public async Task> ScanAsync( + string agentId, string ports, CancellationToken ct = default) + { + if (!_agentConnection.TryGetValue(agentId, out var connectionId)) + return []; + + var requestId = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingScans[requestId] = tcs; + try + { + await _hub.Clients.Client(connectionId).SendAsync("ScanNetwork", requestId, ports, ct); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + using var reg = timeout.Token.Register(() => tcs.TrySetResult([])); + return await tcs.Task; + } + catch + { + return []; + } + finally + { + _pendingScans.TryRemove(requestId, out _); + } + } + + public void CompleteScan(string requestId, IReadOnlyList devices) + { + if (_pendingScans.TryGetValue(requestId, out var tcs)) + tcs.TrySetResult(devices); + } } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 02ec266..1ccc214 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -333,6 +333,11 @@ "posDeviceSection": "جهاز نقطة البيع (بطاقة)", "posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.", "posDeviceIp": "عنوان IP لجهاز نقطة البيع", + "detect": "كشف تلقائي", + "detecting": "جارٍ فحص الشبكة…", + "detectNone": "لم يُعثر على أجهزة في الشبكة", + "detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي", + "detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.", "testSent": "تم إرسال الاختبار إلى الطابعة.", "sent": "تم الإرسال إلى الطابعة.", "noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 33f512f..cafffda 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -352,6 +352,11 @@ "posDeviceSection": "Card POS terminal", "posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.", "posDeviceIp": "POS device IP address", + "detect": "Auto-detect", + "detecting": "Scanning the network…", + "detectNone": "No devices found on the network", + "detectOffline": "A print server must be online to auto-detect", + "detectHint": "The print server scans your LAN to find the device.", "testSent": "Test sent to the printer.", "sent": "Sent to the printer.", "noStationItems": "This order has no items for that station.", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 1c30885..ed2576a 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -352,6 +352,11 @@ "posDeviceSection": "دستگاه پوز (کارتخوان)", "posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).", "posDeviceIp": "آدرس IP دستگاه پوز", + "detect": "تشخیص خودکار", + "detecting": "در حال جستجوی شبکه…", + "detectNone": "دستگاهی در شبکه پیدا نشد", + "detectOffline": "برای تشخیص خودکار باید پرینت‌سرور روشن و متصل باشد", + "detectHint": "پرینت‌سرور شبکه محلی را برای یافتن دستگاه اسکن می‌کند.", "testSent": "تست به پرینتر ارسال شد.", "sent": "به پرینتر ارسال شد.", "noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.", diff --git a/web/dashboard/src/components/settings/settings-printer-panel.tsx b/web/dashboard/src/components/settings/settings-printer-panel.tsx index 7fef936..ad6c5bd 100644 --- a/web/dashboard/src/components/settings/settings-printer-panel.tsx +++ b/web/dashboard/src/components/settings/settings-printer-panel.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { Server, Wifi, WifiOff, Trash2, Plus, Loader2 } from "lucide-react"; +import { Server, Wifi, WifiOff, Trash2, Plus, Loader2, Radar } from "lucide-react"; import { apiGet, apiPatch } from "@/lib/api/client"; import { listPrintAgents, @@ -11,8 +11,11 @@ import { revokePrintAgent, testPrintDevice, deviceOptions, + scanNetwork, type PairingCode, + type ScannedDevice, } from "@/lib/api/print-agents"; +import { ApiClientError } from "@/lib/api/client"; import { printErrorMessage } from "@/lib/api/print"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -324,6 +327,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte dir="ltr" className="text-end" /> + { + setReceiptIp(ip); + setReceiptPort(String(port)); + }} + /> + { + setKitchenIp(ip); + setKitchenPort(String(port)); + }} + /> + { + setPosDeviceIp(ip); + setPosDevicePort(String(port)); + }} + /> ); } + +/** + * "Auto-detect" affordance for an IP field: asks the online print agent to scan + * the café LAN for the given ports and lets the owner pick a found host (which + * fills the IP + port) instead of typing an address by hand. + */ +function DetectButton({ + cafeId, + ports, + onPick, +}: { + cafeId: string; + ports: string; + onPick: (ip: string, port: number) => void; +}) { + const t = useTranslations("print"); + const [results, setResults] = useState(null); + + const scan = useMutation({ + mutationFn: () => scanNetwork(cafeId, ports), + onSuccess: (devices) => setResults(devices), + onError: (e) => + notify.error( + e instanceof ApiClientError && e.code === "AGENT_OFFLINE" + ? t("detectOffline") + : t("detectNone"), + ), + }); + + return ( +
+ + {results && + (results.length === 0 ? ( +

{t("detectNone")}

+ ) : ( +
+ {results.map((d) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/web/dashboard/src/lib/api/print-agents.ts b/web/dashboard/src/lib/api/print-agents.ts index 7f0f9e9..ec349d7 100644 --- a/web/dashboard/src/lib/api/print-agents.ts +++ b/web/dashboard/src/lib/api/print-agents.ts @@ -53,6 +53,22 @@ export function testPrintDevice(cafeId: string, deviceId: string): Promise { + return apiPost(`/api/cafes/${cafeId}/print-agents/scan`, { ports }); +} + /** Every device across all agents, for a printer-picker dropdown. */ export function deviceOptions(agents: PrintAgent[]): DeviceOption[] { return agents.flatMap((a) =>