stage 4
Build and Push Docker Image / build (push) Successful in 1m6s

This commit is contained in:
jason
2026-04-21 09:29:44 -05:00
parent 41b06f89c0
commit fc5bce4868
19 changed files with 1469 additions and 190 deletions
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
@@ -222,6 +222,7 @@ function OperationsSection({
}) {
const [newOpen, setNewOpen] = useState(false);
const [edit, setEdit] = useState<OperationRow | null>(null);
const [qrFor, setQrFor] = useState<OperationRow | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -324,6 +325,9 @@ function OperationsSection({
>
</button>
<Button variant="ghost" size="sm" onClick={() => setQrFor(op)} disabled={busyId !== null}>
QR
</Button>
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
Edit
</Button>
@@ -374,10 +378,93 @@ function OperationsSection({
}}
/>
)}
{qrFor && <QrModal operation={qrFor} onClose={() => setQrFor(null)} />}
</section>
);
}
function QrModal({ operation, onClose }: { operation: OperationRow; onClose: () => void }) {
const [data, setData] = useState<
{ dataUrl: string; scanUrl: string; token: string } | null
>(null);
const [error, setError] = useState<string | null>(null);
// Fetch lazily so we don't pre-render QRs for every op on the page. The
// data URL is ~1 KB so this is cheap, but it does require a server hop.
useEffect(() => {
let cancelled = false;
apiFetch<{ dataUrl: string; scanUrl: string; token: string }>(
`/api/v1/operations/${operation.id}/qr`,
)
.then((d) => {
if (!cancelled) setData(d);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiClientError ? err.message : "Load failed");
});
return () => {
cancelled = true;
};
}, [operation.id]);
return (
<Modal
open
onClose={onClose}
title={`QR: step ${operation.sequence}. ${operation.name}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{data ? (
<a
href={data.dataUrl}
download={`op-${operation.sequence}-${operation.id}.png`}
className="inline-flex items-center rounded-md bg-slate-900 text-white text-sm px-3 py-1.5 hover:bg-slate-800"
>
Download PNG
</a>
) : null}
</>
}
>
<div className="space-y-3">
{error ? (
<ErrorBanner message={error} />
) : data ? (
<>
<div className="flex justify-center bg-white border border-slate-200 rounded-md p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.dataUrl}
alt={`QR for ${operation.name}`}
width={256}
height={256}
/>
</div>
<div className="text-xs text-slate-600 space-y-1">
<div>
<span className="font-medium">Scan URL:</span>{" "}
<a href={data.scanUrl} className="text-blue-600 hover:underline break-all">
{data.scanUrl}
</a>
</div>
<div>
<span className="font-medium">Token:</span>{" "}
<code className="text-slate-500">{data.token}</code>
</div>
</div>
</>
) : (
<div className="text-center text-slate-500 text-sm py-10">Rendering QR</div>
)}
</div>
</Modal>
);
}
function OperationModal({
partId,
operation,