feat(order-groups): wire driver delivery flow

This commit is contained in:
Codex 2026-05-06 20:01:31 +03:00
parent f2230f3277
commit 684424dd25
23 changed files with 1685 additions and 1062 deletions

View File

@ -0,0 +1,102 @@
# order_groups Migration Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move the dashboard UI from legacy `orders` records to `order_groups` so all roles work from the grouped delivery table.
**Architecture:** Introduce a dedicated `order_groups` repository and normalize each row into a delivery-group view model. Update the dashboard, list panels, and detail panels to render that model directly, removing assumptions about order-level fields like address, items, history, and delivery slots that no longer exist.
**Tech Stack:** React, Vite, Supabase JS, Vitest, Tailwind CSS.
---
## Chunk 1: Data access and demo fallback
**Files:**
- Create: `src/services/supabase/orderGroupRepository.js`
- Modify: `src/hooks/useOrders.js`
- Modify: `src/data/mockAppData.js`
- Modify: `src/services/deliverySetViews.js`
- Test: `src/services/supabase/orderGroupRepository.test.js` if needed
- [ ] **Step 1: Write the failing test**
Cover the `order_groups` row mapper and a few derived view fields, including customer identity, group counts, and status.
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
- [ ] **Step 3: Write minimal implementation**
Add `fetchOrderGroups`, `mapOrderGroupRowToGroup`, and a demo fallback array shaped like the new table.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
- [ ] **Step 5: Commit**
```bash
git add src/services/supabase/orderGroupRepository.js src/hooks/useOrders.js src/data/mockAppData.js src/services/deliverySetViews.js src/services/supabase/orderGroupRepository.test.js
git commit -m "feat: load dashboard from order_groups"
```
## Chunk 2: Dashboard surfaces
**Files:**
- Modify: `src/pages/DashboardPage.jsx`
- Modify: `src/components/orders/OrdersTable.jsx`
- Modify: `src/components/orders/OrderDetailPanel.jsx`
- Modify: `src/components/driver/DriverDeliveryPlanner.jsx`
- Modify: `src/components/logistics/LogisticsReadinessBoard.jsx`
- Modify: `src/components/logistics/DeliverySetDetailPanel.jsx`
- Modify: `src/components/orders/OrderFilters.jsx`
- [ ] **Step 1: Write the failing test**
Update component tests to expect group labels, counts, and `order_numbers`-based summaries instead of order-level fields.
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- --run src/components/orders/OrdersTable.test.jsx src/components/driver/DriverDeliveryPlanner.test.jsx src/components/logistics/DeliverySetDetailPanel.test.jsx`
- [ ] **Step 3: Write minimal implementation**
Replace order-specific text and bindings with group-based fields and simplify unsupported actions.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- --run src/components/orders/OrdersTable.test.jsx src/components/driver/DriverDeliveryPlanner.test.jsx src/components/logistics/DeliverySetDetailPanel.test.jsx`
- [ ] **Step 5: Commit**
```bash
git add src/pages/DashboardPage.jsx src/components/orders/OrdersTable.jsx src/components/orders/OrderDetailPanel.jsx src/components/driver/DriverDeliveryPlanner.jsx src/components/logistics/LogisticsReadinessBoard.jsx src/components/logistics/DeliverySetDetailPanel.jsx src/components/orders/OrderFilters.jsx
git commit -m "feat: render order groups in dashboard"
```
## Chunk 3: Verification
**Files:**
- Modify: `src/layouts/AppShell.jsx` if counts or labels need adjustment
- Modify: `src/layouts/AppShell.test.jsx` if badge labels change
- [ ] **Step 1: Run the full test suite**
Run: `npm test`
- [ ] **Step 2: Build the app**
Run: `npm run build`
- [ ] **Step 3: Check the UI in the browser**
Open `http://localhost:5174/dashboard` and confirm the grouped delivery list, detail modal, and driver view all render from `order_groups`.
- [ ] **Step 4: Commit**
```bash
git add src/layouts/AppShell.jsx src/layouts/AppShell.test.jsx
git commit -m "test: verify order_groups dashboard migration"
```

View File

@ -5,12 +5,12 @@ export const Badge = ({ children, tone = "neutral" }) => {
return ( return (
<span <span
className={cn( className={cn(
"inline-flex rounded-full px-3 py-1 text-xs font-semibold", "inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-semibold tracking-[0.01em] shadow-sm",
{ {
"bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent", "border-[rgba(18,128,92,0.18)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent",
"bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger", "border-[rgba(201,61,61,0.22)] bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger",
"bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning", "border-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
"bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]": tone === "neutral", "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral",
}, },
)} )}
> >

View File

@ -1,29 +1,131 @@
import React from "react"; import React from "react";
import { getAvailableTransitionsByRole, getStatusTone } from "../../constants/deliveryWorkflow"; import {
import { groupDriverDeliveriesByDate, getDeliveryCity, getDeliveryHalfDay } from "../../services/driverDeliveries"; filterOrderGroups,
getOrderGroupDeliveryHalfDay,
getOrderGroupDeliveryStatusLabel,
getOrderGroupDeliveryStatusTone,
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
DRIVER_VISIBLE_DELIVERY_STATUSES,
isOrderGroupVisibleToDriver,
groupOrderGroupsByDate,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button"; import { Input } from "../UI/Input";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) => { const DRIVER_DELIVERY_STATUS_OPTIONS = [
const groupedOrders = React.useMemo(() => groupDriverDeliveriesByDate(orders), [orders]); { value: "all", label: "Все статусы" },
...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({
value: status,
label: getOrderGroupDeliveryStatusLabel(status),
})),
];
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
const [filters, setFilters] = React.useState({
dateFrom: "",
dateTo: "",
deliveryHalfDay: "all",
deliveryStatus: "all",
});
const agreedOrderGroups = React.useMemo(
() => orderGroups.filter((group) => isOrderGroupVisibleToDriver(group)),
[orderGroups],
);
const filteredOrderGroups = React.useMemo(
() =>
filterOrderGroups(agreedOrderGroups, {
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
deliveryHalfDay: filters.deliveryHalfDay,
deliveryStatus: filters.deliveryStatus,
}),
[agreedOrderGroups, filters.dateFrom, filters.dateTo, filters.deliveryHalfDay, filters.deliveryStatus],
);
const groupedOrderGroups = React.useMemo(
() => groupOrderGroupsByDate(filteredOrderGroups),
[filteredOrderGroups],
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Panel className="space-y-3 p-5"> <Panel className="space-y-3 p-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="space-y-4">
<div> <div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-lg font-semibold">Мои доставки</h3> <div>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <h3 className="text-lg font-semibold">Мои доставки</h3>
Список доставок с адресом, клиентом, составом заказа и базовыми действиями по статусу. <p className="mt-1 text-sm text-[var(--color-text-muted)]">
</p> Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня.
</p>
</div>
<Badge tone="neutral">{filteredOrderGroups.length}</Badge>
</div>
<div className="grid gap-3 md:grid-cols-[repeat(4,minmax(0,1fr))]">
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Дата от
</span>
<Input
type="date"
value={filters.dateFrom}
onChange={(event) => setFilters((current) => ({ ...current, dateFrom: event.target.value }))}
/>
</label>
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Дата до
</span>
<Input
type="date"
value={filters.dateTo}
onChange={(event) => setFilters((current) => ({ ...current, dateTo: event.target.value }))}
/>
</label>
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Время суток
</span>
<Select
value={filters.deliveryHalfDay}
onChange={(event) =>
setFilters((current) => ({ ...current, deliveryHalfDay: event.target.value }))
}
>
{ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</label>
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Статус
</span>
<Select
value={filters.deliveryStatus}
onChange={(event) =>
setFilters((current) => ({ ...current, deliveryStatus: event.target.value }))
}
>
{DRIVER_DELIVERY_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</label>
</div> </div>
<Badge tone="neutral">{orders.length}</Badge>
</div> </div>
</Panel> </Panel>
{groupedOrders.length ? ( {groupedOrderGroups.length ? (
groupedOrders.map((group) => ( groupedOrderGroups.map((group) => (
<Panel key={group.date} className="space-y-4 p-5"> <Panel key={group.date} className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
@ -35,60 +137,44 @@ export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) =
})} })}
</h4> </h4>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <p className="mt-1 text-sm text-[var(--color-text-muted)]">
{group.items.length} {group.items.length === 1 ? "доставка" : "доставки"} {group.items.length} {group.items.length === 1 ? "группа" : "группы"}
</p> </p>
</div> </div>
<Badge tone="neutral">{group.date}</Badge> <Badge tone="neutral">{group.date}</Badge>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
{group.items.map((order) => { {group.items.map((item) => (
const availableTransitions = getAvailableTransitionsByRole({ <button
status: order.status, key={item.id}
role: "driver", type="button"
}); className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
onClick={() => onOpenOrder?.(item.id)}
return ( >
<button <div className="flex flex-wrap items-start justify-between gap-3">
key={order.id} <div>
type="button" <div className="font-medium text-[var(--color-text)]">
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]" {item.displayTitle || item.customerName || item.groupKey}
onClick={() => onOpenOrder?.(order.id)} </div>
> <div className="mt-1 text-sm text-[var(--color-text-muted)]">
<div className="flex flex-wrap items-start justify-between gap-3"> {item.customerDate} · {item.customerPhone}
<div> {getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
<div className="font-medium">{order.customer.address}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
{order.orderNumber} · {order.customer.name}
</div>
</div> </div>
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
</div> </div>
<Badge tone={getOrderGroupDeliveryStatusTone(item.deliveryStatus || item.delivery_status)}>
{getOrderGroupDeliveryStatusLabel(item.deliveryStatus || item.delivery_status)}
</Badge>
</div>
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3"> <div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
<div>{getDeliveryCity(order)}</div> <div>{item.orderNumbers?.[0] || "Номера не указаны"}</div>
<div>{getDeliveryHalfDay(order)}</div> <div>
<div>{order.customer.phone}</div> {item.readyCount || 0}/{item.ordersCount || 0} готово
</div> </div>
<div>{item.smsSentAt ? "SMS отправлено" : "SMS не отправлено"}</div>
<div className="mt-3 flex flex-wrap gap-2"> </div>
{availableTransitions.map((status) => ( </button>
<Button ))}
key={status}
size="sm"
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
onClick={(event) => {
event.stopPropagation();
onStatusChange?.(order.id, status);
}}
>
{status}
</Button>
))}
</div>
</button>
);
})}
</div> </div>
</Panel> </Panel>
)) ))
@ -96,7 +182,7 @@ export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) =
<Panel className="p-6"> <Panel className="p-6">
<h4 className="text-lg font-semibold">Доставки не найдены</h4> <h4 className="text-lg font-semibold">Доставки не найдены</h4>
<p className="mt-2 text-sm text-[var(--color-text-muted)]"> <p className="mt-2 text-sm text-[var(--color-text-muted)]">
Сейчас у вас нет назначенных доставок. Сейчас у вас нет назначенных групп доставки.
</p> </p>
</Panel> </Panel>
)} )}

View File

