feat(orders): add additional attachments panel to Send PO modal
This commit is contained in:
parent
2b3b50596c
commit
14e1a12cfe
1 changed files with 119 additions and 0 deletions
|
|
@ -36,6 +36,7 @@ import {
|
|||
RefreshCw,
|
||||
History,
|
||||
Send,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -108,6 +109,13 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
// build a non-misleading body, so the textarea is disabled until
|
||||
// the count is resolved.
|
||||
const [sendMessageLoading, setSendMessageLoading] = useState(false);
|
||||
// Additional attachments (supplier quote, drawing, NDA, etc.) sent
|
||||
// alongside the PO PDF. PB stores these as file tokens in
|
||||
// `po.additional_attachments`; the modal reads them on open and
|
||||
// writes back via the upload/delete routes.
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
|
||||
const [attachmentUploading, setAttachmentUploading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -331,6 +339,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
toast.error("Couldn't load line count for preview; check the PO before sending.");
|
||||
} finally {
|
||||
setSendMessageLoading(false);
|
||||
// Seed the attachments list from the PO record that just opened
|
||||
// the modal. PB returns a multi-file field as either an array
|
||||
// or (for legacy single-file rows) a bare string — normalise
|
||||
// to string[] so the rest of the modal can treat it uniformly.
|
||||
const rawAtt = po.additional_attachments ?? [];
|
||||
setAttachments(Array.isArray(rawAtt) ? rawAtt : rawAtt ? [rawAtt] : []);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -402,6 +416,58 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
}
|
||||
};
|
||||
|
||||
// ─── Additional attachments handlers ──────────────────────────
|
||||
// Upload one or more files to `po.additional_attachments` via the
|
||||
// dedicated route. We refresh `attachments` from each successful
|
||||
// response so the list always reflects the canonical PB state.
|
||||
const uploadAttachments = async (files: FileList | null) => {
|
||||
if (!sendPo || !files || files.length === 0) return;
|
||||
setAttachmentUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch(
|
||||
`/api/v1/purchase-orders/${sendPo.id}/attachments/upload`,
|
||||
{ method: "POST", body: fd },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
toast.error(
|
||||
`Upload failed for ${file.name}: ${body.error ?? res.status}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const data = (await res.json()) as { attachments: string[] };
|
||||
setAttachments(data.attachments ?? []);
|
||||
}
|
||||
} finally {
|
||||
setAttachmentUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a single attachment by its PB token. The token is what
|
||||
// appears in `additional_attachments[]`; it may differ from the
|
||||
// operator's original filename because PB slug-normalises on disk.
|
||||
const removeAttachment = async (token: string) => {
|
||||
if (!sendPo) return;
|
||||
setAttachmentUploading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/purchase-orders/${sendPo.id}/attachments/${encodeURIComponent(token)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't remove attachment");
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { attachments: string[] };
|
||||
setAttachments(data.attachments ?? []);
|
||||
} finally {
|
||||
setAttachmentUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Order-level "Complete" — derived from items, not stored anywhere.
|
||||
const complete = isOrderComplete(items);
|
||||
|
||||
|
|
@ -1100,6 +1166,59 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
: `${sendMessage.length}/2000`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Additional attachments</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Files sent alongside the PO PDF (supplier quotes, drawings, etc.).
|
||||
</p>
|
||||
</div>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs hover:bg-accent">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{attachmentUploading ? "Uploading…" : "Attach files"}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => uploadAttachments(e.target.files)}
|
||||
disabled={attachmentUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{attachments.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No additional files attached.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{attachments.map((token) => (
|
||||
<li
|
||||
key={token}
|
||||
className="flex items-center justify-between gap-2 rounded border bg-muted/30 px-2 py-1 text-xs"
|
||||
>
|
||||
<a
|
||||
href={sendPo ? `/api/v1/files/purchase_orders/${sendPo.id}/${encodeURIComponent(token)}` : "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-foreground hover:underline"
|
||||
>
|
||||
{token}
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeAttachment(token)}
|
||||
disabled={attachmentUploading}
|
||||
aria-label={`Remove ${token}`}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{sendPo?.status === "mail_failed" && sendPo?.email_error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span className="font-medium">Previous failure:</span>{" "}
|
||||
|
|
|
|||
Loading…
Reference in a new issue