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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue