feat: delivery page improvements and driver shipment workflow

- OrderCompositionPanel: flatten nested source_orders into product list, collapsed by default
- DeliverySlotsPicker: single-line heading 'Доставка завтра · 22.05.2026' instead of two-line
- OrderDetailPanel: add logistics status change panel, reorder sections (delivery date → driver → status → paid storage)
- OrderDetailPanel: require delivery date before assigning driver
- GroupDetailPage: dedicated route /dashboard/group/:groupId instead of modal
- DashboardPage: navigate to group detail page instead of opening modal
- DriverDeliveryPlanner: remove 'agreed' status, add city filter pills, group by status within dates
- DriverDeliveryPlanner: show delivery address instead of order numbers/ready count/SMS
- DriverShipmentPanel: new component with per-item checkboxes, ship-all, comments for unshipped items
- OrderDetailPanel: driver sees shipment panel + status buttons; 'delivered' requires all items shipped or commented
- Driver status: start from 'Назначено вам' (driver_assigned), not 'agreed'
- Remove duplicate header from GroupDetailPage
This commit is contained in:
Codex 2026-05-22 12:53:43 +03:00
parent 9abfbff654
commit 43c5f75055
10 changed files with 799 additions and 262 deletions

View File

@ -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(

View File

@ -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 = ({
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
<summary className="cursor-pointer list-none p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">Доставка на день</p>
<h4 className="font-medium">{formatDeliverySlotGroupLabel(date, referenceDate)}</h4>
</div>
<h4 className="font-medium">{getDeliverySlotGroupHeading(date, referenceDate)}</h4>
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
</div>

View File

@ -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 });
continue;
}
const hasSubOrders = subOrders.some((s) => typeof s === "object" && ("nom" in s || ("items" in s && Array.isArray(s.items))));
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 subOrders) {
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();
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 });
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 subOrders) {
for (const p of subItems) {
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 });
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 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)}
>
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Состав заказа {reference !== "Счет —" ? reference : ""}
Состав заказа{reference && reference !== "Счет —" ? ` ${reference}` : ""}
</p>
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
{products.length > 0 ? `${products.length} поз.` : ""}
{products.length} поз.
<svg
className="h-4 w-4 transition-transform"
style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)" }}

View File

