feat: split big file and update agents.md
This commit is contained in:
56
AGENTS.md
56
AGENTS.md
@@ -5,7 +5,7 @@ FreightOps Control — dense internal logistics dashboard; сохраняй ут
|
|||||||
## Project Specifics
|
## Project Specifics
|
||||||
|
|
||||||
- Source content lives in `src/entities/site-content.ts`; keep visible copy there or directly in route JSX.
|
- Source content lives in `src/entities/site-content.ts`; keep visible copy there or directly in route JSX.
|
||||||
- `src/app` is routing only; page composition belongs to `src/widgets/template-ui.tsx`.
|
- `src/app` is routing only; page composition belongs to per-page widgets (`src/widgets/<page>.tsx`). См. File Map.
|
||||||
- Keep cards at 8px radius or less and preserve the selected palette in `src/app/globals.css`.
|
- Keep cards at 8px radius or less and preserve the selected palette in `src/app/globals.css`.
|
||||||
- This is a static frontend template: do not add real payments, auth, persistence, external API calls, or backend contracts without an explicit product request.
|
- This is a static frontend template: do not add real payments, auth, persistence, external API calls, or backend contracts without an explicit product request.
|
||||||
- After changes run `pnpm lint` and `pnpm build`.
|
- After changes run `pnpm lint` and `pnpm build`.
|
||||||
@@ -14,3 +14,57 @@ FreightOps Control — dense internal logistics dashboard; сохраняй ут
|
|||||||
|
|
||||||
- Кириллица обязательна для видимого текста и выбранных Google Fonts.
|
- Кириллица обязательна для видимого текста и выбранных Google Fonts.
|
||||||
- При AI-правках сохраняйте доменные блоки этого шаблона: они отличают проект от generic landing.
|
- При AI-правках сохраняйте доменные блоки этого шаблона: они отличают проект от generic landing.
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
Источник токенов — `src/app/globals.css` (`@theme` + `:root`/`.dark`). Два шрифта: тело — Inter (`--font-sans`), заголовки/метрики/числа — IBM Plex Mono (`--font-display`, через класс `.font-display` и на `h1/h2/h3`). Работай через семантические классы Tailwind (`bg-card`, `text-foreground`, `text-primary`, `text-muted-foreground`, `border-border`), не хардкодь hex/oklch. Статусные оттенки (emerald/amber/rose) централизованы в `src/shared/lib/ops-tone.ts` — переиспользуй их, а не пиши классы заново.
|
||||||
|
|
||||||
|
Личность: **utilitarian control room** — холодный сине-серый фон (почти белый, лёгкая синяя примесь), глубокий teal primary как рабочий акцент и янтарный accent для предупреждений. Тёмный `foreground` используется как сплошная заливка контрастных панелей (risk queue). Это плотный operator-интерфейс: данные, не маркетинг.
|
||||||
|
|
||||||
|
| Роль | Light | Характер |
|
||||||
|
|---|---|---|
|
||||||
|
| `background` | холодный почти-белый (oklch 0.972 0.004 255) | фон рабочей области |
|
||||||
|
| `foreground` | тёмный сине-чёрный (oklch 0.16 0.012 255) | текст + **инверс-панели** (`bg-foreground text-background`) |
|
||||||
|
| `primary` | глубокий teal (oklch 0.39 0.105 186) | кнопки, иконки, числа, ring, bar-chart |
|
||||||
|
| `secondary` | светлый сине-серый | вторичные badge |
|
||||||
|
| `muted` / `muted-foreground` | сине-серый | подложки иконок, table head, вторичный текст |
|
||||||
|
| `accent` | янтарь (oklch 0.74 0.14 72) | предупреждения (вне семантики — чаще точечно) |
|
||||||
|
| `card` | чистый белый | все панели (`Panel`) |
|
||||||
|
| `border` | мягкий сине-серый | тонкие границы `border border-border` |
|
||||||
|
|
||||||
|
Узнаваемые приёмы (держи их, это и есть «лицо» проекта):
|
||||||
|
- **Mono-числа:** метрики, SLA, проценты и заголовки — `font-display` (IBM Plex Mono), `font-semibold`, `tracking-normal`/`tracking-[0.12em–0.18em]` для caps-лейблов.
|
||||||
|
- **Panel-система:** компонент `Panel` (`@/shared/ui/panel`) — `rounded-md border border-border bg-card` + почти невидимая тень `shadow-[0_1px_0_rgba(15,23,42,0.04)]`. Всё строится из панелей.
|
||||||
|
- **Умеренный radius:** `--radius` = 0.375rem; везде `rounded-md` — мягко, но не «pill».
|
||||||
|
- **Dashboard shell:** `DashboardShell` даёт sticky sidebar (236px) + header-bar с live-бейджами; все рабочие страницы оборачиваются им.
|
||||||
|
- **Статус-токены:** `statusTone`/`routeTone`/`severityTone` — emerald = healthy/green, amber = watch/P2, rose = risk/red/P1; цветные точки и outline-бейджи.
|
||||||
|
- **Тонкие данные:** `Progress` h-2 для capacity/score, CSS bar-chart на `style={{ height }}`, плотные таблицы (`min-w-[860px]`, uppercase thead).
|
||||||
|
- **Inverse hero-блок:** `bg-foreground text-background` для risk queue — единственная тёмная панель на светлом дашборде.
|
||||||
|
|
||||||
|
Do / Don't:
|
||||||
|
- **Do:** держи Panel-сетку, mono-заголовки/числа, sidebar shell, статус-токены из `ops-tone.ts`, плотные таблицы и прогресс-бары; данные — first-class.
|
||||||
|
- **Don't:** маркетинговый hero с крупным sans-заголовком и градиентом, скруглённые «pill»-карточки, тяжёлые тени, декоративные фото — это ломает control-room личность. (`.grainient-field` существует в CSS, но это не часть дашборда — не тащи её в рабочие страницы.)
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Route | Widget |
|
||||||
|
|---|---|
|
||||||
|
| `/` | `src/widgets/ops-dashboard.tsx` (`OpsDashboard`) |
|
||||||
|
| `/dashboard` | `src/widgets/ops-dashboard.tsx` (`OpsDashboard`) |
|
||||||
|
| `/shipments` | `src/widgets/shipment-control.tsx` (`ShipmentControl`) |
|
||||||
|
| `/routes` | `src/widgets/routes-workspace.tsx` (`RoutesWorkspace`) |
|
||||||
|
| `/returns` | `src/widgets/returns-workspace.tsx` (`ReturnsWorkspace`) |
|
||||||
|
| `/analytics` | `src/widgets/analytics-workspace.tsx` (`AnalyticsWorkspace`) |
|
||||||
|
| `/settings` | `src/widgets/settings-workspace.tsx` (`SettingsWorkspace`) |
|
||||||
|
|
||||||
|
Переиспользуемые блоки:
|
||||||
|
- `src/widgets/site-shell.tsx` — `SiteHeader`, `SiteFooter` (рендерятся в `app/layout.tsx`), `DashboardShell` (sidebar + header-bar обёртка всех рабочих страниц).
|
||||||
|
- `src/widgets/metric-strip.tsx` — `MetricStrip` (Dashboard + Analytics).
|
||||||
|
- `src/widgets/control-room-board.tsx` — `ControlRoomBoard` (Dashboard + Routes).
|
||||||
|
- `src/widgets/exception-board.tsx` — `ExceptionBoard` (Dashboard + Shipments + Returns).
|
||||||
|
- `src/widgets/shipment-table.tsx` — `ShipmentTable` (Dashboard + Shipments).
|
||||||
|
- `src/widgets/icon-cards.tsx` — `IconCards` (Returns + Settings).
|
||||||
|
- `src/shared/ui/panel.tsx` / `container.tsx` — `Panel`, `Container` (layout-примитивы шаблона).
|
||||||
|
- `src/shared/lib/ops-tone.ts` — `statusTone`, `routeTone`, `severityTone`.
|
||||||
|
|
||||||
|
Одноразовые блоки колоцированы со своей страницей: `ShiftBriefing` в `ops-dashboard.tsx`; `CatalogToolbar` + `FeaturedGrid` в `shipment-control.tsx`; `PricingTiles` + `RouteTimeline` в `routes-workspace.tsx`; `ReturnsBoard` в `returns-workspace.tsx`; `OpsAnalytics` + `TestimonialBand` в `analytics-workspace.tsx`; `SettingsPanel` в `settings-workspace.tsx`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnalyticsWorkspace } from "@/widgets/template-ui";
|
import { AnalyticsWorkspace } from "@/widgets/analytics-workspace";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <AnalyticsWorkspace />;
|
return <AnalyticsWorkspace />;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OpsDashboard } from "@/widgets/template-ui";
|
import { OpsDashboard } from "@/widgets/ops-dashboard";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <OpsDashboard />;
|
return <OpsDashboard />;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IBM_Plex_Mono, Inter } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/shared/hooks/theme-provider";
|
import { ThemeProvider } from "@/shared/hooks/theme-provider";
|
||||||
import { ThemeMessageListener } from "@/shared/hooks/theme-message-listener";
|
import { ThemeMessageListener } from "@/shared/hooks/theme-message-listener";
|
||||||
import { SiteHeader, SiteFooter } from "@/widgets/template-ui";
|
import { SiteHeader, SiteFooter } from "@/widgets/site-shell";
|
||||||
|
|
||||||
const mono = IBM_Plex_Mono({
|
const mono = IBM_Plex_Mono({
|
||||||
variable: "--font-display",
|
variable: "--font-display",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OpsDashboard } from "@/widgets/template-ui";
|
import { OpsDashboard } from "@/widgets/ops-dashboard";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <OpsDashboard />;
|
return <OpsDashboard />;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReturnsWorkspace } from "@/widgets/template-ui";
|
import { ReturnsWorkspace } from "@/widgets/returns-workspace";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <ReturnsWorkspace />;
|
return <ReturnsWorkspace />;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RoutesWorkspace } from "@/widgets/template-ui";
|
import { RoutesWorkspace } from "@/widgets/routes-workspace";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <RoutesWorkspace />;
|
return <RoutesWorkspace />;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SettingsWorkspace } from "@/widgets/template-ui";
|
import { SettingsWorkspace } from "@/widgets/settings-workspace";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <SettingsWorkspace />;
|
return <SettingsWorkspace />;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ShipmentControl } from "@/widgets/template-ui";
|
import { ShipmentControl } from "@/widgets/shipment-control";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <ShipmentControl />;
|
return <ShipmentControl />;
|
||||||
|
|||||||
16
src/shared/lib/ops-tone.ts
Normal file
16
src/shared/lib/ops-tone.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const statusTone = {
|
||||||
|
green: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||||
|
amber: "border-amber-200 bg-amber-50 text-amber-800",
|
||||||
|
red: "border-rose-200 bg-rose-50 text-rose-700",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const routeTone = {
|
||||||
|
healthy: "bg-emerald-500",
|
||||||
|
watch: "bg-amber-500",
|
||||||
|
risk: "bg-rose-500",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const severityTone = {
|
||||||
|
P1: "border-rose-200 bg-rose-50 text-rose-700",
|
||||||
|
P2: "border-amber-200 bg-amber-50 text-amber-800",
|
||||||
|
} as const;
|
||||||
5
src/shared/ui/container.tsx
Normal file
5
src/shared/ui/container.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Container({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||||
|
return <section className={`mx-auto w-full max-w-[1440px] px-4 sm:px-6 ${className}`}>{children}</section>;
|
||||||
|
}
|
||||||
5
src/shared/ui/panel.tsx
Normal file
5
src/shared/ui/panel.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||||
|
return <div className={`rounded-md border border-border bg-card shadow-[0_1px_0_rgba(15,23,42,0.04)] ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
78
src/widgets/analytics-workspace.tsx
Normal file
78
src/widgets/analytics-workspace.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { analyticsBars, carrierScores, highlights, testimonials } from "@/entities/site-content";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Progress } from "@/shared/ui/progress";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
import { DashboardShell } from "@/widgets/site-shell";
|
||||||
|
import { MetricStrip } from "@/widgets/metric-strip";
|
||||||
|
|
||||||
|
type TestimonialItem = {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
rating: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function OpsAnalytics() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Volume by checkpoint</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Shipped, delayed и returned по контрольным часам смены.</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="rounded-md">static chart</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid h-[320px] grid-cols-6 items-end gap-3 border-b border-l border-border px-3 pb-3">
|
||||||
|
{analyticsBars.map((bar) => (
|
||||||
|
<div key={bar.label} className="grid h-full content-end gap-1">
|
||||||
|
<div className="rounded-t-sm bg-primary" style={{ height: `${bar.shipped}%` }} />
|
||||||
|
<div className="rounded-t-sm bg-amber-400" style={{ height: `${bar.delayed}%` }} />
|
||||||
|
<div className="rounded-t-sm bg-rose-400" style={{ height: `${bar.returned}%` }} />
|
||||||
|
<div className="pt-2 text-center text-xs text-muted-foreground">{bar.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<Panel className="p-4">
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Carrier score</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Оценка курьеров по scans, ETA drift и missed slots.</p>
|
||||||
|
<div className="mt-5 grid gap-4">
|
||||||
|
{carrierScores.map((carrier) => (
|
||||||
|
<div key={carrier.name}>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="font-medium">{carrier.name}</span>
|
||||||
|
<span className="font-display font-semibold">{carrier.score}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={carrier.score} className="h-2" />
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{carrier.meta}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestimonialBand({ items }: { items: readonly TestimonialItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Panel key={item.name} className="p-5">
|
||||||
|
<div className="text-sm font-semibold text-primary">{item.rating}</div>
|
||||||
|
<p className="mt-4 font-display text-2xl leading-tight">"{item.text}"</p>
|
||||||
|
<footer className="mt-4 text-sm text-muted-foreground">{item.name}</footer>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsWorkspace() {
|
||||||
|
return (
|
||||||
|
<DashboardShell title="Analytics" description="Операционная аналитика по смене: объем, задержки, возвраты, carrier score и SLA movement.">
|
||||||
|
<MetricStrip items={highlights} />
|
||||||
|
<OpsAnalytics />
|
||||||
|
<TestimonialBand items={testimonials} />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/widgets/control-room-board.tsx
Normal file
87
src/widgets/control-room-board.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { RouteIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { routeLanes, sidePanels, warehouseNodes } from "@/entities/site-content";
|
||||||
|
import { routeTone } from "@/shared/lib/ops-tone";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Progress } from "@/shared/ui/progress";
|
||||||
|
import { Separator } from "@/shared/ui/separator";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
|
||||||
|
export function ControlRoomBoard() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Route health</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Маршруты читаются по SLA, capacity и следующему checkpoint.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="h-10 w-fit rounded-md">
|
||||||
|
<RouteIcon className="size-4" />
|
||||||
|
Rebalance
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{routeLanes.map((route) => (
|
||||||
|
<div key={route.name} className="rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`size-2 rounded-full ${routeTone[route.status]}`} />
|
||||||
|
<h3 className="font-semibold">{route.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{route.stops} stops</span>
|
||||||
|
<span>capacity {route.capacity}%</span>
|
||||||
|
<span>delay {route.delay}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-display text-3xl font-semibold">{route.sla}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Progress value={route.capacity} className="h-2" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-2 sm:grid-cols-5">
|
||||||
|
{route.checkpoints.map((checkpoint, index) => (
|
||||||
|
<div key={`${route.name}-${checkpoint}-${index}`} className="rounded-md bg-muted px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{checkpoint}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<Panel className="p-4">
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Warehouse load</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Где появится очередь до того, как она станет SLA breach.</p>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{warehouseNodes.map((node) => (
|
||||||
|
<div key={node.name}>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="font-medium">{node.name}</span>
|
||||||
|
<span className={node.status === "hot" ? "font-semibold text-rose-600" : "text-muted-foreground"}>{node.load}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={node.load} className="h-2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator className="my-5" />
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{sidePanels.slice(3).map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<div key={item.title} className="flex items-start gap-3 rounded-md bg-muted p-3">
|
||||||
|
<Icon className="mt-0.5 size-4 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{item.title}: {item.value}</div>
|
||||||
|
<div className="mt-1 text-xs leading-5 text-muted-foreground">{item.text}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/widgets/exception-board.tsx
Normal file
42
src/widgets/exception-board.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { ClockIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { exceptionQueue } from "@/entities/site-content";
|
||||||
|
import { severityTone } from "@/shared/lib/ops-tone";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Separator } from "@/shared/ui/separator";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
|
||||||
|
export function ExceptionBoard() {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Exception queue</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Каждое исключение имеет owner, timer и следующее действие.</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="w-fit rounded-md border-rose-200 bg-rose-50 text-rose-700">
|
||||||
|
no fire-and-forget handoff
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{exceptionQueue.map((item) => (
|
||||||
|
<article key={item.title} className="rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-3">
|
||||||
|
<Badge variant="outline" className={`rounded-md ${severityTone[item.severity]}`}>
|
||||||
|
{item.severity}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<ClockIcon className="size-3.5" />
|
||||||
|
{item.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold leading-tight">{item.title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{item.owner}</p>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<p className="text-sm leading-6">{item.action}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/widgets/icon-cards.tsx
Normal file
26
src/widgets/icon-cards.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
|
||||||
|
type IconItem = {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconCards({ items }: { items: readonly IconItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Panel key={item.title} className="p-4">
|
||||||
|
<Icon className="mb-6 size-5 text-primary" />
|
||||||
|
<h3 className="font-semibold">{item.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/widgets/metric-strip.tsx
Normal file
41
src/widgets/metric-strip.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
|
||||||
|
type MetricItem = {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
text: string;
|
||||||
|
trend: string;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MetricStrip({ items }: { items: readonly MetricItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isNegative = item.trend.startsWith("-");
|
||||||
|
return (
|
||||||
|
<Panel key={item.title} className="p-4">
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-primary">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`rounded-md ${isNegative ? "border-rose-200 bg-rose-50 text-rose-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`}
|
||||||
|
>
|
||||||
|
{item.trend}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="font-display text-4xl font-semibold tracking-normal">{item.value}</div>
|
||||||
|
<h3 className="mt-2 font-semibold">{item.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/widgets/ops-dashboard.tsx
Normal file
77
src/widgets/ops-dashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { BellIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { exceptionQueue, highlights } from "@/entities/site-content";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
import { DashboardShell } from "@/widgets/site-shell";
|
||||||
|
import { MetricStrip } from "@/widgets/metric-strip";
|
||||||
|
import { ControlRoomBoard } from "@/widgets/control-room-board";
|
||||||
|
import { ExceptionBoard } from "@/widgets/exception-board";
|
||||||
|
import { ShipmentTable } from "@/widgets/shipment-table";
|
||||||
|
|
||||||
|
function ShiftBriefing() {
|
||||||
|
return (
|
||||||
|
<Panel className="overflow-hidden">
|
||||||
|
<div className="grid gap-0 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div className="p-5 sm:p-6">
|
||||||
|
<Badge className="mb-5 rounded-md bg-foreground text-background hover:bg-foreground">dispatch briefing</Badge>
|
||||||
|
<h2 className="max-w-[760px] font-display text-4xl font-semibold leading-[1.02] tracking-normal sm:text-5xl">
|
||||||
|
Сегодня смена ломается не объемом, а исключениями на маршрутах
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 max-w-[720px] text-sm leading-7 text-muted-foreground">
|
||||||
|
Фокус диспетчера: missed pickup на North-1, cold chain ETA drift и return bay load 88%. Панель показывает, кто владеет
|
||||||
|
каждым риском и какое действие нужно следующим.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
["Next checkpoint", "14:30", "call courier C04"],
|
||||||
|
["Warehouse load", "84%", "line 1 is hot"],
|
||||||
|
["Customer notices", "17", "ready to send"],
|
||||||
|
].map(([label, value, text]) => (
|
||||||
|
<div key={label} className="rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-3 font-display text-3xl font-semibold">{value}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border bg-foreground p-5 text-background lg:border-l lg:border-t-0 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-background/60">risk queue</div>
|
||||||
|
<div className="mt-2 font-display text-5xl font-semibold">27</div>
|
||||||
|
</div>
|
||||||
|
<BellIcon className="size-8 text-amber-300" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-7 grid gap-3">
|
||||||
|
{exceptionQueue.slice(0, 3).map((item) => (
|
||||||
|
<div key={item.title} className="rounded-md border border-background/12 bg-background/8 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium">{item.title}</div>
|
||||||
|
<span className="rounded-md bg-background px-2 py-1 text-xs font-semibold text-foreground">{item.time}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-background/68">{item.action}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsDashboard() {
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
title="Control room"
|
||||||
|
description="Главная смены: SLA, живые отправления, исключения, маршруты и capacity склада без маркетинговой обертки."
|
||||||
|
>
|
||||||
|
<ShiftBriefing />
|
||||||
|
<MetricStrip items={highlights} />
|
||||||
|
<ControlRoomBoard />
|
||||||
|
<ExceptionBoard />
|
||||||
|
<ShipmentTable />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/widgets/returns-workspace.tsx
Normal file
54
src/widgets/returns-workspace.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { RefreshCcwIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { eventTypes, returnCases } from "@/entities/site-content";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Separator } from "@/shared/ui/separator";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
import { DashboardShell } from "@/widgets/site-shell";
|
||||||
|
import { ExceptionBoard } from "@/widgets/exception-board";
|
||||||
|
import { IconCards } from "@/widgets/icon-cards";
|
||||||
|
|
||||||
|
function ReturnsBoard() {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Return inspection</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Refund readiness зависит от evidence, причины и состояния товара.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="h-10 w-fit rounded-md">
|
||||||
|
<RefreshCcwIcon className="size-4" />
|
||||||
|
Create batch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
|
{returnCases.map((item) => (
|
||||||
|
<article key={item.id} className="rounded-md border border-border bg-background p-4">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-3">
|
||||||
|
<Badge variant="outline" className="rounded-md">{item.id}</Badge>
|
||||||
|
<span className="font-semibold">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">{item.reason}</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{item.evidence}</p>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium">Decision</span>
|
||||||
|
<Badge className="rounded-md">{item.decision}</Badge>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnsWorkspace() {
|
||||||
|
return (
|
||||||
|
<DashboardShell title="Returns" description="Очередь возвратов с reason, value, evidence readiness и решением по следующему шагу.">
|
||||||
|
<ReturnsBoard />
|
||||||
|
<ExceptionBoard />
|
||||||
|
<IconCards items={eventTypes} />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/widgets/routes-workspace.tsx
Normal file
82
src/widgets/routes-workspace.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { CheckIcon, TruckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { routeLanes, tastingSets } from "@/entities/site-content";
|
||||||
|
import { routeTone } from "@/shared/lib/ops-tone";
|
||||||
|
import { Separator } from "@/shared/ui/separator";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
import { DashboardShell } from "@/widgets/site-shell";
|
||||||
|
import { ControlRoomBoard } from "@/widgets/control-room-board";
|
||||||
|
|
||||||
|
type TileItem = {
|
||||||
|
title: string;
|
||||||
|
price: string;
|
||||||
|
items: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function PricingTiles({ title, items }: { title: string; items: readonly TileItem[] }) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<h2 className="mb-5 font-display text-2xl font-semibold">{title}</h2>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<article key={item.title} className="rounded-md border border-border bg-background p-4">
|
||||||
|
<h3 className="font-semibold">{item.title}</h3>
|
||||||
|
<div className="mt-3 font-display text-3xl font-semibold">{item.price}</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{item.items.map((line) => (
|
||||||
|
<div key={line} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<CheckIcon className="size-4 text-primary" />
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteTimeline() {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Stop timeline</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Timeline нужен для ответа на вопрос: где потеряем слот.</p>
|
||||||
|
</div>
|
||||||
|
<TruckIcon className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{routeLanes.map((route) => (
|
||||||
|
<div key={route.name} className="grid gap-3 lg:grid-cols-[160px_1fr_92px] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{route.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{route.delay}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{route.checkpoints.map((checkpoint, index) => (
|
||||||
|
<div key={`${route.name}-${checkpoint}-${index}`} className="relative rounded-md bg-muted px-3 py-3 text-xs font-medium text-muted-foreground">
|
||||||
|
<span className={`mb-2 block size-2 rounded-full ${index < 3 ? routeTone[route.status] : "bg-border"}`} />
|
||||||
|
{checkpoint}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="font-display text-2xl font-semibold">{route.sla}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutesWorkspace() {
|
||||||
|
return (
|
||||||
|
<DashboardShell title="Routes" description="Маршруты, stops, capacity, delay windows и решение о rebalancing.">
|
||||||
|
<ControlRoomBoard />
|
||||||
|
<PricingTiles title="Route snapshots" items={tastingSets} />
|
||||||
|
<RouteTimeline />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/widgets/settings-workspace.tsx
Normal file
43
src/widgets/settings-workspace.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SettingsIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { contactCards, settingsRules } from "@/entities/site-content";
|
||||||
|
import { Switch } from "@/shared/ui/switch";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
import { DashboardShell } from "@/widgets/site-shell";
|
||||||
|
import { IconCards } from "@/widgets/icon-cards";
|
||||||
|
|
||||||
|
function SettingsPanel() {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Dispatch rules</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Настройки описывают операционные правила, но не подключены к API.</p>
|
||||||
|
</div>
|
||||||
|
<SettingsIcon className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{settingsRules.map((rule) => (
|
||||||
|
<div key={rule.title} className="flex flex-col gap-4 rounded-md border border-border bg-background p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{rule.title}</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">{rule.text}</p>
|
||||||
|
</div>
|
||||||
|
<Switch defaultChecked={rule.enabled} aria-label={rule.title} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsWorkspace() {
|
||||||
|
return (
|
||||||
|
<DashboardShell title="Rules" description="Warehouses, notification rules, carrier integrations и audit-friendly operational policy.">
|
||||||
|
<IconCards items={contactCards} />
|
||||||
|
<SettingsPanel />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/widgets/shipment-control.tsx
Normal file
90
src/widgets/shipment-control.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { FilterIcon, SearchIcon, SlidersHorizontalIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { products } from "@/entities/site-content";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
import { DashboardShell } from "@/widgets/site-shell";
|
||||||
|
import { ShipmentTable } from "@/widgets/shipment-table";
|
||||||
|
import { ExceptionBoard } from "@/widgets/exception-board";
|
||||||
|
|
||||||
|
type TextItem = {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
tag: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CatalogToolbar() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-md border border-border bg-card p-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex min-h-11 flex-1 items-center gap-2 rounded-md border border-border bg-background px-3">
|
||||||
|
<SearchIcon className="size-4 text-muted-foreground" />
|
||||||
|
<Input className="border-0 bg-transparent shadow-none focus-visible:ring-0" placeholder="Search by order, zone, courier, SLA risk" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" className="h-11 rounded-md">
|
||||||
|
<FilterIcon className="size-4" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-11 rounded-md">
|
||||||
|
<SlidersHorizontalIcon className="size-4" />
|
||||||
|
Sort
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedGrid({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
items: readonly TextItem[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-5 grid gap-3 lg:grid-cols-[0.8fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{eyebrow}</div>
|
||||||
|
<h2 className="mt-2 font-display text-2xl font-semibold sm:text-3xl">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-[720px] text-sm leading-6 text-muted-foreground">{text}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<article key={item.name} className="rounded-md border border-border bg-background p-4">
|
||||||
|
<Badge variant="outline" className="mb-6 rounded-md">
|
||||||
|
{item.tag}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-sm font-semibold text-primary">{item.price}</div>
|
||||||
|
<h3 className="mt-3 font-semibold">{item.name}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShipmentControl() {
|
||||||
|
return (
|
||||||
|
<DashboardShell title="Shipments" description="Отправления, scan checkpoints, courier owner, SLA flags и быстрые действия оператора.">
|
||||||
|
<CatalogToolbar />
|
||||||
|
<ShipmentTable />
|
||||||
|
<ExceptionBoard />
|
||||||
|
<FeaturedGrid
|
||||||
|
eyebrow="Priority workload"
|
||||||
|
title="Очередь, которую диспетчер должен разобрать первой"
|
||||||
|
text="Карточки показывают не преимущества продукта, а реальные операционные записи: статус, риск, владелец и next action."
|
||||||
|
items={products}
|
||||||
|
/>
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/widgets/shipment-table.tsx
Normal file
64
src/widgets/shipment-table.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { PackageCheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { shipmentRows } from "@/entities/site-content";
|
||||||
|
import { statusTone } from "@/shared/lib/ops-tone";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Progress } from "@/shared/ui/progress";
|
||||||
|
import { Panel } from "@/shared/ui/panel";
|
||||||
|
|
||||||
|
export function ShipmentTable() {
|
||||||
|
return (
|
||||||
|
<Panel className="overflow-hidden">
|
||||||
|
<div className="flex flex-col gap-3 border-b border-border p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold">Live shipments</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Рабочая таблица для dispatch review, а не декоративный список.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="h-10 w-fit rounded-md">
|
||||||
|
<PackageCheckIcon className="size-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[860px] text-left text-sm">
|
||||||
|
<thead className="bg-muted text-xs uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-semibold">Order</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Customer</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Zone</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Status</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">ETA</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">SLA</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Progress</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shipmentRows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-t border-border">
|
||||||
|
<td className="px-4 py-4 font-semibold">{row.id}</td>
|
||||||
|
<td className="px-4 py-4">{row.customer}</td>
|
||||||
|
<td className="px-4 py-4 text-muted-foreground">{row.zone}</td>
|
||||||
|
<td className="px-4 py-4">{row.status}</td>
|
||||||
|
<td className="px-4 py-4 font-medium">{row.eta}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<Badge variant="outline" className={`rounded-md ${statusTone[row.sla]}`}>
|
||||||
|
{row.sla}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Progress value={row.progress} className="h-2 min-w-24" />
|
||||||
|
<span className="text-xs text-muted-foreground">{row.progress}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right font-medium">{row.value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/widgets/site-shell.tsx
Normal file
163
src/widgets/site-shell.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
CalendarClockIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CircleAlertIcon,
|
||||||
|
MenuIcon,
|
||||||
|
RadioTowerIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { shiftSummary, sidePanels, site } from "@/entities/site-content";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Separator } from "@/shared/ui/separator";
|
||||||
|
import { Container } from "@/shared/ui/container";
|
||||||
|
|
||||||
|
type ShellProps = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-border/80 bg-background/94 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex h-16 w-full max-w-[1440px] items-center justify-between px-4 sm:px-6">
|
||||||
|
<Link href="/" className="flex items-center gap-3">
|
||||||
|
<span className="flex size-9 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">
|
||||||
|
FC
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block font-display text-base font-semibold leading-none">{site.name}</span>
|
||||||
|
<span className="hidden text-[11px] uppercase tracking-[0.18em] text-muted-foreground sm:block">
|
||||||
|
{shiftSummary.operatingMode}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden items-center gap-1 lg:flex">
|
||||||
|
{site.nav.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="hidden rounded-md border-amber-200 bg-amber-50 text-amber-800 sm:inline-flex">
|
||||||
|
<CircleAlertIcon className="size-3.5" />
|
||||||
|
{shiftSummary.riskLabel}
|
||||||
|
</Badge>
|
||||||
|
<Button asChild className="hidden h-10 rounded-md px-4 md:inline-flex">
|
||||||
|
<Link href="/shipments">{site.cta}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" className="rounded-md lg:hidden" aria-label="Open menu">
|
||||||
|
<MenuIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-border bg-card">
|
||||||
|
<div className="mx-auto grid w-full max-w-[1440px] gap-8 px-4 py-8 sm:px-6 md:grid-cols-[1.3fr_1fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<div className="font-display text-xl font-semibold">{site.name}</div>
|
||||||
|
<p className="mt-3 max-w-[560px] text-sm leading-6 text-muted-foreground">{site.tagline}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="mb-1 font-semibold text-foreground">Рабочие зоны</div>
|
||||||
|
{site.nav.slice(0, 4).map((item) => (
|
||||||
|
<Link key={item.href} href={item.href} className="hover:text-foreground">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm leading-6 text-muted-foreground">
|
||||||
|
<div className="mb-1 font-semibold text-foreground">Template note</div>
|
||||||
|
Static dashboard UI: no auth, payments, persistence, carrier APIs, or background jobs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardShell({ title, description, children }: ShellProps) {
|
||||||
|
return (
|
||||||
|
<Container className="py-5">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[236px_1fr]">
|
||||||
|
<aside className="h-fit rounded-md border border-border bg-card p-3 lg:sticky lg:top-20">
|
||||||
|
<div className="px-2 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Shift</div>
|
||||||
|
<div className="mt-2 font-display text-2xl font-semibold">{shiftSummary.window}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">{shiftSummary.warehouse}</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<nav className="grid gap-1">
|
||||||
|
{site.nav.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center justify-between rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{sidePanels.slice(0, 3).map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<div key={item.title} className="rounded-md bg-muted p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Icon className="size-4 text-primary" />
|
||||||
|
<span className="font-display text-sm font-semibold">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs font-medium">{item.title}</div>
|
||||||
|
<div className="mt-1 text-xs leading-5 text-muted-foreground">{item.text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 rounded-md border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="rounded-md">
|
||||||
|
<RadioTowerIcon className="size-3.5" />
|
||||||
|
live operations
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="rounded-md">
|
||||||
|
{shiftSummary.dispatcher}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-3xl font-semibold tracking-normal sm:text-4xl">{title}</h1>
|
||||||
|
<p className="mt-2 max-w-[760px] text-sm leading-6 text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button variant="outline" className="h-10 rounded-md">
|
||||||
|
<CalendarClockIcon className="size-4" />
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
<Button className="h-10 rounded-md">
|
||||||
|
Dispatch
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,856 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { ComponentType, ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
ArrowRightIcon,
|
|
||||||
BellIcon,
|
|
||||||
CalendarClockIcon,
|
|
||||||
CheckIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
CircleAlertIcon,
|
|
||||||
ClockIcon,
|
|
||||||
FilterIcon,
|
|
||||||
MenuIcon,
|
|
||||||
PackageCheckIcon,
|
|
||||||
RadioTowerIcon,
|
|
||||||
RefreshCcwIcon,
|
|
||||||
RouteIcon,
|
|
||||||
SearchIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
TruckIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
analyticsBars,
|
|
||||||
carrierScores,
|
|
||||||
contactCards,
|
|
||||||
eventTypes,
|
|
||||||
exceptionQueue,
|
|
||||||
highlights,
|
|
||||||
products,
|
|
||||||
returnCases,
|
|
||||||
routeLanes,
|
|
||||||
settingsRules,
|
|
||||||
shiftSummary,
|
|
||||||
shipmentRows,
|
|
||||||
sidePanels,
|
|
||||||
site,
|
|
||||||
tastingSets,
|
|
||||||
testimonials,
|
|
||||||
warehouseNodes,
|
|
||||||
} from "@/entities/site-content";
|
|
||||||
import { Badge } from "@/shared/ui/badge";
|
|
||||||
import { Button } from "@/shared/ui/button";
|
|
||||||
import { Input } from "@/shared/ui/input";
|
|
||||||
import { Progress } from "@/shared/ui/progress";
|
|
||||||
import { Separator } from "@/shared/ui/separator";
|
|
||||||
import { Switch } from "@/shared/ui/switch";
|
|
||||||
|
|
||||||
type IconComponent = ComponentType<{ className?: string }>;
|
|
||||||
|
|
||||||
type MetricItem = {
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
text: string;
|
|
||||||
trend: string;
|
|
||||||
icon: IconComponent;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TextItem = {
|
|
||||||
name: string;
|
|
||||||
price: string;
|
|
||||||
tag: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TileItem = {
|
|
||||||
title: string;
|
|
||||||
price: string;
|
|
||||||
items: readonly string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type IconItem = {
|
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
icon: IconComponent;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TestimonialItem = {
|
|
||||||
name: string;
|
|
||||||
text: string;
|
|
||||||
rating: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShellProps = {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusTone = {
|
|
||||||
green: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
||||||
amber: "border-amber-200 bg-amber-50 text-amber-800",
|
|
||||||
red: "border-rose-200 bg-rose-50 text-rose-700",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const routeTone = {
|
|
||||||
healthy: "bg-emerald-500",
|
|
||||||
watch: "bg-amber-500",
|
|
||||||
risk: "bg-rose-500",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const severityTone = {
|
|
||||||
P1: "border-rose-200 bg-rose-50 text-rose-700",
|
|
||||||
P2: "border-amber-200 bg-amber-50 text-amber-800",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function Container({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
||||||
return <section className={`mx-auto w-full max-w-[1440px] px-4 sm:px-6 ${className}`}>{children}</section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
||||||
return <div className={`rounded-md border border-border bg-card shadow-[0_1px_0_rgba(15,23,42,0.04)] ${className}`}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteHeader() {
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-50 border-b border-border/80 bg-background/94 backdrop-blur-xl">
|
|
||||||
<div className="mx-auto flex h-16 w-full max-w-[1440px] items-center justify-between px-4 sm:px-6">
|
|
||||||
<Link href="/" className="flex items-center gap-3">
|
|
||||||
<span className="flex size-9 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">
|
|
||||||
FC
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<span className="block font-display text-base font-semibold leading-none">{site.name}</span>
|
|
||||||
<span className="hidden text-[11px] uppercase tracking-[0.18em] text-muted-foreground sm:block">
|
|
||||||
{shiftSummary.operatingMode}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<nav className="hidden items-center gap-1 lg:flex">
|
|
||||||
{site.nav.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="hidden rounded-md border-amber-200 bg-amber-50 text-amber-800 sm:inline-flex">
|
|
||||||
<CircleAlertIcon className="size-3.5" />
|
|
||||||
{shiftSummary.riskLabel}
|
|
||||||
</Badge>
|
|
||||||
<Button asChild className="hidden h-10 rounded-md px-4 md:inline-flex">
|
|
||||||
<Link href="/shipments">{site.cta}</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="icon" className="rounded-md lg:hidden" aria-label="Open menu">
|
|
||||||
<MenuIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteFooter() {
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-border bg-card">
|
|
||||||
<div className="mx-auto grid w-full max-w-[1440px] gap-8 px-4 py-8 sm:px-6 md:grid-cols-[1.3fr_1fr_1fr]">
|
|
||||||
<div>
|
|
||||||
<div className="font-display text-xl font-semibold">{site.name}</div>
|
|
||||||
<p className="mt-3 max-w-[560px] text-sm leading-6 text-muted-foreground">{site.tagline}</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
|
||||||
<div className="mb-1 font-semibold text-foreground">Рабочие зоны</div>
|
|
||||||
{site.nav.slice(0, 4).map((item) => (
|
|
||||||
<Link key={item.href} href={item.href} className="hover:text-foreground">
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm leading-6 text-muted-foreground">
|
|
||||||
<div className="mb-1 font-semibold text-foreground">Template note</div>
|
|
||||||
Static dashboard UI: no auth, payments, persistence, carrier APIs, or background jobs.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardShell({ title, description, children }: ShellProps) {
|
|
||||||
return (
|
|
||||||
<Container className="py-5">
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[236px_1fr]">
|
|
||||||
<aside className="h-fit rounded-md border border-border bg-card p-3 lg:sticky lg:top-20">
|
|
||||||
<div className="px-2 py-3">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Shift</div>
|
|
||||||
<div className="mt-2 font-display text-2xl font-semibold">{shiftSummary.window}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">{shiftSummary.warehouse}</div>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<nav className="grid gap-1">
|
|
||||||
{site.nav.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="flex items-center justify-between rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
<ChevronRightIcon className="size-4" />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<Separator className="my-3" />
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{sidePanels.slice(0, 3).map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<div key={item.title} className="rounded-md bg-muted p-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Icon className="size-4 text-primary" />
|
|
||||||
<span className="font-display text-sm font-semibold">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs font-medium">{item.title}</div>
|
|
||||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{item.text}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="mb-4 flex flex-col gap-3 rounded-md border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="outline" className="rounded-md">
|
|
||||||
<RadioTowerIcon className="size-3.5" />
|
|
||||||
live operations
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary" className="rounded-md">
|
|
||||||
{shiftSummary.dispatcher}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<h1 className="font-display text-3xl font-semibold tracking-normal sm:text-4xl">{title}</h1>
|
|
||||||
<p className="mt-2 max-w-[760px] text-sm leading-6 text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
<Button variant="outline" className="h-10 rounded-md">
|
|
||||||
<CalendarClockIcon className="size-4" />
|
|
||||||
Today
|
|
||||||
</Button>
|
|
||||||
<Button className="h-10 rounded-md">
|
|
||||||
Dispatch
|
|
||||||
<ArrowRightIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpsDashboard() {
|
|
||||||
return (
|
|
||||||
<DashboardShell
|
|
||||||
title="Control room"
|
|
||||||
description="Главная смены: SLA, живые отправления, исключения, маршруты и capacity склада без маркетинговой обертки."
|
|
||||||
>
|
|
||||||
<ShiftBriefing />
|
|
||||||
<MetricStrip items={highlights} />
|
|
||||||
<ControlRoomBoard />
|
|
||||||
<ExceptionBoard />
|
|
||||||
<ShipmentTable />
|
|
||||||
</DashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageHero() {
|
|
||||||
return <OpsDashboard />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShiftBriefing() {
|
|
||||||
return (
|
|
||||||
<Panel className="overflow-hidden">
|
|
||||||
<div className="grid gap-0 lg:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<div className="p-5 sm:p-6">
|
|
||||||
<Badge className="mb-5 rounded-md bg-foreground text-background hover:bg-foreground">dispatch briefing</Badge>
|
|
||||||
<h2 className="max-w-[760px] font-display text-4xl font-semibold leading-[1.02] tracking-normal sm:text-5xl">
|
|
||||||
Сегодня смена ломается не объемом, а исключениями на маршрутах
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 max-w-[720px] text-sm leading-7 text-muted-foreground">
|
|
||||||
Фокус диспетчера: missed pickup на North-1, cold chain ETA drift и return bay load 88%. Панель показывает, кто владеет
|
|
||||||
каждым риском и какое действие нужно следующим.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
|
||||||
{[
|
|
||||||
["Next checkpoint", "14:30", "call courier C04"],
|
|
||||||
["Warehouse load", "84%", "line 1 is hot"],
|
|
||||||
["Customer notices", "17", "ready to send"],
|
|
||||||
].map(([label, value, text]) => (
|
|
||||||
<div key={label} className="rounded-md border border-border bg-background p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">{label}</div>
|
|
||||||
<div className="mt-3 font-display text-3xl font-semibold">{value}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{text}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-border bg-foreground p-5 text-background lg:border-l lg:border-t-0 sm:p-6">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-background/60">risk queue</div>
|
|
||||||
<div className="mt-2 font-display text-5xl font-semibold">27</div>
|
|
||||||
</div>
|
|
||||||
<BellIcon className="size-8 text-amber-300" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-7 grid gap-3">
|
|
||||||
{exceptionQueue.slice(0, 3).map((item) => (
|
|
||||||
<div key={item.title} className="rounded-md border border-background/12 bg-background/8 p-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="font-medium">{item.title}</div>
|
|
||||||
<span className="rounded-md bg-background px-2 py-1 text-xs font-semibold text-foreground">{item.time}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs leading-5 text-background/68">{item.action}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetricStrip({ items }: { items: readonly MetricItem[] }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{items.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isNegative = item.trend.startsWith("-");
|
|
||||||
return (
|
|
||||||
<Panel key={item.title} className="p-4">
|
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
|
||||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-primary">
|
|
||||||
<Icon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`rounded-md ${isNegative ? "border-rose-200 bg-rose-50 text-rose-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`}
|
|
||||||
>
|
|
||||||
{item.trend}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="font-display text-4xl font-semibold tracking-normal">{item.value}</div>
|
|
||||||
<h3 className="mt-2 font-semibold">{item.title}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ControlRoomBoard() {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Route health</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Маршруты читаются по SLA, capacity и следующему checkpoint.</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="h-10 w-fit rounded-md">
|
|
||||||
<RouteIcon className="size-4" />
|
|
||||||
Rebalance
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{routeLanes.map((route) => (
|
|
||||||
<div key={route.name} className="rounded-md border border-border bg-background p-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`size-2 rounded-full ${routeTone[route.status]}`} />
|
|
||||||
<h3 className="font-semibold">{route.name}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>{route.stops} stops</span>
|
|
||||||
<span>capacity {route.capacity}%</span>
|
|
||||||
<span>delay {route.delay}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="font-display text-3xl font-semibold">{route.sla}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Progress value={route.capacity} className="h-2" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-2 sm:grid-cols-5">
|
|
||||||
{route.checkpoints.map((checkpoint, index) => (
|
|
||||||
<div key={`${route.name}-${checkpoint}-${index}`} className="rounded-md bg-muted px-3 py-2 text-xs font-medium text-muted-foreground">
|
|
||||||
{checkpoint}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
<Panel className="p-4">
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Warehouse load</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Где появится очередь до того, как она станет SLA breach.</p>
|
|
||||||
<div className="mt-5 grid gap-3">
|
|
||||||
{warehouseNodes.map((node) => (
|
|
||||||
<div key={node.name}>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="font-medium">{node.name}</span>
|
|
||||||
<span className={node.status === "hot" ? "font-semibold text-rose-600" : "text-muted-foreground"}>{node.load}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={node.load} className="h-2" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Separator className="my-5" />
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{sidePanels.slice(3).map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<div key={item.title} className="flex items-start gap-3 rounded-md bg-muted p-3">
|
|
||||||
<Icon className="mt-0.5 size-4 text-primary" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{item.title}: {item.value}</div>
|
|
||||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{item.text}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExceptionBoard() {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Exception queue</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Каждое исключение имеет owner, timer и следующее действие.</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="w-fit rounded-md border-rose-200 bg-rose-50 text-rose-700">
|
|
||||||
no fire-and-forget handoff
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{exceptionQueue.map((item) => (
|
|
||||||
<article key={item.title} className="rounded-md border border-border bg-background p-4">
|
|
||||||
<div className="mb-5 flex items-center justify-between gap-3">
|
|
||||||
<Badge variant="outline" className={`rounded-md ${severityTone[item.severity]}`}>
|
|
||||||
{item.severity}
|
|
||||||
</Badge>
|
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<ClockIcon className="size-3.5" />
|
|
||||||
{item.time}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold leading-tight">{item.title}</h3>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{item.owner}</p>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
<p className="text-sm leading-6">{item.action}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CatalogToolbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 rounded-md border border-border bg-card p-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex min-h-11 flex-1 items-center gap-2 rounded-md border border-border bg-background px-3">
|
|
||||||
<SearchIcon className="size-4 text-muted-foreground" />
|
|
||||||
<Input className="border-0 bg-transparent shadow-none focus-visible:ring-0" placeholder="Search by order, zone, courier, SLA risk" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" className="h-11 rounded-md">
|
|
||||||
<FilterIcon className="size-4" />
|
|
||||||
Filters
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="h-11 rounded-md">
|
|
||||||
<SlidersHorizontalIcon className="size-4" />
|
|
||||||
Sort
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShipmentTable() {
|
|
||||||
return (
|
|
||||||
<Panel className="overflow-hidden">
|
|
||||||
<div className="flex flex-col gap-3 border-b border-border p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Live shipments</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Рабочая таблица для dispatch review, а не декоративный список.</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="h-10 w-fit rounded-md">
|
|
||||||
<PackageCheckIcon className="size-4" />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[860px] text-left text-sm">
|
|
||||||
<thead className="bg-muted text-xs uppercase tracking-[0.12em] text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 font-semibold">Order</th>
|
|
||||||
<th className="px-4 py-3 font-semibold">Customer</th>
|
|
||||||
<th className="px-4 py-3 font-semibold">Zone</th>
|
|
||||||
<th className="px-4 py-3 font-semibold">Status</th>
|
|
||||||
<th className="px-4 py-3 font-semibold">ETA</th>
|
|
||||||
<th className="px-4 py-3 font-semibold">SLA</th>
|
|
||||||
<th className="px-4 py-3 font-semibold">Progress</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold">Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{shipmentRows.map((row) => (
|
|
||||||
<tr key={row.id} className="border-t border-border">
|
|
||||||
<td className="px-4 py-4 font-semibold">{row.id}</td>
|
|
||||||
<td className="px-4 py-4">{row.customer}</td>
|
|
||||||
<td className="px-4 py-4 text-muted-foreground">{row.zone}</td>
|
|
||||||
<td className="px-4 py-4">{row.status}</td>
|
|
||||||
<td className="px-4 py-4 font-medium">{row.eta}</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<Badge variant="outline" className={`rounded-md ${statusTone[row.sla]}`}>
|
|
||||||
{row.sla}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Progress value={row.progress} className="h-2 min-w-24" />
|
|
||||||
<span className="text-xs text-muted-foreground">{row.progress}%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 text-right font-medium">{row.value}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShipmentControl() {
|
|
||||||
return (
|
|
||||||
<DashboardShell title="Shipments" description="Отправления, scan checkpoints, courier owner, SLA flags и быстрые действия оператора.">
|
|
||||||
<CatalogToolbar />
|
|
||||||
<ShipmentTable />
|
|
||||||
<ExceptionBoard />
|
|
||||||
<FeaturedGrid
|
|
||||||
eyebrow="Priority workload"
|
|
||||||
title="Очередь, которую диспетчер должен разобрать первой"
|
|
||||||
text="Карточки показывают не преимущества продукта, а реальные операционные записи: статус, риск, владелец и next action."
|
|
||||||
items={products}
|
|
||||||
/>
|
|
||||||
</DashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RouteTimeline() {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Stop timeline</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Timeline нужен для ответа на вопрос: где потеряем слот.</p>
|
|
||||||
</div>
|
|
||||||
<TruckIcon className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{routeLanes.map((route) => (
|
|
||||||
<div key={route.name} className="grid gap-3 lg:grid-cols-[160px_1fr_92px] lg:items-center">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold">{route.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{route.delay}</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
{route.checkpoints.map((checkpoint, index) => (
|
|
||||||
<div key={`${route.name}-${checkpoint}-${index}`} className="relative rounded-md bg-muted px-3 py-3 text-xs font-medium text-muted-foreground">
|
|
||||||
<span className={`mb-2 block size-2 rounded-full ${index < 3 ? routeTone[route.status] : "bg-border"}`} />
|
|
||||||
{checkpoint}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="font-display text-2xl font-semibold">{route.sla}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoutesWorkspace() {
|
|
||||||
return (
|
|
||||||
<DashboardShell title="Routes" description="Маршруты, stops, capacity, delay windows и решение о rebalancing.">
|
|
||||||
<ControlRoomBoard />
|
|
||||||
<PricingTiles title="Route snapshots" items={tastingSets} />
|
|
||||||
<RouteTimeline />
|
|
||||||
</DashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReturnsWorkspace() {
|
|
||||||
return (
|
|
||||||
<DashboardShell title="Returns" description="Очередь возвратов с reason, value, evidence readiness и решением по следующему шагу.">
|
|
||||||
<ReturnsBoard />
|
|
||||||
<ExceptionBoard />
|
|
||||||
<IconCards items={eventTypes} />
|
|
||||||
</DashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReturnsBoard() {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Return inspection</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Refund readiness зависит от evidence, причины и состояния товара.</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="h-10 w-fit rounded-md">
|
|
||||||
<RefreshCcwIcon className="size-4" />
|
|
||||||
Create batch
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 lg:grid-cols-3">
|
|
||||||
{returnCases.map((item) => (
|
|
||||||
<article key={item.id} className="rounded-md border border-border bg-background p-4">
|
|
||||||
<div className="mb-6 flex items-center justify-between gap-3">
|
|
||||||
<Badge variant="outline" className="rounded-md">{item.id}</Badge>
|
|
||||||
<span className="font-semibold">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold">{item.reason}</h3>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{item.evidence}</p>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-sm font-medium">Decision</span>
|
|
||||||
<Badge className="rounded-md">{item.decision}</Badge>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpsAnalytics() {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Volume by checkpoint</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Shipped, delayed и returned по контрольным часам смены.</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="rounded-md">static chart</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid h-[320px] grid-cols-6 items-end gap-3 border-b border-l border-border px-3 pb-3">
|
|
||||||
{analyticsBars.map((bar) => (
|
|
||||||
<div key={bar.label} className="grid h-full content-end gap-1">
|
|
||||||
<div className="rounded-t-sm bg-primary" style={{ height: `${bar.shipped}%` }} />
|
|
||||||
<div className="rounded-t-sm bg-amber-400" style={{ height: `${bar.delayed}%` }} />
|
|
||||||
<div className="rounded-t-sm bg-rose-400" style={{ height: `${bar.returned}%` }} />
|
|
||||||
<div className="pt-2 text-center text-xs text-muted-foreground">{bar.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
<Panel className="p-4">
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Carrier score</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Оценка курьеров по scans, ETA drift и missed slots.</p>
|
|
||||||
<div className="mt-5 grid gap-4">
|
|
||||||
{carrierScores.map((carrier) => (
|
|
||||||
<div key={carrier.name}>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="font-medium">{carrier.name}</span>
|
|
||||||
<span className="font-display font-semibold">{carrier.score}</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={carrier.score} className="h-2" />
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{carrier.meta}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnalyticsWorkspace() {
|
|
||||||
return (
|
|
||||||
<DashboardShell title="Analytics" description="Операционная аналитика по смене: объем, задержки, возвраты, carrier score и SLA movement.">
|
|
||||||
<MetricStrip items={highlights} />
|
|
||||||
<OpsAnalytics />
|
|
||||||
<TestimonialBand items={testimonials} />
|
|
||||||
</DashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsPanel() {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-display text-2xl font-semibold">Dispatch rules</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Настройки описывают операционные правила, но не подключены к API.</p>
|
|
||||||
</div>
|
|
||||||
<SettingsIcon className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{settingsRules.map((rule) => (
|
|
||||||
<div key={rule.title} className="flex flex-col gap-4 rounded-md border border-border bg-background p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold">{rule.title}</h3>
|
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{rule.text}</p>
|
|
||||||
</div>
|
|
||||||
<Switch defaultChecked={rule.enabled} aria-label={rule.title} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsWorkspace() {
|
|
||||||
return (
|
|
||||||
<DashboardShell title="Rules" description="Warehouses, notification rules, carrier integrations и audit-friendly operational policy.">
|
|
||||||
<IconCards items={contactCards} />
|
|
||||||
<SettingsPanel />
|
|
||||||
</DashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeaturedGrid({
|
|
||||||
eyebrow,
|
|
||||||
title,
|
|
||||||
text,
|
|
||||||
items,
|
|
||||||
}: {
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
items: readonly TextItem[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-5 grid gap-3 lg:grid-cols-[0.8fr_1fr]">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{eyebrow}</div>
|
|
||||||
<h2 className="mt-2 font-display text-2xl font-semibold sm:text-3xl">{title}</h2>
|
|
||||||
</div>
|
|
||||||
<p className="max-w-[720px] text-sm leading-6 text-muted-foreground">{text}</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 lg:grid-cols-4">
|
|
||||||
{items.map((item) => (
|
|
||||||
<article key={item.name} className="rounded-md border border-border bg-background p-4">
|
|
||||||
<Badge variant="outline" className="mb-6 rounded-md">
|
|
||||||
{item.tag}
|
|
||||||
</Badge>
|
|
||||||
<div className="text-sm font-semibold text-primary">{item.price}</div>
|
|
||||||
<h3 className="mt-3 font-semibold">{item.name}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PricingTiles({ title, items }: { title: string; items: readonly TileItem[] }) {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<h2 className="mb-5 font-display text-2xl font-semibold">{title}</h2>
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{items.map((item) => (
|
|
||||||
<article key={item.title} className="rounded-md border border-border bg-background p-4">
|
|
||||||
<h3 className="font-semibold">{item.title}</h3>
|
|
||||||
<div className="mt-3 font-display text-3xl font-semibold">{item.price}</div>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{item.items.map((line) => (
|
|
||||||
<div key={line} className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<CheckIcon className="size-4 text-primary" />
|
|
||||||
{line}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IconCards({ items }: { items: readonly IconItem[] }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{items.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Panel key={item.title} className="p-4">
|
|
||||||
<Icon className="mb-6 size-5 text-primary" />
|
|
||||||
<h3 className="font-semibold">{item.title}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TestimonialBand({ items }: { items: readonly TestimonialItem[] }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{items.map((item) => (
|
|
||||||
<Panel key={item.name} className="p-5">
|
|
||||||
<div className="text-sm font-semibold text-primary">{item.rating}</div>
|
|
||||||
<p className="mt-4 font-display text-2xl leading-tight">"{item.text}"</p>
|
|
||||||
<footer className="mt-4 text-sm text-muted-foreground">{item.name}</footer>
|
|
||||||
</Panel>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InnerHero({ eyebrow, title, text }: { eyebrow: string; title: string; text: string }) {
|
|
||||||
return (
|
|
||||||
<Container className="py-8">
|
|
||||||
<Panel className="p-5">
|
|
||||||
<Badge variant="outline" className="mb-4 rounded-md">{eyebrow}</Badge>
|
|
||||||
<h1 className="font-display text-3xl font-semibold sm:text-4xl">{title}</h1>
|
|
||||||
<p className="mt-3 max-w-[760px] text-sm leading-6 text-muted-foreground">{text}</p>
|
|
||||||
</Panel>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InfoColumns({ title, items }: { title: string; items: readonly { title: string; text: string }[] }) {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<h2 className="mb-4 font-display text-2xl font-semibold">{title}</h2>
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{items.map((item) => (
|
|
||||||
<article key={item.title} className="rounded-md border border-border bg-background p-4">
|
|
||||||
<h3 className="font-semibold">{item.title}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user