diff --git a/.changeset/add-document-progress-card.md b/.changeset/add-document-progress-card.md new file mode 100644 index 00000000..4c45781d --- /dev/null +++ b/.changeset/add-document-progress-card.md @@ -0,0 +1,21 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `DocumentProgressCard` — a generic, presentational card for a document's lifecycle/fulfilment state: an optional headline percentage, a stacked progress bar, and a status legend driven by arbitrary `segments`. It's domain-agnostic, so it stretches to any status breakdown (shipped / cancelled / pending, PO received / returned / yet-to-receive, etc.). + +```tsx +import { DocumentProgressCard } from "@tailor-platform/app-shell"; + +; +``` + +Each segment is `{ label, value, color }`. Pass an explicit `percent`, an optional `total` (leave a value larger than the segment sum to render an unfilled track remainder), and an optional `legend` override for when the legend should differ from the bar (e.g. overlapping buckets). Percentage and any domain-specific breakdown are derived in the consumer — see the docs for a purchase-order fulfilment recipe. diff --git a/catalogue/src/fundamental/components.md b/catalogue/src/fundamental/components.md index 8b5e935c..a3070bdb 100644 --- a/catalogue/src/fundamental/components.md +++ b/catalogue/src/fundamental/components.md @@ -424,6 +424,27 @@ const table = useDataTable({ **Used in patterns:** KPI tiles, dashboards, **`detail/*`** metric strips where specs call for them. +### `DocumentProgressCard` + +**Import:** `import { DocumentProgressCard } from '@tailor-platform/app-shell'` +**Purpose:** Generic document lifecycle/fulfilment state — optional percentage, stacked progress bar, status legend; arbitrary `segments`. +**API:** `DocumentProgressCardProps` — `segments` (`{ label, value, color? }[]`), `title?`, `percent?`, `legend?` (defaults to `segments`), `total?` (bar denominator; larger than the sum leaves an empty track), `className?`. View-only — `percent` is explicit. +**Example:** + +```tsx + +``` + +**Used in patterns:** **`detail/*`** right-rail cards for arbitrary status breakdowns (shipped/cancelled/pending, PO received/returned/yet-to-receive, etc.). For the purchase-order fulfilment recipe (derived %, net-received/returned bar split, legend override), see `docs/components/document-progress-card.md`. + ### `Avatar` **Import:** `import { Avatar } from '@tailor-platform/app-shell'` diff --git a/docs/components/document-progress-card.md b/docs/components/document-progress-card.md new file mode 100644 index 00000000..ec148a17 --- /dev/null +++ b/docs/components/document-progress-card.md @@ -0,0 +1,137 @@ +--- +title: DocumentProgressCard +description: Generic card visualising a document's lifecycle state — optional percentage, stacked progress bar, and status legend +--- + +# DocumentProgressCard + +`DocumentProgressCard` is a generic, presentational card for communicating the lifecycle/fulfilment state of a document in a record detail right rail. It shows an optional completion percentage, a stacked progress bar, and a legend — driven by an arbitrary set of status `segments`. + +It is view-only and domain-agnostic: pass the segments and an explicit `percent`. Any domain-specific math — deriving the percentage, or decomposing overlapping buckets into bar segments — lives in the consumer. See [Example: purchase-order fulfilment](#example-purchase-order-fulfilment) for a complete, copyable pattern. + +## Import + +```tsx +import { DocumentProgressCard } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx + +``` + +## Props + +| Prop | Type | Default | Description | +| ----------- | --------------------------- | ------------- | --------------------------------------------------------------------------------------------------------- | +| `segments` | `DocumentProgressSegment[]` | **Required** | Status segments rendered as a stacked bar (and, by default, the legend). | +| `title` | `React.ReactNode` | - | Optional card title shown top-left. | +| `percent` | `number` | - | Optional headline percentage (0–100), shown top-right. Explicit — the generic card derives no progress. | +| `legend` | `DocumentProgressSegment[]` | `segments` | Optional legend rows; override only when the legend should differ from the bar (see [Legend](#legend)). | +| `total` | `number` | sum of values | Denominator used to size the bar. A value larger than the segment sum leaves an unfilled track remainder. | +| `className` | `string` | - | Additional CSS classes for the card root. | + +### `DocumentProgressSegment` + +| Field | Type | Description | +| ------- | ----------------------- | ------------------------------------------------------ | +| `label` | `string` | Legend label. | +| `value` | `number` | Amount — shown in the legend and used to size the bar. | +| `color` | `DocumentProgressColor` | Bar / marker color (required). | + +`DocumentProgressColor` is one of: `"indigo"`, `"pink"`, `"green"`, `"amber"`, `"red"`, `"blue"`, `"neutral"`. + +## Progress Bar + +The bar tiles `segments` left-to-right, each sized as `value / (total ?? sum of values)`. When `total` exceeds the segment sum, the shortfall renders as an empty `bg-muted` track — useful for a "remaining" portion you don't want as a colored segment. A `"neutral"` segment reads as a muted/track-like fill. + +## Legend + +By default the legend mirrors `segments`. Pass `legend` to render different rows — for example when buckets overlap and the bar shows a decomposition while the legend shows the raw figures: + +```tsx + +``` + +## Example: purchase-order fulfilment + +A common use is communicating a purchase order's receiving state — **received**, **returned**, and **yet to receive**. `DocumentProgressCard` stays generic, so derive the percentage and the bar breakdown in your component and pass them in. This recipe reproduces the recommended look (labels, indigo/pink/neutral colors, and a bar that decomposes received into "kept" + "returned" against the ordered total): + +```tsx +function PurchaseOrderFulfilment({ + received, + returned, + yetToReceive, + // Whether returned items still count as fulfilled. Set false to subtract them. + returnedCountsAsComplete = true, +}: { + received: number; + returned: number; + yetToReceive: number; + returnedCountsAsComplete?: boolean; +}) { + // Returned is a subset of received; clamp so the breakdown can't exceed it. + const effectiveReturned = Math.min(Math.max(returned, 0), Math.max(received, 0)); + const total = Math.max(received, 0) + Math.max(yetToReceive, 0); // the ordered quantity + const complete = returnedCountsAsComplete ? received : received - effectiveReturned; + const percent = total > 0 ? Math.round((complete / total) * 100) : 0; + + return ( + + ); +} + +// → 30% +``` + +Notes for adapting it: + +- **Colors:** `indigo` (received), `pink` (returned), `neutral` (yet to receive) is the recommended set; swap any of the seven palette colors to fit your domain. +- **Bar vs. legend:** the bar uses `received − returned` so the two colored segments don't double-count the returned items, while the `legend` override keeps "Received items" showing the full received figure. +- **`total`:** pass the ordered quantity so the unfilled portion of the bar represents "yet to receive". Omit it (or use the segment sum) if you want a fully-tiled bar instead. +- **Percentage policy:** flip `returnedCountsAsComplete` to decide whether returned items count toward completion. + +## Input handling + +Segment values are expected to be non-negative numbers. Non-finite or negative values are coerced to `0`, and `percent` is rounded and clamped to `[0, 100]`. + +## Related + +- [MetricCard](./metric-card.md) — Compact KPI summary card. +- [Card](./card.md) — The underlying card primitive. diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index 76c7f3fb..fba2c8d2 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -34,6 +34,7 @@ const App = () => { + diff --git a/examples/vite-app/src/pages/dashboard/document-progress/page.tsx b/examples/vite-app/src/pages/dashboard/document-progress/page.tsx new file mode 100644 index 00000000..2d94de0e --- /dev/null +++ b/examples/vite-app/src/pages/dashboard/document-progress/page.tsx @@ -0,0 +1,155 @@ +import { + Layout, + Grid, + DocumentProgressCard, + type AppShellPageProps, +} from "@tailor-platform/app-shell"; + +const GaugeIcon = () => ( + + + + +); + +/** + * Purchase-order fulfilment, built on the generic DocumentProgressCard — the + * recommended recipe from the docs. Derives the percentage and decomposes + * received into "kept" + "returned" in the consumer, keeping the card generic. + */ +const PurchaseOrderFulfilment = ({ + title = "Fulfilment rate", + received, + returned, + yetToReceive, + returnedCountsAsComplete = true, +}: { + title?: string; + received: number; + returned: number; + yetToReceive: number; + returnedCountsAsComplete?: boolean; +}) => { + const effectiveReturned = Math.min(Math.max(returned, 0), Math.max(received, 0)); + const total = Math.max(received, 0) + Math.max(yetToReceive, 0); + const complete = returnedCountsAsComplete ? received : received - effectiveReturned; + const percent = total > 0 ? Math.round((complete / total) * 100) : 0; + + return ( + + ); +}; + +const DocumentProgressPage = () => { + return ( + + + + +

Arbitrary status segments

+

+ Domain-agnostic — any set of status segments plus an explicit percentage. +

+ + {/* Arbitrary lifecycle: shipped / returned / pending */} + + + {/* More than three statuses — stretches to any workflow */} + + + {/* No percentage — just a composition bar + legend */} + + + {/* Unfilled remainder via total */} + + + +

Recipe: purchase-order fulfilment

+

+ Received / returned / yet-to-receive, derived in the consumer (see{" "} + PurchaseOrderFulfilment in this file and the component docs). +

+ + {/* Matches the Figma baseline — nothing received yet */} + + + {/* Partially received with some returns */} + + + {/* Same data, but returns subtracted from progress */} + + + {/* Fully received */} + + +
+
+ ); +}; + +DocumentProgressPage.appShellPageProps = { + meta: { + title: "Document Progress", + icon: , + }, +} satisfies AppShellPageProps; + +export default DocumentProgressPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index 586629b2..93a66bcf 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -17,6 +17,7 @@ import { createTypedPaths } from "@tailor-platform/app-shell"; export type GeneratedRouteParams = { "/": {}; "/dashboard": {}; + "/dashboard/document-progress": {}; "/dashboard/orders": {}; "/dashboard/orders/:id": { id: string }; "/dashboard/products": {}; diff --git a/packages/core/__snapshots__/src__components__document-progress-card__DocumentProgressCard.test.tsx.snap b/packages/core/__snapshots__/src__components__document-progress-card__DocumentProgressCard.test.tsx.snap new file mode 100644 index 00000000..0d886bf3 --- /dev/null +++ b/packages/core/__snapshots__/src__components__document-progress-card__DocumentProgressCard.test.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DocumentProgressCard > snapshots > legend override distinct from bar segments 1`] = `"
30%
Received items
12
Returned items
2
Yet to receive
28
"`; + +exports[`DocumentProgressCard > snapshots > title, percent, and three segments 1`] = `"
Shipment status60%
Shipped
30
Returned
3
Pending
17
"`; + +exports[`DocumentProgressCard > snapshots > with an unfilled remainder via total 1`] = `"
25%
Done
10
"`; diff --git a/packages/core/src/components/document-progress-card/DocumentProgressCard.test.tsx b/packages/core/src/components/document-progress-card/DocumentProgressCard.test.tsx new file mode 100644 index 00000000..94fa101f --- /dev/null +++ b/packages/core/src/components/document-progress-card/DocumentProgressCard.test.tsx @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { DocumentProgressCard } from "./DocumentProgressCard"; + +afterEach(() => { + cleanup(); +}); + +const getRoot = (container: HTMLElement) => + container.querySelector('[data-slot="document-progress-card"]') as HTMLElement; + +const getPercentText = () => + screen.getByText((_, el) => el?.getAttribute("data-slot") === "document-progress-percent") + .textContent; + +const segments = (container: HTMLElement) => + Array.from( + container.querySelectorAll('[data-slot="document-progress-segment"]'), + ) as HTMLElement[]; + +describe("DocumentProgressCard", () => { + describe("snapshots", () => { + it("title, percent, and three segments", () => { + const { container } = render( + , + ); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("with an unfilled remainder via total", () => { + const { container } = render( + , + ); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("legend override distinct from bar segments", () => { + const { container } = render( + , + ); + expect(container.innerHTML).toMatchSnapshot(); + }); + }); + + it("renders segment labels and values in the legend", () => { + render( + , + ); + expect(screen.getByText("Shipped")).toBeDefined(); + expect(screen.getByText("Pending")).toBeDefined(); + expect(screen.getByText("30")).toBeDefined(); + expect(screen.getByText("10")).toBeDefined(); + }); + + it("renders the headline percentage when provided", () => { + render( + , + ); + expect(getPercentText()).toBe("42%"); + }); + + it("omits the header when neither title nor percent is provided", () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-slot="document-progress-percent"]')).toBeNull(); + // only the bar+legend wrapper is a child of the root (no header row) + expect(getRoot(container).children.length).toBe(1); + }); + + it("clamps the percentage to [0, 100] and rounds it", () => { + render( + , + ); + expect(getPercentText()).toBe("100%"); + }); + + it("sizes bar segments against the sum of values by default", () => { + const { container } = render( + , + ); + const [a, b] = segments(container); + // sum = 40 → 75% / 25% + expect(a.style.width).toBe("75%"); + expect(b.style.width).toBe("25%"); + }); + + it("leaves an unfilled remainder when total exceeds the segment sum", () => { + const { container } = render( + , + ); + const segs = segments(container); + expect(segs).toHaveLength(1); + expect(segs[0].style.width).toBe("25%"); // 10 / 40 + }); + + it("renders the legend independently of the bar when `legend` is provided", () => { + const { container } = render( + , + ); + // bar reflects segments (2), legend reflects the override (3 rows) + expect(segments(container)).toHaveLength(2); + expect(container.querySelectorAll('[data-slot="document-progress-legend-row"]')).toHaveLength( + 3, + ); + expect(screen.getByText("Received items")).toBeDefined(); + expect(screen.getByText("12")).toBeDefined(); + }); + + it("applies the segment color to the bar", () => { + const { container } = render( + , + ); + const [a, b] = segments(container); + expect(a.getAttribute("data-color")).toBe("green"); + expect(b.getAttribute("data-color")).toBe("amber"); + }); + + it("sanitizes non-finite and negative values to zero", () => { + render( + , + ); + expect(screen.getAllByText("0")).toHaveLength(3); + }); + + it("renders the bar as a decorative element", () => { + const { container } = render( + , + ); + const bar = container.querySelector('[data-slot="document-progress-bar"]') as HTMLElement; + expect(bar.getAttribute("aria-hidden")).toBe("true"); + }); + + it("accepts a custom className on the root", () => { + const { container } = render( + , + ); + expect(getRoot(container).className).toContain("custom-card"); + }); +}); diff --git a/packages/core/src/components/document-progress-card/DocumentProgressCard.tsx b/packages/core/src/components/document-progress-card/DocumentProgressCard.tsx new file mode 100644 index 00000000..2dec88ed --- /dev/null +++ b/packages/core/src/components/document-progress-card/DocumentProgressCard.tsx @@ -0,0 +1,183 @@ +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { Card } from "../card"; +import type { + DocumentProgressCardProps, + DocumentProgressColor, + DocumentProgressSegment, +} from "./types"; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/** Maps a palette color to its fill class (used for the bar and legend marker). */ +const colorFill: Record = { + indigo: "astw:bg-indigo-500", + pink: "astw:bg-pink-500", + green: "astw:bg-green-500", + amber: "astw:bg-amber-500", + red: "astw:bg-red-500", + blue: "astw:bg-blue-500", + neutral: "astw:bg-muted-foreground/40", +}; + +// ============================================================================ +// HELPERS +// ============================================================================ + +/** Clamp a fraction to the [0, 1] range, guarding against a zero denominator. */ +const calculateRate = (numerator: number, denominator: number) => + denominator > 0 ? Math.min(1, Math.max(0, numerator / denominator)) : 0; + +/** Coerce an input amount to a non-negative, finite number (NaN/Infinity/negative → 0). */ +const sanitizeValue = (value: number) => (Number.isFinite(value) ? Math.max(0, value) : 0); + +/** Sanitize each segment's value. */ +const resolveSegments = (segments: DocumentProgressSegment[]) => + segments.map((segment) => ({ + label: segment.label, + value: sanitizeValue(segment.value), + color: segment.color, + })); + +// ============================================================================ +// DOCUMENT PROGRESS CARD +// ============================================================================ + +/** + * DocumentProgressCard — a generic, presentational card for a document's + * lifecycle/fulfilment state: an optional headline percentage, a stacked + * progress bar, and a status legend. + * + * View-only and domain-agnostic — pass an arbitrary set of `segments` (e.g. + * shipped / returned / pending, or cancelled / blocked / …) plus an explicit + * `percent`. Derive the percentage and, where buckets overlap, the bar + * breakdown in the consumer (see the docs for a purchase-order example). + * + * @example + * ```tsx + * + * ``` + */ +export function DocumentProgressCard({ + title, + percent, + segments, + legend, + total, + className, +}: DocumentProgressCardProps) { + const barSegments = resolveSegments(segments); + const legendItems = resolveSegments(legend ?? segments); + + const segmentSum = barSegments.reduce((sum, segment) => sum + segment.value, 0); + const requestedTotal = total != null && Number.isFinite(total) ? total : 0; + // Never let the denominator fall below the segment sum (would overflow the bar). + const denominator = Math.max(segmentSum, requestedTotal); + + const hasPercent = percent != null && Number.isFinite(percent); + const clampedPercent = hasPercent ? Math.round(Math.min(100, Math.max(0, percent))) : null; + + return ( + + {/* Header: optional title + optional completion percentage */} + {(title != null || hasPercent) && ( +
+ {title} + {hasPercent && ( + + {clampedPercent}% + + )} +
+ )} + + {/* Bar + legend */} +
+ {/* Decorative — the percentage and legend convey the same values as text. */} +
+ {barSegments.map((segment, index) => { + const fraction = calculateRate(segment.value, denominator); + if (fraction <= 0) return null; + return ( +
+ ); + })} +
+ + {legendItems.length > 0 && ( +
+ {legendItems.map((segment, index) => ( + + ))} +
+ )} +
+ + ); +} + +// ============================================================================ +// LEGEND ROW +// ============================================================================ + +function LegendRow({ + label, + color, + value, +}: { + label: ReactNode; + color: DocumentProgressColor; + value: ReactNode; +}) { + return ( +
+
+ + {label} +
+ {value} +
+ ); +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +export default DocumentProgressCard; diff --git a/packages/core/src/components/document-progress-card/index.ts b/packages/core/src/components/document-progress-card/index.ts new file mode 100644 index 00000000..164ad5b4 --- /dev/null +++ b/packages/core/src/components/document-progress-card/index.ts @@ -0,0 +1,2 @@ +export { DocumentProgressCard, default } from "./DocumentProgressCard"; +export type { DocumentProgressCardProps } from "./types"; diff --git a/packages/core/src/components/document-progress-card/types.ts b/packages/core/src/components/document-progress-card/types.ts new file mode 100644 index 00000000..401cf7d9 --- /dev/null +++ b/packages/core/src/components/document-progress-card/types.ts @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; + +// ============================================================================ +// COLOR +// ============================================================================ + +/** + * Curated palette for progress segments. Each value maps to a fixed fill used + * for both the stacked bar and the legend marker. + */ +export type DocumentProgressColor = + | "indigo" + | "pink" + | "green" + | "amber" + | "red" + | "blue" + | "neutral"; + +// ============================================================================ +// SEGMENT +// ============================================================================ + +/** + * A single status segment: its amount, a legend label, and a color. + */ +export interface DocumentProgressSegment { + /** Legend label (e.g. "Shipped"). */ + label: string; + /** Amount — shown in the legend and used to size the bar. */ + value: number; + /** Bar / legend-marker color. */ + color: DocumentProgressColor; +} + +// ============================================================================ +// COMPONENT PROPS +// ============================================================================ + +/** + * Props for the generic DocumentProgressCard. + */ +export interface DocumentProgressCardProps { + /** Optional card title shown top-left. */ + title?: ReactNode; + /** + * Headline percentage shown top-right (0–100). Optional and explicit — the + * generic card has no notion of "complete", so the consumer supplies it. + */ + percent?: number; + /** Status segments rendered as a single stacked bar (and, by default, the legend). */ + segments: DocumentProgressSegment[]; + /** + * Legend rows. Defaults to `segments`. Provide this only when the legend + * should differ from the bar (e.g. overlapping buckets shown distinctly). + */ + legend?: DocumentProgressSegment[]; + /** + * Denominator used to size the bar. Defaults to the sum of segment values + * (the bar is fully tiled). A larger value leaves an unfilled track remainder. + */ + total?: number; + /** Additional CSS classes for the card root. */ + className?: string; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61eb11df..6a0767fd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,10 @@ export { type UseAttachmentOptions, } from "./components/attachment"; export { MetricCard, type MetricCardProps } from "./components/metric-card"; +export { + DocumentProgressCard, + type DocumentProgressCardProps, +} from "./components/document-progress-card"; export { Layout, type LayoutProps } from "./components/layout/Layout"; export { Grid, type GridProps, type GridItemProps } from "./components/grid"; export { Button, buttonVariants, type ButtonProps } from "./components/button";