supersam/src/components/admin/ActionLogPanel.jsx

365 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};