Add collection confirm rewrite implementation plan

This commit is contained in:
SJCoetz 2026-07-10 01:00:08 +02:00
parent 683b6c8d0a
commit d4b3f7c041

View file

@ -0,0 +1,440 @@
# Collection Confirm Rewrite — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rewrite the Collection Confirm row card layout (no more 3-column cram) and fix the actor hydration so the "actor id is empty" toast never fires for a signed-in operator.
**Architecture:** One file (`src/app/(app)/collection-confirm/page.tsx`) gets two changes: a stacked-vertical `ConfirmCard` JSX rewrite, and a cookie-based actor hydration effect.
**Tech Stack:** React 19, shadcn/ui primitives, existing `getClientIdentity` helper.
**Spec:** [docs/superpowers/specs/2026-07-09-collection-confirm-rewrite-design.md](file:///Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/docs/superpowers/specs/2026-07-09-collection-confirm-rewrite-design.md)
---
## File Structure
| File | Responsibility | Touched? |
| --- | --- | --- |
| `src/app/(app)/collection-confirm/page.tsx` | Cookie-based actor hydration, identity pill, rewritten `ConfirmCard` | Modify |
---
## Task 1: Cookie-based actor hydration
**Files:**
- Modify: `src/app/(app)/collection-confirm/page.tsx`
- [ ] **Step 1: Update the imports**
Add `getClientIdentity` to the existing `@/lib/dev-identity` import. Find the import block (around line 1–12). Add the new import. If `@/lib/dev-identity` is not yet imported, add a fresh import line.
```ts
import { getClientIdentity } from "@/lib/dev-identity";
```
(Keep all existing imports.)
- [ ] **Step 2: Replace the localStorage hydration effect**
Find the `useEffect` that reads `localStorage.wfdash.actor` (around lines 151–163). Replace it with a cookie-based hydration:
```ts
useEffect(() => {
if (typeof window === "undefined") return;
// The dev-identity switcher writes the active email to the
// `wfdash_dev_email` cookie. The earlier localStorage-based
// hydration never matched because nothing wrote to localStorage.
// getClientIdentity() returns the cookie value or the default
// (DEFAULT_DEV_EMAIL) when unset, so the actor is never empty
// for a signed-in operator.
const email = getClientIdentity();
if (!email) return;
const name = email.split("@")[0] || email;
setActor({ id: email, name });
}, []);
```
- [ ] **Step 3: Lint**
```bash
npx eslint 'src/app/(app)/collection-confirm/page.tsx' 2>&1 | tail -n 6
```
Expected: exit 0. Pre-existing errors on unrelated lines are fine.
- [ ] **Step 4: Commit (hydration only)**
```bash
git add 'src/app/(app)/collection-confirm/page.tsx'
git commit -m "fix(collection-confirm): hydrate actor from dev-identity cookie, not localStorage"
```
---
## Task 2: Identity pill in the page header
**Files:**
- Modify: `src/app/(app)/collection-confirm/page.tsx`
- [ ] **Step 1: Add a `displayName` state for the header pill**
Right after the existing `actor` state declaration (line 111), add:
```ts
// Display name for the header pill. Mirrors the dev-identity
// switcher's `shortName` helper. Kept in state (rather than
// recomputed on every render) so the pill is stable across
// re-renders triggered by the row data.
const [displayName, setDisplayName] = useState<string>("");
```
- [ ] **Step 2: Populate the display name in the same effect as Task 1**
Inside the `useEffect` from Task 1, add the side effect to set the pill. The new effect body:
```ts
useEffect(() => {
if (typeof window === "undefined") return;
const email = getClientIdentity();
if (!email) return;
const name = email.split("@")[0] || email;
setActor({ id: email, name });
setDisplayName(name);
}, []);
```
- [ ] **Step 3: Add the pill to the page header**
Find the page `<header>` block (around lines 372–385). Update it to add a `displayName` pill next to the existing `Refresh` button. The new header markup:
```tsx
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Truck className="h-5 w-5 text-muted-foreground" />
Collection Confirm
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Review collected order items, adjust quantity, add notes, and confirm for invoicing.
</p>
</div>
<div className="flex items-center gap-2">
<span
title={displayName}
aria-label={`Signed in as ${displayName}`}
className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground"
>
<UserRound className="h-3 w-3" />
<span className="font-mono">{displayName || "—"}</span>
</span>
<Button variant="outline" size="sm" onClick={() => void load()}>
Refresh
</Button>
</div>
</header>
```
Note: `UserRound` is already imported in the page's existing `lucide-react` import block. If you need a different icon (the spec uses `User`; the existing page imports `UserRound`), prefer `UserRound` since it’s already in scope. If `UserRound` is not in the import list, add it.
- [ ] **Step 4: Lint**
```bash
npx eslint 'src/app/(app)/collection-confirm/page.tsx' 2>&1 | tail -n 6
```
Expected: exit 0. Pre-existing errors on unrelated lines are fine.
- [ ] **Step 5: Commit**
```bash
git add 'src/app/(app)/collection-confirm/page.tsx'
git commit -m "feat(collection-confirm): show signed-in-as pill in the page header"
```
---
## Task 3: Rewrite `ConfirmCard` JSX (stacked vertical layout)
**Files:**
- Modify: `src/app/(app)/collection-confirm/page.tsx`
- [ ] **Step 1: Replace the `ConfirmCard` return value**
Find the entire `return` block of `ConfirmCard` (starts at the `return (` after the `const entries = ...` line, ends at the `</li>` before the closing brace of the function). Replace it with the new stacked-vertical layout:
```tsx
return (
<li className="overflow-hidden rounded-xl border bg-card shadow-sm">
{/* ── Header: checkbox + supplier + description + links ─── */}
<div className="flex items-start gap-3 p-4">
<div className="pt-1">
<Checkbox
aria-label="Select item"
checked={selected}
onCheckedChange={() => onToggleSelected(row.id)}
disabled={busy}
/>
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="truncate text-sm font-semibold">
{row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"}
</p>
<p className="truncate text-sm text-foreground">{row.description || "(no description)"}</p>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
{row.expand?.order?.expand?.customer?.customer && (
<span className="inline-flex items-center gap-1">
<UserRound className="h-3 w-3" />
{row.expand.order.expand.customer.customer}
</span>
)}
{row.expand?.order?.expand?.company?.company && (
<span className="inline-flex items-center gap-1">
<Building2 className="h-3 w-3" />
{row.expand.order.expand.company.company}
</span>
)}
{row.expand?.purchase_order?.po_number && (
<span className="inline-flex items-center gap-1 font-mono">
<FileText className="h-3 w-3" />
{row.expand.purchase_order.po_number}
</span>
)}
{row.expand?.order && (
<Link href={`/orders/${row.expand.order.id}`} className="inline-flex items-center gap-1 hover:underline">
Order {row.expand.order.order_number ?? row.expand.order.id.slice(0, 6)}
<ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
</div>
</div>
{/* ── Details: qty + problem + invoice + attachments ─────── */}
<div className="grid grid-cols-1 gap-3 border-t bg-muted/20 px-4 py-3 md:grid-cols-2">
<div className="space-y-1 text-sm">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Ordered</span>
<span className="font-mono">{row.order_qty ?? row.quantity ?? 0}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Collected</span>
<span className="font-mono">{row.qty_collected ?? 0}</span>
</div>
{row.problem_flag && (
<div className="inline-flex items-center gap-1 rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
<AlertTriangle className="h-3 w-3" />
Problem: {row.problem_type ?? "flagged"}
</div>
)}
{row.collection_photo && (
<Button size="xs" variant="outline" onClick={() => setPhotoOpen(true)}>
<ImageIcon className="mr-1 h-3 w-3" />
View photo
</Button>
)}
</div>
<div className="space-y-1 text-sm">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{attachments.map((name) => (
<a
key={name}
href={attachmentUrl(row, name)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-0.5 text-xs hover:bg-accent"
>
<Paperclip className="h-3 w-3" />
{displayFilename(name)}
</a>
))}
</div>
)}
{invoice && (
<div className="inline-flex items-center gap-1 rounded-md bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
Invoice {invoice.number} · {invoice.date}
</div>
)}
</div>
</div>
{/* ── Actions: 2-up grid on sm, 3-up on lg, primary spans 2 ── */}
<div className="grid grid-cols-1 gap-2 border-t px-4 py-3 sm:grid-cols-2 lg:grid-cols-3">
<Button size="sm" variant="outline" onClick={() => setQuantityOpen(true)} disabled={busy}>
<Pencil className="mr-1 h-3.5 w-3.5" />
Adjust Quantity
</Button>
<Button size="sm" variant="outline" onClick={() => setNoteOpen(true)} disabled={busy}>
<MessageSquare className="mr-1 h-3.5 w-3.5" />
Add Note
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAttachOpen(true)}
disabled={busy}
aria-label="Attach invoice"
>
<Paperclip className="mr-1 h-3.5 w-3.5" />
Attach
</Button>
<Button
size="sm"
disabled={busy || !canConfirm}
onClick={() => void onConfirm(row)}
className="bg-emerald-600 text-white hover:bg-emerald-700 lg:col-span-2"
>
{busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-1 h-3.5 w-3.5" />}
Confirm for invoicing
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onRequestReturn(row)}
disabled={busy}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
Return to pool
</Button>
{!canConfirm && (
<p className="text-[11px] text-muted-foreground sm:col-span-2 lg:col-span-3">
Add a confirming note before invoicing this row.
</p>
)}
</div>
{/* ── Notes feed (unchanged) ─────────────────────────── */}
<div className="border-t bg-muted/30 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Notes</p>
{entries.length === 0 ? (
<p className="mt-1 text-xs text-muted-foreground">No notes yet.</p>
) : (
<ul className="mt-2 space-y-2">
{entries.map((entry, idx) => (
<li key={`${entry.ts}-${idx}`} className="rounded-md border bg-card p-2 text-xs">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{new Date(entry.ts).toLocaleString()}</span>
<span>·</span>
<span>{entry.actorName}</span>
<span>·</span>
<span className="font-semibold uppercase tracking-wide">{categoryLabel(entry.category)}</span>
</div>
<p className="mt-1 text-foreground">{entry.body}</p>
</li>
))}
</ul>
)}
</div>
{/* ── Dialogs (unchanged) ────────────────────────────── */}
<QuantityDialog
open={quantityOpen}
row={row}
busy={busy}
onClose={() => setQuantityOpen(false)}
onSubmit={(qty, reason) => {
setQuantityOpen(false);
return onQuantity(row, qty, reason);
}}
/>
<NoteDialog
open={noteOpen}
busy={busy}
problemFlag={!!row.problem_flag}
onClose={() => setNoteOpen(false)}
onSubmit={(body, options) => {
setNoteOpen(false);
return onAddNote(row, body, options);
}}
/>
<ModalShell.Dialog open={photoOpen} onOpenChange={setPhotoOpen} size="lg" title="Collection photo">
{row.collection_photo ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={fileUrl(row, row.collection_photo)} alt="Collection photo" className="max-h-[60vh] w-full rounded-md object-contain" />
) : (
<p className="text-sm text-muted-foreground">No photo attached.</p>
)}
</ModalShell.Dialog>
<AttachDialog
open={attachOpen}
busy={busy}
onClose={() => setAttachOpen(false)}
onSubmit={async (file) => {
setAttachOpen(false);
await onAttach(row, file);
}}
/>
<ConfirmInvoiceDialog
open={isConfirmingThis}
row={row}
busy={busy}
bulkRows={confirmModal?.bulkRows}
onClose={onCloseConfirm}
onSubmit={async (invoice) => {
if (confirmModal?.bulkRows && confirmModal.bulkRows.length > 0) {
await onSubmitBulkConfirm(confirmModal.bulkRows, invoice);
} else {
await onSubmitConfirm(row, invoice);
}
}}
/>
</li>
);
```
- [ ] **Step 2: Lint**
```bash
npx eslint 'src/app/(app)/collection-confirm/page.tsx' 2>&1 | tail -n 12
```
Expected: exit 0. Pre-existing errors on unrelated lines (lines we already saw in the audit — 278 `setBy` type) are fine.
- [ ] **Step 3: Run the page tests (if any)**
```bash
npm run test:run -- 'src/app/(app)/collection-confirm'
```
Expected: same passing set as before (we have a pre-existing failure on `view-model.test.ts:278` for the `setBy` type — unrelated to this change). No new failures.
- [ ] **Step 4: Commit**
```bash
git add 'src/app/(app)/collection-confirm/page.tsx'
git commit -m "feat(collection-confirm): rewrite row card as stacked vertical sections"
```
---
## Task 4: Final verification
- [ ] **Step 1: Targeted tests**
```bash
npm run test:run -- 'src/app/(app)/collection-confirm'
```
Expected: same passing set as before, no new failures.
- [ ] **Step 2: Full suite**
```bash
npm run test:run
```
Expected: same passing set as before. Pre-existing failures are acceptable.
- [ ] **Step 3: Manual smoke**
1. Open the Collection Confirm page.
2. Confirm the page header shows a "Signed in as X" pill (the local-part of your dev identity).
3. Confirm every row card reads top-to-bottom: header → details (qty + problem + attachments + invoice) → actions (in a 2-up grid on small screens, 3-up on large) → notes feed.
4. Pick a row with `Confirm for invoicing` enabled. Click it. The Confirm Invoice dialog opens.
5. Enter a date and invoice number, click `Confirm`. The submit succeeds — **no "actor id is empty" toast**.
6. If the row is fresh in dev (cookie unset), the page should still hydrate to `DEFAULT_DEV_EMAIL` (`david.els1@outlook.com`) and the confirm should work.