Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/add-document-progress-card.md
Original file line number Diff line number Diff line change
@@ -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";

<DocumentProgressCard
title="Shipment status"
percent={60}
segments={[
{ label: "Shipped", value: 30, color: "green" },
{ label: "Returned", value: 3, color: "red" },
{ label: "Pending", value: 17, color: "neutral" },
]}
/>;
```

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.
21 changes: 21 additions & 0 deletions catalogue/src/fundamental/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<DocumentProgressCard
title="Shipment status"
percent={60}
segments={[
{ label: "Shipped", value: 30, color: "green" },
{ label: "Returned", value: 3, color: "red" },
{ label: "Pending", value: 17, color: "neutral" },
]}
/>
```

**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'`
Expand Down
137 changes: 137 additions & 0 deletions docs/components/document-progress-card.md
Original file line number Diff line number Diff line change
@@ -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
<DocumentProgressCard
title="Shipment status"
percent={60}
segments={[
{ label: "Shipped", value: 30, color: "green" },
{ label: "Returned", value: 3, color: "red" },
{ label: "Pending", value: 17, color: "neutral" },
]}
/>
```

## 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
<DocumentProgressCard
percent={30}
total={40}
segments={[
{ label: "Net received", value: 10, color: "indigo" },
{ label: "Returned", value: 2, color: "pink" },
]}
legend={[
{ label: "Received items", value: 12, color: "indigo" },
{ label: "Returned items", value: 2, color: "pink" },
{ label: "Yet to receive", value: 28, color: "neutral" },
]}
/>
```

## 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 (
<DocumentProgressCard
title="Fulfilment rate"
percent={percent}
total={total} // ordered quantity — the shortfall renders as the "yet to receive" track
// Bar: net received (kept) + returned. Yet-to-receive is the unfilled remainder.
segments={[
{ label: "Received items", value: received - effectiveReturned, color: "indigo" },
{ label: "Returned items", value: effectiveReturned, color: "pink" },
]}
// Legend: the three buckets as-is, so "Received items" shows the full amount.
legend={[
{ label: "Received items", value: received, color: "indigo" },
{ label: "Returned items", value: returned, color: "pink" },
{ label: "Yet to receive", value: yetToReceive, color: "neutral" },
]}
/>
);
}

// <PurchaseOrderFulfilment received={12} returned={2} yetToReceive={28} /> → 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.
1 change: 1 addition & 0 deletions examples/vite-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const App = () => {
<SidebarItem to="/dashboard" activeMatch="exact" />
<SidebarItem to="/dashboard/orders" />
<SidebarItem to="/dashboard/products" />
<SidebarItem to="/dashboard/document-progress" />
</SidebarGroup>
<SidebarItem to="/settings" />
</DefaultSidebar>
Expand Down
155 changes: 155 additions & 0 deletions examples/vite-app/src/pages/dashboard/document-progress/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {
Layout,
Grid,
DocumentProgressCard,
type AppShellPageProps,
} from "@tailor-platform/app-shell";

const GaugeIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 14 4-4" />
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
</svg>
);

/**
* 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 (
<DocumentProgressCard
title={title}
percent={percent}
total={total}
segments={[
{ label: "Received items", value: received - effectiveReturned, color: "indigo" },
{ label: "Returned items", value: effectiveReturned, color: "pink" },
]}
legend={[
{ label: "Received items", value: received, color: "indigo" },
{ label: "Returned items", value: returned, color: "pink" },
{ label: "Yet to receive", value: yetToReceive, color: "neutral" },
]}
/>
);
};

const DocumentProgressPage = () => {
return (
<Layout>
<Layout.Header title="Document Progress Card" />

<Layout.Column>
<h2 className="mb-1 text-lg font-semibold">Arbitrary status segments</h2>
<p className="mb-4 text-muted-foreground text-sm">
Domain-agnostic — any set of status segments plus an explicit percentage.
</p>
<Grid minChildWidth={320} gap={4}>
{/* Arbitrary lifecycle: shipped / returned / pending */}
<DocumentProgressCard
title="Shipment status"
percent={60}
segments={[
{ label: "Shipped", value: 30, color: "green" },
{ label: "Returned", value: 3, color: "red" },
{ label: "Pending", value: 17, color: "neutral" },
]}
/>

{/* More than three statuses — stretches to any workflow */}
<DocumentProgressCard
title="Order lifecycle"
percent={45}
segments={[
{ label: "Fulfilled", value: 45, color: "green" },
{ label: "Backordered", value: 20, color: "amber" },
{ label: "Cancelled", value: 10, color: "red" },
{ label: "Open", value: 25, color: "neutral" },
]}
/>

{/* No percentage — just a composition bar + legend */}
<DocumentProgressCard
title="Invoice batch"
segments={[
{ label: "Paid", value: 18, color: "green" },
{ label: "Overdue", value: 4, color: "red" },
{ label: "Draft", value: 8, color: "neutral" },
]}
/>

{/* Unfilled remainder via total */}
<DocumentProgressCard
title="Approvals"
percent={40}
total={20}
segments={[{ label: "Approved", value: 8, color: "indigo" }]}
/>
</Grid>

<h2 className="mt-8 mb-1 text-lg font-semibold">Recipe: purchase-order fulfilment</h2>
<p className="mb-4 text-muted-foreground text-sm">
Received / returned / yet-to-receive, derived in the consumer (see{" "}
<code>PurchaseOrderFulfilment</code> in this file and the component docs).
</p>
<Grid minChildWidth={320} gap={4}>
{/* Matches the Figma baseline — nothing received yet */}
<PurchaseOrderFulfilment received={0} returned={0} yetToReceive={40} />

{/* Partially received with some returns */}
<PurchaseOrderFulfilment received={12} returned={2} yetToReceive={28} />

{/* Same data, but returns subtracted from progress */}
<PurchaseOrderFulfilment
title="Fulfilment rate (net of returns)"
received={12}
returned={2}
yetToReceive={28}
returnedCountsAsComplete={false}
/>

{/* Fully received */}
<PurchaseOrderFulfilment received={40} returned={0} yetToReceive={0} />
</Grid>
</Layout.Column>
</Layout>
);
};

DocumentProgressPage.appShellPageProps = {
meta: {
title: "Document Progress",
icon: <GaugeIcon />,
},
} satisfies AppShellPageProps;

export default DocumentProgressPage;
1 change: 1 addition & 0 deletions examples/vite-app/src/routes.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {};
Expand Down
Loading
Loading