feat(orders): exhaustive timeline + status summary + specific field_change labels
This commit is contained in:
parent
b1a09ae71a
commit
db24e8929c
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue