feat: action_logs — журнал действий сотрудников с фильтрами

This commit is contained in:
root 2026-05-27 15:12:46 +00:00
parent 8f50a68687
commit dac8450586
5 changed files with 360 additions and 2 deletions

View File

@ -0,0 +1,263 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
import { useAuth } from "../../context/AuthContext";
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 formatMSK = (isoStr) => {
if (!isoStr) return "—";
try {
const d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const year = d.getFullYear();
const hours = String(d.getUTCHours() + 3).padStart(2, "0"); // UTC+3
const mins = String(d.getUTCMinutes()).padStart(2, "0");
const h = parseInt(hours, 10);
const adjustedHours = h >= 24 ? String(h - 24).padStart(2, "0") : hours;
return `${day}.${month}.${year} ${adjustedHours}:${mins}`;
} catch {
return isoStr;
}
};
const formatMSKCorrect = (isoStr) => {
if (!isoStr) return "—";
try {
const d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
// Format in Moscow timezone
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;
}
};
const ROLE_LABELS = {
mega_admin: "Мега-админ",
admin: "Админ",
manager: "Менеджер",
logistician: "Логист",
driver: "Водитель",
};
export const ActionLogPanel = ({ orderGroupId = null }) => {
const { user } = useAuth();
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 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)
);
}, [logs, filterSearch]);
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>
<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 ? 5 : 6} 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 max-w-[150px] truncate">{log.old_value || "—"}</td>
<td className="py-2 pr-3 max-w-[150px] truncate">{log.new_value || "—"}</td>
{!orderGroupId && (
<td className="py-2 pr-3 text-xs font-mono text-[var(--color-text-muted)]">
{log.order_group_id?.slice(0, 8)}...
</td>
)}
</tr>
{expandedId === log.id && (log.details || log.old_value?.length > 40 || log.new_value?.length > 40) && (
<tr className="bg-[var(--color-surface-strong)]">
<td colSpan={orderGroupId ? 5 : 6} className="py-2 px-3">
<div className="space-y-1 text-xs">
{log.old_value?.length > 40 && (
<div><span className="text-[var(--color-text-muted)]">Было:</span> {log.old_value}</div>
)}
{log.new_value?.length > 40 && (
<div><span className="text-[var(--color-text-muted)]">Стало:</span> {log.new_value}</div>
)}
{log.details && (
<div><span className="text-[var(--color-text-muted)]">Детали:</span> {JSON.stringify(log.details)}</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
{loading && <div className="py-4 text-center text-sm text-[var(--color-text-muted)]">Загрузка...</div>}
</Panel>
);
};

View File

@ -7,6 +7,7 @@ import { AdminDashboard } from "../components/admin/AdminDashboard";
import UserManagementPanel from "../components/admin/UserManagementPanel";
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { Panel } from "../components/UI/Panel";
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
import { useAuth } from "../context/AuthContext";
@ -22,6 +23,7 @@ const MEGA_ADMIN_NAV = [
{ key: "users", label: "Пользователи", description: "Управление пользователями и ролями.", badge: null },
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
];
const ROLE_SECTION = {
@ -94,6 +96,7 @@ export const DashboardPage = () => {
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
{ key: "users", label: "Пользователи", description: "Управление пользователями.", badge: null },
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
]
: userRole === "logistician"
? [
@ -118,6 +121,7 @@ export const DashboardPage = () => {
if (activeSection === "users") return <div className="space-y-6 xl:space-y-8"><UserManagementPanel /></div>;
if (activeSection === "stop_words") return <div className="space-y-6 xl:space-y-8"><StopWordsPanel /></div>;
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
if (userRole === "driver") {
return (

View File

@ -0,0 +1,66 @@
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
import { safeSupabaseCall } from "../safeSupabaseCall";
const requireSupabase = () => {
if (!hasSupabaseConfig || !supabase) {
throw new Error("Supabase не сконфигурирован");
}
return supabase;
};
const ACTION_LABELS = {
status_change: "Смена статуса",
driver_assigned: "Назначение водителя",
driver_removed: "Снятие водителя",
date_assigned: "Назначение даты доставки",
client_confirmed: "Подтверждение клиента",
client_cancelled: "Отмена клиентом",
cancelled: "Отмена доставки",
manual_confirmation: "Ручное подтверждение",
paid_storage: "Платное хранение",
sms_sent: "Отправка SMS",
invitation_created: "Создание приглашения",
};
export const getActionLabel = (action) => ACTION_LABELS[action] || action;
export const logAction = async ({ orderGroupId, action, oldValue = null, newValue = null, details = null }) => {
const client = requireSupabase();
return safeSupabaseCall(async () => {
const { data, error } = await client.rpc("log_action", {
p_order_group_id: orderGroupId,
p_action: action,
p_old_value: oldValue,
p_new_value: newValue,
p_details: details,
});
if (error) throw error;
return data;
}, "Ошибка записи в журнал действий");
};
export const fetchActionLogs = async ({
orderGroupId = null,
action = null,
performedBy = null,
dateFrom = null,
dateTo = null,
limit = 200,
offset = 0,
} = {}) => {
const client = requireSupabase();
return safeSupabaseCall(async () => {
const { data, error } = await client.rpc("get_action_logs", {
p_order_group_id: orderGroupId,
p_action: action,
p_performed_by: performedBy,
p_date_from: dateFrom,
p_date_to: dateTo,
p_limit: limit,
p_offset: offset,
});
if (error) throw error;
return data || [];
}, "Ошибка загрузки журнала действий");
};

View File

@ -1,4 +1,5 @@
import { safeSupabaseCall } from "../safeSupabaseCall";
import { logAction } from "./actionLogService";
import logger from "../../utils/logger";
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
import {
@ -210,6 +211,8 @@ export const updateOrderGroupDeliveryChoice = async ({
throw error;
}
await logAction({ orderGroupId, action: "date_assigned", newValue: "manual: " + deliveryDate + " " + (deliveryTime || ""), details: { delivery_date_source: "manual" } }).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка сохранения согласования доставки");
};
@ -268,6 +271,8 @@ export const assignDriverToOrderGroup = async ({
throw error;
}
await logAction({ orderGroupId, action: driverId ? "driver_assigned" : "driver_removed", newValue: driverId || "removed", details: { driver_id: driverId } }).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка назначения водителя");
};
@ -333,6 +338,13 @@ export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
if (error) throw error;
// Determine specific action type for better log readability
const logActionType = status === "paid_storage" ? "paid_storage"
: status === "cancelled" ? "cancelled"
: "status_change";
const oldValue = current?.delivery_status || null;
await logAction({ orderGroupId, action: logActionType, oldValue, newValue: status, details: { source: "admin_panel" } }).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка обновления статуса доставки");
};
@ -364,4 +376,4 @@ export const fetchOrderGroups = async () => {
return group;
}).filter(Boolean);
}, "Ошибка загрузки групп доставки");
};
};

View File

@ -192,6 +192,19 @@ Deno.serve(async (request) => {
throw groupUpdateError;
}
// Log: client confirmed delivery choice
await supabase.from("action_logs").insert({
order_group_id: invitation.order_group_id,
action: "client_confirmed",
old_value: currentGroup.delivery_status,
new_value: "agreed",
details: {
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
source: "auto",
},
});
await insertIntegrationEvent(supabase, {
order_id: null,
event_type: "delivery_choice_confirmed",
@ -344,4 +357,4 @@ Deno.serve(async (request) => {
corsHeaders,
);
}
});
});