197f6f2d38
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>
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
"use client";
|
|
|
|
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 { 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;
|
|
receiptPrinterIp?: string | null;
|
|
receiptPrinterPort?: number | null;
|
|
kitchenPrinterIp?: string | null;
|
|
kitchenPrinterPort?: number | null;
|
|
paperWidthMm: number;
|
|
autoCutEnabled: boolean;
|
|
receiptHeader?: string | null;
|
|
receiptFooter?: string | null;
|
|
wifiPassword?: string | null;
|
|
posDeviceIp?: string | null;
|
|
posDevicePort?: number | null;
|
|
receiptPrintDeviceId?: string | null;
|
|
kitchenPrintDeviceId?: string | null;
|
|
};
|
|
|
|
type SettingsPrinterPanelProps = {
|
|
cafeId: string;
|
|
onOpenPrintTest?: () => void;
|
|
};
|
|
|
|
export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinterPanelProps) {
|
|
const t = useTranslations("print");
|
|
const tSettings = useTranslations("settings");
|
|
const tCommon = useTranslations("common");
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
|
|
const [receiptIp, setReceiptIp] = useState("");
|
|
const [receiptPort, setReceiptPort] = useState("9100");
|
|
const [kitchenIp, setKitchenIp] = useState("");
|
|
const [kitchenPort, setKitchenPort] = useState("9100");
|
|
const [paperWidth, setPaperWidth] = useState("80");
|
|
const [autoCut, setAutoCut] = useState(true);
|
|
const [receiptHeader, setReceiptHeader] = useState("");
|
|
const [receiptFooter, setReceiptFooter] = useState("");
|
|
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],
|
|
queryFn: () => apiGet<{ id: string }[]>(`/api/cafes/${cafeId}/branches`),
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
const branchId = branches[0]?.id;
|
|
|
|
const { data: settings, refetch } = useQuery({
|
|
queryKey: ["branch-print-settings", cafeId, branchId],
|
|
queryFn: () =>
|
|
apiGet<BranchPrintSettings>(
|
|
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`
|
|
),
|
|
enabled: !!cafeId && !!branchId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!settings) return;
|
|
setReceiptIp(settings.receiptPrinterIp ?? "");
|
|
setReceiptPort(String(settings.receiptPrinterPort ?? 9100));
|
|
setKitchenIp(settings.kitchenPrinterIp ?? "");
|
|
setKitchenPort(String(settings.kitchenPrinterPort ?? 9100));
|
|
setPaperWidth(String(settings.paperWidthMm === 58 ? 58 : 80));
|
|
setAutoCut(settings.autoCutEnabled);
|
|
setReceiptHeader(settings.receiptHeader ?? "");
|
|
setReceiptFooter(settings.receiptFooter ?? "");
|
|
setWifiPassword(settings.wifiPassword ?? "");
|
|
setPosDeviceIp(settings.posDeviceIp ?? "");
|
|
setPosDevicePort(String(settings.posDevicePort ?? 8088));
|
|
setReceiptDeviceId(settings.receiptPrintDeviceId ?? "");
|
|
setKitchenDeviceId(settings.kitchenPrintDeviceId ?? "");
|
|
}, [settings]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
apiPatch<BranchPrintSettings>(
|
|
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`,
|
|
{
|
|
receiptPrinterIp: receiptIp.trim() || null,
|
|
receiptPrinterPort: parseInt(receiptPort, 10) || 9100,
|
|
kitchenPrinterIp: kitchenIp.trim() || null,
|
|
kitchenPrinterPort: parseInt(kitchenPort, 10) || 9100,
|
|
paperWidthMm: paperWidth === "58" ? 58 : 80,
|
|
autoCutEnabled: autoCut,
|
|
receiptHeader: receiptHeader.trim() || null,
|
|
receiptFooter: receiptFooter.trim() || null,
|
|
wifiPassword: wifiPassword.trim() || null,
|
|
posDeviceIp: posDeviceIp.trim() || null,
|
|
posDevicePort: parseInt(posDevicePort, 10) || 8088,
|
|
receiptPrintDeviceId: receiptDeviceId || null,
|
|
kitchenPrintDeviceId: kitchenDeviceId || null,
|
|
}
|
|
),
|
|
onSuccess: () => {
|
|
setMessage(t("settingsSaved"));
|
|
void refetch();
|
|
},
|
|
});
|
|
|
|
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>;
|
|
}
|
|
|
|
if (!branchId) {
|
|
return (
|
|
<Card className="rounded-xl border border-border/80 shadow-sm">
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-muted-foreground">{t("noBranchForPrinter")}</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<CardTitle className="text-base font-medium">{t("printerSettings")}</CardTitle>
|
|
{onOpenPrintTest ? (
|
|
<Button variant="outline" size="sm" onClick={onOpenPrintTest}>
|
|
{tSettings("nav.printTest")}
|
|
</Button>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardContent className="space-y-6 px-6 pb-6 pt-0">
|
|
{message ? (
|
|
<p className="rounded-lg border border-border/80 bg-muted/40 px-4 py-2.5 text-xs">
|
|
{message}
|
|
</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">
|
|
<Input
|
|
id="receipt-ip"
|
|
value={receiptIp}
|
|
onChange={(e) => setReceiptIp(e.target.value)}
|
|
placeholder="192.168.1.100"
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("port")} htmlFor="receipt-port">
|
|
<Input
|
|
id="receipt-port"
|
|
value={receiptPort}
|
|
onChange={(e) => setReceiptPort(e.target.value)}
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("kitchenPrinter")} htmlFor="kitchen-ip">
|
|
<Input
|
|
id="kitchen-ip"
|
|
value={kitchenIp}
|
|
onChange={(e) => setKitchenIp(e.target.value)}
|
|
placeholder="192.168.1.101"
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("port")} htmlFor="kitchen-port">
|
|
<Input
|
|
id="kitchen-port"
|
|
value={kitchenPort}
|
|
onChange={(e) => setKitchenPort(e.target.value)}
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("paperWidth")} htmlFor="paper-width">
|
|
<select
|
|
id="paper-width"
|
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
|
value={paperWidth}
|
|
onChange={(e) => setPaperWidth(e.target.value)}
|
|
>
|
|
<option value="80">80mm</option>
|
|
<option value="58">58mm</option>
|
|
</select>
|
|
</LabeledField>
|
|
<LabeledField label={t("autoCut")} htmlFor="auto-cut">
|
|
<label className="flex h-10 items-center gap-2 text-sm">
|
|
<input
|
|
id="auto-cut"
|
|
type="checkbox"
|
|
checked={autoCut}
|
|
onChange={(e) => setAutoCut(e.target.checked)}
|
|
/>
|
|
{t("autoCut")}
|
|
</label>
|
|
</LabeledField>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-4 border-t border-border/80 pt-6">
|
|
<LabeledField label={t("receiptHeader")} htmlFor="receipt-header">
|
|
<Input
|
|
id="receipt-header"
|
|
value={receiptHeader}
|
|
onChange={(e) => setReceiptHeader(e.target.value)}
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("receiptFooter")} htmlFor="receipt-footer">
|
|
<Input
|
|
id="receipt-footer"
|
|
value={receiptFooter}
|
|
onChange={(e) => setReceiptFooter(e.target.value)}
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("wifiOnReceipt")} htmlFor="wifi-pass">
|
|
<Input
|
|
id="wifi-pass"
|
|
value={wifiPassword}
|
|
onChange={(e) => setWifiPassword(e.target.value)}
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
</section>
|
|
|
|
<section className="space-y-4 rounded-lg border border-border/80 bg-muted/20 p-4 sm:p-5">
|
|
<div className="space-y-1">
|
|
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
|
{t("posDeviceSection")}
|
|
</p>
|
|
<p className="text-xs leading-relaxed text-muted-foreground">{t("posDeviceHint")}</p>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<LabeledField label={t("posDeviceIp")} htmlFor="pos-device-ip">
|
|
<Input
|
|
id="pos-device-ip"
|
|
value={posDeviceIp}
|
|
onChange={(e) => setPosDeviceIp(e.target.value)}
|
|
placeholder="192.168.1.50"
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("port")} htmlFor="pos-device-port">
|
|
<Input
|
|
id="pos-device-port"
|
|
value={posDevicePort}
|
|
onChange={(e) => setPosDevicePort(e.target.value)}
|
|
dir="ltr"
|
|
className="text-end"
|
|
/>
|
|
</LabeledField>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="border-t border-border/80 pt-4">
|
|
<Button
|
|
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
|
|
disabled={save.isPending}
|
|
onClick={() => save.mutate()}
|
|
>
|
|
{t("saveSettings")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|