diff --git a/docs/superpowers/plans/2026-05-06-order-groups-migration.md b/docs/superpowers/plans/2026-05-06-order-groups-migration.md new file mode 100644 index 0000000..720cde6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-order-groups-migration.md @@ -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" +``` diff --git a/src/components/UI/Badge.jsx b/src/components/UI/Badge.jsx index 3a48372..1c39a30 100644 --- a/src/components/UI/Badge.jsx +++ b/src/components/UI/Badge.jsx @@ -5,12 +5,12 @@ export const Badge = ({ children, tone = "neutral" }) => { return ( diff --git a/src/components/driver/DriverDeliveryPlanner.jsx b/src/components/driver/DriverDeliveryPlanner.jsx index c3282e5..3e0ea68 100644 --- a/src/components/driver/DriverDeliveryPlanner.jsx +++ b/src/components/driver/DriverDeliveryPlanner.jsx @@ -1,29 +1,131 @@ import React from "react"; -import { getAvailableTransitionsByRole, getStatusTone } from "../../constants/deliveryWorkflow"; -import { groupDriverDeliveriesByDate, getDeliveryCity, getDeliveryHalfDay } from "../../services/driverDeliveries"; +import { + 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 { Button } from "../UI/Button"; +import { Input } from "../UI/Input"; +import { Select } from "../UI/Select"; import { Panel } from "../UI/Panel"; -export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) => { - const groupedOrders = React.useMemo(() => groupDriverDeliveriesByDate(orders), [orders]); +const DRIVER_DELIVERY_STATUS_OPTIONS = [ + { 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 (
-
-
-

Мои доставки

-

- Список доставок с адресом, клиентом, составом заказа и базовыми действиями по статусу. -

+
+
+
+

Мои доставки

+

+ Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня. +

+
+ {filteredOrderGroups.length} +
+ +
+ + + +
- {orders.length}
- {groupedOrders.length ? ( - groupedOrders.map((group) => ( + {groupedOrderGroups.length ? ( + groupedOrderGroups.map((group) => (
@@ -35,60 +137,44 @@ export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) = })}

- {group.items.length} {group.items.length === 1 ? "доставка" : "доставки"} + {group.items.length} {group.items.length === 1 ? "группа" : "группы"}

{group.date}
- {group.items.map((order) => { - const availableTransitions = getAvailableTransitionsByRole({ - status: order.status, - role: "driver", - }); - - return ( - - ))} -
- - ); - })} +
{item.smsSentAt ? "SMS отправлено" : "SMS не отправлено"}
+
+ + ))}
)) @@ -96,7 +182,7 @@ export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) =

Доставки не найдены