@ -13,6 +13,43 @@ import { Input } from "../UI/Input";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel";
const extractCity = (address) => {
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
<Badge tone="neutral">{deliveryCountLabel}</Badge>
</div>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Показываем только согласованные к доставке группы. Выберите дату ниже.
Показываем только назначенные вам группы доставки. Выберите дату и город.
</p>
</div>
</div>
@ -120,7 +183,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
</label>
</div>
{/* Date pills showing days with deliveries */}
{/* Date pills */}
{sortedDeliveryDates.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
<button
@ -161,11 +224,77 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
})}
</div>
)}
{/* City pills */}
{sortedCities.length > 1 && (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
className={[
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
!filters.selectedCity
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
].join(" ")}
>
Все города
</button>
{sortedCities.map((city) => {
const count = cityDeliveryMap.get(city) || 0;
const selected = filters.selectedCity === city;
return (
<button
key={city}
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
className={[
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
selected
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
].join(" ")}
>
<span>{city}</span>
{count > 0 && (
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
{count}
</span>
)}
</button>
);
})}
</div>
)}
</div>
</Panel>
{groupedOrderGroups.length ? (
groupedOrderGroups.map((group) => (
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 (
<Panel key={group.date} className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@ -192,8 +321,14 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
</Badge>
</div>
{sortedBuckets.map(([statusValue, { label, tone, items }]) => (
<div key={statusValue} className="space-y-2">
<div className="flex items-center gap-2">
<Badge tone={tone}>{label}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {items.length === 1 ? "группа" : "группы"}</span>
</div>
<div className="grid gap-3">
{group.items.map((item) => (
{items.map((item) => (
<button
key={item.id}
type="button"
@ -210,23 +345,19 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
</div>
</div>
<Badge tone={getOrderGroupDeliveryStatusTone(item.deliveryStatus || item.delivery_status)}>
{(() => { const s = item.deliveryStatus || item.delivery_status; return s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s); })()}
</Badge>
</div>
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
<div>{item.orderNumbers?.[0] || "Номера не указаны"}</div>
<div>
{item.readyCount || 0}/{item.ordersCount || 0} готово
</div>
<div>{item.smsSentAt ? "SMS отправлено" : "SMS не отправлено"}</div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
</div>
</button>
))}
</div>
</div>
))}
</Panel>
))
);
})
) : (
<Panel className="p-6">
<h4 className="text-lg font-semibold">Доставки не найдены</h4>

View File

@ -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 (
<Panel className="space-y-3 p-5">
<strong>Состав заказа</strong>
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</Panel>
);
}
return (
<Panel className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<strong>Отгрузка</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены.
</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Badge tone={allShipped ? "accent" : "neutral"}>
{shippedCount}/{items.length} отгружено
</Badge>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0}>
Сбросить
</Button>
</div>
<div className="space-y-2">
{items.map((item) => {
const isShipped = shippedItems.has(item.id);
const hasComment = !isShipped && comments[item.id]?.trim();
return (
<div
key={item.id}
className={[
"rounded-[18px] border px-4 py-3 text-sm transition",
isShipped
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: hasComment
? "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
].join(" ")}
>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isShipped}
onChange={() => toggleItem(item.id)}
className="mt-0.5 h-4 w-4 flex-shrink-0 accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className={isShipped ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}>
{item.name}
</span>
{(item.quantity || item.unit) ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{!isShipped && (
<input
type="text"
placeholder="Причина неотгрузки (дефект, нет в наличии...)"
value={comments[item.id] || ""}
onChange={(e) =>
setComments((prev) => ({ ...prev, [item.id]: e.target.value }))
}
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
)}
</div>
</label>
</div>
);
})}
</div>
{unshippedCount > 0 && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
<p className="font-medium text-[var(--color-text)]">
Не отгружено: {unshippedCount} {unshippedCount === 1 ? "позиция" : unshippedCount < 5 ? "позиции" : "позиций"}
</p>
{unshippedWithoutComment > 0 && (
<p className="mt-1 text-[var(--color-text-muted)]">
Укажите причину для каждой неотгруженной позиции, чтобы завершить доставку.
</p>
)}
</div>
)}
</Panel>
);
};

View File

@ -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 = ({
</div>
</Panel>
{canManageDelivery && ["manager", "logistician", "admin"].includes(userRole) ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Назначение водителя</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{order.assignedDriverId
? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.`
: "Выберите водителя для доставки."}
</p>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
<Select
className="h-[46px] py-0"
value={selectedDriverId}
onChange={(e) => {
setSelectedDriverId(e.target.value);
setDriverMessage("");
}}
disabled={isSavingDeliveryChoice}
>
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>{driver.name || driver.email}</option>
))}
</Select>
<Button
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
onClick={handleAssignDriver}
disabled={isSavingDeliveryChoice || !selectedDriverId}
>
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"}
</Button>
</div>
{driverMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
) : null}
</Panel>
) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Обновите статус по мере выполнения доставки.
</p>
</div>
<div className="flex flex-wrap gap-2">
{[
{ value: "loaded", label: "Загружено" },
{ value: "on_route", label: "В пути" },
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
].map((statusOption) => (
<Button
key={statusOption.value}
variant={
(order.deliveryStatus || order.delivery_status) === statusOption.value ? "primary" : "secondary"}
onClick={() => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: statusOption.value,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
}
});
}}
disabled={isSavingDeliveryChoice}
>
{statusOption.label}
</Button>
))}
</div>
</Panel>
) : null}
{["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<PaidStoragePanel
order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingDeliveryChoice={isSavingDeliveryChoice}
setFormMessage={setFormMessage}
/>
) : null}
{canManageDelivery ? (
<Panel className="space-y-4 p-5">
<div>
@ -849,6 +770,152 @@ export const OrderDetailPanel = ({
</Panel>
) : null}
{canManageDelivery && ["manager", "logistician", "admin"].includes(userRole) ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Назначение водителя</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{order.assignedDriverId
? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.`
: "Выберите водителя для доставки."}
</p>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
<Select
className="h-[46px] py-0"
value={selectedDriverId}
onChange={(e) => {
setSelectedDriverId(e.target.value);
setDriverMessage("");
}}
disabled={isSavingDeliveryChoice}
>
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>{driver.name || driver.email}</option>
))}
</Select>
<Button
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
onClick={handleAssignDriver}
disabled={isSavingDeliveryChoice || !selectedDriverId}
>
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"}
</Button>
</div>
{driverMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
) : null}
</Panel>
) : null}
{["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Измените статус, если водитель забыл обновить или нужна корректировка.
</p>
</div>
<div className="flex flex-wrap gap-2">
{[
{ 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) => (
<Button
key={statusOption.value}
variant={
(order.deliveryStatus || order.delivery_status) === statusOption.value ? "primary" : "secondary"}
onClick={() => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: statusOption.value,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
}
});
}}
disabled={isSavingDeliveryChoice}
>
{statusOption.label}
</Button>
))}
</div>
</Panel>
) : null}
{["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<PaidStoragePanel
order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingDeliveryChoice={isSavingDeliveryChoice}
setFormMessage={setFormMessage}
/>
) : null}
{userRole === "driver" && order ? (
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} />
) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Обновите статус по мере выполнения доставки.
</p>
</div>
<div className="flex flex-wrap gap-2">
{[
{ value: "loaded", label: "Загружено" },
{ value: "on_route", label: "В пути" },
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
].map((statusOption) => (
<Button
key={statusOption.value}
variant={
(order.deliveryStatus || order.delivery_status) === statusOption.value ? "primary" : "secondary"}
onClick={() => {
if (statusOption.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) {
setFormMessage("Укажите причину для каждой неотгруженной позиции перед завершением доставки.");
return;
}
onChangeDeliveryStatus({
orderGroupId: order.id,
status: statusOption.value,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
}
});
}}
disabled={isSavingDeliveryChoice}
>
{statusOption.label}
</Button>
))}
</div>
{formMessage ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null}
</Panel>
) : null}
<Panel className="space-y-4 p-5">
<strong>Номера заказов</strong>
{renderList(order.orderNumbers)}

