Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -5,7 +5,7 @@ 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 `42d70dcc3d`.** 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `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.
|
||||||
|
|
||||||
@@ -32,6 +32,34 @@ Common mistakes:
|
|||||||
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 `42d70dcc3d`.** 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `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.
|
||||||
|
|
||||||
@@ -32,6 +32,34 @@ Common mistakes:
|
|||||||
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"pid": 43636,
|
|
||||||
"version": "0.9.9",
|
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
|
||||||
"startedAt": 1783570299043
|
|
||||||
}
|
|
||||||
@@ -9,3 +9,8 @@
|
|||||||
{"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":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":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":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>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# KPI hybrid — только kit (stats-12 DNA)
|
||||||
|
|
||||||
|
Preview: [stats-12](https://reui.io/preview/base/stats-12). 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 `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||||
|
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
|
||||||
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||||
|
|
||||||
|
Импорты UI: `@evobgp/ui/components/*`.
|
||||||
|
|
||||||
|
## NEVER
|
||||||
|
|
||||||
|
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||||
|
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||||
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
|
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
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 |
|
||||||
|
|----------|-----|
|
||||||
|
| **llms.txt** | https://reui.io/llms.txt |
|
||||||
|
| **Get Started** | https://reui.io/docs/get-started |
|
||||||
|
| **Styling** | https://reui.io/docs/styling |
|
||||||
|
| **MCP** | https://reui.io/docs/mcp |
|
||||||
|
| **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-зоны.
|
||||||
|
|
||||||
|
## Когда 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 | `@reui` | `@/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) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
|
||||||
|
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||||
|
|
||||||
|
## Установленные ReUI (apps/web)
|
||||||
|
|
||||||
|
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
||||||
|
|
||||||
|
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] `user-reui` search/get_block + previewUrl
|
||||||
|
- [ ] `surface: frame`
|
||||||
|
- [ ] CLI add из `apps/web` при новом item
|
||||||
|
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
|
||||||
|
- [ ] `pnpm --filter @evobgp/web run build`
|
||||||
@@ -11,7 +11,7 @@ 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 `42d70dcc3d`.** 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
| Роуты | `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). 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,8 @@ 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` или `DataGridSection` (не 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-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 +73,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 +98,8 @@ 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 `42d70dcc3d`.** 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `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.
|
||||||
|
|
||||||
@@ -32,6 +32,34 @@ Common mistakes:
|
|||||||
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -150,15 +150,23 @@ jobs:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
openapi:
|
openapi:
|
||||||
needs: [changes]
|
needs: [changes]
|
||||||
if: needs.changes.outputs.openapi == 'true'
|
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
|
- name: Enable pnpm via corepack
|
||||||
|
run: corepack enable
|
||||||
- name: Lint OpenAPI (Redocly)
|
- name: Lint OpenAPI (Redocly)
|
||||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||||
|
- name: Check OpenAPI→TS codegen is fresh
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
chmod +x scripts/check-openapi-gen.sh
|
||||||
|
sh scripts/check-openapi-gen.sh
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
web:
|
web:
|
||||||
@@ -172,12 +180,13 @@ jobs:
|
|||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Enable pnpm via corepack
|
- name: Enable pnpm via corepack
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- name: pnpm install, typecheck, lint, build
|
- name: pnpm install, typecheck, lint, test, build
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm --filter @evobgp/web run typecheck
|
pnpm --filter @evobgp/web run typecheck
|
||||||
pnpm --filter @evobgp/web run lint
|
pnpm --filter @evobgp/web run lint
|
||||||
|
pnpm --filter @evobgp/web run test
|
||||||
pnpm --filter @evobgp/web run build
|
pnpm --filter @evobgp/web run build
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ 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 `42d70dcc3d`.** 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `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.
|
||||||
|
|
||||||
@@ -32,6 +32,34 @@ Common mistakes:
|
|||||||
- **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 documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ 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/`.
|
||||||
|
|
||||||
|
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 стека.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,7 +9,10 @@
|
|||||||
"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",
|
||||||
@@ -40,22 +43,24 @@
|
|||||||
"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'] } }],
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ 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 { FrameDataGrid } from '@/components/reui-kit'
|
||||||
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,7 +47,7 @@ export function AccessApiKeysCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataGridCard
|
<FrameDataGrid
|
||||||
title="API-ключи"
|
title="API-ключи"
|
||||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||||
actions={
|
actions={
|
||||||
@@ -85,7 +85,7 @@ export function AccessApiKeysCard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</DataGridCard>
|
</FrameDataGrid>
|
||||||
|
|
||||||
<ApiKeyCreateDialog
|
<ApiKeyCreateDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
|
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="GET /v1/health · GET /v1/ready"
|
||||||
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
|
slices={slices}
|
||||||
>
|
centerLabel="Проверки"
|
||||||
{loading ? (
|
badge={healthOk ? 'API OK' : 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="Доступность live-агентов"
|
||||||
</div>
|
primary={{
|
||||||
) : (
|
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
label: 'Online',
|
||||||
)}
|
percent: speakersPct,
|
||||||
</AnalyticsCardShell>
|
}}
|
||||||
|
secondary={{
|
||||||
|
value: net.speakersTotal - net.speakersOnline,
|
||||||
|
label: 'Offline',
|
||||||
|
percent: 100 - speakersPct,
|
||||||
|
}}
|
||||||
|
footer={`Пиры Established: ${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,5 +1,4 @@
|
|||||||
import type { ComponentProps, ReactNode } from 'react'
|
import type { ComponentProps, ReactNode } from 'react'
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
@@ -36,67 +35,32 @@ export function BadgeTabs({
|
|||||||
listClassName,
|
listClassName,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
}: BadgeTabsProps) {
|
}: BadgeTabsProps) {
|
||||||
const tabsRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = tabsRef.current?.querySelector('[data-slot=tabs]')
|
|
||||||
if (!el || !(el instanceof HTMLElement)) return
|
|
||||||
const flexDirection = getComputedStyle(el).flexDirection
|
|
||||||
const listRect = el.querySelector('[data-slot=tabs-list]')?.getBoundingClientRect()
|
|
||||||
const panelRect = el.querySelector('[data-slot=tabs-content]')?.getBoundingClientRect()
|
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7311/ingest/6b35c3ae-1bcd-4c9c-81eb-f157c9347393', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'c10bcb' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
sessionId: 'c10bcb',
|
|
||||||
runId: 'post-fix',
|
|
||||||
hypothesisId: 'H1',
|
|
||||||
location: 'badge-tabs.tsx:useEffect',
|
|
||||||
message: 'BadgeTabs layout metrics',
|
|
||||||
data: {
|
|
||||||
flexDirection,
|
|
||||||
orientation: el.getAttribute('data-orientation'),
|
|
||||||
listLeft: listRect?.left,
|
|
||||||
panelLeft: panelRect?.left,
|
|
||||||
stacked: flexDirection === 'column',
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}),
|
|
||||||
}).catch(() => {})
|
|
||||||
// #endregion
|
|
||||||
}, [value, defaultValue])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={tabsRef} className={cn('w-full', className)}>
|
<Tabs
|
||||||
<Tabs
|
value={value}
|
||||||
value={value}
|
defaultValue={defaultValue}
|
||||||
defaultValue={defaultValue}
|
onValueChange={onValueChange}
|
||||||
onValueChange={onValueChange}
|
orientation="horizontal"
|
||||||
className="w-full"
|
className={cn('w-full', className)}
|
||||||
|
>
|
||||||
|
<TabsList
|
||||||
|
variant="line"
|
||||||
|
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||||
>
|
>
|
||||||
<TabsList
|
{items.map((item) => (
|
||||||
variant="line"
|
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||||
className={cn(
|
{item.icon}
|
||||||
'mb-4 w-full justify-start gap-6',
|
{item.label}
|
||||||
listClassName,
|
{item.count !== undefined ? (
|
||||||
)}
|
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
||||||
>
|
{item.count}
|
||||||
{items.map((item) => (
|
</Badge>
|
||||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
) : null}
|
||||||
{item.icon}
|
</TabsTrigger>
|
||||||
{item.label}
|
))}
|
||||||
{item.count !== undefined ? (
|
</TabsList>
|
||||||
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
|
||||||
{item.count}
|
</Tabs>
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { type LoadState, type MetricTone } from "./data"
|
|
||||||
|
|
||||||
export const toneStyles: Record<
|
|
||||||
MetricTone,
|
|
||||||
{
|
|
||||||
dot: string
|
|
||||||
stroke: string
|
|
||||||
bar: string
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
danger: {
|
|
||||||
dot: "bg-red-500",
|
|
||||||
stroke: "var(--color-red-500)",
|
|
||||||
bar: "bg-red-500",
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
dot: "bg-emerald-500",
|
|
||||||
stroke: "var(--color-emerald-500)",
|
|
||||||
bar: "bg-emerald-500",
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
dot: "bg-amber-500",
|
|
||||||
stroke: "var(--color-amber-500)",
|
|
||||||
bar: "bg-amber-500",
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
dot: "bg-blue-500",
|
|
||||||
stroke: "var(--color-blue-500)",
|
|
||||||
bar: "bg-blue-500",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadStateColor: Record<LoadState, string> = {
|
|
||||||
nominal: "var(--color-zinc-300)",
|
|
||||||
warm: "var(--color-amber-500)",
|
|
||||||
critical: "var(--color-red-500)",
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { SecurityDashboard } from "./components/security-dashboard"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6"
|
|
||||||
aria-labelledby="page-heading"
|
|
||||||
>
|
|
||||||
<SecurityDashboard />
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, type ComponentType } from "react"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@evobgp/ui/components/tabs"
|
||||||
|
|
||||||
|
import { BillingTab } from "./billing-tab"
|
||||||
|
import { SETTINGS_TAB_ITEMS } from "./data"
|
||||||
|
import { NotificationsTab } from "./notifications-tab"
|
||||||
|
import { ProfileTab } from "./profile-tab"
|
||||||
|
import { SecurityTab } from "./security-tab"
|
||||||
|
|
||||||
|
const TAB_COMPONENTS: Record<string, ComponentType> = {
|
||||||
|
profile: ProfileTab,
|
||||||
|
security: SecurityTab,
|
||||||
|
notifications: NotificationsTab,
|
||||||
|
billing: BillingTab,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings Navigation ──
|
||||||
|
|
||||||
|
function SettingsNavigation({
|
||||||
|
isMobile,
|
||||||
|
activeValue,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
activeValue: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||||
|
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||||
|
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||||
|
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||||
|
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||||
|
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSettings() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [activeTab, setActiveTab] = useState("profile")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-4xl space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="px-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
|
Account Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||||
|
Update your profile, access, notifications, and billing preferences.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
orientation={isMobile ? "horizontal" : "vertical"}
|
||||||
|
className={cn("w-full gap-4 lg:gap-8")}
|
||||||
|
>
|
||||||
|
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{SETTINGS_TAB_ITEMS.map((tab) => {
|
||||||
|
const TabComponent = TAB_COMPONENTS[tab.value]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
||||||
|
<TabComponent />
|
||||||
|
</TabsContent>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
|
||||||
|
import { BILLING_PLANS, COUNTRIES } from "./data"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
|
||||||
|
export function BillingTab() {
|
||||||
|
const [country, setCountry] = useState("us")
|
||||||
|
const handleCountryChange = createSelectValueHandler(setCountry)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard title="Plan and billing" description="Current subscription">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Plan and billing"
|
||||||
|
description="Review your current subscription and plan options."
|
||||||
|
>
|
||||||
|
{BILLING_PLANS.map((plan, index) => (
|
||||||
|
<SettingRow
|
||||||
|
key={plan.id}
|
||||||
|
title={plan.name}
|
||||||
|
titleAddon={
|
||||||
|
plan.current ? (
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{plan.price}
|
||||||
|
</span>{" "}
|
||||||
|
/ {plan.period} · {plan.features.join(", ")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
last={index === BILLING_PLANS.length - 1}
|
||||||
|
>
|
||||||
|
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
|
||||||
|
{plan.current ? "Manage" : "Switch"}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Billing details"
|
||||||
|
description="Invoices and payments"
|
||||||
|
footer={<Button>Update billing</Button>}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Billing details"
|
||||||
|
description="Manage invoice contacts and payment details."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Billing email"
|
||||||
|
description="Receives invoices and renewal notices."
|
||||||
|
labelFor="settings-7-billing-email"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-7-billing-email"
|
||||||
|
defaultValue="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Invoice profile"
|
||||||
|
description="Business details used on receipts and tax forms."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Company identity used for invoices and tax documents.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-company-name">
|
||||||
|
Company name
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="settings-7-company-name"
|
||||||
|
defaultValue="Acme Labs"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-billing-country">
|
||||||
|
Country
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={country} onValueChange={handleCountryChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="settings-7-billing-country"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(COUNTRIES, country)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{COUNTRIES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Tax ID"
|
||||||
|
description="Optional number used for VAT or company invoices."
|
||||||
|
labelFor="settings-7-tax-id"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>VAT</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-tax-id"
|
||||||
|
defaultValue="US-2048-ACME"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Card on file"
|
||||||
|
description="Used for monthly renewals."
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Visa ending in 4242
|
||||||
|
</span>
|
||||||
|
<Button variant="outline">Update</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type ReactNode } from "react"
|
||||||
|
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type SelectOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingPlan = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: string
|
||||||
|
period: string
|
||||||
|
current: boolean
|
||||||
|
features: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
id: string
|
||||||
|
device: string
|
||||||
|
browser: string
|
||||||
|
location: string
|
||||||
|
lastActive: string
|
||||||
|
current: boolean
|
||||||
|
icon: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsTabItem = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
icon: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ──
|
||||||
|
|
||||||
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
|
{
|
||||||
|
value: "profile",
|
||||||
|
label: "My Profile",
|
||||||
|
icon: (
|
||||||
|
<UserIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "security",
|
||||||
|
label: "Security",
|
||||||
|
icon: (
|
||||||
|
<ShieldIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "notifications",
|
||||||
|
label: "Notifications",
|
||||||
|
icon: (
|
||||||
|
<BellIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "billing",
|
||||||
|
label: "Billing",
|
||||||
|
icon: (
|
||||||
|
<CreditCardIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TIMEZONES: SelectOption[] = [
|
||||||
|
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
|
||||||
|
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
|
||||||
|
{ value: "utc+0", label: "UTC+0 (London)" },
|
||||||
|
{ value: "utc+1", label: "UTC+1 (Berlin)" },
|
||||||
|
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ROLES: SelectOption[] = [
|
||||||
|
{ value: "engineering-lead", label: "Engineering Lead" },
|
||||||
|
{ value: "developer", label: "Developer" },
|
||||||
|
{ value: "designer", label: "Designer" },
|
||||||
|
{ value: "product-manager", label: "Product Manager" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const COUNTRIES: SelectOption[] = [
|
||||||
|
{ value: "us", label: "United States" },
|
||||||
|
{ value: "uk", label: "United Kingdom" },
|
||||||
|
{ value: "de", label: "Germany" },
|
||||||
|
{ value: "uz", label: "Uzbekistan" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TIMEOUTS: SelectOption[] = [
|
||||||
|
{ value: "5", label: "5 minutes" },
|
||||||
|
{ value: "10", label: "10 minutes" },
|
||||||
|
{ value: "15", label: "15 minutes" },
|
||||||
|
{ value: "30", label: "30 minutes" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const QUIET_HOURS: SelectOption[] = [
|
||||||
|
{ value: "18:00", label: "6:00 PM" },
|
||||||
|
{ value: "20:00", label: "8:00 PM" },
|
||||||
|
{ value: "22:00", label: "10:00 PM" },
|
||||||
|
{ value: "23:00", label: "11:00 PM" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const QUIET_HOURS_END: SelectOption[] = [
|
||||||
|
{ value: "06:00", label: "6:00 AM" },
|
||||||
|
{ value: "08:00", label: "8:00 AM" },
|
||||||
|
{ value: "09:00", label: "9:00 AM" },
|
||||||
|
{ value: "10:00", label: "10:00 AM" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DIGEST_CADENCE: SelectOption[] = [
|
||||||
|
{ value: "daily", label: "Daily summary" },
|
||||||
|
{ value: "weekly", label: "Weekly digest" },
|
||||||
|
{ value: "mentions", label: "Only mentions" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const RECOVERY_METHODS: SelectOption[] = [
|
||||||
|
{ value: "authenticator", label: "Authenticator first" },
|
||||||
|
{ value: "sms", label: "SMS fallback" },
|
||||||
|
{ value: "email", label: "Email fallback" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BILLING_PLANS: BillingPlan[] = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Free",
|
||||||
|
price: "$0",
|
||||||
|
period: "forever",
|
||||||
|
current: false,
|
||||||
|
features: ["1 workspace", "Basic exports", "Community support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
price: "$29",
|
||||||
|
period: "per month",
|
||||||
|
current: true,
|
||||||
|
features: ["Unlimited workspaces", "Automations", "Priority support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team",
|
||||||
|
name: "Team",
|
||||||
|
price: "$79",
|
||||||
|
period: "per month",
|
||||||
|
current: false,
|
||||||
|
features: ["Everything in Pro", "SSO", "Shared billing"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SESSIONS: Session[] = [
|
||||||
|
{
|
||||||
|
id: "sess-1",
|
||||||
|
device: "macOS",
|
||||||
|
browser: "Chrome",
|
||||||
|
location: "San Francisco, CA",
|
||||||
|
lastActive: "Active now",
|
||||||
|
current: true,
|
||||||
|
icon: (
|
||||||
|
<MonitorIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sess-2",
|
||||||
|
device: "iPhone",
|
||||||
|
browser: "Safari",
|
||||||
|
location: "San Francisco, CA",
|
||||||
|
lastActive: "2 hours ago",
|
||||||
|
current: false,
|
||||||
|
icon: (
|
||||||
|
<SmartphoneIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sess-3",
|
||||||
|
device: "Windows",
|
||||||
|
browser: "Firefox",
|
||||||
|
location: "New York, NY",
|
||||||
|
lastActive: "3 days ago",
|
||||||
|
current: false,
|
||||||
|
icon: (
|
||||||
|
<MonitorIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import { Switch } from "@evobgp/ui/components/switch"
|
||||||
|
|
||||||
|
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
|
||||||
|
export function NotificationsTab() {
|
||||||
|
const [autoDismiss, setAutoDismiss] = useState("10")
|
||||||
|
const [quietStart, setQuietStart] = useState("20:00")
|
||||||
|
const [quietEnd, setQuietEnd] = useState("08:00")
|
||||||
|
const [digestCadence, setDigestCadence] = useState("weekly")
|
||||||
|
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
|
||||||
|
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
|
||||||
|
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
|
||||||
|
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard title="Activity alerts" description="Desktop and badge">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Activity alerts"
|
||||||
|
description="Control desktop, badge, and sound alerts."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Desktop notifications"
|
||||||
|
description="Show alerts for mentions and approvals."
|
||||||
|
labelFor="settings-7-desktop-notifications"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-desktop-notifications" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Quiet hours"
|
||||||
|
description="Hold non-urgent alerts outside your workday."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Define when non-urgent activity should stay muted.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-quiet-start">
|
||||||
|
Start
|
||||||
|
</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={quietStart}
|
||||||
|
onValueChange={handleQuietStartChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="settings-7-quiet-start"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(QUIET_HOURS, quietStart)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{QUIET_HOURS.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
|
||||||
|
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
|
||||||
|
<SelectTrigger id="settings-7-quiet-end" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{QUIET_HOURS_END.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Unread badge"
|
||||||
|
description="Display a badge when new activity arrives."
|
||||||
|
labelFor="settings-7-unread-badge"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-unread-badge" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Auto dismiss"
|
||||||
|
description="Choose how long alerts stay visible."
|
||||||
|
labelFor="settings-7-auto-dismiss"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
|
||||||
|
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(TIMEOUTS, autoDismiss)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{TIMEOUTS.map((timeout) => (
|
||||||
|
<SelectItem key={timeout.value} value={timeout.value}>
|
||||||
|
{timeout.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Email updates"
|
||||||
|
description="Inbox preferences"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline">Reset</Button>
|
||||||
|
<Button>Save preferences</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Email updates"
|
||||||
|
description="Control which emails reach your inbox."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Team communication"
|
||||||
|
description="Messages, approvals, and activity summaries."
|
||||||
|
labelFor="settings-7-team-communication"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-team-communication" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Product announcements"
|
||||||
|
description="Releases, improvements, and launches."
|
||||||
|
labelFor="settings-7-product-announcements"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-product-announcements" />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Weekly digest"
|
||||||
|
description="A recap of workspaces, mentions, and tasks."
|
||||||
|
labelFor="settings-7-weekly-digest"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-weekly-digest" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Digest cadence"
|
||||||
|
description="Set how often summary emails arrive."
|
||||||
|
labelFor="settings-7-digest-cadence"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={digestCadence}
|
||||||
|
onValueChange={handleDigestCadenceChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{DIGEST_CADENCE.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@evobgp/ui/components/avatar"
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface ProfileAvatarUploadProps {
|
||||||
|
defaultAvatar: string
|
||||||
|
alt: string
|
||||||
|
inputId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileAvatarUpload({
|
||||||
|
defaultAvatar,
|
||||||
|
alt,
|
||||||
|
inputId,
|
||||||
|
}: ProfileAvatarUploadProps) {
|
||||||
|
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
|
||||||
|
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
|
||||||
|
useFileUpload({
|
||||||
|
accept: "image/*",
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentFile = files[0] ?? null
|
||||||
|
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
|
||||||
|
const previewUrl =
|
||||||
|
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
|
||||||
|
const hasPhoto = Boolean(previewUrl)
|
||||||
|
|
||||||
|
const handleCancelUpload = () => {
|
||||||
|
if (!currentFile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFile(currentFile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePhoto = () => {
|
||||||
|
if (currentFile) {
|
||||||
|
removeFile(currentFile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRemovedCurrentPhoto(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex grow flex-wrap items-center justify-start gap-2">
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="size-10">
|
||||||
|
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
|
||||||
|
<AvatarFallback className="text-muted-foreground bg-muted">
|
||||||
|
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{currentFile ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={handleCancelUpload}
|
||||||
|
className="absolute -top-1 -right-1 size-4 rounded-full"
|
||||||
|
aria-label={`Cancel ${currentFile.file.name}`}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative inline-flex">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={openFileDialog}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<UploadIcon aria-hidden="true" />
|
||||||
|
{hasPhoto ? "Change" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
{...getInputProps({ id: inputId })}
|
||||||
|
className="sr-only"
|
||||||
|
aria-label="Upload profile image"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPhoto ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertAction,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/reui/alert"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import { Textarea } from "@evobgp/ui/components/textarea"
|
||||||
|
import { ROLES, TIMEZONES } from "./data"
|
||||||
|
import { ProfileAvatarUpload } from "./profile-avatar-upload"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
import { UserIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function ProfileTab() {
|
||||||
|
const [role, setRole] = useState("engineering-lead")
|
||||||
|
const [timezone, setTimezone] = useState("utc-8")
|
||||||
|
const handleRoleChange = createSelectValueHandler(setRole)
|
||||||
|
const handleTimezoneChange = createSelectValueHandler(setTimezone)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<UserIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>Complete your profile.</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Add a photo and keep your role and timezone current.
|
||||||
|
</AlertDescription>
|
||||||
|
<AlertAction>
|
||||||
|
<Button type="button" variant="outline" size="xs">
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="xs">
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</AlertAction>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard
|
||||||
|
title="My profile"
|
||||||
|
description="Public account details"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
<Button>Save changes</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Profile fields"
|
||||||
|
description="Update your personal profile details."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Photo"
|
||||||
|
description="Shown in comments and mentions."
|
||||||
|
>
|
||||||
|
<ProfileAvatarUpload
|
||||||
|
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
|
||||||
|
alt="Alex Morgan"
|
||||||
|
inputId="settings-7-profile-photo"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Full name"
|
||||||
|
description="Used across the workspace."
|
||||||
|
labelFor="settings-7-full-name"
|
||||||
|
>
|
||||||
|
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Email address"
|
||||||
|
description="Primary sign-in email."
|
||||||
|
labelFor="settings-7-email"
|
||||||
|
titleAddon={<Badge variant="success-light">Verified</Badge>}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-7-email"
|
||||||
|
defaultValue="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Username"
|
||||||
|
description="Visible in mentions and links."
|
||||||
|
labelFor="settings-7-username"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>@</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-username"
|
||||||
|
defaultValue="alexmorgan"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Profile details"
|
||||||
|
description="Public details shared across the workspace."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Profile details</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Public profile and workspace defaults.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
|
||||||
|
<Select value={role} onValueChange={handleRoleChange}>
|
||||||
|
<SelectTrigger id="settings-7-role" className="w-full">
|
||||||
|
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{ROLES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-timezone">
|
||||||
|
Time zone
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={timezone} onValueChange={handleTimezoneChange}>
|
||||||
|
<SelectTrigger id="settings-7-timezone" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(TIMEZONES, timezone)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{TIMEZONES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>https://</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-website"
|
||||||
|
defaultValue="alexmorgan.dev"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Bio"
|
||||||
|
description="Short profile summary."
|
||||||
|
labelFor="settings-7-bio"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="settings-7-bio"
|
||||||
|
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
|
||||||
|
rows={4}
|
||||||
|
className="min-h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Fragment, useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@evobgp/ui/components/item"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import { Switch } from "@evobgp/ui/components/switch"
|
||||||
|
import { RECOVERY_METHODS, SESSIONS } from "./data"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
import { LogOutIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function SecurityTab() {
|
||||||
|
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
|
||||||
|
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard
|
||||||
|
title="Change password"
|
||||||
|
description="Access protection"
|
||||||
|
footer={<Button>Update password</Button>}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Password settings"
|
||||||
|
description="Update your password and keep your account protected."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Current password"
|
||||||
|
description="Verify your identity before making changes."
|
||||||
|
labelFor="settings-7-current-password"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-7-current-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="New password"
|
||||||
|
description="Use at least 12 characters and a unique phrase."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">New password</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Create and confirm the next password for this account.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-new-password">
|
||||||
|
New password
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="settings-7-new-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Create a new password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-confirm-password">
|
||||||
|
Confirm password
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="settings-7-confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm the new password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Two-step verification"
|
||||||
|
description="Extra sign-in checks"
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Two-step verification"
|
||||||
|
description="Add backup checks for future sign-ins."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Authenticator app"
|
||||||
|
description="Use one-time codes from an app."
|
||||||
|
labelFor="settings-7-authenticator-app"
|
||||||
|
titleAddon={
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
Recommended
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-authenticator-app" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Recovery phone"
|
||||||
|
description="Used if you lose access to your authenticator app."
|
||||||
|
labelFor="settings-7-recovery-phone"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>+1</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-recovery-phone"
|
||||||
|
defaultValue="415 555 0148"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Delivery method"
|
||||||
|
description="Choose how backup verification requests are delivered."
|
||||||
|
labelFor="settings-7-recovery-method"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={recoveryMethod}
|
||||||
|
onValueChange={handleRecoveryMethodChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="settings-7-recovery-method" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{RECOVERY_METHODS.map((method) => (
|
||||||
|
<SelectItem key={method.value} value={method.value}>
|
||||||
|
{method.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Trusted devices"
|
||||||
|
description="Skip repeat prompts on known devices."
|
||||||
|
labelFor="settings-7-trusted-devices"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-trusted-devices" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Active sessions" description="Signed-in devices">
|
||||||
|
<ItemGroup className="gap-0">
|
||||||
|
{SESSIONS.map((session, index) => (
|
||||||
|
<Fragment key={session.id}>
|
||||||
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
|
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||||
|
<ItemMedia className="self-center!">
|
||||||
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
|
{session.icon}
|
||||||
|
</Item>
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
|
<ItemTitle className="gap-2 leading-5">
|
||||||
|
{session.browser} on {session.device}
|
||||||
|
{session.current ? (
|
||||||
|
<Badge variant="success-light" size="xs">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="leading-5">
|
||||||
|
{session.location} · {session.lastActive}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
|
<ItemActions className="w-28 shrink-0 justify-end self-center">
|
||||||
|
{session.current ? (
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
This device
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Delete account" description="Irreversible changes">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Delete account"
|
||||||
|
description="Review irreversible actions before deleting your account."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Close account"
|
||||||
|
description="Permanently remove your account, sessions, and recovery settings."
|
||||||
|
last
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
|
||||||
|
<p className="text-muted-foreground text-xs leading-5">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Button variant="destructive">Delete account</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
|
||||||
|
interface SettingRowProps {
|
||||||
|
title: string
|
||||||
|
description?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
labelFor?: string
|
||||||
|
contentClassName?: string
|
||||||
|
titleAddon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingRow({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
last,
|
||||||
|
labelFor,
|
||||||
|
contentClassName,
|
||||||
|
titleAddon,
|
||||||
|
}: SettingRowProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{labelFor ? (
|
||||||
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
|
) : (
|
||||||
|
<FieldTitle>{title}</FieldTitle>
|
||||||
|
)}
|
||||||
|
{titleAddon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description ? (
|
||||||
|
<FieldDescription>{description}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldContent
|
||||||
|
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!last ? <FieldSeparator /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
|
interface SettingsCardProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
footerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
||||||
|
export function SettingsCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
footerClassName,
|
||||||
|
}: SettingsCardProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||||
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
|
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||||
|
|
||||||
|
{footer ? (
|
||||||
|
<FrameFooter
|
||||||
|
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</FrameFooter>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
|
||||||
|
interface SettingsFieldGroupProps {
|
||||||
|
legend: string
|
||||||
|
description: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
fieldGroupClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsFieldGroup({
|
||||||
|
legend,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
fieldGroupClassName,
|
||||||
|
}: SettingsFieldGroupProps) {
|
||||||
|
return (
|
||||||
|
<FieldSet className={cn("gap-0", className)}>
|
||||||
|
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||||
|
{/* Description */}
|
||||||
|
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||||
|
{/* List */}
|
||||||
|
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
|
||||||
|
{children}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type Dispatch, type SetStateAction } from "react"
|
||||||
|
|
||||||
|
import type { SelectOption } from "./data"
|
||||||
|
|
||||||
|
export function getOptionLabel<T extends SelectOption>(
|
||||||
|
options: T[],
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSelectValueHandler(
|
||||||
|
setValue: Dispatch<SetStateAction<string>>
|
||||||
|
) {
|
||||||
|
return (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { AccountSettings } from "./components/account-settings"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
|
||||||
|
<AccountSettings />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ReactNode } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// ── Types ──
|
|
||||||
|
|
||||||
export interface CardData {
|
|
||||||
icon: ReactNode
|
|
||||||
iconBg: string
|
|
||||||
value: string | number
|
|
||||||
label: string
|
|
||||||
info: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Data ──
|
|
||||||
|
|
||||||
export const cards: CardData[] = [
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<Headphones aria-hidden="true" />
|
|
||||||
),
|
|
||||||
iconBg: "text-blue-600 dark:text-blue-400",
|
|
||||||
value: 320,
|
|
||||||
label: "Support Tickets",
|
|
||||||
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<CircleCheckIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
iconBg: "text-emerald-600 dark:text-emerald-400",
|
|
||||||
value: "98%",
|
|
||||||
label: "Resolved",
|
|
||||||
info: <Badge variant="success-light">+2.1% this month</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<SmileIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
iconBg: "text-amber-600 dark:text-amber-400",
|
|
||||||
value: "4.8",
|
|
||||||
label: "Satisfaction Rate",
|
|
||||||
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
|
||||||
|
|
||||||
import { cards } from "./data"
|
|
||||||
|
|
||||||
export function Stats() {
|
|
||||||
return (
|
|
||||||
<div className="@container w-full grow">
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
|
|
||||||
{cards.map((card, i) => (
|
|
||||||
<Frame key={i}>
|
|
||||||
<FramePanel className="flex flex-col items-start gap-6">
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
|
|
||||||
card.iconBg
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{card.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-foreground text-2xl leading-none font-bold">
|
|
||||||
{card.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-sm font-medium">
|
|
||||||
{card.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{card.info}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Stats } from "./components/stats"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
|
||||||
<Stats />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
export type ActivityDotBadge = {
|
|
||||||
label: string
|
|
||||||
dotClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActivityLightBadge = {
|
|
||||||
label: string
|
|
||||||
variant:
|
|
||||||
| "primary-light"
|
|
||||||
| "success-light"
|
|
||||||
| "warning-light"
|
|
||||||
| "info-light"
|
|
||||||
| "destructive-light"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FinanceActivity = {
|
|
||||||
id: number
|
|
||||||
user: string
|
|
||||||
avatar: string
|
|
||||||
action: string
|
|
||||||
target: string
|
|
||||||
detail: string
|
|
||||||
badges: [ActivityDotBadge] | [ActivityDotBadge, ActivityLightBadge]
|
|
||||||
attachment?: {
|
|
||||||
name: string
|
|
||||||
size: string
|
|
||||||
}
|
|
||||||
participants?: {
|
|
||||||
src: string
|
|
||||||
fallback: string
|
|
||||||
}[]
|
|
||||||
participantCount?: number
|
|
||||||
actions?: {
|
|
||||||
label: string
|
|
||||||
variant?: "default" | "outline"
|
|
||||||
}[]
|
|
||||||
date: string
|
|
||||||
dateTime: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const financeActivities: FinanceActivity[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
user: "Nadia Flores",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
|
||||||
action: "approved payout batch",
|
|
||||||
target: "ACH-4182",
|
|
||||||
detail: "$142,800 routed to 38 merchant accounts",
|
|
||||||
badges: [
|
|
||||||
{ label: "Payout", dotClass: "bg-emerald-500" },
|
|
||||||
{ label: "Same Day", variant: "success-light" },
|
|
||||||
],
|
|
||||||
date: "5 minutes ago",
|
|
||||||
dateTime: "2026-05-06T09:45:00+05:00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
user: "Theo Ramsey",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
|
||||||
action: "flagged review on",
|
|
||||||
target: "Transfer TX-9041",
|
|
||||||
detail: "Velocity threshold exceeded for new payee",
|
|
||||||
badges: [
|
|
||||||
{ label: "Risk", dotClass: "bg-amber-500" },
|
|
||||||
{ label: "High", variant: "warning-light" },
|
|
||||||
],
|
|
||||||
actions: [{ label: "Review" }, { label: "Clear", variant: "outline" }],
|
|
||||||
date: "18 minutes ago",
|
|
||||||
dateTime: "2026-05-06T09:32:00+05:00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
user: "Iris Chen",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=96&h=96&dpr=2&q=80",
|
|
||||||
action: "reconciled ledger entry",
|
|
||||||
target: "LDG-7749",
|
|
||||||
detail: "Subscription invoice matched to bank settlement",
|
|
||||||
badges: [{ label: "Ledger", dotClass: "bg-sky-500" }],
|
|
||||||
attachment: {
|
|
||||||
name: "settlement-match.csv",
|
|
||||||
size: "48kb",
|
|
||||||
},
|
|
||||||
date: "42 minutes ago",
|
|
||||||
dateTime: "2026-05-06T09:08:00+05:00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
user: "Marcus Bell",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
|
||||||
action: "raised limit for",
|
|
||||||
target: "Northstar Workspace",
|
|
||||||
detail: "Monthly card volume increased to $850K",
|
|
||||||
badges: [{ label: "Limit Raised", dotClass: "bg-violet-500" }],
|
|
||||||
participants: [
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "SC",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "MR",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
participantCount: 2,
|
|
||||||
date: "1 hour ago",
|
|
||||||
dateTime: "2026-05-06T08:50:00+05:00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
user: "Amara Okafor",
|
|
||||||
avatar:
|
|
||||||
"https://images.unsplash.com/photo-1544723795-3fb6469f5b39?w=96&h=96&dpr=2&q=80",
|
|
||||||
action: "sent invoice reminder",
|
|
||||||
target: "INV-2098",
|
|
||||||
detail: "Net 7 renewal balance due tomorrow",
|
|
||||||
badges: [
|
|
||||||
{ label: "Invoice", dotClass: "bg-orange-500" },
|
|
||||||
{ label: "Due Tomorrow", variant: "destructive-light" },
|
|
||||||
],
|
|
||||||
attachment: {
|
|
||||||
name: "invoice-2098.pdf",
|
|
||||||
size: "76kb",
|
|
||||||
},
|
|
||||||
date: "2 hours ago",
|
|
||||||
dateTime: "2026-05-06T07:50:00+05:00",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import {
|
|
||||||
Timeline,
|
|
||||||
TimelineContent,
|
|
||||||
TimelineDate,
|
|
||||||
TimelineHeader,
|
|
||||||
TimelineIndicator,
|
|
||||||
TimelineItem,
|
|
||||||
TimelineSeparator,
|
|
||||||
} from "@/components/reui/timeline"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarGroup,
|
|
||||||
AvatarGroupCount,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@evobgp/ui/components/avatar"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { ButtonGroup } from "@evobgp/ui/components/button-group"
|
|
||||||
import {
|
|
||||||
financeActivities,
|
|
||||||
type ActivityDotBadge,
|
|
||||||
type FinanceActivity,
|
|
||||||
} from "./data"
|
|
||||||
import { PaperclipIcon, DownloadIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function getInitials(name: string) {
|
|
||||||
return name
|
|
||||||
.split(" ")
|
|
||||||
.map((part) => part[0])
|
|
||||||
.join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityTarget({ target }: { target: string }) {
|
|
||||||
const idMatch = target.match(/^(.*?)([A-Z]+-\d+)$/)
|
|
||||||
|
|
||||||
if (!idMatch) {
|
|
||||||
return <span className="font-medium">{target}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, prefix, id] = idMatch
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{prefix}
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-foreground hover:text-primary font-medium underline underline-offset-2"
|
|
||||||
>
|
|
||||||
{id}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityAttachment({
|
|
||||||
attachment,
|
|
||||||
}: {
|
|
||||||
attachment: NonNullable<FinanceActivity["attachment"]>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ButtonGroup>
|
|
||||||
<Button type="button" variant="outline" size="xs">
|
|
||||||
<PaperclipIcon aria-hidden="true" />
|
|
||||||
<span className="truncate">{attachment.name}</span>
|
|
||||||
<span className="opacity-60">({attachment.size})</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon-xs"
|
|
||||||
aria-label={`Download ${attachment.name}`}
|
|
||||||
>
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParticipantGroup({
|
|
||||||
participants,
|
|
||||||
count,
|
|
||||||
}: {
|
|
||||||
participants: NonNullable<FinanceActivity["participants"]>
|
|
||||||
count?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AvatarGroup className="-space-x-1">
|
|
||||||
{participants.map((participant) => (
|
|
||||||
<Avatar key={participant.src} className="size-5">
|
|
||||||
<AvatarImage src={participant.src} alt={participant.fallback} />
|
|
||||||
<AvatarFallback className="text-[9px]">
|
|
||||||
{participant.fallback}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
{count && count > 0 ? (
|
|
||||||
<AvatarGroupCount className="size-5 text-[9px] leading-none">
|
|
||||||
+{count}
|
|
||||||
</AvatarGroupCount>
|
|
||||||
) : null}
|
|
||||||
</AvatarGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DotBadge({ badge }: { badge: ActivityDotBadge }) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="gap-1.5">
|
|
||||||
<span
|
|
||||||
className={cn("size-1.5 shrink-0 rounded-full", badge.dotClass)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{badge.label}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityStatusRow({ activity }: { activity: FinanceActivity }) {
|
|
||||||
const [primaryBadge, secondaryBadge] = activity.badges
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
||||||
<TimelineDate dateTime={activity.dateTime} className="mt-0 mb-0">
|
|
||||||
{activity.date}
|
|
||||||
</TimelineDate>
|
|
||||||
<DotBadge badge={primaryBadge} />
|
|
||||||
{secondaryBadge ? (
|
|
||||||
<Badge variant={secondaryBadge.variant}>{secondaryBadge.label}</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FinanceActivityTimeline() {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="w-full max-w-md"
|
|
||||||
aria-labelledby="finance-activity-title"
|
|
||||||
>
|
|
||||||
<div className="mb-5 space-y-0.5">
|
|
||||||
<h1 id="finance-activity-title" className="text-base font-semibold">
|
|
||||||
Finance Activity
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-sm leading-5">
|
|
||||||
Payouts, risk, invoices, and ledger updates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Timeline defaultValue={financeActivities.length}>
|
|
||||||
{financeActivities.map((activity) => (
|
|
||||||
<TimelineItem
|
|
||||||
key={activity.id}
|
|
||||||
step={activity.id}
|
|
||||||
className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
|
||||||
>
|
|
||||||
<TimelineHeader>
|
|
||||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:-left-8 group-data-[orientation=vertical]/timeline:h-[calc(100%-2.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6" />
|
|
||||||
<TimelineIndicator className="size-7 overflow-hidden rounded-full border-none group-data-[orientation=vertical]/timeline:-left-8">
|
|
||||||
<Avatar className="size-7">
|
|
||||||
<AvatarImage src={activity.avatar} alt={activity.user} />
|
|
||||||
<AvatarFallback className="text-[10px] font-medium">
|
|
||||||
{getInitials(activity.user)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</TimelineIndicator>
|
|
||||||
</TimelineHeader>
|
|
||||||
<TimelineContent className="min-w-0 pb-1">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm leading-5">
|
|
||||||
<span className="text-foreground hover:text-primary font-medium">
|
|
||||||
{activity.user}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{activity.action}
|
|
||||||
</span>{" "}
|
|
||||||
<ActivityTarget target={activity.target} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs leading-4">
|
|
||||||
{activity.detail}
|
|
||||||
</p>
|
|
||||||
{(activity.attachment ||
|
|
||||||
activity.actions?.length ||
|
|
||||||
activity.participants?.length) && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
||||||
{activity.attachment ? (
|
|
||||||
<ActivityAttachment attachment={activity.attachment} />
|
|
||||||
) : null}
|
|
||||||
{activity.participants?.length ? (
|
|
||||||
<ParticipantGroup
|
|
||||||
participants={activity.participants}
|
|
||||||
count={activity.participantCount}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activity.actions?.map((action) => (
|
|
||||||
<Button
|
|
||||||
key={action.label}
|
|
||||||
type="button"
|
|
||||||
size="xs"
|
|
||||||
variant={
|
|
||||||
action.variant === "outline" ? "outline" : "default"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ActivityStatusRow activity={activity} />
|
|
||||||
</TimelineContent>
|
|
||||||
</TimelineItem>
|
|
||||||
))}
|
|
||||||
</Timeline>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { FinanceActivityTimeline } from "./components/finance-activity-timeline"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-svh w-full items-start justify-center p-6 sm:p-8 md:p-12">
|
|
||||||
<FinanceActivityTimeline />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface CountedLineTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountedLineTabsProps {
|
||||||
|
tabs: CountedLineTab[]
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
className?: string
|
||||||
|
listClassName?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
||||||
|
export function CountedLineTabs({
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
className,
|
||||||
|
listClassName,
|
||||||
|
children,
|
||||||
|
}: CountedLineTabsProps) {
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onValueChange={onValueChange} className={className}>
|
||||||
|
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.count !== undefined ? (
|
||||||
|
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{children}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
PanelCard,
|
||||||
FrameDescription,
|
panelCardContentFlushClassName,
|
||||||
FrameHeader,
|
} from '@/components/panel-card'
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
|
||||||
export function DashboardFramePanel({
|
export function DashboardFramePanel({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -24,20 +22,15 @@ export function DashboardFramePanel({
|
|||||||
className?: string
|
className?: string
|
||||||
contentClassName?: string
|
contentClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame stacked dense className={cn('h-full w-full', className)}>
|
<PanelCard
|
||||||
{hasHeader ? (
|
title={title}
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b border-(--frame-panel-border-color)">
|
description={description}
|
||||||
<div className="min-w-0 space-y-0.5">
|
actions={actions}
|
||||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
className={cn('h-full', className)}
|
||||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
|
||||||
</div>
|
>
|
||||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
{children}
|
||||||
</FrameHeader>
|
</PanelCard>
|
||||||
) : null}
|
|
||||||
<FramePanel className={cn('flex flex-col p-0!', contentClassName)}>{children}</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user