@ -3,20 +3,41 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DriverDeliveryPlanner } from "./DriverDeliveryPlanner"; import { DriverDeliveryPlanner } from "./DriverDeliveryPlanner";
const orders = [ const orderGroups = [
{ {
id: "driver-order-1", id: "driver-order-1",
orderNumber: "CD-240031", groupKey: "9780001231|16.04.26",
status: "К доставке", displayTitle: "Мария Волкова",
scheduledDelivery: "2026-04-16T12:00:00Z", displaySubtitle: "+7 978 000-12-31 · 16.04.26",
customer: { customerName: "Мария Волкова",
name: "Мария Волкова", customerPhone: "+7 978 000-12-31",
address: "Симферополь, ул. Ленина, 10", customerDate: "16.04.26",
phone: "+7 978 000-12-31", orderNumbers: ["CD-240031"],
}, ordersCount: 1,
orderNotes: [{ text: "Подъезд узкий" }], readyCount: 1,
comments: ["Позвонить за час"], notReadyCount: 0,
driverRouteOrder: 1, status: "ready_for_notification",
deliveryStatus: "agreed",
deliveryHalfDay: "Первая половина дня",
smsSentAt: null,
updatedAt: "2026-04-16T12:00:00Z",
},
{
id: "driver-order-2",
groupKey: "9780001232|16.04.26",
displayTitle: "Не показывать",
customerName: "Не показывать",
customerPhone: "+7 978 000-12-32",
customerDate: "16.04.26",
orderNumbers: ["CD-240032"],
ordersCount: 1,
readyCount: 0,
notReadyCount: 1,
status: "manual_work",
deliveryStatus: "pending_confirmation",
deliveryHalfDay: "Вторая половина дня",
smsSentAt: null,
updatedAt: "2026-04-16T13:00:00Z",
}, },
]; ];
@ -24,16 +45,19 @@ describe("DriverDeliveryPlanner", () => {
it("renders a simple delivery list without kanban or route editing", () => { it("renders a simple delivery list without kanban or route editing", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<DriverDeliveryPlanner <DriverDeliveryPlanner
orders={orders} orderGroups={orderGroups}
onOpenOrder={() => {}} onOpenOrder={() => {}}
onStatusChange={() => {}}
/>, />,
); );
expect(markup).toContain("Мои доставки"); expect(markup).toContain("Мои доставки");
expect(markup).toContain("CD-240031");
expect(markup).toContain("Мария Волкова"); expect(markup).toContain("Мария Волкова");
expect(markup).toContain("Симферополь, ул. Ленина, 10"); expect(markup).toContain("CD-240031");
expect(markup).not.toContain("Не показывать");
expect(markup).toContain("Дата от");
expect(markup).toContain("Время суток");
expect(markup).toContain("Статус");
expect(markup).toContain("Согласовано");
expect(markup).not.toContain("Канбан"); expect(markup).not.toContain("Канбан");
expect(markup).not.toContain("Перетащите"); expect(markup).not.toContain("Перетащите");
expect(markup).not.toContain("Календарь"); expect(markup).not.toContain("Календарь");

View File

@ -1,124 +1,14 @@
import React from "react";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel"; import { OrderDetailPanel } from "../orders/OrderDetailPanel";
import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews";
const PRODUCTION_STEP_LABELS = {
sourceProductionAt: "\u0417\u0430\u043F\u0443\u0441\u043A \u043F\u0440\u043E\u0438\u0437\u0432\u043E\u0434\u0441\u0442\u0432\u0430",
sourceSawAt: "\u0420\u0430\u0441\u043A\u0440\u043E\u0439",
sourceGlueAt: "\u0421\u043A\u043B\u0435\u0439\u043A\u0430",
sourceHGlueAt: "H-\u0441\u043A\u043B\u0435\u0439\u043A\u0430",
sourceCurveAt: "\u041A\u0440\u0438\u0432\u043E\u043B\u0438\u043D\u0435\u0439\u043D\u044B\u0435",
sourceAcceptAt: "Контроль качества",
sourceShipAt: "\u041E\u0442\u0433\u0440\u0443\u0437\u043A\u0430",
};
const formatStepDate = (iso) => {
if (!iso) {
return null;
}
return new Date(iso).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
});
};
export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => { export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
if (!deliverySet) { if (!deliverySet) {
return null; return null;
} }
const bucketLabel = DELIVERY_SET_BUCKET_LABELS[deliverySet.status] || deliverySet.status;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<Panel className="space-y-4 p-6"> <OrderDetailPanel order={deliverySet} />
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold">{deliverySet.name}</h3>
<p className="text-sm text-[var(--color-text-muted)]">
{deliverySet.sourceCustomerCity || "\u2014"} \u00B7 {deliverySet.orderCount}{" "}
{deliverySet.orderCount === 1 ? "заказ" : deliverySet.orderCount < 5 ? "заказа" : "заказов"} в наборе
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge tone={deliverySet.status === "ready_to_launch" ? "accent" : "neutral"}>
{bucketLabel}
</Badge>
{deliverySet.readyAt ? (
<Badge tone="neutral">
Готов с {formatStepDate(deliverySet.readyAt)}
</Badge>
) : null}
</div>
</div>
{deliverySet.linkedBillTexts ? (
<div className="text-sm text-[var(--color-text-muted)]">
Связанные счета: {deliverySet.linkedBillTexts}
</div>
) : null}
{deliverySet.readyReason ? (
<div className="text-sm text-[var(--color-text-muted)]">
{deliverySet.readyReason === "all_accepted"
? "Все заказы набора прошли контроль качества, можно запускать доставку."
: "Не все заказы набора ещё прошли контроль качества."}
</div>
) : null}
</Panel>
{deliverySet.orders.map((order) => (
<Panel key={order.id} className="space-y-3 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold">{order.orderNumber}</div>
{order.sourceFieldSummary?.sourceOrderNumber ? (
<div className="text-sm text-[var(--color-text-muted)]">
1С: {order.sourceFieldSummary.sourceOrderNumber}
</div>
) : null}
</div>
<Badge tone="neutral">{order.status}</Badge>
</div>
<div className="grid gap-2 md:grid-cols-3">
{Object.entries(PRODUCTION_STEP_LABELS).map(([key, label]) => {
const value = order.sourceFieldSummary?.[key];
if (!value) {
return null;
}
return (
<div key={key} className="text-sm">
<span className="text-[var(--color-text-muted)]">{label}:</span>{" "}
<span className="font-medium">{formatStepDate(value)}</span>
</div>
);
})}
</div>
{order.sourceFieldSummary?.sourceCustomerPhone ? (
<div className="text-sm text-[var(--color-text-muted)]">
\u260E {order.sourceFieldSummary.sourceCustomerPhone}
{order.sourceFieldSummary.sourceCustomerEmail
? ` \u00B7 ${order.sourceFieldSummary.sourceCustomerEmail}`
: ""}
</div>
) : null}
{order.deliverySlots?.length ? (
<div className="text-sm">
<span className="text-[var(--color-text-muted)]">Слот:</span>{" "}
<span className="font-medium">
{order.deliverySlots[0].date} \u00B7 {order.deliverySlots[0].time}
</span>
</div>
) : null}
</Panel>
))}
{onClose ? ( {onClose ? (
<div className="flex justify-end"> <div className="flex justify-end">

View File

@ -1,122 +1,145 @@
import React from "react"; import React from "react";
import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews"; import {
buildOrderGroupBuckets,
filterOrderGroups,
getOrderGroupStatusLabel,
getOrderGroupStatusTone,
ORDER_GROUP_BUCKET_LABELS,
ORDER_GROUP_STATUS_LABELS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { OrderFilters } from "../orders/OrderFilters";
const BUCKET_TONES = {
approaching: "neutral",
ready_to_launch: "accent",
awaiting_client: "warning",
manual_work: "danger",
agreed: "accent",
completed: "neutral",
};
const BUCKET_ICONS = { const BUCKET_ICONS = {
approaching: "\u2192",
ready_to_launch: "\u2713", ready_to_launch: "\u2713",
awaiting_client: "\u23F3", sms_sent: "\u2709",
manual_work: "\u26A0", manual_work: "\u26A0",
agreed: "\u2B50",
completed: "\u2714",
}; };
export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) => { const ORDER_GROUP_STATUS_OPTIONS = [
const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS); { value: "all", label: "Все статусы" },
const buckets = deliverySetBuckets || {}; ...Object.entries(ORDER_GROUP_STATUS_LABELS).map(([value, label]) => ({ value, label })),
const totalSets = bucketKeys.reduce( ];
(sum, key) => sum + (buckets[key]?.length || 0),
0, const renderOrderNumbers = (group) => {
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
return <span>Номера не указаны</span>;
}
return (
<div className="flex flex-wrap gap-2">
{group.orderNumbers.map((number) => (
<span
key={number}
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
>
{number}
</span>
))}
</div>
); );
};
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
const [filters, setFilters] = React.useState({ query: "", status: "all" });
const filteredGroups = React.useMemo(
() => filterOrderGroups(orderGroups, filters),
[filters, orderGroups],
);
const deliveryGroupBuckets = React.useMemo(
() => buildOrderGroupBuckets(filteredGroups),
[filteredGroups],
);
const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS);
const buckets = deliveryGroupBuckets || {};
const totalGroups = filteredGroups.length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="flex items-center justify-between p-5"> <Panel className="space-y-4 p-5">
<div> <div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Наборы доставки</h2> <div className="min-w-0">
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <h2 className="text-lg font-semibold">Наборы доставки</h2>
Группировка импортированных заказов по клиентским наборам. Каждый набор запускается в доставку целиком после приёмки всех заказов. <p className="mt-1 text-sm text-[var(--color-text-muted)]">
</p> Группы из таблицы `order_groups`, разбитые по состоянию готовности.
</p>
</div>
<Badge tone="neutral">{totalGroups} групп</Badge>
</div> </div>
<Badge tone="neutral">{totalSets} наборов</Badge>
<OrderFilters
filters={filters}
setFilters={setFilters}
statusOptions={ORDER_GROUP_STATUS_OPTIONS}
/>
</Panel> </Panel>
<div className="grid gap-6 xl:grid-cols-2"> {!totalGroups ? (
{bucketKeys.map((bucketKey) => { <Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
const sets = buckets[bucketKey] || []; По этому поиску ничего не найдено.
const label = DELIVERY_SET_BUCKET_LABELS[bucketKey]; </Panel>
const tone = BUCKET_TONES[bucketKey]; ) : (
const icon = BUCKET_ICONS[bucketKey]; <div className="grid gap-6 xl:grid-cols-2">
{bucketKeys.map((bucketKey) => {
const groups = buckets[bucketKey] || [];
const label = ORDER_GROUP_BUCKET_LABELS[bucketKey];
const icon = BUCKET_ICONS[bucketKey];
if (!groups.length) {
return (
<Panel key={bucketKey} className="p-5 opacity-50">
<div className="flex items-center gap-2">
<span className="text-lg">{icon}</span>
<h3 className="font-semibold">{label}</h3>
</div>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">Нет групп</p>
</Panel>
);
}
if (!sets.length) {
return ( return (
<Panel key={bucketKey} className="p-5 opacity-50"> <div key={bucketKey} className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg">{icon}</span> <span className="text-lg">{icon}</span>
<h3 className="font-semibold">{label}</h3> <h3 className="font-semibold">{label}</h3>
<Badge tone={bucketKey === "sms_sent" ? "accent" : "neutral"}>{groups.length}</Badge>
</div> </div>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">Нет наборов</p>
</Panel>
);
}
return ( {groups.map((group) => (
<div key={bucketKey} className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-lg">{icon}</span>
<h3 className="font-semibold">{label}</h3>
<Badge tone={tone}>{sets.length}</Badge>
</div>
{sets.map((set) => {
const setOrders = Array.isArray(set.orders) ? set.orders : [];
const orderCount = set.orderCount ?? setOrders.length;
return (
<button <button
key={set.key} key={group.id}
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5 text-left transition hover:bg-[var(--color-accent-soft)]" className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 !text-left !text-[var(--color-text)] transition hover:bg-[var(--color-accent-soft)] sm:p-5"
onClick={() => { onClick={() => {
if (onSelectSet) { if (onSelectSet) {
onSelectSet(set); onSelectSet(group);
} }
}} }}
type="button" type="button"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="truncate font-semibold">{set.name}</div> <div className="break-words text-base font-semibold leading-tight !text-[var(--color-text)] sm:text-lg">
<div className="mt-1 text-sm text-[var(--color-text-muted)]"> {group.displayTitle || group.customerName || group.groupKey}
{set.sourceCustomerCity || "\u2014"} \u00B7 {orderCount}{" "}
{orderCount === 1 ? "заказ" : orderCount < 5 ? "заказа" : "заказов"}
</div> </div>
{set.linkedBillTexts ? ( <div className="mt-1 text-sm leading-6 text-[var(--color-text-muted)]">
<div className="mt-1 text-xs text-[var(--color-text-muted)]"> {group.customerDate || "—"} · {group.customerPhone || "—"} · {group.ordersCount || 0}{" "}
Связанные счета: {set.linkedBillTexts} {group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}
</div> </div>
) : null} <div className="mt-2">{renderOrderNumbers(group)}</div>
</div> </div>
<Badge tone={tone}>{label}</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2"> <Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupStatusLabel(group.status)}</Badge>
{setOrders.map((order) => (
<span
key={order.id}
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
>
{order.orderNumber}
{order.sourceOrderNumber ? ` (${order.sourceOrderNumber})` : ""}
</span>
))}
</div> </div>
</button> </button>
); ))}
})} </div>
</div> );
); })}
})} </div>
</div> )}
</div> </div>
); );
}; };

View File

