From 40b28be0ee0474c15719e3acab39013c7625f06e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 25 May 2026 12:31:58 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20automation=20funnel=20=E2=80=94=20correc?= =?UTF-8?q?t=20data=20logic,=20remove=20emojis,=20rename=20to=20=D0=92?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BD=D0=BA=D0=B0=20=D1=81=D0=BE=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 + docker-compose.app.yml | 6 +- index.html | 8 +- package.json | 5 +- src/components/ErrorBoundary.jsx | 80 ++++ src/components/UI/DatePicker.jsx | 250 ++++++++++++ src/components/admin/AdminDashboard.jsx | 310 +++++++++++++++ src/components/admin/ErrorLogPanel.jsx | 248 ++++++++++++ src/components/admin/UserManagementPanel.jsx | 313 +++++++++++++++ .../driver/DriverDeliveryPlanner.jsx | 173 +++++--- .../driver/DriverDeliveryPlanner.jsx.bak | 371 ++++++++++++++++++ src/components/orders/OrderFilters.jsx | 171 ++++---- src/hooks/useAdminStats.js | 57 +++ src/main.jsx | 7 +- src/pages/DashboardPage.jsx | 126 +++--- src/services/orderGroupViews.js | 8 +- src/utils/errorLogger.js | 185 +++++++++ 17 files changed, 2107 insertions(+), 217 deletions(-) create mode 100644 src/components/ErrorBoundary.jsx create mode 100644 src/components/UI/DatePicker.jsx create mode 100644 src/components/admin/AdminDashboard.jsx create mode 100644 src/components/admin/ErrorLogPanel.jsx create mode 100644 src/components/admin/UserManagementPanel.jsx create mode 100644 src/components/driver/DriverDeliveryPlanner.jsx.bak create mode 100644 src/hooks/useAdminStats.js create mode 100644 src/utils/errorLogger.js diff --git a/Dockerfile b/Dockerfile index 6367bbb..5e753d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,12 @@ WORKDIR /app COPY package*.json ./ RUN npm install --prefer-offline COPY . . +ARG VITE_SUPABASE_URL +ARG VITE_SUPABASE_ANON_KEY +ARG VITE_SUPABASE_SERVICE_ROLE_KEY +ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL +ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY +ENV VITE_SUPABASE_SERVICE_ROLE_KEY=$VITE_SUPABASE_SERVICE_ROLE_KEY RUN npm run build # Serve stage diff --git a/docker-compose.app.yml b/docker-compose.app.yml index a477ee3..fc948aa 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -3,6 +3,10 @@ services: build: context: /opt/supersam dockerfile: Dockerfile + args: + VITE_SUPABASE_URL: ${VITE_SUPABASE_URL} + VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY} + VITE_SUPABASE_SERVICE_ROLE_KEY: ${VITE_SUPABASE_SERVICE_ROLE_KEY:-} container_name: supersam-app restart: unless-stopped networks: @@ -15,13 +19,11 @@ services: - traefik.http.routers.supersam-app.tls.certresolver=letsencrypt - traefik.http.routers.supersam-app.service=supersam-app - traefik.http.services.supersam-app.loadbalancer.server.port=80 - # Redirect HTTP to HTTPS - traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`) - traefik.http.routers.supersam-app-http.entryPoints=http - traefik.http.routers.supersam-app-http.middlewares=redirect-to-https - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https - traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true - # Security headers via Traefik - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin diff --git a/index.html b/index.html index 91bbccc..6ac6a54 100644 --- a/index.html +++ b/index.html @@ -13,13 +13,7 @@ Construction Delivery Control - +
diff --git a/package.json b/package.json index 3248eb3..228d050 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "7.3.0", - "tailwind-merge": "3.3.0" + "tailwind-merge": "3.3.0", + "recharts": "^2.15.0" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -37,4 +38,4 @@ "vite": "^6.2.0", "vitest": "^3.0.9" } -} +} \ No newline at end of file diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..617e83b --- /dev/null +++ b/src/components/ErrorBoundary.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { logError } from '../utils/errorLogger'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + // Extract component stack for richer context + const componentInfo = { + component: errorInfo?.componentStack || null, + props: this.props, + }; + + logError(error, componentInfo); + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + + renderDefaultFallback() { + return ( +
+

+ Something went wrong +

+

+ An unexpected error occurred. You can try again. +

