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 { supabase } from "../../supabaseClient";
// Russian labels for status values
// Status labels
const STATUS_LABELS = {
pending_confirmation: "Ожидает подтверждения",
pending_confirmation: "Ожидает согласования",
first_sms_sent: "1-е SMS отправлено",
second_sms_sent: "2-е SMS отправлено",
second_sms_sending: "Отправка 2-го SMS",
@ -29,31 +29,57 @@ const STATUS_LABELS = {
null: "—",
};
// Fields to show in changes, with Russian labels
// Field labels for changes
const FIELD_LABELS = {
delivery_status: "Статус доставки",
notification_status: "Статус уведомления",
first_sms_sent_at: "1-е SMS отправлено",
second_sms_sent_at: "2-е SMS отправлено",
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: "Комментарий к звонку",
};
// 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)
const SKIP_FIELDS = new Set([
"status", // duplicates delivery_status or notification_status
"group_key", // internal
"delivery_link", // long URL, not useful in history
"delivery_link_code", // internal
"delivery_invitation_id", // internal ID
"next_notification_check_at", // internal scheduling
"sms_attempts", // counter, noisy
"last_sms_error", // shown in SMS log instead
"updated_at", // meta
"can_launch_invitation", // internal flag
"status",
"group_key",
"delivery_link",
"delivery_link_code",
"delivery_invitation_id",
"next_notification_check_at",
"sms_attempts",
"last_sms_error",
"updated_at",
"can_launch_invitation",
"source",
]);
const ACTION_LABELS = {
@ -114,10 +140,23 @@ const formatChanges = (changes) => {
return lines;
};
// Progress Stepper
// Visual horizontal stepper showing where the client is in the journey.
// Stages: Добавлен SMS Согласовано Водитель (delivery only) Доставлен
// Get specific label for field_change action based on what actually changed
const getFieldChangeLabel = (changes) => {
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 = [
{ key: "created", icon: "📥", label: "Добавлен" },
{ key: "sms", icon: "📨", label: "SMS" },
@ -140,34 +179,28 @@ const getActiveStageIndex = (order) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
// Terminal states
if (deliveryStatus === "cancelled") return -1; // special: show all grey
if (deliveryStatus === "problem") return -2; // special: show problem indicator
if (deliveryStatus === "cancelled") return -1;
if (deliveryStatus === "problem") return -2;
if (deliveryStatus === "paid_storage") return -3;
// Final delivery stage
if (isPickup) {
if (deliveryStatus === "picked_up") return stages.length - 1;
} else {
if (deliveryStatus === "delivered") return stages.length - 1;
}
// Driver assigned (delivery only)
if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) {
return 3; // driver stage
return 3;
}
// Agreed
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
return 2;
}
// SMS sent
if (firstSms || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
return 1;
}
// Just created
return 0;
};
@ -176,7 +209,6 @@ const ProgressStepper = ({ order }) => {
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
const activeIdx = getActiveStageIndex(order);
// Special states
if (activeIdx === -1) {
return (
<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) => {
const isDone = idx < activeIdx;
const isActive = idx === activeIdx;
const isFuture = idx > activeIdx;
return (
<React.Fragment key={stage.key}>
{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 }) => {
const [history, setHistory] = useState(null);
const [smsLogs, setSmsLogs] = useState([]);
@ -295,11 +427,19 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
if (history && history.length > 0) {
for (const h of history) {
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({
id: `hist-${h.id}`,
created_at: h.created_at,
type: "history",
label: ACTION_LABELS[h.action] ?? h.action,
label,
changes,
});
}
@ -337,11 +477,12 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
}, [history, smsLogs, invitation]);
if (!history && smsLogs.length === 0 && !invitation) return null;
if (allEvents.length === 0) return null;
return (
<div className="space-y-4">
<ProgressStepper order={order} />
<StatusSummary order={order} />
{allEvents.length > 0 && (
<ol className="relative space-y-3 pl-4">
{allEvents.map((ev, idx) => (
<li key={ev.id ?? idx} className="relative border-l border-[var(--color-border)] pl-4">
@ -385,6 +526,7 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
</li>
))}
</ol>
)}
</div>
);
};