Add collection confirm rewrite implementation plan
This commit is contained in:
parent
683b6c8d0a
commit
d4b3f7c041
1 changed files with 440 additions and 0 deletions
440
docs/superpowers/plans/2026-07-09-collection-confirm-rewrite.md
Normal file
440
docs/superpowers/plans/2026-07-09-collection-confirm-rewrite.md
Normal 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.
|
||||
Loading…
Reference in a new issue