From dac84505866d1814961cdc26948e6a2ba3899f8b Mon Sep 17 00:00:00 2001 From: root Date: Wed, 27 May 2026 15:12:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20action=5Flogs=20=E2=80=94=20=D0=B6?= =?UTF-8?q?=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B8=D0=B9=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2=20=D1=81=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/ActionLogPanel.jsx | 263 ++++++++++++++++++ src/pages/DashboardPage.jsx | 4 + src/services/supabase/actionLogService.js | 66 +++++ src/services/supabase/orderGroupRepository.js | 14 +- .../confirm-delivery-choice/index.ts | 15 +- 5 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/components/admin/ActionLogPanel.jsx create mode 100644 src/services/supabase/actionLogService.js diff --git a/src/components/admin/ActionLogPanel.jsx b/src/components/admin/ActionLogPanel.jsx new file mode 100644 index 0000000..0cc00a6 --- /dev/null +++ b/src/components/admin/ActionLogPanel.jsx @@ -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 ( + +
+
+

Журнал действий

+

+ Кто, что и когда делал с доставками +

+
+ +
+ + {/* Filters */} +
+ 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]" + /> + + 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="С" + /> + 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="По" + /> +
+ + {error && ( +
+ {error} +
+ )} + + {/* Table */} +
+ + + + + + + + + {!orderGroupId && } + + + + {filteredLogs.length === 0 && !loading && ( + + + + )} + {filteredLogs.map((log) => ( + + setExpandedId(expandedId === log.id ? null : log.id)} + > + + + + + + {!orderGroupId && ( + + )} + + {expandedId === log.id && (log.details || log.old_value?.length > 40 || log.new_value?.length > 40) && ( + + + + )} + + ))} + +
Дата/ВремяСотрудникДействиеБылоСталоГруппа
+ Нет записей +
{formatMSKCorrect(log.performed_at)} + {log.performer_name || "Система"} + {log.performer_role && ( + ({ROLE_LABELS[log.performer_role] || log.performer_role}) + )} + + + {getActionLabel(log.action)} + + {log.old_value || "—"}{log.new_value || "—"} + {log.order_group_id?.slice(0, 8)}... +
+
+ {log.old_value?.length > 40 && ( +
Было: {log.old_value}
+ )} + {log.new_value?.length > 40 && ( +
Стало: {log.new_value}
+ )} + {log.details && ( +
Детали: {JSON.stringify(log.details)}
+ )} +
+
+
+ + {loading &&
Загрузка...
} +
+ ); +}; + diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index a5cb88d..3e55cde 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -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
; if (activeSection === "stop_words") return
; if (activeSection === "errors") return
; + if (activeSection === "action_log") return
; if (userRole === "driver") { return ( diff --git a/src/services/supabase/actionLogService.js b/src/services/supabase/actionLogService.js new file mode 100644 index 0000000..3e9b897 --- /dev/null +++ b/src/services/supabase/actionLogService.js @@ -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 || []; + }, "Ошибка загрузки журнала действий"); +}; + diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index a646b26..3a1b472 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -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); }, "Ошибка загрузки групп доставки"); -}; +}; \ No newline at end of file diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index 5cfa666..f105c99 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -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, ); } -}); +}); \ No newline at end of file