Adds NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED. When on, a CLOSED PO's own
submitter -- plus Accounts / Manager / SuperUser -- can attach documents to
it, so POs whose uploads were lost to the document-upload bug can be fixed
without reopening them. Off by default, so production stays unchanged until
enabled.
- lib/permissions.ts: canAddClosedPoAttachment(role, { isSubmitter }) gated
by the flag; allowed roles are ACCOUNTS/MANAGER/SUPERUSER (plus the PO's
own submitter regardless of role).
- uploadPoDocuments: a CLOSED PO is otherwise immutable, so it now enforces
the permission server-side; the normal create/receipt flows upload while
the PO is pre-CLOSED and are unaffected.
- po-detail.tsx: when allowed, the Attachments card renders an uploader
(ClosedPoAttachmentUploader) and shows even when the PO has no docs yet.
- Enabled on staging (staging-up.sh) so the remediation can be exercised;
documented in .env.example and CLAUDE.md.
Tests: closed-po-attachments.test.ts covers the flag-on role matrix (own
submitter / Accounts / Manager / SuperUser allowed; other submitter-role and
auditor refused; non-closed PO unaffected); po-document-upload.test.ts adds
the flag-off case (closed PO stays immutable). Full unit + integration suites
green; tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
55 lines
2 KiB
TypeScript
55 lines
2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { FileUploader } from "@/components/po/file-uploader";
|
|
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
|
|
|
|
/**
|
|
* Feature-flagged uploader shown on a CLOSED PO's detail page so its submitter (or
|
|
* Accounts / Manager / SuperUser) can attach documents that were lost to the upload
|
|
* bug. Gating is decided server-side in po-detail.tsx; the server action re-checks
|
|
* the permission, so this component is only the UI.
|
|
*/
|
|
export function ClosedPoAttachmentUploader({ poId }: { poId: string }) {
|
|
const router = useRouter();
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
async function handleUpload() {
|
|
if (files.length === 0) return;
|
|
setBusy(true);
|
|
setError("");
|
|
const err = await uploadPoDocuments(poId, files);
|
|
if (err) {
|
|
setError(err.error);
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
setFiles([]);
|
|
setBusy(false);
|
|
router.refresh();
|
|
}
|
|
|
|
return (
|
|
<div className="mt-5 border-t border-neutral-100 pt-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Add attachments</p>
|
|
<p className="mt-0.5 text-xs text-neutral-400">
|
|
This purchase order is closed. Attach any documents that are missing.
|
|
</p>
|
|
<div className="mt-3">
|
|
<FileUploader files={files} onChange={setFiles} disabled={busy} />
|
|
</div>
|
|
{error && <p className="mt-2 text-sm text-danger-700">{error}</p>}
|
|
<button
|
|
type="button"
|
|
onClick={handleUpload}
|
|
disabled={busy || files.length === 0}
|
|
className="mt-3 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{busy ? "Uploading…" : `Upload${files.length > 0 ? ` ${files.length} file${files.length > 1 ? "s" : ""}` : ""}`}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|