@ -1,24 +1,24 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews"; import { ORDER_GROUP_BUCKET_LABELS, ORDER_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
describe("LogisticsReadinessBoard", () => { describe("LogisticsReadinessBoard", () => {
it("renders all delivery-set bucket labels from the model", () => { it("renders all group bucket labels from the model", () => {
const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS); const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS);
expect(bucketKeys).toContain("approaching");
expect(bucketKeys).toContain("ready_to_launch"); expect(bucketKeys).toContain("ready_to_launch");
expect(bucketKeys).toContain("awaiting_client");
expect(bucketKeys).toContain("manual_work"); expect(bucketKeys).toContain("manual_work");
expect(bucketKeys).toContain("agreed"); expect(bucketKeys).toContain("sms_sent");
expect(bucketKeys).toContain("completed"); expect(bucketKeys).toHaveLength(3);
expect(bucketKeys).toHaveLength(6);
}); });
it("renders bucket labels in Russian", () => { it("renders bucket labels in Russian", () => {
expect(DELIVERY_SET_BUCKET_LABELS.approaching).toBe("На подходе"); expect(ORDER_GROUP_BUCKET_LABELS.ready_to_launch).toBe("Готовы к уведомлению");
expect(DELIVERY_SET_BUCKET_LABELS.ready_to_launch).toBe("Готово к запуску"); expect(ORDER_GROUP_BUCKET_LABELS.sms_sent).toBe("Уведомления отправлены");
expect(DELIVERY_SET_BUCKET_LABELS.awaiting_client).toBe("Ожидает клиента"); expect(ORDER_GROUP_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа");
expect(DELIVERY_SET_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа");
expect(DELIVERY_SET_BUCKET_LABELS.agreed).toBe("Согласовано");
expect(DELIVERY_SET_BUCKET_LABELS.completed).toBe("Завершено");
}); });
});
it("renders status labels in Russian", () => {
expect(ORDER_GROUP_STATUS_LABELS.ready_for_notification).toBe("Готово к уведомлению");
expect(ORDER_GROUP_STATUS_LABELS.sms_sent).toBe("SMS отправлены");
expect(ORDER_GROUP_STATUS_LABELS.manual_work).toBe("Нужна ручная работа");
});
});

View File

@ -1,237 +1,125 @@
import React from "react";
import { getAvailableTransitionsByRole, getDeliveryAgreementComment, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
import { demoUsers } from "../../data/mockAppData";
import { formatDateTime } from "../../utils/formatters"; import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { getOrderGroupStatusLabel, getOrderGroupStatusTone } from "../../services/orderGroupViews";
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); const renderList = (values) => {
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен"; if (!Array.isArray(values) || !values.length) {
return <p className="text-sm text-[var(--color-text-muted)]">Нет данных</p>;
const splitItem = (item) => {
if (!item) {
return { name: "Позиция", quantity: "" };
} }
if (typeof item === "string") { return (
const [name, quantity] = item.split("|").map((part) => part.trim()); <div className="flex flex-wrap gap-2">
return { {values.map((value, index) => (
name: name || item, <span
quantity: quantity || "", key={`${value}-${index}`}
}; className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
} >
{value}
if (typeof item === "object") { </span>
return { ))}
name: item.name || item.label || "Позиция", </div>
quantity: typeof item.quantity === "number" ? String(item.quantity) : item.quantity || "", );
};
}
return { name: "Позиция", quantity: "" };
}; };
export const OrderDetailPanel = ({ order, users, currentUser, onStatusChange, onAssignDriver }) => { const renderValue = (value) => value || "Не указано";
export const OrderDetailPanel = ({ order }) => {
if (!order) { if (!order) {
return ( return (
<Panel className="flex min-h-[460px] items-center justify-center"> <Panel className="flex min-h-[460px] items-center justify-center">
<p className="text-sm text-[var(--color-text-muted)]">Выберите заказ для просмотра деталей.</p> <p className="text-sm text-[var(--color-text-muted)]">Выберите группу для просмотра деталей.</p>
</Panel> </Panel>
); );
} }
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
const orderHistory = Array.isArray(order.history) ? order.history : [];
const role = currentUser?.role;
const availableTransitions = role ? getAvailableTransitionsByRole({ status: order.status, role }) : [];
const drivers = (Array.isArray(users) && users.length ? users : demoUsers).filter((u) => u.role === "driver");
const canAssignDriver = role === "logistician" || role === "admin";
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<Panel className="space-y-5 p-6"> <Panel className="space-y-5 p-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Карточка заказа</p> <p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
<h2 className="mt-2 text-2xl font-semibold">{order.orderNumber}</h2> Карточка группы доставки
</p>
<h2 className="mt-2 text-2xl font-semibold">
{order.displayTitle || order.customerName || order.groupKey}
</h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <p className="mt-1 text-sm text-[var(--color-text-muted)]">
{order.customer.name} · {order.customer.address} {order.displaySubtitle || [order.customerPhone, order.customerDate].filter(Boolean).join(" · ") || "Не указано"}
</p> </p>
</div> </div>
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge> <Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupStatusLabel(order.status)}</Badge>
</div> </div>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{getOrderStatusComment(order.status)}
</p>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p> <p className="text-xs text-[var(--color-text-muted)]">Группа</p>
<p className="mt-1 font-medium">{resolveUserName(users, order.managerId)}</p> <p className="mt-1 font-medium">{renderValue(order.groupKey)}</p>
</div> </div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Логист</p>
<p className="mt-1 font-medium">{resolveUserName(users, order.logisticianIds?.[0])}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Водитель</p>
<p className="mt-1 font-medium">{resolveUserName(users, order.assignedDriverId)}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Дата создания</p>
<p className="mt-1 font-medium">{formatDateTime(order.createdAt)}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">План доставки</p>
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Канал связи</p>
<p className="mt-1 font-medium">{order.customer.messenger}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Согласование доставки</p>
<p className="mt-1 font-medium">{order.deliveryAgreementStatus}</p>
</div>
</div>
</Panel>
<Panel className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<strong>Данные клиента</strong>
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
</div>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{getDeliveryAgreementComment(order.deliveryAgreementStatus)}
</p>
<div className="grid gap-4 md:grid-cols-2">
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p> <p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
<p className="mt-1 font-medium">{order.customer.name}</p> <p className="mt-1 font-medium">{renderValue(order.customerName)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Телефон</p> <p className="text-xs text-[var(--color-text-muted)]">Телефон</p>
<p className="mt-1 font-medium">{order.customer.phone}</p> <p className="mt-1 font-medium">{renderValue(order.customerPhone)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Адрес</p> <p className="text-xs text-[var(--color-text-muted)]">Дата</p>
<p className="mt-1 font-medium">{order.customer.address}</p> <p className="mt-1 font-medium">{renderValue(order.customerDate)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Дата доставки</p> <p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p>
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p> <p className="mt-1 font-medium">{order.ordersCount ?? 0}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Готово</p>
<p className="mt-1 font-medium">{order.readyCount ?? 0}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Не готово</p>
<p className="mt-1 font-medium">{order.notReadyCount ?? 0}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
<p className="mt-1 font-medium">{formatDateTime(order.updatedAt)}</p>
</div> </div>
</div> </div>
</Panel> </Panel>
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
<strong>Состав заказа</strong> <strong>Номера заказов</strong>
<div className="space-y-3"> {renderList(order.orderNumbers)}
{orderItems.length ? ( </Panel>
orderItems.map((item) => (
<div <Panel className="space-y-4 p-5">
key={`${item.name}-${item.quantity || "item"}`} <strong>Дополнительные данные</strong>
className="flex items-center justify-between gap-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm" <div className="grid gap-4 md:grid-cols-2">
> <div>
<span>{item.name}</span> <p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null} <p className="mt-1 font-medium">{renderValue(formatDateTime(order.smsSentAt))}</p>
</div> </div>
)) <div>
) : ( <p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p>
<p className="text-sm text-[var(--color-text-muted)]">Состав заказа не указан.</p> <p className="mt-1 font-medium">{renderValue(formatDateTime(order.createdFromExchangeAt))}</p>
)} </div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Source key</p>
<p className="mt-1 font-medium">{renderValue(order.sourceKey)}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Legacy customer</p>
<p className="mt-1 font-medium">{renderValue(order.legacyCustomerName)}</p>
</div>
</div> </div>
</Panel> </Panel>
{order.orderNotes?.length ? ( {order.sourceOrders ? (
<Panel className="space-y-3 p-5"> <Panel className="space-y-3 p-5">
<strong>Комментарии</strong> <strong>Source orders</strong>
<div className="space-y-2"> <pre className="overflow-x-auto rounded-[20px] bg-[var(--color-surface-strong)] p-4 text-xs leading-6 text-[var(--color-text-muted)]">
{order.orderNotes.map((note) => ( {JSON.stringify(order.sourceOrders, null, 2)}
<div </pre>
key={note.id}
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6"
>
{note.text}
</div>
))}
</div>
</Panel>
) : null}
{order.comments?.length ? (
<Panel className="space-y-3 p-5">
<strong>Дополнительные комментарии</strong>
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-muted)]">
{order.comments.map((comment, index) => (
<div key={`${comment}-${index}`} className="rounded-[20px] bg-[var(--color-surface)] p-4">
{comment}
</div>
))}
</div>
</Panel>
) : null}
{availableTransitions.length ? (
<Panel className="space-y-4 p-5">
<strong>Действия</strong>
<div className="flex flex-wrap gap-2">
{availableTransitions.map((status) => (
<Button
key={status}
variant={status === "Проблема доставки" || status === "Платное хранение" || status === "Отменён" ? "ghost" : "secondary"}
onClick={() => onStatusChange?.(status)}
>
{status}
</Button>
))}
</div>
</Panel>
) : null}
{canAssignDriver ? (
<Panel className="space-y-4 p-5">
<strong>Назначить водителя</strong>
<div className="flex flex-wrap gap-2">
{drivers.map((driver) => (
<Button
key={driver.id}
variant={order.assignedDriverId === driver.id ? "primary" : "secondary"}
onClick={() => onAssignDriver?.({ orderId: order.id, driverId: driver.id, actorName: currentUser.name })}
>
{driver.name}
</Button>
))}
{order.assignedDriverId ? (
<Button variant="ghost" onClick={() => onAssignDriver?.({ orderId: order.id, driverId: null, actorName: currentUser.name })}>
Снять водителя
</Button>
) : null}
</div>
</Panel>
) : null}
{orderHistory.length ? (
<Panel className="space-y-3 p-5">
<strong>История</strong>
<div className="space-y-2">
{orderHistory.map((entry) => (
<div
key={entry.id}
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-medium">{entry.action}</span>
<span className="text-[var(--color-text-muted)]">{formatDateTime(entry.at)}</span>
</div>
<div className="mt-2 text-[var(--color-text-muted)]">
{entry.oldStatus || "Начало"} {entry.newStatus}
</div>
</div>
))}
</div>
</Panel> </Panel>
) : null} ) : null}
</div> </div>

View File

@ -5,64 +5,52 @@ import { OrderDetailPanel } from "./OrderDetailPanel";
const order = { const order = {
id: "o-1", id: "o-1",
orderNumber: "CD-240031", groupKey: "9780001231|16.04.26",
status: "Ожидает согласования доставки", displayTitle: "Мария Волкова",
deliveryAgreementStatus: "Ожидание ответа", displaySubtitle: "+7 978 000-12-31 · 16.04.26",
managerId: "u-manager", customerName: "Мария Волкова",
logisticianIds: ["u-logistics"], customerPhone: "+7 978 000-12-31",
assignedDriverId: null, customerDate: "16.04.26",
ordersCount: 1,
readyCount: 1,
notReadyCount: 0,
orderNumbers: ["CD-240031"],
status: "ready_for_notification",
smsSentAt: null,
createdFromExchangeAt: null,
sourceKey: null,
legacyCustomerName: null,
sourceOrders: null,
createdAt: "2026-03-15T08:00:00Z", createdAt: "2026-03-15T08:00:00Z",
scheduledDelivery: "2026-03-16T09:00:00Z", updatedAt: "2026-03-16T09:00:00Z",
customer: { customer: {
name: "Мария Волкова", name: "Мария Волкова",
phone: "+7 978 000-12-31", phone: "+7 978 000-12-31",
address: "Симферополь", date: "16.04.26",
messenger: "СМС",
}, },
items: ["Кухня | 1 шт"],
chatMessages: [],
internalMessages: [],
orderNotes: [],
history: [],
}; };
describe("OrderDetailPanel", () => { describe("OrderDetailPanel", () => {
it("keeps the order card read-first without workflow controls", () => { it("keeps the group card read-first", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<OrderDetailPanel <OrderDetailPanel order={order} />,
order={order}
users={[
{ id: "u-manager", name: "Анна Мельник", role: "manager" },
{ id: "u-logistics", name: "Ольга Синицына", role: "logistician" },
]}
/>,
); );
expect(markup).toContain("CD-240031"); expect(markup).toContain("Карточка группы доставки");
expect(markup).toContain("Мария Волкова"); expect(markup).toContain("Мария Волкова");
expect(markup).toContain("Кухня"); expect(markup).toContain("CD-240031");
expect(markup).toContain("1 шт"); expect(markup).toContain("Готово");
expect(markup).not.toContain("Назначение водителя");
expect(markup).not.toContain("Изменить статус");
expect(markup).not.toContain("Чат с клиентом");
expect(markup).not.toContain("Команда");
}); });
it("does not crash when an order contains invalid date strings", () => { it("does not crash when a group contains missing timestamps", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<OrderDetailPanel <OrderDetailPanel
order={{ order={{
...order, ...order,
createdAt: "2026-03-18T010:00:00Z", createdAt: "broken-date",
scheduledDelivery: "not-a-date", updatedAt: "broken-date",
orderNotes: [ smsSentAt: null,
{ createdFromExchangeAt: null,
id: "note-1",
authorName: "Анна",
text: "Проверка даты",
createdAt: "broken-date",
},
],
}} }}
/>, />,
); );
@ -70,12 +58,9 @@ describe("OrderDetailPanel", () => {
expect(markup).toContain("Не указано"); expect(markup).toContain("Не указано");
}); });
it("does not expose driver assignment or status controls", () => { it("renders order numbers as chips", () => {
const markup = renderToStaticMarkup(<OrderDetailPanel order={order} users={[]} />); const markup = renderToStaticMarkup(<OrderDetailPanel order={order} />);
expect(markup).not.toContain("Назначение водителя"); expect(markup).toContain("CD-240031");
expect(markup).not.toContain("Изменить статус");
expect(markup).not.toContain("Чат с клиентом");
expect(markup).not.toContain("Команда");
}); });
}); });

