diff --git a/docs/sql/add-source-orders-to-order-items.sql b/docs/sql/add-source-orders-to-order-items.sql index 976d0f7..21fdcbb 100644 --- a/docs/sql/add-source-orders-to-order-items.sql +++ b/docs/sql/add-source-orders-to-order-items.sql @@ -1,9 +1,5 @@ --- Migration: add source_orders items to get_delivery_invitation_by_token --- This replaces ONLY the orderItems building section for the group path. --- Apply AFTER the base function is restored. - --- Step 1: First restore the original function (run restore-rpc-original.sql if needed) --- Step 2: Then run this migration +-- Migration: flatten ALL products from source_orders into simple orderItems list +-- This replaces the previous nested orderItems building section. CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text) RETURNS jsonb @@ -21,7 +17,6 @@ DECLARE v_customer_name text; v_customer_phone text; v_order_items jsonb; - v_order_numbers jsonb; v_now timestamptz := timezone('utc', now()); BEGIN IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN @@ -90,20 +85,67 @@ BEGIN NULLIF(v_invitation.customer_phone, '') ); - -- Build orderItems: use source_orders for real product lines if available, - -- otherwise fall back to invoice numbers from order_numbers. + -- Build orderItems: flatten ALL products from source_orders into a flat list. + -- Strategy 1: products inside orderList[].items[] + -- Strategy 2: products inside items[] directly on source_orders entry + -- Strategy 3: fallback to invoice names from order_numbers v_order_items := CASE WHEN v_group.source_orders IS NOT NULL AND jsonb_typeof(v_group.source_orders) = 'array' AND jsonb_array_length(v_group.source_orders) > 0 THEN COALESCE( + -- Strategy 1: flatten products from orderList[].items[] (SELECT jsonb_agg( jsonb_build_object( - 'name', COALESCE(src ->> 'nom', src ->> 'name', ''), - 'quantity', '', - 'items', COALESCE(src -> 'orderList', src -> 'items', '[]'::jsonb) + 'name', p ->> 'product_name', + 'quantity', COALESCE(NULLIF(p ->> 'product_quantity', ''), ''), + 'unit', COALESCE(NULLIF(p ->> 'product_ed', ''), '') ) - ) FROM jsonb_array_elements(v_group.source_orders) AS src), + ) + FROM jsonb_array_elements(v_group.source_orders) AS src, + LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(src -> 'orderList') = 'array' THEN src -> 'orderList' + ELSE '[]'::jsonb + END + ) AS so, + LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(so -> 'items') = 'array' THEN so -> 'items' + ELSE '[]'::jsonb + END + ) AS p + WHERE p ->> 'product_name' IS NOT NULL + AND p ->> 'product_name' != ''), + + -- Strategy 2: products inside items[] directly on source_orders entry + (SELECT jsonb_agg( + jsonb_build_object( + 'name', p ->> 'product_name', + 'quantity', COALESCE(NULLIF(p ->> 'product_quantity', ''), ''), + 'unit', COALESCE(NULLIF(p ->> 'product_ed', ''), '') + ) + ) + FROM jsonb_array_elements(v_group.source_orders) AS src, + LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(src -> 'items') = 'array' THEN src -> 'items' + ELSE '[]'::jsonb + END + ) AS p + WHERE p ->> 'product_name' IS NOT NULL + AND p ->> 'product_name' != '' + AND NOT (p ? 'nom' AND p ? 'items')), -- exclude sub-order entries + + -- Strategy 3: fallback to invoice names + (SELECT jsonb_agg(jsonb_build_object('name', on_num, 'quantity', '')) + FROM jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' THEN to_jsonb(v_group.order_numbers) + ELSE '[]'::jsonb + END + ) AS on_num), + '[]'::jsonb ) ELSE COALESCE( diff --git a/src/components/client/DeliverySlotsPicker.jsx b/src/components/client/DeliverySlotsPicker.jsx index 8159faf..4d4af7d 100644 --- a/src/components/client/DeliverySlotsPicker.jsx +++ b/src/components/client/DeliverySlotsPicker.jsx @@ -1,7 +1,7 @@ import React from "react"; import { Button } from "../UI/Button"; import { Panel } from "../UI/Panel"; -import { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting"; +import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting"; const groupSlotsByDate = (slots) => { const groups = new Map(); @@ -36,7 +36,19 @@ const groupSlotsByDate = (slots) => { .sort(([a], [b]) => a.localeCompare(b)); }; -export { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting"; +const getDeliverySlotGroupHeading = (dateStr, referenceDate = new Date()) => { + const relative = getDeliveryRelativeDayLabel(dateStr, referenceDate); + const formatted = formatDeliveryDate(dateStr); + + if (relative) { + return `Доставка ${relative.charAt(0).toLowerCase()}${relative.slice(1)} · ${formatted}`; + } + + return `Доставка ${formatted}`; +}; + +export { formatDeliveryDate, formatDeliverySlotGroupLabel } from "./deliveryDateFormatting"; +export { getDeliverySlotGroupHeading }; export const DeliverySlotsPicker = ({ slots, @@ -60,10 +72,7 @@ export const DeliverySlotsPicker = ({
-
-

Доставка на день

-

{formatDeliverySlotGroupLabel(date, referenceDate)}

-
+

{getDeliverySlotGroupHeading(date, referenceDate)}

Раскрыть Свернуть
diff --git a/src/components/client/OrderCompositionPanel.jsx b/src/components/client/OrderCompositionPanel.jsx index a25c723..92f7d97 100644 --- a/src/components/client/OrderCompositionPanel.jsx +++ b/src/components/client/OrderCompositionPanel.jsx @@ -4,44 +4,57 @@ import { Panel } from "../UI/Panel"; import { getInvitationReferenceLabel } from "./invitationReference"; const flattenOrderProducts = (rawItems) => { + if (!Array.isArray(rawItems) || rawItems.length === 0) return []; + const products = []; for (const item of rawItems) { if (!item || typeof item !== "object") continue; - const subOrders = Array.isArray(item.items) ? item.items : []; + const subItems = Array.isArray(item.items) ? item.items : []; - if (subOrders.length === 0) { - const name = String(item.product_name || item.name || item.nom || "").trim(); - const qty = String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(); - const unit = String(item.product_ed || item.unit || "").trim(); - if (name) products.push({ name, quantity: qty, unit }); + if (subItems.length > 0) { + const hasSubOrders = subItems.some( + (s) => typeof s === "object" && ("nom" in s || ("items" in s && Array.isArray(s.items))), + ); + + if (hasSubOrders) { + for (const sub of subItems) { + if (!sub || typeof sub !== "object") continue; + const productsList = Array.isArray(sub.items) ? sub.items : []; + for (const p of productsList) { + if (!p || typeof p !== "object") continue; + const pName = String(p.product_name || p.name || "").trim(); + if (!pName) continue; + products.push({ + name: pName, + quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), + unit: String(p.product_ed || p.unit || "").trim(), + }); + } + } + } else { + for (const p of subItems) { + if (!p || typeof p !== "object") continue; + const pName = String(p.product_name || p.name || "").trim(); + if (!pName) continue; + products.push({ + name: pName, + quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), + unit: String(p.product_ed || p.unit || "").trim(), + }); + } + } continue; } - const hasSubOrders = subOrders.some((s) => typeof s === "object" && ("nom" in s || ("items" in s && Array.isArray(s.items)))); - - if (hasSubOrders) { - for (const sub of subOrders) { - if (!sub || typeof sub !== "object") continue; - const productsList = Array.isArray(sub.items) ? sub.items : []; - for (const p of productsList) { - if (!p || typeof p !== "object") continue; - const pName = String(p.product_name || p.name || "").trim(); - const pQty = String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(); - const pUnit = String(p.product_ed || p.unit || "").trim(); - if (pName) products.push({ name: pName, quantity: pQty, unit: pUnit }); - } - } - } else { - for (const p of subOrders) { - if (!p || typeof p !== "object") continue; - const pName = String(p.product_name || p.name || "").trim(); - const pQty = String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(); - const pUnit = String(p.product_ed || p.unit || "").trim(); - if (pName) products.push({ name: pName, quantity: pQty, unit: pUnit }); - } - } + const name = String(item.product_name || item.name || item.nom || "").trim(); + if (!name) continue; + products.push({ + name, + quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(), + unit: String(item.product_ed || item.unit || "").trim(), + }); } return products; @@ -64,10 +77,10 @@ export const OrderCompositionPanel = ({ invitation = {} }) => { onClick={() => setIsExpanded(!isExpanded)} >

- Состав заказа {reference !== "Счет —" ? reference : ""} + Состав заказа{reference && reference !== "Счет —" ? ` ${reference}` : ""}

- {products.length > 0 ? `${products.length} поз.` : ""} + {products.length} поз. { + if (!address || typeof address !== "string") return null; + const trimmed = address.trim(); + if (!trimmed) return null; + + // Patterns: "г.Ялта", "г. Ялта", "г Ялта", "г Ялта ", "Ялта,", "г.Севастополь", etc. + const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i); + if (cityMatch) { + return cityMatch[1].trim(); + } + + // Try common city names directly in the address + const knownCities = [ + "Севастополь", "Ялта", "Симферополь", "Феодосия", "Евпатория", + "Керчь", "Алушта", "Бахчисарай", "Судак", "Инкерман", + "Джанкой", "Красногвардейское", "Раздольное", "Черноморское", + ]; + for (const city of knownCities) { + if (trimmed.toLowerCase().includes(city.toLowerCase())) { + return city; + } + } + + // Fallback: first comma-separated segment if it looks like a city + const firstSegment = trimmed.split(/[,;]/)[0].trim(); + if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) { + return firstSegment; + } + + return null; +}; + +const normalizeCity = (address) => { + const city = extractCity(address); + return city || "Севастополь"; +}; + const DRIVER_DELIVERY_STATUS_OPTIONS = [ { value: "all", label: "Все статусы" }, ...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({ @@ -25,6 +62,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs const [filters, setFilters] = React.useState({ selectedDate: "", deliveryStatus: "all", + selectedCity: "", }); const driverOrderGroups = React.useMemo( @@ -36,7 +74,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs [orderGroups, currentUser], ); - // Build map of date -> count for quick lookup + // Build map of date -> count const dateDeliveryMap = React.useMemo(() => { const map = new Map(); driverOrderGroups.forEach((group) => { @@ -52,6 +90,25 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs return Array.from(dateDeliveryMap.keys()).sort(); }, [dateDeliveryMap]); + // Build map of city -> count + const cityDeliveryMap = React.useMemo(() => { + const map = new Map(); + driverOrderGroups.forEach((group) => { + const city = normalizeCity(group.deliveryAddress || group.delivery_address); + map.set(city, (map.get(city) || 0) + 1); + }); + return map; + }, [driverOrderGroups]); + + const sortedCities = React.useMemo(() => { + return Array.from(cityDeliveryMap.keys()).sort((a, b) => { + // Севастополь first, then alphabetical + if (a === "Севастополь") return -1; + if (b === "Севастополь") return 1; + return a.localeCompare(b, "ru"); + }); + }, [cityDeliveryMap]); + const filteredOrderGroups = React.useMemo(() => { let result = [...driverOrderGroups]; if (filters.selectedDate) { @@ -60,8 +117,14 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs if (filters.deliveryStatus !== "all") { result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus); } + if (filters.selectedCity) { + result = result.filter((group) => { + const city = normalizeCity(group.deliveryAddress || group.delivery_address); + return city === filters.selectedCity; + }); + } return result; - }, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus]); + }, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]); const groupedOrderGroups = React.useMemo( () => groupOrderGroupsByDate(filteredOrderGroups), @@ -85,7 +148,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs {deliveryCountLabel}

- Показываем только согласованные к доставке группы. Выберите дату ниже. + Показываем только назначенные вам группы доставки. Выберите дату и город.

@@ -120,7 +183,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs - {/* Date pills showing days with deliveries */} + {/* Date pills */} {sortedDeliveryDates.length > 0 && (
+ {sortedCities.map((city) => { + const count = cityDeliveryMap.get(city) || 0; + const selected = filters.selectedCity === city; + return ( + + ); + })} +
+ )} {groupedOrderGroups.length ? ( - groupedOrderGroups.map((group) => ( - -
-
-

- {parseGroupDate(group.date)?.toLocaleDateString("ru-RU", { - day: "numeric", - month: "long", - weekday: "long", - }) || "Без даты"} -

-

- {group.items.length} {group.items.length === 1 ? "группа" : "группы"} -

+ groupedOrderGroups.map((group) => { + // Group items by delivery status within each date + const statusBuckets = new Map(); + for (const item of group.items) { + const s = item.deliveryStatus || item.delivery_status || "unknown"; + const label = s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s); + const tone = getOrderGroupDeliveryStatusTone(s); + if (!statusBuckets.has(s)) { + statusBuckets.set(s, { label, tone, items: [] }); + } + statusBuckets.get(s).items.push(item); + } + + // Sort status buckets in driver-relevant order + const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"]; + const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => { + const ia = statusOrder.indexOf(a); + const ib = statusOrder.indexOf(b); + if (ia === -1 && ib === -1) return a.localeCompare(b); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + + return ( + +
+
+

+ {parseGroupDate(group.date)?.toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + weekday: "long", + }) || "Без даты"} +

+

+ {group.items.length} {group.items.length === 1 ? "группа" : "группы"} +

+
+ + {(() => { + const d = parseGroupDate(group.date); + if (!d) return group.date || "—"; + const day = String(d.getDate()).padStart(2, "0"); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const year = d.getFullYear(); + return `${day}.${month}.${year}`; + })()} +
- - {(() => { - const d = parseGroupDate(group.date); - if (!d) return group.date || "—"; - const day = String(d.getDate()).padStart(2, "0"); - const month = String(d.getMonth() + 1).padStart(2, "0"); - const year = d.getFullYear(); - return `${day}.${month}.${year}`; - })()} - -
-
- {group.items.map((item) => ( - + ))}
- +
))} - -
- )) + + ); + }) ) : (

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

diff --git a/src/components/driver/DriverShipmentPanel.jsx b/src/components/driver/DriverShipmentPanel.jsx new file mode 100644 index 0000000..598b2d6 --- /dev/null +++ b/src/components/driver/DriverShipmentPanel.jsx @@ -0,0 +1,246 @@ +import React from "react"; +import { Badge } from "../UI/Badge"; +import { Button } from "../UI/Button"; +import { Panel } from "../UI/Panel"; + +const parseOrderItems = (order) => { + if (!order) return []; + + const sourceOrders = order.sourceOrders || order.source_orders; + if (Array.isArray(sourceOrders) && sourceOrders.length > 0) { + const products = []; + for (const src of sourceOrders) { + if (!src || typeof src !== "object") continue; + const orderList = Array.isArray(src.orderList) ? src.orderList : Array.isArray(src.items) ? src.items : []; + for (const sub of orderList) { + if (!sub || typeof sub !== "object") continue; + const subItems = Array.isArray(sub.items) ? sub.items : []; + const hasProducts = subItems.some( + (p) => typeof p === "object" && (p.product_name || p.name) + ); + if (hasProducts) { + for (const p of subItems) { + if (!p || typeof p !== "object") continue; + const name = String(p.product_name || p.name || "").trim(); + if (!name) continue; + products.push({ + id: `${src.nom || ""}-${sub.nom || ""}-${name}`, + name, + quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), + unit: String(p.product_ed || p.unit || "").trim(), + }); + } + } else if (sub.nom || sub.name) { + products.push({ + id: `${src.nom || ""}-${sub.nom || sub.name || ""}`, + name: String(sub.nom || sub.name || "").trim(), + quantity: "", + unit: "", + }); + } + } + + if (orderList.length === 0) { + const directItems = Array.isArray(src.items) ? src.items : []; + const hasDirect = directItems.some( + (p) => typeof p === "object" && (p.product_name || p.name) && !p.nom + ); + if (hasDirect) { + for (const p of directItems) { + if (!p || typeof p !== "object") continue; + const name = String(p.product_name || p.name || "").trim(); + if (!name || ("nom" in p && "items" in p)) continue; + products.push({ + id: `${src.nom || ""}-${name}`, + name, + quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), + unit: String(p.product_ed || p.unit || "").trim(), + }); + } + } + } + } + if (products.length > 0) return products; + } + + const orderList = order.orderList || order.order_list; + if (Array.isArray(orderList)) { + const products = []; + for (const sub of orderList) { + if (!sub || typeof sub !== "object") continue; + const items = Array.isArray(sub.items) ? sub.items : []; + for (const p of items) { + if (!p || typeof p !== "object") continue; + const name = String(p.product_name || p.name || "").trim(); + if (!name) continue; + products.push({ + id: `${sub.nom || sub.name || ""}-${name}`, + name, + quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), + unit: String(p.product_ed || p.unit || "").trim(), + }); + } + } + if (products.length > 0) return products; + } + + return []; +}; + +export const DriverShipmentPanel = ({ order, onShipmentChange }) => { + const items = React.useMemo(() => parseOrderItems(order), [order]); + const [shippedItems, setShippedItems] = React.useState(new Set()); + const [comments, setComments] = React.useState({}); + const [commentInput, setCommentInput] = React.useState(""); + + const toggleItem = (itemId) => { + setShippedItems((prev) => { + const next = new Set(prev); + if (next.has(itemId)) { + next.delete(itemId); + } else { + next.add(itemId); + } + return next; + }); + }; + + const shipAll = () => { + setShippedItems(new Set(items.map((i) => i.id))); + setComments({}); + }; + + const unshipAll = () => { + setShippedItems(new Set()); + setComments({}); + }; + + const shippedCount = items.filter((i) => shippedItems.has(i.id)).length; + const unshippedCount = items.length - shippedCount; + const allShipped = items.length > 0 && shippedCount === items.length; + const unshippedWithComment = items.filter( + (i) => !shippedItems.has(i.id) && comments[i.id]?.trim(), + ).length; + const unshippedWithoutComment = unshippedCount - unshippedWithComment; + + // Notify parent of shipment state + React.useEffect(() => { + if (onShipmentChange) { + onShipmentChange({ + total: items.length, + shipped: shippedCount, + unshipped: unshippedCount, + unshippedWithoutComment, + allShipped, + canMarkDelivered: allShipped || unshippedWithoutComment === 0, + shipmentData: items.map((item) => ({ + id: item.id, + name: item.name, + quantity: item.quantity, + unit: item.unit, + shipped: shippedItems.has(item.id), + comment: shippedItems.has(item.id) ? "" : (comments[item.id] || ""), + })), + }); + } + }, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]); + + if (items.length === 0) { + return ( + + Состав заказа +

Позиции не указаны

+
+ ); + } + + return ( + +
+
+ Отгрузка +

+ Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены. +

+
+
+ + {shippedCount}/{items.length} отгружено + +
+
+ +
+ + +
+ +
+ {items.map((item) => { + const isShipped = shippedItems.has(item.id); + const hasComment = !isShipped && comments[item.id]?.trim(); + return ( +
+ +
+ ); + })} +
+ + {unshippedCount > 0 && ( +
+

+ Не отгружено: {unshippedCount} {unshippedCount === 1 ? "позиция" : unshippedCount < 5 ? "позиции" : "позиций"} +

+ {unshippedWithoutComment > 0 && ( +

+ Укажите причину для каждой неотгруженной позиции, чтобы завершить доставку. +

+ )} +
+ )} +
+ ); +}; diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index d0298ac..5c10d37 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -4,6 +4,7 @@ import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; import { Select } from "../UI/Select"; import { Panel } from "../UI/Panel"; +import { DriverShipmentPanel } from "../driver/DriverShipmentPanel"; import { getOrderGroupDeliveryStatusLabel, getOrderGroupDisplayStatusLabel, @@ -397,6 +398,7 @@ export const OrderDetailPanel = ({ const [deliveryDate, setDeliveryDate] = React.useState(""); const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]); const [formMessage, setFormMessage] = React.useState(""); + const [shipmentState, setShipmentState] = React.useState(null); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [driverMessage, setDriverMessage] = React.useState(""); const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || ""); @@ -450,6 +452,10 @@ export const OrderDetailPanel = ({ order.deliveryTime || order.deliveryHalfDay, ].filter((value) => value && value !== "Нет данных").join(" · "); + const handleShipmentChange = React.useCallback((state) => { + setShipmentState(state); + }, []); + const handleSaveDeliveryChoice = async () => { if (!deliveryDate || !deliveryTime) { setFormMessage("Укажите дату и половину дня доставки."); @@ -485,6 +491,11 @@ export const OrderDetailPanel = ({ return; } + if (!order.deliveryDate) { + setDriverMessage("Сначала укажите дату и время доставки."); + return; + } + setDriverMessage(""); const response = await onAssignDriver({ orderGroupId: order.id, @@ -590,96 +601,6 @@ export const OrderDetailPanel = ({
- {canManageDelivery && ["manager", "logistician", "admin"].includes(userRole) ? ( - -
- Назначение водителя -

- {order.assignedDriverId - ? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.` - : "Выберите водителя для доставки."} -

-
-
- - -
- {driverMessage ? ( -

{driverMessage}

- ) : null} -
- ) : null} - - {userRole === "driver" && order && onChangeDeliveryStatus ? ( - -
- Статус доставки -

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

-
-
- {[ - { value: "loaded", label: "Загружено" }, - { value: "on_route", label: "В пути" }, - { value: "delivered", label: "Доставлено" }, - { value: "problem", label: "Проблема" }, - { value: "cancelled", label: "Отменено" }, - ].map((statusOption) => ( - - ))} -
-
- ) : null} - - - {["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? ( - - ) : null} - {canManageDelivery ? (
@@ -849,6 +770,152 @@ export const OrderDetailPanel = ({ ) : null} + + {canManageDelivery && ["manager", "logistician", "admin"].includes(userRole) ? ( + +
+ Назначение водителя +

+ {order.assignedDriverId + ? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.` + : "Выберите водителя для доставки."} +

+
+
+ + +
+ {driverMessage ? ( +

{driverMessage}

+ ) : null} +
+ ) : null} + + + {["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? ( + +
+ Статус доставки +

+ Измените статус, если водитель забыл обновить или нужна корректировка. +

+
+
+ {[ + { value: "pending_confirmation", label: "Ожидает согласования" }, + { value: "agreed", label: "Согласовано" }, + { value: "driver_assigned", label: "Назначен водитель" }, + { value: "loaded", label: "Загружено" }, + { value: "on_route", label: "В пути" }, + { value: "delivered", label: "Доставлено" }, + { value: "problem", label: "Проблема" }, + { value: "cancelled", label: "Отменено" }, + ].map((statusOption) => ( + + ))} +
+
+ ) : null} + + + {["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? ( + + ) : null} + + {userRole === "driver" && order ? ( + + ) : null} + + {userRole === "driver" && order && onChangeDeliveryStatus ? ( + +
+ Статус доставки +

+ Обновите статус по мере выполнения доставки. +

+
+
+ {[ + { value: "loaded", label: "Загружено" }, + { value: "on_route", label: "В пути" }, + { value: "delivered", label: "Доставлено" }, + { value: "problem", label: "Проблема" }, + ].map((statusOption) => ( + + ))} +
+ {formMessage ? ( +

{formMessage}

+ ) : null} +
+ ) : null} + Номера заказов {renderList(order.orderNumbers)} diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 3797561..3a561d4 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -1,15 +1,11 @@ import React from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; -import { OrderDetailPanel } from "../components/orders/OrderDetailPanel"; import { OrdersTable } from "../components/orders/OrdersTable"; -import { Button } from "../components/UI/Button"; -import { Modal } from "../components/UI/Modal"; import { Panel } from "../components/UI/Panel"; import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel"; import { useAuth } from "../context/AuthContext"; -import { fetchDrivers } from "../services/supabase/userRepository"; import { useOrderGroups } from "../hooks/useOrderGroups"; import { AppShell } from "../layouts/AppShell"; @@ -33,17 +29,15 @@ const ROLE_SECTION = { export const DashboardPage = () => { const { user, signOut } = useAuth(); + const navigate = useNavigate(); const userRole = user?.role; const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager; const [activeSection, setActiveSection] = React.useState(section.key); - const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false); - const [drivers, setDrivers] = React.useState([]); const { orderGroups, allOrderGroups, filteredOrderGroups, - selectedOrderGroup, selectedOrderGroupId, setSelectedOrderGroupId, filters, @@ -51,33 +45,15 @@ export const DashboardPage = () => { statusOptions, isLoading, loadError, - saveManualDeliveryChoice, - isSavingDeliveryChoice, - assignDriver, - changeDeliveryStatus, } = useOrderGroups(); React.useEffect(() => { setActiveSection(section.key); }, [section.key]); - React.useEffect(() => { - let cancelled = false; - const loadDrivers = async () => { - const result = await fetchDrivers(); - if (cancelled) return; - if (result.data) { - setDrivers(result.data.filter((u) => u.role === "driver")); - } - }; - loadDrivers(); - return () => { cancelled = true; }; - }, []); - - const openGroupModal = React.useCallback((groupId) => { - setSelectedOrderGroupId(groupId); - setIsGroupModalOpen(true); - }, []); + const openGroupPage = React.useCallback((groupId) => { + navigate(`/dashboard/group/${groupId}`); + }, [navigate]); const navItems = [ { @@ -104,7 +80,7 @@ export const DashboardPage = () => { { const renderLogisticsWorkspace = () => (
- +
); @@ -122,7 +98,7 @@ export const DashboardPage = () => {
@@ -167,34 +143,6 @@ export const DashboardPage = () => { ) : null} {renderActiveSection()} - - setIsGroupModalOpen(false)} className="md:max-w-[800px]"> -
-
-
-

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

-
- -
- -
-
); }; diff --git a/src/pages/GroupDetailPage.jsx b/src/pages/GroupDetailPage.jsx new file mode 100644 index 0000000..fc04306 --- /dev/null +++ b/src/pages/GroupDetailPage.jsx @@ -0,0 +1,77 @@ +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { OrderDetailPanel } from "../components/orders/OrderDetailPanel"; +import { Button } from "../components/UI/Button"; +import { Panel } from "../components/UI/Panel"; +import { useAuth } from "../context/AuthContext"; +import { fetchDrivers } from "../services/supabase/userRepository"; +import { useOrderGroups } from "../hooks/useOrderGroups"; + +export const GroupDetailPage = () => { + const { groupId } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const userRole = user?.role; + + const { + allOrderGroups, + selectedOrderGroupId, + setSelectedOrderGroupId, + saveManualDeliveryChoice, + isSavingDeliveryChoice, + assignDriver, + changeDeliveryStatus, + } = useOrderGroups(); + + const [drivers, setDrivers] = React.useState([]); + + React.useEffect(() => { + if (groupId) { + setSelectedOrderGroupId(groupId); + } + }, [groupId, setSelectedOrderGroupId]); + + React.useEffect(() => { + let cancelled = false; + const load = async () => { + const result = await fetchDrivers(); + if (cancelled) return; + if (result.data) { + setDrivers(result.data.filter((u) => u.role === "driver")); + } + }; + load(); + return () => { cancelled = true; }; + }, []); + + const order = allOrderGroups.find((g) => g.id === groupId) || + allOrderGroups.find((g) => g.id === selectedOrderGroupId) || + null; + + return ( +
+
+ +
+ + {order ? ( + + ) : ( + + Группа доставки не найдена. + + )} +
+ ); +}; diff --git a/src/router.jsx b/src/router.jsx index 6a9860e..26c86b7 100644 --- a/src/router.jsx +++ b/src/router.jsx @@ -3,6 +3,7 @@ import { Navigate, createBrowserRouter } from "react-router-dom"; import App from "./App"; import { ClientDeliveryPage } from "./pages/ClientDeliveryPage"; import { DashboardPage } from "./pages/DashboardPage"; +import { GroupDetailPage } from "./pages/GroupDetailPage"; import { LoginPage } from "./pages/LoginPage"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -27,6 +28,10 @@ export const router = createBrowserRouter([ path: "dashboard", element: , }, + { + path: "dashboard/group/:groupId", + element: , + }, { path: "*", element: , diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index d3b0d1d..b0b8e05 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -26,7 +26,6 @@ export const NOTIFICATION_STATUS_LABELS = { }; export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ - "agreed", "driver_assigned", "loaded", "on_route", @@ -34,7 +33,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ "delivered", ]; -export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["agreed", "driver_assigned", "loaded", "on_route", "problem"]; +export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"]; const HALF_DAY_LABELS = { morning: "Первая половина дня",