mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(ui): add sales orders page
This commit is contained in:
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;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -10,5 +10,6 @@ export const routes: Record<RouteKey, string> = {
|
||||
salesClientDetail: "Елена Кузнецова",
|
||||
salesClients: "Клиенты",
|
||||
salesDialogs: "Диалоги",
|
||||
salesOrders: "Продажи",
|
||||
salesOverview: "Обзор",
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user