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>
11 KiB
Compact DN Row + Order Age Badge — Design
Purpose
Two small, independent rendering improvements for two pages the operator uses every day:
-
/deliveries— each delivery-note row currently renders onlyDN {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. -
/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/deliveriesand/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:
- Customer name —
dn.order?.customer_name??"Counter pickup". Always rendered (counter-pickup is the explicit no-order case). - Company name —
dn.order?.company_name. Hidden if falsy. - Order number —
dn.order?.order_number. Hidden if falsy. - Signed-copy indicator —
<Paperclip />icon +"signed copy"text. Hidden if!dn.signed_copy || dn.signed_copy.length === 0. The whole indicator carriestitle={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]+titleto 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
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-300recent→bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300slipping→bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300stale→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 tofresh, 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 withnowinjected explicitly. - All four
bucketToBadgeClassoutputs return non-empty strings bucketToBadgeClassis 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:
<span className="ml-auto text-xs text-muted-foreground hidden sm:inline">
{formatDate(order.created)}
</span>
Replace with:
<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):
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
createdif they want. - Rep filtering: the recent commit
feat(invoicing): filter rows to the logged-in rep's own ordersis orthogonal. Age badge fires regardless of who the rep is. - Long lists: each card gets one badge. No batched summary view changes.
- Existing
formatDatecall:formatDate(order.created)moves from the JSX into the badge'stitle=tooltip so the absolute date is still accessible on hover.
Acceptance
- An order created today shows a green
todaybadge in the right-aligned slot where the date used to live. - An order 5 days old shows an amber
5d oldbadge. - An order 20 days old shows a red
20d oldbadge. - Hovering the badge reveals the full
Created YYYY-MM-DDtooltip. - The order card layout is otherwise unchanged — the badge slot has
the same
ml-auto hidden sm:inlinestyling 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 +bucketToBadgeClassstability.- 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
/ordersand/deliveriesshould 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).