365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||
import { Panel } from "../UI/Panel";
|
||
import { Badge } from "../UI/Badge";
|
||
import { Button } from "../UI/Button";
|
||
import { Skeleton } from "../UI/Loading";
|
||
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { useAuth } from "../../context/AuthContext";
|
||
import { safeSupabaseCall } from "../../services/safeSupabaseCall";
|
||
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
||
|
||
const ACTIONS = [
|
||
"status_change",
|
||
"driver_assigned",
|
||
"driver_removed",
|
||
"date_assigned",
|
||
"client_confirmed",
|
||
"client_cancelled",
|
||
"cancelled",
|
||
"manual_confirmation",
|
||
"paid_storage",
|
||
"sms_sent",
|
||
"invitation_created",
|
||
];
|
||
|
||
const ACTION_TONES = {
|
||
status_change: "accent",
|
||
driver_assigned: "info",
|
||
driver_removed: "warning",
|
||
date_assigned: "info",
|
||
client_confirmed: "success",
|
||
client_cancelled: "danger",
|
||
cancelled: "danger",
|
||
manual_confirmation: "success",
|
||
paid_storage: "warning",
|
||
sms_sent: "accent",
|
||
invitation_created: "accent",
|
||
};
|
||
|
||
const ROLE_LABELS = {
|
||
mega_admin: "Мега-админ",
|
||
admin: "Админ",
|
||
manager: "Менеджер",
|
||
logistician: "Логист",
|
||
driver: "Водитель",
|
||
};
|
||
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
||
const formatMSKCorrect = (isoStr) => {
|
||
if (!isoStr) return "—";
|
||
try {
|
||
const d = new Date(isoStr);
|
||
if (isNaN(d.getTime())) return isoStr;
|
||
const msk = new Date(d.getTime() + 3 * 60 * 60 * 1000);
|
||
const day = String(msk.getUTCDate()).padStart(2, "0");
|
||
const month = String(msk.getUTCMonth() + 1).padStart(2, "0");
|
||
const year = msk.getUTCFullYear();
|
||
const hours = String(msk.getUTCHours()).padStart(2, "0");
|
||
const mins = String(msk.getUTCMinutes()).padStart(2, "0");
|
||
return `${day}.${month}.${year} ${hours}:${mins}`;
|
||
} catch {
|
||
return isoStr;
|
||
}
|
||
};
|
||
|
||
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);
|
||
const [filterAction, setFilterAction] = useState("");
|
||
const [filterDateFrom, setFilterDateFrom] = useState("");
|
||
const [filterDateTo, setFilterDateTo] = useState("");
|
||
const [filterSearch, setFilterSearch] = useState("");
|
||
const [expandedId, setExpandedId] = useState(null);
|
||
const [userNames, setUserNames] = useState({});
|
||
|
||
// Fetch user name map for resolving UUIDs
|
||
useEffect(() => {
|
||
const fetchNames = async () => {
|
||
if (!hasSupabaseConfig || !supabase) return;
|
||
const result = await safeSupabaseCall(
|
||
async () => {
|
||
const { data, error } = await supabase.from("users").select("id, name");
|
||
if (error) throw error;
|
||
return data;
|
||
},
|
||
"Ошибка загрузки пользователей"
|
||
);
|
||
if (result && !result.error) {
|
||
const map = {};
|
||
(Array.isArray(result) ? result : result.data || []).forEach((u) => {
|
||
map[u.id] = u.name;
|
||
});
|
||
setUserNames(map);
|
||
}
|
||
};
|
||
fetchNames();
|
||
}, []);
|
||
|
||
const resolveName = useCallback(
|
||
(uuid) => {
|
||
if (!uuid || !UUID_RE.test(uuid)) return uuid;
|
||
return userNames[uuid] || uuid;
|
||
},
|
||
[userNames]
|
||
);
|
||
|
||
const loadLogs = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const result = await fetchActionLogs({
|
||
orderGroupId,
|
||
action: filterAction || null,
|
||
dateFrom: filterDateFrom || null,
|
||
dateTo: filterDateTo || null,
|
||
limit: 500,
|
||
});
|
||
if (result.error) {
|
||
setError(result.error);
|
||
} else {
|
||
setLogs(result.data || result || []);
|
||
}
|
||
} catch (e) {
|
||
setError(e.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [orderGroupId, filterAction, filterDateFrom, filterDateTo]);
|
||
|
||
useEffect(() => {
|
||
loadLogs();
|
||
}, [loadLogs]);
|
||
|
||
const filteredLogs = useMemo(() => {
|
||
if (!filterSearch) return logs;
|
||
const q = filterSearch.toLowerCase();
|
||
return logs.filter((log) =>
|
||
(log.performer_name || "").toLowerCase().includes(q) ||
|
||
(log.action || "").toLowerCase().includes(q) ||
|
||
(getActionLabel(log.action) || "").toLowerCase().includes(q) ||
|
||
(log.old_value || "").toLowerCase().includes(q) ||
|
||
(log.new_value || "").toLowerCase().includes(q) ||
|
||
(log.order_group_id || "").toLowerCase().includes(q) ||
|
||
(getActionDescription(log) || "").toLowerCase().includes(q) ||
|
||
(log.details?.driver_name || "").toLowerCase().includes(q)
|
||
);
|
||
}, [logs, filterSearch]);
|
||
|
||
/** Human-readable description */
|
||
const getActionDescription = (log) => {
|
||
switch (log.action) {
|
||
case "status_change": {
|
||
const oldVal = resolveName(log.old_value) || "—";
|
||
const newVal = resolveName(log.new_value) || "—";
|
||
return `${oldVal} → ${newVal}`;
|
||
}
|
||
case "driver_assigned": {
|
||
const name = log.details?.driver_name || resolveName(log.new_value) || "водитель";
|
||
return `Назначен: ${name}`;
|
||
}
|
||
case "driver_removed": {
|
||
const name = log.details?.driver_name || resolveName(log.old_value) || "водитель";
|
||
return `Снят: ${name}`;
|
||
}
|
||
case "date_assigned":
|
||
return `Дата: ${log.new_value || "—"}`;
|
||
case "client_confirmed":
|
||
return "Клиент подтвердил";
|
||
case "client_cancelled":
|
||
return "Клиент отменил";
|
||
case "cancelled":
|
||
return "Отменено";
|
||
case "manual_confirmation":
|
||
return "Ручное подтверждение";
|
||
case "paid_storage":
|
||
return "Платное хранение";
|
||
case "sms_sent":
|
||
return "SMS отправлено";
|
||
case "invitation_created":
|
||
return "Приглашение создано";
|
||
default:
|
||
return log.new_value || getActionLabel(log.action);
|
||
}
|
||
};
|
||
|
||
const getActionDesc = getActionDescription;
|
||
|
||
return (
|
||
<Panel className="space-y-4 p-5">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-semibold">Журнал действий</h3>
|
||
<p className="text-sm text-[var(--color-text-muted)]">
|
||
Кто, что и когда делал с доставками
|
||
</p>
|
||
</div>
|
||
<Button
|
||
onClick={loadLogs}
|
||
disabled={loading}
|
||
className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
{loading ? "Загрузка..." : "Обновить"}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск..."
|
||
value={filterSearch}
|
||
onChange={(e) => setFilterSearch(e.target.value)}
|
||
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none focus:border-[var(--color-accent)] min-w-[160px]"
|
||
/>
|
||
<select
|
||
value={filterAction}
|
||
onChange={(e) => setFilterAction(e.target.value)}
|
||
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none"
|
||
>
|
||
<option value="">Все действия</option>
|
||
{ACTIONS.map((a) => (
|
||
<option key={a} value={a}>{getActionLabel(a)}</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
type="date"
|
||
value={filterDateFrom}
|
||
onChange={(e) => setFilterDateFrom(e.target.value)}
|
||
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none"
|
||
title="С"
|
||
/>
|
||
<input
|
||
type="date"
|
||
value={filterDateTo}
|
||
onChange={(e) => setFilterDateTo(e.target.value)}
|
||
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none"
|
||
title="По"
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="rounded-[14px] border border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-3 text-sm text-[var(--color-danger)]">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Table */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]">
|
||
<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>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredLogs.length === 0 && !loading && (
|
||
<tr>
|
||
<td colSpan={orderGroupId ? 4 : 5} className="py-6 text-center text-[var(--color-text-muted)]">
|
||
Нет записей
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{filteredLogs.map((log) => (
|
||
<React.Fragment key={log.id}>
|
||
<tr
|
||
className="border-b border-[var(--color-border)] cursor-pointer hover:bg-[var(--color-surface-strong)]"
|
||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||
>
|
||
<td className="py-2 pr-3 whitespace-nowrap">{formatMSKCorrect(log.performed_at)}</td>
|
||
<td className="py-2 pr-3">
|
||
<span className="font-medium">{log.performer_name || "Система"}</span>
|
||
{log.performer_role && (
|
||
<span className="ml-1 text-xs text-[var(--color-text-muted)]">({ROLE_LABELS[log.performer_role] || log.performer_role})</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2 pr-3">
|
||
<Badge tone={ACTION_TONES[log.action] || "accent"}>
|
||
{getActionLabel(log.action)}
|
||
</Badge>
|
||
</td>
|
||
<td className="py-2 pr-3">
|
||
<span className="text-sm">{getActionDesc(log)}</span>
|
||
</td>
|
||
{!orderGroupId && (
|
||
<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 && (() => {
|
||
const hasChange = log.old_value && log.new_value && log.old_value !== log.new_value;
|
||
const isDriverAction = log.action === "driver_assigned" || log.action === "driver_removed";
|
||
const detailEntries = (log.details && typeof log.details === "object")
|
||
? Object.entries(log.details).filter(([k]) => k !== "source" && k !== "driver_name" && k !== "driver_id")
|
||
: [];
|
||
return (
|
||
<tr className="bg-[var(--color-surface-strong)]">
|
||
<td colSpan={orderGroupId ? 4 : 5} className="py-2 px-3">
|
||
<div className="space-y-1 text-xs">
|
||
{hasChange && !isDriverAction && (
|
||
<div><span className="text-[var(--color-text-muted)]">Было:</span> {resolveName(log.old_value)} → <span className="text-[var(--color-text-muted)]">Стало:</span> {resolveName(log.new_value)}</div>
|
||
)}
|
||
{isDriverAction && log.details?.driver_name && !log.old_value && (
|
||
<div><span className="text-[var(--color-text-muted)]">Водитель:</span> {log.details.driver_name}</div>
|
||
)}
|
||
{isDriverAction && log.old_value && (
|
||
<div><span className="text-[var(--color-text-muted)]">Было:</span> {resolveName(log.old_value)} → <span className="text-[var(--color-text-muted)]">Стало:</span> {log.details?.driver_name || resolveName(log.new_value)}</div>
|
||
)}
|
||
{detailEntries.length > 0 && (
|
||
<div className="space-y-0.5">
|
||
{detailEntries.map(([k, v]) => (
|
||
<div key={k}>
|
||
<span className="text-[var(--color-text-muted)]">
|
||
{{problem_type: "Тип проблемы", delivery_date_source: "Источник даты"}[k] || k}:
|
||
</span> {UUID_RE.test(String(v)) ? resolveName(String(v)) : String(v)}
|
||
</div>
|
||
))}
|
||
</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>
|
||
</tr>
|
||
);
|
||
})()}
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{loading && !filteredLogs.length && (
|
||
<div className="space-y-2">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} className="w-full h-10" />
|
||
))}
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
);
|
||
}; |