+ +
+ ); + } + + render() { + if (this.state.hasError) { + // Allow custom fallback render function + if (typeof this.props.fallback === 'function') { + return this.props.fallback(this.state.error, this.handleRetry); + } + + return this.renderDefaultFallback(); + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/components/UI/DatePicker.jsx b/src/components/UI/DatePicker.jsx new file mode 100644 index 0000000..0e5d62b --- /dev/null +++ b/src/components/UI/DatePicker.jsx @@ -0,0 +1,250 @@ +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { Panel } from "./Panel"; + +const MONTH_NAMES = [ + "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", +]; + +const WEEKDAY_SHORT = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; + +function getDaysInMonth(year, month) { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfWeek(year, month) { + const day = new Date(year, month, 1).getDay(); + return day === 0 ? 6 : day - 1; // Monday = 0 +} + +function formatDateISO(date) { + if (!date) return ""; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function formatDateDisplay(date) { + if (!date) return ""; + return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`; +} + +function parseDateFromISO(str) { + if (!str) return null; + const [y, m, d] = str.split("-").map(Number); + if (!y || !m || !d) return null; + return new Date(y, m - 1, d); +} + +export const DatePicker = ({ + value, + onChange, + placeholder = "Выберите дату", + className = "", + label, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [viewYear, setViewYear] = useState(() => { + const d = value ? parseDateFromISO(value) : new Date(); + return d.getFullYear(); + }); + const [viewMonth, setViewMonth] = useState(() => { + const d = value ? parseDateFromISO(value) : new Date(); + return d.getMonth(); + }); + const containerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const handle = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + setIsOpen(false); + } + }; + document.addEventListener("pointerdown", handle); + return () => document.removeEventListener("pointerdown", handle); + }, [isOpen]); + + useEffect(() => { + const handle = (e) => { + if (e.key === "Escape" && isOpen) setIsOpen(false); + }; + document.addEventListener("keydown", handle); + return () => document.removeEventListener("keydown", handle); + }, [isOpen]); + + const selectedDate = value ? parseDateFromISO(value) : null; + + const handleDayClick = useCallback( + (day) => { + const d = new Date(viewYear, viewMonth, day); + onChange(formatDateISO(d)); + setIsOpen(false); + }, + [viewYear, viewMonth, onChange] + ); + + const handleClear = useCallback( + (e) => { + e.stopPropagation(); + onChange(""); + }, + [onChange] + ); + + const prevMonth = () => { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear((y) => y - 1); + } else { + setViewMonth((m) => m - 1); + } + }; + + const nextMonth = () => { + if (viewMonth === 11) { + setViewMonth(0); + setViewYear((y) => y + 1); + } else { + setViewMonth((m) => m + 1); + } + }; + + const goToToday = () => { + const now = new Date(); + setViewYear(now.getFullYear()); + setViewMonth(now.getMonth()); + onChange(formatDateISO(now)); + setIsOpen(false); + }; + + const daysInMonth = getDaysInMonth(viewYear, viewMonth); + const firstDay = getFirstDayOfWeek(viewYear, viewMonth); + const today = new Date(); + const isToday = (day) => + day === today.getDate() && + viewMonth === today.getMonth() && + viewYear === today.getFullYear(); + const isSelected = (day) => + selectedDate && + day === selectedDate.getDate() && + viewMonth === selectedDate.getMonth() && + viewYear === selectedDate.getFullYear(); + + const cells = []; + for (let i = 0; i < firstDay; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + return ( +
+ {label && ( + + {label} + + )} + + + {isOpen && ( +
+ {/* Header: month/year nav */} +
+ + + {MONTH_NAMES[viewMonth]} {viewYear} + + +
+ + {/* Weekday headers */} +
+ {WEEKDAY_SHORT.map((wd) => ( +
+ {wd} +
+ ))} +
+ + {/* Day grid */} +
+ {cells.map((day, i) => ( + + ))} +
+ + {/* Today button */} +
+ +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/admin/AdminDashboard.jsx b/src/components/admin/AdminDashboard.jsx new file mode 100644 index 0000000..5f59218 --- /dev/null +++ b/src/components/admin/AdminDashboard.jsx @@ -0,0 +1,310 @@ +import React, { useState } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid, +} from 'recharts'; +import { Panel } from '../UI/Panel'; +import { Badge } from '../UI/Badge'; +import { SegmentedTabs } from '../UI/SegmentedTabs'; +import { useAdminStats } from '../../hooks/useAdminStats'; + +const STATUS_COLORS = { + pending_confirmation: '#94a3b8', + manual_confirmation_required: '#eab308', + agreed: '#22c55e', + driver_assigned: '#3b82f6', + loaded: '#6366f1', + on_route: '#8b5cf6', + delivered: '#10b981', + paid_storage: '#06b6d4', + problem: '#ef4444', + cancelled: '#64748b', +}; + +const STATUS_LABELS = { + pending_confirmation: 'Ожидает подтверждения', + manual_confirmation_required: 'Ручное подтверждение', + agreed: 'Согласовано', + driver_assigned: 'Водитель назначен', + loaded: 'Загружено', + on_route: 'В пути', + delivered: 'Доставлено', + paid_storage: 'Оплаченное хранение', + problem: 'Проблема', + cancelled: 'Отменено', +}; + +const PERIOD_OPTIONS = [ + { key: '1d', label: 'Сегодня' }, + { key: '7d', label: '7 дней' }, + { key: '30d', label: '30 дней' }, + { key: 'all', label: 'Все' }, +]; + +const CustomTooltip = ({ active, payload, label: tooltipLabel }) => { + if (!active || !payload?.length) return null; + return ( +
+ {tooltipLabel &&
{tooltipLabel}
} + {payload.map((p, i) => ( +
{p.name}: {p.value}
+ ))} +
+ ); +}; + +const FunnelStep = ({ label, value, maxValue, color, pct }) => { + if (!maxValue) return null; + const widthPct = Math.max(18, (value / maxValue) * 100); + return ( +
+
+ {value} +
+
+ + {pct !== undefined ? pct : (maxValue > 0 ? Math.round((value / maxValue) * 100) : 0)}% + +
+
+ {label} +
+
+ ); +}; + +const FunnelConnector = () => ( +
+); + +export const AdminDashboard = () => { + const [period, setPeriod] = useState('7d'); + const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period); + + if (isLoading) { + return ( + +
Загрузка...
+
+ ); + } + if (error) { + return ( + +
Ошибка: {error}
+
+ ); + } + + const sv = stats || {}; + const totalGroups = sv.total || 0; + const econ = economics || {}; + + const statusPieData = (statusDist || []).map(s => ({ + name: STATUS_LABELS[s.delivery_status] || s.delivery_status, + value: s.count, + status: s.delivery_status, + })).filter(d => d.value > 0); + + const trendData = (dailyTrend || []).map(d => ({ + date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '', + delivered: d.delivered || 0, total: d.total || 0, problems: d.problems || 0, + })); + + const driverData = (driverStats || []).map(d => ({ + name: d.driver_name || 'Неизвестный', + total: d.total || 0, delivered: d.delivered || 0, problems: d.problems || 0, + })); + + return ( +
+ + {/* Period selector */} +
+
+

Аналитика

+

Статистика по доставкам

+
+ +
+ + {/* KPI */} +
+ {[ + { label: 'Всего', val: totalGroups }, + { label: 'Ожидает', val: sv.pending }, + { label: 'В работе', val: sv.in_progress }, + { label: 'Доставлено', val: sv.delivered }, + { label: 'Проблемы', val: sv.problem }, + { label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' }, + ].map((kpi, i) => ( + +
{kpi.label}
+
{kpi.val ?? '—'}
+
+ ))} +
+ + {/* Pie + Line */} +
+ +

По статусам

+ {statusPieData.length === 0 ? ( +
Нет данных
+ ) : ( + + + + {statusPieData.map(entry => ( + + ))} + + } /> + + + + )} +
+ + +

Тренд по дням

+ {trendData.length === 0 ? ( +
Нет данных
+ ) : ( + + + + + + } /> + + + + + + + )} +
+
+ + {/* Status table */} + +

Все статусы

+ {statusPieData.length === 0 ? ( +
Нет данных
+ ) : ( +
+
+
Статус
Кол-во
Доля
+
+ {statusPieData.map(s => { + const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0; + return ( +
+
+
{s.name}
+
{s.value}
+
{pct}%
+
+ ); + })} +
+ )} + + + {/* Воронка согласования */} + +

Воронка согласования

+ {totalGroups === 0 ? ( +
Нет данных
+ ) : ( +
+
+ + + + + + + + + + + + {econ.paid_storage_count > 0 && ( + <> + + + + )} +
+ + {/* Summary */} +
+
+
Автосогласование
+
{econ.auto_confirm_pct ?? 0}%
+
+
+
Ручное вмешательство
+
{econ.manual_intervention_pct ?? 0}%
+
+
+
Всего согласовано
+
{econ.confirmed_auto_total ?? 0}
+
+
+
+ )} +
+ + {/* Drivers */} + +

По водителям

+ {driverData.length === 0 ? ( +
Нет данных
+ ) : ( + + + + + } /> + + + + + + )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/admin/ErrorLogPanel.jsx b/src/components/admin/ErrorLogPanel.jsx new file mode 100644 index 0000000..73612dd --- /dev/null +++ b/src/components/admin/ErrorLogPanel.jsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Panel } from '../UI/Panel'; +import { Badge } from '../UI/Badge'; +import { Select } from '../UI/Select'; +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +const DATE_RANGES = [ + { value: 'today', label: 'Сегодня' }, + { value: '7d', label: '7 дней' }, + { value: '30d', label: '30 дней' }, + { value: 'all', label: 'Всё время' }, +]; + +const ERROR_TONES = { + Error: 'danger', + TypeError: 'warning', + ReferenceError: 'warning', + SyntaxError: 'danger', + RangeError: 'warning', + NetworkError: 'info', + UnhandledRejection: 'danger', + Warning: 'warning', +}; + +export default function ErrorLogPanel() { + const [errors, setErrors] = useState([]); + const [loading, setLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [filterType, setFilterType] = useState(''); + const [filterRange, setFilterRange] = useState('7d'); + const [availableTypes, setAvailableTypes] = useState([]); + const [copied, setCopied] = useState(false); + const intervalRef = useRef(null); + const client = createClient(supabaseUrl, supabaseAnonKey); + + const getRangeStart = (range) => { + const now = new Date(); + switch (range) { + case 'today': return new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString(); + case '7d': return new Date(now.getTime() - 7 * 86400000).toISOString(); + case '30d': return new Date(now.getTime() - 30 * 86400000).toISOString(); + default: return null; + } + }; + + const fetchErrors = useCallback(async () => { + setFetchError(null); + let query = client.from('client_error_logs').select('*').order('created_at', { ascending: false }); + const rangeStart = getRangeStart(filterRange); + if (rangeStart) query = query.gte('created_at', rangeStart); + if (filterType) query = query.eq('error_type', filterType); + const { data, error: err } = await query; + if (err) { setFetchError(err.message); setErrors([]); } + else { + setErrors(data || []); + const types = [...new Set((data || []).map((e) => e.error_type).filter(Boolean))].sort(); + setAvailableTypes(types); + } + setLoading(false); + }, [filterRange, filterType]); + + useEffect(() => { setLoading(true); fetchErrors(); }, [fetchErrors]); + + useEffect(() => { + if (intervalRef.current) clearInterval(intervalRef.current); + intervalRef.current = setInterval(fetchErrors, 30000); + return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; + }, [fetchErrors]); + + const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—'; + const trunc = (s, n) => !s ? '—' : s.length > n ? s.slice(0, n) + '…' : s; + + const handleCopyAll = async () => { + const text = errors.map((e) => { + const ts = e.created_at ? new Date(e.created_at).toISOString() : 'NO_TIMESTAMP'; + return `[${ts}] ${e.error_type || 'Unknown'}: ${e.message || 'No message'} | URL: ${e.url || '-'} | Component: ${e.component || '-'} | Line: ${e.line_number ?? '-'}:${e.column_number ?? '-'} | Stack: ${e.stack || '-'} | Props: ${e.props || '-'} | UA: ${e.user_agent || '-'}`; + }).join('\n\n'); + try { + await navigator.clipboard.writeText(text); + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + {/* Toolbar */} +
+
+ + + + {errors.length} ошибок + +
+
+ + +
+
+ +
+ Автообновление каждые 30 сек +
+ + {fetchError && ( +
+ {fetchError} +
+ )} + + {loading ? ( +
Загрузка…
+ ) : ( +
+ {errors.length === 0 && ( +
+ Нет ошибок за выбранный период +
+ )} + + {errors.map((err) => { + const isExpanded = expandedId === err.id; + return ( +
setExpandedId(isExpanded ? null : err.id)} + > + {/* Summary */} +
+ + {fmtDate(err.created_at)} + + + {err.error_type || 'Unknown'} + + + {trunc(err.message, 120)} + + + {trunc(err.url, 40)} + +
+ + {/* Expanded */} + {isExpanded && ( +
+
+ Сообщение: +
+                        {err.message || '—'}
+                      
+
+
+ Стек: +
+                        {err.stack || '—'}
+                      
+
+
+ Props: +
+                        {err.props || '—'}
+                      
+
+
+
URL: {err.url || '—'}
+
Компонент: {err.component || '—'}
+
Строка: {err.line_number ?? '—'}:{err.column_number ?? '—'}
+
UA: {trunc(err.user_agent, 60)}
+ {err.user_id &&
User ID: {err.user_id}
} +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/admin/UserManagementPanel.jsx b/src/components/admin/UserManagementPanel.jsx new file mode 100644 index 0000000..2546eb5 --- /dev/null +++ b/src/components/admin/UserManagementPanel.jsx @@ -0,0 +1,313 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Panel } from '../UI/Panel'; +import { Badge } from '../UI/Badge'; +import { Input } from '../UI/Input'; +import { Select } from '../UI/Select'; +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const supabaseServiceKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY; + +const ROLES = ['admin', 'driver', 'logistician', 'manager', 'mega_admin', 'production_lead']; + +const ROLE_TONES = { + mega_admin: 'danger', + admin: 'warning', + manager: 'info', + production_lead: 'info', + logistician: 'accent', + driver: 'accent', +}; + +export default function UserManagementPanel() { + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + const [editingRoleId, setEditingRoleId] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [addForm, setAddForm] = useState({ name: '', email: '', role: '' }); + const [addSubmitting, setAddSubmitting] = useState(false); + const [addError, setAddError] = useState(null); + + const client = createClient(supabaseUrl, supabaseAnonKey); + const adminClient = supabaseServiceKey ? createClient(supabaseUrl, supabaseServiceKey) : null; + + const fetchRoles = useCallback(async () => { + const { data, error: err } = await client.from('roles').select('id, name').order('name'); + if (err) { setError(err.message); return []; } + setRoles(data || []); + return data || []; + }, []); + + const fetchUsers = useCallback(async () => { + setLoading(true); + setError(null); + const { data, error: err } = await client + .from('users') + .select('id, email, name, role_id, created_at, last_login, roles(name)') + .order('created_at', { ascending: false }); + if (err) { setError(err.message); setUsers([]); } + else setUsers(data || []); + setLoading(false); + }, []); + + useEffect(() => { fetchRoles(); fetchUsers(); }, [fetchRoles, fetchUsers]); + + const getRoleName = (user) => { + if (user.roles?.name) return user.roles.name; + const match = roles.find((r) => r.id === user.role_id); + return match ? match.name : 'unknown'; + }; + + const getRoleId = (roleName) => { + const match = roles.find((r) => r.name === roleName); + return match ? match.id : null; + }; + + const handleAddUser = async (e) => { + e.preventDefault(); + setAddError(null); + if (!addForm.name || !addForm.email || !addForm.role) { + setAddError('Все поля обязательны.'); + return; + } + setAddSubmitting(true); + try { + if (!adminClient) { + setAddError('Требуется VITE_SUPABASE_SERVICE_ROLE_KEY для управления пользователями.'); + return; + } + const roleId = getRoleId(addForm.role); + const { data: authData, error: authErr } = await adminClient.auth.admin.createUser({ + email: addForm.email, + email_confirm: true, + user_metadata: { name: addForm.name }, + }); + if (authErr) throw authErr; + const { error: insertErr } = await adminClient + .from('users') + .insert({ id: authData.user.id, email: addForm.email, name: addForm.name, role_id: roleId }); + if (insertErr) throw insertErr; + await fetchUsers(); + setShowAddForm(false); + setAddForm({ name: '', email: '', role: '' }); + } catch (err) { + setAddError(err.message || 'Не удалось добавить пользователя.'); + } finally { + setAddSubmitting(false); + } + }; + + const handleRoleChange = async (userId, newRoleName) => { + const roleId = getRoleId(newRoleName); + if (!roleId) return; + try { + const { error: err } = await (adminClient || client) + .from('users') + .update({ role_id: roleId }) + .eq('id', userId); + if (err) throw err; + setEditingRoleId(null); + await fetchUsers(); + } catch (err) { + setError(err.message || 'Не удалось обновить роль.'); + } + }; + + const handleDeleteUser = async (userId) => { + try { + const { error: err } = await (adminClient || client).from('users').delete().eq('id', userId); + if (err) throw err; + if (adminClient) await adminClient.auth.admin.deleteUser(userId); + setDeleteConfirmId(null); + await fetchUsers(); + } catch (err) { + setError(err.message || 'Не удалось удалить пользователя.'); + } + }; + + const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—'; + + return ( + + {/* Toolbar */} +
+ + {users.length} пользователей + + +
+ + {/* Add form */} + {showAddForm && ( +
+
+ setAddForm((f) => ({ ...f, name: e.target.value }))} + className="flex-1 min-w-[140px]!" + /> + setAddForm((f) => ({ ...f, email: e.target.value }))} + className="flex-1 min-w-[180px]!" + /> + +
+ {addError && ( +
{addError}
+ )} + +
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Загрузка… +
+ ) : ( +
+ {/* Header */} +
+ EmailИмяРольСоздан +
+ + {users.length === 0 && ( +
+ Нет пользователей +
+ )} + + {users.map((user) => ( +
+ {user.email} + {user.name || '—'} + + {editingRoleId === user.id ? ( + + ) : ( + setEditingRoleId(user.id)} style={{ cursor: 'pointer' }}> + {getRoleName(user)} + + )} + + {fmtDate(user.created_at)} + +
+ {deleteConfirmId === user.id ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/driver/DriverDeliveryPlanner.jsx b/src/components/driver/DriverDeliveryPlanner.jsx index a5465cb..50f5dc0 100644 --- a/src/components/driver/DriverDeliveryPlanner.jsx +++ b/src/components/driver/DriverDeliveryPlanner.jsx @@ -13,18 +13,27 @@ import { Input } from "../UI/Input"; import { Select } from "../UI/Select"; import { Panel } from "../UI/Panel"; +const CHEVRON_DOWN = ( + + + +); +const CHEVRON_RIGHT = ( + + + +); + const extractCity = (address) => { if (!address || typeof address !== "string") return null; const trimmed = address.trim(); if (!trimmed) return null; - // Patterns: "г.Ялта", "г. Ялта", "г Ялта", "г Ялта ", "Ялта,", "г.Севастополь", etc. const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i); if (cityMatch) { return cityMatch[1].trim(); } - // Try common city names directly in the address const knownCities = [ "Севастополь", "Ялта", "Симферополь", "Феодосия", "Евпатория", "Керчь", "Алушта", "Бахчисарай", "Судак", "Инкерман", @@ -36,7 +45,6 @@ const extractCity = (address) => { } } - // Fallback: first comma-separated segment if it looks like a city const firstSegment = trimmed.split(/[,;]/)[0].trim(); if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) { return firstSegment; @@ -58,12 +66,52 @@ const DRIVER_DELIVERY_STATUS_OPTIONS = [ })), ]; +const pluralGroups = (n) => { + if (n === 1) return "группа"; + if (n >= 2 && n < 5) return "группы"; + return "групп"; +}; + +/** Count items by status, return array of {status, label, tone, count} */ +const countByStatus = (items) => { + const map = new Map(); + for (const item of items) { + const s = item.deliveryStatus || item.delivery_status || "unknown"; + map.set(s, (map.get(s) || 0) + 1); + } + const result = []; + for (const [status, count] of map) { + result.push({ + status, + label: status === "driver_assigned" ? "Назначено" : getOrderGroupDeliveryStatusLabel(status), + tone: getOrderGroupDeliveryStatusTone(status), + count, + }); + } + // Sort: delivered last (green = done), others by severity + const order = ["problem", "cancelled", "on_route", "loaded", "driver_assigned", "paid_storage", "delivered"]; + result.sort((a, b) => { + const ia = order.indexOf(a.status); + const ib = order.indexOf(b.status); + if (ia === -1 && ib === -1) return a.status.localeCompare(b.status); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + return result; +}; + export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => { const [filters, setFilters] = React.useState({ selectedDate: "", deliveryStatus: "all", selectedCity: "", }); + const [collapsedDates, setCollapsedDates] = React.useState({}); + + const toggleDate = (date) => { + setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] })); + }; const driverOrderGroups = React.useMemo( () => orderGroups.filter((group) => { @@ -74,7 +122,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs [orderGroups, currentUser], ); - // Build map of date -> count const dateDeliveryMap = React.useMemo(() => { const map = new Map(); driverOrderGroups.forEach((group) => { @@ -90,7 +137,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs return Array.from(dateDeliveryMap.keys()).sort(); }, [dateDeliveryMap]); - // Build map of city -> count const cityDeliveryMap = React.useMemo(() => { const map = new Map(); driverOrderGroups.forEach((group) => { @@ -102,7 +148,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs const sortedCities = React.useMemo(() => { return Array.from(cityDeliveryMap.keys()).sort((a, b) => { - // Севастополь first, then alphabetical if (a === "Севастополь") return -1; if (b === "Севастополь") return 1; return a.localeCompare(b, "ru"); @@ -137,6 +182,15 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs const isDateSelected = (date) => filters.selectedDate === date; + // Compute per-date status summary for collapsed badges + const dateStatusSummary = React.useMemo(() => { + const summary = {}; + for (const dg of groupedOrderGroups) { + summary[dg.date] = countByStatus(dg.items); + } + return summary; + }, [groupedOrderGroups]); + return (
@@ -271,6 +325,9 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs {groupedOrderGroups.length ? ( groupedOrderGroups.map((group) => { + const isCollapsed = collapsedDates[group.date]; + const statusCounts = dateStatusSummary[group.date] || []; + // Group items by delivery status within each date const statusBuckets = new Map(); for (const item of group.items) { @@ -283,7 +340,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs statusBuckets.get(s).items.push(item); } - // Sort status buckets in driver-relevant order const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"]; const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => { const ia = statusOrder.indexOf(a); @@ -296,8 +352,15 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs return ( -
-
+
- - {sortedBuckets.map(([statusValue, { label, tone, items }]) => ( -
-
- {label} - {items.length} {items.length === 1 ? "группа" : "группы"} -
-
- {items.map((item) => ( - - ))} -
+
+ {statusCounts.map(({ status, label, tone, count }) => ( + {count} {label} + ))}
- ))} + + + {!isCollapsed && ( +
+ {sortedBuckets.map(([statusValue, { label, tone, items }]) => ( +
+
+ {label} + {items.length} {pluralGroups(items.length)} +
+
+ {items.map((item) => ( + + ))} +
+
+ ))} +
+ )} ); }) diff --git a/src/components/driver/DriverDeliveryPlanner.jsx.bak b/src/components/driver/DriverDeliveryPlanner.jsx.bak new file mode 100644 index 0000000..a5465cb --- /dev/null +++ b/src/components/driver/DriverDeliveryPlanner.jsx.bak @@ -0,0 +1,371 @@ +import React from "react"; +import { + getOrderGroupDeliveryHalfDay, + getOrderGroupDeliveryStatusLabel, + getOrderGroupDeliveryStatusTone, + DRIVER_VISIBLE_DELIVERY_STATUSES, + isOrderGroupVisibleToDriver, + groupOrderGroupsByDate, + parseGroupDate, +} from "../../services/orderGroupViews"; +import { Badge } from "../UI/Badge"; +import { Input } from "../UI/Input"; +import { Select } from "../UI/Select"; +import { Panel } from "../UI/Panel"; + +const extractCity = (address) => { + if (!address || typeof address !== "string") return null; + const trimmed = address.trim(); + if (!trimmed) return null; + + // Patterns: "г.Ялта", "г. Ялта", "г Ялта", "г Ялта ", "Ялта,", "г.Севастополь", etc. + const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i); + if (cityMatch) { + return cityMatch[1].trim(); + } + + // Try common city names directly in the address + const knownCities = [ + "Севастополь", "Ялта", "Симферополь", "Феодосия", "Евпатория", + "Керчь", "Алушта", "Бахчисарай", "Судак", "Инкерман", + "Джанкой", "Красногвардейское", "Раздольное", "Черноморское", + ]; + for (const city of knownCities) { + if (trimmed.toLowerCase().includes(city.toLowerCase())) { + return city; + } + } + + // Fallback: first comma-separated segment if it looks like a city + const firstSegment = trimmed.split(/[,;]/)[0].trim(); + if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) { + return firstSegment; + } + + return null; +}; + +const normalizeCity = (address) => { + const city = extractCity(address); + return city || "Севастополь"; +}; + +const DRIVER_DELIVERY_STATUS_OPTIONS = [ + { value: "all", label: "Все статусы" }, + ...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({ + value: status, + label: status === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(status), + })), +]; + +export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => { + const [filters, setFilters] = React.useState({ + selectedDate: "", + deliveryStatus: "all", + selectedCity: "", + }); + + const driverOrderGroups = React.useMemo( + () => orderGroups.filter((group) => { + const isVisible = isOrderGroupVisibleToDriver(group); + const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id; + return isVisible && isAssignedToMe; + }), + [orderGroups, currentUser], + ); + + // Build map of date -> count + const dateDeliveryMap = React.useMemo(() => { + const map = new Map(); + driverOrderGroups.forEach((group) => { + const date = group.deliveryDate; + if (date) { + map.set(date, (map.get(date) || 0) + 1); + } + }); + return map; + }, [driverOrderGroups]); + + const sortedDeliveryDates = React.useMemo(() => { + return Array.from(dateDeliveryMap.keys()).sort(); + }, [dateDeliveryMap]); + + // Build map of city -> count + const cityDeliveryMap = React.useMemo(() => { + const map = new Map(); + driverOrderGroups.forEach((group) => { + const city = normalizeCity(group.deliveryAddress || group.delivery_address); + map.set(city, (map.get(city) || 0) + 1); + }); + return map; + }, [driverOrderGroups]); + + const sortedCities = React.useMemo(() => { + return Array.from(cityDeliveryMap.keys()).sort((a, b) => { + // Севастополь first, then alphabetical + if (a === "Севастополь") return -1; + if (b === "Севастополь") return 1; + return a.localeCompare(b, "ru"); + }); + }, [cityDeliveryMap]); + + const filteredOrderGroups = React.useMemo(() => { + let result = [...driverOrderGroups]; + if (filters.selectedDate) { + result = result.filter((group) => group.deliveryDate === filters.selectedDate); + } + if (filters.deliveryStatus !== "all") { + result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus); + } + if (filters.selectedCity) { + result = result.filter((group) => { + const city = normalizeCity(group.deliveryAddress || group.delivery_address); + return city === filters.selectedCity; + }); + } + return result; + }, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]); + + const groupedOrderGroups = React.useMemo( + () => groupOrderGroupsByDate(filteredOrderGroups), + [filteredOrderGroups], + ); + + const deliveryCountLabel = `${filteredOrderGroups.length} ${ + filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок" + }`; + + const isDateSelected = (date) => filters.selectedDate === date; + + return ( +
+ +
+
+
+
+

Мои доставки

+ {deliveryCountLabel} +
+

+ Показываем только назначенные вам группы доставки. Выберите дату и город. +

+
+
+ +
+ + +
+ + {/* Date pills */} + {sortedDeliveryDates.length > 0 && ( +
+ + {sortedDeliveryDates.map((date) => { + const count = dateDeliveryMap.get(date) || 0; + const selected = isDateSelected(date); + return ( + + ); + })} +
+ )} + + {/* City pills */} + {sortedCities.length > 1 && ( +
+ + {sortedCities.map((city) => { + const count = cityDeliveryMap.get(city) || 0; + const selected = filters.selectedCity === city; + return ( + + ); + })} +
+ )} +
+
+ + {groupedOrderGroups.length ? ( + groupedOrderGroups.map((group) => { + // Group items by delivery status within each date + const statusBuckets = new Map(); + for (const item of group.items) { + const s = item.deliveryStatus || item.delivery_status || "unknown"; + const label = s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s); + const tone = getOrderGroupDeliveryStatusTone(s); + if (!statusBuckets.has(s)) { + statusBuckets.set(s, { label, tone, items: [] }); + } + statusBuckets.get(s).items.push(item); + } + + // Sort status buckets in driver-relevant order + const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"]; + const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => { + const ia = statusOrder.indexOf(a); + const ib = statusOrder.indexOf(b); + if (ia === -1 && ib === -1) return a.localeCompare(b); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + + return ( + +
+
+

+ {parseGroupDate(group.date)?.toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + weekday: "long", + }) || "Без даты"} +

+

+ {group.items.length} {group.items.length === 1 ? "группа" : "группы"} +

+
+ + {(() => { + const d = parseGroupDate(group.date); + if (!d) return group.date || "—"; + const day = String(d.getDate()).padStart(2, "0"); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const year = d.getFullYear(); + return `${day}.${month}.${year}`; + })()} + +
+ + {sortedBuckets.map(([statusValue, { label, tone, items }]) => ( +
+
+ {label} + {items.length} {items.length === 1 ? "группа" : "группы"} +
+
+ {items.map((item) => ( + + ))} +
+
+ ))} +
+ ); + }) + ) : ( + +

Доставки не найдены

+

+ Сейчас у вас нет назначенных групп доставки. +

+
+ )} +
+ ); +}; diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index 77af809..6168c0c 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -1,6 +1,7 @@ import React from "react"; import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; +import { DatePicker } from "../UI/DatePicker"; export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => { const statusValue = filters.displayStatus || filters.status || "all"; @@ -9,25 +10,15 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => { const statusMenuRef = React.useRef(null); React.useEffect(() => { - if (!isStatusOpen) { - return undefined; - } - + if (!isStatusOpen) return undefined; const handlePointerDown = (event) => { - if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) { - setIsStatusOpen(false); - } + if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) setIsStatusOpen(false); }; - const handleKeyDown = (event) => { - if (event.key === "Escape") { - setIsStatusOpen(false); - } + if (event.key === "Escape") setIsStatusOpen(false); }; - document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); - return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); @@ -38,71 +29,105 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => { setFilters((current) => ({ ...current, [key]: value })); }; + const hasDateFilter = filters.dateFrom || filters.dateTo; + + const clearDateFilter = () => { + setFilters((current) => ({ ...current, dateFrom: "", dateTo: "" })); + }; + return ( -
-
- - - {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