docs: spec for compact DN row + order age badge
Two small, independent rendering improvements for /deliveries and /orders:
1. /deliveries — each delivery-note row gets a compact subline with
customer name, company name, parent order number, and a signed-copy
indicator (paperclip + filename tooltip). All data already on the
row payload — no server-side changes.
2. /orders — order card right-aligned metadata slot gets a colour-
coded age badge (today / Xd old) replacing the bare date string.
New src/lib/order-age.ts as a pure function with bucket thresholds
(green <3d, amber 3-7d, orange 8-14d, red 15+d) and a unit test.
No PB schema changes. No /api/v1 route changes. No MCP tool changes.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
c9096dfd84
commit
3fc9c576bf
1 changed files with 305 additions and 0 deletions
|
|
@ -0,0 +1,305 @@
|
|||
# Compact DN Row + Order Age Badge — Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Two small, independent rendering improvements for two pages the
|
||||
operator uses every day:
|
||||
|
||||
1. **`/deliveries`** — each delivery-note row currently renders only
|
||||
`DN {number}` + a one-line subline (delivery date or PO ref). The
|
||||
operator can't tell *who* a delivery note is for or whether the
|
||||
signed copy is back without scrolling up to the customer/order
|
||||
group header. This spec adds a compact subline with customer name,
|
||||
company, parent order number, and a signed-copy indicator — all
|
||||
data the service already returns, no new queries.
|
||||
|
||||
2. **`/orders`** — the order card subtitle already shows
|
||||
`{formatDate(order.created)}` but as a bare date string. The
|
||||
operator wants to triage by **age** ("how long has this been on my
|
||||
board") at a glance, not parse dates. This spec adds a colour-coded
|
||||
age badge (`today`, `3d old`, `7d old`, etc.) replacing the bare
|
||||
date string in the right-aligned metadata slot.
|
||||
|
||||
Both features share no code; they're bundled in one spec only because
|
||||
they were brainstormed together.
|
||||
|
||||
## Scope decisions (confirmed during brainstorming)
|
||||
|
||||
- **DN row enrichment** — compact option from the brainstorm:
|
||||
customer name, company name, parent order number, signed-copy
|
||||
indicator. No item count, no total value (those would require a
|
||||
server-side aggregate; rejected for now).
|
||||
- **Age surfacing** — colour-coded badge, not auto-sort, not
|
||||
raw-age-only. Replaces the existing date string; date is dropped
|
||||
because the badge text already conveys the date implicitly ("3d old
|
||||
on 2026-08-29" is redundant).
|
||||
- **Age thresholds** — `<24h → today (green)`, `1–2d → green`,
|
||||
`3–7d → amber`, `8–14d → orange`, `>14d → red`. Tunable later.
|
||||
- **No server changes** — both features consume data already
|
||||
returned by `/api/v1/deliveries` and `/api/v1/orders` (or, in
|
||||
`/orders`'s case, the page-level fetcher).
|
||||
- **No PB schema changes** — purely UI/rendering work.
|
||||
|
||||
## Architecture
|
||||
|
||||
**New files:**
|
||||
```
|
||||
src/lib/order-age.ts — pure age math + bucket → tailwind class
|
||||
src/lib/order-age.test.ts — unit tests for the util
|
||||
```
|
||||
|
||||
**Modified files:**
|
||||
```
|
||||
src/app/(app)/deliveries/page.tsx — DeliveryNoteRowComponent only
|
||||
src/app/(app)/orders/page.tsx — order card right-aligned metadata slot
|
||||
```
|
||||
|
||||
No service-layer or `/api/v1` route changes. No MCP tool changes.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Compact info on delivery-note rows
|
||||
|
||||
### File: `src/app/(app)/deliveries/page.tsx`
|
||||
|
||||
Scope: **only** the `DeliveryNoteRowComponent` function (lines
|
||||
~1475-1588). No change to the grouping logic, the search filter, the
|
||||
loading endpoint, or any other component on the page.
|
||||
|
||||
### Target row rendering
|
||||
|
||||
```
|
||||
DN DN26083101
|
||||
Acme Mining (Pty) Ltd · Master Drilling Co · ORD-2026-08-058 · 📎 signed copy
|
||||
[status badge] [Mark loaded] [Signed copy]
|
||||
```
|
||||
|
||||
The existing `<p>` subline at line 1537-1547 is replaced with a new
|
||||
piece list:
|
||||
|
||||
1. **Customer name** — `dn.order?.customer_name` ?? `"Counter pickup"`.
|
||||
Always rendered (counter-pickup is the explicit no-order case).
|
||||
2. **Company name** — `dn.order?.company_name`. Hidden if falsy.
|
||||
3. **Order number** — `dn.order?.order_number`. Hidden if falsy.
|
||||
4. **Signed-copy indicator** — `<Paperclip />` icon + `"signed copy"`
|
||||
text. Hidden if `!dn.signed_copy || dn.signed_copy.length === 0`.
|
||||
The whole indicator carries `title={dn.signed_copy.join(", ")}` so
|
||||
the operator can see the filenames on hover.
|
||||
|
||||
Joined by `·` (same separator convention as the order card subtitle),
|
||||
in `text-muted-foreground` (matches the existing subline colour).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Counter-pickup DN** (`dn.order === null`): customer/company/order
|
||||
pieces all collapse to a single `"Counter pickup"` label (no
|
||||
separator emitted for the missing pieces). Signed-copy indicator
|
||||
still appears if files were uploaded directly to the DN.
|
||||
- **Long customer names**: the customer name piece carries
|
||||
`truncate max-w-[40ch]` + `title` to preserve hover-access. Other
|
||||
pieces are short and don't need truncation.
|
||||
- **Multi-DN under one order**: each DN row still renders its own
|
||||
compact subline. The group header (customer → order → list of rows)
|
||||
is unchanged.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- A DN with order attached shows: `DN {n}`, customer name, company
|
||||
name, order number, paperclip if signed copy uploaded.
|
||||
- A counter-pickup DN shows: `DN {n}`, `Counter pickup`, paperclip if
|
||||
signed copy uploaded.
|
||||
- A DN with no signed copy shows no paperclip.
|
||||
- The page's existing search, grouping, and tab logic are unaffected.
|
||||
- No new network request is made when rendering a DN row.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Order age badge
|
||||
|
||||
### New file: `src/lib/order-age.ts`
|
||||
|
||||
```typescript
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, hardcoded for
|
||||
* now) — an order created at 23:00 yesterday read at 01:00 today is
|
||||
* `daysOld = 0` ("today"), not 1. The day delta is computed as
|
||||
* `floor((localEndOfDay - localStartOfCreatedDay) / 1 day)` and is
|
||||
* always returned as a non-negative integer.
|
||||
*
|
||||
* Future-dated `created` values (clock skew, bad data) clamp to 0
|
||||
* to avoid showing "−1d old" or similar nonsense.
|
||||
*
|
||||
* Implementation note: use `Intl.DateTimeFormat` with the
|
||||
* Africa/Johannesburg timezone to get the local YYYY-MM-DD for both
|
||||
* dates, then subtract. Don't divide millisecond deltas by
|
||||
* 86_400_000 — that fails across DST boundaries (SA doesn't observe
|
||||
* DST but the pattern is fragile).
|
||||
*/
|
||||
export function getOrderAge(
|
||||
created: string | Date,
|
||||
now?: Date,
|
||||
): OrderAge;
|
||||
|
||||
/**
|
||||
* Map an age bucket to a tailwind className for the badge.
|
||||
* Centralised so a future palette tweak is a one-line change.
|
||||
*/
|
||||
export function bucketToBadgeClass(bucket: OrderAgeBucket): string;
|
||||
```
|
||||
|
||||
**Bucket thresholds:**
|
||||
|
||||
| `daysOld` | bucket | label |
|
||||
|-----------|--------|-------|
|
||||
| 0 | `fresh` | `today` |
|
||||
| 1–2 | `fresh` | `{daysOld}d old` |
|
||||
| 3–7 | `recent` | `{daysOld}d old` |
|
||||
| 8–14 | `slipping` | `{daysOld}d old` |
|
||||
| ≥ 15 | `stale` | `{daysOld}d old` |
|
||||
|
||||
**Badge class names** (initial palette, tunable):
|
||||
- `fresh` → `bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300`
|
||||
- `recent` → `bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300`
|
||||
- `slipping` → `bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300`
|
||||
- `stale` → `bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300`
|
||||
|
||||
### New file: `src/lib/order-age.test.ts`
|
||||
|
||||
Unit tests, all pure-function:
|
||||
- `daysOld = 0` (created today) → `fresh`, label `"today"`
|
||||
- `daysOld = 1, 2` → `fresh`, label `"1d old"`, `"2d old"`
|
||||
- `daysOld = 3, 7` → `recent`, label `"3d old"`, `"7d old"`
|
||||
- `daysOld = 8, 14` → `slipping`, label `"8d old"`, `"14d old"`
|
||||
- `daysOld = 15, 30, 365` → `stale`, label `"15d old"`, `"30d old"`,
|
||||
`"365d old"`
|
||||
- `daysOld = -1` (future date) → clamps to `fresh`, label `"today"`
|
||||
- **Local-timezone edge case**: an order created at 23:00 JNB yesterday
|
||||
read at 01:00 JNB today must return `daysOld = 0` (calendar-day delta,
|
||||
not millisecond delta). Test this with `now` injected explicitly.
|
||||
- All four `bucketToBadgeClass` outputs return non-empty strings
|
||||
- `bucketToBadgeClass` is stable (same input → same output)
|
||||
|
||||
No React rendering tests for the badge — that would be a separate
|
||||
playwright/e2e concern and is out of scope.
|
||||
|
||||
### Modified file: `src/app/(app)/orders/page.tsx`
|
||||
|
||||
Scope: the right-aligned metadata line in the order card (current line
|
||||
~865-867). The exact JSX currently is:
|
||||
|
||||
```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" />
|
||||
```
|
||||
|
||||
Where `OrderAgeBadge` is a tiny new inline component in the same
|
||||
file (kept local — used only here for now; if a second page needs it,
|
||||
it can be hoisted to `src/components/order-age-badge.tsx`):
|
||||
|
||||
```tsx
|
||||
function OrderAgeBadge({
|
||||
created,
|
||||
className,
|
||||
}: {
|
||||
created: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const age = getOrderAge(created);
|
||||
return (
|
||||
<span
|
||||
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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `title=` attribute preserves the absolute date for operators who
|
||||
want to know the exact day — addresses the brainstorm's "drop raw
|
||||
date" option concern without keeping it as primary UI.
|
||||
|
||||
The existing `formatDate` import (currently from
|
||||
`@/lib/pb-helpers`, line ~1596 — `formatDate(dateStr: string)`) is
|
||||
kept on the page. `getOrderAge` itself does **not** import
|
||||
`formatDate`; it returns the age label only.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Sorting**: not changing. The badge surfaces age; the operator
|
||||
still sorts by `created` if they want.
|
||||
- **Rep filtering**: the recent commit
|
||||
`feat(invoicing): filter rows to the logged-in rep's own orders` is
|
||||
orthogonal. Age badge fires regardless of who the rep is.
|
||||
- **Long lists**: each card gets one badge. No batched summary view
|
||||
changes.
|
||||
- **Existing `formatDate` call**: `formatDate(order.created)` moves
|
||||
from the JSX into the badge's `title=` tooltip so the absolute
|
||||
date is still accessible on hover.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- An order created today shows a green `today` badge in the
|
||||
right-aligned slot where the date used to live.
|
||||
- An order 5 days old shows an amber `5d old` badge.
|
||||
- An order 20 days old shows a red `20d old` badge.
|
||||
- Hovering the badge reveals the full `Created YYYY-MM-DD` tooltip.
|
||||
- The order card layout is otherwise unchanged — the badge slot has
|
||||
the same `ml-auto hidden sm:inline` styling hooks.
|
||||
|
||||
---
|
||||
|
||||
## What's NOT in scope
|
||||
|
||||
- **No new server endpoints.** Both features use existing data.
|
||||
- **No PB schema changes.** Purely UI.
|
||||
- **No item count / total value on DN rows.** Rejected during
|
||||
brainstorm (would need a server-side aggregate).
|
||||
- **No sorting changes on /orders.** Badge only.
|
||||
- **No changes to MCP tools.**
|
||||
- **No changes to /api/v1 routes.**
|
||||
- **No changes to other delivery-note pages** (`/delivery-notes`,
|
||||
`/delivery-notes/[id]`, `/delivery-note-print`). Only
|
||||
`/deliveries`'s row component.
|
||||
|
||||
## Testing
|
||||
|
||||
- `src/lib/order-age.test.ts` — 6+ unit tests covering all bucket
|
||||
thresholds + future-date clamp + `bucketToBadgeClass` stability.
|
||||
- Manual smoke: open `/orders`, verify badges appear with correct
|
||||
colours for orders of various ages; open `/deliveries`, verify the
|
||||
DN row subline shows customer/company/order/paperclip as expected.
|
||||
- Existing page tests (if any) for `/orders` and `/deliveries`
|
||||
should continue to pass — no layout regressions.
|
||||
|
||||
## Risk assessment
|
||||
|
||||
**Low.** Both features are additive rendering changes against
|
||||
existing, well-typed payloads. The age util is a pure function and
|
||||
the only file with logic worth testing. The DN row change touches
|
||||
one component and doesn't change any user-facing interaction
|
||||
(no new buttons, no new fields).
|
||||
Loading…
Reference in a new issue