supersam/src/components/driver/DriverDeliveryPlanner.jsx

433 lines
17 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,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Input } from "../UI/Input";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel";
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"];
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 }) => {
const [filters, setFilters] = React.useState({
selectedDate: "",
deliveryStatus: "all",
selectedCity: "",
});
const [collapsedDates, setCollapsedDates] = React.useState({});
const toggleDate = (date) => {
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] }));
};
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 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;
});
}
return result;
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]);
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]);
return (
<div className="space-y-4">
<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>
{/* Date pills */}
{sortedDeliveryDates.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
className={[
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
!filters.selectedDate
? "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>
{sortedDeliveryDates.map((date) => {
const count = dateDeliveryMap.get(date) || 0;
const selected = isDateSelected(date);
return (
<button
key={date}
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
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>{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"
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) => {
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 = ["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">
<button
type="button"
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>
<div className="grid gap-3">
{items.map((item) => (
<button
key={item.id}
type="button"
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
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>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
{item.customerDate} · {item.customerPhone}
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
</div>
</div>
</div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
</div>
</button>
))}
</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>
);
};