✨ feat(ui): add sales orders page

This commit is contained in:
Andrey committed 2026-06-18 22:16:35 +03:00
1 parent 27cce68e15
commit 6bb23016df
26 files changed
+836 -5

No files matched your search

@@ -0,0 +1,19 @@
import { Icon } from "../../../shared/icons";
export function SalesOrdersHeader({ query, setQuery }: { query: string; setQuery: (value: string) => void }) {
return (
<div className="sales-orders-header">
<div>
<h1>Продажи</h1>
<p>Заказы, платежи, возвраты, подписки и исполнение · основной объект — заказ</p>
</div>
<div className="sales-orders-header-actions">
<label className="sales-orders-search">
<Icon name="search" size={15} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Поиск по ID или клиенту…" />
</label>
<button type="button"><Icon name="download" size={15} />Экспорт</button>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import { useMemo, useState } from "react";
import { salesOrdersData, salesOrderTabs } from "./model";
import { SalesOrdersHeader } from "./SalesOrdersHeader";
import { SalesOrdersPagination } from "./SalesOrdersPagination";
import { SalesOrdersTableCard } from "./SalesOrdersTableCard";
import { SalesOrdersTabs } from "./SalesOrdersTabs";
import type { SalesOrdersTab } from "./types";
export function SalesOrdersPage() {
const [tab, setTab] = useState<SalesOrdersTab>("orders");
const [query, setQuery] = useState("");
const rows = useMemo(() => {
const value = query.trim().toLowerCase();
return salesOrdersData[tab].filter((row) => !value || row.search.includes(value));
}, [query, tab]);
const total = salesOrderTabs.find((item) => item.key === tab)?.count ?? rows.length;
function selectTab(nextTab: SalesOrdersTab) {
setTab(nextTab);
setQuery("");
}
return (
<>
<SalesOrdersHeader query={query} setQuery={setQuery} />
<SalesOrdersTabs activeTab={tab} setActiveTab={selectTab} />
<SalesOrdersTableCard activeTab={tab} rows={rows} />
<SalesOrdersPagination shown={rows.length} total={total} />
</>
);
}
@@ -0,0 +1,15 @@
export function SalesOrdersPagination({ shown, total }: { shown: number; total: number }) {
return (
<div className="sales-orders-pagination">
<div>Показано {shown} из {total}</div>
<div>
<button type="button" disabled>‹</button>
<button className="active" type="button">1</button>
<button type="button">2</button>
<button type="button">›</button>
<span />
<b>20 / стр.</b>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import { Icon } from "../../../shared/icons";
import { FulfillmentTable } from "./tables/FulfillmentTable";
import { OrdersTable } from "./tables/OrdersTable";
import { PaymentsTable } from "./tables/PaymentsTable";
import { RefundsTable } from "./tables/RefundsTable";
import { SubscriptionsTable } from "./tables/SubscriptionsTable";
import type { SalesFulfillment, SalesOrder, SalesOrdersTab, SalesPayment, SalesRefund, SalesSubscription } from "./types";
export function SalesOrdersTableCard({ activeTab, rows }: { activeTab: SalesOrdersTab; rows: Array<{ search: string }> }) {
return (
<div className="sales-orders-card">
<div className="sales-orders-table-scroll">
{activeTab === "orders" && <OrdersTable rows={rows as SalesOrder[]} />}
{activeTab === "subs" && <SubscriptionsTable rows={rows as SalesSubscription[]} />}
{activeTab === "pays" && <PaymentsTable rows={rows as SalesPayment[]} />}
{activeTab === "refunds" && <RefundsTable rows={rows as SalesRefund[]} />}
{activeTab === "exec" && <FulfillmentTable rows={rows as SalesFulfillment[]} />}
{rows.length === 0 && <SalesOrdersEmpty />}
</div>
</div>
);
}
function SalesOrdersEmpty() {
return (
<div className="sales-orders-empty">
<div><Icon name="search" size={22} /></div>
<strong>Ничего не найдено</strong>
<span>Измените поисковый запрос.</span>
</div>
);
}
@@ -0,0 +1,14 @@
import { salesOrderTabs } from "./model";
import type { SalesOrdersTab } from "./types";
export function SalesOrdersTabs({ activeTab, setActiveTab }: { activeTab: SalesOrdersTab; setActiveTab: (tab: SalesOrdersTab) => void }) {
return (
<div className="sales-orders-tabs">
{salesOrderTabs.map((tab) => (
<button className={activeTab === tab.key ? "active" : ""} type="button" onClick={() => setActiveTab(tab.key)} key={tab.key}>
{tab.label} <span>{tab.count}</span>
</button>
))}
</div>
);
}
@@ -0,0 +1,9 @@
import type { StatusBadge as StatusBadgeValue } from "./types";
export function StatusBadge({ value }: { value: StatusBadgeValue }) {
return <span className="sales-orders-status" style={{ background: value.bg, color: value.color }}>{value.label}</span>;
}
export function MonoLink({ children }: { children: string }) {
return <a className="sales-orders-mono-link" href="#" onClick={(event) => event.preventDefault()}>{children}</a>;
}
@@ -0,0 +1,78 @@
import type { SalesFulfillment, SalesOrder, SalesOrdersTab, SalesPayment, SalesRefund, SalesSubscription, StatusTone } from "./types";
const statusTone = {
green: { color: "#389e0d", bg: "#f6ffed" },
amber: { color: "#d48806", bg: "#fffbe6" },
red: { color: "#cf1322", bg: "#fff2f0" },
blue: { color: "#0958d9", bg: "#e6f4ff" },
gray: { color: "#8c8c8c", bg: "#f5f5f5" },
purple: { color: "#722ed1", bg: "#f9f0ff" },
} satisfies Record<StatusTone, { color: string; bg: string }>;
const sourceColor = {
MAX: "#6b5be0",
Telegram: "#2f8fd0",
Web: "#0f9b8e",
} satisfies Record<string, string>;
export const salesOrderTabs: Array<{ key: SalesOrdersTab; label: string; count: number }> = [
{ key: "orders", label: "Заказы", count: 6 },
{ key: "subs", label: "Подписки", count: 4 },
{ key: "pays", label: "Платежи", count: 5 },
{ key: "refunds", label: "Возвраты", count: 3 },
{ key: "exec", label: "Исполнение", count: 4 },
];
function badge(value: `${StatusTone}:${string}`) {
const [tone, label] = value.split(":") as [StatusTone, string];
return { ...statusTone[tone], label };
}
function search(...parts: string[]) {
return parts.join(" ").toLowerCase();
}
export const salesOrders: SalesOrder[] = [
{ id: "ORD-10519", date: "сегодня 14:02", client: "Татьяна Лебедева", offer: "FirePage · Business", amount: "₽2 490", pay: badge("green:Оплачен"), fulfillment: badge("green:Исполнен"), source: { label: "MAX", color: sourceColor.MAX }, seller: "AI-агент", sellerAI: true, search: search("ORD-10519", "Татьяна Лебедева") },
{ id: "ORD-10518", date: "сегодня 13:40", client: "Анна Морозова", offer: "Foxray · Pro", amount: "₽990", pay: badge("green:Оплачен"), fulfillment: badge("green:Исполнен"), source: { label: "Telegram", color: sourceColor.Telegram }, seller: "Анна Котова", search: search("ORD-10518", "Анна Морозова") },
{ id: "ORD-10516", date: "сегодня 12:18", client: "Павел Новиков", offer: "FirePage · Business", amount: "₽2 490", pay: badge("green:Оплачен"), fulfillment: badge("blue:В процессе"), source: { label: "MAX", color: sourceColor.MAX }, seller: "AI-агент", sellerAI: true, search: search("ORD-10516", "Павел Новиков") },
{ id: "ORD-10512", date: "сегодня 11:05", client: "Елена Кузнецова", offer: "Foxray · Team", amount: "₽4 980", pay: badge("amber:Ожидает"), fulfillment: badge("gray:—"), source: { label: "Web", color: sourceColor.Web }, seller: "Иван Петров", search: search("ORD-10512", "Елена Кузнецова") },
{ id: "ORD-10508", date: "вчера 18:22", client: "Игорь Соколов", offer: "Foxray · Pro", amount: "₽990", pay: badge("gray:Возврат"), fulfillment: badge("green:Исполнен"), source: { label: "MAX", color: sourceColor.MAX }, seller: "AI-агент", sellerAI: true, search: search("ORD-10508", "Игорь Соколов") },
{ id: "ORD-10482", date: "вчера 16:50", client: "Сергей Волков", offer: "FirePage · Business", amount: "₽2 490", pay: badge("red:Не оплачен"), fulfillment: badge("gray:—"), source: { label: "Telegram", color: sourceColor.Telegram }, seller: "AI-агент", sellerAI: true, search: search("ORD-10482", "Сергей Волков") },
];
export const salesSubscriptions: SalesSubscription[] = [
{ id: "SUB-2041", client: "Татьяна Лебедева", offer: "FirePage Business · мес", status: badge("green:Активна"), period: "16 июн — 16 июл", next: "16 июл 2026", entitlement: "10 мест", search: search("SUB-2041", "Татьяна Лебедева") },
{ id: "SUB-2038", client: "Анна Морозова", offer: "Foxray Pro · мес", status: badge("green:Активна"), period: "02 июн — 02 июл", next: "02 июл 2026", entitlement: "1 место", search: search("SUB-2038", "Анна Морозова") },
{ id: "SUB-2025", client: "Игорь Соколов", offer: "Foxray Pro · мес", status: badge("gray:Отменена"), period: "—", next: "—", entitlement: "—", search: search("SUB-2025", "Игорь Соколов") },
{ id: "SUB-2011", client: "Елена Кузнецова", offer: "Foxray Team · год", status: badge("amber:Приостановлена"), period: "10 мар — 10 мар 27", next: "пауза", entitlement: "5 мест", search: search("SUB-2011", "Елена Кузнецова") },
];
export const salesPayments: SalesPayment[] = [
{ id: "PAY-88231", order: "ORD-10519", provider: "Точка", amount: "₽2 490", status: badge("green:Успешно"), method: "Карта ···4242", date: "сегодня 14:02", reconcile: { color: statusTone.green.color, label: "Сверено" }, search: search("PAY-88231", "ORD-10519") },
{ id: "PAY-88230", order: "ORD-10518", provider: "Точка", amount: "₽990", status: badge("green:Успешно"), method: "СБП", date: "сегодня 13:40", reconcile: { color: statusTone.green.color, label: "Сверено" }, search: search("PAY-88230", "ORD-10518") },
{ id: "PAY-88224", order: "ORD-10512", provider: "Точка", amount: "₽4 980", status: badge("amber:Ожидает"), method: "Карта", date: "сегодня 11:05", reconcile: { color: statusTone.amber.color, label: "Ожидает сверки" }, search: search("PAY-88224", "ORD-10512") },
{ id: "PAY-88210", order: "ORD-10508", provider: "Точка", amount: "₽990", status: badge("gray:Возврат"), method: "Карта ···1188", date: "вчера 18:25", reconcile: { color: statusTone.green.color, label: "Сверено" }, search: search("PAY-88210", "ORD-10508") },
{ id: "PAY-88198", order: "ORD-10482", provider: "Точка", amount: "₽2 490", status: badge("red:Отклонён"), method: "Карта ···7701", date: "вчера 16:51", reconcile: { color: statusTone.gray.color, label: "—" }, search: search("PAY-88198", "ORD-10482") },
];
export const salesRefunds: SalesRefund[] = [
{ id: "REF-3120", reference: "ORD-10508 · PAY-88210", amount: "₽990", reason: "Запрос клиента", status: badge("green:Завершён"), actor: "Иван Петров", date: "сегодня 09:14", search: search("REF-3120", "ORD-10508 · PAY-88210") },
{ id: "REF-3118", reference: "ORD-10455 · PAY-87990", amount: "₽2 490", reason: "Дубль оплаты", status: badge("amber:В обработке"), actor: "OWNER", date: "вчера 20:02", search: search("REF-3118", "ORD-10455 · PAY-87990") },
{ id: "REF-3101", reference: "ORD-10390 · PAY-87740", amount: "₽4 980", reason: "Брак поставки", status: badge("red:Отклонён"), actor: "Иван Петров", date: "13 июн", search: search("REF-3101", "ORD-10390 · PAY-87740") },
];
export const salesFulfillment: SalesFulfillment[] = [
{ order: "ORD-10519", product: "FirePage", operation: "OP-55120", status: badge("green:Успешно"), attempts: "1", error: "—", updated: "сегодня 14:03", canRetry: false, search: search("ORD-10519", "OP-55120") },
{ order: "ORD-10516", product: "FirePage", operation: "OP-55117", status: badge("blue:В процессе"), attempts: "1", error: "—", updated: "сегодня 12:19", canRetry: false, search: search("ORD-10516", "OP-55117") },
{ order: "ORD-10512", product: "Foxray", operation: "OP-55110", status: badge("amber:Ожидает оплаты"), attempts: "0", error: "—", updated: "сегодня 11:05", canRetry: false, search: search("ORD-10512", "OP-55110") },
{ order: "ORD-10470", product: "Foxray", operation: "OP-55088", status: badge("red:Ошибка"), attempts: "3", error: "TIMEOUT: provider 504", updated: "вчера 15:40", canRetry: true, search: search("ORD-10470", "OP-55088") },
];
export const salesOrdersData = {
orders: salesOrders,
subs: salesSubscriptions,
pays: salesPayments,
refunds: salesRefunds,
exec: salesFulfillment,
};
@@ -0,0 +1,4 @@
@import "./styles/header.css";
@import "./styles/tabs.css";
@import "./styles/table.css";
@import "./styles/pagination.css";
@@ -0,0 +1,80 @@
.sales-orders-page {
width: min(1340px, 100%);
max-width: 1340px;
}
.sales-orders-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 18px;
}
.sales-orders-header h1 {
margin: 0;
color: #1f1f1f;
font-size: 24px;
font-weight: 700;
letter-spacing: 0;
line-height: 1.2;
}
.sales-orders-header p {
margin: 6px 0 0;
color: #8c8c8c;
font-size: 13.5px;
}
.sales-orders-header-actions {
display: flex;
align-items: center;
gap: 9px;
flex: none;
}
.sales-orders-search {
width: 240px;
height: 36px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
color: #bfbfbf;
background: #ffffff;
border: 1px solid #e8e8e8;
border-radius: 8px;
}
.sales-orders-search:focus-within {
border-color: #1677ff;
}
.sales-orders-search input {
width: 100%;
color: #262626;
background: transparent;
border: 0;
outline: 0;
font: inherit;
font-size: 13px;
}
.sales-orders-header-actions button {
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
color: #262626;
background: #ffffff;
border: 1px solid #d9d9d9;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.sales-orders-header-actions button:hover {
color: #1677ff;
border-color: #1677ff;
}
@@ -0,0 +1,53 @@
.sales-orders-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #ffffff;
border: 1px solid #f0f0f0;
border-top: 0;
border-radius: 0 0 12px 12px;
}
.sales-orders-pagination > div:first-child,
.sales-orders-pagination b {
color: #8c8c8c;
font-size: 12.5px;
font-weight: 400;
}
.sales-orders-pagination > div:last-child {
display: flex;
align-items: center;
gap: 6px;
}
.sales-orders-pagination button {
min-width: 30px;
height: 30px;
padding: 0 9px;
color: #595959;
background: #ffffff;
border: 1px solid #e8e8e8;
border-radius: 7px;
font-size: 13px;
}
.sales-orders-pagination button.active {
color: #ffffff;
background: #1677ff;
border-color: #1677ff;
font-weight: 600;
}
.sales-orders-pagination button:disabled {
color: #bfbfbf;
cursor: not-allowed;
}
.sales-orders-pagination span {
width: 1px;
height: 20px;
margin: 0 4px;
background: #f0f0f0;
}
@@ -0,0 +1,215 @@
.sales-orders-card {
overflow: hidden;
background: #ffffff;
border: 1px solid #f0f0f0;
border-radius: 12px 12px 0 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.sales-orders-table-scroll {
overflow-x: auto;
}
.sales-orders-table {
width: 100%;
border-collapse: collapse;
color: #595959;
font-size: 13px;
}
.sales-orders-table.orders {
min-width: 1180px;
}
.sales-orders-table.subscriptions,
.sales-orders-table.fulfillment {
min-width: 1080px;
}
.sales-orders-table.payments {
min-width: 1120px;
}
.sales-orders-table.refunds {
min-width: 1000px;
}
.sales-orders-table th {
padding: 11px 10px;
color: #8c8c8c;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
font-size: 11px;
font-weight: 600;
text-align: left;
}
.sales-orders-table th:first-child,
.sales-orders-table td:first-child {
padding-left: 16px;
}
.sales-orders-table td {
padding: 12px 10px;
border-bottom: 1px solid #f5f5f5;
}
.sales-orders-table tr:hover td {
background: #fafbfc;
}
.sales-orders-table .numeric {
text-align: right;
}
.sales-orders-table .nowrap {
white-space: nowrap;
}
.sales-orders-table .strong-text,
.sales-orders-table strong {
color: #262626;
}
.sales-orders-mono-link,
.mono-strong,
.mono-muted {
font-family: "SF Mono", ui-monospace, Menlo, monospace;
font-size: 12.5px;
}
.sales-orders-mono-link {
color: #1677ff;
font-weight: 600;
text-decoration: none;
}
.mono-strong {
color: #262626;
font-weight: 600;
}
.mono-muted {
color: #595959;
font-size: 12px;
}
.sales-orders-status {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
}
.sales-orders-source {
display: inline-flex;
align-items: center;
gap: 5px;
color: #595959;
font-size: 12px;
}
.sales-orders-source i {
width: 6px;
height: 6px;
border-radius: 50%;
}
.seller,
.seller-ai {
font-size: 12.5px;
font-weight: 500;
}
.seller {
color: #262626;
}
.seller-ai {
color: #722ed1;
}
.sales-orders-table .row-actions {
width: 44px;
text-align: center;
}
.sales-orders-table .row-actions button {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #8c8c8c;
background: transparent;
border: 0;
border-radius: 7px;
}
.sales-orders-table .row-actions button:hover {
color: #262626;
background: #f0f0f0;
}
.attempts.danger,
.mono-muted.error {
color: #cf1322;
font-weight: 600;
}
.sales-orders-retry {
height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
color: #cf1322;
background: #ffffff;
border: 1px solid #ffccc7;
border-radius: 7px;
font-size: 12.5px;
font-weight: 600;
}
.sales-orders-retry:hover {
background: #fff2f0;
}
.muted-dash {
color: #bfbfbf;
font-size: 12px;
}
.sales-orders-empty {
padding: 54px 20px;
text-align: center;
}
.sales-orders-empty div {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
color: #bfbfbf;
background: #f5f5f5;
border-radius: 50%;
}
.sales-orders-empty strong {
display: block;
color: #595959;
font-size: 14px;
font-weight: 600;
}
.sales-orders-empty span {
display: block;
margin-top: 4px;
color: #bfbfbf;
font-size: 12.5px;
}
@@ -0,0 +1,54 @@
.sales-orders-tabs {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 18px;
border-bottom: 1px solid #e8e8e8;
}
.sales-orders-tabs button {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
color: #595959;
background: transparent;
border: 0;
font-size: 14px;
font-weight: 500;
}
.sales-orders-tabs button.active {
color: #1677ff;
font-weight: 600;
}
.sales-orders-tabs button.active::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
background: #1677ff;
content: "";
}
.sales-orders-tabs span {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
color: #8c8c8c;
background: #f0f0f0;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
}
.sales-orders-tabs button.active span {
color: #1677ff;
background: #e6f4ff;
}
@@ -0,0 +1,30 @@
import { Icon } from "../../../../shared/icons";
import { MonoLink, StatusBadge } from "../StatusBadge";
import type { SalesFulfillment } from "../types";
export function FulfillmentTable({ rows }: { rows: SalesFulfillment[] }) {
if (rows.length === 0) return null;
return (
<table className="sales-orders-table fulfillment">
<thead>
<tr>
<th>ЗАКАЗ</th><th>ПРОДУКТ</th><th>ОПЕРАЦИЯ</th><th>СТАТУС</th><th className="numeric">ПОПЫТКИ</th><th>ПОСЛ. ОШИБКА</th><th>ОБНОВЛЕНО</th><th className="numeric">ДЕЙСТВИЕ</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.operation}>
<td><MonoLink>{row.order}</MonoLink></td>
<td>{row.product}</td>
<td className="mono-muted">{row.operation}</td>
<td><StatusBadge value={row.status} /></td>
<td className={`numeric attempts ${row.canRetry ? "danger" : ""}`}>{row.attempts}</td>
<td className={`mono-muted ${row.canRetry ? "error" : ""}`}>{row.error}</td>
<td className="nowrap">{row.updated}</td>
<td className="numeric">{row.canRetry ? <button className="sales-orders-retry" type="button"><Icon name="refresh" size={14} />Повторить</button> : <span className="muted-dash">—</span>}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,33 @@
import { Icon } from "../../../../shared/icons";
import { MonoLink, StatusBadge } from "../StatusBadge";
import type { SalesOrder } from "../types";
export function OrdersTable({ rows }: { rows: SalesOrder[] }) {
if (rows.length === 0) return null;
return (
<table className="sales-orders-table orders">
<thead>
<tr>
<th>ЗАКАЗ</th><th>ДАТА</th><th>КЛИЕНТ</th><th>ПРОДУКТ / OFFER</th><th className="numeric">СУММА</th>
<th>ОПЛАТА</th><th>ИСПОЛНЕНИЕ</th><th>ИСТОЧНИК</th><th>ПРОДАВЕЦ</th><th />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td><MonoLink>{row.id}</MonoLink></td>
<td className="nowrap">{row.date}</td>
<td className="strong-text">{row.client}</td>
<td>{row.offer}</td>
<td className="numeric nowrap"><strong>{row.amount}</strong></td>
<td><StatusBadge value={row.pay} /></td>
<td><StatusBadge value={row.fulfillment} /></td>
<td><span className="sales-orders-source"><i style={{ background: row.source.color }} />{row.source.label}</span></td>
<td><span className={row.sellerAI ? "seller-ai" : "seller"}>{row.seller}</span></td>
<td className="row-actions"><button type="button" aria-label="Действия заказа"><Icon name="more" size={17} /></button></td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,29 @@
import { MonoLink, StatusBadge } from "../StatusBadge";
import type { SalesPayment } from "../types";
export function PaymentsTable({ rows }: { rows: SalesPayment[] }) {
if (rows.length === 0) return null;
return (
<table className="sales-orders-table payments">
<thead>
<tr>
<th>ПЛАТЁЖ</th><th>ЗАКАЗ</th><th>ПРОВАЙДЕР</th><th className="numeric">СУММА</th><th>СТАТУС</th><th>МЕТОД</th><th>ДАТА</th><th>СВЕРКА</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td><span className="mono-strong">{row.id}</span></td>
<td><MonoLink>{row.order}</MonoLink></td>
<td>{row.provider}</td>
<td className="numeric nowrap"><strong>{row.amount}</strong></td>
<td><StatusBadge value={row.status} /></td>
<td className="mono-muted">{row.method}</td>
<td className="nowrap">{row.date}</td>
<td><span style={{ color: row.reconcile.color }}>{row.reconcile.label}</span></td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,28 @@
import { StatusBadge } from "../StatusBadge";
import type { SalesRefund } from "../types";
export function RefundsTable({ rows }: { rows: SalesRefund[] }) {
if (rows.length === 0) return null;
return (
<table className="sales-orders-table refunds">
<thead>
<tr>
<th>ВОЗВРАТ</th><th>ЗАКАЗ / ПЛАТЁЖ</th><th className="numeric">СУММА</th><th>ПРИЧИНА</th><th>СТАТУС</th><th>ИНИЦИАТОР</th><th>ДАТА</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td><span className="mono-strong">{row.id}</span></td>
<td className="mono-muted">{row.reference}</td>
<td className="numeric nowrap"><strong>{row.amount}</strong></td>
<td>{row.reason}</td>
<td><StatusBadge value={row.status} /></td>
<td>{row.actor}</td>
<td className="nowrap">{row.date}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,30 @@
import { Icon } from "../../../../shared/icons";
import { MonoLink, StatusBadge } from "../StatusBadge";
import type { SalesSubscription } from "../types";
export function SubscriptionsTable({ rows }: { rows: SalesSubscription[] }) {
if (rows.length === 0) return null;
return (
<table className="sales-orders-table subscriptions">
<thead>
<tr>
<th>ПОДПИСКА</th><th>КЛИЕНТ</th><th>OFFER</th><th>СТАТУС</th><th>ПЕРИОД</th><th>СЛЕД. СПИСАНИЕ</th><th>ENTITLEMENT</th><th />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td><MonoLink>{row.id}</MonoLink></td>
<td className="strong-text">{row.client}</td>
<td>{row.offer}</td>
<td><StatusBadge value={row.status} /></td>
<td className="nowrap">{row.period}</td>
<td className="nowrap">{row.next}</td>
<td>{row.entitlement}</td>
<td className="row-actions"><button type="button" aria-label="Действия подписки"><Icon name="more" size={17} /></button></td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,69 @@
export type SalesOrdersTab = "orders" | "subs" | "pays" | "refunds" | "exec";
export type StatusTone = "green" | "amber" | "red" | "blue" | "gray" | "purple";
export type StatusBadge = {
color: string;
bg: string;
label: string;
};
export type SalesOrder = {
id: string;
date: string;
client: string;
offer: string;
amount: string;
pay: StatusBadge;
fulfillment: StatusBadge;
source: { label: string; color: string };
seller: string;
sellerAI?: boolean;
search: string;
};
export type SalesSubscription = {
id: string;
client: string;
offer: string;
status: StatusBadge;
period: string;
next: string;
entitlement: string;
search: string;
};
export type SalesPayment = {
id: string;
order: string;
provider: string;
amount: string;
status: StatusBadge;
method: string;
date: string;
reconcile: { color: string; label: string };
search: string;
};
export type SalesRefund = {
id: string;
reference: string;
amount: string;
reason: string;
status: StatusBadge;
actor: string;
date: string;
search: string;
};
export type SalesFulfillment = {
order: string;
product: string;
operation: string;
status: StatusBadge;
attempts: string;
error: string;
updated: string;
canRetry: boolean;
search: string;
};
+1 -1
View File
@@ -7,7 +7,7 @@ export function SalesSidebar({ route, user, setRoute }: { route: RouteKey; user:
{ key: "salesOverview" as const, label: "Обзор", icon: "grid" as const },
{ key: "salesDialogs" as const, label: "Диалоги", icon: "message" as const, badge: "2" },
{ key: "salesClients" as const, label: "Клиенты", icon: "team" as const },
{ label: "Продажи", icon: "cart" as const },
{ key: "salesOrders" as const, label: "Продажи", icon: "cart" as const },
];
return (
<aside className="hub-sidebar sales-workspace-sidebar">
+3 -2
View File
@@ -11,17 +11,18 @@ export function Shell({ route, setRoute, selectedEmployeeId, openEmployeeRoute,
function openEmployee(employee: Employee) {
openEmployeeRoute(employee.id);
}
const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs";
const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs" || route === "salesOrders";
const isSalesDialogs = route === "salesDialogs";
const isSalesClients = route === "salesClients";
const isSalesClientDetail = route === "salesClientDetail";
const isSalesOrders = route === "salesOrders";
return (
<div className="hub-shell">
{isSalesWorkspace ? <SalesSidebar route={route} user={user} setRoute={setRoute} /> : <Sidebar route={route} user={user} setRoute={setRoute} />}
<div className="hub-main">
<TopBar route={route} user={user} currentEmployee={currentEmployee} setRoute={setRoute} />
<main className={`hub-scroll ${isSalesDialogs ? "sales-dialogs-scroll" : ""}`}>
<div className={`hub-page ${isSalesWorkspace ? "sales-workspace-page" : ""} ${isSalesDialogs ? "sales-dialogs-page" : ""} ${isSalesClients ? "sales-clients-page" : ""} ${isSalesClientDetail ? "sales-client-detail-page" : ""}`}>
<div className={`hub-page ${isSalesWorkspace ? "sales-workspace-page" : ""} ${isSalesDialogs ? "sales-dialogs-page" : ""} ${isSalesClients ? "sales-clients-page" : ""} ${isSalesClientDetail ? "sales-client-detail-page" : ""} ${isSalesOrders ? "sales-orders-page" : ""}`}>
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} openEmployee={openEmployee} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
</div>
</main>
@@ -6,6 +6,7 @@ import { ProfilePage } from "../features/profile/ProfilePage";
import { SalesClientDetailPage } from "../features/sales/client-detail/SalesClientDetailPage";
import { SalesClientsPage } from "../features/sales/SalesClientsPage";
import { SalesDialogsPage } from "../features/sales/SalesDialogsPage";
import { SalesOrdersPage } from "../features/sales/orders/SalesOrdersPage";
import { SalesOverviewPage } from "../features/sales/SalesOverviewPage";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
@@ -23,6 +24,7 @@ export function ShellRouteContent({ route, data, currentEmployee, openEmployee,
{route === "salesClientDetail" && <SalesClientDetailPage setRoute={setRoute} />}
{route === "salesOverview" && <SalesOverviewPage products={data.products} />}
{route === "salesDialogs" && <SalesDialogsPage />}
{route === "salesOrders" && <SalesOrdersPage />}
</>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { Icon } from "../shared/icons";
export function TopBar({ route, user, currentEmployee, setRoute }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; setRoute: (route: RouteKey) => void }) {
const st = commandCenterModel("today").st;
const isCommand = route === "command";
const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs";
const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs" || route === "salesOrders";
return (
<header className="hub-topbar">
<div className="breadcrumbs">
+2
View File
@@ -13,6 +13,7 @@ export function routeFromPath(pathname: string): RouteState {
if (path === "/departments/sales/clients") return { route: "salesClients", employeeId: null };
if (path === "/departments/sales/clients/CUS-4702") return { route: "salesClientDetail", employeeId: null };
if (path === "/departments/sales/dialogs") return { route: "salesDialogs", employeeId: null };
if (path === "/departments/sales/orders") return { route: "salesOrders", employeeId: null };
if (path === "/employees") return { route: "employees", employeeId: null };
if (path.startsWith("/employees/")) {
const id = Number(path.split("/")[2]);
@@ -30,6 +31,7 @@ export function pathFromRoute(route: RouteKey, employeeId: number | null = null)
if (route === "salesClients") return "/departments/sales/clients";
if (route === "salesClientDetail") return "/departments/sales/clients/CUS-4702";
if (route === "salesDialogs") return "/departments/sales/dialogs";
if (route === "salesOrders") return "/departments/sales/orders";
if (route === "employees") return "/employees";
if (route === "employeeDetail") return employeeId ? `/employees/${employeeId}` : "/employees";
if (route === "products") return "/products";
+1
View File
@@ -10,5 +10,6 @@ export const routes: Record<RouteKey, string> = {
salesClientDetail: "Елена Кузнецова",
salesClients: "Клиенты",
salesDialogs: "Диалоги",
salesOrders: "Продажи",
salesOverview: "Обзор",
};
+1
View File
@@ -6,6 +6,7 @@
@import "./features/sales/clients/styles.css";
@import "./features/sales/client-detail/styles.css";
@import "./features/sales/dialogs/styles.css";
@import "./features/sales/orders/styles.css";
@import "./features/departments/styles.css";
@import "./shared/table-controls.css";
@import "./features/products/styles.css";
+1 -1
View File
@@ -58,7 +58,7 @@ export type Product = {
createdAt: string;
};
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "products" | "profile" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOverview";
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "products" | "profile" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrders" | "salesOverview";
export type AppData = {
employees: Employee[];