- Сейчас у вас нет назначенных доставок. + Сейчас у вас нет назначенных групп доставки.

)} diff --git a/src/components/driver/DriverDeliveryPlanner.test.jsx b/src/components/driver/DriverDeliveryPlanner.test.jsx index 9ad0662..11c2957 100644 --- a/src/components/driver/DriverDeliveryPlanner.test.jsx +++ b/src/components/driver/DriverDeliveryPlanner.test.jsx @@ -3,20 +3,41 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { DriverDeliveryPlanner } from "./DriverDeliveryPlanner"; -const orders = [ +const orderGroups = [ { id: "driver-order-1", - orderNumber: "CD-240031", - status: "К доставке", - scheduledDelivery: "2026-04-16T12:00:00Z", - customer: { - name: "Мария Волкова", - address: "Симферополь, ул. Ленина, 10", - phone: "+7 978 000-12-31", - }, - orderNotes: [{ text: "Подъезд узкий" }], - comments: ["Позвонить за час"], - driverRouteOrder: 1, + groupKey: "9780001231|16.04.26", + displayTitle: "Мария Волкова", + displaySubtitle: "+7 978 000-12-31 · 16.04.26", + customerName: "Мария Волкова", + customerPhone: "+7 978 000-12-31", + customerDate: "16.04.26", + orderNumbers: ["CD-240031"], + ordersCount: 1, + readyCount: 1, + notReadyCount: 0, + 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", () => { const markup = renderToStaticMarkup( {}} - onStatusChange={() => {}} />, ); expect(markup).toContain("Мои доставки"); - expect(markup).toContain("CD-240031"); 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("Календарь"); diff --git a/src/components/logistics/DeliverySetDetailPanel.jsx b/src/components/logistics/DeliverySetDetailPanel.jsx index 8a254ee..f3b403e 100644 --- a/src/components/logistics/DeliverySetDetailPanel.jsx +++ b/src/components/logistics/DeliverySetDetailPanel.jsx @@ -1,124 +1,14 @@ -import React from "react"; -import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; -import { Panel } from "../UI/Panel"; -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", - }); -}; +import { OrderDetailPanel } from "../orders/OrderDetailPanel"; export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => { if (!deliverySet) { return null; } - const bucketLabel = DELIVERY_SET_BUCKET_LABELS[deliverySet.status] || deliverySet.status; - return (
- -
-
-

{deliverySet.name}

-

- {deliverySet.sourceCustomerCity || "\u2014"} \u00B7 {deliverySet.orderCount}{" "} - {deliverySet.orderCount === 1 ? "заказ" : deliverySet.orderCount < 5 ? "заказа" : "заказов"} в наборе -

-
-
- - {bucketLabel} - - {deliverySet.readyAt ? ( - - Готов с {formatStepDate(deliverySet.readyAt)} - - ) : null} -
-
- - {deliverySet.linkedBillTexts ? ( -
- Связанные счета: {deliverySet.linkedBillTexts} -
- ) : null} - - {deliverySet.readyReason ? ( -
- {deliverySet.readyReason === "all_accepted" - ? "Все заказы набора прошли контроль качества, можно запускать доставку." - : "Не все заказы набора ещё прошли контроль качества."} -
- ) : null} -
- - {deliverySet.orders.map((order) => ( - -
-
-
{order.orderNumber}
- {order.sourceFieldSummary?.sourceOrderNumber ? ( -
- 1С: {order.sourceFieldSummary.sourceOrderNumber} -
- ) : null} -
- {order.status} -
- -
- {Object.entries(PRODUCTION_STEP_LABELS).map(([key, label]) => { - const value = order.sourceFieldSummary?.[key]; - if (!value) { - return null; - } - - return ( -
- {label}:{" "} - {formatStepDate(value)} -
- ); - })} -
- - {order.sourceFieldSummary?.sourceCustomerPhone ? ( -
- \u260E {order.sourceFieldSummary.sourceCustomerPhone} - {order.sourceFieldSummary.sourceCustomerEmail - ? ` \u00B7 ${order.sourceFieldSummary.sourceCustomerEmail}` - : ""} -
- ) : null} - - {order.deliverySlots?.length ? ( -
- Слот:{" "} - - {order.deliverySlots[0].date} \u00B7 {order.deliverySlots[0].time} - -
- ) : null} -
- ))} + {onClose ? (
diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index a161ca6..f88adc7 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -1,122 +1,145 @@ 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 { Panel } from "../UI/Panel"; - -const BUCKET_TONES = { - approaching: "neutral", - ready_to_launch: "accent", - awaiting_client: "warning", - manual_work: "danger", - agreed: "accent", - completed: "neutral", -}; +import { OrderFilters } from "../orders/OrderFilters"; const BUCKET_ICONS = { - approaching: "\u2192", ready_to_launch: "\u2713", - awaiting_client: "\u23F3", + sms_sent: "\u2709", manual_work: "\u26A0", - agreed: "\u2B50", - completed: "\u2714", }; -export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) => { - const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS); - const buckets = deliverySetBuckets || {}; - const totalSets = bucketKeys.reduce( - (sum, key) => sum + (buckets[key]?.length || 0), - 0, +const ORDER_GROUP_STATUS_OPTIONS = [ + { value: "all", label: "Все статусы" }, + ...Object.entries(ORDER_GROUP_STATUS_LABELS).map(([value, label]) => ({ value, label })), +]; + +const renderOrderNumbers = (group) => { + if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) { + return Номера не указаны; + } + + return ( +
+ {group.orderNumbers.map((number) => ( + + {number} + + ))} +
); +}; + +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 (
- -
-

Наборы доставки

-

- Группировка импортированных заказов по клиентским наборам. Каждый набор запускается в доставку целиком после приёмки всех заказов. -

+ +
+
+

Наборы доставки

+

+ Группы из таблицы `order_groups`, разбитые по состоянию готовности. +

+
+ {totalGroups} групп
- {totalSets} наборов + +
-
- {bucketKeys.map((bucketKey) => { - const sets = buckets[bucketKey] || []; - const label = DELIVERY_SET_BUCKET_LABELS[bucketKey]; - const tone = BUCKET_TONES[bucketKey]; - const icon = BUCKET_ICONS[bucketKey]; + {!totalGroups ? ( + + По этому поиску ничего не найдено. + + ) : ( +
+ {bucketKeys.map((bucketKey) => { + const groups = buckets[bucketKey] || []; + const label = ORDER_GROUP_BUCKET_LABELS[bucketKey]; + const icon = BUCKET_ICONS[bucketKey]; + + if (!groups.length) { + return ( + +
+ {icon} +

{label}

+
+

Нет групп

+
+ ); + } - if (!sets.length) { return ( - +
{icon}

{label}

+ {groups.length}
-

Нет наборов

- - ); - } - return ( -
-
- {icon} -

{label}

- {sets.length} -
- - {sets.map((set) => { - const setOrders = Array.isArray(set.orders) ? set.orders : []; - const orderCount = set.orderCount ?? setOrders.length; - - return ( + {groups.map((group) => ( - ); - })} -
- ); - })} -
+ ))} +
+ ); + })} +
+ )}
); }; diff --git a/src/components/logistics/LogisticsReadinessBoard.test.jsx b/src/components/logistics/LogisticsReadinessBoard.test.jsx index 46a1c7f..d6759d7 100644 --- a/src/components/logistics/LogisticsReadinessBoard.test.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.test.jsx @@ -1,24 +1,24 @@ 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", () => { - it("renders all delivery-set bucket labels from the model", () => { - const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS); - expect(bucketKeys).toContain("approaching"); + it("renders all group bucket labels from the model", () => { + const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS); expect(bucketKeys).toContain("ready_to_launch"); - expect(bucketKeys).toContain("awaiting_client"); expect(bucketKeys).toContain("manual_work"); - expect(bucketKeys).toContain("agreed"); - expect(bucketKeys).toContain("completed"); - expect(bucketKeys).toHaveLength(6); + expect(bucketKeys).toContain("sms_sent"); + expect(bucketKeys).toHaveLength(3); }); it("renders bucket labels in Russian", () => { - expect(DELIVERY_SET_BUCKET_LABELS.approaching).toBe("На подходе"); - expect(DELIVERY_SET_BUCKET_LABELS.ready_to_launch).toBe("Готово к запуску"); - expect(DELIVERY_SET_BUCKET_LABELS.awaiting_client).toBe("Ожидает клиента"); - expect(DELIVERY_SET_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа"); - expect(DELIVERY_SET_BUCKET_LABELS.agreed).toBe("Согласовано"); - expect(DELIVERY_SET_BUCKET_LABELS.completed).toBe("Завершено"); + expect(ORDER_GROUP_BUCKET_LABELS.ready_to_launch).toBe("Готовы к уведомлению"); + expect(ORDER_GROUP_BUCKET_LABELS.sms_sent).toBe("Уведомления отправлены"); + expect(ORDER_GROUP_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа"); }); -}); \ No newline at end of file + + 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("Нужна ручная работа"); + }); +}); diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index 341db88..eb65e7c 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -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 { Badge } from "../UI/Badge"; -import { Button } from "../UI/Button"; import { Panel } from "../UI/Panel"; +import { getOrderGroupStatusLabel, getOrderGroupStatusTone } from "../../services/orderGroupViews"; -const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); -const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен"; - -const splitItem = (item) => { - if (!item) { - return { name: "Позиция", quantity: "" }; +const renderList = (values) => { + if (!Array.isArray(values) || !values.length) { + return

Нет данных

; } - if (typeof item === "string") { - const [name, quantity] = item.split("|").map((part) => part.trim()); - return { - name: name || item, - quantity: quantity || "", - }; - } - - if (typeof item === "object") { - return { - name: item.name || item.label || "Позиция", - quantity: typeof item.quantity === "number" ? String(item.quantity) : item.quantity || "", - }; - } - - return { name: "Позиция", quantity: "" }; + return ( +
+ {values.map((value, index) => ( + + {value} + + ))} +
+ ); }; -export const OrderDetailPanel = ({ order, users, currentUser, onStatusChange, onAssignDriver }) => { +const renderValue = (value) => value || "Не указано"; + +export const OrderDetailPanel = ({ order }) => { if (!order) { return ( -

Выберите заказ для просмотра деталей.

+

Выберите группу для просмотра деталей.

); } - 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 (
-

Карточка заказа

-

{order.orderNumber}

+

+ Карточка группы доставки +

+

+ {order.displayTitle || order.customerName || order.groupKey} +

- {order.customer.name} · {order.customer.address} + {order.displaySubtitle || [order.customerPhone, order.customerDate].filter(Boolean).join(" · ") || "Не указано"}

- {order.status} + {getOrderGroupStatusLabel(order.status)}
-

- {getOrderStatusComment(order.status)} -

-
-

Менеджер

-

{resolveUserName(users, order.managerId)}

+

Группа

+

{renderValue(order.groupKey)}

-
-

Логист

-

{resolveUserName(users, order.logisticianIds?.[0])}

-
-
-

Водитель

-

{resolveUserName(users, order.assignedDriverId)}

-
-
-

Дата создания

-

{formatDateTime(order.createdAt)}

-
-
-

План доставки

-

{formatDateTime(order.scheduledDelivery)}

-
-
-

Канал связи

-

{order.customer.messenger}

-
-
-

Согласование доставки

-

{order.deliveryAgreementStatus}

-
-
-
- - -
- Данные клиента - {order.status} -
-

- {getDeliveryAgreementComment(order.deliveryAgreementStatus)} -

-

Клиент

-

{order.customer.name}

+

{renderValue(order.customerName)}

Телефон

-

{order.customer.phone}

+

{renderValue(order.customerPhone)}

-

Адрес

-

{order.customer.address}

+

Дата

+

{renderValue(order.customerDate)}

-

Дата доставки

-

{formatDateTime(order.scheduledDelivery)}

+

Всего заказов

+

{order.ordersCount ?? 0}

+
+
+

Готово

+

{order.readyCount ?? 0}

+
+
+

Не готово

+

{order.notReadyCount ?? 0}

+
+
+

Обновлена

+

{formatDateTime(order.updatedAt)}

- Состав заказа -
- {orderItems.length ? ( - orderItems.map((item) => ( -
- {item.name} - {item.quantity ? {item.quantity} : null} -
- )) - ) : ( -

Состав заказа не указан.

- )} + Номера заказов + {renderList(order.orderNumbers)} + + + + Дополнительные данные +
+
+

SMS отправлено

+

{renderValue(formatDateTime(order.smsSentAt))}

+
+
+

Создано из обмена

+

{renderValue(formatDateTime(order.createdFromExchangeAt))}

+
+
+

Source key

+

{renderValue(order.sourceKey)}

+
+
+

Legacy customer

+

{renderValue(order.legacyCustomerName)}

+
- {order.orderNotes?.length ? ( + {order.sourceOrders ? ( - Комментарии -
- {order.orderNotes.map((note) => ( -
- {note.text} -
- ))} -
-
- ) : null} - - {order.comments?.length ? ( - - Дополнительные комментарии -
- {order.comments.map((comment, index) => ( -
- {comment} -
- ))} -
-
- ) : null} - - {availableTransitions.length ? ( - - Действия -
- {availableTransitions.map((status) => ( - - ))} -
-
- ) : null} - - {canAssignDriver ? ( - - Назначить водителя -
- {drivers.map((driver) => ( - - ))} - {order.assignedDriverId ? ( - - ) : null} -
-
- ) : null} - - {orderHistory.length ? ( - - История -
- {orderHistory.map((entry) => ( -
-
- {entry.action} - {formatDateTime(entry.at)} -
-
- {entry.oldStatus || "Начало"} → {entry.newStatus} -
-
- ))} -
+ Source orders +
+            {JSON.stringify(order.sourceOrders, null, 2)}
+          
) : null}
diff --git a/src/components/orders/OrderDetailPanel.test.jsx b/src/components/orders/OrderDetailPanel.test.jsx index a13fe89..8afe509 100644 --- a/src/components/orders/OrderDetailPanel.test.jsx +++ b/src/components/orders/OrderDetailPanel.test.jsx @@ -5,64 +5,52 @@ import { OrderDetailPanel } from "./OrderDetailPanel"; const order = { id: "o-1", - orderNumber: "CD-240031", - status: "Ожидает согласования доставки", - deliveryAgreementStatus: "Ожидание ответа", - managerId: "u-manager", - logisticianIds: ["u-logistics"], - assignedDriverId: null, + groupKey: "9780001231|16.04.26", + 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", + smsSentAt: null, + createdFromExchangeAt: null, + sourceKey: null, + legacyCustomerName: null, + sourceOrders: null, createdAt: "2026-03-15T08:00:00Z", - scheduledDelivery: "2026-03-16T09:00:00Z", + updatedAt: "2026-03-16T09:00:00Z", customer: { name: "Мария Волкова", phone: "+7 978 000-12-31", - address: "Симферополь", - messenger: "СМС", + date: "16.04.26", }, - items: ["Кухня | 1 шт"], - chatMessages: [], - internalMessages: [], - orderNotes: [], - history: [], }; describe("OrderDetailPanel", () => { - it("keeps the order card read-first without workflow controls", () => { + it("keeps the group card read-first", () => { const markup = renderToStaticMarkup( - , + , ); - expect(markup).toContain("CD-240031"); + expect(markup).toContain("Карточка группы доставки"); expect(markup).toContain("Мария Волкова"); - expect(markup).toContain("Кухня"); - expect(markup).toContain("1 шт"); - expect(markup).not.toContain("Назначение водителя"); - expect(markup).not.toContain("Изменить статус"); - expect(markup).not.toContain("Чат с клиентом"); - expect(markup).not.toContain("Команда"); + expect(markup).toContain("CD-240031"); + expect(markup).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( , ); @@ -70,12 +58,9 @@ describe("OrderDetailPanel", () => { expect(markup).toContain("Не указано"); }); - it("does not expose driver assignment or status controls", () => { - const markup = renderToStaticMarkup(); + it("renders order numbers as chips", () => { + const markup = renderToStaticMarkup(); - expect(markup).not.toContain("Назначение водителя"); - expect(markup).not.toContain("Изменить статус"); - expect(markup).not.toContain("Чат с клиентом"); - expect(markup).not.toContain("Команда"); + expect(markup).toContain("CD-240031"); }); }); diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index 5f1262f..33e4647 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -1,204 +1,49 @@ -import React from "react"; -import { DELIVERY_REGISTRY_FILTER_STATUSES } from "../../constants/orderStatuses"; import { Badge } from "../UI/Badge"; -import { Button } from "../UI/Button"; import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; -const messengers = ["СМС", "Эл. почта"]; -const statusOptions = [ - { 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 ( -
{ - if (!event.currentTarget.contains(event.relatedTarget)) { - onClose(); - } - }} - > - - - {isOpen ? ( -
- {options.map((option) => { - const selected = option.value === value; - - return ( - - ); - })} -
- ) : null} -
- ); -}; - -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); - +export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => { + const selectedStatusLabel = statusOptions.find((option) => option.value === filters.status)?.label || filters.status; const updateFilter = (key, value) => { setFilters((current) => ({ ...current, [key]: value })); }; - const renderFilterField = (label, control, showLabel = false) => ( -
- {showLabel ? ( - - {label} - - ) : null} - {control} -
- ); - - const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => ( -
-
- {renderFilterField( - "Статус", - setOpenMenu((current) => (current === "status" ? null : "status"))} - onChange={(value) => updateFilter("status", value)} - onClose={() => setOpenMenu(null)} - />, - showLabels, - )} - - {renderFilterField( - "Канал", - setOpenMenu((current) => (current === "messenger" ? null : "messenger"))} - onChange={(value) => updateFilter("messenger", value)} - onClose={() => setOpenMenu(null)} - />, - showLabels, - )} -
-
- ); + const activeChips = [filters.status !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean); return ( -
-
- updateFilter("query", event.target.value)} - /> - -
- {activeChips.length ? ( -
-
- Активные фильтры -
-
- {activeChips.map((chip) => ( - {chip.label} - ))} -
-
- ) : null} - {isMobileFiltersOpen - ? renderAdvancedFilters({ - className: "rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3", - }) - : null} +
+ updateFilter("query", event.target.value)} + /> + +
-
-
- updateFilter("query", event.target.value)} - /> - {activeChips.length ? ( -
-
- Активные фильтры -
-
- {activeChips.map((chip) => ( - {chip.label} - ))} -
-
- ) : null} + {activeChips.length ? ( +
+ {activeChips.map((chip) => ( + {chip.label} + ))}
- {renderAdvancedFilters({ showLabels: true })} -
+ ) : null} ); }; diff --git a/src/components/orders/OrderFilters.test.jsx b/src/components/orders/OrderFilters.test.jsx index 36e60bd..f600da1 100644 --- a/src/components/orders/OrderFilters.test.jsx +++ b/src/components/orders/OrderFilters.test.jsx @@ -4,44 +4,26 @@ import { describe, expect, it } from "vitest"; import { OrderFilters } from "./OrderFilters"; describe("OrderFilters", () => { - it("renders only the manager delivery filters", () => { + it("renders only the group delivery filters", () => { const markup = renderToStaticMarkup( {}} + statusOptions={[ + { value: "all", label: "Все статусы" }, + { value: "ready_for_notification", label: "Готовы к уведомлению" }, + ]} />, ); - expect(markup).toContain("Поиск по заявке, клиенту, телефону"); - expect(markup).not.toContain("Активные фильтры"); - expect(markup).not.toContain("Нет"); + expect(markup).toContain("Поиск по группе, клиенту или телефону"); expect(markup).toContain("Статус"); - expect(markup).toContain("Канал"); - expect(markup).toContain("aria-haspopup=\"listbox\""); - expect(markup).not.toContain(" { @@ -49,20 +31,17 @@ describe("OrderFilters", () => { {}} + statusOptions={[ + { value: "all", label: "Все статусы" }, + { value: "ready_for_notification", label: "Готовы к уведомлению" }, + ]} />, ); - expect(markup).toContain("Активные фильтры"); - expect(markup).toContain("Доставка согласована"); - expect(markup).toContain("СМС"); + expect(markup).toContain("Готовы к уведомлению"); + expect(markup).not.toContain("Активные фильтры"); }); }); diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index d110de2..befa022 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -1,110 +1,139 @@ -import React from "react"; -import { getStatusTone } from "../../constants/deliveryWorkflow"; -import { demoUsers } from "../../data/mockAppData"; import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; import { OrderFilters } from "./OrderFilters"; +import { getOrderGroupStatusLabel, getOrderGroupStatusTone } from "../../services/orderGroupViews"; -const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); -const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен"; -const buildOrderSummary = (order) => { - const leadItem = order.items?.[0] || "Состав не указан"; - const leadComment = order.orderNotes?.[0]?.text || order.comments?.[0] || "Без уточнений"; - return `${leadItem}. ${leadComment}`; +const buildGroupSummary = (group) => { + const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`; + const readyCountLabel = `${group.readyCount || 0} готовы`; + + return `${orderCountLabel} · ${readyCountLabel}`; }; -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 (
-

Реестр заказов

+

Группы доставки

- Поиск по номеру, клиенту и телефону. + Поиск по группе, клиенту, телефону и дате доставки.

- {orders.length} + {orderGroups.length}
- {filters && setFilters ? : null} + {filters && setFilters ? ( + + ) : null}
- {orders.map((order) => ( + {!orderGroups.length ? ( + + Группы не найдены. Попробуйте изменить поиск или статус. + + ) : null} + {orderGroups.map((group) => ( ))}
- + {!orderGroups.length ? ( +
+ Группы не найдены. Попробуйте изменить поиск или статус. +
+ ) : ( +
- + - + - + - {orders.map((order) => ( + {orderGroups.map((group) => ( onOpenOrder(order.id)} + onClick={() => onOpenOrder(group.id)} > - + ))} -
ЗаказГруппа КлиентКраткоНомера СтатусМенеджерГотовность Обновлён
-
{order.orderNumber}
-
- {order.customer.messenger} -
+
{group.displayTitle || group.customerName || group.groupKey}
+
{group.groupKey}
-
{order.customer.name}
-
{order.customer.phone}
+
{group.customerName}
+
+ {group.customerPhone} · {group.customerDate} +
- {buildOrderSummary(order)} + {renderOrderNumbers(group)} - {order.status} + {getOrderGroupStatusLabel(group.status)} {resolveUserName(users, order.managerId)} - {formatDateTime(order.updatedAt)} + {group.readyCount || 0}/{group.ordersCount || 0} + + {formatDateTime(group.updatedAt)}
+ + )}
); diff --git a/src/components/orders/OrdersTable.test.jsx b/src/components/orders/OrdersTable.test.jsx index 9dc4a72..8e24214 100644 --- a/src/components/orders/OrdersTable.test.jsx +++ b/src/components/orders/OrdersTable.test.jsx @@ -3,20 +3,20 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { OrdersTable } from "./OrdersTable"; -const orders = [ +const orderGroups = [ { id: "o-1", - orderNumber: "CD-240031", - customer: { - name: "Мария Волкова", - phone: "+7 978 000-12-31", - messenger: "СМС", - }, - items: ["Кухня | 1 шт"], - orderNotes: [{ text: "Подъезд узкий" }], - comments: ["Нужен созвон"], - status: "Ожидает согласования доставки", - managerId: "u-manager", + groupKey: "9780001231|16.04.26", + displayTitle: "Мария Волкова", + displaySubtitle: "+7 978 000-12-31 · 16.04.26", + customerName: "Мария Волкова", + customerPhone: "+7 978 000-12-31", + customerDate: "16.04.26", + orderNumbers: ["CD-240031"], + ordersCount: 1, + readyCount: 1, + notReadyCount: 0, + status: "ready_for_notification", updatedAt: "2026-03-15T08:00:00Z", }, ]; @@ -25,19 +25,22 @@ describe("OrdersTable", () => { it("renders desktop table and mobile card list", () => { const markup = renderToStaticMarkup( {}} - filters={{ search: "", status: "all", messenger: "all" }} + filters={{ query: "", status: "all" }} 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("md:hidden"); - expect(markup).toContain("Поиск по номеру, клиенту и телефону."); - expect(markup).toContain("Поиск по заявке, клиенту, телефону"); - expect(markup).toContain("CD-240031"); + expect(markup).toContain("Поиск по группе, клиенту, телефону"); + expect(markup).toContain("Группы доставки"); expect(markup).toContain("Мария Волкова"); }); }); diff --git a/src/data/mockAppData.js b/src/data/mockAppData.js index 052eb42..cbf3fa5 100644 --- a/src/data/mockAppData.js +++ b/src/data/mockAppData.js @@ -774,6 +774,143 @@ const extraDemoOrders = extraOrderSeeds.map(buildExtraDemoOrder); 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 = [ { id: "n-1", diff --git a/src/hooks/useOrderGroups.js b/src/hooks/useOrderGroups.js new file mode 100644 index 0000000..34d9b8f --- /dev/null +++ b/src/hooks/useOrderGroups.js @@ -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, + }; +}; diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index 2e6a15e..643e2c9 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -62,17 +62,19 @@ export const AppShell = ({
-
-
+
+

Рабочая область

-

{sectionMeta?.label || "Панель"}

-

+

+ {sectionMeta?.label || "Панель"} +

+

{user.name} · {ROLE_LABELS[user.role]}

-
+
{onOpenGuide ? (
{shouldShowMobileNav ? ( -
-
- {navItems.map((item) => ( - - ))} +
+
+ {navItems.map((item) => ( + + ))} +
-
) : null}
); diff --git a/src/layouts/AppShell.test.jsx b/src/layouts/AppShell.test.jsx index af87331..aff19b2 100644 --- a/src/layouts/AppShell.test.jsx +++ b/src/layouts/AppShell.test.jsx @@ -31,6 +31,7 @@ describe("AppShell", () => { expect(markup).toContain("xl:hidden"); expect(markup).toContain("fixed inset-x-0 bottom-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("aria-label=\"Справка\""); diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 36f9cc6..f0c0313 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -1,9 +1,6 @@ import React from "react"; 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 { DeliverySetDetailPanel } from "../components/logistics/DeliverySetDetailPanel"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { OrderDetailPanel } from "../components/orders/OrderDetailPanel"; import { OrdersTable } from "../components/orders/OrdersTable"; @@ -12,24 +9,24 @@ import { Modal } from "../components/UI/Modal"; import { Panel } from "../components/UI/Panel"; import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel"; import { useAuth } from "../context/AuthContext"; -import { useOrders } from "../hooks/useOrders"; +import { useOrderGroups } from "../hooks/useOrderGroups"; import { AppShell } from "../layouts/AppShell"; const ROLE_SECTION = { manager: { key: "orders", - label: "Заказы", - description: "Реестр заказов доставки, поиск и просмотр карточки.", + label: "Группы", + description: "Реестр групп доставки, поиск и просмотр карточки.", }, logistician: { key: "logistics", label: "Логистика", - description: "Готовые заказы на сегодня и ближайшие слоты доставки.", + description: "Группы доставки по готовности к уведомлению.", }, driver: { key: "deliveries", label: "Мои доставки", - description: "Список доставок, адреса и состав заказа.", + description: "Группы доставки по датам и статусам.", }, }; @@ -38,39 +35,29 @@ export const DashboardPage = () => { const userRole = user?.role; const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager; const [activeSection, setActiveSection] = React.useState(section.key); - const [isOrderModalOpen, setIsOrderModalOpen] = React.useState(false); - const [isDeliverySetModalOpen, setIsDeliverySetModalOpen] = React.useState(false); - const [selectedDeliverySet, setSelectedDeliverySet] = React.useState(null); + const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false); const { - orders, - allOrders, - selectedOrder, - selectedOrderId, - setSelectedOrderId, + orderGroups, + allOrderGroups, + filteredOrderGroups, + selectedOrderGroup, + selectedOrderGroupId, + setSelectedOrderGroupId, filters, setFilters, - updateStatus, - assignDriver, - users, + statusOptions, isLoading, loadError, - deliverySetBuckets, - } = useOrders(user); + } = useOrderGroups(); React.useEffect(() => { setActiveSection(section.key); }, [section.key]); - React.useEffect(() => { - if (!selectedOrderId && allOrders[0]?.id) { - setSelectedOrderId(allOrders[0].id); - } - }, [allOrders, selectedOrderId, setSelectedOrderId]); - - const openDeliverySetModal = React.useCallback((deliverySet) => { - setSelectedDeliverySet(deliverySet); - setIsDeliverySetModalOpen(true); + const openGroupModal = React.useCallback((groupId) => { + setSelectedOrderGroupId(groupId); + setIsGroupModalOpen(true); }, []); const navItems = [ @@ -78,7 +65,7 @@ export const DashboardPage = () => { key: section.key, label: section.label, description: section.description, - badge: String(allOrders.length || orders.length || 0), + badge: String(allOrderGroups.length || orderGroups.length || 0), }, ]; const guideSectionMeta = { @@ -89,15 +76,6 @@ export const DashboardPage = () => { const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0]; 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) { return ; } @@ -105,28 +83,27 @@ export const DashboardPage = () => { const renderManagerWorkspace = () => (
); const renderLogisticsWorkspace = () => (
- +
); const renderDriverWorkspace = () => (
updateStatus(orderId, nextStatus, user.name)} + orderGroups={allOrderGroups} + onOpenOrder={openGroupModal} />
); @@ -171,85 +148,23 @@ export const DashboardPage = () => { {renderActiveSection()} - setIsOrderModalOpen(false)}> - {user.role === "driver" ? ( -
-
-
-

Карточка доставки

-

- Адрес, клиент, состав заказа и базовые действия по маршруту. -

-
- -
- - selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name) - } - /> -
- ) : ( -
-
-
-

Карточка заказа

-

- Основные данные заказа, клиента и доставки. -

-
- -
- - selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name) - } - onAssignDriver={assignDriver} - /> -
- )} -
- - { - setIsDeliverySetModalOpen(false); - setSelectedDeliverySet(null); - }} - > + setIsGroupModalOpen(false)}>
-

Карточка набора доставки

-

- Все связанные заказы, их производственные шаги и статус согласования. -

+

Карточка группы доставки

+

Все данные из таблицы `order_groups`.

- { - setIsDeliverySetModalOpen(false); - setSelectedDeliverySet(null); - }} - /> +
diff --git a/src/pages/DashboardPage.test.jsx b/src/pages/DashboardPage.test.jsx index 3e5eaa3..6d35b6f 100644 --- a/src/pages/DashboardPage.test.jsx +++ b/src/pages/DashboardPage.test.jsx @@ -4,17 +4,17 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DashboardPage } from "./DashboardPage"; -const { useAuthMock, useOrdersMock } = vi.hoisted(() => ({ +const { useAuthMock, useOrderGroupsMock } = vi.hoisted(() => ({ useAuthMock: vi.fn(), - useOrdersMock: vi.fn(), + useOrderGroupsMock: vi.fn(), })); vi.mock("../context/AuthContext", () => ({ useAuth: useAuthMock, })); -vi.mock("../hooks/useOrders", () => ({ - useOrders: useOrdersMock, +vi.mock("../hooks/useOrderGroups", () => ({ + useOrderGroups: useOrderGroupsMock, })); vi.mock("../layouts/AppShell", () => ({ @@ -29,90 +29,44 @@ vi.mock("../layouts/AppShell", () => ({ ), })); -const baseOrder = { - id: "order-1", - orderNumber: "CD-240031", - status: "Ожидает согласования доставки", +const baseGroup = { + id: "group-1", + groupKey: "9780001231|16.04.26", + 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", - 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 = { - key: "set-1", - name: "Набор Марии Волковой", - status: "ready_to_launch", - readyAt: "2026-04-15T08:00:00Z", - readyReason: "all_accepted", - sourceCustomerCity: "Симферополь", - orderCount: 1, - linkedBillTexts: "УН-00031", - orders: [baseOrder], -}; - -const mockOrdersState = { - orders: [baseOrder], - allOrders: [baseOrder], - selectedOrder: baseOrder, - selectedOrderId: baseOrder.id, - setSelectedOrderId: vi.fn(), +const mockOrderGroupsState = { + orderGroups: [baseGroup], + allOrderGroups: [baseGroup], + filteredOrderGroups: [baseGroup], + visibleOrderGroups: [baseGroup], + selectedOrderGroup: baseGroup, + selectedOrderGroupId: baseGroup.id, + setSelectedOrderGroupId: vi.fn(), filters: { query: "", status: "all", - stage: "all", - ownerRole: "all", - agingState: "all", - managerId: "all", - logisticianId: "all", - messenger: "all", }, 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: { - approaching: [], - ready_to_launch: [baseDeliverySet], - awaiting_client: [], + ready_to_launch: [baseGroup], + sms_sent: [], manual_work: [], - agreed: [], - completed: [], }, - users: [ - { id: "u-manager", name: "Анна", role: "manager" }, - { id: "u-logistics", name: "Ольга", role: "logistician" }, - { id: "u-driver", name: "Иван", role: "driver" }, + statusOptions: [ + { value: "all", label: "Все статусы" }, + { value: "ready_for_notification", label: "ready_for_notification" }, ], - isSupabaseBacked: true, isLoading: false, loadError: "", }; @@ -121,10 +75,10 @@ describe("DashboardPage", () => { beforeEach(() => { vi.useFakeTimers(); 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({ user: { id: "u-manager", name: "Анна", role: "manager" }, signOut: vi.fn(), @@ -136,8 +90,8 @@ describe("DashboardPage", () => { , ); - expect(markup).toContain("Реестр заказов"); - expect(markup).toContain("Поиск по номеру, клиенту и телефону."); + expect(markup).toContain("Группы доставки"); + expect(markup).toContain("Поиск по группе, клиенту, телефону и дате доставки."); expect(markup).toContain("aria-label=\"Справка\""); 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).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", () => { - useOrdersMock.mockReturnValue({ - ...mockOrdersState, - orders: [ - { - ...baseOrder, - id: "driver-order-1", - status: "Назначен водитель", - scheduledDelivery: "2026-04-15T12:00:00Z", - 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", + useOrderGroupsMock.mockReturnValue({ + ...mockOrderGroupsState, + orderGroups: [baseGroup], + allOrderGroups: [baseGroup], + filteredOrderGroups: [baseGroup], + visibleOrderGroups: [baseGroup], + selectedOrderGroup: baseGroup, + selectedOrderGroupId: baseGroup.id, }); useAuthMock.mockReturnValue({ user: { id: "u-driver", name: "Иван", role: "driver" }, @@ -220,8 +154,8 @@ describe("DashboardPage", () => { ); expect(markup).toContain("Мои доставки"); - expect(markup).toContain("CD-240031"); expect(markup).toContain("Мария Волкова"); + expect(markup).toContain("CD-240031"); expect(markup).not.toContain("Канбан"); expect(markup).not.toContain("Календарь"); expect(markup).not.toContain("История"); diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js new file mode 100644 index 0000000..0a345ec --- /dev/null +++ b/src/services/orderGroupViews.js @@ -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"; +}; diff --git a/src/services/orderGroupViews.test.js b/src/services/orderGroupViews.test.js new file mode 100644 index 0000000..91dd338 --- /dev/null +++ b/src/services/orderGroupViews.test.js @@ -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"); + }); +}); diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js new file mode 100644 index 0000000..c7dae16 --- /dev/null +++ b/src/services/supabase/orderGroupRepository.js @@ -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); + }, "Ошибка загрузки групп доставки"); +}; diff --git a/src/services/supabase/orderGroupRepository.test.js b/src/services/supabase/orderGroupRepository.test.js new file mode 100644 index 0000000..f5ade96 --- /dev/null +++ b/src/services/supabase/orderGroupRepository.test.js @@ -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"); + }); +});