View File

@ -1,204 +1,49 @@
import React from "react";
import { DELIVERY_REGISTRY_FILTER_STATUSES } from "../../constants/orderStatuses";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Input } from "../UI/Input"; import { Input } from "../UI/Input";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
const messengers = ["СМС", "Эл. почта"]; export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
const statusOptions = [ const selectedStatusLabel = statusOptions.find((option) => option.value === filters.status)?.label || filters.status;
{ value: "all", label: "Все статусы" },
...DELIVERY_REGISTRY_FILTER_STATUSES.map((status) => ({ value: status, label: status })),
];
const messengerOptions = [
{ value: "all", label: "Все каналы" },
...messengers.map((messenger) => ({ value: messenger, label: messenger })),
];
const FilterMenu = ({ label, value, options, isOpen, onToggle, onChange, onClose }) => {
const selectedLabel = options.find((option) => option.value === value)?.label || label;
return (
<div
className="relative"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
onClose();
}
}}
>
<button
type="button"
className={[
"flex w-full items-center justify-between gap-3 border border-[var(--color-border)]",
"bg-[var(--color-surface)] px-4 py-3 text-left text-sm text-[var(--color-text)] transition",
isOpen
? "rounded-t-2xl rounded-b-none border-[var(--color-accent)] border-b-transparent bg-[var(--color-dropdown-surface)]"
: "rounded-2xl hover:bg-[var(--color-surface-strong)]",
].join(" ")}
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="truncate">{selectedLabel}</span>
<span className="text-[var(--color-text-muted)]">v</span>
</button>
{isOpen ? (
<div
className="overflow-hidden rounded-b-2xl border border-t-0 border-[var(--color-accent)] bg-[var(--color-dropdown-surface)] px-2 pb-2 pt-1"
role="listbox"
aria-label={label}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
className={[
"flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-sm transition",
selected
? "bg-[var(--color-accent-soft)] font-semibold text-[var(--color-text)]"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-text)]",
].join(" ")}
role="option"
aria-selected={selected}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onChange(option.value);
onClose();
}}
>
<span>{option.label}</span>
{selected ? <span className="text-[var(--color-accent)]"></span> : null}
</button>
);
})}
</div>
) : null}
</div>
);
};
export const OrderFilters = ({ filters, setFilters }) => {
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
const [openMenu, setOpenMenu] = React.useState(null);
const activeChips = [
filters.status !== "all" ? { key: "status", label: filters.status } : null,
filters.messenger !== "all" ? { key: "messenger", label: filters.messenger } : null,
].filter(Boolean);
const updateFilter = (key, value) => { const updateFilter = (key, value) => {
setFilters((current) => ({ ...current, [key]: value })); setFilters((current) => ({ ...current, [key]: value }));
}; };
const renderFilterField = (label, control, showLabel = false) => ( const activeChips = [filters.status !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean);
<div className="flex min-w-0 flex-col gap-2">
{showLabel ? (
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
{label}
</span>
) : null}
{control}
</div>
);
const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => (
<div className={className}>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{renderFilterField(
"Статус",
<FilterMenu
label="Статус"
value={filters.status}
options={statusOptions}
isOpen={openMenu === "status"}
onToggle={() => setOpenMenu((current) => (current === "status" ? null : "status"))}
onChange={(value) => updateFilter("status", value)}
onClose={() => setOpenMenu(null)}
/>,
showLabels,
)}
{renderFilterField(
"Канал",
<FilterMenu
label="Канал"
value={filters.messenger}
options={messengerOptions}
isOpen={openMenu === "messenger"}
onToggle={() => setOpenMenu((current) => (current === "messenger" ? null : "messenger"))}
onChange={(value) => updateFilter("messenger", value)}
onClose={() => setOpenMenu(null)}
/>,
showLabels,
)}
</div>
</div>
);
return ( return (
<Panel className="p-4"> <Panel className="p-4">
<div className="flex flex-col gap-3 md:hidden"> <div className="grid gap-3 md:grid-cols-[minmax(0,1.6fr)_minmax(12rem,0.7fr)]">
<div className="flex items-center gap-3"> <Input
<Input placeholder="Поиск по группе, клиенту или телефону"
placeholder="Поиск по заявке, клиенту, телефону" value={filters.query}
value={filters.query} onChange={(event) => updateFilter("query", event.target.value)}
onChange={(event) => updateFilter("query", event.target.value)} />
/>
<Button size="sm" variant="secondary" onClick={() => setIsMobileFiltersOpen((current) => !current)}> <label className="flex min-w-0 flex-col gap-2">
Фильтры <span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
</Button> Статус
</div> </span>
{activeChips.length ? ( <select
<div> className="h-[46px] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> value={filters.status}
Активные фильтры onChange={(event) => updateFilter("status", event.target.value)}
</div> >
<div className="mt-2 flex flex-wrap gap-2"> {statusOptions.map((option) => (
{activeChips.map((chip) => ( <option key={option.value} value={option.value}>
<Badge key={chip.key}>{chip.label}</Badge> {option.label}
))} </option>
</div> ))}
</div> </select>
) : null} </label>
{isMobileFiltersOpen
? renderAdvancedFilters({
className: "rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3",
})
: null}
</div> </div>
<div className="hidden md:flex md:flex-col md:gap-4"> {activeChips.length ? (
<div <div className="mt-3 flex flex-wrap gap-2">
className={[ {activeChips.map((chip) => (
"grid gap-3 xl:items-start", <Badge key={chip.key}>{chip.label}</Badge>
activeChips.length ? "xl:grid-cols-[minmax(22rem,1.35fr)_minmax(0,1fr)]" : "", ))}
].join(" ")}
>
<Input
placeholder="Поиск по заявке, клиенту, телефону"
value={filters.query}
onChange={(event) => updateFilter("query", event.target.value)}
/>
{activeChips.length ? (
<div className="min-h-[44px] rounded-[20px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3">
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Активные фильтры
</div>
<div className="mt-2 flex flex-wrap gap-2">
{activeChips.map((chip) => (
<Badge key={chip.key}>{chip.label}</Badge>
))}
</div>
</div>
) : null}
</div> </div>
{renderAdvancedFilters({ showLabels: true })} ) : null}
</div>
</Panel> </Panel>
); );
}; };

View File

@ -4,44 +4,26 @@ import { describe, expect, it } from "vitest";
import { OrderFilters } from "./OrderFilters"; import { OrderFilters } from "./OrderFilters";
describe("OrderFilters", () => { describe("OrderFilters", () => {
it("renders only the manager delivery filters", () => { it("renders only the group delivery filters", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<OrderFilters <OrderFilters
filters={{ filters={{
query: "", query: "",
status: "all", status: "all",
stage: "all",
ownerRole: "all",
agingState: "all",
managerId: "all",
logisticianId: "all",
messenger: "all",
}} }}
setFilters={() => {}} setFilters={() => {}}
statusOptions={[
{ value: "all", label: "Все статусы" },
{ value: "ready_for_notification", label: "Готовы к уведомлению" },
]}
/>, />,
); );
expect(markup).toContain("Поиск по заявке, клиенту, телефону"); expect(markup).toContain("Поиск по группе, клиенту или телефону");
expect(markup).not.toContain("Активные фильтры");
expect(markup).not.toContain("Нет");
expect(markup).toContain("Статус"); expect(markup).toContain("Статус");
expect(markup).toContain("Канал"); expect(markup).toContain("<select");
expect(markup).toContain("aria-haspopup=\"listbox\""); expect(markup).not.toContain("Канал");
expect(markup).not.toContain("<select"); expect(markup).not.toContain("Активные фильтры");
expect(markup).not.toContain("Новый");
expect(markup).not.toContain("Требует уточнения");
expect(markup).not.toContain("Подтверждён менеджером");
expect(markup).not.toContain("В очереди производства");
expect(markup).not.toContain("В производстве");
expect(markup).not.toContain("Отменён");
expect(markup).not.toContain("Телеграм");
expect(markup).not.toContain("ВКонтакте");
expect(markup).not.toContain("Макс");
expect(markup).not.toContain("Все этапы");
expect(markup).not.toContain("Все зоны ответственности");
expect(markup).not.toContain("Без фильтра по SLA");
expect(markup).not.toContain("Менеджер");
expect(markup).not.toContain("Логист");
}); });
it("shows active filter chips only when filters are selected", () => { it("shows active filter chips only when filters are selected", () => {
@ -49,20 +31,17 @@ describe("OrderFilters", () => {
<OrderFilters <OrderFilters
filters={{ filters={{
query: "", query: "",
status: "Доставка согласована", status: "ready_for_notification",
stage: "all",
ownerRole: "all",
agingState: "all",
managerId: "all",
logisticianId: "all",
messenger: "СМС",
}} }}
setFilters={() => {}} setFilters={() => {}}
statusOptions={[
{ value: "all", label: "Все статусы" },
{ value: "ready_for_notification", label: "Готовы к уведомлению" },
]}
/>, />,
); );
expect(markup).toContain("Активные фильтры"); expect(markup).toContain("Готовы к уведомлению");
expect(markup).toContain("Доставка согласована"); expect(markup).not.toContain("Активные фильтры");
expect(markup).toContain("СМС");
}); });
}); });

View File

