feat: driver filters + status fixes + logistics problem indicator

DriverDeliveryPlanner:
- Text search (name, address, phone, order numbers)
- Half-day filter (morning/afternoon/unknown)
- 'Reset all filters' button
- tel: link on phone in delivery cards
- Order numbers badges in cards
- Partial delivery warning badge
- Problems-first sorting

saveShipmentData fix:
- Partial shipment with problems → status 'problem' (not 'delivered')
- Full shipment → 'delivered' (unchanged)

DriverShipmentPanel:
- Reset button: confirmation dialog when status is final
- Calls onResetStatus to revert delivery status to 'loaded'

OrderDetailPanel (driver status section):
- 'Return to work' button for delivered/problem/picked_up statuses
- Passes onResetStatus + isSavingStatusChange to DriverShipmentPanel

LogisticsReadinessBoard:
- Warning badge for has_delivery_problem groups in table rows
- Shows delivery_problem_note as tooltip
This commit is contained in:
root 2026-07-10 14:50:15 +00:00
parent c1e5f109be
commit ced5958eac
5 changed files with 259 additions and 16 deletions

View File

@ -8,6 +8,7 @@ import {
isOrderGroupVisibleToDriver,
groupOrderGroupsByDate,
parseGroupDate,
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
@ -110,13 +111,29 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
selectedDate: "",
deliveryStatus: "all",
selectedCity: "",
searchQuery: "",
halfDay: "all",
});
const [collapsedDates, setCollapsedDates] = React.useState({});
const hasActiveFilters = filters.selectedDate || filters.deliveryStatus !== "all" || filters.selectedCity || filters.searchQuery || filters.halfDay !== "all";
const resetAllFilters = () => {
setFilters({ selectedDate: "", deliveryStatus: "all", selectedCity: "", searchQuery: "", halfDay: "all" });
};
const toggleDate = (date) => {
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] }));
};
const normalizePhoneForTel = (phone) => {
const cleaned = String(phone || "").trim();
if (!cleaned) return "";
if (cleaned.startsWith("+7")) return cleaned;
if (cleaned.startsWith("8")) return "+7" + cleaned.slice(1);
return "+7" + cleaned;
};
const driverOrderGroups = React.useMemo(
() => orderGroups.filter((group) => {
const isVisible = isOrderGroupVisibleToDriver(group);
@ -158,6 +175,19 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
});
}, [cityDeliveryMap]);
const getSearchHaystack = (group) => {
return [
group.groupKey,
group.displayTitle,
group.customerName,
group.customerPhone,
group.customerDate,
Array.isArray(group.orderNumbers) ? group.orderNumbers.join(" ") : "",
group.deliveryAddress || group.delivery_address || "",
group.city || "",
].filter(Boolean).join(" ").toLowerCase();
};
const filteredOrderGroups = React.useMemo(() => {
let result = [...driverOrderGroups];
if (filters.selectedDate) {
@ -172,8 +202,39 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
return city === filters.selectedCity;
});
}
if (filters.halfDay !== "all") {
result = result.filter((group) => {
const groupHalfDay = getOrderGroupDeliveryHalfDay(group);
if (filters.halfDay === "unknown") {
return !groupHalfDay;
}
const labelMap = { morning: "Первая половина дня", afternoon: "Вторая половина дня" };
return groupHalfDay === labelMap[filters.halfDay];
});
}
const query = (filters.searchQuery || "").trim().toLowerCase();
if (query) {
result = result.filter((group) => getSearchHaystack(group).includes(query));
}
// Sort: problems first, then by status priority
const statusPriority = ["problem", "on_route", "loaded", "driver_assigned", "agreed", "delivered", "picked_up", "paid_storage"];
result.sort((a, b) => {
// has_delivery_problem always first
const aProblem = a.hasDeliveryProblem || a.has_delivery_problem;
const bProblem = b.hasDeliveryProblem || b.has_delivery_problem;
if (aProblem && !bProblem) return -1;
if (!aProblem && bProblem) return 1;
const sa = a.deliveryStatus || a.delivery_status || "unknown";
const sb = b.deliveryStatus || b.delivery_status || "unknown";
const ia = statusPriority.indexOf(sa);
const ib = statusPriority.indexOf(sb);
if (ia === -1 && ib === -1) return 0;
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return result;
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]);
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity, filters.searchQuery, filters.halfDay]);
const groupedOrderGroups = React.useMemo(
() => groupOrderGroupsByDate(filteredOrderGroups),
@ -245,6 +306,49 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
</label>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Поиск
</span>
<Input
type="text"
placeholder="Имя, адрес, телефон, номер заказа..."
value={filters.searchQuery}
onChange={(event) => setFilters((current) => ({ ...current, searchQuery: event.target.value }))}
/>
</label>
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Время дня
</span>
<Select
value={filters.halfDay}
onChange={(event) => setFilters((current) => ({ ...current, halfDay: event.target.value }))}
>
{ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</label>
</div>
{hasActiveFilters && (
<div className="pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetAllFilters}
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-danger)]"
>
Сбросить все фильтры
</Button>
</div>
)}
{/* Date pills */}
{sortedDeliveryDates.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
@ -402,7 +506,11 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {pluralGroups(items.length)}</span>
</div>
<div className="grid gap-3">
{items.map((item) => (
{items.map((item) => {
const hasProblem = item.hasDeliveryProblem || item.has_delivery_problem;
const phoneTel = normalizePhoneForTel(item.customerPhone);
const halfDayLabel = getOrderGroupDeliveryHalfDay(item);
return (
<Button
key={item.id}
variant="secondary"
@ -410,14 +518,43 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
onClick={() => onOpenOrder?.(item.id)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-medium text-[var(--color-text)]">
{item.displayTitle || item.customerName || item.groupKey}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-[var(--color-text)]">
{item.displayTitle || item.customerName || item.groupKey}
</span>
{hasProblem && (
<Badge tone="danger"> Частично</Badge>
)}
</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
{item.customerDate} · {item.customerPhone}
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-[var(--color-text-muted)]">
{item.customerDate && <span>{item.customerDate}</span>}
{item.customerDate && (halfDayLabel || item.customerPhone) && <span>·</span>}
{halfDayLabel && <span>{halfDayLabel}</span>}
{halfDayLabel && item.customerPhone && <span>·</span>}
{item.customerPhone && phoneTel && (
<a
href={`tel:${phoneTel}`}
onClick={(e) => e.stopPropagation()}
className="text-[var(--color-accent)] hover:underline"
>
{item.customerPhone}
</a>
)}
{item.customerPhone && !phoneTel && <span>{item.customerPhone}</span>}
</div>
{Array.isArray(item.orderNumbers) && item.orderNumbers.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{item.orderNumbers.map((num, idx) => (
<span
key={idx}
className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--color-accent)]"
>
{num}
</span>
))}
</div>
)}
</div>
</div>
@ -425,7 +562,8 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
</div>
</Button>
))}
);
})}
</div>
</div>
))}

