560 lines
22 KiB
Markdown
560 lines
22 KiB
Markdown
|
|
# Compact DN Row + Order Age Badge 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:** Two small, independent rendering improvements: enrich each delivery-note row on `/deliveries` with customer / company / order / signed-copy info, and replace the bare date on `/orders` cards with a colour-coded age badge.
|
||
|
|
|
||
|
|
**Architecture:** Pure UI/rendering work. One new pure-function util (`src/lib/order-age.ts`) with full TDD; two existing pages (`/deliveries`, `/orders`) get small additive edits. No server changes, no PB schema changes, no MCP tool changes.
|
||
|
|
|
||
|
|
**Tech Stack:** Next.js + React 19 (existing). Vitest (existing) for the util test. Tailwind classes for the badge colours (existing).
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File structure
|
||
|
|
|
||
|
|
| Action | File | Responsibility |
|
||
|
|
|----------|------|----------------|
|
||
|
|
| Create | `src/lib/order-age.ts` | Pure: `getOrderAge(created, now?)` + `bucketToBadgeClass(bucket)`. Calendar-day delta in Africa/Johannesburg. |
|
||
|
|
| Create | `src/lib/order-age.test.ts` | Vitest unit tests for every bucket threshold + future-date clamp + JNB-tz edge case + badge-class stability. |
|
||
|
|
| Modify | `src/app/(app)/orders/page.tsx` | Add `OrderAgeBadge` inline component + import `getOrderAge` / `bucketToBadgeClass`. Replace `{formatDate(order.created)}` with `<OrderAgeBadge created={order.created} … />` at line ~866. |
|
||
|
|
| Modify | `src/app/(app)/deliveries/page.tsx` | Edit `DeliveryNoteRowComponent` only (~lines 1533-1547). Replace the subline with the compact pieces list (customer, company, order, paperclip). |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Order-age util (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/lib/order-age.ts`
|
||
|
|
- Create: `src/lib/order-age.test.ts`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing tests**
|
||
|
|
|
||
|
|
Write `src/lib/order-age.test.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { describe, it, expect } from "vitest";
|
||
|
|
import { getOrderAge, bucketToBadgeClass } from "./order-age";
|
||
|
|
|
||
|
|
describe("getOrderAge", () => {
|
||
|
|
// Anchor `now` so the tests don't drift with wall-clock time.
|
||
|
|
// 2026-09-01 12:00 in Africa/Johannesburg = 2026-09-01T10:00:00Z
|
||
|
|
const NOW = new Date("2026-09-01T10:00:00.000Z");
|
||
|
|
|
||
|
|
it("returns daysOld=0 with label 'today' when created today", () => {
|
||
|
|
const result = getOrderAge("2026-09-01T08:00:00.000Z", NOW);
|
||
|
|
expect(result).toEqual({ daysOld: 0, bucket: "fresh", label: "today" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=1 with bucket fresh for 1-day-old orders", () => {
|
||
|
|
const result = getOrderAge("2026-08-31T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(1);
|
||
|
|
expect(result.bucket).toBe("fresh");
|
||
|
|
expect(result.label).toBe("1d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=2 with bucket fresh at the upper edge of fresh", () => {
|
||
|
|
const result = getOrderAge("2026-08-30T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(2);
|
||
|
|
expect(result.bucket).toBe("fresh");
|
||
|
|
expect(result.label).toBe("2d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=3 with bucket recent at the lower edge of recent", () => {
|
||
|
|
const result = getOrderAge("2026-08-29T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(3);
|
||
|
|
expect(result.bucket).toBe("recent");
|
||
|
|
expect(result.label).toBe("3d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=7 with bucket recent at the upper edge of recent", () => {
|
||
|
|
const result = getOrderAge("2026-08-25T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(7);
|
||
|
|
expect(result.bucket).toBe("recent");
|
||
|
|
expect(result.label).toBe("7d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=8 with bucket slipping at the lower edge of slipping", () => {
|
||
|
|
const result = getOrderAge("2026-08-24T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(8);
|
||
|
|
expect(result.bucket).toBe("slipping");
|
||
|
|
expect(result.label).toBe("8d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=14 with bucket slipping at the upper edge of slipping", () => {
|
||
|
|
const result = getOrderAge("2026-08-18T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(14);
|
||
|
|
expect(result.bucket).toBe("slipping");
|
||
|
|
expect(result.label).toBe("14d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=15 with bucket stale at the lower edge of stale", () => {
|
||
|
|
const result = getOrderAge("2026-08-17T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(15);
|
||
|
|
expect(result.bucket).toBe("stale");
|
||
|
|
expect(result.label).toBe("15d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns daysOld=365 with bucket stale for very old orders", () => {
|
||
|
|
const result = getOrderAge("2025-09-01T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(365);
|
||
|
|
expect(result.bucket).toBe("stale");
|
||
|
|
expect(result.label).toBe("365d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("clamps future-dated created to daysOld=0 (clock skew tolerance)", () => {
|
||
|
|
const result = getOrderAge("2026-09-02T08:00:00.000Z", NOW);
|
||
|
|
expect(result.daysOld).toBe(0);
|
||
|
|
expect(result.bucket).toBe("fresh");
|
||
|
|
expect(result.label).toBe("today");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("computes calendar-day delta in Africa/Johannesburg, not millisecond delta", () => {
|
||
|
|
// 23:00 SAST on 2026-08-31 (UTC+2) → 21:00 UTC
|
||
|
|
// 01:00 SAST on 2026-09-01 → 23:00 UTC on 2026-08-31
|
||
|
|
// Both are in the SAME calendar day in JNB (Sep 1 vs Aug 31).
|
||
|
|
// Millisecond delta is 2 hours, but the day delta must be 1.
|
||
|
|
const created = new Date("2026-08-31T21:00:00.000Z"); // 23:00 SAST Aug 31
|
||
|
|
const now = new Date("2026-08-31T23:00:00.000Z"); // 01:00 SAST Sep 1
|
||
|
|
const result = getOrderAge(created, now);
|
||
|
|
expect(result.daysOld).toBe(1);
|
||
|
|
expect(result.label).toBe("1d old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("treats an order created at 23:00 yesterday JNB and read at 01:00 today JNB as today", () => {
|
||
|
|
// Same calendar day in JNB despite the millisecond delta.
|
||
|
|
const created = new Date("2026-08-31T21:00:00.000Z"); // 23:00 SAST Aug 31
|
||
|
|
const now = new Date("2026-09-01T09:00:00.000Z"); // 11:00 SAST Sep 1 → 1d old actually
|
||
|
|
// 11:00 SAST Sep 1 vs 23:00 SAST Aug 31 = next calendar day in JNB.
|
||
|
|
// For a SAME-day case, swap now to 21:00 SAST Aug 31 = 19:00 UTC Aug 31.
|
||
|
|
const sameDayNow = new Date("2026-08-31T19:00:00.000Z");
|
||
|
|
const result = getOrderAge(created, sameDayNow);
|
||
|
|
expect(result.daysOld).toBe(0);
|
||
|
|
expect(result.label).toBe("today");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("bucketToBadgeClass", () => {
|
||
|
|
it("returns a non-empty string for every bucket", () => {
|
||
|
|
expect(bucketToBadgeClass("fresh").length).toBeGreaterThan(0);
|
||
|
|
expect(bucketToBadgeClass("recent").length).toBeGreaterThan(0);
|
||
|
|
expect(bucketToBadgeClass("slipping").length).toBeGreaterThan(0);
|
||
|
|
expect(bucketToBadgeClass("stale").length).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("is stable — same input returns same output", () => {
|
||
|
|
expect(bucketToBadgeClass("fresh")).toBe(bucketToBadgeClass("fresh"));
|
||
|
|
expect(bucketToBadgeClass("recent")).toBe(bucketToBadgeClass("recent"));
|
||
|
|
expect(bucketToBadgeClass("slipping")).toBe(bucketToBadgeClass("slipping"));
|
||
|
|
expect(bucketToBadgeClass("stale")).toBe(bucketToBadgeClass("stale"));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```bash
|
||
|
|
npx vitest run src/lib/order-age.test.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: FAIL — `Cannot find module './order-age'` (or similar import error). The util doesn't exist yet.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write the implementation**
|
||
|
|
|
||
|
|
Write `src/lib/order-age.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
/**
|
||
|
|
* Order-age utility — calendar-day delta + colour-bucket mapping for
|
||
|
|
* the `/orders` list card age badge. See:
|
||
|
|
* docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md
|
||
|
|
*
|
||
|
|
* Why a separate util: keeps the page component free of date math;
|
||
|
|
* trivial to unit-test in isolation; pure function (no React, no PB).
|
||
|
|
*/
|
||
|
|
|
||
|
|
export type OrderAgeBucket = "fresh" | "recent" | "slipping" | "stale";
|
||
|
|
|
||
|
|
export interface OrderAge {
|
||
|
|
/** Integer calendar-day delta. 0 means "today". Never negative. */
|
||
|
|
daysOld: number;
|
||
|
|
bucket: OrderAgeBucket;
|
||
|
|
/** Human-readable badge label. */
|
||
|
|
label: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Office timezone — the rest of the app doesn't deal in TZs explicitly,
|
||
|
|
* but the age badge reads "today" if the order was created on the same
|
||
|
|
* calendar day in JNB, not if it's been less than 24h since `created`.
|
||
|
|
* Hardcoded for now; centralise if a second locale ever needs it.
|
||
|
|
*/
|
||
|
|
const OFFICE_TZ = "Africa/Johannesburg";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a Date as YYYY-MM-DD in the given IANA timezone.
|
||
|
|
* Used to compute the calendar-day delta independent of the
|
||
|
|
* machine's local timezone (vitest runs in UTC by default).
|
||
|
|
*/
|
||
|
|
function toLocalDayKey(d: Date, tz: string): string {
|
||
|
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||
|
|
timeZone: tz,
|
||
|
|
year: "numeric",
|
||
|
|
month: "2-digit",
|
||
|
|
day: "2-digit",
|
||
|
|
}).formatToParts(d);
|
||
|
|
const y = parts.find((p) => p.type === "year")!.value;
|
||
|
|
const m = parts.find((p) => p.type === "month")!.value;
|
||
|
|
const day = parts.find((p) => p.type === "day")!.value;
|
||
|
|
return `${y}-${m}-${day}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute the age of an order relative to `now` (defaults to `new
|
||
|
|
* Date()`). The `daysOld` value is a **calendar-day** delta in the
|
||
|
|
* office's local timezone (Africa/Johannesburg) — an order created at
|
||
|
|
* 23:00 JNB yesterday read at 01:00 JNB today is `daysOld = 0`
|
||
|
|
* ("today"), not 1. The day delta is computed as
|
||
|
|
* `(localTodayKey - localCreatedKey)` parsed as UTC midnight dates
|
||
|
|
* and subtracted. Never returns a negative number; future-dated
|
||
|
|
* `created` values clamp to 0.
|
||
|
|
*/
|
||
|
|
export function getOrderAge(
|
||
|
|
created: string | Date,
|
||
|
|
now: Date = new Date(),
|
||
|
|
): OrderAge {
|
||
|
|
const createdDate = typeof created === "string" ? new Date(created) : created;
|
||
|
|
|
||
|
|
const createdKey = toLocalDayKey(createdDate, OFFICE_TZ);
|
||
|
|
const todayKey = toLocalDayKey(now, OFFICE_TZ);
|
||
|
|
|
||
|
|
// Both keys parse as UTC midnight, so subtraction yields whole days.
|
||
|
|
const createdMs = new Date(`${createdKey}T00:00:00Z`).getTime();
|
||
|
|
const todayMs = new Date(`${todayKey}T00:00:00Z`).getTime();
|
||
|
|
const rawDays = Math.round((todayMs - createdMs) / 86_400_000);
|
||
|
|
const daysOld = rawDays < 0 ? 0 : rawDays;
|
||
|
|
|
||
|
|
let bucket: OrderAgeBucket;
|
||
|
|
if (daysOld <= 2) bucket = "fresh";
|
||
|
|
else if (daysOld <= 7) bucket = "recent";
|
||
|
|
else if (daysOld <= 14) bucket = "slipping";
|
||
|
|
else bucket = "stale";
|
||
|
|
|
||
|
|
const label = daysOld === 0 ? "today" : `${daysOld}d old`;
|
||
|
|
|
||
|
|
return { daysOld, bucket, label };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Map an age bucket to a tailwind className for the badge. Initial
|
||
|
|
* palette per the spec; centralise here so a future palette tweak is
|
||
|
|
* a one-line change.
|
||
|
|
*/
|
||
|
|
export function bucketToBadgeClass(bucket: OrderAgeBucket): string {
|
||
|
|
switch (bucket) {
|
||
|
|
case "fresh":
|
||
|
|
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300";
|
||
|
|
case "recent":
|
||
|
|
return "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300";
|
||
|
|
case "slipping":
|
||
|
|
return "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300";
|
||
|
|
case "stale":
|
||
|
|
return "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
```bash
|
||
|
|
npx vitest run src/lib/order-age.test.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: all tests pass. 14 tests total (12 in `getOrderAge` describe, 2 in `bucketToBadgeClass` describe).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/lib/order-age.ts src/lib/order-age.test.ts
|
||
|
|
git commit -m "feat(orders): add order-age util with bucket thresholds
|
||
|
|
|
||
|
|
Pure function: getOrderAge(created, now?) returns {daysOld, bucket, label}
|
||
|
|
in calendar-day delta for Africa/Johannesburg. bucketToBadgeClass maps
|
||
|
|
to tailwind classes (fresh/recent/slipping/stale). No React, no DB.
|
||
|
|
|
||
|
|
Vitest unit tests cover all 5 bucket thresholds, future-date clamp, and
|
||
|
|
the JNB timezone edge case (same calendar day in JNB = daysOld 0).
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: Wire OrderAgeBadge into the orders page
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/app/(app)/orders/page.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add the imports**
|
||
|
|
|
||
|
|
In `src/app/(app)/orders/page.tsx`, add the new imports at the end of the existing `import { Badge } from "@/components/ui/badge"` block (line 8):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { getOrderAge, bucketToBadgeClass } from "@/lib/order-age";
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `cn` to the `@/lib/utils` import (currently `displayContactName` on line 27). If `cn` is not already exported by `@/lib/utils`, leave the import alone and use template-literal class strings instead — but check first. The util almost certainly exports `cn` (used everywhere in the codebase). If present:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { cn, displayContactName } from "@/lib/utils";
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add the OrderAgeBadge component**
|
||
|
|
|
||
|
|
In `src/app/(app)/orders/page.tsx`, immediately above the existing `formatDate` function (line 90), add:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
/**
|
||
|
|
* Inline age badge for the order card metadata slot. Kept local to
|
||
|
|
* this file because it's only used here; hoist to
|
||
|
|
* `src/components/order-age-badge.tsx` if a second page needs it.
|
||
|
|
*
|
||
|
|
* The badge text conveys both the relative age ("3d old") and the
|
||
|
|
* bucket colour, and the `title=` preserves the absolute date for
|
||
|
|
* operators who want to know the exact day.
|
||
|
|
*/
|
||
|
|
function OrderAgeBadge({
|
||
|
|
created,
|
||
|
|
className,
|
||
|
|
}: {
|
||
|
|
created: string;
|
||
|
|
className?: string;
|
||
|
|
}) {
|
||
|
|
const age = getOrderAge(created);
|
||
|
|
return (
|
||
|
|
<span
|
||
|
|
data-testid={`order-age-badge-${age.bucket}`}
|
||
|
|
className={cn(
|
||
|
|
"rounded-full px-2 py-0.5 text-[10px] font-medium tabular-nums",
|
||
|
|
bucketToBadgeClass(age.bucket),
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
title={`Created ${formatDate(created)}`}
|
||
|
|
>
|
||
|
|
{age.label}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
(If `cn` is not available from `@/lib/utils`, replace `cn(...)` with a template literal: `` `rounded-full px-2 py-0.5 text-[10px] font-medium tabular-nums ${bucketToBadgeClass(age.bucket)}${className ? ` ${className}` : ""}` ``.)
|
||
|
|
|
||
|
|
- [ ] **Step 3: Replace the bare date with the badge**
|
||
|
|
|
||
|
|
In `src/app/(app)/orders/page.tsx`, locate line ~865-867:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<span className="ml-auto text-xs text-muted-foreground hidden sm:inline">
|
||
|
|
{formatDate(order.created)}
|
||
|
|
</span>
|
||
|
|
```
|
||
|
|
|
||
|
|
Replace with:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<OrderAgeBadge
|
||
|
|
created={order.created}
|
||
|
|
className="ml-auto hidden sm:inline-flex"
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Smoke-check in the dev server**
|
||
|
|
|
||
|
|
Start the dev server (per CLAUDE.md / project memory: `http://localhost:4001`):
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Open `http://localhost:4001/orders`. Verify:
|
||
|
|
- A recently-created test order (e.g. `ORD-2026-08-058` from the
|
||
|
|
previous walkthrough — created ~30 min ago) shows a green `today`
|
||
|
|
badge in the right-aligned metadata slot where the date used to live.
|
||
|
|
- Hover the badge — tooltip shows `Created <formatted date>`.
|
||
|
|
- Order card layout is otherwise unchanged.
|
||
|
|
|
||
|
|
Stop the dev server when done (Ctrl+C).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add "src/app/(app)/orders/page.tsx"
|
||
|
|
git commit -m "feat(orders): surface order age as colour-coded badge
|
||
|
|
|
||
|
|
Replaces the bare formatDate(order.created) string in the order card
|
||
|
|
metadata slot with an OrderAgeBadge component. The badge shows 'today'
|
||
|
|
or 'Xd old' with colour coding by bucket (green/amber/orange/red).
|
||
|
|
The absolute date is preserved in the badge's title= tooltip.
|
||
|
|
|
||
|
|
Bucket math lives in the new src/lib/order-age.ts util (covered by
|
||
|
|
unit tests). This change is rendering-only — no server or schema impact.
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: Compact info on delivery-note rows
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/app/(app)/deliveries/page.tsx` (only `DeliveryNoteRowComponent`, lines ~1475-1588)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add the Paperclip import**
|
||
|
|
|
||
|
|
In `src/app/(app)/deliveries/page.tsx`, the lucide-react import already includes `Pencil`, `Truck`, `User`. Add `Paperclip`:
|
||
|
|
|
||
|
|
Locate the lucide-react import (around lines 86-94) and add `Paperclip` to the named-imports list:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import {
|
||
|
|
CheckSquare,
|
||
|
|
FileText,
|
||
|
|
Loader2,
|
||
|
|
PackageOpen,
|
||
|
|
Paperclip, // ← add this
|
||
|
|
Pencil,
|
||
|
|
Square,
|
||
|
|
Truck,
|
||
|
|
User,
|
||
|
|
} from "lucide-react";
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Replace the subline with the compact pieces list**
|
||
|
|
|
||
|
|
In `src/app/(app)/deliveries/page.tsx`, locate `DeliveryNoteRowComponent` (lines ~1475-1588). Find the `<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">…</p>` block (around line 1537-1547). Replace its **children** (the `{dn.delivery_date && …}` + `<span className={…}>{subline}</span>` content) with the following compact pieces list:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
|
||
|
|
{dn.order ? (
|
||
|
|
<>
|
||
|
|
<span className="truncate max-w-[40ch]" title={dn.order.customer_name ?? ""}>
|
||
|
|
{dn.order.customer_name}
|
||
|
|
</span>
|
||
|
|
{dn.order.company_name && (
|
||
|
|
<>
|
||
|
|
<span aria-hidden>·</span>
|
||
|
|
<span>{dn.order.company_name}</span>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
{dn.order.order_number && (
|
||
|
|
<>
|
||
|
|
<span aria-hidden>·</span>
|
||
|
|
<span className="font-mono">{dn.order.order_number}</span>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<span className="italic">Counter pickup</span>
|
||
|
|
)}
|
||
|
|
{dn.signed_copy && dn.signed_copy.length > 0 && (
|
||
|
|
<>
|
||
|
|
<span aria-hidden>·</span>
|
||
|
|
<span
|
||
|
|
className="inline-flex items-center gap-1"
|
||
|
|
title={dn.signed_copy.join(", ")}
|
||
|
|
>
|
||
|
|
<Paperclip className="h-3 w-3" aria-hidden />
|
||
|
|
signed copy
|
||
|
|
</span>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
{dn.delivery_date && (
|
||
|
|
<>
|
||
|
|
<span aria-hidden>·</span>
|
||
|
|
<span>Delivery {dn.delivery_date}</span>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</p>
|
||
|
|
```
|
||
|
|
|
||
|
|
This keeps the existing `text-muted-foreground` / `tabular-nums` styling and adds: customer name (truncated, with title tooltip), optional company, optional order number, optional signed-copy indicator, optional delivery date. Counter-pickup DNs (`dn.order === null`) collapse to a single "Counter pickup" label per the spec.
|
||
|
|
|
||
|
|
Leave the rest of `DeliveryNoteRowComponent` (the `<FileText />` icon, the title row with `DN {n}`, the status badge column, the two buttons) untouched.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Smoke-check in the dev server**
|
||
|
|
|
||
|
|
Start the dev server:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Open `http://localhost:4001/deliveries`. Verify on the To Load tab:
|
||
|
|
- A DN with an order attached renders the customer name + company + order number under the DN number, joined by `·`.
|
||
|
|
- A counter-pickup DN (no order) shows "Counter pickup" in the same slot.
|
||
|
|
- A DN with a signed copy uploaded shows a paperclip icon + "signed copy" text.
|
||
|
|
- Hovering the paperclip reveals the filenames.
|
||
|
|
- Layout is otherwise unchanged — buttons still on the right, status badge still on top.
|
||
|
|
|
||
|
|
Stop the dev server (Ctrl+C).
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add "src/app/(app)/deliveries/page.tsx"
|
||
|
|
git commit -m "feat(deliveries): enrich DN row subline with customer/order/paperclip
|
||
|
|
|
||
|
|
The /deliveries To Load + Back from Driver + Checked In tabs each
|
||
|
|
render delivery-note rows showing only 'DN {number}' + a one-line
|
||
|
|
status subline. Operators couldn't identify the customer or tell
|
||
|
|
whether the signed copy was back without scrolling up to the
|
||
|
|
customer/order group header.
|
||
|
|
|
||
|
|
Replace the existing subline with a compact pieces list:
|
||
|
|
customer name (truncated, with title tooltip) · company name ·
|
||
|
|
parent order number · signed-copy indicator (paperclip icon +
|
||
|
|
filenames in title=) · delivery date (if set). Counter-pickup DNs
|
||
|
|
(no parent order) collapse to 'Counter pickup'.
|
||
|
|
|
||
|
|
All data is already on the row payload from /api/v1/deliveries —
|
||
|
|
no server or schema changes.
|
||
|
|
|
||
|
|
Co-Authored-By: Claude Code <noreply@anthropic.com>"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Self-review
|
||
|
|
|
||
|
|
**Spec coverage check (against `docs/superpowers/specs/2026-09-01-delivery-row-info-and-order-age-badge-design.md`):**
|
||
|
|
|
||
|
|
| Spec section | Plan task |
|
||
|
|
|---|---|
|
||
|
|
| § Feature 1: Compact info on delivery-note rows — 4 pieces (customer, company, order, signed-copy indicator) + edge cases | Task 3 ✓ |
|
||
|
|
| § Feature 1: Counter-pickup fallback ("Counter pickup") | Task 3 Step 2 ✓ |
|
||
|
|
| § Feature 1: Signed-copy indicator (paperclip + filename tooltip) | Task 3 Step 2 ✓ |
|
||
|
|
| § Feature 1: Long customer name truncation | Task 3 Step 2 (`max-w-[40ch]` + title) ✓ |
|
||
|
|
| § Feature 1: No new network request | Task 3 only edits rendering, no fetch added ✓ |
|
||
|
|
| § Feature 2: New `src/lib/order-age.ts` with `getOrderAge` + `bucketToBadgeClass` | Task 1 ✓ |
|
||
|
|
| § Feature 2: Bucket thresholds (0/1-2/3-7/8-14/≥15) | Task 1 Step 1 (tests cover all edges) ✓ |
|
||
|
|
| § Feature 2: Calendar-day delta in Africa/Johannesburg | Task 1 Step 1 (timezone test) + Step 3 (`OFFICE_TZ` + `toLocalDayKey`) ✓ |
|
||
|
|
| § Feature 2: Future-date clamp | Task 1 Step 1 (clamp test) + Step 3 (`rawDays < 0 ? 0 : rawDays`) ✓ |
|
||
|
|
| § Feature 2: Tailwind classes (4 buckets, dark variants) | Task 1 Step 3 (`bucketToBadgeClass`) ✓ |
|
||
|
|
| § Feature 2: Unit tests for all bucket thresholds | Task 1 Step 1 ✓ |
|
||
|
|
| § Feature 2: JNB-tz edge case test (same calendar day = daysOld 0) | Task 1 Step 1 ✓ |
|
||
|
|
| § Feature 2: `bucketToBadgeClass` stability test | Task 1 Step 1 ✓ |
|
||
|
|
| § Feature 2: `OrderAgeBadge` inline component | Task 2 Step 2 ✓ |
|
||
|
|
| § Feature 2: Replace bare date with badge, preserving `title=` for absolute date | Task 2 Step 3 ✓ |
|
||
|
|
| § Feature 2: Existing `formatDate` import kept on page | Task 2 Step 3 (uses local `formatDate`) ✓ |
|
||
|
|
| § What's NOT in scope: no server changes, no schema, no MCP, no other pages | All tasks comply ✓ |
|
||
|
|
|
||
|
|
**Placeholder scan:** No "TBD" / "TODO" / "implement later" / "fill in details". All code blocks are complete. The one hedge (`if cn is not available…`) is an explicit fallback instruction for a real ambiguity, not a placeholder.
|
||
|
|
|
||
|
|
**Type consistency:** `OrderAgeBucket`, `OrderAge`, `getOrderAge`, `bucketToBadgeClass` are defined and used with matching signatures across Tasks 1 and 2. `OrderAgeBadge` props match its usage at Task 2 Step 3.
|
||
|
|
|
||
|
|
**Ambiguity check:** None found. The `cn`-available vs. not-available branch is the only conditional, and the plan gives an explicit fallback.
|
||
|
|
|
||
|
|
All spec requirements are addressed; no gaps.
|