@ -1,110 +1,139 @@
import React from "react";
import { getStatusTone } from "../../constants/deliveryWorkflow";
import { demoUsers } from "../../data/mockAppData";
import { formatDateTime } from "../../utils/formatters"; import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { OrderFilters } from "./OrderFilters"; import { OrderFilters } from "./OrderFilters";
import { getOrderGroupStatusLabel, getOrderGroupStatusTone } from "../../services/orderGroupViews";
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); const buildGroupSummary = (group) => {
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен"; const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const buildOrderSummary = (order) => { const readyCountLabel = `${group.readyCount || 0} готовы`;
const leadItem = order.items?.[0] || "Состав не указан";
const leadComment = order.orderNotes?.[0]?.text || order.comments?.[0] || "Без уточнений"; return `${orderCountLabel} · ${readyCountLabel}`;
return `${leadItem}. ${leadComment}`;
}; };
export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder, users, filters, setFilters }) => { const renderOrderNumbers = (group) => {
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
return "Номера не указаны";
}
return group.orderNumbers.slice(0, 3).join(" · ");
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
}) => {
return ( return (
<Panel className="p-0"> <Panel className="p-0">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4"> <div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h2 className="text-lg font-semibold">Реестр заказов</h2> <h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]"> <p className="text-sm text-[var(--color-text-muted)]">
Поиск по номеру, клиенту и телефону. Поиск по группе, клиенту, телефону и дате доставки.
</p> </p>
</div> </div>
<Badge tone="neutral">{orders.length}</Badge> <Badge tone="neutral">{orderGroups.length}</Badge>
</div> </div>
{filters && setFilters ? <OrderFilters filters={filters} setFilters={setFilters} /> : null} {filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} />
) : null}
</div> </div>
<div className="space-y-3 p-4 md:hidden"> <div className="space-y-3 p-4 md:hidden">
{orders.map((order) => ( {!orderGroups.length ? (
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</Panel>
) : null}
{orderGroups.map((group) => (
<button <button
key={order.id} key={group.id}
type="button" type="button"
onClick={() => onOpenOrder(order.id)} onClick={() => onOpenOrder(group.id)}
className={[ className={[
"w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition", "w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition",
selectedOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "", selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
].join(" ")} ].join(" ")}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div className="min-w-0">
<div className="font-medium">{order.orderNumber}</div> <div className="truncate font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{order.customer.name}</div> <div className="mt-1 text-sm text-[var(--color-text-muted)]">
{group.displaySubtitle || [group.customerPhone, group.customerDate].filter(Boolean).join(" · ")}
</div>
</div> </div>
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge> <Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupStatusLabel(group.status)}</Badge>
</div> </div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildOrderSummary(order)}</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-[var(--color-text-muted)]"> <div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div>
<span>{order.customer.phone}</span> <div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderOrderNumbers(group)}</div>
<span>{resolveUserName(users, order.managerId)}</span> <div className="mt-3 text-xs text-[var(--color-text-muted)]">
<span>{formatDateTime(order.updatedAt)}</span> {formatDateTime(group.updatedAt)}
</div> </div>
</button> </button>
))} ))}
</div> </div>
<div className="hidden overflow-x-auto md:block"> <div className="hidden overflow-x-auto md:block">
<table className="min-w-full border-collapse"> {!orderGroups.length ? (
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус.
</div>
) : (
<table className="min-w-full border-collapse">
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]"> <thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<tr> <tr>
<th className="px-5 py-4 font-medium">Заказ</th> <th className="px-5 py-4 font-medium">Группа</th>
<th className="px-5 py-4 font-medium">Клиент</th> <th className="px-5 py-4 font-medium">Клиент</th>
<th className="px-5 py-4 font-medium">Кратко</th> <th className="px-5 py-4 font-medium">Номера</th>
<th className="px-5 py-4 font-medium">Статус</th> <th className="px-5 py-4 font-medium">Статус</th>
<th className="px-5 py-4 font-medium">Менеджер</th> <th className="px-5 py-4 font-medium">Готовность</th>
<th className="px-5 py-4 font-medium">Обновлён</th> <th className="px-5 py-4 font-medium">Обновлён</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{orders.map((order) => ( {orderGroups.map((group) => (
<tr <tr
key={order.id} key={group.id}
className={[ className={[
"cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]", "cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]",
selectedOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "", selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
].join(" ")} ].join(" ")}
onClick={() => onOpenOrder(order.id)} onClick={() => onOpenOrder(group.id)}
> >
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="font-medium">{order.orderNumber}</div> <div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]"> <div className="mt-1 text-sm text-[var(--color-text-muted)]">{group.groupKey}</div>
{order.customer.messenger}
</div>
</td> </td>
<td className="px-5 py-4 text-sm"> <td className="px-5 py-4 text-sm">
<div>{order.customer.name}</div> <div>{group.customerName}</div>
<div className="mt-1 text-[var(--color-text-muted)]">{order.customer.phone}</div> <div className="mt-1 text-[var(--color-text-muted)]">
{group.customerPhone} · {group.customerDate}
</div>
</td> </td>
<td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]"> <td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
{buildOrderSummary(order)} {renderOrderNumbers(group)}
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge> <Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupStatusLabel(group.status)}</Badge>
</td> </td>
<td className="px-5 py-4 text-sm">{resolveUserName(users, order.managerId)}</td>
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]"> <td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
{formatDateTime(order.updatedAt)} {group.readyCount || 0}/{group.ordersCount || 0}
</td>
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
</Panel> </Panel>
); );

View File

@ -3,20 +3,20 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { OrdersTable } from "./OrdersTable"; import { OrdersTable } from "./OrdersTable";
const orders = [ const orderGroups = [
{ {
id: "o-1", id: "o-1",
orderNumber: "CD-240031", groupKey: "9780001231|16.04.26",
customer: { displayTitle: "Мария Волкова",
name: "Мария Волкова", displaySubtitle: "+7 978 000-12-31 · 16.04.26",
phone: "+7 978 000-12-31", customerName: "Мария Волкова",
messenger: "СМС", customerPhone: "+7 978 000-12-31",
}, customerDate: "16.04.26",
items: ["Кухня | 1 шт"], orderNumbers: ["CD-240031"],
orderNotes: [{ text: "Подъезд узкий" }], ordersCount: 1,
comments: ["Нужен созвон"], readyCount: 1,
status: "Ожидает согласования доставки", notReadyCount: 0,
managerId: "u-manager", status: "ready_for_notification",
updatedAt: "2026-03-15T08:00:00Z", updatedAt: "2026-03-15T08:00:00Z",
}, },
]; ];
@ -25,19 +25,22 @@ describe("OrdersTable", () => {
it("renders desktop table and mobile card list", () => { it("renders desktop table and mobile card list", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<OrdersTable <OrdersTable
orders={orders} orderGroups={orderGroups}
selectedOrderId={null} selectedOrderGroupId={null}
onOpenOrder={() => {}} onOpenOrder={() => {}}
filters={{ search: "", status: "all", messenger: "all" }} filters={{ query: "", status: "all" }}
setFilters={() => {}} setFilters={() => {}}
statusOptions={[
{ value: "all", label: "Все статусы" },
{ value: "ready_for_notification", label: "ready_for_notification" },
]}
/>, />,
); );
expect(markup).toContain("hidden overflow-x-auto md:block"); expect(markup).toContain("hidden overflow-x-auto md:block");
expect(markup).toContain("md:hidden"); expect(markup).toContain("md:hidden");
expect(markup).toContain("Поиск по номеру, клиенту и телефону."); expect(markup).toContain("Поиск по группе, клиенту, телефону");
expect(markup).toContain("Поиск по заявке, клиенту, телефону"); expect(markup).toContain("Группы доставки");
expect(markup).toContain("CD-240031");
expect(markup).toContain("Мария Волкова"); expect(markup).toContain("Мария Волкова");
}); });
}); });

View File

@ -774,6 +774,143 @@ const extraDemoOrders = extraOrderSeeds.map(buildExtraDemoOrder);
export const demoOrders = [...baseDemoOrders, ...extraDemoOrders]; export const demoOrders = [...baseDemoOrders, ...extraDemoOrders];
export const demoOrderGroups = [
{
id: "953c5bda-7e77-47af-9b7f-9d2c2cf3e7c5",
groupKey: "3939375462|14.04.26",
customerName: "Калинина Дарья Егоровна",
customerPhone: "3939375462",
customerDate: "14.04.26",
ordersCount: 1,
readyCount: 1,
notReadyCount: 0,
orderNumbers: ["СФ Т\\ЕА-23094"],
status: "ready_for_notification",
deliveryStatus: "agreed",
deliveryHalfDay: "Первая половина дня",
smsSentAt: null,
createdFromExchangeAt: null,
sourceKey: null,
legacyCustomerName: null,
legacyCustomerPhone: null,
legacyCustomerPhoneNormalized: null,
legacyCustomerDate: null,
legacyOrdersTotal: null,
legacyOrdersReady: null,
legacyOrdersNotReady: null,
sourceOrders: null,
createdAt: "2026-05-05T09:43:53.750061+00:00",
updatedAt: "2026-05-05T09:43:53.750061+00:00",
},
{
id: "6420ea0d-7a4d-4a18-94cc-7d6d0a4a22ac",
groupKey: "2263561168|17.04.26",
customerName: "Петров Константин Владимирович",
customerPhone: "2263561168",
customerDate: "17.04.26",
ordersCount: 2,
readyCount: 2,
notReadyCount: 0,
orderNumbers: ["СФ Т\\ЕА-21974", "СФ Т\\ЕА-21975"],
status: "ready_for_notification",
deliveryStatus: "driver_assigned",
deliveryHalfDay: "Вторая половина дня",
smsSentAt: "2026-05-05T11:10:00+00:00",
createdFromExchangeAt: "2026-05-05T09:20:00+00:00",
sourceKey: "1c-21974",
legacyCustomerName: null,
legacyCustomerPhone: null,
legacyCustomerPhoneNormalized: null,
legacyCustomerDate: null,
legacyOrdersTotal: null,
legacyOrdersReady: null,
legacyOrdersNotReady: null,
sourceOrders: null,
createdAt: "2026-05-05T09:43:53.750061+00:00",
updatedAt: "2026-05-05T11:10:00+00:00",
},
{
id: "2e5c0ca6-dbd9-4dfd-95ca-f449b8d12a24",
groupKey: "8926690125|17.03.26",
customerName: "Иванов Степан Дмитриевич",
customerPhone: "8926690125",
customerDate: "17.03.26",
ordersCount: 1,
readyCount: 0,
notReadyCount: 1,
orderNumbers: ["СФ Т\\ЕА-16477"],
status: "manual_work",
deliveryStatus: "pending_confirmation",
smsSentAt: null,
createdFromExchangeAt: null,
sourceKey: "1c-16477",
legacyCustomerName: null,
legacyCustomerPhone: null,
legacyCustomerPhoneNormalized: null,
legacyCustomerDate: null,
legacyOrdersTotal: null,
legacyOrdersReady: null,
legacyOrdersNotReady: null,
sourceOrders: null,
createdAt: "2026-05-05T09:43:53.750061+00:00",
updatedAt: "2026-05-05T09:43:53.750061+00:00",
},
{
id: "30108722-e37b-424e-8307-328f7d80706e",
groupKey: "4227515073|11.04.26",
customerName: "Романов Кирилл Викторович",
customerPhone: "4227515073",
customerDate: "11.04.26",
ordersCount: 3,
readyCount: 3,
notReadyCount: 0,
orderNumbers: ["СФ Т\\ЕА-23120", "СФ Т\\ЕА-23123", "СФ Т\\ЕА-23129"],
status: "ready_for_notification",
deliveryStatus: "loaded",
deliveryHalfDay: "Первая половина дня",
smsSentAt: null,
createdFromExchangeAt: null,
sourceKey: "1c-23120",
legacyCustomerName: null,
legacyCustomerPhone: null,
legacyCustomerPhoneNormalized: null,
legacyCustomerDate: null,
legacyOrdersTotal: null,
legacyOrdersReady: null,
legacyOrdersNotReady: null,
sourceOrders: null,
createdAt: "2026-05-05T09:43:53.750061+00:00",
updatedAt: "2026-05-05T09:43:53.750061+00:00",
},
{
id: "78a5db18-c603-4317-bfdb-989a69979e9a",
groupKey: "6206926364|20.04.26",
customerName: "Антонов Ярослав",
customerPhone: "6206926364",
customerDate: "20.04.26",
ordersCount: 1,
readyCount: 1,
notReadyCount: 0,
orderNumbers: ["СФ Т\\ЕА-24508"],
status: "sms_sent",
deliveryStatus: "on_route",
deliveryHalfDay: "Вторая половина дня",
smsSentAt: "2026-05-05T12:45:00+00:00",
createdFromExchangeAt: null,
sourceKey: null,
legacyCustomerName: null,
legacyCustomerPhone: null,
legacyCustomerPhoneNormalized: null,
legacyCustomerDate: null,
legacyOrdersTotal: null,
legacyOrdersReady: null,
legacyOrdersNotReady: null,
sourceOrders: null,
createdAt: "2026-05-05T09:43:53.750061+00:00",
updatedAt: "2026-05-05T12:45:00+00:00",
},
];
export const demoNotifications = [ export const demoNotifications = [
{ {
id: "n-1", id: "n-1",

119
src/hooks/useOrderGroups.js Normal file
View File

@ -0,0 +1,119 @@
import React from "react";
import { demoOrderGroups } from "../data/mockAppData";
import { fetchOrderGroups } from "../services/supabase/orderGroupRepository";
import {
buildOrderGroupBuckets,
filterOrderGroups,
groupOrderGroupsByDate,
getOrderGroupStatusLabel,
} from "../services/orderGroupViews";
import { hasSupabaseConfig } from "../supabaseClient";
const cloneLiveGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => ({ ...group })) : []);
export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() =>
hasSupabaseConfig ? [] : cloneLiveGroups(demoOrderGroups),
);
const [filters, setFilters] = React.useState({
query: "",
status: "all",
});
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(() =>
hasSupabaseConfig ? null : demoOrderGroups[0]?.id ?? null,
);
const [isLoading, setIsLoading] = React.useState(hasSupabaseConfig);
const [loadError, setLoadError] = React.useState("");
React.useEffect(() => {
let cancelled = false;
const loadLiveData = async () => {
if (!hasSupabaseConfig) {
setOrderGroups(cloneLiveGroups(demoOrderGroups));
setIsLoading(false);
setLoadError("");
return;
}
setIsLoading(true);
setLoadError("");
const groupsResult = await fetchOrderGroups();
if (cancelled) {
return;
}
if (groupsResult.error) {
setLoadError(groupsResult.error?.message || "Не удалось загрузить группы доставки");
setOrderGroups([]);
setIsLoading(false);
return;
}
setOrderGroups(groupsResult.data || []);
setIsLoading(false);
};
loadLiveData();
return () => {
cancelled = true;
};
}, []);
React.useEffect(() => {
if (!orderGroups.length) {
setSelectedOrderGroupId(null);
return;
}
if (!selectedOrderGroupId || !orderGroups.some((group) => group.id === selectedOrderGroupId)) {
setSelectedOrderGroupId(orderGroups[0].id);
}
}, [orderGroups, selectedOrderGroupId]);
const statusOptions = React.useMemo(() => {
const statuses = Array.from(new Set(orderGroups.map((group) => group.status).filter(Boolean))).sort((left, right) =>
left.localeCompare(right),
);
return [
{ value: "all", label: "Все статусы" },
...statuses.map((status) => ({ value: status, label: getOrderGroupStatusLabel(status) })),
];
}, [orderGroups]);
const filteredOrderGroups = React.useMemo(
() => filterOrderGroups(orderGroups, filters),
[filters, orderGroups],
);
const visibleOrderGroups = filteredOrderGroups;
const selectedOrderGroup =
visibleOrderGroups.find((group) => group.id === selectedOrderGroupId) ||
orderGroups.find((group) => group.id === selectedOrderGroupId) ||
visibleOrderGroups[0] ||
null;
const orderGroupsByDate = React.useMemo(() => groupOrderGroupsByDate(orderGroups), [orderGroups]);
const deliveryGroupBuckets = React.useMemo(() => buildOrderGroupBuckets(orderGroups), [orderGroups]);
return {
orderGroups,
allOrderGroups: orderGroups,
filteredOrderGroups,
visibleOrderGroups,
selectedOrderGroup,
selectedOrderGroupId,
setSelectedOrderGroupId,
filters,
setFilters,
statusOptions,
orderGroupsByDate,
deliveryGroupBuckets,
isLoading,
loadError,
};
};

