mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-11-01 12:33:30 +00:00
feat: bulk printing of vouchers
This commit is contained in:
176
frontend/src/app/print/page.tsx
Normal file
176
frontend/src/app/print/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import "./styles.css";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense, useEffect, useRef, useState } from "react";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import { Voucher } from "@/types/voucher";
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
formatMaxGuests,
|
||||||
|
formatSpeed,
|
||||||
|
} from "@/utils/format";
|
||||||
|
import { useGlobal } from "@/contexts/GlobalContext";
|
||||||
|
import { formatCode } from "@/utils/format";
|
||||||
|
|
||||||
|
export type PrintMode = "list" | "grid";
|
||||||
|
|
||||||
|
function VoucherBlock({ voucher }: { voucher: Voucher }) {
|
||||||
|
const { wifiConfig, wifiString } = useGlobal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-voucher">
|
||||||
|
<div className="print-header">
|
||||||
|
<div className="print-title">WiFi Access Voucher</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="print-voucher-code">{formatCode(voucher.code)}</div>
|
||||||
|
|
||||||
|
<div className="print-info-row">
|
||||||
|
<span className="print-label">Duration:</span>
|
||||||
|
<span className="print-value">
|
||||||
|
{formatDuration(voucher.timeLimitMinutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="print-info-row">
|
||||||
|
<span className="print-label">Max Guests:</span>
|
||||||
|
<span className="print-value">
|
||||||
|
{formatMaxGuests(voucher.authorizedGuestLimit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="print-info-row">
|
||||||
|
<span className="print-label">Data Limit:</span>
|
||||||
|
<span className="print-value">
|
||||||
|
{voucher.dataUsageLimitMBytes
|
||||||
|
? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
|
||||||
|
: "Unlimited"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="print-info-row">
|
||||||
|
<span className="print-label">Down Speed:</span>
|
||||||
|
<span className="print-value">
|
||||||
|
{formatSpeed(voucher.rxRateLimitKbps)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="print-info-row">
|
||||||
|
<span className="print-label">Up Speed:</span>
|
||||||
|
<span className="print-value">
|
||||||
|
{formatSpeed(voucher.txRateLimitKbps)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wifiConfig && (
|
||||||
|
<div className="print-qr-section">
|
||||||
|
{wifiString && (
|
||||||
|
<>
|
||||||
|
<div className="font-bold mb-2">Scan to Connect</div>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={wifiString}
|
||||||
|
size={140}
|
||||||
|
level="H"
|
||||||
|
marginSize={4}
|
||||||
|
title="Wi-Fi Access QR Code"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="print-qr-text">
|
||||||
|
<strong>Network:</strong> {wifiConfig.ssid}
|
||||||
|
<br />
|
||||||
|
{wifiConfig.type === "nopass" ? (
|
||||||
|
"No Password"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>Password:</strong> {wifiConfig.password}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{wifiConfig.hidden && <div>(Hidden Network)</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="print-footer">
|
||||||
|
<div>
|
||||||
|
<strong className="text-sm">ID:</strong> {voucher.id}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong className="text-sm">Printed:</strong>{" "}
|
||||||
|
{new Date().toUTCString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vouchers() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [vouchers, setVouchers] = useState<Voucher[]>([]);
|
||||||
|
const [mode, setMode] = useState<PrintMode>("list");
|
||||||
|
const lastSearchParams = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const paramString = searchParams.toString();
|
||||||
|
if (lastSearchParams.current === paramString) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSearchParams.current = paramString;
|
||||||
|
|
||||||
|
const vouchersParam = searchParams.get("vouchers");
|
||||||
|
const modeParam = searchParams.get("mode");
|
||||||
|
|
||||||
|
if (!vouchersParam || !modeParam) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedVouchers = JSON.parse(decodeURIComponent(vouchersParam));
|
||||||
|
setVouchers(parsedVouchers);
|
||||||
|
setMode(modeParam as PrintMode);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
router.replace("/");
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse vouchers:", error);
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
return !vouchers.length ? (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
No vouchers to print, press backspace
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={mode === "grid" ? "print-grid" : "print-list"}>
|
||||||
|
{vouchers.map((v) => (
|
||||||
|
<VoucherBlock key={v.id} voucher={v} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrintPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" || e.key === "Backspace") router.replace("/");
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-wrapper">
|
||||||
|
<Suspense
|
||||||
|
fallback={<div style={{ textAlign: "center" }}>Loading...</div>}
|
||||||
|
>
|
||||||
|
<Vouchers />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/app/print/styles.css
Normal file
67
frontend/src/app/print/styles.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Print page settings */
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: auto;
|
||||||
|
margin: 4; /* ~3mm is too fine; browsers usually round to ~4px */
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow: visible !important;
|
||||||
|
height: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
position: static !important;
|
||||||
|
touch-action: auto !important;
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-wrapper {
|
||||||
|
@apply p-3 m-0 p-0 bg-white text-black font-mono text-sm leading-snug;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-list {
|
||||||
|
@apply flex flex-col gap-3 max-w-[80mm] mx-auto;
|
||||||
|
}
|
||||||
|
.print-grid {
|
||||||
|
@apply grid gap-3 grid-cols-[repeat(auto-fill,minmax(250px,1fr))];
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-voucher {
|
||||||
|
@apply border-2 border-black p-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-header {
|
||||||
|
@apply text-center border-b-2 border-black pb-1;
|
||||||
|
}
|
||||||
|
.print-title {
|
||||||
|
@apply font-bold text-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-voucher-code {
|
||||||
|
@apply text-xl font-bold text-center border-2 border-black p-1 my-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-info-row {
|
||||||
|
@apply flex justify-between my-1.5 border-b border-dotted border-black;
|
||||||
|
}
|
||||||
|
.print-label {
|
||||||
|
@apply font-bold flex-1;
|
||||||
|
}
|
||||||
|
.print-value {
|
||||||
|
@apply flex-1 text-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-qr-section {
|
||||||
|
@apply flex flex-col items-center justify-center my-3 border-t-2 border-black pt-2;
|
||||||
|
}
|
||||||
|
.print-qr-section .print-qr-text {
|
||||||
|
@apply text-center text-xs mt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.print-footer {
|
||||||
|
@apply text-left border-t-2 border-black pt-2 text-xs;
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import { Voucher } from "@/types/voucher";
|
import { Voucher } from "@/types/voucher";
|
||||||
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format";
|
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format";
|
||||||
import { memo } from "react";
|
import { memo, useCallback } from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
voucher: Voucher;
|
voucher: Voucher;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
onClick?: () => void;
|
onClick?: (v: Voucher) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
||||||
const statusClass = voucher.expired
|
const statusClass = voucher.expired
|
||||||
? "bg-status-danger text-status-danger"
|
? "bg-status-danger text-status-danger"
|
||||||
: "bg-status-success text-status-success";
|
: "bg-status-success text-status-success";
|
||||||
|
const onClickHandler = useCallback(
|
||||||
|
() => onClick?.(voucher),
|
||||||
|
[voucher, onClick],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClickHandler}
|
||||||
className={`card card-interactive
|
className={`card card-interactive
|
||||||
${selected ? "border-accent" : ""}
|
${selected ? "border-accent" : ""}
|
||||||
${editMode ? "relative" : ""}`}
|
${editMode ? "relative" : ""}`}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export default function Modal({
|
|||||||
ref,
|
ref,
|
||||||
children,
|
children,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// lock scroll + handle Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevOverflow = document.body.style.overflow;
|
const prevOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export default function VoucherModal({ voucher, onClose }: Props) {
|
|||||||
})();
|
})();
|
||||||
}, [voucher.id]);
|
}, [voucher.id]);
|
||||||
|
|
||||||
const rawCode = details?.code ?? voucher.code;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<Modal onClose={onClose}>
|
||||||
<VoucherCode voucher={voucher} contentClassName="mb-8" />
|
<VoucherCode voucher={voucher} contentClassName="mb-8" />
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import Spinner from "@/components/utils/Spinner";
|
import Spinner from "@/components/utils/Spinner";
|
||||||
import VoucherCard from "@/components/VoucherCard";
|
import VoucherCard from "@/components/VoucherCard";
|
||||||
import VoucherModal from "@/components/modals/VoucherModal";
|
import VoucherModal from "@/components/modals/VoucherModal";
|
||||||
|
import { PrintMode } from "@/app/print/page";
|
||||||
import { Voucher } from "@/types/voucher";
|
import { Voucher } from "@/types/voucher";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { notify } from "@/utils/notifications";
|
import { notify } from "@/utils/notifications";
|
||||||
import { useMemo, useEffect, useCallback, useState } from "react";
|
import { useMemo, useEffect, useCallback, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function VouchersTab() {
|
export default function VouchersTab() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -14,8 +16,28 @@ export default function VouchersTab() {
|
|||||||
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
|
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const filteredVouchers = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return vouchers;
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase().trim();
|
||||||
|
return vouchers.filter((voucher) =>
|
||||||
|
voucher.name?.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}, [vouchers, searchQuery]);
|
||||||
|
|
||||||
|
const expiredIds = useMemo(
|
||||||
|
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
|
||||||
|
[filteredVouchers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedVouchers = useMemo(
|
||||||
|
() => filteredVouchers.filter((v) => selectedIds.has(v.id)),
|
||||||
|
[filteredVouchers, selectedIds],
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -28,16 +50,71 @@ export default function VouchersTab() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = useCallback(() => {
|
||||||
setSelected(new Set());
|
setSelectedIds(new Set());
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const cancelEdit = useCallback(() => {
|
const cancelEdit = useCallback(() => {
|
||||||
setSelected(new Set());
|
setSelectedIds(new Set());
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string) => {
|
||||||
|
setSelectedIds((p) => {
|
||||||
|
const s = new Set(p);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clickCard = useCallback(
|
||||||
|
(v: Voucher) => (editMode ? toggleSelect(v.id) : setViewVoucher(v)),
|
||||||
|
[editMode, toggleSelect, setViewVoucher],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
if (selectedVouchers.length === filteredVouchers.length) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(filteredVouchers.map((v) => v.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
setViewVoucher(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteVouchers = useCallback(
|
||||||
|
async (kind: "selected" | "expired") => {
|
||||||
|
setBusy(true);
|
||||||
|
const kind_word = kind === "selected" ? "" : "expired";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res =
|
||||||
|
kind === "selected"
|
||||||
|
? await api.deleteSelected([...selectedVouchers.map((v) => v.id)])
|
||||||
|
: await api.deleteSelected([...expiredIds]);
|
||||||
|
|
||||||
|
const count = res.vouchersDeleted || 0;
|
||||||
|
if (count > 0) {
|
||||||
|
notify(
|
||||||
|
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
} else {
|
||||||
|
notify(`No ${kind_word} vouchers were deleted`, "info");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify(`Failed to delete ${kind_word} vouchers`, "error");
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
cancelEdit();
|
||||||
|
},
|
||||||
|
[selectedVouchers, expiredIds, cancelEdit],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
window.addEventListener("vouchersUpdated", load);
|
window.addEventListener("vouchersUpdated", load);
|
||||||
@@ -53,67 +130,14 @@ export default function VouchersTab() {
|
|||||||
};
|
};
|
||||||
}, [load, cancelEdit]);
|
}, [load, cancelEdit]);
|
||||||
|
|
||||||
const filteredVouchers = useMemo(() => {
|
const handlePrintClick = (mode: PrintMode) => {
|
||||||
if (!searchQuery.trim()) return vouchers;
|
// Prepare the data for the URL
|
||||||
|
const vouchersParam = encodeURIComponent(JSON.stringify(vouchers));
|
||||||
|
const printUrl = `/print?vouchers=${vouchersParam}&mode=${mode}`;
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase().trim();
|
router.replace(printUrl);
|
||||||
return vouchers.filter((voucher) =>
|
|
||||||
voucher.name?.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}, [vouchers, searchQuery]);
|
|
||||||
|
|
||||||
const expiredVouchers = useMemo(
|
|
||||||
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
|
|
||||||
[filteredVouchers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: string) => {
|
|
||||||
setSelected((p) => {
|
|
||||||
const s = new Set(p);
|
|
||||||
s.has(id) ? s.delete(id) : s.add(id);
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
if (selected.size === filteredVouchers.length) {
|
|
||||||
setSelected(new Set());
|
|
||||||
} else {
|
|
||||||
setSelected(new Set(filteredVouchers.map((v) => v.id)));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
|
||||||
setViewVoucher(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const deleteVouchers = useCallback(
|
|
||||||
async (kind: "selected" | "expired") => {
|
|
||||||
setBusy(true);
|
|
||||||
const kind_word = kind === "selected" ? "" : "expired";
|
|
||||||
try {
|
|
||||||
const res =
|
|
||||||
kind === "selected"
|
|
||||||
? await api.deleteSelected([...selected])
|
|
||||||
: await api.deleteSelected([...expiredVouchers]);
|
|
||||||
const count = res.vouchersDeleted || 0;
|
|
||||||
if (count > 0) {
|
|
||||||
notify(
|
|
||||||
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
setSelected(new Set());
|
|
||||||
} else {
|
|
||||||
notify(`No ${kind_word} vouchers were deleted`, "info");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
notify(`Failed to delete ${kind_word} vouchers`, "error");
|
|
||||||
}
|
|
||||||
setBusy(false);
|
|
||||||
},
|
|
||||||
[selected],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@@ -149,30 +173,44 @@ export default function VouchersTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={selectAll}
|
onClick={selectAll}
|
||||||
disabled={!filteredVouchers.length}
|
disabled={!filteredVouchers.length}
|
||||||
className="btn-secondary"
|
className="btn-primary"
|
||||||
>
|
>
|
||||||
Select All
|
Select All
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePrintClick("grid")}
|
||||||
|
disabled={!selectedVouchers.length}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Print (Tile)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePrintClick("list")}
|
||||||
|
disabled={!selectedVouchers.length}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Print (List)
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteVouchers("selected")}
|
onClick={() => deleteVouchers("selected")}
|
||||||
disabled={busy || !selected.size}
|
disabled={busy || !selectedVouchers.length}
|
||||||
className="btn-danger"
|
className="btn-danger"
|
||||||
>
|
>
|
||||||
Delete Selected
|
Delete Selected
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteVouchers("expired")}
|
onClick={() => deleteVouchers("expired")}
|
||||||
disabled={busy || !expiredVouchers.length}
|
disabled={busy || !expiredIds.length}
|
||||||
className="btn-warning"
|
className="btn-warning"
|
||||||
>
|
>
|
||||||
Delete Expired
|
Delete Expired
|
||||||
</button>
|
</button>
|
||||||
<button onClick={cancelEdit} className="btn-secondary">
|
<button onClick={cancelEdit} className="btn-primary">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
{busy ? <Spinner /> : <></>}
|
{busy ? <Spinner /> : <></>}
|
||||||
<span className="text-sm text-secondary font-bold ml-auto">
|
<span className="text-sm text-secondary font-bold ml-auto">
|
||||||
{selected.size} selected
|
{selectedVouchers.length} selected
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -199,10 +237,8 @@ export default function VouchersTab() {
|
|||||||
key={v.id}
|
key={v.id}
|
||||||
voucher={v}
|
voucher={v}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
selected={selected.has(v.id)}
|
selected={selectedVouchers.includes(v)}
|
||||||
onClick={() =>
|
onClick={clickCard}
|
||||||
editMode ? toggleSelect(v.id) : setViewVoucher(v)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useGlobal } from "@/contexts/GlobalContext";
|
||||||
|
|
||||||
|
export type Theme = "system" | "light" | "dark";
|
||||||
|
|
||||||
export default function ThemeSwitcher() {
|
export default function ThemeSwitcher() {
|
||||||
type themeType = "system" | "light" | "dark";
|
const { theme, setTheme } = useGlobal();
|
||||||
const [theme, setTheme] = useState<themeType>("system");
|
|
||||||
|
|
||||||
// Load saved theme
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = localStorage.getItem("theme") as themeType | null;
|
|
||||||
setTheme(stored || "system");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Apply theme class
|
|
||||||
useEffect(() => {
|
|
||||||
const html = document.documentElement;
|
|
||||||
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
const apply = () => {
|
|
||||||
// Only disable transitions on Safari
|
|
||||||
if (isSafari) {
|
|
||||||
html.classList.add("transition-disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = theme === "dark" || (theme === "system" && mql.matches);
|
|
||||||
html.classList.toggle("dark", isDark);
|
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
|
|
||||||
// Re-enable transitions after a brief delay on Safari
|
|
||||||
if (isSafari) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
html.classList.remove("transition-disabled");
|
|
||||||
}, 150);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
apply();
|
|
||||||
|
|
||||||
// For system mode, listen to changes
|
|
||||||
mql.addEventListener("change", apply);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mql.removeEventListener("change", apply);
|
|
||||||
};
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(e) => setTheme(e.target.value as any)}
|
onChange={(e) => setTheme(e.target.value as Theme)}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
<option value="system">⚙️ System</option>
|
<option value="system">⚙️ System</option>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { copyText } from "@/utils/clipboard";
|
import { copyText } from "@/utils/clipboard";
|
||||||
import { formatCode } from "@/utils/format";
|
import { formatCode } from "@/utils/format";
|
||||||
import { notify } from "@/utils/notifications";
|
import { notify } from "@/utils/notifications";
|
||||||
import { VoucherPrintWindow } from "@/components/utils/VoucherPrintContent";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Voucher } from "@/types/voucher";
|
import { Voucher } from "@/types/voucher";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
voucher: Voucher;
|
voucher: Voucher;
|
||||||
@@ -15,7 +15,7 @@ type Props = {
|
|||||||
export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
|
export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
|
||||||
const code = formatCode(voucher.code);
|
const code = formatCode(voucher.code);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [printing, setPrinting] = useState(false);
|
const router = useRouter();
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
if (await copyText(voucher.code)) {
|
if (await copyText(voucher.code)) {
|
||||||
@@ -27,6 +27,13 @@ export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
const vouchersParam = encodeURIComponent(JSON.stringify([voucher]));
|
||||||
|
const printUrl = `/print?vouchers=${vouchersParam}&mode=list`;
|
||||||
|
|
||||||
|
router.replace(printUrl);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`text-center ${contentClassName}`}>
|
<div className={`text-center ${contentClassName}`}>
|
||||||
<div
|
<div
|
||||||
@@ -39,10 +46,9 @@ export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
|
|||||||
<button onClick={handleCopy} className="btn-success">
|
<button onClick={handleCopy} className="btn-success">
|
||||||
{copied ? "Copied" : "Copy Code"}
|
{copied ? "Copied" : "Copy Code"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setPrinting(true)} className="btn-primary">
|
<button onClick={handlePrint} className="btn-primary">
|
||||||
Print Voucher
|
Print Voucher
|
||||||
</button>
|
</button>
|
||||||
{printing && <VoucherPrintWindow voucher={voucher} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
|
||||||
import { Voucher } from "@/types/voucher";
|
|
||||||
import {
|
|
||||||
formatBytes,
|
|
||||||
formatDuration,
|
|
||||||
formatGuestUsage,
|
|
||||||
formatSpeed,
|
|
||||||
} from "@/utils/format";
|
|
||||||
import { RenderInWindow } from "@/components/utils/RenderInWindow";
|
|
||||||
import { useGlobal } from "@/contexts/GlobalContext";
|
|
||||||
|
|
||||||
const baseStyles = `
|
|
||||||
@media print {
|
|
||||||
@page {
|
|
||||||
size: 80mm auto;
|
|
||||||
margin: 5mm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: white !important;
|
|
||||||
color: black;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.wrapper {
|
|
||||||
max-width: 80mm;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
.header { text-align: center; border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 15px; }
|
|
||||||
.title { font-weight: bold; font-size: 16px; margin-bottom: 5px; }
|
|
||||||
.voucher-code { font-size: 18px; font-weight: bold; text-align: center; border: 2px solid #000; padding: 10px; margin: 15px 0; }
|
|
||||||
.info-row { display: flex; justify-content: space-between; margin: 5px 0; border-bottom: 1px dotted #000; }
|
|
||||||
.label { font-weight: bold; flex: 1; }
|
|
||||||
.value { flex: 1; text-align: right; }
|
|
||||||
.qr-section { text-align: center; margin: 20px 0; border-top: 2px solid #000; padding-top: 15px; }
|
|
||||||
.footer { text-align: center; margin-top: 20px; border-top: 2px solid #000; padding-top: 10px; font-size: 10px; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
voucher: Voucher;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VoucherPrintWindow({ voucher }: Props) {
|
|
||||||
const { wifiConfig, wifiString } = useGlobal();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RenderInWindow
|
|
||||||
title={`Voucher - ${voucher.code}`}
|
|
||||||
width={500}
|
|
||||||
height={700}
|
|
||||||
onReady={(win) => {
|
|
||||||
setTimeout(() => win.print(), 100);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="wrapper">
|
|
||||||
<style>{baseStyles}</style>
|
|
||||||
|
|
||||||
<div className="header">
|
|
||||||
<div className="title">WiFi Access Voucher</div>
|
|
||||||
<div>UniFi Network</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="voucher-code">{voucher.code}</div>
|
|
||||||
|
|
||||||
<div className="info-row">
|
|
||||||
<span className="label">Duration:</span>
|
|
||||||
<span className="value">
|
|
||||||
{formatDuration(voucher.timeLimitMinutes)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-row">
|
|
||||||
<span className="label">Guests:</span>
|
|
||||||
<span className="value">
|
|
||||||
{formatGuestUsage(
|
|
||||||
voucher.authorizedGuestCount,
|
|
||||||
voucher.authorizedGuestLimit,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-row">
|
|
||||||
<span className="label">Data Limit:</span>
|
|
||||||
<span className="value">
|
|
||||||
{voucher.dataUsageLimitMBytes
|
|
||||||
? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
|
|
||||||
: "Unlimited"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-row">
|
|
||||||
<span className="label">Down Speed:</span>
|
|
||||||
<span className="value">{formatSpeed(voucher.rxRateLimitKbps)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-row">
|
|
||||||
<span className="label">Up Speed:</span>
|
|
||||||
<span className="value">{formatSpeed(voucher.txRateLimitKbps)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{wifiConfig && (
|
|
||||||
<div className="qr-section">
|
|
||||||
<div className="font-bold mb-2">Scan to Connect</div>
|
|
||||||
{wifiString && (
|
|
||||||
<QRCodeSVG
|
|
||||||
value={wifiString}
|
|
||||||
size={140}
|
|
||||||
level="H"
|
|
||||||
marginSize={4}
|
|
||||||
title="Wi-Fi Access QR Code"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="wifi-info mt-2 text-xs">
|
|
||||||
<strong>Network:</strong> {wifiConfig.ssid}
|
|
||||||
<br />
|
|
||||||
{wifiConfig.type === "nopass" ? (
|
|
||||||
"No Password"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<strong>Password:</strong> {wifiConfig.password}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{wifiConfig.hidden && <div>(Hidden Network)</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="footer">
|
|
||||||
<div>ID: {voucher.id}</div>
|
|
||||||
<div>Generated: {new Date().toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RenderInWindow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Theme } from "@/components/utils/ThemeSwitcher";
|
||||||
import {
|
import {
|
||||||
generateWifiConfig,
|
generateWifiConfig,
|
||||||
generateWiFiQRString,
|
generateWiFiQRString,
|
||||||
@@ -10,6 +11,8 @@ import React, { createContext, useContext, useEffect, useState } from "react";
|
|||||||
type GlobalContextType = {
|
type GlobalContextType = {
|
||||||
wifiConfig: WifiConfig | null;
|
wifiConfig: WifiConfig | null;
|
||||||
wifiString: string | null;
|
wifiString: string | null;
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (t: Theme) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
|
const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
|
||||||
@@ -17,23 +20,65 @@ const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
|
|||||||
export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
|
export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState<GlobalContextType>({
|
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
|
||||||
wifiConfig: null,
|
const [wifiString, setWifiString] = useState<string | null>(null);
|
||||||
wifiString: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const [theme, setTheme] = useState<Theme>("system");
|
||||||
|
|
||||||
|
// WiFi setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const wifiConfig = generateWifiConfig();
|
const cfg = generateWifiConfig();
|
||||||
const wifiString = wifiConfig ? generateWiFiQRString(wifiConfig) : null;
|
const str = cfg ? generateWiFiQRString(cfg) : null;
|
||||||
setValue({ wifiConfig, wifiString });
|
setWifiConfig(cfg);
|
||||||
|
setWifiString(str);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Could not generate WiFi configuration: ${e}`);
|
console.warn(`Could not generate WiFi configuration: ${e}`);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load theme on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("theme") as Theme | null;
|
||||||
|
if (stored) setTheme(stored);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme when changed
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (isSafari) html.classList.add("transition-disabled");
|
||||||
|
|
||||||
|
const isDark = theme === "dark" || (theme === "system" && mql.matches);
|
||||||
|
html.classList.toggle("dark", isDark);
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
|
||||||
|
if (isSafari) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => html.classList.remove("transition-disabled"), 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
mql.addEventListener("change", apply);
|
||||||
|
return () => mql.removeEventListener("change", apply);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
|
<GlobalContext.Provider
|
||||||
|
value={{
|
||||||
|
wifiConfig,
|
||||||
|
wifiString,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</GlobalContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ export function formatCode(code: string) {
|
|||||||
return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code;
|
return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatMaxGuests(maxGuests: number | null | undefined) {
|
||||||
|
return !maxGuests ? "Unlimited" : Math.max(maxGuests, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDuration(m: number | null | undefined) {
|
export function formatDuration(m: number | null | undefined) {
|
||||||
if (!m) return "Unlimited";
|
if (!m) return "Unlimited";
|
||||||
const days = Math.floor(m / 1440),
|
const days = Math.floor(m / 1440),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getRuntimeConfig } from "@/utils/runtimeConfig";
|
import { getRuntimeConfig } from "@/utils/runtimeConfig";
|
||||||
|
|
||||||
// Derive the type from the array
|
// Derive the type from the array for easy printing
|
||||||
const validWifiTypes = ["WPA", "WEP", "nopass"] as const;
|
const validWifiTypes = ["WPA", "WEP", "nopass"] as const;
|
||||||
type WifiType = (typeof validWifiTypes)[number];
|
type WifiType = (typeof validWifiTypes)[number];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user