feat: action_logs — журнал действий сотрудников с фильтрами
This commit is contained in:
parent
8f50a68687
commit
dac8450586
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
}, "Ошибка загрузки журнала действий");
|
||||
};
|
||||
|
||||
|
|
@ -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);
|
||||
}, "Ошибка обновления статуса доставки");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue