-
-
-
- {isStatusOpen ? (
-
+ {/* Row 1: Status + Search */}
+
+
+
- return (
-
- );
- })}
-
- ) : null}
+ {isStatusOpen ? (
+
+ {statusOptions.map((option) => {
+ const isSelected = option.value === statusValue;
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+
+
updateFilter("query", event.target.value)}
+ />
+
+
+ {/* Row 2: Date range */}
+
+ updateFilter("dateFrom", v)}
+ placeholder="С даты"
+ label="Период: с"
+ className="min-w-[140px] flex-1 md:max-w-[200px]"
+ />
+ updateFilter("dateTo", v)}
+ placeholder="По дату"
+ label="по"
+ className="min-w-[140px] flex-1 md:max-w-[200px]"
+ />
+ {hasDateFilter && (
+
+ )}
-
updateFilter("query", event.target.value)}
- />
);
-};
+};
\ No newline at end of file
diff --git a/src/hooks/useAdminStats.js b/src/hooks/useAdminStats.js
new file mode 100644
index 0000000..3299fc1
--- /dev/null
+++ b/src/hooks/useAdminStats.js
@@ -0,0 +1,57 @@
+import { useState, useEffect, useCallback } from "react";
+
+const PERIOD_DAYS = { today: 1, "7d": 7, "30d": 30, all: 0 };
+
+const rpcCall = async (fnName, params) => {
+ const { createClient } = await import("@supabase/supabase-js");
+ const supabaseUrl = window.__SUPABASE_URL__ || import.meta.env.VITE_SUPABASE_URL;
+ const supabaseAnonKey = window.__SUPABASE_ANON_KEY__ || import.meta.env.VITE_SUPABASE_ANON_KEY;
+ const client = createClient(supabaseUrl, supabaseAnonKey);
+ const { data, error } = await client.rpc(fnName, params);
+ if (error) throw error;
+ return data;
+};
+
+export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) => {
+ const [stats, setStats] = useState(null);
+ const [statusDist, setStatusDist] = useState([]);
+ const [dailyTrend, setDailyTrend] = useState([]);
+ const [driverStats, setDriverStats] = useState([]);
+ const [economics, setEconomics] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const days = PERIOD_DAYS[period] ?? 30;
+
+ const fetchAll = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const params = {
+ p_days: days,
+ p_date_from: dateFrom || null,
+ p_date_to: dateTo || null,
+ };
+ const [s, sd, dt, ds, econ] = await Promise.all([
+ rpcCall("admin_delivery_stats", params),
+ rpcCall("admin_status_distribution", params),
+ rpcCall("admin_daily_trend", params),
+ rpcCall("admin_driver_stats", params),
+ rpcCall("admin_automation_economics", params),
+ ]);
+ setStats(s?.[0] || null);
+ setStatusDist(sd || []);
+ setDailyTrend(dt || []);
+ setDriverStats(ds || []);
+ setEconomics(econ?.[0] || null);
+ } catch (e) {
+ setError(e.message || "Ошибка загрузки статистики");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [days, dateFrom, dateTo]);
+
+ useEffect(() => { fetchAll(); }, [fetchAll]);
+
+ return { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch: fetchAll };
+};
\ No newline at end of file
diff --git a/src/main.jsx b/src/main.jsx
index fa73f15..3203f01 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -4,16 +4,21 @@ import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext";
+import ErrorBoundary from "./components/ErrorBoundary";
+import { initErrorLogging } from "./utils/errorLogger";
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
import "./index.css";
registerPwaServiceWorker();
+initErrorLogging();
ReactDOM.createRoot(document.getElementById("root")).render(
-
+
+
+
,
diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx
index 81c15e8..604d930 100644
--- a/src/pages/DashboardPage.jsx
+++ b/src/pages/DashboardPage.jsx
@@ -3,6 +3,9 @@ import { Navigate, useNavigate } from "react-router-dom";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { OrdersTable } from "../components/orders/OrdersTable";
+import AdminDashboard from "../components/admin/AdminDashboard";
+import UserManagementPanel from "../components/admin/UserManagementPanel";
+import ErrorLogPanel from "../components/admin/ErrorLogPanel";
import { Panel } from "../components/UI/Panel";
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
import { useAuth } from "../context/AuthContext";
@@ -12,28 +15,26 @@ import { usePwaStatus } from "../hooks/usePwaStatus";
import { useOrderGroups } from "../hooks/useOrderGroups";
import { AppShell } from "../layouts/AppShell";
+const MEGA_ADMIN_NAV = [
+ { key: "analytics", label: "Аналитика", description: "Статистика доставки, графики и показатели.", badge: null },
+ { key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: null },
+ { key: "users", label: "Пользователи", description: "Управление пользователями и ролями.", badge: null },
+ { key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
+];
+
const ROLE_SECTION = {
- manager: {
- key: "orders",
- label: "Группы",
- description: "Реестр групп доставки, поиск и просмотр карточки.",
- },
- logistician: {
- key: "logistics",
- label: "Логистика",
- description: "Группы доставки по готовности к уведомлению.",
- },
- driver: {
- key: "deliveries",
- label: "Мои доставки",
- description: "Группы доставки по датам и статусам.",
- },
+ mega_admin: { key: "analytics", label: "Аналитика" },
+ admin: { key: "analytics", label: "Аналитика", description: "Статистика доставки." },
+ manager: { key: "orders", label: "Группы", description: "Реестр групп доставки, поиск и просмотр карточки." },
+ logistician: { key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению." },
+ driver: { key: "deliveries", label: "Мои доставки", description: "Группы доставки по датам и статусам." },
};
export const DashboardPage = () => {
const { user, signOut } = useAuth();
const navigate = useNavigate();
const userRole = user?.role;
+ const isMegaAdmin = userRole === "mega_admin";
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
const [activeSection, setActiveSection] = React.useState(section.key);
@@ -45,7 +46,6 @@ export const DashboardPage = () => {
markAllAsRead: markAllNotificationsRead,
} = useNotifications(user?.id);
- // Auto-restore push subscription on login
const { isSupported, isSubscribed, subscribe } = usePushNotifications(user?.id);
React.useEffect(() => {
@@ -77,69 +77,50 @@ export const DashboardPage = () => {
navigate("/dashboard/group/" + groupId);
}, [navigate]);
- const navItems = [
- {
- key: section.key,
- label: section.label,
- description: section.description,
- badge: String(allOrderGroups.length || orderGroups.length || 0),
- },
- ];
- const guideSectionMeta = {
- key: "guide",
- label: "Справка",
- description: "Карта продукта, роли, сценарии и частые вопросы.",
- };
- const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
+ const navItems = isMegaAdmin
+ ? MEGA_ADMIN_NAV
+ : userRole === "admin"
+ ? [
+ { key: "analytics", label: "Аналитика", description: "Статистика доставки.", badge: null },
+ { key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
+ ]
+ : [
+ { key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
+ ];
+
+ const guideSectionMeta = { key: "guide", label: "Справка", description: "Карта продукта, роли, сценарии и частые вопросы." };
+ const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems.find((n) => n.key === activeSection) || navItems[0];
const isGuideOpen = activeSection === "guide";
if (!user) {
return
;
}
- const renderManagerWorkspace = () => (
-
-
-
- );
-
- const renderLogisticsWorkspace = () => (
-
-
-
- );
-
- const renderDriverWorkspace = () => (
-
-
-
- );
-
const renderActiveSection = () => {
- if (activeSection === "guide") {
- return
;
- }
+ if (activeSection === "guide") return
;
+ if (activeSection === "analytics") return
;
+ if (activeSection === "users") return
;
+ if (activeSection === "errors") return
;
if (userRole === "driver") {
- return renderDriverWorkspace();
+ return (
+
+
+
+ );
}
-
if (userRole === "logistician") {
- return renderLogisticsWorkspace();
+ return (
+
+
+
+ );
}
-
- return renderManagerWorkspace();
+ return (
+
+
+
+ );
};
return (
@@ -160,18 +141,17 @@ export const DashboardPage = () => {
onMarkNotificationRead={markNotificationRead}
onMarkAllNotificationsRead={markAllNotificationsRead}
>
- {isLoading ? (
+ {isLoading && (
Загружаем данные...
- ) : null}
- {loadError ? (
+ )}
+ {loadError && (
Не удалось загрузить данные. Обратитесь к администратору.
- ) : null}
-
+ )}
{renderActiveSection()}
);
-};
\ No newline at end of file
+};
diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js
index b0b8e05..1e5b56e 100644
--- a/src/services/orderGroupViews.js
+++ b/src/services/orderGroupViews.js
@@ -316,6 +316,10 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
+ { value: "status:manual_required", label: "Требует ручной обработки" },
+ { value: "status:second_sms_sent", label: "Повторное SMS" },
+ { value: "status:sms_sent", label: "SMS отправлены" },
+ { value: "status:ready_for_notification", label: "Готово к уведомлению" },
];
export const getOrderGroupStatusLabel = (status) =>
@@ -334,7 +338,7 @@ export const getOrderGroupDeliveryStatusTone = (status) => {
case "loaded":
return "info";
case "on_route":
- return "accent";
+ return "warning";
case "delivered":
return "accent";
case "paid_storage":
@@ -362,7 +366,7 @@ export const groupOrderGroupsByDate = (groups) => {
const rightTime = parseGroupDate(rightDate)?.getTime();
if (leftTime != null && rightTime != null && leftTime !== rightTime) {
- return leftTime - rightTime;
+ return rightTime - leftTime;
}
return leftDate.localeCompare(rightDate);
diff --git a/src/utils/errorLogger.js b/src/utils/errorLogger.js
new file mode 100644
index 0000000..3160d2d
--- /dev/null
+++ b/src/utils/errorLogger.js
@@ -0,0 +1,185 @@
+import { createClient } from '@supabase/supabase-js';
+
+const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
+const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
+
+const supabase = createClient(supabaseUrl, supabaseAnonKey);
+
+// Debounce tracking: message -> last timestamp
+const recentErrors = new Map();
+const DEBOUNCE_MS = 10000;
+
+function getUserId() {
+ // Try to get from Supabase auth session in localStorage
+ try {
+ const keys = Object.keys(localStorage);
+ const authKey = keys.find(
+ (k) => k.startsWith('sb-') && k.endsWith('-auth-token')
+ );
+ if (authKey) {
+ const data = JSON.parse(localStorage.getItem(authKey));
+ return data?.user?.id || null;
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+function isDebounced(message) {
+ const now = Date.now();
+ const lastTime = recentErrors.get(message);
+ if (lastTime && now - lastTime < DEBOUNCE_MS) {
+ return true;
+ }
+ recentErrors.set(message, now);
+
+ // Periodically clean up old entries to avoid memory leak
+ if (recentErrors.size > 100) {
+ for (const [key, ts] of recentErrors) {
+ if (now - ts > DEBOUNCE_MS) recentErrors.delete(key);
+ }
+ }
+
+ return false;
+}
+
+async function insertErrorLog(entry) {
+ try {
+ await supabase.from('client_error_logs').insert([entry]);
+ } catch {
+ // Fire-and-forget — swallow insertion errors
+ }
+}
+
+function logError(error, componentInfo) {
+ if (!(error instanceof Error) && typeof error !== 'object' && typeof error !== 'string') {
+ return;
+ }
+
+ const message =
+ typeof error === 'string'
+ ? error
+ : error?.message || String(error);
+
+ // Debounce identical messages within 10 seconds
+ if (isDebounced(message)) return;
+
+ const stack =
+ typeof error === 'object' && error !== null ? error.stack || null : null;
+
+ let line_number = null;
+ let column_number = null;
+ let url = null;
+
+ // Try to parse first frame from the stack for line/column/url
+ if (stack) {
+ const frameMatch = stack.match(/at\s+.*\((.+):(\d+):(\d+)\)/);
+ if (frameMatch) {
+ url = frameMatch[1];
+ line_number = parseInt(frameMatch[2], 10) || null;
+ column_number = parseInt(frameMatch[3], 10) || null;
+ }
+ }
+
+ const entry = {
+ user_id: getUserId(),
+ message,
+ stack,
+ url,
+ line_number,
+ column_number,
+ error_type:
+ typeof error === 'object' && error !== null
+ ? error.constructor?.name || 'Error'
+ : 'String',
+ component: componentInfo?.component || null,
+ props: componentInfo?.props
+ ? JSON.stringify(componentInfo.props)
+ : null,
+ user_agent: navigator.userAgent,
+ created_at: new Date().toISOString(),
+ };
+
+ // Fire-and-forget
+ insertErrorLog(entry);
+}
+
+function initErrorLogging(userId) {
+ // If a userId is provided, override the getUserId logic
+ if (userId) {
+ const origGetUserId = getUserId;
+ // We store it so future logError calls can use it
+ window.__supersam_user_id__ = userId;
+ }
+
+ // Catch synchronous errors
+ window.onerror = function (message, source, lineno, colno, error) {
+ const msg = typeof message === 'string' ? message : String(message);
+
+ if (isDebounced(msg)) return;
+
+ const entry = {
+ user_id: userId || getUserId() || window.__supersam_user_id__ || null,
+ message: msg,
+ stack: error?.stack || null,
+ url: source || null,
+ line_number: lineno || null,
+ column_number: colno || null,
+ error_type: error?.constructor?.name || 'Error',
+ component: null,
+ props: null,
+ user_agent: navigator.userAgent,
+ created_at: new Date().toISOString(),
+ };
+
+ insertErrorLog(entry);
+ };
+
+ // Catch unhandled promise rejections
+ window.onunhandledrejection = function (event) {
+ const error = event.reason;
+ const message =
+ error instanceof Error
+ ? error.message
+ : typeof error === 'string'
+ ? error
+ : String(error);
+
+ if (isDebounced(message)) return;
+
+ let line_number = null;
+ let column_number = null;
+ let url = null;
+
+ if (error?.stack) {
+ const frameMatch = error.stack.match(/at\s+.*\((.+):(\d+):(\d+)\)/);
+ if (frameMatch) {
+ url = frameMatch[1];
+ line_number = parseInt(frameMatch[2], 10) || null;
+ column_number = parseInt(frameMatch[3], 10) || null;
+ }
+ }
+
+ const entry = {
+ user_id: userId || getUserId() || window.__supersam_user_id__ || null,
+ message,
+ stack: error?.stack || null,
+ url,
+ line_number,
+ column_number,
+ error_type:
+ error instanceof Error
+ ? error.constructor?.name || 'Error'
+ : 'UnhandledRejection',
+ component: null,
+ props: null,
+ user_agent: navigator.userAgent,
+ created_at: new Date().toISOString(),
+ };
+
+ insertErrorLog(entry);
+ };
+}
+
+export { initErrorLogging, logError };
\ No newline at end of file