View File

@ -89,11 +89,12 @@ const parseOrderItems = (order) => {
return [];
};
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment }) => {
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange }) => {
const [stopWords, setStopWords] = React.useState([]);
const [scopeActive, setScopeActive] = React.useState(true);
const [savedShipment, setSavedShipment] = React.useState([]);
const [justSaved, setJustSaved] = React.useState(false);
const [showResetConfirm, setShowResetConfirm] = React.useState(false);
React.useEffect(() => {
if (!supabase) return;
@ -168,11 +169,27 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
setComments({});
};
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
const unshipAll = () => {
if (isStatusFinal && onResetStatus) {
setShowResetConfirm(true);
return;
}
setShippedItems(new Set());
setComments({});
};
const confirmResetAll = () => {
setShippedItems(new Set());
setComments({});
setShowResetConfirm(false);
if (onResetStatus) {
onResetStatus();
}
};
const shippedCount = items.filter((i) => shippedItems.has(i.id)).length;
const unshippedCount = items.length - shippedCount;
const allShipped = items.length > 0 && shippedCount === items.length;
@ -232,11 +249,36 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0}>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить
</Button>
</div>
{showResetConfirm && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4 space-y-3">
<p className="text-sm font-medium text-[var(--color-text)]">
Сбросить отгрузку и вернуть статус?
</p>
<p className="text-xs text-[var(--color-text-muted)]">
Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен.
Отгрузка будет очищена, логист увидит что доставка требует доработки.
</p>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={confirmResetAll}
disabled={isSavingStatusChange}
>
{isSavingStatusChange ? "Сохраняем..." : "Да, сбросить"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(false)}>
Отмена
</Button>
</div>
</div>
)}
<div className="space-y-2">
{items.map((item) => {
const isShipped = shippedItems.has(item.id);

View File

@ -158,7 +158,17 @@ const renderRow = (group, onSelectSet) => (
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
<div className="px-3 py-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
<div className="flex items-center gap-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
<span
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
className="inline-flex items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]"
>
Проблема
</span>
)}
</div>
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}

View File

@ -1088,7 +1088,27 @@ export const OrderDetailPanel = ({
) : null}
{userRole === "driver" && order ? (
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} onSaveShipment={handleSaveShipment} isSavingShipment={isSavingShipment} />
<DriverShipmentPanel
order={order}
onShipmentChange={handleShipmentChange}
onSaveShipment={handleSaveShipment}
isSavingShipment={isSavingShipment}
onResetStatus={() => {
if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "loaded",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус");
} else {
setFormMessage("Статус сброшен, отгрузка очищена");
}
});
}
}}
isSavingStatusChange={isSavingStatusChange}
/>
) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? (
@ -1135,7 +1155,10 @@ export const OrderDetailPanel = ({
}
}
if (statusOptions.length === 0) return null;
// "Return to work" button for final statuses
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
if (statusOptions.length === 0 && !canReturn) return null;
return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value;
@ -1163,6 +1186,28 @@ export const OrderDetailPanel = ({
);
});
})()}
{(() => {
const currentStatus = order.deliveryStatus || order.delivery_status;
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
if (!canReturn) return null;
return (
<Button
variant="ghost"
disabled={isSavingStatusChange}
onClick={() => {
setPendingStatus({
value: "loaded",
label: "Вернуть в работу",
mismatch: false,
deliveryType: "delivery",
});
}}
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-warning)]"
>
Вернуть в работу
</Button>
);
})()}
</div>
{pendingStatus ? (
<div className="flex items-center gap-3 mt-2">

View File

@ -409,8 +409,16 @@ export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
? shipmentData.filter((i) => !i.shipped).map((i) => `${i.name}${i.quantity ? ` (${i.quantity}${i.unit ? ` ${i.unit}` : ""})` : ""}${i.comment ? `${i.comment}` : ""}`).join("; ")
: null;
// Any shipment data saved = delivered status. Problems go into has_delivery_problem/delivery_problem_note columns.
const newDeliveryStatus = hasAnyShipped ? "delivered" : undefined;
// If all shipped → delivered. If partial (some shipped, some not) → problem.
// If nothing shipped → keep current status (driver just clearing checkboxes).
let newDeliveryStatus;
if (hasAnyShipped && !hasProblem) {
newDeliveryStatus = "delivered";
} else if (hasAnyShipped && hasProblem) {
newDeliveryStatus = "problem";
} else {
newDeliveryStatus = undefined;
}
const updatePayload = {
driver_shipment_data: shipmentData,