View File

@ -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 = () => {
<OrdersTable
orderGroups={filteredOrderGroups}
selectedOrderGroupId={selectedOrderGroupId}
onOpenOrder={openGroupModal}
onOpenOrder={openGroupPage}
filters={filters}
setFilters={setFilters}
statusOptions={statusOptions}
@ -114,7 +90,7 @@ export const DashboardPage = () => {
const renderLogisticsWorkspace = () => (
<div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupModal} statusOptions={statusOptions} />
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
</div>
);
@ -122,7 +98,7 @@ export const DashboardPage = () => {
<div className="space-y-6 xl:space-y-8">
<DriverDeliveryPlanner
orderGroups={allOrderGroups}
onOpenOrder={openGroupModal}
onOpenOrder={openGroupPage}
currentUser={user}
/>
</div>
@ -167,34 +143,6 @@ export const DashboardPage = () => {
) : null}
{renderActiveSection()}
<Modal isOpen={isGroupModalOpen} onClose={() => setIsGroupModalOpen(false)} className="md:max-w-[800px]">
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">Карточка группы доставки</h3>
</div>
<Button
variant="ghost"
onClick={() => {
setIsGroupModalOpen(false);
}}
>
Закрыть
</Button>
</div>
<OrderDetailPanel
order={selectedOrderGroup}
canManageDelivery={["manager", "logistician", "admin"].includes(userRole)}
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
isSavingDeliveryChoice={isSavingDeliveryChoice}
drivers={drivers}
onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus}
userRole={userRole}
/>
</div>
</Modal>
</AppShell>
);
};

View File

@ -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 (
<div className="mx-auto w-full max-w-3xl space-y-5">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => navigate("/dashboard")} className="text-sm">
Назад к списку
</Button>
</div>
{order ? (
<OrderDetailPanel
order={order}
canManageDelivery={["manager", "logistician", "admin"].includes(userRole)}
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
isSavingDeliveryChoice={isSavingDeliveryChoice}
drivers={drivers}
onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus}
userRole={userRole}
/>
) : (
<Panel className="p-6 text-sm text-[var(--color-text-muted)]">
Группа доставки не найдена.
</Panel>
)}
</div>
);
};

View File

@ -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: <DashboardPage />,
},
{
path: "dashboard/group/:groupId",
element: <GroupDetailPage />,
},
{
path: "*",
element: <NotFoundPage />,

View File

@ -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: "Первая половина дня",