Compare commits

...

4 Commits
dev ... main

Author SHA1 Message Date
root ae1e0a2e0e fix: 3 бага доставки — самовывоз/водитель/отгрузка
Bug 1: deliveryType='delivery' перезаписывался на 'pickup' маппером из-за адреса/источника. Теперь если БД явно содержит delivery_type='delivery', маппер это уважает.

Bug 2: водитель не мог сохранить частичную отгрузку — добавлена кнопка 'Сохранить отгрузку' в DriverShipmentPanel + saveShipmentData() в репозитории.

Bug 3: при смене типа на самовывоз assigned_driver_id не очищался — добавлено assigned_driver_id: null в updatePayload.
2026-06-15 15:10:30 +00:00
root efa2a83634 fix: requires_address warning only shown when address is actually empty
- Previously: status 'requires_address' always showed 'Адрес не указан' warning even when address was filled in
- Now: warning only appears when both status is 'requires_address' AND effectiveAddress is empty
- Label 'Доставка (требуется адрес)' also only shows when address is missing
2026-06-12 20:08:15 +00:00
root 491b7705fd fix: date format dd.MM.yyyy in logistics board, align columns via CSS grid
- LogisticsReadinessBoard: formatDate() for delivery date (was raw yyyy-MM-dd)
- LogisticsReadinessBoard: replaced <table> with CSS grid for aligned columns
- OrdersTable: replaced <table> with CSS grid for aligned columns
- Both tables: 6-column grid with fixed proportions, all columns visible
2026-06-12 19:28:10 +00:00
root d313a3b6b9 fix: driver badge shows only assigned deliveries, add agreed+paid_storage to visible statuses
- DashboardPage: driver badge now counts only groups visible to driver AND assigned to current user
- DRIVER_VISIBLE_DELIVERY_STATUSES: added 'agreed' and 'paid_storage' so driver sees all relevant statuses
- DriverDeliveryPlanner: added agreed/paid_storage to status sort order
- Previously badge showed allOrderGroups.length (5) but list was empty because filter was too strict
2026-06-12 19:22:36 +00:00
11 changed files with 232 additions and 98 deletions

View File

@ -347,7 +347,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
statusBuckets.get(s).items.push(item); statusBuckets.get(s).items.push(item);
} }
const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "picked_up", "problem"]; const statusOrder = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up", "paid_storage", "problem"];
const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => { const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => {
const ia = statusOrder.indexOf(a); const ia = statusOrder.indexOf(a);
const ib = statusOrder.indexOf(b); const ib = statusOrder.indexOf(b);

View File

@ -89,7 +89,7 @@ const parseOrderItems = (order) => {
return []; return [];
}; };
export const DriverShipmentPanel = ({ order, onShipmentChange }) => { export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment }) => {
const [stopWords, setStopWords] = React.useState([]); const [stopWords, setStopWords] = React.useState([]);
const [scopeActive, setScopeActive] = React.useState(true); const [scopeActive, setScopeActive] = React.useState(true);
@ -261,6 +261,29 @@ export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
)} )}
</div> </div>
)} )}
{onSaveShipment && items.length > 0 && (
<div className="flex items-center gap-3">
<Button
onClick={() => onSaveShipment(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] || ""),
})))}
disabled={isSavingShipment || shippedCount === 0}
>
{isSavingShipment ? "Сохраняем..." : "Сохранить отгрузку"}
</Button>
{shippedCount > 0 && shippedCount < items.length && (
<span className="text-xs text-[var(--color-text-muted)]">
Частичная отгрузка: {shippedCount}/{items.length}
</span>
)}
</div>
)}
</Panel> </Panel>
); );
}; };

View File