View File

@ -62,17 +62,19 @@ export const AppShell = ({
<div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0"> <div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
<Panel className="p-4 xl:hidden"> <Panel className="p-4 xl:hidden">
<div className="flex items-start justify-between gap-3"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0"> <div className="min-w-0 flex-1 space-y-1">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]"> <p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Рабочая область Рабочая область
</p> </p>
<h2 className="mt-2 truncate text-xl font-semibold">{sectionMeta?.label || "Панель"}</h2> <h2 className="text-lg font-semibold leading-tight sm:text-xl md:text-2xl">
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> {sectionMeta?.label || "Панель"}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{user.name} · {ROLE_LABELS[user.role]} {user.name} · {ROLE_LABELS[user.role]}
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
{onOpenGuide ? ( {onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка"> <Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"} {isGuideOpen ? "Назад" : "?"}
@ -119,26 +121,28 @@ export const AppShell = ({
</div> </div>
{shouldShowMobileNav ? ( {shouldShowMobileNav ? (
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 backdrop-blur xl:hidden"> <div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 backdrop-blur xl:hidden">
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto"> <div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
{navItems.map((item) => ( {navItems.map((item) => (
<button <button
key={item.key} key={item.key}
className={[ className={[
"flex min-w-[120px] flex-1 items-center justify-center gap-2 rounded-[18px] px-3 py-3 text-sm transition", "flex min-w-[120px] flex-1 items-center justify-center gap-2 rounded-[18px] px-3 py-3 text-sm transition",
activeSection === item.key activeSection === item.key
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]" ? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]", : "bg-[var(--color-surface)] text-[var(--color-text-muted)]",
].join(" ")} ].join(" ")}
onClick={() => onSectionChange(item.key)} onClick={() => onSectionChange(item.key)}
type="button" type="button"
> >
<span className="truncate font-medium">{item.label}</span> <span className="truncate font-medium">{item.label}</span>
{item.badge ? <Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge> : null} {item.badge ? (
</button> <Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
))} ) : null}
</button>
))}
</div>
</div> </div>
</div>
) : null} ) : null}
</div> </div>
); );

View File

@ -31,6 +31,7 @@ describe("AppShell", () => {
expect(markup).toContain("xl:hidden"); expect(markup).toContain("xl:hidden");
expect(markup).toContain("fixed inset-x-0 bottom-0"); expect(markup).toContain("fixed inset-x-0 bottom-0");
expect(markup).toContain("min-w-0"); expect(markup).toContain("min-w-0");
expect(markup).toContain("flex flex-col gap-3 md:flex-row md:items-start md:justify-between");
expect(markup).toContain("Рабочая область"); expect(markup).toContain("Рабочая область");
expect(markup).toContain("Заказы"); expect(markup).toContain("Заказы");
expect(markup).toContain("aria-label=\"Справка\""); expect(markup).toContain("aria-label=\"Справка\"");

View File

@ -1,9 +1,6 @@
import React from "react"; import React from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { DRIVER_STATUSES } from "../constants/deliveryWorkflow";
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner"; import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { DeliverySetDetailPanel } from "../components/logistics/DeliverySetDetailPanel";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel"; import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import { OrdersTable } from "../components/orders/OrdersTable"; import { OrdersTable } from "../components/orders/OrdersTable";
@ -12,24 +9,24 @@ import { Modal } from "../components/UI/Modal";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel"; import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useOrders } from "../hooks/useOrders"; import { useOrderGroups } from "../hooks/useOrderGroups";
import { AppShell } from "../layouts/AppShell"; import { AppShell } from "../layouts/AppShell";
const ROLE_SECTION = { const ROLE_SECTION = {
manager: { manager: {
key: "orders", key: "orders",
label: "Заказы", label: "Группы",
description: "Реестр заказов доставки, поиск и просмотр карточки.", description: "Реестр групп доставки, поиск и просмотр карточки.",
}, },
logistician: { logistician: {
key: "logistics", key: "logistics",
label: "Логистика", label: "Логистика",
description: отовые заказы на сегодня и ближайшие слоты доставки.", description: руппы доставки по готовности к уведомлению.",
}, },
driver: { driver: {
key: "deliveries", key: "deliveries",
label: "Мои доставки", label: "Мои доставки",
description: "Список доставок, адреса и состав заказа.", description: "Группы доставки по датам и статусам.",
}, },
}; };
@ -38,39 +35,29 @@ export const DashboardPage = () => {
const userRole = user?.role; const userRole = user?.role;
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager; const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
const [activeSection, setActiveSection] = React.useState(section.key); const [activeSection, setActiveSection] = React.useState(section.key);
const [isOrderModalOpen, setIsOrderModalOpen] = React.useState(false); const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false);
const [isDeliverySetModalOpen, setIsDeliverySetModalOpen] = React.useState(false);
const [selectedDeliverySet, setSelectedDeliverySet] = React.useState(null);
const { const {
orders, orderGroups,
allOrders, allOrderGroups,
selectedOrder, filteredOrderGroups,
selectedOrderId, selectedOrderGroup,
setSelectedOrderId, selectedOrderGroupId,
setSelectedOrderGroupId,
filters, filters,
setFilters, setFilters,
updateStatus, statusOptions,
assignDriver,
users,
isLoading, isLoading,
loadError, loadError,
deliverySetBuckets, } = useOrderGroups();
} = useOrders(user);
React.useEffect(() => { React.useEffect(() => {
setActiveSection(section.key); setActiveSection(section.key);
}, [section.key]); }, [section.key]);
React.useEffect(() => { const openGroupModal = React.useCallback((groupId) => {
if (!selectedOrderId && allOrders[0]?.id) { setSelectedOrderGroupId(groupId);
setSelectedOrderId(allOrders[0].id); setIsGroupModalOpen(true);
}
}, [allOrders, selectedOrderId, setSelectedOrderId]);
const openDeliverySetModal = React.useCallback((deliverySet) => {
setSelectedDeliverySet(deliverySet);
setIsDeliverySetModalOpen(true);
}, []); }, []);
const navItems = [ const navItems = [
@ -78,7 +65,7 @@ export const DashboardPage = () => {
key: section.key, key: section.key,
label: section.label, label: section.label,
description: section.description, description: section.description,
badge: String(allOrders.length || orders.length || 0), badge: String(allOrderGroups.length || orderGroups.length || 0),
}, },
]; ];
const guideSectionMeta = { const guideSectionMeta = {
@ -89,15 +76,6 @@ export const DashboardPage = () => {
const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0]; const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
const isGuideOpen = activeSection === "guide"; const isGuideOpen = activeSection === "guide";
const openOrderModal = (orderId) => {
setSelectedOrderId(orderId);
setIsOrderModalOpen(true);
};
const driverOrders = React.useMemo(
() => allOrders.filter((order) => DRIVER_STATUSES.includes(order.status)),
[allOrders],
);
if (!user) { if (!user) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
@ -105,28 +83,27 @@ export const DashboardPage = () => {
const renderManagerWorkspace = () => ( const renderManagerWorkspace = () => (
<div className="space-y-6 xl:space-y-8"> <div className="space-y-6 xl:space-y-8">
<OrdersTable <OrdersTable
orders={orders} orderGroups={filteredOrderGroups}
selectedOrderId={selectedOrderId} selectedOrderGroupId={selectedOrderGroupId}
onOpenOrder={openOrderModal} onOpenOrder={openGroupModal}
users={users}
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
statusOptions={statusOptions}
/> />
</div> </div>
); );
const renderLogisticsWorkspace = () => ( const renderLogisticsWorkspace = () => (
<div className="space-y-6 xl:space-y-8"> <div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard deliverySetBuckets={deliverySetBuckets} onSelectSet={openDeliverySetModal} /> <LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupModal} />
</div> </div>
); );
const renderDriverWorkspace = () => ( const renderDriverWorkspace = () => (
<div className="space-y-6 xl:space-y-8"> <div className="space-y-6 xl:space-y-8">
<DriverDeliveryPlanner <DriverDeliveryPlanner
orders={driverOrders} orderGroups={allOrderGroups}
onOpenOrder={openOrderModal} onOpenOrder={openGroupModal}
onStatusChange={(orderId, nextStatus) => updateStatus(orderId, nextStatus, user.name)}
/> />
</div> </div>
); );
@ -171,85 +148,23 @@ export const DashboardPage = () => {
{renderActiveSection()} {renderActiveSection()}
<Modal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)}> <Modal isOpen={isGroupModalOpen} onClose={() => setIsGroupModalOpen(false)}>
{user.role === "driver" ? (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">Карточка доставки</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Адрес, клиент, состав заказа и базовые действия по маршруту.
</p>
</div>
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
Закрыть
</Button>
</div>
<DriverDeliveryDetail
order={selectedOrder}
onStatusChange={(nextStatus) =>
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
}
/>
</div>
) : (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">Карточка заказа</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Основные данные заказа, клиента и доставки.
</p>
</div>
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
Закрыть
</Button>
</div>
<OrderDetailPanel
order={selectedOrder}
users={users}
currentUser={user}
onStatusChange={(nextStatus) =>
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
}
onAssignDriver={assignDriver}
/>
</div>
)}
</Modal>
<Modal
isOpen={isDeliverySetModalOpen}
onClose={() => {
setIsDeliverySetModalOpen(false);
setSelectedDeliverySet(null);
}}
>
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h3 className="text-xl font-semibold">Карточка набора доставки</h3> <h3 className="text-xl font-semibold">Карточка группы доставки</h3>
<p className="text-sm text-[var(--color-text-muted)]"> <p className="text-sm text-[var(--color-text-muted)]">Все данные из таблицы `order_groups`.</p>
Все связанные заказы, их производственные шаги и статус согласования.
</p>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setIsDeliverySetModalOpen(false); setIsGroupModalOpen(false);
setSelectedDeliverySet(null);
}} }}
> >
Закрыть Закрыть
</Button> </Button>
</div> </div>
<DeliverySetDetailPanel <OrderDetailPanel order={selectedOrderGroup} />
deliverySet={selectedDeliverySet}
onClose={() => {
setIsDeliverySetModalOpen(false);
setSelectedDeliverySet(null);
}}
/>
</div> </div>
</Modal> </Modal>
</AppShell> </AppShell>

View File

@ -4,17 +4,17 @@ import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "./DashboardPage"; import { DashboardPage } from "./DashboardPage";
const { useAuthMock, useOrdersMock } = vi.hoisted(() => ({ const { useAuthMock, useOrderGroupsMock } = vi.hoisted(() => ({
useAuthMock: vi.fn(), useAuthMock: vi.fn(),
useOrdersMock: vi.fn(), useOrderGroupsMock: vi.fn(),
})); }));
vi.mock("../context/AuthContext", () => ({ vi.mock("../context/AuthContext", () => ({
useAuth: useAuthMock, useAuth: useAuthMock,
})); }));
vi.mock("../hooks/useOrders", () => ({ vi.mock("../hooks/useOrderGroups", () => ({
useOrders: useOrdersMock, useOrderGroups: useOrderGroupsMock,
})); }));
vi.mock("../layouts/AppShell", () => ({ vi.mock("../layouts/AppShell", () => ({
@ -29,90 +29,44 @@ vi.mock("../layouts/AppShell", () => ({
), ),
})); }));
const baseOrder = { const baseGroup = {
id: "order-1", id: "group-1",
orderNumber: "CD-240031", groupKey: "9780001231|16.04.26",
status: "Ожидает согласования доставки", displayTitle: "Мария Волкова",
displaySubtitle: "+7 978 000-12-31 · 16.04.26",
customerName: "Мария Волкова",
customerPhone: "+7 978 000-12-31",
customerDate: "16.04.26",
ordersCount: 1,
readyCount: 1,
notReadyCount: 0,
orderNumbers: ["CD-240031"],
status: "ready_for_notification",
updatedAt: "2026-04-15T09:00:00Z", updatedAt: "2026-04-15T09:00:00Z",
createdAt: "2026-04-14T09:00:00Z",
scheduledDelivery: "2026-04-16T12:00:00Z",
customer: {
name: "Мария Волкова",
phone: "+7 978 000-12-31",
address: "Симферополь, ул. Ленина, 10",
messenger: "СМС",
items: ["Кухонный гарнитур | 1 комплект"],
},
items: ["Кухонный гарнитур | 1 комплект"],
comments: ["Нужен звонок за час"],
orderNotes: [{ id: "note-1", text: "Подъезд узкий" }],
history: [],
chatMessages: [],
deliverySlots: [],
}; };
const baseDeliverySet = { const mockOrderGroupsState = {
key: "set-1", orderGroups: [baseGroup],
name: "Набор Марии Волковой", allOrderGroups: [baseGroup],
status: "ready_to_launch", filteredOrderGroups: [baseGroup],
readyAt: "2026-04-15T08:00:00Z", visibleOrderGroups: [baseGroup],
readyReason: "all_accepted", selectedOrderGroup: baseGroup,
sourceCustomerCity: "Симферополь", selectedOrderGroupId: baseGroup.id,
orderCount: 1, setSelectedOrderGroupId: vi.fn(),
linkedBillTexts: "УН-00031",
orders: [baseOrder],
};
const mockOrdersState = {
orders: [baseOrder],
allOrders: [baseOrder],
selectedOrder: baseOrder,
selectedOrderId: baseOrder.id,
setSelectedOrderId: vi.fn(),
filters: { filters: {
query: "", query: "",
status: "all", status: "all",
stage: "all",
ownerRole: "all",
agingState: "all",
managerId: "all",
logisticianId: "all",
messenger: "all",
}, },
setFilters: vi.fn(), setFilters: vi.fn(),
notifications: [],
pushNotification: vi.fn(),
updateStatus: vi.fn(),
addChatMessage: vi.fn(),
addInternalMessage: vi.fn(),
addOrderNote: vi.fn(),
assignDriver: vi.fn(),
reassignDelivery: vi.fn(),
autoAssignLogisticians: vi.fn(),
saveDriverRouteOrder: vi.fn(),
metrics: {
total: 1,
readyToShip: 1,
awaitingDeliveryCoordination: 1,
exceptions: 0,
inLogistics: 1,
},
agingAlerts: [],
agingSummary: { warning: 0, critical: 0 },
deliverySetBuckets: { deliverySetBuckets: {
approaching: [], ready_to_launch: [baseGroup],
ready_to_launch: [baseDeliverySet], sms_sent: [],
awaiting_client: [],
manual_work: [], manual_work: [],
agreed: [],
completed: [],
}, },
users: [ statusOptions: [
{ id: "u-manager", name: "Анна", role: "manager" }, { value: "all", label: "Все статусы" },
{ id: "u-logistics", name: "Ольга", role: "logistician" }, { value: "ready_for_notification", label: "ready_for_notification" },
{ id: "u-driver", name: "Иван", role: "driver" },
], ],
isSupabaseBacked: true,
isLoading: false, isLoading: false,
loadError: "", loadError: "",
}; };
@ -121,10 +75,10 @@ describe("DashboardPage", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-15T09:00:00Z")); vi.setSystemTime(new Date("2026-04-15T09:00:00Z"));
useOrdersMock.mockReturnValue(mockOrdersState); useOrderGroupsMock.mockReturnValue(mockOrderGroupsState);
}); });
it("keeps the manager dashboard on the delivery registry only", () => { it("keeps the manager dashboard on the group registry only", () => {
useAuthMock.mockReturnValue({ useAuthMock.mockReturnValue({
user: { id: "u-manager", name: "Анна", role: "manager" }, user: { id: "u-manager", name: "Анна", role: "manager" },
signOut: vi.fn(), signOut: vi.fn(),
@ -136,8 +90,8 @@ describe("DashboardPage", () => {
</MemoryRouter>, </MemoryRouter>,
); );
expect(markup).toContain("Реестр заказов"); expect(markup).toContain("Группы доставки");
expect(markup).toContain("Поиск по номеру, клиенту и телефону."); expect(markup).toContain("Поиск по группе, клиенту, телефону и дате доставки.");
expect(markup).toContain("aria-label=\"Справка\""); expect(markup).toContain("aria-label=\"Справка\"");
expect(markup).not.toContain("<span>Справка</span>"); expect(markup).not.toContain("<span>Справка</span>");
expect(markup).not.toContain("доставочный контур"); expect(markup).not.toContain("доставочный контур");
@ -169,7 +123,7 @@ describe("DashboardPage", () => {
); );
expect(markup).toContain("Наборы доставки"); expect(markup).toContain("Наборы доставки");
expect(markup).toContain("Готово к запуску"); expect(markup).toContain("Готовы к уведомлению");
expect(markup).not.toContain("Управление ботами"); expect(markup).not.toContain("Управление ботами");
expect(markup).not.toContain("рабочая панель"); expect(markup).not.toContain("рабочая панель");
expect(markup).not.toContain("Сегодня"); expect(markup).not.toContain("Сегодня");
@ -179,34 +133,14 @@ describe("DashboardPage", () => {
}); });
it("keeps the driver dashboard on the deliveries list only", () => { it("keeps the driver dashboard on the deliveries list only", () => {
useOrdersMock.mockReturnValue({ useOrderGroupsMock.mockReturnValue({
...mockOrdersState, ...mockOrderGroupsState,
orders: [ orderGroups: [baseGroup],
{ allOrderGroups: [baseGroup],
...baseOrder, filteredOrderGroups: [baseGroup],
id: "driver-order-1", visibleOrderGroups: [baseGroup],
status: "Назначен водитель", selectedOrderGroup: baseGroup,
scheduledDelivery: "2026-04-15T12:00:00Z", selectedOrderGroupId: baseGroup.id,
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
},
],
allOrders: [
{
...baseOrder,
id: "driver-order-1",
status: "Назначен водитель",
scheduledDelivery: "2026-04-15T12:00:00Z",
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
},
],
selectedOrder: {
...baseOrder,
id: "driver-order-1",
status: "Назначен водитель",
scheduledDelivery: "2026-04-15T12:00:00Z",
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
},
selectedOrderId: "driver-order-1",
}); });
useAuthMock.mockReturnValue({ useAuthMock.mockReturnValue({
user: { id: "u-driver", name: "Иван", role: "driver" }, user: { id: "u-driver", name: "Иван", role: "driver" },
@ -220,8 +154,8 @@ describe("DashboardPage", () => {
); );
expect(markup).toContain("Мои доставки"); expect(markup).toContain("Мои доставки");
expect(markup).toContain("CD-240031");
expect(markup).toContain("Мария Волкова"); expect(markup).toContain("Мария Волкова");
expect(markup).toContain("CD-240031");
expect(markup).not.toContain("Канбан"); expect(markup).not.toContain("Канбан");
expect(markup).not.toContain("Календарь"); expect(markup).not.toContain("Календарь");
expect(markup).not.toContain("История"); expect(markup).not.toContain("История");

View File

@ -0,0 +1,360 @@
const normalizeDate = (value) => (value ? String(value) : "");
const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.customerDate || "");
export const DELIVERY_GROUP_STATUS_LABELS = {
pending_confirmation: "Ожидает согласования",
agreed: "Согласовано",
driver_assigned: "Назначен водитель",
loaded: "Загружено",
on_route: "В пути",
delivered: "Доставлено",
problem: "Проблема",
cancelled: "Отменено",
};
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"agreed",
"driver_assigned",
"loaded",
"on_route",
"problem",
"delivered",
];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["agreed", "driver_assigned", "loaded", "on_route", "problem"];
const HALF_DAY_LABELS = {
morning: "Первая половина дня",
afternoon: "Вторая половина дня",
};
const normalizeDeliveryHalfDayLabel = (value) => {
const normalized = normalizeDate(value).trim();
if (!normalized) {
return "";
}
const lower = normalized.toLowerCase();
if (lower.includes("до обеда") || lower.includes("первая половина дня") || lower.includes("утро")) {
return HALF_DAY_LABELS.morning;
}
if (lower.includes("после обеда") || lower.includes("вторая половина дня") || lower.includes("вечер")) {
return HALF_DAY_LABELS.afternoon;
}
return normalized;
};
const parseJsonIfNeeded = (value) => {
if (typeof value !== "string") {
return value;
}
try {
return JSON.parse(value);
} catch {
return value;
}
};
const findDeliveryHalfDayInValue = (value) => {
const parsedValue = parseJsonIfNeeded(value);
if (Array.isArray(parsedValue)) {
for (const item of parsedValue) {
const match = findDeliveryHalfDayInValue(item);
if (match) {
return match;
}
}
return "";
}
if (parsedValue && typeof parsedValue === "object") {
const candidates = [
parsedValue.deliveryTime,
parsedValue.delivery_time,
parsedValue.time,
parsedValue.deliveryHalfDay,
parsedValue.delivery_half_day,
parsedValue.window,
parsedValue.deliveryWindow,
parsedValue.delivery_window,
parsedValue.slot?.time,
parsedValue.deliverySlot?.time,
];
for (const candidate of candidates) {
const match = normalizeDeliveryHalfDayLabel(candidate);
if (match) {
return match;
}
}
for (const nestedValue of Object.values(parsedValue)) {
const match = findDeliveryHalfDayInValue(nestedValue);
if (match) {
return match;
}
}
}
return normalizeDeliveryHalfDayLabel(parsedValue);
};
export const getOrderGroupDeliveryHalfDay = (group) =>
normalizeDeliveryHalfDayLabel(
group?.deliveryHalfDay ||
group?.deliveryTime ||
group?.deliveryWindow ||
findDeliveryHalfDayInValue(group?.sourceOrders),
);
export const isOrderGroupAgreedForDelivery = (group) => {
if (!group) {
return false;
}
return isOrderGroupVisibleToDriver(group);
};
export const getOrderGroupDeliveryStatusLabel = (status) =>
DELIVERY_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
export const isOrderGroupVisibleToDriver = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status || "pending_confirmation";
return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus);
};
const parseGroupDate = (value) => {
const normalized = normalizeDate(value);
if (!normalized) {
return null;
}
const isoDateMatch = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (isoDateMatch) {
return new Date(normalized);
}
const shortDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{2})$/);
if (shortDateMatch) {
const [, day, month, year] = shortDateMatch;
return new Date(Date.UTC(Number(`20${year}`), Number(month) - 1, Number(day)));
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const filterOrderGroups = (groups, filters = {}) => {
const query = normalizeDate(filters.query).trim().toLowerCase();
const status = filters.status || "all";
const deliveryStatus = normalizeDate(filters.deliveryStatus || "all");
const dateFrom = normalizeDate(filters.dateFrom);
const dateTo = normalizeDate(filters.dateTo);
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
const isWithinDateRange = (group) => {
const deliveryDate = parseGroupDate(getDeliveryDate(group));
if (!deliveryDate) {
return !dateFrom && !dateTo;
}
if (dateFrom) {
const fromDate = parseGroupDate(dateFrom);
if (fromDate && deliveryDate < fromDate) {
return false;
}
}
if (dateTo) {
const toDate = parseGroupDate(dateTo);
if (toDate && deliveryDate > toDate) {
return false;
}
}
return true;
};
const getSearchHaystack = (group) =>
(group.searchText ||
[
group.groupKey,
group.displayTitle,
group.customerName,
group.customerPhone,
group.customerDate,
Array.isArray(group.orderNumbers) ? group.orderNumbers.join(" ") : "",
group.status,
getOrderGroupStatusLabel(group.status),
group.deliveryStatus,
getOrderGroupDeliveryStatusLabel(group.deliveryStatus),
]
.filter(Boolean)
.join(" "))
.toLowerCase();
return (groups || []).filter((group) => {
if (status !== "all" && group.status !== status) {
return false;
}
if (deliveryStatus !== "all") {
const groupDeliveryStatus = group.deliveryStatus || group.delivery_status || "pending_confirmation";
if (groupDeliveryStatus !== deliveryStatus) {
return false;
}
}
if (!isWithinDateRange(group)) {
return false;
}
if (deliveryHalfDay !== "all") {
const groupDeliveryHalfDay = getOrderGroupDeliveryHalfDay(group);
if (deliveryHalfDay === "unknown") {
if (groupDeliveryHalfDay) {
return false;
}
} else if (groupDeliveryHalfDay !== HALF_DAY_LABELS[deliveryHalfDay]) {
return false;
}
}
if (!query) {
return true;
}
return getSearchHaystack(group).includes(query);
});
};
export const ORDER_GROUP_STATUS_LABELS = {
ready_for_notification: "Готово к уведомлению",
sms_sent: "SMS отправлены",
manual_work: "Нужна ручная работа",
};
export const getOrderGroupStatusLabel = (status) =>
ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
export const getOrderGroupDeliveryStatusTone = (status) => {
if (status === "problem") {
return "warning";
}
if (status === "delivered") {
return "accent";
}
if (status === "cancelled") {
return "danger";
}
return "neutral";
};
export const groupOrderGroupsByDate = (groups) => {
const buckets = (groups || []).reduce((accumulator, group) => {
const date = getDeliveryDate(group) || "Без даты";
accumulator[date] = accumulator[date] || [];
accumulator[date].push(group);
return accumulator;
}, {});
return Object.entries(buckets)
.sort(([leftDate], [rightDate]) => {
const leftTime = parseGroupDate(leftDate)?.getTime();
const rightTime = parseGroupDate(rightDate)?.getTime();
if (leftTime != null && rightTime != null && leftTime !== rightTime) {
return leftTime - rightTime;
}
return leftDate.localeCompare(rightDate);
})
.map(([date, items]) => ({
date,
items: [...items].sort((left, right) => {
const leftCount = Number(left.ordersCount || 0);
const rightCount = Number(right.ordersCount || 0);
if (leftCount !== rightCount) {
return rightCount - leftCount;
}
return (right.updatedAt || "").localeCompare(left.updatedAt || "");
}),
}));
};
const getBucketKey = (group) => {
if (group.smsSentAt) {
return "sms_sent";
}
if ((group.readyCount || 0) > 0 && (group.notReadyCount || 0) > 0) {
return "manual_work";
}
if ((group.notReadyCount || 0) > 0) {
return "manual_work";
}
if (group.status === "ready_for_notification" || (group.readyCount || 0) >= (group.ordersCount || 0)) {
return "ready_to_launch";
}
return "manual_work";
};
export const ORDER_GROUP_BUCKET_LABELS = {
ready_to_launch: "Готовы к уведомлению",
sms_sent: "Уведомления отправлены",
manual_work: "Нужна ручная работа",
};
export const ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS = [
{ value: "all", label: "Все интервалы" },
{ value: "morning", label: HALF_DAY_LABELS.morning },
{ value: "afternoon", label: HALF_DAY_LABELS.afternoon },
{ value: "unknown", label: "Без времени" },
];
export const buildOrderGroupBuckets = (groups) => {
const buckets = {
ready_to_launch: [],
sms_sent: [],
manual_work: [],
};
for (const group of groups || []) {
const bucketKey = getBucketKey(group);
buckets[bucketKey].push(group);
}
return buckets;
};
export const getOrderGroupStatusTone = (group) => {
if (group.smsSentAt) {
return "accent";
}
if ((group.notReadyCount || 0) > 0) {
return "warning";
}
return "neutral";
};

View File

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
buildOrderGroupBuckets,
filterOrderGroups,
getOrderGroupDeliveryStatusLabel,
getOrderGroupDeliveryHalfDay,
groupOrderGroupsByDate,
isOrderGroupAgreedForDelivery,
isOrderGroupVisibleToDriver,
} from "./orderGroupViews";
describe("orderGroupViews", () => {
const groups = [
{
id: "g-1",
customerDate: "14.04.26",
customerName: "А",
customerPhone: "1",
deliveryTime: "До обеда",
orderNumbers: ["A-1"],
status: "ready_for_notification",
deliveryStatus: "agreed",
smsSentAt: null,
ordersCount: 1,
readyCount: 1,
notReadyCount: 0,
createdAt: "2026-05-05T10:00:00Z",
updatedAt: "2026-05-05T10:00:00Z",
},
{
id: "g-2",
customerDate: "14.04.26",
customerName: "B",
customerPhone: "2",
deliveryStatus: "delivered",
sourceOrders: [{ delivery_time: "После обеда" }],
orderNumbers: ["B-1", "B-2"],
status: "ready_for_notification",
smsSentAt: "2026-05-05T12:00:00Z",
ordersCount: 2,
readyCount: 2,
notReadyCount: 0,
createdAt: "2026-05-05T11:00:00Z",
updatedAt: "2026-05-05T11:00:00Z",
},
{
id: "g-3",
customerDate: "15.04.26",
customerName: "C",
customerPhone: "3",
deliveryStatus: "pending_confirmation",
orderNumbers: ["C-1"],
status: "draft",
smsSentAt: null,
ordersCount: 1,
readyCount: 0,
notReadyCount: 1,
createdAt: "2026-05-05T13:00:00Z",
updatedAt: "2026-05-05T13:00:00Z",
},
];
it("groups delivery groups by customer date", () => {
const grouped = groupOrderGroupsByDate(groups);
expect(grouped).toHaveLength(2);
expect(grouped[0].date).toBe("14.04.26");
expect(grouped[0].items).toHaveLength(2);
expect(grouped[1].date).toBe("15.04.26");
});
it("builds readiness buckets from group status and sms timestamp", () => {
const buckets = buildOrderGroupBuckets(groups);
expect(buckets.ready_to_launch).toHaveLength(1);
expect(buckets.sms_sent).toHaveLength(1);
expect(buckets.manual_work).toHaveLength(1);
});
it("normalizes delivery half day and filters by date and time", () => {
expect(getOrderGroupDeliveryHalfDay(groups[0])).toBe("Первая половина дня");
expect(getOrderGroupDeliveryHalfDay(groups[1])).toBe("Вторая половина дня");
expect(getOrderGroupDeliveryStatusLabel(groups[0].deliveryStatus)).toBe("Согласовано");
expect(isOrderGroupVisibleToDriver(groups[0])).toBe(true);
expect(isOrderGroupAgreedForDelivery(groups[0])).toBe(true);
expect(isOrderGroupVisibleToDriver(groups[2])).toBe(false);
const filtered = filterOrderGroups(groups, {
dateFrom: "2026-04-14",
dateTo: "2026-04-14",
deliveryHalfDay: "morning",
deliveryStatus: "agreed",
});
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("g-1");
});
});

View File

@ -0,0 +1,152 @@
import { safeSupabaseCall } from "../safeSupabaseCall";
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
import {
getOrderGroupDeliveryHalfDay,
getOrderGroupDeliveryStatusLabel,
getOrderGroupStatusLabel,
} from "../orderGroupViews";
const requireSupabase = () => {
if (!hasSupabaseConfig || !supabase) {
throw new Error("Supabase не сконфигурирован");
}
return supabase;
};
const normalizeText = (value) => (value == null ? "" : String(value)).trim();
const normalizePhone = (value) => normalizeText(value).replace(/[\s\-()]/g, "");
const toNumber = (value, fallback = 0) => {
const nextValue = Number(value);
return Number.isFinite(nextValue) ? nextValue : fallback;
};
const toStringArray = (value) => {
if (Array.isArray(value)) {
return value.filter((item) => item != null && String(item).trim() !== "").map(String);
}
if (value == null || value === "") {
return [];
}
return [String(value)];
};
const parseGroupKey = (groupKey) => {
if (!groupKey || typeof groupKey !== "string") {
return { phone: "", date: "" };
}
const [phone = "", date = ""] = groupKey.split("|");
return {
phone: normalizePhone(phone),
date: normalizeText(date),
};
};
export const mapOrderGroupRowToDeliveryGroup = (row) => {
if (!row) {
return null;
}
const parsedKey = parseGroupKey(row.group_key);
const customerName = normalizeText(row.customer_name || row.legacy_customer_name);
const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone);
const customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date);
const ordersCount = toNumber(row.orders_count ?? row.legacy_orders_total, 0);
const readyCount = toNumber(row.ready_count ?? row.legacy_orders_ready, 0);
const notReadyCount = toNumber(row.not_ready_count ?? row.legacy_orders_not_ready, 0);
const orderNumbers = toStringArray(row.order_numbers);
const deliveryStatus = normalizeText(row.delivery_status) || "pending_confirmation";
return {
id: row.id,
groupKey: row.group_key,
customer: {
name: customerName,
phone: customerPhone,
phoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
date: customerDate,
ordersCount,
readyCount,
notReadyCount,
},
customerName,
customerPhone,
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
customerDate,
ordersCount,
readyCount,
notReadyCount,
orderNumbers,
status: row.status || "draft",
smsSentAt: row.sms_sent_at || null,
createdFromExchangeAt: row.created_from_exchange_at || null,
sourceKey: row.source_key || null,
legacyCustomerName: row.legacy_customer_name || null,
legacyCustomerPhone: row.legacy_customer_phone || null,
legacyCustomerPhoneNormalized: row.legacy_customer_phone_normalized || null,
legacyCustomerDate: row.legacy_customer_date || null,
legacyOrdersTotal: row.legacy_orders_total ?? null,
legacyOrdersReady: row.legacy_orders_ready ?? null,
legacyOrdersNotReady: row.legacy_orders_not_ready ?? null,
sourceOrders: row.source_orders || null,
createdAt: row.created_at,
updatedAt: row.updated_at,
deliveryStatus,
delivery_status: deliveryStatus,
displayTitle: customerName || `Группа ${row.group_key || row.id}`,
displaySubtitle: [customerPhone, customerDate].filter(Boolean).join(" · ") || row.group_key || row.id,
deliveryDate: customerDate,
deliveryHalfDay: getOrderGroupDeliveryHalfDay({
deliveryHalfDay: row.delivery_half_day,
deliveryTime: row.delivery_time,
deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders,
}),
orderNumberSummary: orderNumbers.length ? orderNumbers.join(", ") : "Номера не указаны",
searchText: [
row.group_key,
customerName,
customerPhone,
customerDate,
row.delivery_half_day,
row.delivery_time,
row.delivery_window,
deliveryStatus,
getOrderGroupDeliveryStatusLabel(deliveryStatus),
orderNumbers.join(" "),
row.status,
getOrderGroupStatusLabel(row.status),
getOrderGroupDeliveryHalfDay({
deliveryHalfDay: row.delivery_half_day,
deliveryTime: row.delivery_time,
deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders,
}),
getOrderGroupDeliveryStatusLabel(deliveryStatus),
]
.filter(Boolean)
.join(" ")
.toLowerCase(),
};
};
export const fetchOrderGroups = async () => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
const { data, error } = await client
.from("order_groups")
.select("*")
.order("updated_at", { ascending: false });
if (error) {
throw error;
}
return (data || []).map(mapOrderGroupRowToDeliveryGroup).filter(Boolean);
}, "Ошибка загрузки групп доставки");
};

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { mapOrderGroupRowToDeliveryGroup } from "./orderGroupRepository";
describe("mapOrderGroupRowToDeliveryGroup", () => {
it("normalizes the order_groups row into a delivery-group view model", () => {
const group = mapOrderGroupRowToDeliveryGroup({
id: "953c5bda-7e77-47af-9b7f-9d2c2cf3e7c5",
group_key: "3939375462|14.04.26",
customer_name: "Калинина Дарья Егоровна",
customer_phone: "3939375462",
customer_date: "14.04.26",
orders_count: 1,
ready_count: 1,
not_ready_count: 0,
order_numbers: ["СФ Т\\ЕА-23094"],
status: "ready_for_notification",
delivery_status: "agreed",
sms_sent_at: null,
delivery_time: "До обеда",
created_from_exchange_at: null,
source_key: null,
legacy_customer_name: null,
legacy_customer_phone: null,
legacy_customer_phone_normalized: null,
legacy_customer_date: null,
legacy_orders_total: null,
legacy_orders_ready: null,
legacy_orders_not_ready: null,
source_orders: null,
created_at: "2026-05-05 09:43:53.750061+00",
updated_at: "2026-05-05 09:43:53.750061+00",
});
expect(group.id).toBe("953c5bda-7e77-47af-9b7f-9d2c2cf3e7c5");
expect(group.groupKey).toBe("3939375462|14.04.26");
expect(group.customer.name).toBe("Калинина Дарья Егоровна");
expect(group.customer.phone).toBe("3939375462");
expect(group.customer.date).toBe("14.04.26");
expect(group.ordersCount).toBe(1);
expect(group.readyCount).toBe(1);
expect(group.notReadyCount).toBe(0);
expect(group.orderNumbers).toEqual(["СФ Т\\ЕА-23094"]);
expect(group.displayTitle).toBe("Калинина Дарья Егоровна");
expect(group.displaySubtitle).toBe("3939375462 · 14.04.26");
expect(group.deliveryDate).toBe("14.04.26");
expect(group.deliveryHalfDay).toBe("Первая половина дня");
expect(group.deliveryStatus).toBe("agreed");
});
});