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)}
+ >
+ | {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 || "—"} |
+ {!orderGroupId && (
+
+ {log.order_group_id?.slice(0, 8)}...
+ |
+ )}
+
+ {expandedId === log.id && (log.details || log.old_value?.length > 40 || log.new_value?.length > 40) && (
+
+
+
+ {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