feat(order-groups): wire driver delivery flow
This commit is contained in:
parent
f2230f3277
commit
684424dd25
|
|
@ -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"
|
||||||
|
```
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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("Календарь");
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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("Нужна ручная работа");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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("Команда");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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("СМС");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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("Мария Волкова");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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=\"Справка\"");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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("История");
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
};
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}, "Ошибка загрузки групп доставки");
|
||||||
|
};
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue