feat(orders): exhaustive timeline + status summary + specific field_change labels

This commit is contained in:
root 2026-08-06 07:48:40 +00:00
parent b1a09ae71a
commit db24e8929c
1 changed files with 213 additions and 71 deletions

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState, useMemo } from "react"; import React, { useEffect, useState, useMemo } from "react";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
// Russian labels for status values // Status labels
const STATUS_LABELS = { const STATUS_LABELS = {
pending_confirmation: "Ожидает подтверждения", pending_confirmation: "Ожидает согласования",
first_sms_sent: "1-е SMS отправлено", first_sms_sent: "1-е SMS отправлено",
second_sms_sent: "2-е SMS отправлено", second_sms_sent: "2-е SMS отправлено",
second_sms_sending: "Отправка 2-го SMS", second_sms_sending: "Отправка 2-го SMS",
@ -29,31 +29,57 @@ const STATUS_LABELS = {
null: "—", null: "—",
}; };
// Fields to show in changes, with Russian labels // Field labels for changes
const FIELD_LABELS = { const FIELD_LABELS = {
delivery_status: "Статус доставки", delivery_status: "Статус доставки",
notification_status: "Статус уведомления", notification_status: "Статус уведомления",
first_sms_sent_at: "1-е SMS отправлено", first_sms_sent_at: "Время 1-го SMS",
second_sms_sent_at: "2-е SMS отправлено", second_sms_sent_at: "Время 2-го SMS",
manual_confirmation_at: "Ручное согласование", manual_confirmation_at: "Ручное согласование",
paid_storage_at: "Платное хранение", paid_storage_at: "Платное хранение",
has_delivery_problem: "Проблема доставки", has_delivery_problem: "Проблема доставки",
delivery_problem_note: "Описание проблемы", delivery_problem_note: "Описание проблемы",
assigned_driver_id: "Водитель", assigned_driver_id: "Водитель",
delivery_date: "Дата доставки",
delivery_time: "Время доставки",
delivery_address: "Адрес доставки",
called: "Статус звонка",
called_at: "Время звонка",
called_comment: "Комментарий к звонку",
};
// Specific human-readable labels for field_change entries
const FIELD_CHANGE_LABELS = {
first_sms_sent_at: "Обновлено время отправки 1-го SMS",
second_sms_sent_at: "Обновлено время отправки 2-го SMS",
manual_confirmation_at: "Ручное согласование",
paid_storage_at: "Платное хранение",
has_delivery_problem: "Отметка проблемы доставки",
delivery_problem_note: "Описание проблемы",
assigned_driver_id: "Назначение водителя",
delivery_date: "Дата доставки",
delivery_time: "Время доставки",
delivery_address: "Адрес доставки",
called: "Статус звонка",
called_at: "Время звонка",
called_comment: "Комментарий к звонку",
delivery_status: "Статус доставки",
notification_status: "Статус уведомления",
}; };
// Fields to SKIP (redundant or noisy) // Fields to SKIP (redundant or noisy)
const SKIP_FIELDS = new Set([ const SKIP_FIELDS = new Set([
"status", // duplicates delivery_status or notification_status "status",
"group_key", // internal "group_key",
"delivery_link", // long URL, not useful in history "delivery_link",
"delivery_link_code", // internal "delivery_link_code",
"delivery_invitation_id", // internal ID "delivery_invitation_id",
"next_notification_check_at", // internal scheduling "next_notification_check_at",
"sms_attempts", // counter, noisy "sms_attempts",
"last_sms_error", // shown in SMS log instead "last_sms_error",
"updated_at", // meta "updated_at",
"can_launch_invitation", // internal flag "can_launch_invitation",
"source",
]); ]);
const ACTION_LABELS = { const ACTION_LABELS = {
@ -114,10 +140,23 @@ const formatChanges = (changes) => {
return lines; return lines;
}; };
// Progress Stepper // Get specific label for field_change action based on what actually changed
// Visual horizontal stepper showing where the client is in the journey. const getFieldChangeLabel = (changes) => {
// Stages: Добавлен SMS Согласовано Водитель (delivery only) Доставлен if (!changes || typeof changes !== "object") return null;
const keys = Object.keys(changes).filter((k) => !SKIP_FIELDS.has(k));
if (keys.length === 1 && FIELD_CHANGE_LABELS[keys[0]]) {
return FIELD_CHANGE_LABELS[keys[0]];
}
if (keys.length === 1) {
return `Изменение: ${translateField(keys[0])}`;
}
if (keys.length > 1) {
return `Изменено полей: ${keys.length}`;
}
return null;
};
// Progress Stepper
const STAGES_DELIVERY = [ const STAGES_DELIVERY = [
{ key: "created", icon: "📥", label: "Добавлен" }, { key: "created", icon: "📥", label: "Добавлен" },
{ key: "sms", icon: "📨", label: "SMS" }, { key: "sms", icon: "📨", label: "SMS" },
@ -140,34 +179,28 @@ const getActiveStageIndex = (order) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup"; const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY; const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
// Terminal states if (deliveryStatus === "cancelled") return -1;
if (deliveryStatus === "cancelled") return -1; // special: show all grey if (deliveryStatus === "problem") return -2;
if (deliveryStatus === "problem") return -2; // special: show problem indicator
if (deliveryStatus === "paid_storage") return -3; if (deliveryStatus === "paid_storage") return -3;
// Final delivery stage
if (isPickup) { if (isPickup) {
if (deliveryStatus === "picked_up") return stages.length - 1; if (deliveryStatus === "picked_up") return stages.length - 1;
} else { } else {
if (deliveryStatus === "delivered") return stages.length - 1; if (deliveryStatus === "delivered") return stages.length - 1;
} }
// Driver assigned (delivery only)
if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) { if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) {
return 3; // driver stage return 3;
} }
// Agreed
if (deliveryStatus === "agreed" || notifStatus === "confirmed") { if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
return 2; return 2;
} }
// SMS sent
if (firstSms || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) { if (firstSms || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
return 1; return 1;
} }
// Just created
return 0; return 0;
}; };
@ -176,7 +209,6 @@ const ProgressStepper = ({ order }) => {
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY; const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
const activeIdx = getActiveStageIndex(order); const activeIdx = getActiveStageIndex(order);
// Special states
if (activeIdx === -1) { if (activeIdx === -1) {
return ( return (
<div className="flex items-center gap-2 rounded-xl bg-[rgba(239,68,68,0.08)] px-3 py-2"> <div className="flex items-center gap-2 rounded-xl bg-[rgba(239,68,68,0.08)] px-3 py-2">
@ -207,7 +239,6 @@ const ProgressStepper = ({ order }) => {
{stages.map((stage, idx) => { {stages.map((stage, idx) => {
const isDone = idx < activeIdx; const isDone = idx < activeIdx;
const isActive = idx === activeIdx; const isActive = idx === activeIdx;
const isFuture = idx > activeIdx;
return ( return (
<React.Fragment key={stage.key}> <React.Fragment key={stage.key}>
{idx > 0 && ( {idx > 0 && (
@ -242,6 +273,107 @@ const ProgressStepper = ({ order }) => {
); );
}; };
// Current Status Summary (compact card above timeline)
const StatusSummary = ({ order }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
const notifStatus = order?.notificationStatus || order?.notification_status || "";
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
const secondSms = order?.secondSmsSentAt || order?.second_sms_sent_at;
const called = order?.called;
const calledAt = order?.calledAt || order?.called_at;
const calledComment = order?.calledComment || order?.called_comment;
const invitationOpened = order?.invitationOpenedAt || order?.invitationOpenedAt;
const invitationAccessCount = order?.invitationAccessCount || order?.invitationAccessCount;
const manualConfirmationAt = order?.manualConfirmationAt || order?.manual_confirmation_at;
const paidStorageAt = order?.paidStorageAt || order?.paid_storage_at;
const driverName = order?.assignedDriverName || order?.assignedDriverName;
const items = [];
// SMS status
if (firstSms) {
items.push({ icon: "📨", label: "1-е SMS", value: fmtTimestamp(firstSms), tone: "ok" });
} else {
items.push({ icon: "📨", label: "1-е SMS", value: "не отправлено", tone: "muted" });
}
if (secondSms) {
items.push({ icon: "📨", label: "2-е SMS", value: fmtTimestamp(secondSms), tone: "ok" });
}
// Invitation access
if (invitationOpened || invitationAccessCount > 0) {
const count = invitationAccessCount || 1;
items.push({
icon: "👁",
label: "Страница согласования",
value: `открыл ${count} раз${(order?.invitationLastAccessedAt || order?.invitationLastAccessedAt) ? `, последний: ${fmtTimestamp(order.invitationLastAccessedAt || order.invitationLastAccessedAt)}` : ""}`,
tone: "ok",
});
} else if (firstSms) {
items.push({ icon: "👁", label: "Страница согласования", value: "не открывал", tone: "warning" });
}
// Agreement
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
items.push({ icon: "✅", label: "Согласовано", value: "да", tone: "ok" });
} else if (manualConfirmationAt) {
items.push({ icon: "✋", label: "Ручное согласование", value: fmtTimestamp(manualConfirmationAt), tone: "warning" });
} else if (firstSms) {
items.push({ icon: "⏳", label: "Согласование", value: "ждёт ответа клиента", tone: "warning" });
}
// Call status
if (called) {
const parts = [fmtTimestamp(calledAt)];
if (calledComment) parts.push(`«${calledComment}»`);
items.push({ icon: "📞", label: "Звонок", value: parts.join(" · "), tone: "ok" });
} else {
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
}
// Driver (delivery only)
if (!isPickup) {
if (driverName) {
items.push({ icon: "🚚", label: "Водитель", value: driverName, tone: "ok" });
} else if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
items.push({ icon: "🚚", label: "Водитель", value: "не назначен", tone: "warning" });
}
}
// Paid storage
if (paidStorageAt) {
items.push({ icon: "📦", label: "Платное хранение", value: fmtTimestamp(paidStorageAt), tone: "warning" });
}
// Problem
if (deliveryStatus === "problem") {
items.push({ icon: "⚠️", label: "Проблема", value: order?.deliveryProblemNote || order?.delivery_problem_note || "есть", tone: "danger" });
}
if (items.length === 0) return null;
return (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{items.map((item, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg bg-[var(--color-surface)] px-3 py-1.5">
<span className="text-sm flex-shrink-0">{item.icon}</span>
<span className="text-xs text-[var(--color-text-muted)] flex-shrink-0">{item.label}:</span>
<span className={`text-xs font-medium truncate ${
item.tone === "ok" ? "text-[var(--color-accent)]"
: item.tone === "warning" ? "text-[var(--color-warning)]"
: item.tone === "danger" ? "text-[var(--color-danger)]"
: "text-[var(--color-text-muted)]"
}`}>
{item.value}
</span>
</div>
))}
</div>
);
};
// Main component
export const OrderHistoryTimeline = ({ order, userRole }) => { export const OrderHistoryTimeline = ({ order, userRole }) => {
const [history, setHistory] = useState(null); const [history, setHistory] = useState(null);
const [smsLogs, setSmsLogs] = useState([]); const [smsLogs, setSmsLogs] = useState([]);
@ -295,11 +427,19 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
if (history && history.length > 0) { if (history && history.length > 0) {
for (const h of history) { for (const h of history) {
const changes = formatChanges(h.metadata?.changes); const changes = formatChanges(h.metadata?.changes);
// For field_change, generate a specific label instead of generic "Изменение данных заказа"
let label = ACTION_LABELS[h.action] ?? h.action;
if (h.action === "field_change") {
const specificLabel = getFieldChangeLabel(h.metadata?.changes);
if (specificLabel) label = specificLabel;
}
events.push({ events.push({
id: `hist-${h.id}`, id: `hist-${h.id}`,
created_at: h.created_at, created_at: h.created_at,
type: "history", type: "history",
label: ACTION_LABELS[h.action] ?? h.action, label,
changes, changes,
}); });
} }
@ -337,54 +477,56 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
}, [history, smsLogs, invitation]); }, [history, smsLogs, invitation]);
if (!history && smsLogs.length === 0 && !invitation) return null; if (!history && smsLogs.length === 0 && !invitation) return null;
if (allEvents.length === 0) return null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<ProgressStepper order={order} /> <ProgressStepper order={order} />
<ol className="relative space-y-3 pl-4"> <StatusSummary order={order} />
{allEvents.map((ev, idx) => ( {allEvents.length > 0 && (
<li key={ev.id ?? idx} className="relative border-l border-[var(--color-border)] pl-4"> <ol className="relative space-y-3 pl-4">
<span className="absolute -left-[5px] top-1 h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" aria-hidden /> {allEvents.map((ev, idx) => (
<p className="text-xs text-[var(--color-text-muted)]">{fmtTimestamp(ev.created_at)}</p> <li key={ev.id ?? idx} className="relative border-l border-[var(--color-border)] pl-4">
<p className="mt-0.5 text-sm font-medium text-[var(--color-text)]">{ev.label}</p> <span className="absolute -left-[5px] top-1 h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" aria-hidden />
<p className="text-xs text-[var(--color-text-muted)]">{fmtTimestamp(ev.created_at)}</p>
<p className="mt-0.5 text-sm font-medium text-[var(--color-text)]">{ev.label}</p>
{ev.type === "history" && ev.changes.length > 0 && ( {ev.type === "history" && ev.changes.length > 0 && (
<div className="mt-1 space-y-0.5"> <div className="mt-1 space-y-0.5">
{ev.changes.map((c, i) => ( {ev.changes.map((c, i) => (
<p key={i} className="text-sm text-[var(--color-text-muted)]"> <p key={i} className="text-sm text-[var(--color-text-muted)]">
{c.field}: <span className="font-medium">{c.oldVal}</span> {c.field}: <span className="font-medium">{c.oldVal}</span>
{" → "} {" → "}
<span className="font-medium text-[var(--color-text)]">{c.newVal}</span> <span className="font-medium text-[var(--color-text)]">{c.newVal}</span>
</p> </p>
))} ))}
</div> </div>
)} )}
{ev.type === "sms" && ( {ev.type === "sms" && (
<> <>
<p className="mt-0.5 text-sm"> <p className="mt-0.5 text-sm">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
ev.smsStatus === "доставлено" ev.smsStatus === "доставлено"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: ev.smsStatus === "ошибка" : ev.smsStatus === "ошибка"
? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]" ? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]" : "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
}`}> }`}>
{ev.smsStatus} {ev.smsStatus}
</span> </span>
</p> </p>
{ev.smsText && <p className="mt-0.5 text-xs text-[var(--color-text-muted)]">«{ev.smsText}»</p>} {ev.smsText && <p className="mt-0.5 text-xs text-[var(--color-text-muted)]">«{ev.smsText}»</p>}
{ev.smsError && <p className="mt-0.5 text-xs text-[var(--color-danger)]">Ошибка: {ev.smsError}</p>} {ev.smsError && <p className="mt-0.5 text-xs text-[var(--color-danger)]">Ошибка: {ev.smsError}</p>}
</> </>
)} )}
{ev.type === "invitation" && ev.accessCount != null && ev.accessCount > 0 && ( {ev.type === "invitation" && ev.accessCount != null && ev.accessCount > 0 && (
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">Открытий страницы: {ev.accessCount}</p> <p className="mt-0.5 text-xs text-[var(--color-text-muted)]">Открытий страницы: {ev.accessCount}</p>
)} )}
</li> </li>
))} ))}
</ol> </ol>
)}
</div> </div>
); );
}; };