@handled-ai/design-system 0.17.2 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts/empty-chart-state.d.ts +11 -0
- package/dist/charts/empty-chart-state.js +70 -0
- package/dist/charts/empty-chart-state.js.map +1 -0
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.js +1 -0
- package/dist/charts/index.js.map +1 -1
- package/dist/charts/pipeline-overview.d.ts +2 -1
- package/dist/charts/pipeline-overview.js +29 -1
- package/dist/charts/pipeline-overview.js.map +1 -1
- package/dist/components/actor-byline.d.ts +3 -0
- package/dist/components/actor-byline.js +5 -0
- package/dist/components/actor-byline.js.map +1 -0
- package/dist/components/days-open-cell.d.ts +16 -0
- package/dist/components/days-open-cell.js +73 -0
- package/dist/components/days-open-cell.js.map +1 -0
- package/dist/components/detail-drawer.d.ts +16 -0
- package/dist/components/detail-drawer.js +45 -0
- package/dist/components/detail-drawer.js.map +1 -0
- package/dist/components/insights-filter-bar.d.ts +2 -1
- package/dist/components/insights-filter-bar.js +13 -5
- package/dist/components/insights-filter-bar.js.map +1 -1
- package/dist/components/linked-entity-cell.d.ts +14 -0
- package/dist/components/linked-entity-cell.js +96 -0
- package/dist/components/linked-entity-cell.js.map +1 -0
- package/dist/components/metric-card.d.ts +14 -1
- package/dist/components/metric-card.js +86 -0
- package/dist/components/metric-card.js.map +1 -1
- package/dist/components/performance-metrics-table.d.ts +2 -1
- package/dist/components/performance-metrics-table.js +78 -46
- package/dist/components/performance-metrics-table.js.map +1 -1
- package/dist/components/pill.d.ts +26 -0
- package/dist/components/pill.js +77 -0
- package/dist/components/pill.js.map +1 -0
- package/dist/components/quick-segment.d.ts +13 -0
- package/dist/components/quick-segment.js +96 -0
- package/dist/components/quick-segment.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/__tests__/insights-charts.test.tsx +62 -0
- package/src/charts/empty-chart-state.tsx +44 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/pipeline-overview.tsx +38 -1
- package/src/components/__tests__/insights-primitives.test.tsx +117 -0
- package/src/components/__tests__/performance-metrics-table.test.tsx +54 -0
- package/src/components/__tests__/user-display.test.tsx +75 -0
- package/src/components/actor-byline.tsx +1 -0
- package/src/components/days-open-cell.tsx +50 -0
- package/src/components/detail-drawer.tsx +60 -0
- package/src/components/insights-filter-bar.tsx +13 -4
- package/src/components/linked-entity-cell.tsx +74 -0
- package/src/components/metric-card.tsx +82 -0
- package/src/components/performance-metrics-table.tsx +99 -63
- package/src/components/pill.tsx +67 -0
- package/src/components/quick-segment.tsx +68 -0
- package/src/index.ts +5 -0
- package/src/lib/__tests__/user-display.test.ts +53 -11
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import { DaysOpenCell, getDaysOpenIntent } from "../days-open-cell"
|
|
6
|
+
import { DetailDrawer } from "../detail-drawer"
|
|
7
|
+
import { InsightsFilterBar } from "../insights-filter-bar"
|
|
8
|
+
import { LinkedEntityCell } from "../linked-entity-cell"
|
|
9
|
+
import { KpiStrip } from "../metric-card"
|
|
10
|
+
import { Pill, StatusPill } from "../pill"
|
|
11
|
+
import { QuickSegment } from "../quick-segment"
|
|
12
|
+
|
|
13
|
+
describe("Insights primitives", () => {
|
|
14
|
+
it("renders compact InsightsFilterBar without changing default API", () => {
|
|
15
|
+
const { container } = render(
|
|
16
|
+
<InsightsFilterBar
|
|
17
|
+
variant="compact"
|
|
18
|
+
filters={[{ id: "status", label: "Status", options: ["All", "Open"], defaultValue: "All" }]}
|
|
19
|
+
values={{ status: "Open" }}
|
|
20
|
+
onChange={() => {}}
|
|
21
|
+
onClearAll={() => {}}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const bar = container.querySelector('[data-slot="insights-filter-bar"]')!
|
|
26
|
+
expect(bar.className).toContain("p-2")
|
|
27
|
+
expect(bar.className).toContain("gap-2")
|
|
28
|
+
expect(screen.getByRole("button", { name: /Status: Open/i }).className).toContain("h-7")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("renders KpiStrip items and changes", () => {
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<KpiStrip
|
|
34
|
+
items={[
|
|
35
|
+
{ label: "New", value: 42, unit: "leads", change: { value: "8%", direction: "up" } },
|
|
36
|
+
{ label: "Aging", value: 12, subtitle: "over SLA", change: { value: "3%", direction: "down", isGood: true } },
|
|
37
|
+
]}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(container.querySelectorAll('[data-slot="kpi-strip-item"]')).toHaveLength(2)
|
|
42
|
+
expect(screen.getByText("New")).not.toBeNull()
|
|
43
|
+
expect(screen.getByText("42")).not.toBeNull()
|
|
44
|
+
expect(screen.getByText("8%")).not.toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("QuickSegment exposes selected state and invokes onSelect", () => {
|
|
48
|
+
const onSelect = vi.fn()
|
|
49
|
+
render(<QuickSegment label="At risk" value="risk" count={5} selected onSelect={onSelect} />)
|
|
50
|
+
|
|
51
|
+
const button = screen.getByRole("button", { name: /At risk/i })
|
|
52
|
+
expect(button.getAttribute("aria-pressed")).toBe("true")
|
|
53
|
+
expect(screen.getByText("5")).not.toBeNull()
|
|
54
|
+
fireEvent.click(button)
|
|
55
|
+
expect(onSelect).toHaveBeenCalledWith("risk")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("LinkedEntityCell renders entity links and metadata", () => {
|
|
59
|
+
const onNavigate = vi.fn()
|
|
60
|
+
render(
|
|
61
|
+
<LinkedEntityCell
|
|
62
|
+
name="Acme Health"
|
|
63
|
+
href="/accounts/acme"
|
|
64
|
+
subtitle="Account"
|
|
65
|
+
meta="Tier 1"
|
|
66
|
+
onNavigate={onNavigate}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const link = screen.getByRole("link", { name: "Acme Health" })
|
|
71
|
+
expect(link.getAttribute("href")).toBe("/accounts/acme")
|
|
72
|
+
expect(screen.getByText(/Tier 1/)).not.toBeNull()
|
|
73
|
+
fireEvent.click(link)
|
|
74
|
+
expect(onNavigate).toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("DaysOpenCell maps thresholds to status intents", () => {
|
|
78
|
+
expect(getDaysOpenIntent(2, 7, 30)).toBe("success")
|
|
79
|
+
expect(getDaysOpenIntent(10, 7, 30)).toBe("warning")
|
|
80
|
+
expect(getDaysOpenIntent(45, 7, 30)).toBe("error")
|
|
81
|
+
|
|
82
|
+
render(<DaysOpenCell days={45} />)
|
|
83
|
+
expect(screen.getByTestId("days-open-pill").getAttribute("data-variant")).toBe("error")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("Pill and StatusPill render wrapper variants", () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<div>
|
|
89
|
+
<Pill variant="info">Info</Pill>
|
|
90
|
+
<StatusPill status="Blocked" intent="error" />
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(container.querySelector('[data-slot="pill"]')?.getAttribute("data-variant")).toBe("info")
|
|
95
|
+
expect(container.querySelector('[data-slot="status-pill"]')?.getAttribute("data-variant")).toBe("error")
|
|
96
|
+
expect(screen.getByText("Blocked")).not.toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("DetailDrawer renders title, content, and footer when open", () => {
|
|
100
|
+
render(
|
|
101
|
+
<DetailDrawer
|
|
102
|
+
open
|
|
103
|
+
onOpenChange={() => {}}
|
|
104
|
+
title="Referral details"
|
|
105
|
+
description="A drawer for insights"
|
|
106
|
+
footer={<button type="button">Done</button>}
|
|
107
|
+
>
|
|
108
|
+
<div>Drawer body</div>
|
|
109
|
+
</DetailDrawer>
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(screen.getByText("Referral details")).not.toBeNull()
|
|
113
|
+
expect(screen.getByText("A drawer for insights")).not.toBeNull()
|
|
114
|
+
expect(screen.getByText("Drawer body")).not.toBeNull()
|
|
115
|
+
expect(screen.getByRole("button", { name: "Done" })).not.toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, within } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
PerformanceMetricsTable,
|
|
7
|
+
type PerformanceMetricsTableRow,
|
|
8
|
+
} from "../performance-metrics-table"
|
|
9
|
+
|
|
10
|
+
const rows: PerformanceMetricsTableRow[] = [
|
|
11
|
+
{
|
|
12
|
+
id: "row-1",
|
|
13
|
+
label: "Acme Health",
|
|
14
|
+
avatarFallback: "AH",
|
|
15
|
+
role: "Coordinator",
|
|
16
|
+
primaryValue: 10,
|
|
17
|
+
primaryTarget: 20,
|
|
18
|
+
ratePercent: 71,
|
|
19
|
+
metricOne: 5,
|
|
20
|
+
metricTwo: "1.2h",
|
|
21
|
+
metricThree: 8,
|
|
22
|
+
metricFour: 3,
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
function renderTable(primaryMetricDisplayMode?: "progress" | "value") {
|
|
27
|
+
return render(
|
|
28
|
+
<PerformanceMetricsTable
|
|
29
|
+
rows={rows}
|
|
30
|
+
primaryMetricDisplayMode={primaryMetricDisplayMode}
|
|
31
|
+
roleOptions={["All", "Coordinator"]}
|
|
32
|
+
viewOptions={["By Entity"]}
|
|
33
|
+
/>,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("PerformanceMetricsTable", () => {
|
|
38
|
+
it("defaults the primary metric display to progress", () => {
|
|
39
|
+
renderTable()
|
|
40
|
+
|
|
41
|
+
const row = screen.getByText("Acme Health").closest("tr")!
|
|
42
|
+
expect(within(row).getByText("10/20")).not.toBeNull()
|
|
43
|
+
expect(within(row).getByText("50%")).not.toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("renders the primary metric as a plain value in value mode", () => {
|
|
47
|
+
renderTable("value")
|
|
48
|
+
|
|
49
|
+
const row = screen.getByText("Acme Health").closest("tr")!
|
|
50
|
+
expect(within(row).getByText("10")).not.toBeNull()
|
|
51
|
+
expect(within(row).queryByText("10/20")).toBeNull()
|
|
52
|
+
expect(within(row).queryByText("50%")).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { ActorByline } from "../actor-byline"
|
|
6
|
+
import { UserPill } from "../user-pill"
|
|
7
|
+
|
|
8
|
+
describe("UserPill", () => {
|
|
9
|
+
it("renders a display name and derived initials from profile", () => {
|
|
10
|
+
const { container } = render(
|
|
11
|
+
<UserPill profile={{ first_name: "Sarah", last_name: "Mitchell", email: "sarah@example.com" }} />
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
expect(container.querySelector('[data-slot="user-pill"]')).not.toBeNull()
|
|
15
|
+
expect(screen.getByText("Sarah Mitchell")).not.toBeNull()
|
|
16
|
+
expect(screen.getByText("SM")).not.toBeNull()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("does not require profile and can render direct name/email props with subtitle", () => {
|
|
20
|
+
render(<UserPill name="Marcus Webb" email="marcus@example.com" subtitle="Account executive" variant="compact" />)
|
|
21
|
+
|
|
22
|
+
expect(screen.getByText("Marcus Webb")).not.toBeNull()
|
|
23
|
+
expect(screen.getByText("Account executive")).not.toBeNull()
|
|
24
|
+
expect(screen.getByText("MW")).not.toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("falls back to email local part and initials from direct email prop", () => {
|
|
28
|
+
render(<UserPill email="fallback@example.com" />)
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText("fallback")).not.toBeNull()
|
|
31
|
+
expect(screen.getByText("FA")).not.toBeNull()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("ActorByline", () => {
|
|
36
|
+
it("renders plain byline text with actor name, verb, subject, and string timestamp", () => {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<ActorByline
|
|
39
|
+
actor={{ first_name: "Cory", last_name: "Pitt", email: "cory@example.com" }}
|
|
40
|
+
verb="closed"
|
|
41
|
+
subject="this opportunity"
|
|
42
|
+
timestamp="3 min ago"
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(container.querySelector('[data-slot="actor-byline"]')).not.toBeNull()
|
|
47
|
+
expect(container.textContent).toBe("Cory Pitt closed this opportunity · 3 min ago")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
it("renders Date timestamps deterministically", () => {
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<ActorByline
|
|
54
|
+
actor={{ name: "Cory Pitt", email: "cory@withhandled.com" }}
|
|
55
|
+
verb="closed"
|
|
56
|
+
subject="this opportunity"
|
|
57
|
+
timestamp={new Date("2026-05-24T18:30:00.000Z")}
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(container.textContent).toBe("Cory Pitt closed this opportunity · 2026-05-24T18:30:00.000Z")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("omits missing pieces and null timestamp cleanly", () => {
|
|
65
|
+
const { container } = render(<ActorByline actor={{ name: "Handled AI", email: "agent@handled.ai" }} subject="a task" timestamp={null} />)
|
|
66
|
+
|
|
67
|
+
expect(container.textContent).toBe("Handled AI a task")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("falls back through shared user-display rules", () => {
|
|
71
|
+
const { container } = render(<ActorByline actor={{ first_name: null, last_name: null, name: null, email: "fallback@example.com" }} />)
|
|
72
|
+
|
|
73
|
+
expect(container.textContent).toBe("fallback")
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ActorByline, type ActorBylineProps } from "./user-display"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
import { StatusPill, type PillStatus } from "./pill"
|
|
7
|
+
|
|
8
|
+
export interface DaysOpenCellProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
days: number | null | undefined
|
|
10
|
+
warningAt?: number
|
|
11
|
+
criticalAt?: number
|
|
12
|
+
emptyLabel?: string
|
|
13
|
+
suffix?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getDaysOpenIntent(days: number, warningAt: number, criticalAt: number): PillStatus {
|
|
17
|
+
if (days >= criticalAt) return "error"
|
|
18
|
+
if (days >= warningAt) return "warning"
|
|
19
|
+
return "success"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function DaysOpenCell({
|
|
23
|
+
days,
|
|
24
|
+
warningAt = 7,
|
|
25
|
+
criticalAt = 30,
|
|
26
|
+
emptyLabel = "—",
|
|
27
|
+
suffix = "d open",
|
|
28
|
+
className,
|
|
29
|
+
...props
|
|
30
|
+
}: DaysOpenCellProps) {
|
|
31
|
+
if (days === null || days === undefined) {
|
|
32
|
+
return (
|
|
33
|
+
<div data-slot="days-open-cell" className={cn("text-sm text-muted-foreground", className)} {...props}>
|
|
34
|
+
{emptyLabel}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const intent = getDaysOpenIntent(days, warningAt, criticalAt)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div data-slot="days-open-cell" className={cn("inline-flex items-center", className)} {...props}>
|
|
43
|
+
<StatusPill data-testid="days-open-pill" status={`${days} ${suffix}`} intent={intent}>
|
|
44
|
+
{days} {suffix}
|
|
45
|
+
</StatusPill>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { getDaysOpenIntent }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
import {
|
|
7
|
+
Sheet,
|
|
8
|
+
SheetContent,
|
|
9
|
+
SheetDescription,
|
|
10
|
+
SheetFooter,
|
|
11
|
+
SheetHeader,
|
|
12
|
+
SheetTitle,
|
|
13
|
+
} from "./sheet"
|
|
14
|
+
|
|
15
|
+
export interface DetailDrawerProps {
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
title: React.ReactNode
|
|
19
|
+
description?: React.ReactNode
|
|
20
|
+
children: React.ReactNode
|
|
21
|
+
footer?: React.ReactNode
|
|
22
|
+
side?: "right" | "left"
|
|
23
|
+
className?: string
|
|
24
|
+
contentClassName?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DetailDrawer({
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
children,
|
|
33
|
+
footer,
|
|
34
|
+
side = "right",
|
|
35
|
+
className,
|
|
36
|
+
contentClassName,
|
|
37
|
+
}: DetailDrawerProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
40
|
+
<SheetContent
|
|
41
|
+
data-slot="detail-drawer"
|
|
42
|
+
side={side}
|
|
43
|
+
className={cn("w-full gap-0 p-0 sm:max-w-xl", className)}
|
|
44
|
+
>
|
|
45
|
+
<SheetHeader data-slot="detail-drawer-header" className="border-b border-border p-5">
|
|
46
|
+
<SheetTitle>{title}</SheetTitle>
|
|
47
|
+
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
|
48
|
+
</SheetHeader>
|
|
49
|
+
<div data-slot="detail-drawer-content" className={cn("flex-1 overflow-y-auto p-5", contentClassName)}>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
{footer ? (
|
|
53
|
+
<SheetFooter data-slot="detail-drawer-footer" className="border-t border-border p-5">
|
|
54
|
+
{footer}
|
|
55
|
+
</SheetFooter>
|
|
56
|
+
) : null}
|
|
57
|
+
</SheetContent>
|
|
58
|
+
</Sheet>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -23,6 +23,7 @@ export interface FilterDefinition {
|
|
|
23
23
|
|
|
24
24
|
export interface InsightsFilterBarProps {
|
|
25
25
|
filters: FilterDefinition[]
|
|
26
|
+
variant?: "default" | "compact"
|
|
26
27
|
values: Record<string, string>
|
|
27
28
|
onChange: (filterId: string, value: string) => void
|
|
28
29
|
onClearAll?: () => void
|
|
@@ -45,6 +46,7 @@ function InsightsFilterBar({
|
|
|
45
46
|
onChange,
|
|
46
47
|
onClearAll,
|
|
47
48
|
className,
|
|
49
|
+
variant = "default",
|
|
48
50
|
}: InsightsFilterBarProps) {
|
|
49
51
|
const showClearAll = onClearAll && hasNonDefaultValue(filters, values)
|
|
50
52
|
|
|
@@ -52,11 +54,12 @@ function InsightsFilterBar({
|
|
|
52
54
|
<div
|
|
53
55
|
data-slot="insights-filter-bar"
|
|
54
56
|
className={cn(
|
|
55
|
-
"flex flex-wrap items-center
|
|
57
|
+
"flex flex-wrap items-center rounded-md border border-border bg-card shadow-sm",
|
|
58
|
+
variant === "compact" ? "gap-2 p-2" : "gap-3 p-4",
|
|
56
59
|
className
|
|
57
60
|
)}
|
|
58
61
|
>
|
|
59
|
-
<div className="flex items-center gap-2">
|
|
62
|
+
<div className={cn("flex items-center gap-2", variant === "compact" && "sr-only")}>
|
|
60
63
|
<FilterIcon className="h-4 w-4 text-muted-foreground" />
|
|
61
64
|
<span className="text-sm font-medium text-muted-foreground">
|
|
62
65
|
Filters:
|
|
@@ -80,7 +83,10 @@ function InsightsFilterBar({
|
|
|
80
83
|
<Button
|
|
81
84
|
variant="outline"
|
|
82
85
|
size="sm"
|
|
83
|
-
className=
|
|
86
|
+
className={cn(
|
|
87
|
+
"gap-1.5 text-xs font-normal shadow-none",
|
|
88
|
+
variant === "compact" ? "h-7 px-2" : "h-8"
|
|
89
|
+
)}
|
|
84
90
|
>
|
|
85
91
|
{IconComp ? (
|
|
86
92
|
<IconComp className="h-3.5 w-3.5 text-muted-foreground" />
|
|
@@ -118,7 +124,10 @@ function InsightsFilterBar({
|
|
|
118
124
|
<Button
|
|
119
125
|
variant="ghost"
|
|
120
126
|
size="sm"
|
|
121
|
-
className=
|
|
127
|
+
className={cn(
|
|
128
|
+
"text-xs text-destructive hover:text-destructive",
|
|
129
|
+
variant === "compact" ? "h-7 px-2" : "h-8"
|
|
130
|
+
)}
|
|
122
131
|
onClick={onClearAll}
|
|
123
132
|
>
|
|
124
133
|
Clear All
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ExternalLink } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
|
|
8
|
+
export interface LinkedEntityCellProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
name: React.ReactNode
|
|
10
|
+
href?: string
|
|
11
|
+
subtitle?: React.ReactNode
|
|
12
|
+
meta?: React.ReactNode
|
|
13
|
+
icon?: React.ReactNode
|
|
14
|
+
external?: boolean
|
|
15
|
+
onNavigate?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function LinkedEntityCell({
|
|
19
|
+
name,
|
|
20
|
+
href,
|
|
21
|
+
subtitle,
|
|
22
|
+
meta,
|
|
23
|
+
icon,
|
|
24
|
+
external = false,
|
|
25
|
+
onNavigate,
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: LinkedEntityCellProps) {
|
|
29
|
+
const content = (
|
|
30
|
+
<>
|
|
31
|
+
<span className="truncate">{name}</span>
|
|
32
|
+
{external ? <ExternalLink className="h-3 w-3 shrink-0 opacity-60" aria-hidden="true" /> : null}
|
|
33
|
+
</>
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
data-slot="linked-entity-cell"
|
|
39
|
+
className={cn("flex min-w-0 items-center gap-2", className)}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
{icon ? (
|
|
43
|
+
<span data-slot="linked-entity-cell-icon" className="shrink-0 text-muted-foreground">
|
|
44
|
+
{icon}
|
|
45
|
+
</span>
|
|
46
|
+
) : null}
|
|
47
|
+
<div className="min-w-0 flex-1">
|
|
48
|
+
{href ? (
|
|
49
|
+
<a
|
|
50
|
+
data-slot="linked-entity-cell-link"
|
|
51
|
+
href={href}
|
|
52
|
+
target={external ? "_blank" : undefined}
|
|
53
|
+
rel={external ? "noreferrer" : undefined}
|
|
54
|
+
onClick={onNavigate}
|
|
55
|
+
className="inline-flex max-w-full items-center gap-1 truncate font-medium text-foreground underline-offset-4 hover:text-primary hover:underline"
|
|
56
|
+
>
|
|
57
|
+
{content}
|
|
58
|
+
</a>
|
|
59
|
+
) : (
|
|
60
|
+
<span data-slot="linked-entity-cell-name" className="block truncate font-medium text-foreground">
|
|
61
|
+
{name}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
{subtitle || meta ? (
|
|
65
|
+
<div data-slot="linked-entity-cell-meta" className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
66
|
+
{subtitle}
|
|
67
|
+
{subtitle && meta ? <span className="px-1">·</span> : null}
|
|
68
|
+
{meta}
|
|
69
|
+
</div>
|
|
70
|
+
) : null}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -24,6 +24,88 @@ export interface MetricCardProps {
|
|
|
24
24
|
showInfo?: boolean
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export interface KpiStripItem {
|
|
28
|
+
id?: string
|
|
29
|
+
label: React.ReactNode
|
|
30
|
+
value: React.ReactNode
|
|
31
|
+
unit?: React.ReactNode
|
|
32
|
+
subtitle?: React.ReactNode
|
|
33
|
+
change?: MetricCardProps["change"]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface KpiStripProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
37
|
+
items: KpiStripItem[]
|
|
38
|
+
columns?: 2 | 3 | 4
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function KpiStrip({ items, columns = 4, className, ...props }: KpiStripProps) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
data-slot="kpi-strip"
|
|
45
|
+
className={cn(
|
|
46
|
+
"grid gap-3 rounded-xl border border-border bg-card p-3 shadow-sm",
|
|
47
|
+
columns === 2 && "sm:grid-cols-2",
|
|
48
|
+
columns === 3 && "sm:grid-cols-3",
|
|
49
|
+
columns === 4 && "sm:grid-cols-2 lg:grid-cols-4",
|
|
50
|
+
className
|
|
51
|
+
)}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{items.map((item, index) => {
|
|
55
|
+
const isGoodDirection = item.change
|
|
56
|
+
? item.change.isGood !== undefined
|
|
57
|
+
? item.change.isGood
|
|
58
|
+
: item.change.direction === "up"
|
|
59
|
+
: false
|
|
60
|
+
const ChangeIcon = item.change?.direction === "down" ? ArrowDown : ArrowUp
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
key={item.id ?? index}
|
|
65
|
+
data-slot="kpi-strip-item"
|
|
66
|
+
className="min-w-0 rounded-lg bg-muted/40 px-3 py-2"
|
|
67
|
+
>
|
|
68
|
+
<div data-slot="kpi-strip-label" className="truncate text-xs font-medium text-muted-foreground">
|
|
69
|
+
{item.label}
|
|
70
|
+
</div>
|
|
71
|
+
<div className="mt-1 flex items-baseline gap-1">
|
|
72
|
+
<span data-slot="kpi-strip-value" className="truncate text-2xl font-bold tracking-tight text-foreground">
|
|
73
|
+
{item.value}
|
|
74
|
+
</span>
|
|
75
|
+
{item.unit ? (
|
|
76
|
+
<span data-slot="kpi-strip-unit" className="text-sm font-semibold text-muted-foreground">
|
|
77
|
+
{item.unit}
|
|
78
|
+
</span>
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
{item.subtitle || item.change ? (
|
|
82
|
+
<div className="mt-1 flex items-center gap-2 text-xs">
|
|
83
|
+
{item.change ? (
|
|
84
|
+
<span
|
|
85
|
+
data-slot="kpi-strip-change"
|
|
86
|
+
className={cn(
|
|
87
|
+
"inline-flex items-center gap-0.5 font-semibold",
|
|
88
|
+
isGoodDirection ? "text-emerald-600" : "text-red-600"
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<ChangeIcon className="h-3 w-3 stroke-[3]" />
|
|
92
|
+
{item.change.value}
|
|
93
|
+
</span>
|
|
94
|
+
) : null}
|
|
95
|
+
{item.subtitle ? (
|
|
96
|
+
<span data-slot="kpi-strip-subtitle" className="truncate text-muted-foreground">
|
|
97
|
+
{item.subtitle}
|
|
98
|
+
</span>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
) : null}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
27
109
|
export function MetricCard({
|
|
28
110
|
title,
|
|
29
111
|
value,
|