fix: 8 bugfixes — current undefined, auth email, sequential statuses, problem dialog, action log details, KPI layout, date sort, driver lock

This commit is contained in:
root 2026-05-28 10:03:19 +00:00
parent 2ee437e83e
commit 805ceca152
9 changed files with 294 additions and 103 deletions

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
const ACTIONS = [
@ -78,6 +79,7 @@ const ROLE_LABELS = {
export const ActionLogPanel = ({ orderGroupId = null }) => {
const { user } = useAuth();
const navigate = useNavigate();
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@ -194,15 +196,16 @@ export const ActionLogPanel = ({ orderGroupId = null }) => {
<th className="pb-2 pr-3 font-medium">Дата/Время</th>
<th className="pb-2 pr-3 font-medium">Сотрудник</th>
<th className="pb-2 pr-3 font-medium">Действие</th>
<th className="pb-2 pr-3 font-medium">Действие</th>
<th className="pb-2 pr-3 font-medium">Было</th>
<th className="pb-2 pr-3 font-medium">Стало</th>
{!orderGroupId && <th className="pb-2 pr-3 font-medium">Группа</th>}
{!orderGroupId && <th className="pb-2 pr-3 font-medium">Группа доставки</th>}
</tr>
</thead>
<tbody>
{filteredLogs.length === 0 && !loading && (
<tr>
<td colSpan={orderGroupId ? 5 : 6} className="py-6 text-center text-[var(--color-text-muted)]">
<td colSpan={orderGroupId ? 6 : 7} className="py-6 text-center text-[var(--color-text-muted)]">
Нет записей
</td>
</tr>
@ -224,27 +227,57 @@ export const ActionLogPanel = ({ orderGroupId = null }) => {
<Badge tone={ACTION_TONES[log.action] || "accent"}>
{getActionLabel(log.action)}
</Badge>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
{log.action === "status_change" && (log.new_value || "")}
{log.action === "driver_assigned" && "→ " + (log.new_value || "водитель")}
{log.action === "driver_removed" && "← " + (log.old_value || "водитель")}
{log.action === "date_assigned" && (log.new_value || "")}
{log.action === "paid_storage" && (log.new_value || "")}
</div>
</td>
<td className="py-2 pr-3 max-w-[150px] truncate">{log.old_value || "—"}</td>
<td className="py-2 pr-3 max-w-[150px] truncate">{log.new_value || "—"}</td>
{!orderGroupId && (
<td className="py-2 pr-3 text-xs font-mono text-[var(--color-text-muted)]">
{log.order_group_id?.slice(0, 8)}...
<td className="py-2 pr-3 text-sm">
{log.order_group_id ? (
<a
href={`/dashboard/group/${log.order_group_id}`}
className="text-[var(--color-accent)] hover:underline font-medium"
onClick={(e) => { e.preventDefault(); navigate(`/dashboard/group/${log.order_group_id}`); }}
>
Группа
</a>
) : "—"}
</td>
)}
</tr>
{expandedId === log.id && (log.details || log.old_value?.length > 40 || log.new_value?.length > 40) && (
<tr className="bg-[var(--color-surface-strong)]">
<td colSpan={orderGroupId ? 5 : 6} className="py-2 px-3">
<td colSpan={orderGroupId ? 6 : 7} className="py-2 px-3">
<div className="space-y-1 text-xs">
{log.old_value?.length > 40 && (
{log.old_value && (
<div><span className="text-[var(--color-text-muted)]">Было:</span> {log.old_value}</div>
)}
{log.new_value?.length > 40 && (
{log.new_value && (
<div><span className="text-[var(--color-text-muted)]">Стало:</span> {log.new_value}</div>
)}
{log.details && (
<div><span className="text-[var(--color-text-muted)]">Детали:</span> {JSON.stringify(log.details)}</div>
{log.details && typeof log.details === "object" && (
<div className="space-y-0.5">
{Object.entries(log.details).map(([k, v]) => (
<div key={k}><span className="text-[var(--color-text-muted)]">{k}:</span> {String(v)}</div>
))}
</div>
)}
{log.details && typeof log.details === "string" && (
<div><span className="text-[var(--color-text-muted)]">Детали:</span> {log.details}</div>
)}
{log.order_group_id && (
<div><span className="text-[var(--color-text-muted)]">Группа:</span>{" "}
<a href={`/dashboard/group/${log.order_group_id}`}
className="text-[var(--color-accent)] hover:underline"
onClick={(e) => { e.preventDefault(); navigate(`/dashboard/group/${log.order_group_id}`); }}
>Перейти к группе</a>
</div>
)}
</div>
</td>

View File

@ -141,7 +141,7 @@ export const AdminDashboard = () => {
</div>
{/* KPI — centered on mobile */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 1fr))`, gap: '0.4rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}>
{[
{ label: 'Всего', val: totalGroups },
{ label: 'Ожидает', val: sv.pending },
@ -152,7 +152,7 @@ export const AdminDashboard = () => {
].map((kpi, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: 'var(--color-text)' }}>{kpi.val ?? '—'}</div>
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>{kpi.val ?? '—'}</div>
</Panel>
))}
</div>

View File

@ -5,9 +5,9 @@ export const KpiCard = ({ label, value, hint }) => {
return (
<Panel className="p-5">
<p className="text-sm text-[var(--color-text-muted)]">{label}</p>
<div className="mt-4 flex items-end justify-between gap-4">
<div className="mt-3">
<span className="text-3xl font-semibold">{value}</span>
<span className="text-xs text-[var(--color-text-muted)]">{hint}</span>
{hint && <p className="mt-1 text-xs text-[var(--color-text-muted)]">{hint}</p>}
</div>
</Panel>
);

View File

@ -1,10 +1,42 @@
import React from "react";
import React, { useState } from "react";
import { getAvailableTransitionsByRole, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../../services/driverDeliveries";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
const PROBLEM_REASONS = [
{ value: "client_absent", label: "Клиент не принял", description: "Клиент отказался или не вышел на связь" },
{ value: "damage", label: "Повреждение заказа", description: "Товар повреждён при транспортировке" },
{ value: "wrong_address", label: "Неверный адрес", description: "Адрес доставки указан неверно" },
{ value: "other", label: "Другое", description: "Иная причина проблемы доставки" },
];
const ProblemReasonModal = ({ onSelect, onCancel }) => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
<Panel className="mx-4 w-full max-w-md space-y-4 p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold">Причина проблемы</h3>
<p className="text-sm text-[var(--color-text-muted)]">Укажите причину возникшей проблемы с доставкой.</p>
<div className="space-y-2">
{PROBLEM_REASONS.map((reason) => (
<button
key={reason.value}
type="button"
className="w-full rounded-[16px] border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]"
onClick={() => onSelect(reason.value, reason.label)}
>
<span className="font-medium">{reason.label}</span>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">{reason.description}</p>
</button>
))}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onCancel}>Отмена</Button>
</div>
</Panel>
</div>
);
const splitItem = (item) => {
if (!item) {
return { name: "Позиция", quantity: "" };
@ -29,6 +61,8 @@ const splitItem = (item) => {
};
export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
const [showProblemModal, setShowProblemModal] = useState(false);
if (!order) {
return null;
}
@ -39,8 +73,42 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
});
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
const currentStatus = order.status;
const IN_TRANSIT_STATUSES = ["Загружен", "В пути"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let actionButtons = [];
if (currentStatus === "Назначен водитель") {
actionButtons = [
{ value: "Загружен", label: "Загружено" },
{ value: "Проблема доставки", label: "Проблема" },
];
} else if (isOnRoute) {
actionButtons = [
{ value: "Доставлен", label: "Доставлено" },
{ value: "Проблема доставки", label: "Проблема" },
];
} else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
actionButtons = [];
} else {
actionButtons = availableTransitions.map((status) => ({
value: status,
label: status === "Проблема доставки" ? "Проблема" : status,
}));
}
return (
<div className="space-y-4">
{showProblemModal && (
<ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => {
setShowProblemModal(false);
onStatusChange?.("Проблема доставки", { reason: reasonValue, reasonLabel });
}}
onCancel={() => setShowProblemModal(false)}
/>
)}
<Panel className="space-y-5 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
@ -110,22 +178,28 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
</div>
</Panel>
{availableTransitions.length ? (
{actionButtons.length > 0 && (
<Panel className="space-y-4 p-6">
<h3 className="text-lg font-semibold">Быстрые действия</h3>
<div className="flex flex-wrap gap-2">
{availableTransitions.map((status) => (
{actionButtons.map((btn) => (
<Button
key={status}
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
onClick={() => onStatusChange?.(status)}
key={btn.value}
variant={btn.value === "Проблема доставки" ? "ghost" : "secondary"}
onClick={() => {
if (btn.value === "Проблема доставки") {
setShowProblemModal(true);
return;
}
onStatusChange?.(btn.value);
}}
>
{status}
{btn.label}
</Button>
))}
</div>
</Panel>
) : null}
)}
</div>
);
};

View File

@ -398,6 +398,39 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoic
);
};
const PROBLEM_REASONS = [
{ value: "client_absent", label: "Клиент не принял", description: "Клиент отказался или не вышел на связь" },
{ value: "damage", label: "Повреждение заказа", description: "Товар повреждён при транспортировке" },
{ value: "wrong_address", label: "Неверный адрес", description: "Адрес доставки указан неверно" },
{ value: "other", label: "Другое", description: "Иная причина проблемы доставки" },
];
const ProblemReasonModal = ({ onSelect, onCancel }) => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
<Panel className="mx-4 w-full max-w-md space-y-4 p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold">Причина проблемы</h3>
<p className="text-sm text-[var(--color-text-muted)]">Укажите причину возникшей проблемы с доставкой.</p>
<div className="space-y-2">
{PROBLEM_REASONS.map((reason) => (
<button
key={reason.value}
type="button"
className="w-full rounded-[16px] border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]"
onClick={() => onSelect(reason.value, reason.label)}
>
<span className="font-medium">{reason.label}</span>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">{reason.description}</p>
</button>
))}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onCancel}>Отмена</Button>
</div>
</Panel>
</div>
);
export const OrderDetailPanel = ({
order,
canManageDelivery = false,
@ -408,6 +441,7 @@ export const OrderDetailPanel = ({
onChangeDeliveryStatus,
userRole,
}) => {
const [problemReason, setProblemReason] = React.useState(null);
const [deliveryDate, setDeliveryDate] = React.useState("");
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const [formMessage, setFormMessage] = React.useState("");
@ -804,9 +838,15 @@ export const OrderDetailPanel = ({
<div>
<strong>Назначение водителя</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{order.assignedDriverId
? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.`
: "Выберите водителя для доставки."}
{(() => {
const ds = order.deliveryStatus || order.delivery_status;
if (["loaded", "on_route", "delivered"].includes(ds)) {
return "Доставка в процессе — сменить водителя нельзя.";
}
return order.assignedDriverId
? "Назначен водитель. Вы можете изменить назначение."
: "Выберите водителя для доставки.";
})()}
</p>
</div>
{order.assignedDriverId ? (
@ -824,6 +864,10 @@ export const OrderDetailPanel = ({
</div>
</div>
) : null}
{(() => {
const ds = order.deliveryStatus || order.delivery_status;
const isDriverLocked = ["loaded", "on_route", "delivered"].includes(ds);
return !isDriverLocked ? (
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
<Select
className="h-[46px] py-0"
@ -847,6 +891,8 @@ export const OrderDetailPanel = ({
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"}
</Button>
</div>
) : null;
})()}
{driverMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
) : null}
@ -932,20 +978,59 @@ export const OrderDetailPanel = ({
Обновите статус по мере выполнения доставки.
</p>
</div>
{problemReason !== null ? (
<ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "problem",
details: { reason: reasonValue, reasonLabel },
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("Статус обновлён: проблема — " + reasonLabel);
}
setProblemReason(null);
});
}}
onCancel={() => setProblemReason(null)}
/>
) : null}
<div className="flex flex-wrap gap-2">
{[
{(() => {
const currentStatus = order.deliveryStatus || order.delivery_status;
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let availableButtons = [];
if (currentStatus === "driver_assigned") {
availableButtons = [
{ value: "loaded", label: "Загружено" },
{ value: "on_route", label: "В пути" },
{ value: "problem", label: "Проблема" },
];
} else if (isOnRoute) {
availableButtons = [
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
].map((statusOption) => (
];
} else if (currentStatus === "delivered" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
availableButtons = [];
} else {
availableButtons = [
{ value: "loaded", label: "Загружено" },
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
];
}
return availableButtons.map((statusOption) => (
<Button
key={statusOption.value}
variant={
(order.deliveryStatus || order.delivery_status) === statusOption.value ? "primary" : "secondary"}
variant={currentStatus === statusOption.value ? "primary" : "secondary"}
onClick={() => {
if (statusOption.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) {
setFormMessage("Укажите причину для каждой неотгруженной позиции перед завершением доставки.");
if (statusOption.value === "problem") {
setProblemReason("selecting");
return;
}
onChangeDeliveryStatus({
@ -963,7 +1048,8 @@ export const OrderDetailPanel = ({
>
{statusOption.label}
</Button>
))}
));
})()}
</div>
{formMessage ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>

View File

@ -223,7 +223,7 @@ export const ORDER_STATUS_TRANSITIONS = {
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
"Назначен водитель": ["Загружен", "Проблема доставки"],
Загружен: ["В пути", "Проблема доставки"],
Загружен: ["Доставлен", "Проблема доставки"],
"В пути": ["Доставлен", "Проблема доставки"],
Доставлен: ["Закрыт"],
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
@ -248,7 +248,7 @@ export const ROLE_TRANSITION_TARGETS = {
"Закрыт",
"Отменён",
],
driver: ["Загружен", "В пути", "Доставлен", "Проблема доставки"],
driver: ["Загружен", "Доставлен", "Проблема доставки"],
admin: ORDER_STATUSES,
};
@ -267,7 +267,7 @@ export const LOGISTICS_STATUSES = [
"Проблема доставки",
];
export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "В пути", "Доставлен"];
export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "Доставлен"];
export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан.";

View File

@ -176,10 +176,10 @@ export const useOrderGroups = () => {
}
}, []);
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status }) => {
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
setIsSavingDeliveryChoice(true);
try {
const result = await updateDeliveryStatus({ orderGroupId, status });
const result = await updateDeliveryStatus({ orderGroupId, status, details });
if (result.error) {
return {
success: false,

View File

@ -366,7 +366,7 @@ export const groupOrderGroupsByDate = (groups) => {
const rightTime = parseGroupDate(rightDate)?.getTime();
if (leftTime != null && rightTime != null && leftTime !== rightTime) {
return rightTime - leftTime;
return leftTime - rightTime;
}
return leftDate.localeCompare(rightDate);

View File

@ -277,10 +277,19 @@ export const assignDriverToOrderGroup = async ({
}, "Ошибка назначения водителя");
};
export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
export const updateDeliveryStatus = async ({ orderGroupId, status, details } = {}) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
// Fetch current status before any update (needed for audit log)
const { data: current, error: fetchCurrentError } = await client
.from("order_groups")
.select("delivery_status")
.eq("id", orderGroupId)
.single();
if (fetchCurrentError) throw fetchCurrentError;
// Bypass stale RPC for paid_storage transitions
// Server-side RPC still enforces driver-assignment checks that block
// manager/logistician from moving groups into/out of paid_storage.
@ -297,17 +306,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
.eq("id", orderGroupId);
if (updateError) throw updateError;
} else {
// For cancelling paid_storage: check current status first
const { data: current, error: fetchError } = await client
.from("order_groups")
.select("delivery_status")
.eq("id", orderGroupId)
.single();
if (fetchError) throw fetchError;
if (current.delivery_status === "paid_storage" && status === "pending_confirmation") {
} else if (current.delivery_status === "paid_storage" && status === "pending_confirmation") {
const { error: updateError } = await client
.from("order_groups")
.update({
@ -327,7 +326,6 @@ export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
if (rpcError) throw rpcError;
}
}
// Fetch updated group
const { data, error } = await client
@ -343,7 +341,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
: status === "cancelled" ? "cancelled"
: "status_change";
const oldValue = current?.delivery_status || null;
await logAction({ orderGroupId, action: logActionType, oldValue, newValue: status, details: { source: "admin_panel" } }).catch(() => {});
await logAction({ orderGroupId, action: logActionType, oldValue, newValue: status, details: { source: "admin_panel", ...details } }).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка обновления статуса доставки");