supersam/src/components/driver/DriverDeliveryPlanner.jsx

582 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CRIMEAN_CITIES } from "../../constants/cities.js";
import React from "react";
import {
getOrderGroupDeliveryHalfDay,
getOrderGroupDeliveryStatusLabel,
getOrderGroupDeliveryStatusTone,
DRIVER_VISIBLE_DELIVERY_STATUSES,
isOrderGroupVisibleToDriver,
groupOrderGroupsByDate,
parseGroupDate,
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Input } from "../UI/Input";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
const CHEVRON_DOWN = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6l4 4 4-4" />
</svg>
);
const CHEVRON_RIGHT = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4l4 4-4 4" />
</svg>
);
const extractCity = (address) => {
if (!address || typeof address !== "string") return null;
const trimmed = address.trim();
if (!trimmed) return null;
const cityMatch = trimmed.match(/(?:г\.\s+|гор\.\s+|пос\.\s+|с\.\s+|дер\.\s+|пгт\.\s+|город\s+|село\s+|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|\s|$)/i);
if (cityMatch) {
return cityMatch[1].trim();
}
for (const city of CRIMEAN_CITIES) {
if (trimmed.toLowerCase().includes(city.toLowerCase())) {
return city;
}
}
// Бахчисарайский р-н → Бахчисарай
const district = trimmed.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
if (district) {
const base = district[1];
for (const city of CRIMEAN_CITIES) {
if (city.toLowerCase().startsWith(base.toLowerCase())) return city;
}
}
// no match → null (caller falls back to Севастополь)
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) => ({
value: status,
label: status === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(status),
})),
];
const pluralGroups = (n) => {
if (n === 1) return "группа";
if (n >= 2 && n < 5) return "группы";
return "групп";
};
/** Count items by status, return array of {status, label, tone, count} */
const countByStatus = (items) => {
const map = new Map();
for (const item of items) {
const s = item.deliveryStatus || item.delivery_status || "unknown";
map.set(s, (map.get(s) || 0) + 1);
}
const result = [];
for (const [status, count] of map) {
result.push({
status,
label: status === "driver_assigned" ? "Назначено" : getOrderGroupDeliveryStatusLabel(status),
tone: getOrderGroupDeliveryStatusTone(status),
count,
});
}
// Sort: delivered last (green = done), others by severity
const order = ["problem", "cancelled", "on_route", "loaded", "driver_assigned", "paid_storage", "delivered", "picked_up"];
result.sort((a, b) => {
const ia = order.indexOf(a.status);
const ib = order.indexOf(b.status);
if (ia === -1 && ib === -1) return a.status.localeCompare(b.status);
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return result;
};
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser, isLoading = false }) => {
const [filters, setFilters] = React.useState({
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);
const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id;
return isVisible && isAssignedToMe;
}),
[orderGroups, currentUser],
);
const dateDeliveryMap = React.useMemo(() => {
const map = new Map();
driverOrderGroups.forEach((group) => {
const date = group.deliveryDate;
if (date) {
map.set(date, (map.get(date) || 0) + 1);
}
});
return map;
}, [driverOrderGroups]);
const sortedDeliveryDates = React.useMemo(() => {
return Array.from(dateDeliveryMap.keys()).sort();
}, [dateDeliveryMap]);
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) => {
if (a === "Севастополь") return -1;
if (b === "Севастополь") return 1;
return a.localeCompare(b, "ru");
});
}, [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) {
result = result.filter((group) => group.deliveryDate === filters.selectedDate);
}
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;
});
}
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, filters.searchQuery, filters.halfDay]);
const groupedOrderGroups = React.useMemo(
() => groupOrderGroupsByDate(filteredOrderGroups),
[filteredOrderGroups],
);
const deliveryCountLabel = `${filteredOrderGroups.length} ${
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
}`;
const isDateSelected = (date) => filters.selectedDate === date;
// Compute per-date status summary for collapsed badges
const dateStatusSummary = React.useMemo(() => {
const summary = {};
for (const dg of groupedOrderGroups) {
summary[dg.date] = countByStatus(dg.items);
}
return summary;
}, [groupedOrderGroups]);
if (isLoading) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-4 fs-zone-card">
<Panel className="space-y-3 p-5">
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-lg font-semibold">Мои доставки</h3>
<Badge tone="neutral">{deliveryCountLabel}</Badge>
</div>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Показываем только назначенные вам группы доставки. Выберите дату и город.
</p>
</div>
</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="date"
value={filters.selectedDate}
onChange={(event) => setFilters((current) => ({ ...current, selectedDate: 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.deliveryStatus}
onChange={(event) =>
setFilters((current) => ({ ...current, deliveryStatus: event.target.value }))
}
>
{DRIVER_DELIVERY_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</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">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
className={[
"rounded-full px-3 py-1.5 text-xs font-medium",
!filters.selectedDate
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "text-[var(--color-text-muted)]",
].join(" ")}
>
Все даты
</Button>
{sortedDeliveryDates.map((date) => {
const count = dateDeliveryMap.get(date) || 0;
const selected = isDateSelected(date);
return (
<Button
key={date}
type="button"
variant="ghost"
size="sm"
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
className={[
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
selected
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "text-[var(--color-text-muted)]",
].join(" ")}
>
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</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>
)}
{/* City pills */}
{sortedCities.length > 1 && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
className={[
"rounded-full px-3 py-1.5 text-xs font-medium",
!filters.selectedCity
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "text-[var(--color-text-muted)]",
].join(" ")}
>
Все города
</Button>
{sortedCities.map((city) => {
const count = cityDeliveryMap.get(city) || 0;
const selected = filters.selectedCity === city;
return (
<Button
key={city}
type="button"
variant="ghost"
size="sm"
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
className={[
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
selected
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "text-[var(--color-text-muted)]",
].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) => {
const isCollapsed = collapsedDates[group.date];
const statusCounts = dateStatusSummary[group.date] || [];
// 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);
}
const statusOrder = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up", "paid_storage", "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">
<Button
type="button"
variant="ghost"
className="flex w-full items-center gap-3 text-left"
onClick={() => toggleDate(group.date)}
>
<span className="shrink-0 text-[var(--color-text-muted)] transition-transform">
{isCollapsed ? CHEVRON_RIGHT : CHEVRON_DOWN}
</span>
<div className="min-w-0 flex-1">
<h4 className="text-lg font-semibold capitalize">
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
weekday: "long",
}) || "Без даты"}
</h4>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
{statusCounts.map(({ status, label, tone, count }) => (
<Badge key={status} tone={tone}>{count} {label}</Badge>
))}
</div>
</Button>
{!isCollapsed && (
<div className="space-y-4 pt-1">
{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} {pluralGroups(items.length)}</span>
</div>
{/* Table layout — № | Фамилия | Телефон | Город | Часть дня */}
<div className="overflow-x-auto">
<div className="min-w-[640px]">
<div className="grid grid-cols-[minmax(90px,1fr)_minmax(120px,1.5fr)_minmax(130px,1.2fr)_minmax(100px,1fr)_minmax(120px,1fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.1em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium"> счёта</div>
<div className="px-3 py-1.5 font-medium">Клиент</div>
<div className="px-3 py-1.5 font-medium">Телефон</div>
<div className="px-3 py-1.5 font-medium">Город</div>
<div className="px-3 py-1.5 font-medium">Часть дня</div>
</div>
{items.map((item) => {
const hasProblem = item.hasDeliveryProblem || item.has_delivery_problem;
const phoneTel = normalizePhoneForTel(item.customerPhone);
const halfDayLabel = getOrderGroupDeliveryHalfDay(item);
const city = normalizeCity(item.deliveryAddress || item.delivery_address);
const orderNum = (Array.isArray(item.orderNumbers) && item.orderNumbers.length > 0)
? item.orderNumbers.join(", ")
: (item.groupKey || "—");
return (
<button
key={item.id}
type="button"
className={`grid grid-cols-[minmax(90px,1fr)_minmax(120px,1.5fr)_minmax(130px,1.2fr)_minmax(100px,1fr)_minmax(120px,1fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${hasProblem ? "bg-[rgba(239,68,68,0.04)]" : ""}`}
onClick={() => onOpenOrder?.(item.id)}
>
<div className="px-3 py-2 text-xs">
<span className="font-semibold text-[var(--color-accent)]">{orderNum}</span>
{hasProblem && <span className="ml-1 text-[10px] font-bold text-[var(--color-danger)]"></span>}
</div>
<div className="px-3 py-2 text-xs font-medium text-[var(--color-text)]">
{item.customerName || item.displayTitle || item.groupKey}
</div>
<div className="px-3 py-2 text-xs">
{item.customerPhone && phoneTel ? (
<a
href={`tel:${phoneTel}`}
onClick={(e) => e.stopPropagation()}
className="text-[var(--color-accent)] hover:underline"
>
{item.customerPhone}
</a>
) : (
<span className="text-[var(--color-text-muted)]">{item.customerPhone || "—"}</span>
)}
</div>
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{city}
</div>
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{halfDayLabel || "—"}
</div>
</button>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</Panel>
);
})
) : (
<Panel className="p-6">
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
Сейчас у вас нет назначенных групп доставки.
</p>
</Panel>
)}
</div>
);
};