Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc803bcb34 | ||
|
|
e469c421ca | ||
|
|
094f75913b | ||
|
|
764a4e5b4e | ||
|
|
2d29a892f9 | ||
|
|
d9bec85c02 | ||
|
|
d56405af7b | ||
|
|
927e27640a | ||
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b | ||
|
|
35e262b5ae | ||
|
|
8267141136 | ||
|
|
34a29ad337 | ||
|
|
b04b27c215 | ||
|
|
6a25a3d137 | ||
|
|
32d9dc6acb | ||
|
|
fc4f234cc5 | ||
|
|
821f342476 | ||
|
|
4634aac2d1 | ||
|
|
7953ae6e78 | ||
|
|
d14550cda1 | ||
|
|
632adaa63f | ||
|
|
cc80858952 | ||
|
|
b8dbf15254 | ||
|
|
f45b2bed90 | ||
|
|
b13c233679 | ||
|
|
37f28dfcfd | ||
|
|
6c6e76fca3 | ||
|
|
26f5172f88 | ||
|
|
8fe74c1d3b | ||
|
|
13e3d21ce2 | ||
|
|
a0cfbcdab1 | ||
|
|
c3369059af | ||
|
|
53ce80c9ff | ||
|
|
1e04e91dd8 | ||
|
|
5fbe9c9b56 | ||
|
|
fd3a217cbe | ||
|
|
ff6efec4c5 | ||
|
|
738d2e2256 | ||
|
|
2e3e1493f5 | ||
|
|
fd2fd8298d | ||
|
|
df7cd99060 | ||
|
|
c273cea067 | ||
|
|
1f969e6cac | ||
|
|
c1132cbe19 | ||
|
|
b28ad88b22 | ||
|
|
b871d62de6 | ||
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a | ||
|
|
bbcf9d76d9 | ||
|
|
55eb2a6c89 | ||
|
|
a3f3ffd672 | ||
|
|
1a142e68a9 | ||
|
|
46e7b2aa6b | ||
|
|
1152bb40f7 |
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -79,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|
|||||||
|--------|----------|--------|
|
|--------|----------|--------|
|
||||||
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
||||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
||||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → `reui-kit/ResourcePage` |
|
||||||
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
||||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||||
|
|
||||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
|
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
|
||||||
|
|
||||||
## Уже установленные shared-обёртки
|
## Уже установленные shared-обёртки
|
||||||
|
|
||||||
В `apps/web/src/components/`:
|
В `apps/web/src/components/`:
|
||||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
||||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
||||||
- `EmptyState` — пустые списки
|
- `EmptyState` — пустые списки (empty-state-14 DNA)
|
||||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
||||||
- `LoadingButton` — кнопка с loading-состоянием
|
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
|
||||||
- `StatusBadge` — статусные бейджи
|
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
|
||||||
- `SectionCards` — сетка KPI-карточек
|
- `SegmentedTabs` — секции страницы (c-tabs-9)
|
||||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
|
||||||
|
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
|
||||||
|
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
|
||||||
- `TruncatedText` — текст с тултипом
|
- `TruncatedText` — текст с тултипом
|
||||||
- `ModeToggle` — переключатель темы
|
|
||||||
|
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
|
||||||
|
|
||||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -79,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"pid": 43636,
|
|
||||||
"version": "0.9.9",
|
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
|
||||||
"startedAt": 1783570299043
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
|
||||||
|
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"reui": {
|
"reui": {
|
||||||
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
|
"url": "https://mcp.reui.io",
|
||||||
"headers": {
|
"headers": {
|
||||||
"X-Reui-Style": "base-nova"
|
"Authorization": "Bearer <REUI_LICENSE_KEY>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ alwaysApply: true
|
|||||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||||
|------------|-------------|------------------|-------|
|
|------------|-------------|------------------|-------|
|
||||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
|||||||
## Dependency Management
|
## Dependency Management
|
||||||
|
|
||||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||||
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
|
||||||
|
|
||||||
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||||
*Проверка:* review.
|
*Проверка:* review.
|
||||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
|||||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||||
*Проверка:* CI job `bird2`.
|
*Проверка:* CI job `bird2`.
|
||||||
|
|
||||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
|
||||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||||
|
|
||||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
|
||||||
*Проверка:* CI job `openapi`.
|
*Проверка:* CI job `openapi`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
|||||||
```powershell
|
```powershell
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -race -count=1
|
go test ./... -race -count=1
|
||||||
npx @redocly/cli lint docs/openapi.yaml
|
pnpm exec redocly lint docs/openapi.yaml
|
||||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||||
|
|
||||||
|
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||||
|
|
||||||
|
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
|
||||||
|
|
||||||
|
## MUST
|
||||||
|
|
||||||
|
| Зона | Компонент / DNA |
|
||||||
|
|------|-----------------|
|
||||||
|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||||
|
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
|
||||||
|
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
|
||||||
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
|
||||||
|
|
||||||
|
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
|
||||||
|
|
||||||
|
## NEVER
|
||||||
|
|
||||||
|
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||||
|
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
|
||||||
|
- Другой size / radius / solid brand fill вместо `elevated`
|
||||||
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
|
- Голый lucide `size-4` в name-cell без IconTile
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# ReUI MCP — обязательно (PRO + free)
|
||||||
|
|
||||||
|
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
|
||||||
|
|
||||||
|
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||||
|
|
||||||
|
| Документ | URL |
|
||||||
|
|----------|-----|
|
||||||
|
| **Introduction** | https://reui.io/docs |
|
||||||
|
| **llms.txt** | https://reui.io/llms.txt |
|
||||||
|
| **Get Started** | https://reui.io/docs/get-started |
|
||||||
|
| **Styling** | https://reui.io/docs/styling |
|
||||||
|
| **Registry** | https://reui.io/docs/registry |
|
||||||
|
| **MCP** | https://reui.io/docs/mcp |
|
||||||
|
| **Agent Skills** | https://reui.io/docs/agent-skills |
|
||||||
|
| **Cursor MCP** | https://reui.io/docs/cursor |
|
||||||
|
| **Blocks** | https://reui.io/blocks |
|
||||||
|
| **Settings blocks** | https://reui.io/blocks/application/settings |
|
||||||
|
| **License** | https://reui.io/docs/license-setup |
|
||||||
|
| **Base UI components** | https://reui.io/docs/components/base/<name> |
|
||||||
|
|
||||||
|
## Primary MCP
|
||||||
|
|
||||||
|
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
|
||||||
|
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
|
||||||
|
|
||||||
|
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||||
|
|
||||||
|
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||||
|
|
||||||
|
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
|
||||||
|
|
||||||
|
## Когда ReUI vs shadcn
|
||||||
|
|
||||||
|
| Задача | Registry | Импорт |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
|
||||||
|
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
|
||||||
|
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
||||||
|
| Filters | `@reui` | `@/components/reui/filters` |
|
||||||
|
| Frame surface | `@reui` | `@/components/reui/frame` |
|
||||||
|
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
|
||||||
|
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
||||||
|
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
||||||
|
| Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
|
||||||
|
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
|
||||||
|
|
||||||
|
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
|
||||||
|
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
|
||||||
|
|
||||||
|
## MCP workflow
|
||||||
|
|
||||||
|
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
|
||||||
|
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
|
||||||
|
2. Страница целиком → `compose_page`; секция → `get_block`
|
||||||
|
3. `get_component` для API primitives из `componentsUsed`
|
||||||
|
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
|
||||||
|
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
|
||||||
|
6. Adapt by reuse → kit / route
|
||||||
|
7. `validate_usage` + `get_audit_checklist`
|
||||||
|
|
||||||
|
## Размещение
|
||||||
|
|
||||||
|
| Слой | Путь | Импорт |
|
||||||
|
|------|------|--------|
|
||||||
|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||||
|
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||||
|
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
|
||||||
|
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||||
|
|
||||||
|
## Установленные ReUI (apps/web)
|
||||||
|
|
||||||
|
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
|
||||||
|
|
||||||
|
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`, `FrameSection`
|
||||||
|
|
||||||
|
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env.local (gitignored)
|
||||||
|
REUI_LICENSE_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
|
||||||
|
|
||||||
|
## Эталоны preview
|
||||||
|
|
||||||
|
| Зона | Preview |
|
||||||
|
|------|---------|
|
||||||
|
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
|
||||||
|
| List | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
|
| Settings | https://reui.io/preview/base/settings-16 |
|
||||||
|
| Auth | https://reui.io/preview/base/auth-13 |
|
||||||
|
| Shell | https://reui.io/preview/base/app-shell-12 |
|
||||||
|
| Empty | https://reui.io/preview/base/empty-state-12 |
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
- Копипаст с reui.io без CLI
|
||||||
|
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
|
||||||
|
- Radix-варианты docs — только Base UI
|
||||||
|
- Raw `bg-emerald-*` вместо ReUI `variant`
|
||||||
|
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||||
|
- Смешивать Card и Frame на ops-экране
|
||||||
|
- Ставить shadcn/ui blocks выше ReUI PRO
|
||||||
|
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] `user-reui` search/get_block + previewUrl
|
||||||
|
- [ ] `surface: frame`
|
||||||
|
- [ ] CLI add из `apps/web` при новом item
|
||||||
|
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
|
||||||
|
- [ ] `pnpm --filter @evobgp/web run build`
|
||||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
|||||||
|
|
||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ alwaysApply: false
|
|||||||
# Web UI — React + shadcn/ui + ReUI
|
# Web UI — React + shadcn/ui + ReUI
|
||||||
|
|
||||||
**Источники правды:**
|
**Источники правды:**
|
||||||
|
- **ReUI PRO first:** MCP `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) — pages / KPI / lists / settings / shell
|
||||||
|
- shadcn primitives: MCP `plugin-shadcn-shadcn` (secondary)
|
||||||
|
- ReUI Base UI: https://reui.io/docs/components/base/<name> · [llms.txt](https://reui.io/llms.txt)
|
||||||
- shadcn/ui React: https://ui.shadcn.com/docs/components
|
- shadcn/ui React: https://ui.shadcn.com/docs/components
|
||||||
- ReUI Base UI: https://reui.io/docs/components/base/<name>
|
|
||||||
- ReUI llms.txt: https://reui.io/llms.txt
|
|
||||||
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
|
|
||||||
|
|
||||||
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
|
Иерархия: **ReUI PRO > shadcn**. Общие: `.cursor/rules/engineering.mdc`, `context7-stack.mdc`.
|
||||||
|
|
||||||
## Слои UI
|
## Слои UI
|
||||||
|
|
||||||
@@ -22,16 +22,19 @@ alwaysApply: false
|
|||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||||
|
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
|
||||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||||
|
|
||||||
|
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
|
||||||
|
|
||||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
|
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
|
||||||
*Rationale:* единый источник правды и API.
|
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
|
||||||
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
*Проверка:* review; [`reui-mcp.mdc`](reui-mcp.mdc).
|
||||||
|
|
||||||
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
|
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
|
||||||
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
|
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
|
||||||
@@ -45,8 +48,15 @@ alwaysApply: false
|
|||||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||||
|
|
||||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||||
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||||
|
|
||||||
|
**WEB-06a** | MUST | Переключатели по роли UX:
|
||||||
|
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
|
||||||
|
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
|
||||||
|
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
|
||||||
|
Не `variant="line"` для page/in-grid tabs.
|
||||||
|
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
|
||||||
|
|
||||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||||
@@ -70,7 +80,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
|
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
|
||||||
|
|
||||||
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
|
**WEB-15** | MUST | Сомнения — MCP `user-reui` + `plugin-shadcn-shadcn` + docs + `pnpm --filter @evobgp/web run typecheck`.
|
||||||
|
|
||||||
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
||||||
|
|
||||||
@@ -95,7 +105,11 @@ pnpm --filter @evobgp/web run build
|
|||||||
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
||||||
*Проверка:* review `queries/*.ts`.
|
*Проверка:* review `queries/*.ts`.
|
||||||
|
|
||||||
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
||||||
|
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
||||||
|
|
||||||
|
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
|
||||||
|
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -79,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.cursor
|
||||||
|
.claude
|
||||||
|
.codegraph
|
||||||
|
.agents
|
||||||
|
memory-bank
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
!**/.env.*.example
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
apps/web/src/routeTree.gen.ts
|
||||||
|
apps/web/playwright-report
|
||||||
|
apps/web/test-results
|
||||||
|
|
||||||
|
*.md
|
||||||
|
AGENTS.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
LICENSE
|
||||||
|
docs
|
||||||
|
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.golangci.yml
|
||||||
|
.releaserc.json
|
||||||
|
.commitlintrc.*
|
||||||
|
redocly.yaml
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
data
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
coverage
|
||||||
|
.coverage
|
||||||
|
*.out
|
||||||
|
.release-version
|
||||||
|
CHANGELOG.md
|
||||||
|
deploy/docker/docker-bake.override.hcl
|
||||||
|
deploy/compose/runtime-logs
|
||||||
+50
-21
@@ -1,55 +1,84 @@
|
|||||||
# Gitea Actions
|
# Gitea Actions
|
||||||
|
|
||||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
| Workflow | Когда | Что |
|
||||||
|
|----------|--------|-----|
|
||||||
| Job | PR | push в main |
|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||||
|-----|-----|-------------|
|
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||||
| commitlint | да | — |
|
|
||||||
| **release** | — | semantic-release + docker push (один run) |
|
|
||||||
|
|
||||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
|
|
||||||
## CI (quality gates)
|
## CI (quality gates)
|
||||||
|
|
||||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||||
|
|
||||||
На **pull request** — **commitlint** (Conventional Commits).
|
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||||
|
|
||||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||||
|
|
||||||
## Release (job в ci.yaml)
|
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||||
|
|
||||||
После успешных quality gates на **push в main** job **release**:
|
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||||
|
|
||||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||||
|
|
||||||
|
## CD (job publish)
|
||||||
|
|
||||||
|
После успешных quality gates на **push в main** job **publish**:
|
||||||
|
|
||||||
|
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||||
|
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||||
|
|
||||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||||
|
|
||||||
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
### Секреты
|
### Секреты
|
||||||
|
|
||||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||||
|
|
||||||
|
**`docker_hub_token`**: PAT Docker Hub (Settings → Actions → Secrets, можно на уровне организации). Логин перед зеркалом `docker.io` и `bake`, чтобы не ловить anonymous 429. Логин: `docker_hub_username` (если задан) или `gitea.actor`.
|
||||||
|
|
||||||
### Теги образов
|
### Теги образов
|
||||||
|
|
||||||
```text
|
```text
|
||||||
git.shts.su/<owner>/<имя>:latest
|
git.shx.one/<owner>/<имя>:latest
|
||||||
git.shts.su/<owner>/<имя>:v1.2.3
|
git.shx.one/<owner>/<имя>:v1.2.3
|
||||||
git.shts.su/<owner>/<имя>:1.2.3
|
git.shx.one/<owner>/<имя>:1.2.3
|
||||||
git.shts.su/<owner>/<имя>:<short-sha>
|
git.shx.one/<owner>/<имя>:<short-sha>
|
||||||
git.shts.su/<owner>/<имя>:sha-<full-sha>
|
git.shx.one/<owner>/<имя>:sha-<full-sha>
|
||||||
```
|
```
|
||||||
|
|
||||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||||
|
|
||||||
|
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||||
|
|
||||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.shts.su/myuser/evobgp-api:1.2.3
|
docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||||
|
|
||||||
|
## act_runner: cache server
|
||||||
|
|
||||||
|
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||||
|
|
||||||
|
В `config.yaml` runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||||
|
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||||
|
port: 8088
|
||||||
|
```
|
||||||
|
|
||||||
|
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||||
|
|
||||||
|
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- 1–3 bullets: what and why -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- [ ] Docs updated if behavior/API changed (DOC-02)
|
||||||
|
- [ ] Conventional Commits (EN title / RU body)
|
||||||
|
|
||||||
|
### Backend (if Go / OpenAPI / migrations)
|
||||||
|
|
||||||
|
- [ ] `go test ./... -race -count=1` (or scoped packages) when touching jobs/pipeline/httpapi
|
||||||
|
- [ ] OpenAPI lint + regenerate `apps/web/src/types/api.gen.ts` if `docs/openapi.yaml` changed
|
||||||
|
- [ ] Migration pairs postgres+sqlite (DEP-03)
|
||||||
|
|
||||||
|
### Frontend (if `apps/web` / `packages/ui`) — WEB-19
|
||||||
|
|
||||||
|
- [ ] `pnpm --filter @evobgp/web run typecheck`
|
||||||
|
- [ ] `pnpm --filter @evobgp/web run lint`
|
||||||
|
- [ ] `pnpm --filter @evobgp/web run build`
|
||||||
|
- [ ] `pnpm --filter @evobgp/web run test`
|
||||||
|
- [ ] UI follows ReUI PRO + `docs/ui-design-contract.md` (surface `frame`, kit; cite previewUrl)
|
||||||
|
|
||||||
|
### KPI / Quick Actions
|
||||||
|
|
||||||
|
- [ ] If changing `kpi-stat-grid.tsx` / `quick-action-grid.tsx`, note sibling-app sync (vps / CFDM / fw / auth-portal)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
|
with:
|
||||||
|
is_pull_request: false
|
||||||
|
before_sha: ${{ github.event.before }}
|
||||||
|
head_sha: ${{ github.sha }}
|
||||||
|
allow_registry_login: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||||
|
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [quality]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
|
needs.quality.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Install release tooling
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: sh scripts/ci/pnpm-ci.sh
|
||||||
|
- name: Verify releasable commit messages
|
||||||
|
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||||
|
- name: Semantic release
|
||||||
|
run: pnpm exec semantic-release
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.shx.one
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Detect new release
|
||||||
|
id: rel
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version=""
|
||||||
|
if [ -f .release-version ]; then
|
||||||
|
version="$(tr -d '[:space:]' < .release-version)"
|
||||||
|
echo "New release from semantic-release: $version"
|
||||||
|
else
|
||||||
|
git fetch --tags --force origin || true
|
||||||
|
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||||
|
if [ -n "${tag:-}" ]; then
|
||||||
|
version="${tag#v}"
|
||||||
|
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${version:-}" ]; then
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No releasable commits — skipping image publish"
|
||||||
|
fi
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evobgp
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Prepare image metadata
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||||
|
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||||
|
password: ${{ secrets.docker_hub_token }}
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: Mirror base images into buildcache
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
run: sh deploy/docker/mirror-base-images.sh
|
||||||
|
- name: Build and push images (bake)
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
IMAGE_TAG: latest
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
|
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||||
|
SHA_FULL: ${{ github.sha }}
|
||||||
|
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||||
|
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||||
|
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||||
|
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
working-directory: deploy/docker
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "${MIRROR_ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||||
|
-f docker-bake.hcl default --push
|
||||||
+18
-356
@@ -1,363 +1,25 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ---------------------------------------------------------------------------
|
quality:
|
||||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
with:
|
||||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
is_pull_request: true
|
||||||
# ---------------------------------------------------------------------------
|
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||||
changes:
|
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||||
runs-on: ubuntu-latest
|
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
outputs:
|
secrets:
|
||||||
openapi: ${{ steps.detect.outputs.openapi }}
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
go: ${{ steps.detect.outputs.go }}
|
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||||
web: ${{ steps.detect.outputs.web }}
|
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
|
||||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
|
||||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
|
||||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: detect
|
|
||||||
name: Detect changed paths per module
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
openapi=false
|
|
||||||
go=false
|
|
||||||
web=false
|
|
||||||
bird_conf=false
|
|
||||||
docker_go=false
|
|
||||||
docker_web=false
|
|
||||||
docker_bird=false
|
|
||||||
|
|
||||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
|
||||||
set_all_flags_true() {
|
|
||||||
openapi=true
|
|
||||||
go=true
|
|
||||||
web=true
|
|
||||||
bird_conf=true
|
|
||||||
docker_go=true
|
|
||||||
docker_web=true
|
|
||||||
docker_bird=true
|
|
||||||
}
|
|
||||||
|
|
||||||
write_outputs() {
|
|
||||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
|
||||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
base="${{ github.event.pull_request.base.sha }}"
|
|
||||||
head="${{ github.event.pull_request.head.sha }}"
|
|
||||||
FILES="$(git diff --name-only "$base" "$head")"
|
|
||||||
else
|
|
||||||
before="${{ github.event.before }}"
|
|
||||||
after="${{ github.sha }}"
|
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
|
||||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
|
||||||
else
|
|
||||||
set_all_flags_true
|
|
||||||
write_outputs
|
|
||||||
echo "No parent commit — full pipeline (all modules)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
|
||||||
set_all_flags_true
|
|
||||||
write_outputs
|
|
||||||
echo "Empty diff — full pipeline fallback"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
full_pipeline=false
|
|
||||||
|
|
||||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
|
||||||
[ -z "${f:-}" ] && continue
|
|
||||||
case "$f" in
|
|
||||||
# CI/CD инфраструктура — все узлы quality gates
|
|
||||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
|
||||||
full_pipeline=true
|
|
||||||
;;
|
|
||||||
docs/openapi.yaml|redocly.yaml)
|
|
||||||
openapi=true
|
|
||||||
;;
|
|
||||||
docs/api.md|docs/access.md)
|
|
||||||
openapi=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
|
||||||
;;
|
|
||||||
apps/web/*|packages/ui/*|packages/shared/*)
|
|
||||||
web=true
|
|
||||||
;;
|
|
||||||
deploy/bird/*)
|
|
||||||
bird_conf=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
deploy/compose/*|deploy/docker/*)
|
|
||||||
docker_go=true
|
|
||||||
docker_web=true
|
|
||||||
docker_bird=true
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
go.mod|go.sum|go.work)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
migrations/*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
cmd/*|internal/*|*.go)
|
|
||||||
go=true
|
|
||||||
bird_conf=true
|
|
||||||
;;
|
|
||||||
docs/*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
|
||||||
full_pipeline=true
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
go=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done <<< "$FILES"
|
|
||||||
|
|
||||||
if $full_pipeline; then
|
|
||||||
set_all_flags_true
|
|
||||||
fi
|
|
||||||
|
|
||||||
write_outputs
|
|
||||||
|
|
||||||
echo "Changed files (first 30):"
|
|
||||||
printf '%s\n' "$FILES" | head -n 30
|
|
||||||
echo "--- flags ---"
|
|
||||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
|
||||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
openapi:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.openapi == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
- name: Lint OpenAPI (Redocly)
|
|
||||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
web:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.web == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
- name: Enable pnpm via corepack
|
|
||||||
run: corepack enable
|
|
||||||
- name: pnpm install, typecheck, lint, build
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm --filter @evobgp/web run typecheck
|
|
||||||
pnpm --filter @evobgp/web run lint
|
|
||||||
pnpm --filter @evobgp/web run build
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
go:
|
|
||||||
needs: [changes]
|
|
||||||
if: needs.changes.outputs.go == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.24"
|
|
||||||
cache: true
|
|
||||||
cache-dependency-path: go.sum
|
|
||||||
- name: Vet
|
|
||||||
run: go vet ./...
|
|
||||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
|
||||||
run: sh scripts/lint-httpapi.sh
|
|
||||||
- name: Check migration pairs (DEP-03)
|
|
||||||
run: sh scripts/check-migrations-pair.sh
|
|
||||||
- name: Validate remote speaker compose
|
|
||||||
run: sh scripts/validate-remote-speaker-compose.sh
|
|
||||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
|
||||||
- name: golangci-lint
|
|
||||||
uses: golangci/golangci-lint-action@v6
|
|
||||||
with:
|
|
||||||
version: v1.64.8
|
|
||||||
install-mode: goinstall
|
|
||||||
- name: Test
|
|
||||||
run: go test ./... -race -count=1
|
|
||||||
- name: Build all commands
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
out="${RUNNER_TEMP}/evobgp-bin"
|
|
||||||
mkdir -p "$out"
|
|
||||||
for d in cmd/*/; do
|
|
||||||
name="$(basename "$d")"
|
|
||||||
go build -o "$out/$name" "./$d"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
bird2:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [changes, go]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
needs.go.result != 'failure' &&
|
|
||||||
(needs.changes.outputs.go == 'true' ||
|
|
||||||
needs.changes.outputs.bird_conf == 'true' ||
|
|
||||||
needs.changes.outputs.docker_bird == 'true' ||
|
|
||||||
needs.changes.outputs.docker_go == 'true')
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
|
||||||
$SUDO apt-get update -qq
|
|
||||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
|
||||||
bird --version
|
|
||||||
- name: bird -p on all scenario bird.conf files
|
|
||||||
env:
|
|
||||||
WORKSPACE: ${{ github.workspace }}
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
WS="${WORKSPACE:-$PWD}"
|
|
||||||
cd "$WS"
|
|
||||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
|
||||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
|
||||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
|
||||||
echo "==> $conf"
|
|
||||||
bird -c "$WS/$conf" -p
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
commitlint:
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
- name: Lint commit messages
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
npm ci
|
|
||||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
release:
|
|
||||||
needs: [changes, openapi, web, go, bird2]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
|
||||||
needs.changes.result == 'success' &&
|
|
||||||
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
|
||||||
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
|
||||||
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
|
||||||
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
persist-credentials: true
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
- name: Install release tooling
|
|
||||||
run: npm ci
|
|
||||||
- name: Verify releasable commit messages
|
|
||||||
run: node scripts/commit/verify-release-commits.mjs
|
|
||||||
- name: Semantic release
|
|
||||||
run: npx semantic-release
|
|
||||||
env:
|
|
||||||
GITEA_URL: https://git.shts.su
|
|
||||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
- name: Detect new release
|
|
||||||
id: rel
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -f .release-version ]; then
|
|
||||||
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "No releasable commits — skipping image publish"
|
|
||||||
fi
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Prepare image metadata
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
|
||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
|
||||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
|
||||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Log in to Gitea Registry
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shts.su
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
|
||||||
- name: Build and push images (bake)
|
|
||||||
if: steps.rel.outputs.released == 'true'
|
|
||||||
env:
|
|
||||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
|
||||||
IMAGE_TAG: latest
|
|
||||||
VERSION: ${{ steps.meta.outputs.version }}
|
|
||||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
|
||||||
SHA_FULL: ${{ github.sha }}
|
|
||||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
|
||||||
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
|
||||||
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
|
||||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
cd "${{ github.workspace }}/deploy/docker"
|
|
||||||
sh write-bake-override.sh
|
|
||||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
|
||||||
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||||
|
name: quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_pull_request:
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
base_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
head_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
before_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
allow_registry_login:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT:
|
||||||
|
required: false
|
||||||
|
docker_hub_token:
|
||||||
|
required: false
|
||||||
|
docker_hub_username:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
openapi: ${{ steps.detect.outputs.openapi }}
|
||||||
|
go: ${{ steps.detect.outputs.go }}
|
||||||
|
web: ${{ steps.detect.outputs.web }}
|
||||||
|
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||||
|
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||||
|
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||||
|
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||||
|
steps:
|
||||||
|
- if: ${{ inputs.is_pull_request }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- id: detect
|
||||||
|
name: Detect changed paths per module
|
||||||
|
env:
|
||||||
|
IS_PR: ${{ inputs.is_pull_request }}
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
openapi=false
|
||||||
|
go=false
|
||||||
|
web=false
|
||||||
|
bird_conf=false
|
||||||
|
docker_go=false
|
||||||
|
docker_web=false
|
||||||
|
docker_bird=false
|
||||||
|
|
||||||
|
set_all_flags_true() {
|
||||||
|
openapi=true
|
||||||
|
go=true
|
||||||
|
web=true
|
||||||
|
bird_conf=true
|
||||||
|
docker_go=true
|
||||||
|
docker_web=true
|
||||||
|
docker_bird=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_outputs() {
|
||||||
|
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||||
|
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$IS_PR" = "true" ]; then
|
||||||
|
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||||
|
else
|
||||||
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
before="$BEFORE_SHA"
|
||||||
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
|
else
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "No parent commit — full pipeline (all modules)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "Empty diff — full pipeline fallback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
full_pipeline=false
|
||||||
|
|
||||||
|
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||||
|
[ -z "${f:-}" ] && continue
|
||||||
|
case "$f" in
|
||||||
|
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
docs/openapi.yaml|redocly.yaml)
|
||||||
|
openapi=true
|
||||||
|
;;
|
||||||
|
docs/api.md|docs/access.md)
|
||||||
|
openapi=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
|
||||||
|
;;
|
||||||
|
*.md|AGENTS.md)
|
||||||
|
;;
|
||||||
|
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||||
|
;;
|
||||||
|
apps/web/*|packages/ui/*|packages/shared/*)
|
||||||
|
web=true
|
||||||
|
;;
|
||||||
|
deploy/bird/*)
|
||||||
|
bird_conf=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||||
|
docker_go=true
|
||||||
|
docker_web=true
|
||||||
|
docker_bird=true
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
go.mod|go.sum|go.work)
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
migrations/*)
|
||||||
|
go=true
|
||||||
|
;;
|
||||||
|
cmd/*|internal/*|*.go)
|
||||||
|
go=true
|
||||||
|
bird_conf=true
|
||||||
|
;;
|
||||||
|
docs/*)
|
||||||
|
;;
|
||||||
|
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$FILES"
|
||||||
|
|
||||||
|
if $full_pipeline; then
|
||||||
|
set_all_flags_true
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_outputs
|
||||||
|
|
||||||
|
echo "Changed files (first 30):"
|
||||||
|
printf '%s\n' "$FILES" | head -n 30
|
||||||
|
echo "--- flags ---"
|
||||||
|
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||||
|
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||||
|
|
||||||
|
openapi:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, Redocly, codegen check
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec redocly lint docs/openapi.yaml
|
||||||
|
chmod +x scripts/check-openapi-gen.sh
|
||||||
|
sh scripts/check-openapi-gen.sh
|
||||||
|
|
||||||
|
web:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, typecheck, lint, test, build
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm --filter @evobgp/web run typecheck
|
||||||
|
pnpm --filter @evobgp/web run lint
|
||||||
|
pnpm --filter @evobgp/web run test
|
||||||
|
pnpm --filter @evobgp/web run build
|
||||||
|
|
||||||
|
go:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.go == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
|
with:
|
||||||
|
go-version: "1.24"
|
||||||
|
cache: false
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: go-hash
|
||||||
|
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.GOMODCACHE }}
|
||||||
|
${{ env.GOCACHE }}
|
||||||
|
${{ env.GOBIN }}
|
||||||
|
${{ env.GOLANGCI_LINT_CACHE }}
|
||||||
|
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
go-${{ runner.os }}-1.24-gl1.64.8-
|
||||||
|
go-${{ runner.os }}-1.24-
|
||||||
|
- name: Download modules
|
||||||
|
env:
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: go mod download
|
||||||
|
- name: Vet
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
run: go vet ./...
|
||||||
|
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||||
|
run: sh scripts/lint-httpapi.sh
|
||||||
|
- name: Check migration pairs (DEP-03)
|
||||||
|
run: sh scripts/check-migrations-pair.sh
|
||||||
|
- name: Validate remote speaker compose
|
||||||
|
run: sh scripts/validate-remote-speaker-compose.sh
|
||||||
|
- name: golangci-lint
|
||||||
|
env:
|
||||||
|
GOLANGCI_LINT_VERSION: v1.64.8
|
||||||
|
run: sh scripts/ci/golangci-lint.sh
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: go test ./... -race -count=1
|
||||||
|
- name: Build all commands
|
||||||
|
env:
|
||||||
|
GOFLAGS: -mod=readonly
|
||||||
|
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||||
|
GOCACHE: ${{ env.GOCACHE }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
out="${RUNNER_TEMP}/evobgp-bin"
|
||||||
|
mkdir -p "$out"
|
||||||
|
for d in cmd/*/; do
|
||||||
|
name="$(basename "$d")"
|
||||||
|
go build -o "$out/$name" "./$d"
|
||||||
|
done
|
||||||
|
|
||||||
|
bird2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [changes, go]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.changes.result == 'success' &&
|
||||||
|
needs.go.result != 'failure' &&
|
||||||
|
(needs.changes.outputs.go == 'true' ||
|
||||||
|
needs.changes.outputs.bird_conf == 'true' ||
|
||||||
|
needs.changes.outputs.docker_bird == 'true' ||
|
||||||
|
needs.changes.outputs.docker_go == 'true')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||||
|
bird --version
|
||||||
|
- name: bird -p on all scenario bird.conf files
|
||||||
|
env:
|
||||||
|
WORKSPACE: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
WS="${WORKSPACE:-$PWD}"
|
||||||
|
cd "$WS"
|
||||||
|
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||||
|
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||||
|
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||||
|
echo "==> $conf"
|
||||||
|
bird -c "$WS/$conf" -p
|
||||||
|
done
|
||||||
|
|
||||||
|
commitlint:
|
||||||
|
if: inputs.is_pull_request
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Lint commit messages
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
|
||||||
|
docker-check:
|
||||||
|
needs: [changes]
|
||||||
|
if: >-
|
||||||
|
inputs.is_pull_request &&
|
||||||
|
(needs.changes.outputs.docker_go == 'true' ||
|
||||||
|
needs.changes.outputs.docker_web == 'true' ||
|
||||||
|
needs.changes.outputs.docker_bird == 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evobgp
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
if: secrets.docker_hub_token != ''
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||||
|
password: ${{ secrets.docker_hub_token }}
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: bake --print
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||||
|
- name: bake (no push)
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||||
|
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||||
|
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
|
||||||
|
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
|
||||||
|
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||||
@@ -21,9 +21,14 @@ Thumbs.db
|
|||||||
|
|
||||||
# Env / secrets (local only)
|
# Env / secrets (local only)
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.*.example
|
!.env.*.example
|
||||||
|
|
||||||
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
||||||
deploy/compose/runtime-logs/
|
deploy/compose/runtime-logs/
|
||||||
|
# Local MCP configs (may contain REUI license Bearer)
|
||||||
|
.cursor/mcp.json
|
||||||
|
.mcp.json
|
||||||
|
.codegraph/daemon.pid
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"codegraph": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "codegraph",
|
|
||||||
"args": [
|
|
||||||
"serve",
|
|
||||||
"--mcp"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reui": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://mcp.reui.io/api/mcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -79,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
@@ -284,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@
|
|||||||
[
|
[
|
||||||
"@markwylde/semantic-release-gitea",
|
"@markwylde/semantic-release-gitea",
|
||||||
{
|
{
|
||||||
"giteaUrl": "https://git.shts.su",
|
"giteaUrl": "https://git.shx.one",
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"path": "CHANGELOG.md",
|
"path": "CHANGELOG.md",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
||||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
||||||
| Точки входа процессов | `cmd/*/` |
|
| Точки входа процессов | `cmd/*/` |
|
||||||
| Веб (SvelteKit) | `web/` |
|
| Веб (React + ReUI) | `apps/web/` |
|
||||||
| Compose, деплой | `deploy/compose/` |
|
| Compose, деплой | `deploy/compose/` |
|
||||||
|
|
||||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
||||||
@@ -69,6 +69,10 @@ pnpm --filter @evobgp/web run build
|
|||||||
|
|
||||||
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
||||||
|
|
||||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte UI удалён (миграция завершена).
|
||||||
|
|
||||||
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
|
||||||
|
|
||||||
|
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills). Skill: `.claude/skills/reui` (`668fb463eb`).
|
||||||
|
|
||||||
|
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd deploy\compose
|
cd deploy\compose
|
||||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
|
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
|
||||||
docker login git.shts.su
|
docker login git.shx.one
|
||||||
docker compose --profile microvps pull
|
docker compose --profile microvps pull
|
||||||
docker compose --profile microvps up -d
|
docker compose --profile microvps up -d
|
||||||
```
|
```
|
||||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
|||||||
|
|
||||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||||
|
|
||||||
Лицензия и условия использования — по политике владельца репозитория.
|
Лицензия и условия использования — по политике владельца репозитория.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# EvoBGP web (Vite) — переменные окружения.
|
||||||
|
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REUI_LICENSE_KEY=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
|
||||||
|
# недоступен либо VITE_AUTH_ENABLED=false).
|
||||||
|
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth-portal SSO
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
|
||||||
|
# Пример:
|
||||||
|
# VITE_AUTH_ENABLED=true
|
||||||
|
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
#
|
||||||
|
# Backend опционально может отдавать GET /v1/auth/config
|
||||||
|
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
|
||||||
|
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
|
||||||
|
# VITE_AUTH_ENABLED=false
|
||||||
|
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke against local API (+ optional UI). Profile: compose microvps-full.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080)
|
||||||
|
* - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173)
|
||||||
|
* - EVOBGP_E2E_TOKEN (default dev)
|
||||||
|
*/
|
||||||
|
const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080'
|
||||||
|
const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173'
|
||||||
|
const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev'
|
||||||
|
|
||||||
|
test.describe('EvoBGP smoke', () => {
|
||||||
|
test('API health and modules list', async ({ request }) => {
|
||||||
|
const health = await request.get(`${apiBase}/v1/health`)
|
||||||
|
expect(health.ok()).toBeTruthy()
|
||||||
|
|
||||||
|
const mods = await request.get(`${apiBase}/v1/modules?limit=10`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect([200, 401]).toContain(mods.status())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('UI loads when available', async ({ page }) => {
|
||||||
|
test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke')
|
||||||
|
await page.goto(uiBase)
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('body')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
|
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
@@ -28,6 +28,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||||
|
linterOptions: {
|
||||||
|
reportUnusedDisableDirectives: 'off',
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
|||||||
+13
-6
@@ -9,10 +9,14 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsr generate && tsc --noEmit",
|
"typecheck": "tsr generate && tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts",
|
||||||
|
"openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.0.0",
|
"@base-ui/react": "^1.0.0",
|
||||||
|
"@date-fns/tz": "^1.5.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -23,8 +27,8 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@tanstack/react-router": "^1.130.2",
|
"@tanstack/react-router": "^1.130.2",
|
||||||
"@tanstack/react-router-devtools": "^1.130.2",
|
"@tanstack/react-router-devtools": "^1.130.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^9.1.2",
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"@tanstack/react-virtual": "^3.14.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
@@ -36,26 +40,29 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
|
"shadcn": "^4.19.0",
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
"@tanstack/router-plugin": "^1.130.0",
|
|
||||||
"@tanstack/router-cli": "^1.130.0",
|
"@tanstack/router-cli": "^1.130.0",
|
||||||
|
"@tanstack/router-plugin": "^1.130.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"@eslint/js": "^9.0.0",
|
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.0",
|
"eslint-plugin-react-refresh": "^0.4.0",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.0.0",
|
||||||
"typescript-eslint": "^8.0.0",
|
|
||||||
"happy-dom": "^18.0.0",
|
"happy-dom": "^18.0.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"tailwindcss": "^4.1.0",
|
"tailwindcss": "^4.1.0",
|
||||||
"tw-animate-css": "^1.0.0",
|
"tw-animate-css": "^1.0.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 60_000,
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
})
|
||||||
@@ -2,9 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Plus, RefreshCw } from 'lucide-react'
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
|
||||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
@@ -47,45 +45,38 @@ export function AccessApiKeysCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataGridCard
|
<QueryState
|
||||||
title="API-ключи"
|
data={items}
|
||||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
isLoading={isLoading}
|
||||||
actions={
|
isError={isError}
|
||||||
<>
|
error={error}
|
||||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
empty={false}
|
||||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||||
Обновить
|
onRetry={onRetry}
|
||||||
</Button>
|
|
||||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<QueryState
|
{(data) => (
|
||||||
data={items}
|
<AccessApiKeysGrid
|
||||||
isLoading={isLoading}
|
items={data}
|
||||||
isError={isError}
|
isLoading={isLoading}
|
||||||
error={error}
|
onRotate={handleRotated}
|
||||||
empty={items.length === 0}
|
onRevoke={(id) => revoke.mutate(id)}
|
||||||
emptyTitle="Нет ключей"
|
rotatePending={rotate.isPending}
|
||||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
revokePending={revoke.isPending}
|
||||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
actions={
|
||||||
onRetry={onRetry}
|
<>
|
||||||
>
|
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||||
{(data) => (
|
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||||
<AccessApiKeysGrid
|
Обновить
|
||||||
items={data}
|
</Button>
|
||||||
isLoading={isLoading}
|
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||||
onRotate={handleRotated}
|
<Plus />
|
||||||
onRevoke={(id) => revoke.mutate(id)}
|
Создать
|
||||||
rotatePending={rotate.isPending}
|
</Button>
|
||||||
revokePending={revoke.isPending}
|
</>
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
</QueryState>
|
)}
|
||||||
</DataGridCard>
|
</QueryState>
|
||||||
|
|
||||||
<ApiKeyCreateDialog
|
<ApiKeyCreateDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
|
import {
|
||||||
|
ResourcePage,
|
||||||
|
createSearchFilterField,
|
||||||
|
createTextFilterQuery,
|
||||||
|
type DataGridColumnDef,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type { ApiKey } from '@/types/api'
|
import type { ApiKey } from '@/types/api'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
|
||||||
|
]
|
||||||
|
|
||||||
export function AccessApiKeysGrid({
|
export function AccessApiKeysGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -21,6 +28,7 @@ export function AccessApiKeysGrid({
|
|||||||
onRevoke,
|
onRevoke,
|
||||||
rotatePending = false,
|
rotatePending = false,
|
||||||
revokePending = false,
|
revokePending = false,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: ApiKey[]
|
items: ApiKey[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
@@ -28,13 +36,25 @@ export function AccessApiKeysGrid({
|
|||||||
onRevoke: (id: string) => void
|
onRevoke: (id: string) => void
|
||||||
rotatePending?: boolean
|
rotatePending?: boolean
|
||||||
revokePending?: boolean
|
revokePending?: boolean
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
cell: ({ row }) => (
|
||||||
|
<DataGridNameCell
|
||||||
|
icon={KeyRound}
|
||||||
|
title={row.original.name}
|
||||||
|
subtitle={`${row.original.prefix}…`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
minSize: 160,
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,14 +65,6 @@ export function AccessApiKeysGrid({
|
|||||||
),
|
),
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'prefix',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Префикс' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -77,20 +89,21 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
id: 'last_used_at',
|
id: 'last_used_at',
|
||||||
accessorFn: (row) => row.last_used_at ?? '',
|
accessorFn: (row) => row.last_used_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Последнее использование' },
|
meta: { headerTitle: 'Использован' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
size: 88,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const k = row.original
|
const k = row.original
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -135,23 +148,24 @@ export function AccessApiKeysGrid({
|
|||||||
[onRevoke, onRotate, revokePending, rotatePending],
|
[onRevoke, onRotate, revokePending, rotatePending],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="API-ключи"
|
||||||
recordCount={filteredCount}
|
description="Полный токен показывается только при создании и ротации."
|
||||||
|
filterFields={filterFields}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterQueryChange={setFilterQuery}
|
||||||
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
|
getFilterFieldValue={(row) =>
|
||||||
|
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`
|
||||||
|
}
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ключей"
|
primaryAction={actions}
|
||||||
searchValue={globalFilter}
|
pinLastColumn
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет ключей' }}
|
||||||
searchPlaceholder="Поиск API-ключей…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@evobgp/ui/components/tooltip'
|
} from '@evobgp/ui/components/tooltip'
|
||||||
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { FrameSection } from '@/components/reui-kit'
|
||||||
|
|
||||||
|
/** Chart Frame shell — chart-1 spacing, not Card.
|
||||||
|
* @see https://reui.io/preview/base/chart-1
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
export function AnalyticsCardShell({
|
export function AnalyticsCardShell({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -47,7 +51,7 @@ export function AnalyticsCardShell({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<FrameSection
|
||||||
title={titleNode}
|
title={titleNode}
|
||||||
description={description}
|
description={description}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
@@ -57,6 +61,6 @@ export function AnalyticsCardShell({
|
|||||||
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</PanelCard>
|
</FrameSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
export type AnalyticsKpiItem = {
|
export type AnalyticsKpiItem = {
|
||||||
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TONE_CLASS = {
|
function deltaBadgeVariant(
|
||||||
success: 'text-success',
|
tone?: 'success' | 'warning' | 'destructive' | 'muted',
|
||||||
warning: 'text-warning',
|
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
|
||||||
destructive: 'text-destructive',
|
if (tone === 'success') return 'success-light'
|
||||||
muted: 'text-muted-foreground',
|
if (tone === 'warning') return 'warning-light'
|
||||||
} as const
|
if (tone === 'destructive') return 'destructive-light'
|
||||||
|
return 'outline'
|
||||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
|
||||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
|
||||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
|
||||||
return <Minus className="size-3" />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
|
||||||
|
return {
|
||||||
|
id: item.label,
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
footer: item.delta ? (
|
||||||
|
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
|
||||||
|
{item.delta.label}
|
||||||
|
</Badge>
|
||||||
|
) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
|
||||||
|
export function AnalyticsKpiRow({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
items: AnalyticsKpiItem[]
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
<KpiStatGrid
|
||||||
{items.map((item) => (
|
items={items.map(toKpiItem)}
|
||||||
<div key={item.label} className="min-w-0 space-y-1">
|
embedded
|
||||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
className={cn(className)}
|
||||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
aria-label="Показатели"
|
||||||
{item.delta ? (
|
/>
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 text-xs',
|
|
||||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DeltaIcon direction={item.delta.direction} />
|
|
||||||
{item.delta.label}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@evobgp/ui/components/chart'
|
} from '@evobgp/ui/components/chart'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
@@ -40,9 +42,10 @@ export function ChartDonutMetric({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-6', className)}>
|
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
@@ -75,7 +78,7 @@ export function ChartDonutMetric({
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
<ul className="min-w-0 flex-1 space-y-3">
|
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
|
||||||
{slices.map((slice) => {
|
{slices.map((slice) => {
|
||||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
onClick={() => navigate({ to: '/monitoring' })}
|
||||||
>
|
>
|
||||||
Мониторинг
|
Мониторинг
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
|||||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
|
||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
|
||||||
import { readinessBreakdown } from '@/lib/metrics'
|
import { readinessBreakdown } from '@/lib/metrics'
|
||||||
import type { ReadyStatus } from '@/queries/monitoring'
|
import type { ReadyStatus } from '@/queries/monitoring'
|
||||||
|
|
||||||
@@ -13,21 +12,25 @@ export function MonitoringHealthCard({
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const slices = readinessBreakdown(ready, healthOk)
|
const slices = readinessBreakdown(ready, healthOk)
|
||||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<DonutBreakdownCard
|
||||||
|
title="Доступность системы"
|
||||||
|
description="Проверки живучести и готовности"
|
||||||
|
slices={[]}
|
||||||
|
centerLabel="Проверки"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnalyticsCardShell
|
<DonutBreakdownCard
|
||||||
title="Доступность системы"
|
title="Доступность системы"
|
||||||
description="Проверки живучести и готовности"
|
description="Проверки живучести и готовности"
|
||||||
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
|
slices={slices}
|
||||||
>
|
centerLabel="Проверки"
|
||||||
{loading ? (
|
badge={healthOk ? 'API в норме' : undefined}
|
||||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
/>
|
||||||
Загрузка…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
|
|
||||||
)}
|
|
||||||
</AnalyticsCardShell>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
|
||||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
|
||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
|
||||||
import { peerSessionBreakdown } from '@/lib/metrics'
|
import { peerSessionBreakdown } from '@/lib/metrics'
|
||||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||||
@@ -16,51 +15,44 @@ export function NetworkOverviewAnalyticsCard({
|
|||||||
}) {
|
}) {
|
||||||
const net = aggregateNetworkMetrics(peers, speakers)
|
const net = aggregateNetworkMetrics(peers, speakers)
|
||||||
const slices = peerSessionBreakdown(peers)
|
const slices = peerSessionBreakdown(peers)
|
||||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
const speakersPct =
|
||||||
|
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<DonutBreakdownCard
|
||||||
|
title="Сводка BGP"
|
||||||
|
description="Установленные сессии и спикеры"
|
||||||
|
slices={[]}
|
||||||
|
centerLabel="Пиры"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnalyticsCardShell
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
title="Сводка BGP"
|
<DonutBreakdownCard
|
||||||
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
|
title="Сводка BGP"
|
||||||
info="Снимок текущего состояния пиров и спикеров."
|
description="Распределение состояний пиров"
|
||||||
>
|
slices={slices}
|
||||||
<AnalyticsKpiRow
|
centerLabel="Пиры"
|
||||||
items={[
|
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
|
||||||
{
|
|
||||||
label: 'Пиры с установленной сессией',
|
|
||||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
|
||||||
delta: {
|
|
||||||
direction: net.peersMismatch > 0 ? 'down' : 'up',
|
|
||||||
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
|
|
||||||
tone: net.peersMismatch > 0 ? 'warning' : 'success',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Спикеры в сети',
|
|
||||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
|
||||||
delta: {
|
|
||||||
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
|
|
||||||
label:
|
|
||||||
net.speakersOnline < net.speakersTotal
|
|
||||||
? `${net.speakersTotal - net.speakersOnline} не в сети`
|
|
||||||
: 'все в сети',
|
|
||||||
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Пиры всего',
|
|
||||||
value: loading ? '—' : String(net.peersTotal),
|
|
||||||
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
<SegmentedProgressCard
|
||||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
title="Спикеры"
|
||||||
Загрузка…
|
description="Доступность агентов"
|
||||||
</div>
|
primary={{
|
||||||
) : (
|
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
label: 'В сети',
|
||||||
)}
|
percent: speakersPct,
|
||||||
</AnalyticsCardShell>
|
}}
|
||||||
|
secondary={{
|
||||||
|
value: net.speakersTotal - net.speakersOnline,
|
||||||
|
label: 'Не в сети',
|
||||||
|
percent: 100 - speakersPct,
|
||||||
|
}}
|
||||||
|
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
|
||||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
|
||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
|
||||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
|
||||||
import type { JobRow, RevisionRow } from '@/types/api'
|
|
||||||
|
|
||||||
export function OperationsAnalyticsCard({
|
|
||||||
jobs,
|
|
||||||
revisions,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
jobs: JobRow[]
|
|
||||||
revisions: RevisionRow[]
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
|
||||||
const failed = jobs.filter((j) =>
|
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
|
||||||
).length
|
|
||||||
const slices = jobStatusBreakdown(jobs)
|
|
||||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnalyticsCardShell
|
|
||||||
title="Операции и задачи"
|
|
||||||
description="Статистика ревизий и фоновых jobs"
|
|
||||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
|
||||||
>
|
|
||||||
<AnalyticsKpiRow
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: 'Ревизий',
|
|
||||||
value: loading ? '—' : String(revisions.length),
|
|
||||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Активных задач',
|
|
||||||
value: loading ? '—' : String(running),
|
|
||||||
delta: {
|
|
||||||
direction: running > 0 ? 'up' : 'neutral',
|
|
||||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
|
||||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'С ошибкой',
|
|
||||||
value: loading ? '—' : String(failed),
|
|
||||||
delta: {
|
|
||||||
direction: failed > 0 ? 'down' : 'up',
|
|
||||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
|
||||||
tone: failed > 0 ? 'destructive' : 'success',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Загрузка…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
|
||||||
)}
|
|
||||||
</AnalyticsCardShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evobgp/ui/components/dropdown-menu'
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@evobgp/ui/components/sidebar'
|
||||||
|
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
APP_SWITCHER_ICONS,
|
||||||
|
CURRENT_APP_ID,
|
||||||
|
getCurrentApp,
|
||||||
|
} from '@/lib/app-switcher-config'
|
||||||
|
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||||
|
|
||||||
|
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
|
||||||
|
export function AppSwitcher() {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const { config, isLoading } = useAppSwitcherConfig()
|
||||||
|
const current = getCurrentApp(config)
|
||||||
|
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<CurrentIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">{current.name}</span>
|
||||||
|
{current.subtitle ? (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{current.subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="min-w-56 rounded-lg"
|
||||||
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||||
|
</div>
|
||||||
|
{config.apps.map((app) => {
|
||||||
|
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||||
|
const isCurrent = app.id === CURRENT_APP_ID
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={app.id} disabled>
|
||||||
|
<Icon />
|
||||||
|
{app.name}
|
||||||
|
<CheckIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={app.id}
|
||||||
|
nativeButton={false}
|
||||||
|
render={<a href={app.url} />}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
{app.name}
|
||||||
|
{app.shortcut ? (
|
||||||
|
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
|
|
||||||
// Deterministic per-dot value so the field reads as noise, not a flat grid.
|
|
||||||
function grain(i: number, j: number) {
|
|
||||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
|
||||||
return n - Math.floor(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static dot field adapted from auth-3's WaveDots backdrop with the twinkle
|
|
||||||
* animation removed: a dense grid of dots at a fixed per-dot brightness,
|
|
||||||
* painted once and again on resize or theme change. The dot color resolves
|
|
||||||
* from the canvas `color` token so it stays neutral in light and dark.
|
|
||||||
* customize: GAP (density), DOT (size), PEAK (max brightness).
|
|
||||||
*/
|
|
||||||
export function CardDotField({ className }: { className?: string }) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current
|
|
||||||
if (!canvas) return
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
const GAP = 3 // dot spacing in px (tight grid, still distinct dots)
|
|
||||||
const DOT = 1.5 // dot side in px
|
|
||||||
const BASE = 0.05 // dim end of a dot
|
|
||||||
const PEAK = 0.32 // bright end of a dot (kept subtle for a card surface)
|
|
||||||
|
|
||||||
const draw = () => {
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
|
||||||
if (!rect.width || !rect.height) return
|
|
||||||
|
|
||||||
// Resetting width clears the canvas and restores the identity transform,
|
|
||||||
// so the color probe below reads a raw device pixel.
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
|
||||||
canvas.width = Math.round(rect.width * dpr)
|
|
||||||
canvas.height = Math.round(rect.height * dpr)
|
|
||||||
|
|
||||||
// Resolve the token to concrete sRGB by painting + reading it back, so
|
|
||||||
// oklch never casts a color and dark mode recolors on theme change.
|
|
||||||
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
|
|
||||||
ctx.fillRect(0, 0, 1, 1)
|
|
||||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
|
||||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
|
||||||
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
||||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
|
||||||
ctx.fillStyle = color
|
|
||||||
|
|
||||||
const cols = Math.ceil(rect.width / GAP) + 1
|
|
||||||
const rows = Math.ceil(rect.height / GAP) + 1
|
|
||||||
for (let i = 0; i < cols; i++) {
|
|
||||||
const x = i * GAP
|
|
||||||
for (let j = 0; j < rows; j++) {
|
|
||||||
const q = grain(i, j)
|
|
||||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
|
||||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
|
||||||
if (a > 1) a = 1
|
|
||||||
ctx.globalAlpha = a
|
|
||||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
draw()
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => draw())
|
|
||||||
resizeObserver.observe(canvas)
|
|
||||||
|
|
||||||
// Repaint when the theme class toggles so the resolved color stays correct.
|
|
||||||
const themeObserver = new MutationObserver(() => draw())
|
|
||||||
themeObserver.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class", "style"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect()
|
|
||||||
themeObserver.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute inset-0 h-full w-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Frame } from "@/components/reui/frame"
|
|
||||||
|
|
||||||
import { CardItem } from "./card-item"
|
|
||||||
import { CARDS } from "./data"
|
|
||||||
|
|
||||||
export function CardGrid() {
|
|
||||||
return (
|
|
||||||
<Frame className="@container w-full">
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="grid gap-1 @2xl:grid-cols-2">
|
|
||||||
{CARDS.map((card) => (
|
|
||||||
<CardItem key={card.title} card={card} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { FramePanel } from "@/components/reui/frame"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
|
||||||
import { CardDotField } from "./card-dot-field"
|
|
||||||
import { ICard } from "./data"
|
|
||||||
import { ChevronRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function CardItem({ card }: { card: ICard }) {
|
|
||||||
return (
|
|
||||||
<FramePanel className="isolate">
|
|
||||||
{/* Card */}
|
|
||||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
|
||||||
<div className="relative z-10 space-y-7.5">
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
"p-0",
|
|
||||||
"border-background flex size-11 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
|
||||||
card.iconBg
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{card.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="block text-sm leading-tight font-medium">
|
|
||||||
{card.title}
|
|
||||||
</span>
|
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
|
||||||
{card.description}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-primay inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
{card.link}
|
|
||||||
<ChevronRightIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { type ReactNode } from "react"
|
|
||||||
import { ShoppingBagIcon, TrendingUp, BarChart3Icon, Settings2Icon } from "lucide-react"
|
|
||||||
|
|
||||||
export interface ICard {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
link: string
|
|
||||||
icon: ReactNode
|
|
||||||
iconBg: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CARDS: ICard[] = [
|
|
||||||
{
|
|
||||||
title: "Recent Orders Overview",
|
|
||||||
description:
|
|
||||||
"Track and review all recent purchases, updates, and status changes in one place.",
|
|
||||||
link: "View Orders",
|
|
||||||
icon: (
|
|
||||||
<ShoppingBagIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
iconBg: "bg-green-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Active Opportunities Pipeline",
|
|
||||||
description:
|
|
||||||
"Monitor ongoing deals, check potential revenue, and update opportunity stages.",
|
|
||||||
link: "Open Pipeline",
|
|
||||||
icon: (
|
|
||||||
<TrendingUp aria-hidden="true" />
|
|
||||||
),
|
|
||||||
iconBg: "bg-indigo-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Performance & Sales Reports",
|
|
||||||
description:
|
|
||||||
"Analyze weekly and monthly reports to gain deeper insights into performance trends.",
|
|
||||||
link: "View Reports",
|
|
||||||
icon: (
|
|
||||||
<BarChart3Icon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
iconBg: "bg-sky-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Integration Settings & Sync",
|
|
||||||
description:
|
|
||||||
"Manage connections with third-party tools and ensure data stays in sync.",
|
|
||||||
link: "Manage Integrations",
|
|
||||||
icon: (
|
|
||||||
<Settings2Icon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
iconBg: "bg-orange-600",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { CardGrid } from "./components/card-grid"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-svh w-full max-w-4xl items-center justify-center p-8">
|
|
||||||
<CardGrid />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { CardItem } from "./card-item"
|
|
||||||
import { CARDS } from "./data"
|
|
||||||
|
|
||||||
export function CardGrid() {
|
|
||||||
return (
|
|
||||||
<div className="@container w-full">
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="grid gap-5 @2xl:grid-cols-3">
|
|
||||||
{CARDS.map((card) => (
|
|
||||||
<CardItem key={card.label} card={card} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
} from "@/components/reui/frame"
|
|
||||||
import { ICard } from "./data"
|
|
||||||
import { LinkIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function CardItem({ card }: { card: ICard }) {
|
|
||||||
return (
|
|
||||||
<Frame spacing="sm">
|
|
||||||
{/* Header */}
|
|
||||||
<FrameHeader className="px-1! py-1!">
|
|
||||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
|
||||||
{card.icon}
|
|
||||||
<span className="text-foreground text-sm font-medium">
|
|
||||||
{card.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</FrameHeader>
|
|
||||||
{/* Content */}
|
|
||||||
<FramePanel className="space-y-3.5">
|
|
||||||
<p className="text-xs leading-relaxed">{card.description}</p>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
<LinkIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
|
||||||
{card.link}
|
|
||||||
</a>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { type ReactNode } from "react"
|
|
||||||
import { PackageIcon, TrendingUp, MapPinIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export interface ICard {
|
|
||||||
label: string
|
|
||||||
icon: ReactNode
|
|
||||||
description: string
|
|
||||||
link: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CARDS: ICard[] = [
|
|
||||||
{
|
|
||||||
label: "Binance",
|
|
||||||
icon: (
|
|
||||||
<PackageIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
description:
|
|
||||||
"Track trading volumes, liquidity shifts, and price movements for informed decisions",
|
|
||||||
link: "https://www.binance.com/en/markets/over..",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Revenue",
|
|
||||||
icon: (
|
|
||||||
<TrendingUp aria-hidden="true" />
|
|
||||||
),
|
|
||||||
description:
|
|
||||||
"Get instant insights into earnings and cash flow performance.",
|
|
||||||
link: "https://nexo.io/earn/crypto-detailed-portfol..",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Shipments",
|
|
||||||
icon: (
|
|
||||||
<MapPinIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
description:
|
|
||||||
"Stay on top of deliveries and track shipment statuses efficiently.",
|
|
||||||
link: "https://www.educare.io/platform/analytics/e..",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { CardGrid } from "./components/card-grid"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-6">
|
|
||||||
<CardGrid />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
||||||
import { Cell, Pie, PieChart } from "recharts"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarGroup,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@evobgp/ui/components/avatar"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from "@evobgp/ui/components/chart"
|
|
||||||
import { Separator } from "@evobgp/ui/components/separator"
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@evobgp/ui/components/tabs"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@evobgp/ui/components/tooltip"
|
|
||||||
import {
|
|
||||||
allocationMemberCount,
|
|
||||||
allocationMembers,
|
|
||||||
allocationPeriods,
|
|
||||||
inflowChartConfig,
|
|
||||||
inflowPeriods,
|
|
||||||
SEGMENT_COUNT,
|
|
||||||
type AllocationPeriod,
|
|
||||||
type InflowFund,
|
|
||||||
type InflowPeriod,
|
|
||||||
} from "./data"
|
|
||||||
import { InfoIcon } from "lucide-react"
|
|
||||||
|
|
||||||
const segments = Array.from({ length: SEGMENT_COUNT }, (_, index) => index)
|
|
||||||
|
|
||||||
const CHART_REVEAL_STYLE = `
|
|
||||||
@keyframes dashboard-1-flow-reveal-up {
|
|
||||||
from {
|
|
||||||
clip-path: inset(100% 0 0 0);
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
clip-path: inset(0 0 0 0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-1-flow-reveal-up {
|
|
||||||
animation: dashboard-1-flow-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.dashboard-1-flow-reveal-up {
|
|
||||||
animation: none;
|
|
||||||
clip-path: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
function AllocationMeter({ period }: { period: AllocationPeriod }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-label={`${period.label} capacity allocation is ${period.allocation}`}
|
|
||||||
className="flex h-7 w-full items-stretch justify-between"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{segments.map((segment) => (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
key={segment}
|
|
||||||
className={cn(
|
|
||||||
"h-full w-1 shrink-0 rounded-full",
|
|
||||||
segment < period.filledSegments ? "bg-success" : "bg-muted"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MemberStack() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AvatarGroup className="-space-x-2">
|
|
||||||
{allocationMembers.map((member) => (
|
|
||||||
<Avatar key={member.name} className="size-6">
|
|
||||||
{member.avatar ? (
|
|
||||||
<AvatarImage src={member.avatar} alt={member.name} />
|
|
||||||
) : null}
|
|
||||||
<AvatarFallback className="bg-background text-xs font-medium">
|
|
||||||
{member.initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
</AvatarGroup>
|
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
|
||||||
{allocationMemberCount} Members
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AllocationChart() {
|
|
||||||
return (
|
|
||||||
<Frame className="@container h-full w-full">
|
|
||||||
<FramePanel>
|
|
||||||
<Tabs
|
|
||||||
defaultValue={allocationPeriods[0].value}
|
|
||||||
className="h-full w-full min-w-0 gap-4"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<h2 className="text-sm font-medium">Capacity Allocation</h2>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground/70 hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex shrink-0 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
||||||
aria-label="Capacity Allocation info"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="top"
|
|
||||||
className="max-w-56 px-2.5 py-1.5 text-xs leading-5"
|
|
||||||
>
|
|
||||||
Fulfillment capacity by selected period.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
<TabsList>
|
|
||||||
{allocationPeriods.map((period) => (
|
|
||||||
<TabsTrigger key={period.value} value={period.value}>
|
|
||||||
{period.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allocationPeriods.map((period) => (
|
|
||||||
<TabsContent
|
|
||||||
key={period.value}
|
|
||||||
value={period.value}
|
|
||||||
className="mt-0"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{/* Metric */}
|
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
|
||||||
<span className="text-[26px] font-medium">
|
|
||||||
{period.allocation}
|
|
||||||
</span>
|
|
||||||
<span className="text-success text-xs font-medium">
|
|
||||||
{period.delta}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground/70 text-xs">
|
|
||||||
{period.comparison}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart */}
|
|
||||||
<AllocationMeter period={period} />
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<p>
|
|
||||||
<span className="text-muted-foreground/70 text-xs">
|
|
||||||
Queued Orders:
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{period.exposure}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<MemberStack />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DonutSlice =
|
|
||||||
| InflowFund
|
|
||||||
| {
|
|
||||||
key: "reserve"
|
|
||||||
name: string
|
|
||||||
amount: string
|
|
||||||
share: number
|
|
||||||
color: string
|
|
||||||
fill: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDonutData(period: InflowPeriod) {
|
|
||||||
const trackedShare = period.funds.reduce(
|
|
||||||
(total, fund) => total + fund.share,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
|
||||||
|
|
||||||
return [
|
|
||||||
...period.funds,
|
|
||||||
{
|
|
||||||
key: "reserve",
|
|
||||||
name: "Reserve Capacity",
|
|
||||||
amount: "",
|
|
||||||
share: reserveShare,
|
|
||||||
color: "var(--muted)",
|
|
||||||
fill: "var(--color-reserve)",
|
|
||||||
},
|
|
||||||
] satisfies DonutSlice[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartTooltipFormatter(item: unknown) {
|
|
||||||
const fund = item as DonutSlice
|
|
||||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-2.5 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: fund.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoTooltip() {
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
aria-label="About Decision Flow"
|
|
||||||
className="text-muted-foreground/70 -my-1"
|
|
||||||
size="icon-sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent side="top" sideOffset={8}>
|
|
||||||
<p>Tracked decisions entering fulfillment lanes.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
|
||||||
const chartData = getDonutData(period)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dashboard-1-flow-reveal-up relative size-[8.25rem] shrink-0">
|
|
||||||
<ChartContainer
|
|
||||||
aria-label={`Decision Flow: ${period.total} total for ${period.label}`}
|
|
||||||
className="aspect-square size-[8.25rem]"
|
|
||||||
config={inflowChartConfig}
|
|
||||||
initialDimension={{ width: 132, height: 132 }}
|
|
||||||
>
|
|
||||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
|
||||||
<ChartTooltip
|
|
||||||
cursor={false}
|
|
||||||
wrapperStyle={{ zIndex: 30 }}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
hideLabel
|
|
||||||
hideIndicator
|
|
||||||
formatter={(_value, _name, item) =>
|
|
||||||
ChartTooltipFormatter(item.payload)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
dataKey="share"
|
|
||||||
endAngle={-230}
|
|
||||||
innerRadius={47}
|
|
||||||
isAnimationActive={false}
|
|
||||||
nameKey="name"
|
|
||||||
outerRadius={62}
|
|
||||||
paddingAngle={1}
|
|
||||||
cornerRadius={3}
|
|
||||||
startAngle={130}
|
|
||||||
stroke="var(--background)"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
{chartData.map((item) => (
|
|
||||||
<Cell key={item.key} fill={item.fill} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
</PieChart>
|
|
||||||
</ChartContainer>
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
|
||||||
<span className="text-muted-foreground/70 text-xs">Flow</span>
|
|
||||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
|
||||||
return (
|
|
||||||
<ul className="flex min-w-0 flex-1 flex-col">
|
|
||||||
{period.funds.map((fund, index) => (
|
|
||||||
<li key={fund.key}>
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
|
||||||
style={{ backgroundColor: fund.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{fund.name}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">{fund.amount}</span>
|
|
||||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
|
||||||
{fund.share}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{index < period.funds.length - 1 ? (
|
|
||||||
<Separator className="w-auto" />
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
|
||||||
<InflowDonut period={period} />
|
|
||||||
<InflowLegend period={period} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InflowChart() {
|
|
||||||
return (
|
|
||||||
<TooltipProvider delay={150}>
|
|
||||||
<style>{CHART_REVEAL_STYLE}</style>
|
|
||||||
<Frame className="@container h-full w-full">
|
|
||||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
|
||||||
<Tabs defaultValue="week" className="gap-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
|
||||||
<h2 className="text-sm font-medium">Decision Flow</h2>
|
|
||||||
<InfoTooltip />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsList className="w-full @sm:w-auto">
|
|
||||||
{inflowPeriods.map((period) => (
|
|
||||||
<TabsTrigger key={period.value} value={period.value}>
|
|
||||||
{period.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{inflowPeriods.map((period) => (
|
|
||||||
<TabsContent
|
|
||||||
key={period.value}
|
|
||||||
value={period.value}
|
|
||||||
className="mt-0"
|
|
||||||
>
|
|
||||||
<InflowPeriodPanel period={period} />
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Chart() {
|
|
||||||
return (
|
|
||||||
<div className="@container grid h-full w-full min-w-0 auto-rows-fr gap-3">
|
|
||||||
<AllocationChart />
|
|
||||||
<InflowChart />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
|
||||||
|
|
||||||
import { FULFILLMENT_CARDS, type FulfillmentCard } from "./data"
|
|
||||||
|
|
||||||
function CardItem({ card }: { card: FulfillmentCard }) {
|
|
||||||
return (
|
|
||||||
<FramePanel>
|
|
||||||
{/* Heading */}
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
"p-0",
|
|
||||||
"border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
|
||||||
card.iconBg
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{card.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="text-muted-foreground text-sm leading-tight">
|
|
||||||
{card.typeLabel}
|
|
||||||
</p>
|
|
||||||
<h3 className="text-sm leading-tight font-medium">{card.title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 space-y-1.5">
|
|
||||||
<p className="text-muted-foreground text-sm leading-tight">
|
|
||||||
{card.metricLabel}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className="text-xl font-medium tracking-tight">
|
|
||||||
{card.balance}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium",
|
|
||||||
card.change.positive ? "text-teal-600" : "text-rose-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{card.change.percent} ({card.change.amount})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Chart() {
|
|
||||||
return (
|
|
||||||
<Frame className="@container w-full">
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
|
||||||
{FULFILLMENT_CARDS.map((card) => (
|
|
||||||
<CardItem key={card.title} card={card} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameFooter,
|
|
||||||
FramePanel,
|
|
||||||
} from "@/components/reui/frame"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { Progress } from "@evobgp/ui/components/progress"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
import { Separator } from "@evobgp/ui/components/separator"
|
|
||||||
import {
|
|
||||||
PERFORMANCE_RANGE_OPTIONS,
|
|
||||||
SHIFT_ACTIVITY,
|
|
||||||
SHIFT_PERFORMANCE,
|
|
||||||
SHIFT_PIPELINE_PROGRESS,
|
|
||||||
} from "./data"
|
|
||||||
import { TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function InvestorCard() {
|
|
||||||
return (
|
|
||||||
<Frame className="h-full w-full">
|
|
||||||
{/* Content */}
|
|
||||||
<FramePanel>
|
|
||||||
<div className="mb-6 flex items-start justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-px">
|
|
||||||
<h3 className="text-base font-semibold">Shift Performance</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select defaultValue="today" items={PERFORMANCE_RANGE_OPTIONS}>
|
|
||||||
<SelectTrigger className="h-8! w-28">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
align="start"
|
|
||||||
alignItemWithTrigger={false}
|
|
||||||
className="w-28"
|
|
||||||
>
|
|
||||||
{PERFORMANCE_RANGE_OPTIONS.map((range) => (
|
|
||||||
<SelectItem key={range.value} value={range.value}>
|
|
||||||
{range.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{SHIFT_PERFORMANCE.map((item) => (
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-start justify-start"
|
|
||||||
key={item.label}
|
|
||||||
>
|
|
||||||
<div className="text-foreground text-xl font-bold">
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-0.5 text-xs font-semibold [&_svg]:h-3 [&_svg]:w-3",
|
|
||||||
item.trend === "positive"
|
|
||||||
? "text-emerald-500"
|
|
||||||
: "text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.trend === "positive" ? (
|
|
||||||
<TrendingUp aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
{item.delta}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="mb-2.5 flex items-center justify-between">
|
|
||||||
<span className="text-foreground text-sm font-medium">
|
|
||||||
Pipeline Progress
|
|
||||||
</span>
|
|
||||||
<span className="text-foreground text-xs font-semibold">
|
|
||||||
{SHIFT_PIPELINE_PROGRESS}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={SHIFT_PIPELINE_PROGRESS}
|
|
||||||
className="h-1! **:data-[slot=progress-track]:h-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-foreground mb-2.5 text-sm font-medium">
|
|
||||||
Recent Activity
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{SHIFT_ACTIVITY.map((activity) => (
|
|
||||||
<li
|
|
||||||
key={activity.id}
|
|
||||||
className="flex items-center justify-between gap-2.5 text-sm"
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
|
||||||
<CircleCheckIcon className={cn(
|
|
||||||
"h-3.5 w-3.5 shrink-0",
|
|
||||||
activity.tone === "success" && "text-emerald-500",
|
|
||||||
activity.tone === "info" && "text-sky-500",
|
|
||||||
activity.tone === "warning" && "text-amber-500"
|
|
||||||
)} aria-hidden="true" />
|
|
||||||
<span className="text-foreground truncate text-xs">
|
|
||||||
{activity.title}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
activity.tone === "success"
|
|
||||||
? "success-light"
|
|
||||||
: activity.tone === "info"
|
|
||||||
? "info-light"
|
|
||||||
: "warning-light"
|
|
||||||
}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{activity.status}
|
|
||||||
</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
{/* Footer */}
|
|
||||||
<FrameFooter className="flex-row items-center gap-2.5 p-2!">
|
|
||||||
<Button variant="outline" className="flex-1">
|
|
||||||
Schedule
|
|
||||||
</Button>
|
|
||||||
<Button className="flex-1">Full Report</Button>
|
|
||||||
</FrameFooter>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Chart as CapacityChart } from "./capacity-chart"
|
|
||||||
import { Chart as ChartCards } from "./chart-cards"
|
|
||||||
import { InvestorCard as CommanderCard } from "./commander-card"
|
|
||||||
import { ExceptionGrid } from "./exception-grid"
|
|
||||||
import { Navbar } from "./navbar"
|
|
||||||
|
|
||||||
export function Dashboard() {
|
|
||||||
return (
|
|
||||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2">
|
|
||||||
<Navbar />
|
|
||||||
|
|
||||||
<section aria-label="Fulfillment metrics">
|
|
||||||
<ChartCards />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
aria-label="Fulfillment operations"
|
|
||||||
className="grid min-w-0 items-stretch gap-3 @5xl:grid-cols-2"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0">
|
|
||||||
<CommanderCard />
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0">
|
|
||||||
<CapacityChart />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-label="Fulfillment exception queue">
|
|
||||||
<ExceptionGrid />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,676 +0,0 @@
|
|||||||
import { type ReactNode } from "react"
|
|
||||||
import { type BadgeProps } from "@/components/reui/badge"
|
|
||||||
|
|
||||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
|
||||||
import { PackageIcon, TruckIcon, TriangleAlertIcon, BotIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export type FulfillmentStatus = "On Time" | "At Risk" | "Delayed" | "Blocked"
|
|
||||||
export type AutomationLevel = "Autopilot" | "Copilot" | "Manual"
|
|
||||||
|
|
||||||
export interface TeamMember {
|
|
||||||
name: string
|
|
||||||
initials: string
|
|
||||||
avatar: string
|
|
||||||
role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FulfillmentException {
|
|
||||||
id: string
|
|
||||||
reference: string
|
|
||||||
customer: string
|
|
||||||
email: string
|
|
||||||
avatar: string
|
|
||||||
initials: string
|
|
||||||
lane: string
|
|
||||||
facility: string
|
|
||||||
stage: string
|
|
||||||
promise: string
|
|
||||||
slaMinutes: number
|
|
||||||
automation: AutomationLevel
|
|
||||||
owner: string
|
|
||||||
units: number
|
|
||||||
value: number
|
|
||||||
risk: string
|
|
||||||
status: FulfillmentStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STATUS_ORDER: FulfillmentStatus[] = [
|
|
||||||
"On Time",
|
|
||||||
"At Risk",
|
|
||||||
"Delayed",
|
|
||||||
"Blocked",
|
|
||||||
]
|
|
||||||
|
|
||||||
export const STATUS_BADGE_VARIANT: Record<
|
|
||||||
FulfillmentStatus,
|
|
||||||
BadgeProps["variant"]
|
|
||||||
> = {
|
|
||||||
"On Time": "success-outline",
|
|
||||||
"At Risk": "warning-outline",
|
|
||||||
Delayed: "info-outline",
|
|
||||||
Blocked: "destructive-outline",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AUTOMATION_BADGE_VARIANT: Record<
|
|
||||||
AutomationLevel,
|
|
||||||
BadgeProps["variant"]
|
|
||||||
> = {
|
|
||||||
Autopilot: "success-light",
|
|
||||||
Copilot: "info-light",
|
|
||||||
Manual: "warning-light",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NAV_MEMBERS: TeamMember[] = [
|
|
||||||
{
|
|
||||||
name: "Maya Singh",
|
|
||||||
initials: "MS",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
|
||||||
role: "Fulfillment lead",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Leo Martins",
|
|
||||||
initials: "LM",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
|
||||||
role: "Automation owner",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Nora Albright",
|
|
||||||
initials: "NA",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
|
||||||
role: "Capacity planner",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const TEAM_MEMBERS = NAV_MEMBERS.map((member) => ({
|
|
||||||
src: member.avatar,
|
|
||||||
initials: member.initials,
|
|
||||||
name: member.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const TEAM_EXTRA_COUNT = 11
|
|
||||||
|
|
||||||
export interface FulfillmentCardChange {
|
|
||||||
positive: boolean
|
|
||||||
percent: string
|
|
||||||
amount: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FulfillmentCard {
|
|
||||||
typeLabel: string
|
|
||||||
title: string
|
|
||||||
metricLabel: string
|
|
||||||
balance: string
|
|
||||||
change: FulfillmentCardChange
|
|
||||||
icon: ReactNode
|
|
||||||
iconBg: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FULFILLMENT_CARDS: FulfillmentCard[] = [
|
|
||||||
{
|
|
||||||
typeLabel: "Outbound",
|
|
||||||
title: "Orders Ready",
|
|
||||||
metricLabel: "Ready Volume",
|
|
||||||
balance: "18,420",
|
|
||||||
change: {
|
|
||||||
positive: true,
|
|
||||||
percent: "+11.8%",
|
|
||||||
amount: "1,946",
|
|
||||||
},
|
|
||||||
iconBg: "bg-neutral-950",
|
|
||||||
icon: (
|
|
||||||
<PackageIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
typeLabel: "Promise",
|
|
||||||
title: "Same-Day SLA",
|
|
||||||
metricLabel: "Service Level",
|
|
||||||
balance: "94.8%",
|
|
||||||
change: {
|
|
||||||
positive: true,
|
|
||||||
percent: "+1.2 pts",
|
|
||||||
amount: "shift",
|
|
||||||
},
|
|
||||||
iconBg: "bg-indigo-600",
|
|
||||||
icon: (
|
|
||||||
<TruckIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
typeLabel: "Inventory",
|
|
||||||
title: "Stock Risk",
|
|
||||||
metricLabel: "Blocked SKUs",
|
|
||||||
balance: "31",
|
|
||||||
change: {
|
|
||||||
positive: true,
|
|
||||||
percent: "13 fewer",
|
|
||||||
amount: "since 06:00",
|
|
||||||
},
|
|
||||||
iconBg: "bg-amber-400",
|
|
||||||
icon: (
|
|
||||||
<TriangleAlertIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
typeLabel: "Policy",
|
|
||||||
title: "AI Autopilot",
|
|
||||||
metricLabel: "Auto Resolved",
|
|
||||||
balance: "71.6%",
|
|
||||||
change: {
|
|
||||||
positive: true,
|
|
||||||
percent: "+8.4 pts",
|
|
||||||
amount: "policy",
|
|
||||||
},
|
|
||||||
iconBg: "bg-cyan-600",
|
|
||||||
icon: (
|
|
||||||
<BotIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export type AllocationPeriod = {
|
|
||||||
value: "week" | "month" | "year"
|
|
||||||
label: string
|
|
||||||
allocation: string
|
|
||||||
delta: string
|
|
||||||
comparison: string
|
|
||||||
exposure: string
|
|
||||||
filledSegments: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AllocationMember = {
|
|
||||||
name: string
|
|
||||||
initials: string
|
|
||||||
avatar?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SEGMENT_COUNT = 56
|
|
||||||
export const allocationMemberCount = 6
|
|
||||||
|
|
||||||
export const allocationPeriods: AllocationPeriod[] = [
|
|
||||||
{
|
|
||||||
value: "week",
|
|
||||||
label: "Week",
|
|
||||||
allocation: "86%",
|
|
||||||
delta: "+5.8%",
|
|
||||||
comparison: "vs labor plan",
|
|
||||||
exposure: "3,840 orders",
|
|
||||||
filledSegments: 48,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "month",
|
|
||||||
label: "Month",
|
|
||||||
allocation: "79%",
|
|
||||||
delta: "+2.4%",
|
|
||||||
comparison: "vs prior month",
|
|
||||||
exposure: "18 priority lanes",
|
|
||||||
filledSegments: 44,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "year",
|
|
||||||
label: "Year",
|
|
||||||
allocation: "74%",
|
|
||||||
delta: "+9.2%",
|
|
||||||
comparison: "automation lift",
|
|
||||||
exposure: "6 facilities",
|
|
||||||
filledSegments: 41,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const allocationMembers: AllocationMember[] = TEAM_MEMBERS.map(
|
|
||||||
(member) => ({
|
|
||||||
name: member.name,
|
|
||||||
initials: member.initials,
|
|
||||||
avatar: member.src,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export type PerformanceTrend = "positive" | "negative"
|
|
||||||
export type ActivityTone = "success" | "info" | "warning"
|
|
||||||
|
|
||||||
export interface PerformanceMetric {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
trend: PerformanceTrend
|
|
||||||
delta: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShiftActivity {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
time: string
|
|
||||||
status: string
|
|
||||||
tone: ActivityTone
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PERFORMANCE_RANGE_OPTIONS = [
|
|
||||||
{ label: "Today", value: "today" },
|
|
||||||
{ label: "Week", value: "week" },
|
|
||||||
{ label: "Month", value: "month" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SHIFT_PERFORMANCE: PerformanceMetric[] = [
|
|
||||||
{
|
|
||||||
label: "Orders Cleared",
|
|
||||||
value: "18.4k",
|
|
||||||
trend: "positive",
|
|
||||||
delta: "+11.8%",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "SLA Recovery",
|
|
||||||
value: "94.8%",
|
|
||||||
trend: "positive",
|
|
||||||
delta: "+1.2 pts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Risk Exposure",
|
|
||||||
value: "$128k",
|
|
||||||
trend: "negative",
|
|
||||||
delta: "-9.4%",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SHIFT_PIPELINE_PROGRESS = 76
|
|
||||||
|
|
||||||
export const SHIFT_ACTIVITY: ShiftActivity[] = [
|
|
||||||
{
|
|
||||||
id: "wave-release",
|
|
||||||
title: "Released priority wave to dock B",
|
|
||||||
time: "4 min ago",
|
|
||||||
status: "Cleared",
|
|
||||||
tone: "success",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "carrier-reprice",
|
|
||||||
title: "Carrier mix repriced for zone 6",
|
|
||||||
time: "12 min ago",
|
|
||||||
status: "Review",
|
|
||||||
tone: "info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inventory-hold",
|
|
||||||
title: "Inventory hold isolated to 3 SKUs",
|
|
||||||
time: "23 min ago",
|
|
||||||
status: "Watch",
|
|
||||||
tone: "warning",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export type InflowFundKey = "autopilot" | "copilot" | "manual" | "reserve"
|
|
||||||
|
|
||||||
export interface InflowFund {
|
|
||||||
key: Exclude<InflowFundKey, "reserve">
|
|
||||||
name: string
|
|
||||||
amount: string
|
|
||||||
share: number
|
|
||||||
color: string
|
|
||||||
fill: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InflowPeriod {
|
|
||||||
value: "week" | "month" | "year"
|
|
||||||
label: string
|
|
||||||
total: string
|
|
||||||
headline: string
|
|
||||||
description: string
|
|
||||||
delta: string
|
|
||||||
funds: InflowFund[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflowAutopilotColor = "oklch(0.62 0.19 149)"
|
|
||||||
const inflowCopilotColor = "oklch(0.58 0.18 257)"
|
|
||||||
const inflowManualColor = "oklch(0.72 0.16 78)"
|
|
||||||
|
|
||||||
export const inflowChartConfig = {
|
|
||||||
flow: {
|
|
||||||
label: "Flow",
|
|
||||||
},
|
|
||||||
autopilot: {
|
|
||||||
label: "Autopilot",
|
|
||||||
color: inflowAutopilotColor,
|
|
||||||
},
|
|
||||||
copilot: {
|
|
||||||
label: "Copilot",
|
|
||||||
color: inflowCopilotColor,
|
|
||||||
},
|
|
||||||
manual: {
|
|
||||||
label: "Manual",
|
|
||||||
color: inflowManualColor,
|
|
||||||
},
|
|
||||||
reserve: {
|
|
||||||
label: "Reserve",
|
|
||||||
color: "oklch(0.7 0.04 260)",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export const inflowPeriods: InflowPeriod[] = [
|
|
||||||
{
|
|
||||||
value: "week",
|
|
||||||
label: "Week",
|
|
||||||
total: "18.4k",
|
|
||||||
headline: "Exception Flow",
|
|
||||||
description: "Orders entering decision lanes",
|
|
||||||
delta: "+6.2%",
|
|
||||||
funds: [
|
|
||||||
{
|
|
||||||
key: "autopilot",
|
|
||||||
name: "Autopilot",
|
|
||||||
amount: "9.1k",
|
|
||||||
share: 49.5,
|
|
||||||
color: inflowAutopilotColor,
|
|
||||||
fill: "var(--color-autopilot)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "copilot",
|
|
||||||
name: "Copilot",
|
|
||||||
amount: "5.2k",
|
|
||||||
share: 28.3,
|
|
||||||
color: inflowCopilotColor,
|
|
||||||
fill: "var(--color-copilot)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "manual",
|
|
||||||
name: "Manual",
|
|
||||||
amount: "2.8k",
|
|
||||||
share: 15.2,
|
|
||||||
color: inflowManualColor,
|
|
||||||
fill: "var(--color-manual)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "month",
|
|
||||||
label: "Month",
|
|
||||||
total: "76.8k",
|
|
||||||
headline: "Resolved Flow",
|
|
||||||
description: "Completed decisions this month",
|
|
||||||
delta: "+14.8%",
|
|
||||||
funds: [
|
|
||||||
{
|
|
||||||
key: "autopilot",
|
|
||||||
name: "Autopilot",
|
|
||||||
amount: "41.6k",
|
|
||||||
share: 54.2,
|
|
||||||
color: inflowAutopilotColor,
|
|
||||||
fill: "var(--color-autopilot)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "copilot",
|
|
||||||
name: "Copilot",
|
|
||||||
amount: "20.3k",
|
|
||||||
share: 26.4,
|
|
||||||
color: inflowCopilotColor,
|
|
||||||
fill: "var(--color-copilot)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "manual",
|
|
||||||
name: "Manual",
|
|
||||||
amount: "9.8k",
|
|
||||||
share: 12.8,
|
|
||||||
color: inflowManualColor,
|
|
||||||
fill: "var(--color-manual)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "year",
|
|
||||||
label: "Year",
|
|
||||||
total: "812k",
|
|
||||||
headline: "Network Flow",
|
|
||||||
description: "Decisions across six facilities",
|
|
||||||
delta: "+21.5%",
|
|
||||||
funds: [
|
|
||||||
{
|
|
||||||
key: "autopilot",
|
|
||||||
name: "Autopilot",
|
|
||||||
amount: "428k",
|
|
||||||
share: 52.7,
|
|
||||||
color: inflowAutopilotColor,
|
|
||||||
fill: "var(--color-autopilot)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "copilot",
|
|
||||||
name: "Copilot",
|
|
||||||
amount: "224k",
|
|
||||||
share: 27.6,
|
|
||||||
color: inflowCopilotColor,
|
|
||||||
fill: "var(--color-copilot)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "manual",
|
|
||||||
name: "Manual",
|
|
||||||
amount: "103k",
|
|
||||||
share: 12.7,
|
|
||||||
color: inflowManualColor,
|
|
||||||
fill: "var(--color-manual)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const FULFILLMENT_ROWS: FulfillmentException[] = [
|
|
||||||
{
|
|
||||||
id: "row-1001",
|
|
||||||
reference: "NSC-84721",
|
|
||||||
customer: "Avery Outdoor",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "AO",
|
|
||||||
lane: "Chicago to Austin",
|
|
||||||
facility: "ORD-2",
|
|
||||||
stage: "Carrier tender",
|
|
||||||
promise: "Today 18:00",
|
|
||||||
slaMinutes: 42,
|
|
||||||
automation: "Copilot",
|
|
||||||
owner: "Maya Singh",
|
|
||||||
units: 480,
|
|
||||||
value: 38240,
|
|
||||||
risk: "Carrier capacity is tight after midday cutoff",
|
|
||||||
status: "At Risk",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1002",
|
|
||||||
reference: "NSC-84734",
|
|
||||||
customer: "Field & Frame",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "FF",
|
|
||||||
lane: "Dallas to Phoenix",
|
|
||||||
facility: "DFW-1",
|
|
||||||
stage: "Pick wave",
|
|
||||||
promise: "Today 16:30",
|
|
||||||
slaMinutes: 88,
|
|
||||||
automation: "Autopilot",
|
|
||||||
owner: "Leo Martins",
|
|
||||||
units: 310,
|
|
||||||
value: 21480,
|
|
||||||
risk: "Wave optimized by carton density",
|
|
||||||
status: "On Time",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1003",
|
|
||||||
reference: "NSC-84755",
|
|
||||||
customer: "MetroFit Labs",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "ML",
|
|
||||||
lane: "Newark to Boston",
|
|
||||||
facility: "EWR-3",
|
|
||||||
stage: "Inventory hold",
|
|
||||||
promise: "Today 15:15",
|
|
||||||
slaMinutes: -24,
|
|
||||||
automation: "Manual",
|
|
||||||
owner: "Nora Albright",
|
|
||||||
units: 126,
|
|
||||||
value: 18760,
|
|
||||||
risk: "Lot trace requires human release",
|
|
||||||
status: "Blocked",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1004",
|
|
||||||
reference: "NSC-84763",
|
|
||||||
customer: "Northline Studio",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "NS",
|
|
||||||
lane: "Los Angeles to Seattle",
|
|
||||||
facility: "LAX-4",
|
|
||||||
stage: "Packing",
|
|
||||||
promise: "Today 19:45",
|
|
||||||
slaMinutes: 114,
|
|
||||||
automation: "Autopilot",
|
|
||||||
owner: "Leo Martins",
|
|
||||||
units: 840,
|
|
||||||
value: 52210,
|
|
||||||
risk: "Packing line is running above plan",
|
|
||||||
status: "On Time",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1005",
|
|
||||||
reference: "NSC-84801",
|
|
||||||
customer: "Urban Pantry",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "UP",
|
|
||||||
lane: "Atlanta to Miami",
|
|
||||||
facility: "ATL-2",
|
|
||||||
stage: "Cold chain",
|
|
||||||
promise: "Today 17:00",
|
|
||||||
slaMinutes: 9,
|
|
||||||
automation: "Copilot",
|
|
||||||
owner: "Maya Singh",
|
|
||||||
units: 212,
|
|
||||||
value: 30440,
|
|
||||||
risk: "Reefer handoff needs confirmation",
|
|
||||||
status: "Delayed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1006",
|
|
||||||
reference: "NSC-84819",
|
|
||||||
customer: "Glow Market",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1489424731084-a5d8b219a5bb?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "GM",
|
|
||||||
lane: "Las Vegas to Denver",
|
|
||||||
facility: "LAS-1",
|
|
||||||
stage: "Labeling",
|
|
||||||
promise: "Tomorrow 09:20",
|
|
||||||
slaMinutes: 312,
|
|
||||||
automation: "Autopilot",
|
|
||||||
owner: "Nora Albright",
|
|
||||||
units: 94,
|
|
||||||
value: 10920,
|
|
||||||
risk: "No current risk",
|
|
||||||
status: "On Time",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1007",
|
|
||||||
reference: "NSC-84827",
|
|
||||||
customer: "Ridge Supply",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "RS",
|
|
||||||
lane: "Portland to San Jose",
|
|
||||||
facility: "PDX-1",
|
|
||||||
stage: "Split shipment",
|
|
||||||
promise: "Today 20:00",
|
|
||||||
slaMinutes: 36,
|
|
||||||
automation: "Copilot",
|
|
||||||
owner: "Maya Singh",
|
|
||||||
units: 176,
|
|
||||||
value: 14680,
|
|
||||||
risk: "Two SKUs short at primary node",
|
|
||||||
status: "At Risk",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1008",
|
|
||||||
reference: "NSC-84842",
|
|
||||||
customer: "Casa Verde",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "CV",
|
|
||||||
lane: "Nashville to Charlotte",
|
|
||||||
facility: "BNA-2",
|
|
||||||
stage: "Dock queue",
|
|
||||||
promise: "Today 14:30",
|
|
||||||
slaMinutes: -51,
|
|
||||||
automation: "Manual",
|
|
||||||
owner: "Nora Albright",
|
|
||||||
units: 265,
|
|
||||||
value: 22750,
|
|
||||||
risk: "Outbound door is constrained",
|
|
||||||
status: "Delayed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1009",
|
|
||||||
reference: "NSC-84864",
|
|
||||||
customer: "Beacon Cycle",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1552058544-f2b08422138a?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "BC",
|
|
||||||
lane: "Columbus to Pittsburgh",
|
|
||||||
facility: "CMH-1",
|
|
||||||
stage: "Fraud review",
|
|
||||||
promise: "Tomorrow 11:45",
|
|
||||||
slaMinutes: 510,
|
|
||||||
automation: "Manual",
|
|
||||||
owner: "Maya Singh",
|
|
||||||
units: 58,
|
|
||||||
value: 8920,
|
|
||||||
risk: "Payment review blocks release",
|
|
||||||
status: "Blocked",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-1010",
|
|
||||||
reference: "NSC-84888",
|
|
||||||
customer: "Aster Goods",
|
|
||||||
email: "[email protected]",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "AG",
|
|
||||||
lane: "Reno to Salt Lake City",
|
|
||||||
facility: "RNO-1",
|
|
||||||
stage: "Manifest",
|
|
||||||
promise: "Today 22:15",
|
|
||||||
slaMinutes: 177,
|
|
||||||
automation: "Autopilot",
|
|
||||||
owner: "Leo Martins",
|
|
||||||
units: 390,
|
|
||||||
value: 19340,
|
|
||||||
risk: "Manifest is ready for carrier scan",
|
|
||||||
status: "On Time",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function fulfillmentSearchBlob(row: FulfillmentException): string {
|
|
||||||
return [
|
|
||||||
row.reference,
|
|
||||||
row.customer,
|
|
||||||
row.email,
|
|
||||||
row.lane,
|
|
||||||
row.facility,
|
|
||||||
row.stage,
|
|
||||||
row.promise,
|
|
||||||
row.automation,
|
|
||||||
row.owner,
|
|
||||||
row.risk,
|
|
||||||
row.status,
|
|
||||||
String(row.units),
|
|
||||||
String(row.value),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase()
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
import { memo } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
|
||||||
import {
|
|
||||||
DataGridTableRowSelect,
|
|
||||||
DataGridTableRowSelectAll,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import { type ColumnDef, type Row } from "@tanstack/react-table"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@evobgp/ui/components/avatar"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
|
||||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@evobgp/ui/components/tooltip"
|
|
||||||
import {
|
|
||||||
AUTOMATION_BADGE_VARIANT,
|
|
||||||
STATUS_BADGE_VARIANT,
|
|
||||||
type AutomationLevel,
|
|
||||||
type FulfillmentException,
|
|
||||||
type FulfillmentStatus,
|
|
||||||
} from "./data"
|
|
||||||
import { PackageIcon, InfoIcon, MoreHorizontalIcon, EyeIcon, BellIcon, CopyIcon, TriangleAlertIcon } from "lucide-react"
|
|
||||||
|
|
||||||
const currencyCompact = new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const numberCompact = new Intl.NumberFormat("en-US", {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const availabilityColor: Record<FulfillmentStatus, string> = {
|
|
||||||
"On Time": "bg-success",
|
|
||||||
"At Risk": "bg-warning",
|
|
||||||
Delayed: "bg-info",
|
|
||||||
Blocked: "bg-destructive",
|
|
||||||
}
|
|
||||||
|
|
||||||
const stageProgress: Record<string, number> = {
|
|
||||||
"Carrier tender": 72,
|
|
||||||
"Pick wave": 64,
|
|
||||||
"Inventory hold": 28,
|
|
||||||
Packing: 82,
|
|
||||||
"Cold chain": 48,
|
|
||||||
Labeling: 76,
|
|
||||||
"Split shipment": 39,
|
|
||||||
"Dock queue": 31,
|
|
||||||
"Fraud review": 24,
|
|
||||||
Manifest: 90,
|
|
||||||
}
|
|
||||||
|
|
||||||
function DotSeparator() {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBadge = memo(function StatusBadge({
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
status: FulfillmentStatus
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Badge variant={STATUS_BADGE_VARIANT[status]} className="gap-1.5">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("size-1.5 rounded-full", availabilityColor[status])}
|
|
||||||
/>
|
|
||||||
{status}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function AutomationBadge({ level }: { level: AutomationLevel }) {
|
|
||||||
return <Badge variant={AUTOMATION_BADGE_VARIANT[level]}>{level}</Badge>
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReferenceCell = memo(function ReferenceCell({
|
|
||||||
row,
|
|
||||||
}: {
|
|
||||||
row: Row<FulfillmentException>
|
|
||||||
}) {
|
|
||||||
const order = row.original
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-primary truncate text-sm font-medium underline-offset-2 transition-colors hover:underline"
|
|
||||||
aria-label={`View order ${order.reference}`}
|
|
||||||
>
|
|
||||||
{order.reference}
|
|
||||||
</a>
|
|
||||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
|
||||||
<span className="shrink-0">{order.facility}</span>
|
|
||||||
<DotSeparator />
|
|
||||||
<span className="truncate">{order.owner}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const CustomerCell = memo(function CustomerCell({
|
|
||||||
row,
|
|
||||||
}: {
|
|
||||||
row: Row<FulfillmentException>
|
|
||||||
}) {
|
|
||||||
const order = row.original
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<Avatar className="size-8">
|
|
||||||
<AvatarImage src={order.avatar} alt={order.customer} />
|
|
||||||
<AvatarFallback>{order.initials}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
|
||||||
availabilityColor[order.status]
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-foreground hover:text-primary line-clamp-1 font-medium underline-offset-2 transition-colors hover:underline"
|
|
||||||
aria-label={`View customer ${order.customer}`}
|
|
||||||
>
|
|
||||||
{order.customer}
|
|
||||||
</a>
|
|
||||||
<div
|
|
||||||
className="text-muted-foreground line-clamp-1 text-xs"
|
|
||||||
title={order.email}
|
|
||||||
>
|
|
||||||
{order.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const StageCell = memo(function StageCell({
|
|
||||||
row,
|
|
||||||
}: {
|
|
||||||
row: Row<FulfillmentException>
|
|
||||||
}) {
|
|
||||||
const order = row.original
|
|
||||||
const progress = stageProgress[order.stage] ?? 50
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<Item render={<span />} className="w-auto shrink-0 border-0 p-0">
|
|
||||||
<ItemMedia variant="icon" className="text-muted-foreground size-auto">
|
|
||||||
<PackageIcon className="size-4" aria-hidden="true" />
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<span className="text-foreground min-w-0 truncate font-medium">
|
|
||||||
{order.stage}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="bg-muted block h-1.5 min-w-16 flex-1 overflow-hidden rounded-full">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"block h-full rounded-full",
|
|
||||||
availabilityColor[order.status]
|
|
||||||
)}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
|
||||||
{progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const LaneCell = memo(function LaneCell({
|
|
||||||
row,
|
|
||||||
}: {
|
|
||||||
row: Row<FulfillmentException>
|
|
||||||
}) {
|
|
||||||
const order = row.original
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex max-w-full min-w-0 flex-col gap-0.5">
|
|
||||||
<span
|
|
||||||
className="text-foreground block max-w-full min-w-0 truncate font-medium"
|
|
||||||
title={order.lane}
|
|
||||||
>
|
|
||||||
{order.lane}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-muted-foreground block max-w-full min-w-0 truncate text-xs"
|
|
||||||
title={order.facility}
|
|
||||||
>
|
|
||||||
{order.facility}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const ValueCell = memo(function ValueCell({
|
|
||||||
row,
|
|
||||||
}: {
|
|
||||||
row: Row<FulfillmentException>
|
|
||||||
}) {
|
|
||||||
const valueHint =
|
|
||||||
row.original.value >= 30000 ? "Priority lane" : "Standard lane"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<span className="text-foreground font-medium tabular-nums">
|
|
||||||
{currencyCompact.format(row.original.value)}
|
|
||||||
</span>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-5 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
aria-label={`Value hint for ${row.original.reference}: ${valueHint}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-48 p-2.5 text-xs leading-5">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{valueHint}</span>
|
|
||||||
<span className="text-background/80">
|
|
||||||
{numberCompact.format(row.original.units)} units
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function StateCell({ row }: { row: Row<FulfillmentException> }) {
|
|
||||||
const sla = row.original.slaMinutes
|
|
||||||
const slaHint =
|
|
||||||
sla < 0
|
|
||||||
? `${Math.abs(sla)} min overdue`
|
|
||||||
: sla <= 45
|
|
||||||
? `${sla} min buffer`
|
|
||||||
: `Due ${row.original.promise}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
|
||||||
<StatusBadge status={row.original.status} />
|
|
||||||
<span className="text-muted-foreground max-w-full truncate text-xs">
|
|
||||||
{slaHint}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RiskCell({ row }: { row: Row<FulfillmentException> }) {
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex items-center gap-1.5 rounded-full focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
aria-label={`Risk note for ${row.original.reference}: ${row.original.risk}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
|
||||||
<span className="max-w-32 truncate text-xs">{row.original.risk}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs p-3 text-xs leading-5">
|
|
||||||
{row.original.risk}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionsCell({ row }: { row: Row<FulfillmentException> }) {
|
|
||||||
const copyReference = async () => {
|
|
||||||
await navigator.clipboard?.writeText(row.original.reference)
|
|
||||||
toast.success("Reference copied", {
|
|
||||||
description: row.original.reference,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="size-7"
|
|
||||||
aria-label={`Actions for ${row.original.reference}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
toast.info("Opening order", {
|
|
||||||
description: row.original.reference,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<EyeIcon className="size-4" aria-hidden="true" />
|
|
||||||
View order
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
toast.info("Owner notified", {
|
|
||||||
description: row.original.owner,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BellIcon className="size-4" aria-hidden="true" />
|
|
||||||
Notify owner
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={copyReference}>
|
|
||||||
<CopyIcon className="size-4" aria-hidden="true" />
|
|
||||||
Copy reference
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() =>
|
|
||||||
toast.warning("Escalation staged", {
|
|
||||||
description: "Connect this action to your incident workflow.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TriangleAlertIcon className="size-4" aria-hidden="true" />
|
|
||||||
Escalate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const columns: ColumnDef<FulfillmentException>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "id",
|
|
||||||
id: "id",
|
|
||||||
header: () => <DataGridTableRowSelectAll />,
|
|
||||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
|
||||||
enableSorting: false,
|
|
||||||
size: 35,
|
|
||||||
enableResizing: false,
|
|
||||||
enableHiding: false,
|
|
||||||
meta: {
|
|
||||||
headerClassName: "ps-4!",
|
|
||||||
cellClassName: "ps-4!",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "reference",
|
|
||||||
id: "reference",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ReferenceCell row={row} />,
|
|
||||||
size: 138,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: true,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Order",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customer",
|
|
||||||
id: "customer",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <CustomerCell row={row} />,
|
|
||||||
size: 210,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: true,
|
|
||||||
minSize: 190,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Customer",
|
|
||||||
autoSize: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "lane",
|
|
||||||
id: "lane",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <LaneCell row={row} />,
|
|
||||||
size: 165,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: true,
|
|
||||||
enableResizing: true,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Lane",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "stage",
|
|
||||||
id: "stage",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <StageCell row={row} />,
|
|
||||||
size: 160,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: true,
|
|
||||||
enableResizing: true,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Stage",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "automation",
|
|
||||||
id: "automation",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <AutomationBadge level={row.original.automation} />,
|
|
||||||
size: 112,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: true,
|
|
||||||
enableResizing: true,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Automation",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "value",
|
|
||||||
id: "value",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ValueCell row={row} />,
|
|
||||||
size: 120,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: true,
|
|
||||||
enableResizing: true,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "risk",
|
|
||||||
id: "risk",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <RiskCell row={row} />,
|
|
||||||
size: 170,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: true,
|
|
||||||
enableResizing: true,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Risk",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
id: "status",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <StateCell row={row} />,
|
|
||||||
size: 142,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: true,
|
|
||||||
enableResizing: true,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "State",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "",
|
|
||||||
cell: ({ row }) => <ActionsCell row={row} />,
|
|
||||||
size: 46,
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
import { useMemo, useState } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { DataGrid as ReuiDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
|
||||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
|
||||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from "@/components/reui/frame"
|
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type PaginationState,
|
|
||||||
type RowSelectionState,
|
|
||||||
type SortingState,
|
|
||||||
type VisibilityState,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupButton,
|
|
||||||
InputGroupInput,
|
|
||||||
} from "@evobgp/ui/components/input-group"
|
|
||||||
import { Label } from "@evobgp/ui/components/label"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@evobgp/ui/components/popover"
|
|
||||||
import { Separator } from "@evobgp/ui/components/separator"
|
|
||||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
|
||||||
import {
|
|
||||||
FULFILLMENT_ROWS,
|
|
||||||
fulfillmentSearchBlob,
|
|
||||||
STATUS_ORDER,
|
|
||||||
type FulfillmentStatus,
|
|
||||||
} from "./data"
|
|
||||||
import { columns, StatusBadge } from "./exception-columns"
|
|
||||||
import { SearchIcon, XIcon, FilterIcon, MoreHorizontalIcon, FileDownIcon, RefreshCwIcon, SettingsIcon, PlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface ToolbarProps {
|
|
||||||
searchQuery: string
|
|
||||||
onSearchChange: (value: string) => void
|
|
||||||
selectedStatuses: FulfillmentStatus[]
|
|
||||||
onStatusChange: (checked: boolean, status: FulfillmentStatus) => void
|
|
||||||
onClearFilters: () => void
|
|
||||||
hasActiveFilters: boolean
|
|
||||||
statusCounts: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({
|
|
||||||
searchQuery,
|
|
||||||
onSearchChange,
|
|
||||||
selectedStatuses,
|
|
||||||
onStatusChange,
|
|
||||||
onClearFilters,
|
|
||||||
hasActiveFilters,
|
|
||||||
statusCounts,
|
|
||||||
}: ToolbarProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<InputGroup className="w-full min-w-52 sm:w-60">
|
|
||||||
<InputGroupAddon align="inline-start">
|
|
||||||
<SearchIcon aria-hidden="true" />
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
placeholder="Search orders..."
|
|
||||||
aria-label="Search orders"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
{searchQuery.length > 0 && (
|
|
||||||
<InputGroupAddon align="inline-end">
|
|
||||||
<InputGroupButton
|
|
||||||
aria-label="Clear search"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={() => onSearchChange("")}
|
|
||||||
>
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
</InputGroupButton>
|
|
||||||
</InputGroupAddon>
|
|
||||||
)}
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="outline" aria-label="Filter by order status">
|
|
||||||
<FilterIcon aria-hidden="true" />
|
|
||||||
Status
|
|
||||||
{selectedStatuses.length > 0 && (
|
|
||||||
<Badge variant="info-outline">
|
|
||||||
{selectedStatuses.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="flex w-48 flex-col gap-2.5 p-3"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground text-xs font-medium">
|
|
||||||
Filter by status
|
|
||||||
</span>
|
|
||||||
{STATUS_ORDER.map((status) => (
|
|
||||||
<div key={status} className="flex items-center gap-2.5">
|
|
||||||
<Checkbox
|
|
||||||
id={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
|
||||||
checked={selectedStatuses.includes(status)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onStatusChange(checked === true, status)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
|
||||||
className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 font-normal"
|
|
||||||
>
|
|
||||||
<StatusBadge status={status} />
|
|
||||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
|
||||||
{statusCounts[status] ?? 0}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="outline" aria-label="Exception queue actions">
|
|
||||||
<MoreHorizontalIcon aria-hidden="true" />
|
|
||||||
Actions
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
toast.success("Export ready", {
|
|
||||||
description: "Exception queue export prepared.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileDownIcon aria-hidden="true" />
|
|
||||||
Export CSV
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
toast.message("Queue refreshed", {
|
|
||||||
description: "Live data would refresh through your API.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon aria-hidden="true" />
|
|
||||||
Refresh
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
toast.info("View settings", {
|
|
||||||
description: "Column and density controls are available.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
View settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExceptionGrid() {
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 5,
|
|
||||||
})
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
|
||||||
{ id: "value", desc: true },
|
|
||||||
])
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
const [selectedStatuses, setSelectedStatuses] = useState<FulfillmentStatus[]>(
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
|
||||||
columns.map((column) => column.id as string)
|
|
||||||
)
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
|
||||||
risk: false,
|
|
||||||
})
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
||||||
|
|
||||||
const statusCounts = useMemo(
|
|
||||||
() =>
|
|
||||||
FULFILLMENT_ROWS.reduce(
|
|
||||||
(acc, row) => {
|
|
||||||
acc[row.status] = (acc[row.status] || 0) + 1
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
return FULFILLMENT_ROWS.filter((row) => {
|
|
||||||
const matchesStatus =
|
|
||||||
!selectedStatuses.length || selectedStatuses.includes(row.status)
|
|
||||||
const matchesSearch =
|
|
||||||
!searchQuery ||
|
|
||||||
fulfillmentSearchBlob(row).includes(searchQuery.toLowerCase())
|
|
||||||
|
|
||||||
return matchesStatus && matchesSearch
|
|
||||||
})
|
|
||||||
}, [searchQuery, selectedStatuses])
|
|
||||||
|
|
||||||
const hasActiveFilters =
|
|
||||||
searchQuery.trim().length > 0 || selectedStatuses.length > 0
|
|
||||||
|
|
||||||
const resetToFirstPage = () => {
|
|
||||||
setPagination((current) =>
|
|
||||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
|
||||||
setSearchQuery(value)
|
|
||||||
resetToFirstPage()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStatusChange = (checked: boolean, status: FulfillmentStatus) => {
|
|
||||||
setSelectedStatuses((current) =>
|
|
||||||
checked ? [...current, status] : current.filter((item) => item !== status)
|
|
||||||
)
|
|
||||||
resetToFirstPage()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
|
||||||
setSelectedStatuses([])
|
|
||||||
setSearchQuery("")
|
|
||||||
resetToFirstPage()
|
|
||||||
}
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
columns,
|
|
||||||
data: filteredData,
|
|
||||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
state: { pagination, sorting, columnOrder, columnVisibility, rowSelection },
|
|
||||||
columnResizeMode: "onChange",
|
|
||||||
enableRowSelection: true,
|
|
||||||
autoResetPageIndex: false,
|
|
||||||
onColumnOrderChange: setColumnOrder,
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider delay={200}>
|
|
||||||
<ReuiDataGrid
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredData.length}
|
|
||||||
emptyMessage={
|
|
||||||
filteredData.length === 0
|
|
||||||
? "No fulfillment exceptions match your filters."
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
tableLayout={{
|
|
||||||
columnsPinnable: true,
|
|
||||||
columnsResizable: true,
|
|
||||||
columnsMovable: true,
|
|
||||||
columnsVisibility: true,
|
|
||||||
headerSticky: true,
|
|
||||||
dense: true,
|
|
||||||
}}
|
|
||||||
tableClassNames={{
|
|
||||||
bodyRow: "[&>td]:h-16",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Frame variant="default" spacing="sm" className="w-full">
|
|
||||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<FrameTitle className="text-balance">Exception Queue</FrameTitle>
|
|
||||||
<FrameDescription className="text-xs text-pretty">
|
|
||||||
{filteredData.length} of {FULFILLMENT_ROWS.length} fulfillment
|
|
||||||
records
|
|
||||||
</FrameDescription>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
toast.info("Create exception", {
|
|
||||||
description:
|
|
||||||
"Connect this button to your incident intake flow.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlusIcon aria-hidden="true" />
|
|
||||||
Add exception
|
|
||||||
</Button>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel className="bg-card p-0! shadow-none!">
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<Toolbar
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
selectedStatuses={selectedStatuses}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
onClearFilters={handleClearFilters}
|
|
||||||
hasActiveFilters={hasActiveFilters}
|
|
||||||
statusCounts={statusCounts}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<DataGridScrollArea>
|
|
||||||
<DataGridTable />
|
|
||||||
</DataGridScrollArea>
|
|
||||||
</FramePanel>
|
|
||||||
<FrameFooter>
|
|
||||||
<DataGridPagination />
|
|
||||||
</FrameFooter>
|
|
||||||
</Frame>
|
|
||||||
</ReuiDataGrid>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
import { type DateRange } from "react-day-picker"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@evobgp/ui/components/popover"
|
|
||||||
import { CalendarIcon, DownloadIcon } from "lucide-react"
|
|
||||||
|
|
||||||
type PeriodKey = "last30" | "prev30"
|
|
||||||
|
|
||||||
type ReportDateRange = {
|
|
||||||
from: Date
|
|
||||||
to: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
type DateRangePreset = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
period: PeriodKey
|
|
||||||
range: ReportDateRange
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportRange = (
|
|
||||||
fromMonth: number,
|
|
||||||
fromDay: number,
|
|
||||||
toMonth: number,
|
|
||||||
toDay: number,
|
|
||||||
year = 2026
|
|
||||||
): ReportDateRange => ({
|
|
||||||
from: new Date(year, fromMonth, fromDay),
|
|
||||||
to: new Date(year, toMonth, toDay),
|
|
||||||
})
|
|
||||||
|
|
||||||
const preset = (
|
|
||||||
id: string,
|
|
||||||
label: string,
|
|
||||||
period: PeriodKey,
|
|
||||||
range: ReportDateRange
|
|
||||||
): DateRangePreset => ({ id, label, period, range })
|
|
||||||
|
|
||||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
|
||||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
|
||||||
|
|
||||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
|
||||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
|
||||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
|
||||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
|
||||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
|
||||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
|
||||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
|
||||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
|
||||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
|
||||||
]
|
|
||||||
|
|
||||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
|
||||||
|
|
||||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
|
||||||
const secondFrom = second.from
|
|
||||||
const secondTo = second.to ?? second.from
|
|
||||||
|
|
||||||
return (
|
|
||||||
Boolean(secondFrom && secondTo) &&
|
|
||||||
first.from.getTime() === secondFrom?.getTime() &&
|
|
||||||
first.to.getTime() === secondTo?.getTime()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRange(
|
|
||||||
range: DateRange | undefined,
|
|
||||||
fallback: ReportDateRange
|
|
||||||
): ReportDateRange {
|
|
||||||
if (!range?.from) return fallback
|
|
||||||
|
|
||||||
const from = range.from
|
|
||||||
const to = range.to ?? range.from
|
|
||||||
|
|
||||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReportRange(range: ReportDateRange) {
|
|
||||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPeriodForRange(range: ReportDateRange) {
|
|
||||||
const matchingPreset = getMatchingPreset(range)
|
|
||||||
|
|
||||||
if (matchingPreset) return matchingPreset.period
|
|
||||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
|
||||||
? "prev30"
|
|
||||||
: "last30"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMatchingPreset(range: DateRange | undefined) {
|
|
||||||
if (!range?.from || !range.to) return undefined
|
|
||||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
|
||||||
|
|
||||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
|
||||||
isSameRange(preset.range, normalizedRange)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportDateRangePicker({
|
|
||||||
period,
|
|
||||||
onPeriodChange,
|
|
||||||
}: {
|
|
||||||
period: PeriodKey
|
|
||||||
onPeriodChange: (value: PeriodKey) => void
|
|
||||||
}) {
|
|
||||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [committedRange, setCommittedRange] =
|
|
||||||
useState<ReportDateRange>(initialRange)
|
|
||||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
|
||||||
initialRange
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
|
||||||
if (nextOpen) {
|
|
||||||
setDraftRange(committedRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(nextOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleApply() {
|
|
||||||
const nextRange = normalizeRange(draftRange, committedRange)
|
|
||||||
|
|
||||||
setCommittedRange(nextRange)
|
|
||||||
onPeriodChange(getPeriodForRange(nextRange))
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{formatReportRange(committedRange)}
|
|
||||||
</span>
|
|
||||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent align="end" className="w-auto p-0">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
|
||||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
|
||||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
|
||||||
const selected = selectedPresetId === preset.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={selected ? "secondary" : "ghost"}
|
|
||||||
className={
|
|
||||||
selected
|
|
||||||
? "justify-start"
|
|
||||||
: "text-muted-foreground justify-start"
|
|
||||||
}
|
|
||||||
onClick={() => setDraftRange(preset.range)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
selected={draftRange}
|
|
||||||
onSelect={setDraftRange}
|
|
||||||
numberOfMonths={2}
|
|
||||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
|
||||||
disabled={{
|
|
||||||
after: MAX_REPORT_DATE,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setDraftRange(committedRange)
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" onClick={handleApply}>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header action controls reused from the solution-agents-8 report toolbar.
|
|
||||||
export function NavbarActions() {
|
|
||||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
|
||||||
|
|
||||||
function handleExport() {
|
|
||||||
toast.success("Export queued", {
|
|
||||||
description: "Fulfillment command report is being prepared.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
|
||||||
|
|
||||||
<Button size="sm" type="button" onClick={handleExport}>
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
<span className="hidden sm:block">Export</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@evobgp/ui/components/breadcrumb"
|
|
||||||
|
|
||||||
// Navbar breadcrumb
|
|
||||||
|
|
||||||
export function NavbarBreadcrumb() {
|
|
||||||
return (
|
|
||||||
<Breadcrumb className="min-w-0">
|
|
||||||
<BreadcrumbList className="flex-nowrap">
|
|
||||||
<BreadcrumbItem className="hidden md:inline-flex">
|
|
||||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
|
|
||||||
<BreadcrumbSeparator className="hidden md:flex" />
|
|
||||||
|
|
||||||
<BreadcrumbItem className="hidden md:inline-flex">
|
|
||||||
<BreadcrumbLink render={<a href="#" />}>Operations</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
|
|
||||||
<BreadcrumbSeparator className="hidden md:flex" />
|
|
||||||
|
|
||||||
<BreadcrumbItem className="min-w-0">
|
|
||||||
<BreadcrumbPage className="truncate">Fulfillment</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarGroup,
|
|
||||||
AvatarGroupCount,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@evobgp/ui/components/avatar"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { Input } from "@evobgp/ui/components/input"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@evobgp/ui/components/popover"
|
|
||||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
|
||||||
import { UserPlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Header presence controls with team avatars and invite action.
|
|
||||||
|
|
||||||
export function NavbarPresence() {
|
|
||||||
const [email, setEmail] = useState("")
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const handleInvite = () => {
|
|
||||||
if (!email.trim()) return
|
|
||||||
setEmail("")
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
|
||||||
<AvatarGroup>
|
|
||||||
{TEAM_MEMBERS.map((member, index) => (
|
|
||||||
<Avatar key={index} size="sm">
|
|
||||||
<AvatarImage src={member.src} alt={member.name} />
|
|
||||||
<AvatarFallback className="text-[9px]! font-medium">
|
|
||||||
{member.initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
<AvatarGroupCount className="text-[10px]! font-medium">
|
|
||||||
+{TEAM_EXTRA_COUNT}
|
|
||||||
</AvatarGroupCount>
|
|
||||||
</AvatarGroup>
|
|
||||||
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Invite team member"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<UserPlusIcon aria-hidden="true" />
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
|
||||||
/>
|
|
||||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
|
||||||
Send invite
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { NavbarActions } from "./navbar-actions"
|
|
||||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
|
||||||
|
|
||||||
// Navbar with breadcrumb and report range actions.
|
|
||||||
|
|
||||||
export function Navbar() {
|
|
||||||
return (
|
|
||||||
<header
|
|
||||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
|
||||||
aria-label="Fulfillment command header"
|
|
||||||
>
|
|
||||||
<NavbarBreadcrumb />
|
|
||||||
|
|
||||||
<NavbarActions />
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Dashboard } from "./components/dashboard"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return (
|
|
||||||
<main className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6">
|
|
||||||
<Dashboard />
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { Area, AreaChart, ResponsiveContainer, Tooltip } from "recharts"
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
|
||||||
|
|
||||||
import { activeUsersData, customersData, revenueData } from "./data"
|
|
||||||
|
|
||||||
// Business metric cards
|
|
||||||
|
|
||||||
const businessCards = [
|
|
||||||
{
|
|
||||||
title: "Revenue",
|
|
||||||
period: "reui.io, 28 days",
|
|
||||||
value: "$6.2K",
|
|
||||||
timestamp: "",
|
|
||||||
data: revenueData,
|
|
||||||
color: "var(--color-emerald-500)",
|
|
||||||
gradientId: "revenueGradient",
|
|
||||||
formatValue: (value: number) => `$${(value / 1000).toFixed(1)}K`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Signups",
|
|
||||||
period: "Last 28 days",
|
|
||||||
value: "4,238",
|
|
||||||
timestamp: "3h ago",
|
|
||||||
data: customersData,
|
|
||||||
color: "var(--color-blue-500)",
|
|
||||||
gradientId: "customersGradient",
|
|
||||||
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Active Licenses",
|
|
||||||
period: "ReUI Cloud, 28 days",
|
|
||||||
value: "4,238",
|
|
||||||
timestamp: "1h ago",
|
|
||||||
data: activeUsersData,
|
|
||||||
color: "var(--color-violet-500)",
|
|
||||||
gradientId: "usersGradient",
|
|
||||||
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Chart() {
|
|
||||||
return (
|
|
||||||
<div className="@container w-full max-w-6xl">
|
|
||||||
<div className="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
|
||||||
{businessCards.map((card) => (
|
|
||||||
<Card key={card.title}>
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-sm font-semibold">{card.title}</div>
|
|
||||||
|
|
||||||
{/* Chart */}
|
|
||||||
<div className="flex items-end justify-between gap-2.5">
|
|
||||||
{/* Value */}
|
|
||||||
<div className="flex flex-col gap-px pb-2">
|
|
||||||
<div className="text-muted-foreground text-xs whitespace-nowrap">
|
|
||||||
{card.period}
|
|
||||||
</div>
|
|
||||||
<div className="text-foreground text-xl font-semibold tracking-tight">
|
|
||||||
{card.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative h-16 w-full max-w-40">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart
|
|
||||||
data={card.data}
|
|
||||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id={card.gradientId}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={card.color}
|
|
||||||
stopOpacity={0.3}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={card.color}
|
|
||||||
stopOpacity={0.05}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
cursor={{
|
|
||||||
stroke: card.color,
|
|
||||||
strokeWidth: 1,
|
|
||||||
strokeDasharray: "2 2",
|
|
||||||
}}
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const value = payload[0].value as number
|
|
||||||
return (
|
|
||||||
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
|
|
||||||
<CardContent className="flex min-w-24 flex-col gap-1 px-2 py-1.5">
|
|
||||||
<span className="text-muted-foreground text-[10px] leading-none font-medium">
|
|
||||||
{card.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-popover-foreground text-xs leading-none font-semibold tabular-nums">
|
|
||||||
{card.formatValue(value)}
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke={card.color}
|
|
||||||
fill={`url(#${card.gradientId})`}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{
|
|
||||||
r: 4,
|
|
||||||
fill: card.color,
|
|
||||||
stroke: "white",
|
|
||||||
strokeWidth: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
import { type ComponentProps } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@evobgp/ui/components/avatar"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
|
||||||
import { type ModuleRecord, type ModuleStatus } from "./data"
|
|
||||||
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
|
|
||||||
|
|
||||||
const moduleStatusVariant: Record<
|
|
||||||
ModuleStatus,
|
|
||||||
ComponentProps<typeof Badge>["variant"]
|
|
||||||
> = {
|
|
||||||
Planned: "info-outline",
|
|
||||||
Backlog: "outline",
|
|
||||||
"In Progress": "warning-outline",
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleStatusDotClass: Record<ModuleStatus, string> = {
|
|
||||||
Planned: "bg-sky-500 dark:bg-sky-400",
|
|
||||||
Backlog: "bg-muted-foreground/50",
|
|
||||||
"In Progress": "bg-amber-500 dark:bg-amber-400",
|
|
||||||
}
|
|
||||||
|
|
||||||
function DotSeparator() {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProgressToneClass(value: number) {
|
|
||||||
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
|
|
||||||
if (value >= 40) return "text-amber-500 dark:text-amber-400"
|
|
||||||
if (value > 0) return "text-sky-500 dark:text-sky-400"
|
|
||||||
|
|
||||||
return "text-muted-foreground/35"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWindowDurationLabel(module: ModuleRecord) {
|
|
||||||
const start = new Date(module.dateStart).getTime()
|
|
||||||
const end = new Date(module.dateEnd).getTime()
|
|
||||||
const dayMs = 24 * 60 * 60 * 1000
|
|
||||||
const days = Math.max(1, Math.round((end - start) / dayMs))
|
|
||||||
|
|
||||||
return `${days}-day window`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCompactDateRange(module: ModuleRecord) {
|
|
||||||
return module.dateRange.replace(/, 2026/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModuleProgress({ module }: { module: ModuleRecord }) {
|
|
||||||
const value = module.progress
|
|
||||||
const radius = 18
|
|
||||||
const circumference = 2 * Math.PI * radius
|
|
||||||
const dashOffset = circumference - (value / 100) * circumference
|
|
||||||
const progressClassName = getProgressToneClass(value)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<div className="relative size-10 shrink-0">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 44 44"
|
|
||||||
className="absolute inset-0 size-10 -rotate-90"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="22"
|
|
||||||
cy="22"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
className="stroke-muted-foreground/20"
|
|
||||||
strokeWidth="3.25"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="22"
|
|
||||||
cy="22"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
className={cn("stroke-current", progressClassName)}
|
|
||||||
strokeWidth="3.25"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={dashOffset}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
|
|
||||||
{value}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="text-foreground text-sm font-medium tabular-nums">
|
|
||||||
{value}% ready
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs tabular-nums">
|
|
||||||
{module.tasksCompleted}/{module.tasksTotal} tasks
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModuleNameCell({ module }: { module: ModuleRecord }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<span className="text-foreground truncate text-sm leading-5 font-medium">
|
|
||||||
{module.name}
|
|
||||||
</span>
|
|
||||||
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
|
|
||||||
<span className="shrink-0">{module.kind}</span>
|
|
||||||
<DotSeparator />
|
|
||||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
||||||
<Avatar className="size-4 shrink-0">
|
|
||||||
{module.owner.avatar ? (
|
|
||||||
<AvatarImage src={module.owner.avatar} alt={module.owner.name} />
|
|
||||||
) : null}
|
|
||||||
<AvatarFallback className="text-[8px]">
|
|
||||||
{module.owner.initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="truncate">{module.owner.name}</span>
|
|
||||||
</span>
|
|
||||||
<DotSeparator />
|
|
||||||
<span className="truncate font-mono tracking-wide">
|
|
||||||
{module.domain}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModuleDateCell({ module }: { module: ModuleRecord }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="text-foreground truncate text-sm font-medium tabular-nums">
|
|
||||||
{getCompactDateRange(module)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
|
|
||||||
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
|
||||||
{getWindowDurationLabel(module)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
|
|
||||||
return (
|
|
||||||
<Badge variant={moduleStatusVariant[module.status]}>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"size-1.5 shrink-0 rounded-full!",
|
|
||||||
moduleStatusDotClass[module.status]
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{module.status}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModuleActions({
|
|
||||||
module,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
module: ModuleRecord
|
|
||||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={`Open ${module.name}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
onAction("open", module)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileTextIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
|
|
||||||
module.name
|
|
||||||
}`}
|
|
||||||
className={cn(module.favorite && "text-amber-500")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
onAction("favorite", module)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StarIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={`More actions for ${module.name}`}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
|
|
||||||
<CopyIcon aria-hidden="true" />
|
|
||||||
Duplicate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => onAction("archive", module)}
|
|
||||||
>
|
|
||||||
<ArchiveIcon aria-hidden="true" />
|
|
||||||
Archive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createModuleGridColumns({
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
|
||||||
}): ColumnDef<ModuleRecord>[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessorKey: "progress",
|
|
||||||
id: "progress",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ModuleProgress module={row.original} />,
|
|
||||||
size: 210,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: false,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Progress",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
id: "name",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ModuleNameCell module={row.original} />,
|
|
||||||
minSize: 300,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: false,
|
|
||||||
meta: {
|
|
||||||
autoSize: true,
|
|
||||||
headerTitle: "Module",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "dateStart",
|
|
||||||
id: "dateStart",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ModuleDateCell module={row.original} />,
|
|
||||||
sortingFn: (rowA, rowB) =>
|
|
||||||
new Date(rowA.original.dateStart).getTime() -
|
|
||||||
new Date(rowB.original.dateStart).getTime(),
|
|
||||||
size: 180,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: false,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Window",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
id: "status",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
|
|
||||||
size: 126,
|
|
||||||
enableSorting: true,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: false,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Status",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} visibility={true} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<ModuleActions module={row.original} onAction={onAction} />
|
|
||||||
),
|
|
||||||
size: 104,
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
enableResizing: false,
|
|
||||||
meta: {
|
|
||||||
headerTitle: "Actions",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Chart } from "./chart"
|
|
||||||
import { ModulesDataGridView } from "./data-grid-view"
|
|
||||||
import { Navbar } from "./navbar"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ReUI operations dashboard: navbar -> metric charts -> module grid.
|
|
||||||
* The sections are copied from reviewed donor blocks.
|
|
||||||
* Customize: swap the records and chart series in data.tsx first.
|
|
||||||
*/
|
|
||||||
export function Dashboard() {
|
|
||||||
return (
|
|
||||||
<div className="bg-background text-foreground flex min-h-svh w-full flex-col">
|
|
||||||
<Navbar />
|
|
||||||
|
|
||||||
<main
|
|
||||||
className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 p-4 pt-6 sm:p-8"
|
|
||||||
aria-labelledby="page-heading"
|
|
||||||
>
|
|
||||||
<h1 id="page-heading" className="sr-only">
|
|
||||||
ReUI Operations Dashboard
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Metric Charts */}
|
|
||||||
<Chart />
|
|
||||||
|
|
||||||
{/* Module Grid */}
|
|
||||||
<ModulesDataGridView />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
|
||||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
|
||||||
import {
|
|
||||||
DataGridTable,
|
|
||||||
DataGridTableHeader,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupButton,
|
|
||||||
InputGroupInput,
|
|
||||||
} from "@evobgp/ui/components/input-group"
|
|
||||||
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
|
|
||||||
import {
|
|
||||||
MODULE_RECORDS,
|
|
||||||
MODULE_STATUS_OPTIONS,
|
|
||||||
type ModuleRecord,
|
|
||||||
type ModuleStatus,
|
|
||||||
} from "./data"
|
|
||||||
import { CircleCheckIcon, FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
|
|
||||||
|
|
||||||
type ModuleSort = "name" | "dateStart" | "progress" | "status"
|
|
||||||
|
|
||||||
const sortLabels: Record<ModuleSort, string> = {
|
|
||||||
name: "Name",
|
|
||||||
dateStart: "Window",
|
|
||||||
progress: "Progress",
|
|
||||||
status: "Status",
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_MODULE_MESSAGE = "No ReUI modules match the selected filters."
|
|
||||||
|
|
||||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
|
||||||
return [{ id: sortBy, desc: false }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModuleSearchBlob(module: ModuleRecord) {
|
|
||||||
return [
|
|
||||||
module.name,
|
|
||||||
module.id,
|
|
||||||
module.kind,
|
|
||||||
module.domain,
|
|
||||||
module.owner.name,
|
|
||||||
module.owner.role,
|
|
||||||
module.health,
|
|
||||||
module.status,
|
|
||||||
module.dateRange,
|
|
||||||
]
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModulesDataGridView() {
|
|
||||||
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
})
|
|
||||||
const [sortBy, setSortBy] = useState<ModuleSort>("name")
|
|
||||||
const [sorting, setSorting] = useState<SortingState>(() =>
|
|
||||||
buildSorting("name")
|
|
||||||
)
|
|
||||||
|
|
||||||
const filteredModules = useMemo(() => {
|
|
||||||
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
|
|
||||||
|
|
||||||
return modules.filter((module) => {
|
|
||||||
const matchesSearch =
|
|
||||||
normalizedSearchQuery.length === 0 ||
|
|
||||||
getModuleSearchBlob(module).includes(normalizedSearchQuery)
|
|
||||||
const matchesStatus =
|
|
||||||
selectedStatuses.length === 0 ||
|
|
||||||
selectedStatuses.includes(module.status)
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus
|
|
||||||
})
|
|
||||||
}, [modules, searchQuery, selectedStatuses])
|
|
||||||
|
|
||||||
const activeFilterCount = selectedStatuses.length
|
|
||||||
|
|
||||||
const resetPagination = useCallback(() => {
|
|
||||||
setPagination((current) => ({
|
|
||||||
...current,
|
|
||||||
pageIndex: 0,
|
|
||||||
}))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSearchChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setSearchQuery(value)
|
|
||||||
resetPagination()
|
|
||||||
},
|
|
||||||
[resetPagination]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleStatusToggle = useCallback(
|
|
||||||
(status: ModuleStatus, checked: boolean) => {
|
|
||||||
setSelectedStatuses((current) => {
|
|
||||||
if (checked) {
|
|
||||||
return current.includes(status) ? current : [...current, status]
|
|
||||||
}
|
|
||||||
|
|
||||||
return current.filter((item) => item !== status)
|
|
||||||
})
|
|
||||||
resetPagination()
|
|
||||||
},
|
|
||||||
[resetPagination]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSortChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const nextSort = value as ModuleSort
|
|
||||||
setSortBy(nextSort)
|
|
||||||
setSorting(buildSorting(nextSort))
|
|
||||||
resetPagination()
|
|
||||||
},
|
|
||||||
[resetPagination]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleModuleAction = useCallback(
|
|
||||||
(action: ModuleRowAction, module: ModuleRecord) => {
|
|
||||||
if (action === "favorite") {
|
|
||||||
setModules((current) =>
|
|
||||||
current.map((item) =>
|
|
||||||
item.id === module.id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
favorite: !item.favorite,
|
|
||||||
}
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
)
|
|
||||||
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
|
|
||||||
description: module.name,
|
|
||||||
icon: (
|
|
||||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "open") {
|
|
||||||
toast.info("Open ReUI module", {
|
|
||||||
description: `${module.name} (${module.kind})`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.message(
|
|
||||||
action === "duplicate" ? "Duplicate module" : "Archive module",
|
|
||||||
{
|
|
||||||
description: `Connect this action to your ${module.name} flow.`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleAddModule = () => {
|
|
||||||
toast.success("Add ReUI module", {
|
|
||||||
description: "Open your module creation dialog.",
|
|
||||||
icon: (
|
|
||||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() => createModuleGridColumns({ onAction: handleModuleAction }),
|
|
||||||
[handleModuleAction]
|
|
||||||
)
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
|
||||||
const table = useReactTable({
|
|
||||||
data: filteredModules,
|
|
||||||
columns,
|
|
||||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
|
||||||
state: {
|
|
||||||
pagination,
|
|
||||||
sorting,
|
|
||||||
},
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGrid
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredModules.length}
|
|
||||||
emptyMessage={EMPTY_MODULE_MESSAGE}
|
|
||||||
tableLayout={{
|
|
||||||
dense: true,
|
|
||||||
rowBorder: true,
|
|
||||||
headerSticky: false,
|
|
||||||
columnsVisibility: false,
|
|
||||||
columnsResizable: false,
|
|
||||||
columnsMovable: false,
|
|
||||||
width: "fixed",
|
|
||||||
}}
|
|
||||||
tableClassNames={{
|
|
||||||
bodyRow: "group/module-row [&>td]:h-16",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex w-full max-w-6xl flex-col">
|
|
||||||
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
|
|
||||||
<span className="text-muted-foreground truncate text-sm">ReUI</span>
|
|
||||||
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
|
||||||
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
|
||||||
<h2 className="text-foreground truncate text-sm font-medium">
|
|
||||||
Modules
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
|
|
||||||
<InputGroup className="w-full min-w-40 sm:w-48">
|
|
||||||
<InputGroupAddon align="inline-start">
|
|
||||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(event) => handleSearchChange(event.target.value)}
|
|
||||||
placeholder="Search..."
|
|
||||||
aria-label="Search modules"
|
|
||||||
/>
|
|
||||||
{searchQuery.length > 0 ? (
|
|
||||||
<InputGroupAddon align="inline-end">
|
|
||||||
<InputGroupButton
|
|
||||||
size="icon-xs"
|
|
||||||
aria-label="Clear search"
|
|
||||||
onClick={() => handleSearchChange("")}
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" aria-hidden="true" />
|
|
||||||
</InputGroupButton>
|
|
||||||
</InputGroupAddon>
|
|
||||||
) : null}
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button type="button" variant="outline">
|
|
||||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
|
|
||||||
{sortLabels[sortBy]}
|
|
||||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-44">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{(["name", "dateStart", "progress", "status"] as const).map(
|
|
||||||
(value) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={value}
|
|
||||||
onClick={() => handleSortChange(value)}
|
|
||||||
>
|
|
||||||
{sortLabels[value]}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button type="button" variant="outline">
|
|
||||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
|
||||||
Filters
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<Badge variant="outline" radius="full">
|
|
||||||
{activeFilterCount}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-48">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
|
||||||
{MODULE_STATUS_OPTIONS.map((status) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={status}
|
|
||||||
checked={selectedStatuses.includes(status)}
|
|
||||||
closeOnClick={false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleStatusToggle(status, checked === true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
closeOnClick={false}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedStatuses([])
|
|
||||||
resetPagination()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset filters
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button type="button" onClick={handleAddModule}>
|
|
||||||
Add Module
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredModules.length > 0 ? (
|
|
||||||
<DataGridScrollArea>
|
|
||||||
<DataGridTable />
|
|
||||||
</DataGridScrollArea>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DataGridScrollArea>
|
|
||||||
<DataGridTableHeader />
|
|
||||||
</DataGridScrollArea>
|
|
||||||
<div className="text-muted-foreground flex min-h-48 w-full items-center justify-center px-4 text-center text-sm">
|
|
||||||
{EMPTY_MODULE_MESSAGE}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t px-0 py-3">
|
|
||||||
{filteredModules.length > 0 ? (
|
|
||||||
<DataGridPagination
|
|
||||||
sizes={[10, 15, 20]}
|
|
||||||
info="{from} - {to} of {count} modules"
|
|
||||||
className="py-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground text-center text-sm">
|
|
||||||
0 modules
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DataGrid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
|
|
||||||
|
|
||||||
export type ModuleKind = "System" | "Feature" | "Area"
|
|
||||||
|
|
||||||
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
|
|
||||||
|
|
||||||
export interface ModuleOwner {
|
|
||||||
name: string
|
|
||||||
initials: string
|
|
||||||
role: string
|
|
||||||
avatar?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModuleRecord {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
kind: ModuleKind
|
|
||||||
owner: ModuleOwner
|
|
||||||
domain: string
|
|
||||||
progress: number
|
|
||||||
tasksCompleted: number
|
|
||||||
tasksTotal: number
|
|
||||||
contributors: number
|
|
||||||
blockers: number
|
|
||||||
health: ModuleHealth
|
|
||||||
dateStart: string
|
|
||||||
dateEnd: string
|
|
||||||
dateRange: string
|
|
||||||
status: ModuleStatus
|
|
||||||
favorite: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
|
|
||||||
"Planned",
|
|
||||||
"Backlog",
|
|
||||||
"In Progress",
|
|
||||||
]
|
|
||||||
|
|
||||||
const moduleOwners = {
|
|
||||||
maya: {
|
|
||||||
name: "Nora Vale",
|
|
||||||
initials: "NV",
|
|
||||||
role: "ReUI release lead",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
jonah: {
|
|
||||||
name: "Jonah Lee",
|
|
||||||
initials: "JL",
|
|
||||||
role: "ReUI product ops",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
nina: {
|
|
||||||
name: "Nina Santos",
|
|
||||||
initials: "NS",
|
|
||||||
role: "Docs owner",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
elijah: {
|
|
||||||
name: "Elijah Morgan",
|
|
||||||
initials: "EM",
|
|
||||||
role: "License lead",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
priya: {
|
|
||||||
name: "Priya Shah",
|
|
||||||
initials: "PS",
|
|
||||||
role: "Lifecycle PM",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
omar: {
|
|
||||||
name: "Omar Haddad",
|
|
||||||
initials: "OH",
|
|
||||||
role: "Trust owner",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
sofia: {
|
|
||||||
name: "Sofia Romero",
|
|
||||||
initials: "SR",
|
|
||||||
role: "Content lead",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
kenji: {
|
|
||||||
name: "Kenji Tan",
|
|
||||||
initials: "KT",
|
|
||||||
role: "Platform lead",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
|
||||||
},
|
|
||||||
lena: {
|
|
||||||
name: "Lena Wade",
|
|
||||||
initials: "LW",
|
|
||||||
role: "Developer tools",
|
|
||||||
},
|
|
||||||
} satisfies Record<string, ModuleOwner>
|
|
||||||
|
|
||||||
export const MODULE_RECORDS: ModuleRecord[] = [
|
|
||||||
{
|
|
||||||
id: "core-workflow",
|
|
||||||
name: "Registry Sync",
|
|
||||||
kind: "System",
|
|
||||||
owner: moduleOwners.maya,
|
|
||||||
domain: "registry.reui.io",
|
|
||||||
progress: 25,
|
|
||||||
tasksCompleted: 8,
|
|
||||||
tasksTotal: 32,
|
|
||||||
contributors: 6,
|
|
||||||
blockers: 0,
|
|
||||||
health: "On Track",
|
|
||||||
dateStart: "2026-04-17",
|
|
||||||
dateEnd: "2026-05-01",
|
|
||||||
dateRange: "Apr 17 - May 01, 2026",
|
|
||||||
status: "Planned",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "onboarding-flow",
|
|
||||||
name: "Pro Onboarding",
|
|
||||||
kind: "Feature",
|
|
||||||
owner: moduleOwners.jonah,
|
|
||||||
domain: "pro.reui.io",
|
|
||||||
progress: 0,
|
|
||||||
tasksCompleted: 0,
|
|
||||||
tasksTotal: 18,
|
|
||||||
contributors: 4,
|
|
||||||
blockers: 0,
|
|
||||||
health: "Watch",
|
|
||||||
dateStart: "2026-04-19",
|
|
||||||
dateEnd: "2026-05-03",
|
|
||||||
dateRange: "Apr 19 - May 03, 2026",
|
|
||||||
status: "Backlog",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "workspace-setup",
|
|
||||||
name: "Docs Portal",
|
|
||||||
kind: "Area",
|
|
||||||
owner: moduleOwners.nina,
|
|
||||||
domain: "docs.reui.io",
|
|
||||||
progress: 0,
|
|
||||||
tasksCompleted: 2,
|
|
||||||
tasksTotal: 14,
|
|
||||||
contributors: 3,
|
|
||||||
blockers: 1,
|
|
||||||
health: "Blocked",
|
|
||||||
dateStart: "2026-04-21",
|
|
||||||
dateEnd: "2026-05-05",
|
|
||||||
dateRange: "Apr 21 - May 05, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "permission-matrix",
|
|
||||||
name: "Access Matrix",
|
|
||||||
kind: "System",
|
|
||||||
owner: moduleOwners.maya,
|
|
||||||
domain: "admin.reui.io",
|
|
||||||
progress: 42,
|
|
||||||
tasksCompleted: 11,
|
|
||||||
tasksTotal: 26,
|
|
||||||
contributors: 5,
|
|
||||||
blockers: 0,
|
|
||||||
health: "Watch",
|
|
||||||
dateStart: "2026-04-22",
|
|
||||||
dateEnd: "2026-05-06",
|
|
||||||
dateRange: "Apr 22 - May 06, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "billing-rules",
|
|
||||||
name: "License Billing",
|
|
||||||
kind: "Feature",
|
|
||||||
owner: moduleOwners.elijah,
|
|
||||||
domain: "billing.reui.io",
|
|
||||||
progress: 64,
|
|
||||||
tasksCompleted: 21,
|
|
||||||
tasksTotal: 33,
|
|
||||||
contributors: 7,
|
|
||||||
blockers: 0,
|
|
||||||
health: "On Track",
|
|
||||||
dateStart: "2026-04-18",
|
|
||||||
dateEnd: "2026-05-02",
|
|
||||||
dateRange: "Apr 18 - May 02, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notification-center",
|
|
||||||
name: "Release Notes",
|
|
||||||
kind: "Area",
|
|
||||||
owner: moduleOwners.priya,
|
|
||||||
domain: "changelog.reui.io",
|
|
||||||
progress: 18,
|
|
||||||
tasksCompleted: 5,
|
|
||||||
tasksTotal: 28,
|
|
||||||
contributors: 4,
|
|
||||||
blockers: 2,
|
|
||||||
health: "Blocked",
|
|
||||||
dateStart: "2026-04-23",
|
|
||||||
dateEnd: "2026-05-09",
|
|
||||||
dateRange: "Apr 23 - May 09, 2026",
|
|
||||||
status: "Backlog",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "audit-trail",
|
|
||||||
name: "Trust Audit",
|
|
||||||
kind: "System",
|
|
||||||
owner: moduleOwners.omar,
|
|
||||||
domain: "trust.reui.io",
|
|
||||||
progress: 76,
|
|
||||||
tasksCompleted: 19,
|
|
||||||
tasksTotal: 25,
|
|
||||||
contributors: 5,
|
|
||||||
blockers: 0,
|
|
||||||
health: "On Track",
|
|
||||||
dateStart: "2026-04-15",
|
|
||||||
dateEnd: "2026-04-30",
|
|
||||||
dateRange: "Apr 15 - Apr 30, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "template-library",
|
|
||||||
name: "Block Library",
|
|
||||||
kind: "Feature",
|
|
||||||
owner: moduleOwners.sofia,
|
|
||||||
domain: "blocks.reui.io",
|
|
||||||
progress: 33,
|
|
||||||
tasksCompleted: 10,
|
|
||||||
tasksTotal: 30,
|
|
||||||
contributors: 6,
|
|
||||||
blockers: 0,
|
|
||||||
health: "Watch",
|
|
||||||
dateStart: "2026-04-24",
|
|
||||||
dateEnd: "2026-05-10",
|
|
||||||
dateRange: "Apr 24 - May 10, 2026",
|
|
||||||
status: "Planned",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "integration-hub",
|
|
||||||
name: "Integration Hub",
|
|
||||||
kind: "Area",
|
|
||||||
owner: moduleOwners.kenji,
|
|
||||||
domain: "integrations.reui.io",
|
|
||||||
progress: 58,
|
|
||||||
tasksCompleted: 14,
|
|
||||||
tasksTotal: 24,
|
|
||||||
contributors: 8,
|
|
||||||
blockers: 1,
|
|
||||||
health: "Watch",
|
|
||||||
dateStart: "2026-04-20",
|
|
||||||
dateEnd: "2026-05-04",
|
|
||||||
dateRange: "Apr 20 - May 04, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "api-console",
|
|
||||||
name: "API Console",
|
|
||||||
kind: "Feature",
|
|
||||||
owner: moduleOwners.lena,
|
|
||||||
domain: "api.reui.io",
|
|
||||||
progress: 91,
|
|
||||||
tasksCompleted: 29,
|
|
||||||
tasksTotal: 32,
|
|
||||||
contributors: 4,
|
|
||||||
blockers: 0,
|
|
||||||
health: "On Track",
|
|
||||||
dateStart: "2026-04-12",
|
|
||||||
dateEnd: "2026-04-26",
|
|
||||||
dateRange: "Apr 12 - Apr 26, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "role-automation",
|
|
||||||
name: "Role Automation",
|
|
||||||
kind: "System",
|
|
||||||
owner: moduleOwners.maya,
|
|
||||||
domain: "admin.reui.io",
|
|
||||||
progress: 12,
|
|
||||||
tasksCompleted: 3,
|
|
||||||
tasksTotal: 25,
|
|
||||||
contributors: 3,
|
|
||||||
blockers: 1,
|
|
||||||
health: "Blocked",
|
|
||||||
dateStart: "2026-04-25",
|
|
||||||
dateEnd: "2026-05-12",
|
|
||||||
dateRange: "Apr 25 - May 12, 2026",
|
|
||||||
status: "Backlog",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "workspace-invites",
|
|
||||||
name: "Team Invites",
|
|
||||||
kind: "Feature",
|
|
||||||
owner: moduleOwners.jonah,
|
|
||||||
domain: "teams.reui.io",
|
|
||||||
progress: 47,
|
|
||||||
tasksCompleted: 15,
|
|
||||||
tasksTotal: 32,
|
|
||||||
contributors: 5,
|
|
||||||
blockers: 0,
|
|
||||||
health: "Watch",
|
|
||||||
dateStart: "2026-04-19",
|
|
||||||
dateEnd: "2026-05-06",
|
|
||||||
dateRange: "Apr 19 - May 06, 2026",
|
|
||||||
status: "Planned",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "release-checklist",
|
|
||||||
name: "Release Checklist",
|
|
||||||
kind: "Area",
|
|
||||||
owner: moduleOwners.nina,
|
|
||||||
domain: "release.reui.io",
|
|
||||||
progress: 84,
|
|
||||||
tasksCompleted: 26,
|
|
||||||
tasksTotal: 31,
|
|
||||||
contributors: 7,
|
|
||||||
blockers: 0,
|
|
||||||
health: "On Track",
|
|
||||||
dateStart: "2026-04-16",
|
|
||||||
dateEnd: "2026-05-01",
|
|
||||||
dateRange: "Apr 16 - May 01, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "reporting-digest",
|
|
||||||
name: "Usage Digest",
|
|
||||||
kind: "Feature",
|
|
||||||
owner: moduleOwners.priya,
|
|
||||||
domain: "reports.reui.io",
|
|
||||||
progress: 5,
|
|
||||||
tasksCompleted: 2,
|
|
||||||
tasksTotal: 38,
|
|
||||||
contributors: 3,
|
|
||||||
blockers: 0,
|
|
||||||
health: "Watch",
|
|
||||||
dateStart: "2026-04-28",
|
|
||||||
dateEnd: "2026-05-16",
|
|
||||||
dateRange: "Apr 28 - May 16, 2026",
|
|
||||||
status: "Backlog",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "security-review",
|
|
||||||
name: "Security Review",
|
|
||||||
kind: "System",
|
|
||||||
owner: moduleOwners.omar,
|
|
||||||
domain: "trust.reui.io",
|
|
||||||
progress: 69,
|
|
||||||
tasksCompleted: 18,
|
|
||||||
tasksTotal: 26,
|
|
||||||
contributors: 6,
|
|
||||||
blockers: 2,
|
|
||||||
health: "Blocked",
|
|
||||||
dateStart: "2026-04-18",
|
|
||||||
dateEnd: "2026-05-07",
|
|
||||||
dateRange: "Apr 18 - May 07, 2026",
|
|
||||||
status: "In Progress",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "help-center",
|
|
||||||
name: "Help Center",
|
|
||||||
kind: "Area",
|
|
||||||
owner: moduleOwners.sofia,
|
|
||||||
domain: "help.reui.io",
|
|
||||||
progress: 39,
|
|
||||||
tasksCompleted: 9,
|
|
||||||
tasksTotal: 23,
|
|
||||||
contributors: 4,
|
|
||||||
blockers: 0,
|
|
||||||
health: "On Track",
|
|
||||||
dateStart: "2026-04-23",
|
|
||||||
dateEnd: "2026-05-11",
|
|
||||||
dateRange: "Apr 23 - May 11, 2026",
|
|
||||||
status: "Planned",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "data-retention",
|
|
||||||
name: "Data Retention",
|
|
||||||
kind: "System",
|
|
||||||
owner: moduleOwners.kenji,
|
|
||||||
domain: "privacy.reui.io",
|
|
||||||
progress: 22,
|
|
||||||
tasksCompleted: 7,
|
|
||||||
tasksTotal: 32,
|
|
||||||
contributors: 5,
|
|
||||||
blockers: 1,
|
|
||||||
health: "Watch",
|
|
||||||
dateStart: "2026-04-27",
|
|
||||||
dateEnd: "2026-05-14",
|
|
||||||
dateRange: "Apr 27 - May 14, 2026",
|
|
||||||
status: "Backlog",
|
|
||||||
favorite: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const revenueData = [
|
|
||||||
{ value: 1000 },
|
|
||||||
{ value: 4500 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 5200 },
|
|
||||||
{ value: 1500 },
|
|
||||||
{ value: 6100 },
|
|
||||||
{ value: 3000 },
|
|
||||||
{ value: 6800 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 1000 },
|
|
||||||
{ value: 4000 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 3000 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 6238 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const customersData = [
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 4500 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 5200 },
|
|
||||||
{ value: 1500 },
|
|
||||||
{ value: 5100 },
|
|
||||||
{ value: 2500 },
|
|
||||||
{ value: 6800 },
|
|
||||||
{ value: 1800 },
|
|
||||||
{ value: 1000 },
|
|
||||||
{ value: 3000 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 2700 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 4238 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const activeUsersData = [
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 3500 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 5200 },
|
|
||||||
{ value: 1200 },
|
|
||||||
{ value: 4100 },
|
|
||||||
{ value: 3500 },
|
|
||||||
{ value: 5800 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 800 },
|
|
||||||
{ value: 3000 },
|
|
||||||
{ value: 1000 },
|
|
||||||
{ value: 4000 },
|
|
||||||
{ value: 2000 },
|
|
||||||
{ value: 4238 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export type TeamMember = {
|
|
||||||
src: string
|
|
||||||
name: string
|
|
||||||
initials: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TEAM_MEMBERS: TeamMember[] = [
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
|
||||||
name: "Mira Stone",
|
|
||||||
initials: "MS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
|
|
||||||
name: "Alex Johnson",
|
|
||||||
initials: "AJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
|
||||||
name: "Sarah Chen",
|
|
||||||
initials: "SC",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const TEAM_EXTRA_COUNT = 8
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
|
||||||
import { PlusIcon, MoreHorizontalIcon, CopyIcon, Share2Icon, DownloadIcon, SettingsIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Navbar actions with a primary module action and overflow menu.
|
|
||||||
|
|
||||||
export function NavbarActions() {
|
|
||||||
const handleAddModule = () => {
|
|
||||||
toast.success("Add Module", {
|
|
||||||
description: "Open your module creation dialog.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button type="button" onClick={handleAddModule}>
|
|
||||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
|
||||||
<span className="sr-only md:not-sr-only">Add Module</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="ghost" size="icon" aria-label="More options" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent sideOffset={7} align="end">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<CopyIcon aria-hidden="true" />
|
|
||||||
Copy link
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Share2Icon aria-hidden="true" />
|
|
||||||
Share
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
Export
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@evobgp/ui/components/breadcrumb"
|
|
||||||
import { HouseIcon, LayoutDashboardIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Navbar breadcrumb
|
|
||||||
|
|
||||||
export function NavbarBreadcrumb() {
|
|
||||||
return (
|
|
||||||
<Breadcrumb>
|
|
||||||
{/* List */}
|
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbLink href="#" className="flex items-center md:gap-1.5">
|
|
||||||
<HouseIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
|
||||||
<span className="hidden md:block">Home</span>
|
|
||||||
</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
|
|
||||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
|
||||||
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
|
|
||||||
<LayoutDashboardIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
|
||||||
<span className="hidden md:block">ReUI</span>
|
|
||||||
</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarGroup,
|
|
||||||
AvatarGroupCount,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@evobgp/ui/components/avatar"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { Input } from "@evobgp/ui/components/input"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@evobgp/ui/components/popover"
|
|
||||||
import { Separator } from "@evobgp/ui/components/separator"
|
|
||||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
|
||||||
import { UserPlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Navbar presence with team avatars and invite
|
|
||||||
|
|
||||||
export function NavbarPresence() {
|
|
||||||
const [email, setEmail] = useState("")
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const handleInvite = () => {
|
|
||||||
if (!email.trim()) return
|
|
||||||
setEmail("")
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{/* List */}
|
|
||||||
<AvatarGroup>
|
|
||||||
{TEAM_MEMBERS.map((member, index) => (
|
|
||||||
<Avatar key={index} size="sm">
|
|
||||||
<AvatarImage src={member.src} alt={member.name} />
|
|
||||||
<AvatarFallback className="text-[8px]">
|
|
||||||
{member.initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
<AvatarGroupCount>+{TEAM_EXTRA_COUNT}</AvatarGroupCount>
|
|
||||||
</AvatarGroup>
|
|
||||||
|
|
||||||
<Separator orientation="vertical" className="my-auto h-4" />
|
|
||||||
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={<Button variant="outline" aria-label="Invite team member" />}
|
|
||||||
>
|
|
||||||
<UserPlusIcon aria-hidden="true" />
|
|
||||||
<span className="hidden md:block">Invite</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
|
||||||
/>
|
|
||||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
|
||||||
Send invite
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { NavbarActions } from "./navbar-actions"
|
|
||||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
|
||||||
import { NavbarPresence } from "./navbar-presence"
|
|
||||||
|
|
||||||
// Navbar with breadcrumb, team presence, and actions
|
|
||||||
|
|
||||||
export function Navbar() {
|
|
||||||
return (
|
|
||||||
<header className="border-border bg-background sticky top-0 z-20 flex h-12 w-full shrink-0 items-center justify-between gap-2 border-b px-4">
|
|
||||||
{/* Left - breadcrumb */}
|
|
||||||
<NavbarBreadcrumb />
|
|
||||||
|
|
||||||
{/* Right - team presence + actions */}
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<NavbarPresence />
|
|
||||||
<NavbarActions />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Dashboard } from "./components/dashboard"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return <Dashboard />
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
|
|
||||||
// Deterministic per-dot value so the field reads as noise, not a flat grid.
|
|
||||||
function grain(i: number, j: number) {
|
|
||||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
|
||||||
return n - Math.floor(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static dot field adapted from card-5's reviewed background effect.
|
|
||||||
* The dots resolve from the current text color, so the surface stays
|
|
||||||
* neutral and theme-aware without hard-coded colors.
|
|
||||||
*/
|
|
||||||
export function CardDotField({ className }: { className?: string }) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current
|
|
||||||
if (!canvas) return
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
const GAP = 3
|
|
||||||
const DOT = 1.5
|
|
||||||
const BASE = 0.03
|
|
||||||
const PEAK = 0.2
|
|
||||||
|
|
||||||
const draw = () => {
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
|
||||||
if (!rect.width || !rect.height) return
|
|
||||||
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
|
||||||
canvas.width = Math.round(rect.width * dpr)
|
|
||||||
canvas.height = Math.round(rect.height * dpr)
|
|
||||||
|
|
||||||
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
|
|
||||||
ctx.fillRect(0, 0, 1, 1)
|
|
||||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
|
||||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
|
||||||
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
||||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
|
||||||
ctx.fillStyle = color
|
|
||||||
|
|
||||||
const cols = Math.ceil(rect.width / GAP) + 1
|
|
||||||
const rows = Math.ceil(rect.height / GAP) + 1
|
|
||||||
for (let i = 0; i < cols; i++) {
|
|
||||||
const x = i * GAP
|
|
||||||
for (let j = 0; j < rows; j++) {
|
|
||||||
const q = grain(i, j)
|
|
||||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
|
||||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
|
||||||
if (a > 1) a = 1
|
|
||||||
ctx.globalAlpha = a
|
|
||||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
draw()
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => draw())
|
|
||||||
resizeObserver.observe(canvas)
|
|
||||||
|
|
||||||
const themeObserver = new MutationObserver(() => draw())
|
|
||||||
themeObserver.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class", "style"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect()
|
|
||||||
themeObserver.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute inset-0 h-full w-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
|
||||||
|
|
||||||
type TooltipItem = {
|
|
||||||
value?: unknown
|
|
||||||
name?: unknown
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChartTooltip({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
active?: boolean
|
|
||||||
payload?: readonly TooltipItem[]
|
|
||||||
label?: string | number
|
|
||||||
}) {
|
|
||||||
if (!active || !payload?.length) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
|
|
||||||
<CardContent className="min-w-28 space-y-1.5 px-2 py-1.5">
|
|
||||||
<div className="text-muted-foreground text-[10px] leading-none">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{payload.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={`${String(item.name)}-${index}`}
|
|
||||||
className="flex items-center justify-between gap-3 text-xs leading-none"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="size-1.5 rounded-full"
|
|
||||||
style={{ backgroundColor: item.color }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{String(item.name)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold tabular-nums">
|
|
||||||
{Number(item.value ?? 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { type ComponentProps } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
|
|
||||||
export type MetricTone = "danger" | "success" | "warning" | "info"
|
|
||||||
|
|
||||||
export type MetricCard = {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
delta: string
|
|
||||||
deltaVariant: ComponentProps<typeof Badge>["variant"]
|
|
||||||
detail: string
|
|
||||||
tone: MetricTone
|
|
||||||
sparkline: number[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metricCards: MetricCard[] = [
|
|
||||||
{
|
|
||||||
id: "signal-risk",
|
|
||||||
title: "Signal Risk",
|
|
||||||
label: "Threat Index",
|
|
||||||
value: "Elevated",
|
|
||||||
delta: "+12.5%",
|
|
||||||
deltaVariant: "destructive-light",
|
|
||||||
detail: "5 cases",
|
|
||||||
tone: "danger",
|
|
||||||
sparkline: [18, 21, 15, 33, 29, 35, 28, 31, 19],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mesh-uptime",
|
|
||||||
title: "Mesh Uptime",
|
|
||||||
label: "Service Health",
|
|
||||||
value: "99.98%",
|
|
||||||
delta: "+0.2%",
|
|
||||||
deltaVariant: "success-light",
|
|
||||||
detail: "58 zones",
|
|
||||||
tone: "success",
|
|
||||||
sparkline: [42, 40, 42, 38, 39, 41, 40, 43, 46],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "edge-traffic",
|
|
||||||
title: "Edge Traffic",
|
|
||||||
label: "Scrubbed Load",
|
|
||||||
value: "4.8 GB/s",
|
|
||||||
delta: "-3.1%",
|
|
||||||
deltaVariant: "warning-light",
|
|
||||||
detail: "clean flow",
|
|
||||||
tone: "warning",
|
|
||||||
sparkline: [21, 27, 28, 34, 32, 35, 33, 31, 34],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sensor-reach",
|
|
||||||
title: "Sensor Reach",
|
|
||||||
label: "Global Nodes",
|
|
||||||
value: "18,420",
|
|
||||||
delta: "+6.8%",
|
|
||||||
deltaVariant: "success-light",
|
|
||||||
detail: "93 regions",
|
|
||||||
tone: "success",
|
|
||||||
sparkline: [25, 23, 29, 28, 26, 31, 27, 30, 29],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const threatVectors = [
|
|
||||||
{ name: "Bot", blocked: 34, watched: 62 },
|
|
||||||
{ name: "Phish", blocked: 30, watched: 74 },
|
|
||||||
{ name: "DDoS", blocked: 52, watched: 33 },
|
|
||||||
{ name: "Inject", blocked: 22, watched: 48 },
|
|
||||||
{ name: "Auth", blocked: 43, watched: 68 },
|
|
||||||
{ name: "Probe", blocked: 18, watched: 58 },
|
|
||||||
{ name: "Exfil", blocked: 56, watched: 35 },
|
|
||||||
{ name: "Beacon", blocked: 38, watched: 64 },
|
|
||||||
{ name: "Day0", blocked: 14, watched: 28 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const networkFlow = [
|
|
||||||
{ month: "January", api: 1820, webhook: 1640 },
|
|
||||||
{ month: "February", api: 2340, webhook: 2160 },
|
|
||||||
{ month: "March", api: 1960, webhook: 1880 },
|
|
||||||
{ month: "April", api: 2780, webhook: 2540 },
|
|
||||||
{ month: "May", api: 2100, webhook: 1920 },
|
|
||||||
{ month: "June", api: 3120, webhook: 2880 },
|
|
||||||
{ month: "July", api: 2540, webhook: 2320 },
|
|
||||||
{ month: "August", api: 3480, webhook: 3160 },
|
|
||||||
{ month: "September", api: 2860, webhook: 2580 },
|
|
||||||
{ month: "October", api: 2420, webhook: 2140 },
|
|
||||||
{ month: "November", api: 3240, webhook: 2960 },
|
|
||||||
{ month: "December", api: 2680, webhook: 2440 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const networkFlowSummary = {
|
|
||||||
change: "+12.8%",
|
|
||||||
year: "2026",
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSamples = [
|
|
||||||
72, 68, 22, 18, 61, 71, 20, 26, 31, 70, 46, 88, 39, 25, 12, 33, 18, 28, 42,
|
|
||||||
36, 61, 68, 74, 70, 91, 32, 82, 66, 52, 76, 48, 35, 70, 62, 57, 49, 37, 58,
|
|
||||||
71, 7, 11, 69, 34, 28, 40, 61, 17, 55, 64, 19, 63, 67,
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type LoadState = "nominal" | "warm" | "critical"
|
|
||||||
|
|
||||||
export type LoadPoint = {
|
|
||||||
node: string
|
|
||||||
load: number
|
|
||||||
state: LoadState
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadDistribution: LoadPoint[] = loadSamples.map((load, index) => ({
|
|
||||||
node: `N${String(index).padStart(2, "0")}`,
|
|
||||||
load,
|
|
||||||
state: load >= 86 ? "critical" : load >= 76 ? "warm" : "nominal",
|
|
||||||
}))
|
|
||||||
|
|
||||||
const loadStateTotals = loadDistribution.reduce<Record<LoadState, number>>(
|
|
||||||
(totals, point) => ({
|
|
||||||
...totals,
|
|
||||||
[point.state]: totals[point.state] + 1,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
nominal: 0,
|
|
||||||
warm: 0,
|
|
||||||
critical: 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export const clusterLoadSummary = {
|
|
||||||
details: [
|
|
||||||
{
|
|
||||||
label: "Nodes",
|
|
||||||
value: String(loadDistribution.length),
|
|
||||||
variant: "secondary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Warm",
|
|
||||||
value: String(loadStateTotals.warm),
|
|
||||||
variant: "warning-light",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Critical",
|
|
||||||
value: String(loadStateTotals.critical),
|
|
||||||
variant: "destructive-light",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies {
|
|
||||||
details: Array<{
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
variant: ComponentProps<typeof Badge>["variant"]
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const activeThreats = [
|
|
||||||
{
|
|
||||||
id: "00",
|
|
||||||
label: "API Flood",
|
|
||||||
source: "Edge WAF",
|
|
||||||
state: "Mitigating",
|
|
||||||
value: 4521,
|
|
||||||
progress: 92,
|
|
||||||
tone: "danger",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "01",
|
|
||||||
label: "Mail Spoof",
|
|
||||||
source: "Mail Relay",
|
|
||||||
state: "Reviewing",
|
|
||||||
value: 3102,
|
|
||||||
progress: 64,
|
|
||||||
tone: "warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "02",
|
|
||||||
label: "Cloud Probe",
|
|
||||||
source: "Cloud API",
|
|
||||||
state: "Queued",
|
|
||||||
value: 1250,
|
|
||||||
progress: 26,
|
|
||||||
tone: "info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "03",
|
|
||||||
label: "Mesh Beacon",
|
|
||||||
source: "Int Node",
|
|
||||||
state: "Watching",
|
|
||||||
value: 420,
|
|
||||||
progress: 9,
|
|
||||||
tone: "success",
|
|
||||||
},
|
|
||||||
] satisfies Array<{
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
source: string
|
|
||||||
state: string
|
|
||||||
value: number
|
|
||||||
progress: number
|
|
||||||
tone: MetricTone
|
|
||||||
}>
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import {
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Cell,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
|
||||||
|
|
||||||
import { ChartTooltip } from "./chart-tooltip"
|
|
||||||
import { activeThreats, clusterLoadSummary, loadDistribution } from "./data"
|
|
||||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
|
||||||
import { loadStateColor, toneStyles } from "./tone-styles"
|
|
||||||
|
|
||||||
const chartGridProps = {
|
|
||||||
vertical: false,
|
|
||||||
stroke: "var(--border)",
|
|
||||||
strokeDasharray: "3 3",
|
|
||||||
strokeOpacity: 0.75,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadPanel() {
|
|
||||||
return (
|
|
||||||
<Card className="relative h-full overflow-hidden p-0">
|
|
||||||
<PanelCorners />
|
|
||||||
<CardContent className="flex h-full flex-col p-4">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h2 className="text-sm leading-4 font-semibold">Cluster Load</h2>
|
|
||||||
<div className="text-muted-foreground flex flex-wrap items-center gap-3 text-xs leading-4">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="size-2 rounded-full bg-zinc-300"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Normal
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="size-2 rounded-full bg-amber-500"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Warm
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="size-2 rounded-full bg-red-500"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Critical
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
|
||||||
{clusterLoadSummary.details.map((detail) => (
|
|
||||||
<Badge
|
|
||||||
key={detail.label}
|
|
||||||
variant={detail.variant}
|
|
||||||
radius="full"
|
|
||||||
className="h-6 gap-1.5 px-2 text-xs"
|
|
||||||
>
|
|
||||||
<span className="font-semibold tabular-nums">
|
|
||||||
{detail.value}
|
|
||||||
</span>
|
|
||||||
<span>{detail.label}</span>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto h-60 pt-5">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={loadDistribution}
|
|
||||||
margin={{ top: 10, right: 0, bottom: 0, left: 0 }}
|
|
||||||
barCategoryGap={2}
|
|
||||||
>
|
|
||||||
<CartesianGrid {...chartGridProps} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="node"
|
|
||||||
axisLine={false}
|
|
||||||
interval={25}
|
|
||||||
tickLine={false}
|
|
||||||
tickMargin={10}
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
/>
|
|
||||||
<YAxis hide domain={[0, 100]} />
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ fill: "var(--muted)" }}
|
|
||||||
content={({ active, payload, label }) => (
|
|
||||||
<ChartTooltip
|
|
||||||
active={active}
|
|
||||||
payload={payload}
|
|
||||||
label={label}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="load"
|
|
||||||
name="Load"
|
|
||||||
radius={[5, 5, 5, 5]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
>
|
|
||||||
{loadDistribution.map((entry) => (
|
|
||||||
<Cell
|
|
||||||
key={entry.node}
|
|
||||||
fill={loadStateColor[entry.state]}
|
|
||||||
opacity={entry.state === "nominal" ? 0.62 : 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActiveThreatsPanel() {
|
|
||||||
return (
|
|
||||||
<Card className="relative overflow-hidden p-0">
|
|
||||||
<PanelCorners />
|
|
||||||
<CardContent className="space-y-5 p-4">
|
|
||||||
<PanelHeading title="Active Lanes" description="Threat Queue" />
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{activeThreats.map((threat) => {
|
|
||||||
const tone = toneStyles[threat.tone]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={threat.id} className="space-y-2.5">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn("size-2 shrink-0 rounded-full", tone.dot)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="truncate text-sm font-medium">
|
|
||||||
[{threat.id}] {threat.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-xs">
|
|
||||||
<span>{threat.source}</span>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
|
||||||
/>
|
|
||||||
<span>{threat.state}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-sm font-semibold tabular-nums">
|
|
||||||
{threat.value.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-muted h-1.5 min-w-0 flex-1 overflow-hidden rounded-full">
|
|
||||||
<div
|
|
||||||
className={cn("h-full rounded-full", tone.bar)}
|
|
||||||
style={{ width: `${threat.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground w-8 text-right text-[10px] leading-none tabular-nums">
|
|
||||||
{threat.progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
|
||||||
|
|
||||||
import { CardDotField } from "@/components/dashboard/card-dot-field"
|
|
||||||
import { type MetricCard } from "./data"
|
|
||||||
import { PanelCorners } from "./panel-heading"
|
|
||||||
import { toneStyles } from "./tone-styles"
|
|
||||||
|
|
||||||
function Sparkline({
|
|
||||||
values,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
values: readonly number[]
|
|
||||||
color: string
|
|
||||||
}) {
|
|
||||||
const min = Math.min(...values)
|
|
||||||
const max = Math.max(...values)
|
|
||||||
const spread = Math.max(1, max - min)
|
|
||||||
const points = values
|
|
||||||
.map((value, index) => {
|
|
||||||
const x = (index / (values.length - 1)) * 72
|
|
||||||
const y = 28 - ((value - min) / spread) * 22
|
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
||||||
})
|
|
||||||
.join(" ")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 72 32"
|
|
||||||
className="h-9 w-24 shrink-0 opacity-95"
|
|
||||||
role="img"
|
|
||||||
aria-label="Metric trend"
|
|
||||||
>
|
|
||||||
<polyline
|
|
||||||
points={points}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="square"
|
|
||||||
strokeLinejoin="miter"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetricTile({ metric }: { metric: MetricCard }) {
|
|
||||||
const tone = toneStyles[metric.tone]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="relative overflow-hidden p-0">
|
|
||||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
|
||||||
<PanelCorners />
|
|
||||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">
|
|
||||||
{metric.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs leading-4">
|
|
||||||
{metric.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end justify-between gap-4">
|
|
||||||
<div className="min-w-0 space-y-2.5">
|
|
||||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight">
|
|
||||||
{metric.value}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={metric.deltaVariant} radius="full">
|
|
||||||
{metric.delta}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{metric.detail}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
import { type DateRange } from "react-day-picker"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@evobgp/ui/components/popover"
|
|
||||||
import { CalendarIcon, ShieldCheckIcon, DownloadIcon } from "lucide-react"
|
|
||||||
|
|
||||||
type PeriodKey = "last30" | "prev30"
|
|
||||||
|
|
||||||
type ReportDateRange = {
|
|
||||||
from: Date
|
|
||||||
to: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
type DateRangePreset = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
period: PeriodKey
|
|
||||||
range: ReportDateRange
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportRange = (
|
|
||||||
fromMonth: number,
|
|
||||||
fromDay: number,
|
|
||||||
toMonth: number,
|
|
||||||
toDay: number,
|
|
||||||
year = 2026
|
|
||||||
): ReportDateRange => ({
|
|
||||||
from: new Date(year, fromMonth, fromDay),
|
|
||||||
to: new Date(year, toMonth, toDay),
|
|
||||||
})
|
|
||||||
|
|
||||||
const preset = (
|
|
||||||
id: string,
|
|
||||||
label: string,
|
|
||||||
period: PeriodKey,
|
|
||||||
range: ReportDateRange
|
|
||||||
): DateRangePreset => ({ id, label, period, range })
|
|
||||||
|
|
||||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
|
||||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
|
||||||
|
|
||||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
|
||||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
|
||||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
|
||||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
|
||||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
|
||||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
|
||||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
|
||||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
|
||||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
|
||||||
]
|
|
||||||
|
|
||||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
|
||||||
|
|
||||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
|
||||||
const secondFrom = second.from
|
|
||||||
const secondTo = second.to ?? second.from
|
|
||||||
|
|
||||||
return (
|
|
||||||
Boolean(secondFrom && secondTo) &&
|
|
||||||
first.from.getTime() === secondFrom?.getTime() &&
|
|
||||||
first.to.getTime() === secondTo?.getTime()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRange(
|
|
||||||
range: DateRange | undefined,
|
|
||||||
fallback: ReportDateRange
|
|
||||||
): ReportDateRange {
|
|
||||||
if (!range?.from) return fallback
|
|
||||||
|
|
||||||
const from = range.from
|
|
||||||
const to = range.to ?? range.from
|
|
||||||
|
|
||||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReportRange(range: ReportDateRange) {
|
|
||||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPeriodForRange(range: ReportDateRange) {
|
|
||||||
const matchingPreset = getMatchingPreset(range)
|
|
||||||
|
|
||||||
if (matchingPreset) return matchingPreset.period
|
|
||||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
|
||||||
? "prev30"
|
|
||||||
: "last30"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMatchingPreset(range: DateRange | undefined) {
|
|
||||||
if (!range?.from || !range.to) return undefined
|
|
||||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
|
||||||
|
|
||||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
|
||||||
isSameRange(preset.range, normalizedRange)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportDateRangePicker({
|
|
||||||
period,
|
|
||||||
onPeriodChange,
|
|
||||||
}: {
|
|
||||||
period: PeriodKey
|
|
||||||
onPeriodChange: (value: PeriodKey) => void
|
|
||||||
}) {
|
|
||||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [committedRange, setCommittedRange] =
|
|
||||||
useState<ReportDateRange>(initialRange)
|
|
||||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
|
||||||
initialRange
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
|
||||||
if (nextOpen) {
|
|
||||||
setDraftRange(committedRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(nextOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleApply() {
|
|
||||||
const nextRange = normalizeRange(draftRange, committedRange)
|
|
||||||
|
|
||||||
setCommittedRange(nextRange)
|
|
||||||
onPeriodChange(getPeriodForRange(nextRange))
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{formatReportRange(committedRange)}
|
|
||||||
</span>
|
|
||||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent align="end" className="w-auto p-0">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
|
||||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
|
||||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
|
||||||
const selected = selectedPresetId === preset.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={selected ? "secondary" : "ghost"}
|
|
||||||
className={
|
|
||||||
selected
|
|
||||||
? "justify-start"
|
|
||||||
: "text-muted-foreground justify-start"
|
|
||||||
}
|
|
||||||
onClick={() => setDraftRange(preset.range)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
selected={draftRange}
|
|
||||||
onSelect={setDraftRange}
|
|
||||||
numberOfMonths={2}
|
|
||||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
|
||||||
disabled={{
|
|
||||||
after: MAX_REPORT_DATE,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setDraftRange(committedRange)
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" onClick={handleApply}>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavbarActions() {
|
|
||||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
|
||||||
|
|
||||||
function handleExport() {
|
|
||||||
toast.success("Export queued", {
|
|
||||||
description: "Security telemetry report is being prepared.",
|
|
||||||
icon: (
|
|
||||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
|
||||||
|
|
||||||
<Button size="sm" type="button" onClick={handleExport}>
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
<span className="hidden sm:block">Export</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@evobgp/ui/components/breadcrumb"
|
|
||||||
|
|
||||||
export function NavbarBreadcrumb() {
|
|
||||||
return (
|
|
||||||
<Breadcrumb className="min-w-0">
|
|
||||||
<BreadcrumbList className="flex-nowrap">
|
|
||||||
<BreadcrumbItem className="hidden md:inline-flex">
|
|
||||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
|
|
||||||
<BreadcrumbSeparator className="hidden md:flex" />
|
|
||||||
|
|
||||||
<BreadcrumbItem className="hidden md:inline-flex">
|
|
||||||
<BreadcrumbLink render={<a href="#" />}>Security</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
|
|
||||||
<BreadcrumbSeparator className="hidden md:flex" />
|
|
||||||
|
|
||||||
<BreadcrumbItem className="min-w-0">
|
|
||||||
<BreadcrumbPage className="truncate">Telemetry</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { NavbarActions } from "./navbar-actions"
|
|
||||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
|
||||||
|
|
||||||
export function Navbar() {
|
|
||||||
return (
|
|
||||||
<header
|
|
||||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
|
||||||
aria-label="Security telemetry header"
|
|
||||||
>
|
|
||||||
<NavbarBreadcrumb />
|
|
||||||
|
|
||||||
<NavbarActions />
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
AreaChart,
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts"
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
|
||||||
import {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
type ChartConfig,
|
|
||||||
} from "@evobgp/ui/components/chart"
|
|
||||||
|
|
||||||
import { networkFlow, networkFlowSummary, threatVectors } from "./data"
|
|
||||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
|
||||||
|
|
||||||
const threatChartConfig = {
|
|
||||||
blocked: {
|
|
||||||
label: "Blocked",
|
|
||||||
color: "var(--color-blue-600)",
|
|
||||||
},
|
|
||||||
watched: {
|
|
||||||
label: "Watched",
|
|
||||||
color: "var(--color-sky-300)",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
const flowChartConfig = {
|
|
||||||
api: {
|
|
||||||
label: "API Calls",
|
|
||||||
color: "var(--color-yellow-500)",
|
|
||||||
},
|
|
||||||
webhook: {
|
|
||||||
label: "Webhooks",
|
|
||||||
color: "var(--color-emerald-500)",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
const chartGridProps = {
|
|
||||||
vertical: false,
|
|
||||||
stroke: "var(--border)",
|
|
||||||
strokeDasharray: "3 3",
|
|
||||||
strokeOpacity: 0.75,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChartColor(config: ChartConfig, name: string | number) {
|
|
||||||
return config[String(name)]?.color
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChartLabel(config: ChartConfig, name: string | number) {
|
|
||||||
return config[String(name)]?.label ?? String(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTooltipItem(
|
|
||||||
config: ChartConfig,
|
|
||||||
value: unknown,
|
|
||||||
name: string | number
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="size-2 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: getChartColor(config, name) }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{getChartLabel(config, name)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-foreground font-semibold tabular-nums">
|
|
||||||
{Number(value ?? 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CrosshatchPattern({
|
|
||||||
config,
|
|
||||||
idPrefix,
|
|
||||||
}: {
|
|
||||||
config: ChartConfig
|
|
||||||
idPrefix: string
|
|
||||||
}) {
|
|
||||||
const entries = Object.entries(config).filter(([, value]) => value.color)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{entries.map(([key, { color }]) => (
|
|
||||||
<pattern
|
|
||||||
key={key}
|
|
||||||
id={`${idPrefix}-${key}`}
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="8"
|
|
||||||
height="8"
|
|
||||||
patternUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<path d="M0,8 L8,0" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
|
||||||
<path d="M0,0 L8,8" stroke={color} strokeWidth="0.8" opacity="0.2" />
|
|
||||||
</pattern>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreatVectorsPanel() {
|
|
||||||
return (
|
|
||||||
<Card className="relative overflow-hidden p-0">
|
|
||||||
<PanelCorners />
|
|
||||||
<CardContent className="flex flex-col gap-5 p-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<PanelHeading title="Threat Vectors" description="Blocked Signals" />
|
|
||||||
<div className="text-muted-foreground flex shrink-0 flex-wrap items-center gap-4 text-xs sm:justify-end">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="size-2 rounded-full bg-blue-600"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Blocked
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="size-2 rounded-full bg-sky-300"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Watched
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChartContainer
|
|
||||||
config={threatChartConfig}
|
|
||||||
className="h-56 w-full min-w-0"
|
|
||||||
>
|
|
||||||
<BarChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={threatVectors}
|
|
||||||
margin={{ top: 8, right: 4, bottom: 0, left: 4 }}
|
|
||||||
barGap={3}
|
|
||||||
barCategoryGap={8}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<pattern
|
|
||||||
id="dashboard5-threat-blocked"
|
|
||||||
patternUnits="userSpaceOnUse"
|
|
||||||
width="8"
|
|
||||||
height="8"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
width="8"
|
|
||||||
height="8"
|
|
||||||
fill="var(--color-blocked)"
|
|
||||||
opacity="0.1"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M0,8 L8,0 M4,12 L12,4 M-4,4 L4,-4"
|
|
||||||
stroke="var(--color-blocked)"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
opacity="0.55"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M2,10 L10,2 M6,14 L14,6 M-2,6 L6,-2"
|
|
||||||
stroke="var(--color-blocked)"
|
|
||||||
strokeWidth="1"
|
|
||||||
opacity="0.25"
|
|
||||||
/>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid {...chartGridProps} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="name"
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tickMargin={10}
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
/>
|
|
||||||
<YAxis hide domain={[0, 78]} />
|
|
||||||
<ChartTooltip
|
|
||||||
cursor={false}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
indicator="dot"
|
|
||||||
className="min-w-36 gap-2"
|
|
||||||
labelFormatter={(value) => (
|
|
||||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
|
||||||
<span className="text-xs font-medium">{value}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
formatter={(value, name) =>
|
|
||||||
formatTooltipItem(threatChartConfig, value, name)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="blocked"
|
|
||||||
fill="url(#dashboard5-threat-blocked)"
|
|
||||||
stroke="var(--color-blocked)"
|
|
||||||
strokeWidth={1}
|
|
||||||
radius={[5, 5, 5, 5]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="watched"
|
|
||||||
fill="var(--color-watched)"
|
|
||||||
fillOpacity={0.86}
|
|
||||||
stroke="var(--color-watched)"
|
|
||||||
strokeWidth={1}
|
|
||||||
radius={[5, 5, 5, 5]}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NetworkFlowPanel() {
|
|
||||||
return (
|
|
||||||
<Card className="relative overflow-hidden p-0">
|
|
||||||
<PanelCorners />
|
|
||||||
<CardContent className="flex flex-col gap-5 p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<PanelHeading title="Network Flow" description="API and webhooks" />
|
|
||||||
<Badge variant="success-light" radius="full">
|
|
||||||
{networkFlowSummary.change}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChartContainer
|
|
||||||
config={flowChartConfig}
|
|
||||||
className="h-56 w-full min-w-0"
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={networkFlow}
|
|
||||||
margin={{ top: 20, right: 0, bottom: 0, left: 0 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid {...chartGridProps} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="month"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickMargin={8}
|
|
||||||
tickFormatter={(value) => String(value).slice(0, 3)}
|
|
||||||
/>
|
|
||||||
<YAxis hide />
|
|
||||||
<ChartTooltip
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
indicator="dot"
|
|
||||||
className="min-w-40 gap-2.5"
|
|
||||||
labelFormatter={(value) => (
|
|
||||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{value} {networkFlowSummary.year}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
formatter={(value, name) =>
|
|
||||||
formatTooltipItem(flowChartConfig, value, name)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<CrosshatchPattern
|
|
||||||
config={flowChartConfig}
|
|
||||||
idPrefix="dashboard5-flow-crosshatch"
|
|
||||||
/>
|
|
||||||
</defs>
|
|
||||||
<Area
|
|
||||||
dataKey="webhook"
|
|
||||||
type="natural"
|
|
||||||
fill="url(#dashboard5-flow-crosshatch-webhook)"
|
|
||||||
fillOpacity={0.5}
|
|
||||||
stroke="var(--color-webhook)"
|
|
||||||
stackId="a"
|
|
||||||
strokeWidth={1.25}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="api"
|
|
||||||
type="natural"
|
|
||||||
fill="url(#dashboard5-flow-crosshatch-api)"
|
|
||||||
fillOpacity={0.5}
|
|
||||||
stroke="var(--color-api)"
|
|
||||||
stackId="a"
|
|
||||||
strokeWidth={1.25}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export function PanelCorners() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelHeading({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h2 className="text-sm leading-4 font-semibold">{title}</h2>
|
|
||||||
<p className="text-muted-foreground text-xs leading-4">{description}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { metricCards } from "./data"
|
|
||||||
import { ActiveThreatsPanel, LoadPanel } from "./load-panels"
|
|
||||||
import { MetricTile } from "./metric-tile"
|
|
||||||
import { Navbar } from "./navbar"
|
|
||||||
import { NetworkFlowPanel, ThreatVectorsPanel } from "./network-panels"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dense edge security dashboard inspired by a telemetry wall.
|
|
||||||
* The main entry only owns section order; records and panel details stay
|
|
||||||
* in focused local files so the block remains easy to adapt.
|
|
||||||
*/
|
|
||||||
export function SecurityDashboard() {
|
|
||||||
return (
|
|
||||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4">
|
|
||||||
<h1 id="page-heading" className="sr-only">
|
|
||||||
Edge Security Telemetry
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<Navbar />
|
|
||||||
|
|
||||||
<section
|
|
||||||
aria-label="Security Summary"
|
|
||||||
className="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
|
||||||
>
|
|
||||||
{metricCards.map((metric) => (
|
|
||||||
<MetricTile key={metric.id} metric={metric} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
aria-label="Security Flow"
|
|
||||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-2"
|
|
||||||
>
|
|
||||||
<ThreatVectorsPanel />
|
|
||||||
<NetworkFlowPanel />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
aria-label="Security Load"
|
|
||||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]"
|
|
||||||
>
|
|
||||||
<LoadPanel />
|
|
||||||
<ActiveThreatsPanel />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user