@ -10,7 +10,7 @@ import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading"; import { SkeletonPage } from "../UI/Loading";
import { OrderFilters } from "../orders/OrderFilters"; import { OrderFilters } from "../orders/OrderFilters";
import { formatDateTime } from "../../utils/formatters"; import { formatDate, formatDateTime } from "../../utils/formatters";
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => { export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" }); const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
@ -60,17 +60,17 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
const totalGroups = filteredGroups.length; const totalGroups = filteredGroups.length;
const COLS = "grid-cols-[minmax(140px,2fr)_minmax(80px,1fr)_minmax(100px,1.2fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(120px,1fr)]";
const TableHeader = () => ( const TableHeader = () => (
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]`}>
<tr> <div className="px-4 py-3 font-medium">Клиент</div>
<th className="px-4 py-3 font-medium">Клиент</th> <div className="px-4 py-3 font-medium">Город</div>
<th className="px-4 py-3 font-medium hidden sm:table-cell">Город</th> <div className="px-4 py-3 font-medium">Дата доставки</div>
<th className="px-4 py-3 font-medium hidden md:table-cell">Дата доставки</th> <div className="px-4 py-3 font-medium">Водитель</div>
<th className="px-4 py-3 font-medium hidden lg:table-cell">Водитель</th> <div className="px-4 py-3 font-medium">Статус</div>
<th className="px-4 py-3 font-medium">Статус</th> <div className="px-4 py-3 font-medium">Обновлён</div>
<th className="px-4 py-3 font-medium hidden md:table-cell">Обновлён</th> </div>
</tr>
</thead>
); );
if (isLoading) { if (isLoading) {
@ -146,42 +146,38 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
{!isCollapsed && ( {!isCollapsed && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full border-collapse border-t border-[var(--color-border)]">
<TableHeader /> <TableHeader />
<tbody>
{groups.map((group) => ( {groups.map((group) => (
<tr <button
key={group.id} key={group.id}
className="cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]" type="button"
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
onClick={() => { if (onSelectSet) onSelectSet(group.id); }} onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
> >
<td className="px-4 py-2.5"> <div className="px-4 py-2.5">
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div> <div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="text-xs text-[var(--color-text-muted)] sm:hidden">{group.customerPhone || "—"}</div> <div className="text-xs text-[var(--color-text-muted)]">{group.customerPhone || "—"}</div>
<div className="text-xs text-[var(--color-text-muted)] md:hidden">{group.deliveryDate || "—"}</div> </div>
</td> <div className="px-4 py-2.5 text-sm">
<td className="px-4 py-2.5 text-sm hidden sm:table-cell">
{group.city || group.customerAddress || "—"} {group.city || group.customerAddress || "—"}
</td> </div>
<td className="px-4 py-2.5 text-sm hidden md:table-cell"> <div className="px-4 py-2.5 text-sm">
{group.deliveryDate {group.deliveryDate
? <span>{group.deliveryDate}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span> ? <span>{formatDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
: <span className="text-[var(--color-text-muted)]"></span> : <span className="text-[var(--color-text-muted)]"></span>
} }
</td> </div>
<td className="px-4 py-2.5 text-sm hidden lg:table-cell"> <div className="px-4 py-2.5 text-sm">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>} {group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</td> </div>
<td className="px-4 py-2.5"> <div className="px-4 py-2.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge> <Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
</td> </div>
<td className="px-4 py-2.5 text-sm text-[var(--color-text-muted)] hidden md:table-cell"> <div className="px-4 py-2.5 text-sm text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)} {formatDateTime(group.updatedAt)}
</td> </div>
</tr> </button>
))} ))}
</tbody>
</table>
</div> </div>
)} )}
</Panel> </Panel>

View File

@ -544,7 +544,8 @@ export const OrderDetailPanel = ({
drivers = [], drivers = [],
onAssignDriver, onAssignDriver,
onChangeDeliveryStatus, onChangeDeliveryStatus,
userRole, onSaveShipmentData,
userRole = "driver",
}) => { }) => {
const [problemReason, setProblemReason] = React.useState(null); const [problemReason, setProblemReason] = React.useState(null);
const [pendingStatus, setPendingStatus] = React.useState(null); const [pendingStatus, setPendingStatus] = React.useState(null);
@ -552,6 +553,7 @@ export const OrderDetailPanel = ({
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]); const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const [formMessage, setFormMessage] = React.useState(""); const [formMessage, setFormMessage] = React.useState("");
const [shipmentState, setShipmentState] = React.useState(null); const [shipmentState, setShipmentState] = React.useState(null);
const [isSavingShipment, setIsSavingShipment] = React.useState(false);
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
const [driverMessage, setDriverMessage] = React.useState(""); const [driverMessage, setDriverMessage] = React.useState("");
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || ""); const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
@ -564,6 +566,26 @@ export const OrderDetailPanel = ({
const handleShipmentChange = React.useCallback((state) => { const handleShipmentChange = React.useCallback((state) => {
setShipmentState(state); setShipmentState(state);
}, []); }, []);
const handleSaveShipment = React.useCallback(async (shipmentData) => {
if (!onSaveShipmentData) return;
setIsSavingShipment(true);
try {
const result = await onSaveShipmentData({
orderGroupId: order.id,
shipmentData,
});
if (!result.success) {
setFormMessage(result.error || "Не удалось сохранить данные отгрузки");
} else {
setFormMessage("Данные отгрузки сохранены");
}
} catch (err) {
setFormMessage("Не удалось сохранить данные отгрузки");
} finally {
setIsSavingShipment(false);
}
}, [onSaveShipmentData, order?.id]);
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
const [currentMonth, setCurrentMonth] = React.useState(() => { const [currentMonth, setCurrentMonth] = React.useState(() => {
const existingDeliveryDate = fromDateKey(order?.deliveryDate); const existingDeliveryDate = fromDateKey(order?.deliveryDate);
@ -710,17 +732,18 @@ export const OrderDetailPanel = ({
{(() => { {(() => {
const isPickup = isPickupOrder; const isPickup = isPickupOrder;
const effectiveAddress = isPickup
? (order.customerAddress || "")
: (order.deliveryAddress || "");
const requiresAddress = (order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && !effectiveAddress;
const deliveryTypeLabel = isPickup const deliveryTypeLabel = isPickup
? "Самовывоз" ? "Самовывоз"
: (order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") : requiresAddress
? "Доставка (требуется адрес)" ? "Доставка (требуется адрес)"
: "Доставка"; : "Доставка";
const dateLabel = isPickup ? "Дата самовывоза" : "Дата доставки"; const dateLabel = isPickup ? "Дата самовывоза" : "Дата доставки";
const timeLabel = isPickup ? "Время самовывоза" : "Время доставки"; const timeLabel = isPickup ? "Время самовывоза" : "Время доставки";
const addressLabel = isPickup ? "Адрес клиента" : "Адрес доставки"; const addressLabel = isPickup ? "Адрес клиента" : "Адрес доставки";
const effectiveAddress = isPickup
? (order.customerAddress || "")
: (order.deliveryAddress || "");
return ( return (
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-4"> <div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-4">
<div> <div>
@ -740,7 +763,7 @@ export const OrderDetailPanel = ({
Тип Тип
</p> </p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{deliveryTypeLabel}</p> <p className="mt-1 text-base font-medium !text-[var(--color-text)]">{deliveryTypeLabel}</p>
{(order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && ( {requiresAddress && (
<div className="mt-2 flex items-start gap-2 rounded-xl border border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] p-3"> <div className="mt-2 flex items-start gap-2 rounded-xl border border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] p-3">
<span className="text-lg">📍</span> <span className="text-lg">📍</span>
<div> <div>
@ -1023,7 +1046,7 @@ export const OrderDetailPanel = ({
) : null} ) : null}
{userRole === "driver" && order ? ( {userRole === "driver" && order ? (
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} /> <DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} onSaveShipment={handleSaveShipment} isSavingShipment={isSavingShipment} />
) : null} ) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? ( {userRole === "driver" && order && onChangeDeliveryStatus ? (

View File

@ -141,57 +141,51 @@ export const OrdersTable = ({
Группы не найдены. Попробуйте изменить поиск или статус. Группы не найдены. Попробуйте изменить поиск или статус.
</div> </div>
) : ( ) : (
<table className="min-w-full border-collapse"> <div>
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]"> <div className="grid grid-cols-[minmax(180px,2.5fr)_minmax(100px,1.2fr)_minmax(100px,1fr)_minmax(80px,1fr)_minmax(120px,1.2fr)_minmax(130px,1.2fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<tr> <div className="px-5 py-4 font-medium">Группа / Клиент</div>
<th className="px-5 py-4 font-medium">Группа / Клиент</th> <div className="px-5 py-4 font-medium">Счёта</div>
<th className="px-5 py-4 font-medium">Счёта</th> <div className="px-5 py-4 font-medium">Статус</div>
<th className="px-5 py-4 font-medium">Статус</th> <div className="px-5 py-4 font-medium">Водитель</div>
<th className="px-5 py-4 font-medium">Водитель</th> <div className="px-5 py-4 font-medium">Дата доставки</div>
<th className="px-5 py-4 font-medium">Дата доставки</th> <div className="px-5 py-4 font-medium">Обновлён</div>
<th className="px-5 py-4 font-medium">Обновлён</th> </div>
</tr>
</thead>
<tbody>
{orderGroups.map((group) => ( {orderGroups.map((group) => (
<tr <button
key={group.id} key={group.id}
className={[ type="button"
"cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]", className={`grid grid-cols-[minmax(180px,2.5fr)_minmax(100px,1.2fr)_minmax(100px,1fr)_minmax(80px,1fr)_minmax(120px,1.2fr)_minmax(130px,1.2fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`}
selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
].join(" ")}
onClick={() => onOpenOrder(group.id)} onClick={() => onOpenOrder(group.id)}
> >
<td className="px-5 py-4"> <div className="px-5 py-4">
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div> <div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]"> <div className="mt-1 text-sm text-[var(--color-text-muted)]">
{[group.customerName, group.customerPhone].filter(Boolean).join(" · ")} {[group.customerName, group.customerPhone].filter(Boolean).join(" · ")}
</div> </div>
<div className="text-xs text-[var(--color-text-muted)]">{group.groupKey}</div> <div className="text-xs text-[var(--color-text-muted)]">{group.groupKey}</div>
</td> </div>
<td className="max-w-[260px] px-5 py-4 text-sm text-[var(--color-text-muted)]"> <div className="max-w-[260px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
{renderOrderNumbers(group)} {renderOrderNumbers(group)}
</td> </div>
<td className="px-5 py-4 text-center"> <div className="px-5 py-4">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge> <Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
</td> </div>
<td className="px-5 py-4 text-sm"> <div className="px-5 py-4 text-sm">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>} {group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</td> </div>
<td className="px-5 py-4 text-sm"> <div className="px-5 py-4 text-sm">
{group.deliveryDate ? ( {group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span> <span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : ( ) : (
<span className="text-[var(--color-text-muted)]"></span> <span className="text-[var(--color-text-muted)]"></span>
)} )}
</td> </div>
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]"> <div className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)} {formatDateTime(group.updatedAt)}
</td> </div>
</tr> </button>
))} ))}
</tbody> </div>
</table>
)} )}
</div> </div>
</Panel> </Panel>

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { assignDriverToOrderGroup, fetchOrderGroups, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository"; import { assignDriverToOrderGroup, fetchOrderGroups, saveShipmentData, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
import { import {
buildOrderGroupBuckets, buildOrderGroupBuckets,
filterOrderGroups, filterOrderGroups,
@ -171,10 +171,10 @@ export const useOrderGroups = () => {
} }
}, []); }, []);
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => { const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details, shipmentData }) => {
setIsSavingStatusChange(true); setIsSavingStatusChange(true);
try { try {
const result = await updateDeliveryStatus({ orderGroupId, status, details }); const result = await updateDeliveryStatus({ orderGroupId, status, details, shipmentData });
if (result.error) { if (result.error) {
return { return {
success: false, success: false,
@ -195,6 +195,27 @@ export const useOrderGroups = () => {
} }
}, []); }, []);
const onSaveShipmentData = React.useCallback(async ({ orderGroupId, shipmentData }) => {
try {
const result = await saveShipmentData({ orderGroupId, shipmentData });
if (result.error) {
return {
success: false,
error: getErrorMessage(result.error, "Не удалось сохранить данные отгрузки"),
};
}
setOrderGroups((currentGroups) =>
currentGroups.map((group) => (group.id === orderGroupId ? result.data : group)),
);
return { success: true, data: result.data };
} catch (error) {
return {
success: false,
error: getErrorMessage(error, "Не удалось сохранить данные отгрузки"),
};
}
}, []);
return { return {
orderGroups, orderGroups,
allOrderGroups: orderGroups, allOrderGroups: orderGroups,
@ -211,6 +232,7 @@ export const useOrderGroups = () => {
saveManualDeliveryChoice, saveManualDeliveryChoice,
assignDriver, assignDriver,
changeDeliveryStatus, changeDeliveryStatus,
saveShipmentData: onSaveShipmentData,
isSavingDeliveryChoice, isSavingDeliveryChoice,
isSavingDriverAssignment, isSavingDriverAssignment,
isSavingStatusChange, isSavingStatusChange,

View File

@ -7,6 +7,7 @@
import React from "react"; import React from "react";
import { Navigate, useNavigate, useSearchParams, useLocation } from "react-router-dom"; import { Navigate, useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner"; import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { isOrderGroupVisibleToDriver } from "../services/orderGroupViews";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { OrdersTable } from "../components/orders/OrdersTable"; import { OrdersTable } from "../components/orders/OrdersTable";
import { AdminDashboard } from "../components/admin/AdminDashboard"; import { AdminDashboard } from "../components/admin/AdminDashboard";
@ -108,6 +109,16 @@ export const DashboardPage = () => {
return [...set].sort(); return [...set].sort();
}, [allOrderGroups]); }, [allOrderGroups]);
// Driver order count (for badge)
const driverOrderCount = React.useMemo(() => {
if (userRole !== "driver" || !user) return 0;
return allOrderGroups.filter((g) => {
const isVisible = isOrderGroupVisibleToDriver(g);
const isAssignedToMe = g.assignedDriverId === user.id;
return isVisible && isAssignedToMe;
}).length;
}, [allOrderGroups, user, userRole]);
// Navigation Builder // Navigation Builder
const openGroupPage = React.useCallback((groupId) => { const openGroupPage = React.useCallback((groupId) => {
navigate("/dashboard/group/" + groupId); navigate("/dashboard/group/" + groupId);
@ -130,7 +141,7 @@ export const DashboardPage = () => {
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) }, { key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
] ]
: [ : [
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) }, { key: section.key, label: section.label, description: section.description, badge: String(userRole === "driver" ? driverOrderCount : (allOrderGroups.length || orderGroups.length || 0)) },
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null }, { key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
]; ];

View File

@ -35,6 +35,7 @@ export const GroupDetailPage = () => {
isSavingStatusChange, isSavingStatusChange,
assignDriver, assignDriver,
changeDeliveryStatus, changeDeliveryStatus,
saveShipmentData,
isLoading, isLoading,
} = useOrderGroups(); } = useOrderGroups();
@ -111,6 +112,7 @@ export const GroupDetailPage = () => {
drivers={drivers} drivers={drivers}
onAssignDriver={assignDriver} onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus} onChangeDeliveryStatus={changeDeliveryStatus}
onSaveShipmentData={saveShipmentData}
userRole={userRole} userRole={userRole}
/> />
</ErrorBoundary> </ErrorBoundary>

View File

@ -29,12 +29,14 @@ export const NOTIFICATION_STATUS_LABELS = {
}; };
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"agreed",
"driver_assigned", "driver_assigned",
"loaded", "loaded",
"on_route", "on_route",
"problem", "problem",
"delivered", "delivered",
"picked_up", "picked_up",
"paid_storage",
]; ];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"]; export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];

View File

@ -117,9 +117,16 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
// Also treat address equal to "САМОВЫВОЗ" as pickup indicator // Also treat address equal to "САМОВЫВОЗ" as pickup indicator
const isPickupAddress = deliveryAddress.toUpperCase() === "САМОВЫВОЗ"; const isPickupAddress = deliveryAddress.toUpperCase() === "САМОВЫВОЗ";
// Resolve effective delivery type: DB field takes precedence, but if it says "delivery" // Resolve effective delivery type:
// while source data clearly indicates pickup, treat as pickup // - If DB explicitly says "pickup" → pickup
const effectiveDeliveryType = (row.delivery_type === "pickup" || deliveryStatus === "pickup" || isPickupFromSource || isPickupAddress) // - If status is "pickup" → pickup
// - If DB explicitly says "delivery" (manually confirmed by logistician) → honor it, don't override
// - Otherwise fall back to source/address detection
const effectiveDeliveryType = (row.delivery_type === "pickup" || deliveryStatus === "pickup")
? "pickup"
: row.delivery_type === "delivery"
? "delivery"
: (isPickupFromSource || isPickupAddress)
? "pickup" ? "pickup"
: (row.delivery_type || "delivery"); : (row.delivery_type || "delivery");
@ -214,6 +221,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
pickupDate: row.pickup_date || null, pickupDate: row.pickup_date || null,
pickupTimeSlot: row.pickup_time_slot || null, pickupTimeSlot: row.pickup_time_slot || null,
driverShipmentData: row.driver_shipment_data || null, driverShipmentData: row.driver_shipment_data || null,
syncedTo1cAt: row.synced_to_1c_at || null,
deliveryHalfDay: getOrderGroupDeliveryHalfDay({ deliveryHalfDay: getOrderGroupDeliveryHalfDay({
deliveryHalfDay: rawDeliveryHalfDay, deliveryHalfDay: rawDeliveryHalfDay,
deliveryTime: rawDeliveryTime, deliveryTime: rawDeliveryTime,
@ -254,7 +262,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
}; };
}; };
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot`; const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, synced_to_1c_at`;
export const updateOrderGroupDeliveryChoice = async ({ export const updateOrderGroupDeliveryChoice = async ({
orderGroupId, orderGroupId,
@ -280,6 +288,8 @@ export const updateOrderGroupDeliveryChoice = async ({
if (deliveryType === "pickup") { if (deliveryType === "pickup") {
updatePayload.pickup_date = pickupDate || null; updatePayload.pickup_date = pickupDate || null;
updatePayload.pickup_time_slot = pickupTimeSlot || null; updatePayload.pickup_time_slot = pickupTimeSlot || null;
// Pickup orders don't need a driver — clear assignment
updatePayload.assigned_driver_id = null;
} else { } else {
updatePayload.pickup_date = null; updatePayload.pickup_date = null;
updatePayload.pickup_time_slot = null; updatePayload.pickup_time_slot = null;
@ -378,7 +388,40 @@ export const assignDriverToOrderGroup = async ({
}, "Ошибка назначения водителя"); }, "Ошибка назначения водителя");
}; };
export const updateDeliveryStatus = async ({ orderGroupId, status, details } = {}) => { export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
const { error: updateError } = await client
.from("order_groups")
.update({
driver_shipment_data: shipmentData,
updated_at: new Date().toISOString(),
})
.eq("id", orderGroupId);
if (updateError) throw updateError;
const { data, error } = await client
.from("order_groups")
.select(ORDER_GROUP_SELECT_FIELDS)
.eq("id", orderGroupId)
.single();
if (error) throw error;
await logAction({
orderGroupId,
action: "shipment_data_saved",
newValue: "shipment_data_updated",
details: { itemCount: shipmentData?.length || 0 },
}).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка сохранения данных отгрузки");
};
export const updateDeliveryStatus = async ({ orderGroupId, status, details, shipmentData } = {}) => {
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
@ -428,6 +471,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details } = {
if (rpcError) throw rpcError; if (rpcError) throw rpcError;
} }
// Save shipment data if provided (e.g. partial delivery info)
if (shipmentData) {
const { error: shipmentUpdateError } = await client
.from("order_groups")
.update({
driver_shipment_data: shipmentData,
updated_at: new Date().toISOString(),
})
.eq("id", orderGroupId);
if (shipmentUpdateError) {
// Log but don't fail the status update
console.error("[updateDeliveryStatus] Failed to save shipment data:", shipmentUpdateError);
}
}
// Fetch updated group // Fetch updated group
const { data, error } = await client const { data, error } = await client
.from("order_groups") .from("order_groups")

View File

@ -241,6 +241,7 @@ alter table public.order_groups add column if not exists last_sms_error text;
alter table public.order_groups add column if not exists next_notification_check_at timestamptz; alter table public.order_groups add column if not exists next_notification_check_at timestamptz;
alter table public.order_groups add column if not exists delivery_date date; alter table public.order_groups add column if not exists delivery_date date;
alter table public.order_groups add column if not exists delivery_time text; alter table public.order_groups add column if not exists delivery_time text;
alter table public.order_groups add column if not exists synced_to_1c_at timestamptz;
alter table public.orders add column if not exists source_order_number text; alter table public.orders add column if not exists source_order_number text;
alter table public.orders add column if not exists source_order_date date; alter table public.orders add column if not exists source_order_date date;
@ -465,6 +466,7 @@ create index if not exists idx_delivery_invitations_expires_at on public.deliver
create index if not exists idx_order_groups_status on public.order_groups (status); create index if not exists idx_order_groups_status on public.order_groups (status);
create index if not exists idx_order_groups_delivery_status on public.order_groups (delivery_status); create index if not exists idx_order_groups_delivery_status on public.order_groups (delivery_status);
create index if not exists idx_order_groups_notification_status on public.order_groups (notification_status, next_notification_check_at); create index if not exists idx_order_groups_notification_status on public.order_groups (notification_status, next_notification_check_at);
create index if not exists idx_order_groups_synced_to_1c on public.order_groups (synced_to_1c_at);
create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc); create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc);
create index if not exists idx_integration_events_event_type on public.integration_events (event_type); create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc); create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc);