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:
parent
c1e5f109be
commit
ced5958eac
|
|
@ -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)]">
|
||||
<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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue