fix: automation funnel — correct data logic, remove emojis, rename to Воронка согласования
This commit is contained in:
parent
89d6a01b68
commit
40b28be0ee
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,13 +13,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Construction Delivery Control</title>
|
||||
<script>
|
||||
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
|
||||
navigator.serviceWorker?.getRegistrations?.().then(function (regs) {
|
||||
regs.forEach(function (r) { r.unregister(); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
minHeight: '200px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '0.5rem', color: '#e53e3e' }}>
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p style={{ marginBottom: '1rem', color: '#718096', fontSize: '0.9rem' }}>
|
||||
An unexpected error occurred. You can try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
style={{
|
||||
padding: '0.5rem 1.25rem',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
backgroundColor: '#3182ce',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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 (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
{label && (
|
||||
<span className="mb-1 block text-xs font-semibold text-[var(--color-text-muted)]">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
className={[
|
||||
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||
isOpen
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className={value ? "" : "text-[var(--color-text-muted)]"}>
|
||||
{value ? formatDateDisplay(selectedDate) : placeholder}
|
||||
</span>
|
||||
<span className="ml-2 flex items-center gap-1">
|
||||
{value && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClear}
|
||||
className="text-[var(--color-text-muted)] hover:text-[var(--color-danger)] text-xs"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[var(--color-text-muted)]">📅</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
|
||||
style={{ minWidth: "280px" }}
|
||||
>
|
||||
{/* Header: month/year nav */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMonth}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-[var(--color-text)]">
|
||||
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextMonth}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 px-2 pt-2">
|
||||
{WEEKDAY_SHORT.map((wd) => (
|
||||
<div
|
||||
key={wd}
|
||||
className="flex h-8 items-center justify-center text-xs font-semibold text-[var(--color-text-muted)]"
|
||||
>
|
||||
{wd}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 gap-0 px-2 pb-1">
|
||||
{cells.map((day, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={day ? () => handleDayClick(day) : undefined}
|
||||
className={[
|
||||
"flex h-9 w-full items-center justify-center rounded-xl text-sm transition",
|
||||
!day
|
||||
? ""
|
||||
: isSelected(day)
|
||||
? "bg-[var(--color-accent)] text-white font-bold shadow-sm"
|
||||
: isToday(day)
|
||||
? "border border-[var(--color-accent)] text-[var(--color-accent)] font-semibold"
|
||||
: "text-[var(--color-text)] hover:bg-[var(--color-accent-soft)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{day || ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Today button */}
|
||||
<div className="border-t border-[var(--color-border)] px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToToday}
|
||||
className="w-full rounded-xl py-2 text-xs font-semibold text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition"
|
||||
>
|
||||
Сегодня
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div style={{
|
||||
background: 'var(--color-surface, #1e293d)',
|
||||
border: '1px solid var(--color-border, #334155)',
|
||||
borderRadius: '12px', padding: '8px 12px', fontSize: '0.8rem',
|
||||
color: 'var(--color-text, #e2e8f0)',
|
||||
}}>
|
||||
{tooltipLabel && <div style={{ marginBottom: '4px', fontWeight: 600 }}>{tooltipLabel}</div>}
|
||||
{payload.map((p, i) => (
|
||||
<div key={i} style={{ color: p.color }}>{p.name}: <strong>{p.value}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FunnelStep = ({ label, value, maxValue, color, pct }) => {
|
||||
if (!maxValue) return null;
|
||||
const widthPct = Math.max(18, (value / maxValue) * 100);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px', width: '100%' }}>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--color-text)', fontWeight: 700, textAlign: 'center' }}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{
|
||||
width: widthPct + '%',
|
||||
height: '32px',
|
||||
background: color,
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width 0.4s ease',
|
||||
minWidth: '50px',
|
||||
maxWidth: '100%',
|
||||
}}>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 600, color: '#fff', textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>
|
||||
{pct !== undefined ? pct : (maxValue > 0 ? Math.round((value / maxValue) * 100) : 0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: '200px' }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FunnelConnector = () => (
|
||||
<div style={{ width: '2px', height: '6px', background: 'var(--color-border)' }} />
|
||||
);
|
||||
|
||||
export const AdminDashboard = () => {
|
||||
const [period, setPeriod] = useState('7d');
|
||||
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Panel>
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted)' }}>Загрузка...</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Panel>
|
||||
<div style={{ color: 'var(--color-danger)', padding: '1rem' }}>Ошибка: {error}</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
|
||||
{/* Period selector */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.25rem' }}>Аналитика</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p>
|
||||
</div>
|
||||
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
|
||||
</div>
|
||||
|
||||
{/* KPI */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: '0.5rem' }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
<Panel key={i} style={{ padding: '0.5rem 0.75rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--color-text-muted)', marginBottom: '0.1rem' }}>{kpi.label}</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--color-text)' }}>{kpi.val ?? '—'}</div>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pie + Line */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1rem' }}>
|
||||
<Panel style={{ padding: '1rem' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>По статусам</h3>
|
||||
{statusPieData.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={statusPieData} cx="50%" cy="50%" innerRadius={40} outerRadius={80}
|
||||
dataKey="value" nameKey="name" paddingAngle={2}>
|
||||
{statusPieData.map(entry => (
|
||||
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: '0.65rem', color: 'var(--color-text-muted)' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel style={{ padding: '1rem' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>Тренд по дням</h3>
|
||||
{trendData.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: 'var(--color-text-muted)' }} />
|
||||
<YAxis tick={{ fontSize: 10, fill: 'var(--color-text-muted)' }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: '0.65rem' }} />
|
||||
<Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Status table */}
|
||||
<Panel style={{ padding: '1rem' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>Все статусы</h3>
|
||||
{statusPieData.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '10px 1fr 70px 55px',
|
||||
gap: '0 0.5rem', padding: '0.35rem 0.4rem', alignItems: 'center',
|
||||
borderBottom: '1px solid var(--color-border)', fontSize: '0.65rem',
|
||||
color: 'var(--color-text-muted)', fontWeight: 600,
|
||||
}}>
|
||||
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
|
||||
</div>
|
||||
{statusPieData.map(s => {
|
||||
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
|
||||
return (
|
||||
<div key={s.status} style={{
|
||||
display: 'grid', gridTemplateColumns: '10px 1fr 70px 55px',
|
||||
gap: '0 0.5rem', padding: '0.45rem 0.4rem', alignItems: 'center',
|
||||
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
|
||||
}}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--color-text)' }}>{s.name}</div>
|
||||
<div style={{ textAlign: 'right', fontSize: '0.78rem', fontWeight: 600, color: 'var(--color-text)' }}>{s.value}</div>
|
||||
<div style={{ textAlign: 'right', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{pct}%</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Воронка согласования */}
|
||||
<Panel style={{ padding: '1rem' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--color-text)' }}>Воронка согласования</h3>
|
||||
{totalGroups === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0', padding: '0.5rem 0' }}>
|
||||
<FunnelStep label="SMS 1 отправлено" value={econ.sms1_sent_count || 0} maxValue={totalGroups} color="#06b6d4" />
|
||||
<FunnelConnector />
|
||||
<FunnelStep label="Согласовано после SMS 1" value={econ.confirmed_after_sms1 || 0} maxValue={totalGroups} color="#22c55e" pct={econ.sms1_conversion_pct ?? 0} />
|
||||
<FunnelConnector />
|
||||
<FunnelStep label="SMS 2 отправлено" value={econ.sms2_sent_count || 0} maxValue={totalGroups} color="#0284c7" />
|
||||
<FunnelConnector />
|
||||
<FunnelStep label="Согласовано после SMS 2" value={econ.confirmed_after_sms2 || 0} maxValue={totalGroups} color="#14b8a6" pct={econ.sms2_conversion_pct ?? 0} />
|
||||
<FunnelConnector />
|
||||
<FunnelStep label="Ручное подтверждение" value={econ.confirmed_via_manual || 0} maxValue={totalGroups} color="#eab308" />
|
||||
<FunnelConnector />
|
||||
<FunnelStep label="Застряло в ручном" value={econ.stuck_in_manual || 0} maxValue={totalGroups} color="#ef4444" />
|
||||
{econ.paid_storage_count > 0 && (
|
||||
<>
|
||||
<FunnelConnector />
|
||||
<FunnelStep label="Платное хранение" value={econ.paid_storage_count || 0} maxValue={totalGroups} color="#06b6d4" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem',
|
||||
marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '0.68rem', color: '#22c55e', marginBottom: '2px' }}>Автосогласование</div>
|
||||
<div style={{ fontSize: '1.05rem', fontWeight: 700, color: '#22c55e' }}>{econ.auto_confirm_pct ?? 0}%</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '0.68rem', color: '#ef4444', marginBottom: '2px' }}>Ручное вмешательство</div>
|
||||
<div style={{ fontSize: '1.05rem', fontWeight: 700, color: '#ef4444' }}>{econ.manual_intervention_pct ?? 0}%</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '0.68rem', color: 'var(--color-text-muted)', marginBottom: '2px' }}>Всего согласовано</div>
|
||||
<div style={{ fontSize: '1.05rem', fontWeight: 700, color: 'var(--color-text)' }}>{econ.confirmed_auto_total ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Drivers */}
|
||||
<Panel style={{ padding: '1rem' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>По водителям</h3>
|
||||
{driverData.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, driverData.length * 45)}>
|
||||
<BarChart data={driverData} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: 'var(--color-text-muted)' }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10, fill: 'var(--color-text-muted)' }} width={120} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: '0.65rem' }} />
|
||||
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" />
|
||||
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<Panel>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Select value={filterRange} onChange={(e) => setFilterRange(e.target.value)} className="min-w-[100px]! text-sm! py-2!">
|
||||
{DATE_RANGES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</Select>
|
||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="min-w-[140px]! text-sm! py-2!">
|
||||
<option value="">Все типы</option>
|
||||
{availableTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</Select>
|
||||
<span style={{ color: 'var(--color-text-muted, #94a3b8)', fontSize: '0.85rem' }}>
|
||||
{errors.length} ошибок
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button onClick={fetchErrors} style={{
|
||||
background: 'transparent', border: '1px solid var(--color-border, #334155)',
|
||||
borderRadius: '9999px', padding: '0.4rem 0.75rem', cursor: 'pointer',
|
||||
fontSize: '0.85rem', color: 'var(--color-text-muted, #94a3b8)',
|
||||
}}>
|
||||
↻
|
||||
</button>
|
||||
<button onClick={handleCopyAll} disabled={errors.length === 0} style={{
|
||||
background: copied ? 'var(--color-accent, #22c55e)' : 'var(--color-accent-soft, rgba(18,128,92,0.15))',
|
||||
color: copied ? '#fff' : 'var(--color-accent, #22c55e)',
|
||||
border: '1px solid var(--color-accent, #22c55e)',
|
||||
borderRadius: '9999px', padding: '0.4rem 0.75rem', cursor: 'pointer',
|
||||
fontSize: '0.85rem', fontWeight: 600,
|
||||
}}>
|
||||
{copied ? '✓ Скопировано' : '📋 Копировать всё'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted, #94a3b8)', marginBottom: '0.5rem' }}>
|
||||
Автообновление каждые 30 сек
|
||||
</div>
|
||||
|
||||
{fetchError && (
|
||||
<div style={{
|
||||
background: 'rgba(201,61,61,0.12)', color: 'var(--color-danger, #ef4444)',
|
||||
borderRadius: '12px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.9rem',
|
||||
}}>
|
||||
{fetchError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>Загрузка…</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
{errors.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>
|
||||
Нет ошибок за выбранный период
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.map((err) => {
|
||||
const isExpanded = expandedId === err.id;
|
||||
return (
|
||||
<div
|
||||
key={err.id}
|
||||
style={{ borderBottom: '1px solid var(--color-border, #334155)', cursor: 'pointer' }}
|
||||
onClick={() => setExpandedId(isExpanded ? null : err.id)}
|
||||
>
|
||||
{/* Summary */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1.4fr 1fr 2.5fr 1.5fr',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.55rem 0.75rem',
|
||||
fontSize: '0.88rem',
|
||||
}}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)', whiteSpace: 'nowrap' }}>
|
||||
{fmtDate(err.created_at)}
|
||||
</span>
|
||||
<Badge tone={ERROR_TONES[err.error_type] || 'neutral'}>
|
||||
{err.error_type || 'Unknown'}
|
||||
</Badge>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={err.message}>
|
||||
{trunc(err.message, 120)}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={err.url}>
|
||||
{trunc(err.url, 40)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded */}
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem 1rem',
|
||||
background: 'var(--color-surface-strong, #1e293b)',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}>
|
||||
<div>
|
||||
<strong>Сообщение:</strong>
|
||||
<pre style={{
|
||||
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
fontFamily: 'monospace', fontSize: '0.82rem',
|
||||
}}>
|
||||
{err.message || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Стек:</strong>
|
||||
<pre style={{
|
||||
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
fontFamily: 'monospace', fontSize: '0.82rem',
|
||||
background: 'var(--color-surface, #0f172a)', padding: '0.5rem',
|
||||
borderRadius: '8px', border: '1px solid var(--color-border, #334155)',
|
||||
maxHeight: '250px', overflow: 'auto',
|
||||
}}>
|
||||
{err.stack || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Props:</strong>
|
||||
<pre style={{
|
||||
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
fontFamily: 'monospace', fontSize: '0.82rem',
|
||||
background: 'var(--color-surface, #0f172a)', padding: '0.5rem',
|
||||
borderRadius: '8px', border: '1px solid var(--color-border, #334155)',
|
||||
maxHeight: '180px', overflow: 'auto',
|
||||
}}>
|
||||
{err.props || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem',
|
||||
fontSize: '0.82rem', color: 'var(--color-text-muted, #94a3b8)',
|
||||
}}>
|
||||
<div><strong>URL:</strong> {err.url || '—'}</div>
|
||||
<div><strong>Компонент:</strong> {err.component || '—'}</div>
|
||||
<div><strong>Строка:</strong> {err.line_number ?? '—'}:{err.column_number ?? '—'}</div>
|
||||
<div><strong>UA:</strong> {trunc(err.user_agent, 60)}</div>
|
||||
{err.user_id && <div style={{ gridColumn: '1 / -1' }}><strong>User ID:</strong> {err.user_id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Panel>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--color-text-muted, #94a3b8)', fontSize: '0.9rem' }}>
|
||||
{users.length} пользователей
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setShowAddForm(!showAddForm); setAddError(null); }}
|
||||
style={{
|
||||
background: 'var(--color-accent, #22c55e)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '9999px',
|
||||
padding: '0.5rem 1rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{showAddForm ? '✕ Отмена' : '+ Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleAddUser} style={{
|
||||
background: 'var(--color-surface-strong, #1e293b)',
|
||||
borderRadius: '16px',
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="Имя"
|
||||
value={addForm.name}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="flex-1 min-w-[140px]!"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={addForm.email}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="flex-1 min-w-[180px]!"
|
||||
/>
|
||||
<Select
|
||||
value={addForm.role}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, role: e.target.value }))}
|
||||
className="min-w-[140px]!"
|
||||
>
|
||||
<option value="">Выберите роль…</option>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
{addError && (
|
||||
<div style={{ color: 'var(--color-danger, #ef4444)', fontSize: '0.85rem' }}>{addError}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addSubmitting}
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
background: addSubmitting ? 'var(--color-text-muted, #94a3b8)' : 'var(--color-accent, #22c55e)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '9999px',
|
||||
padding: '0.5rem 1.25rem',
|
||||
cursor: addSubmitting ? 'wait' : 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{addSubmitting ? 'Добавление…' : 'Добавить'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: 'rgba(201,61,61,0.12)',
|
||||
color: 'var(--color-danger, #ef4444)',
|
||||
borderRadius: '12px',
|
||||
padding: '0.75rem 1rem',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.9rem',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 2fr 1.2fr 1fr auto',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--color-text-muted, #94a3b8)',
|
||||
borderBottom: '1px solid var(--color-border, #334155)',
|
||||
}}>
|
||||
<span>Email</span><span>Имя</span><span>Роль</span><span>Создан</span><span></span>
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>
|
||||
Нет пользователей
|
||||
</div>
|
||||
)}
|
||||
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 2fr 1.2fr 1fr auto',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderBottom: '1px solid var(--color-border, #334155)',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.email}</span>
|
||||
<span style={{ fontWeight: 500 }}>{user.name || '—'}</span>
|
||||
|
||||
{editingRoleId === user.id ? (
|
||||
<Select
|
||||
value={getRoleName(user)}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
onBlur={() => setEditingRoleId(null)}
|
||||
autoFocus
|
||||
className="text-xs! py-1!"
|
||||
>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<span onClick={() => setEditingRoleId(user.id)} style={{ cursor: 'pointer' }}>
|
||||
<Badge tone={ROLE_TONES[getRoleName(user)] || 'neutral'}>{getRoleName(user)}</Badge>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)' }}>{fmtDate(user.created_at)}</span>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
{deleteConfirmId === user.id ? (
|
||||
<>
|
||||
<button onClick={() => handleDeleteUser(user.id)} style={{
|
||||
background: 'var(--color-danger, #ef4444)', color: '#fff', border: 'none',
|
||||
borderRadius: '8px', padding: '0.25rem 0.5rem', cursor: 'pointer', fontSize: '0.8rem',
|
||||
}}>Да</button>
|
||||
<button onClick={() => setDeleteConfirmId(null)} style={{
|
||||
background: 'transparent', color: 'var(--color-text-muted, #94a3b8)', border: '1px solid var(--color-border, #334155)',
|
||||
borderRadius: '8px', padding: '0.25rem 0.5rem', cursor: 'pointer', fontSize: '0.8rem',
|
||||
}}>Нет</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => setDeleteConfirmId(user.id)} style={{
|
||||
background: 'transparent', color: 'var(--color-danger, #ef4444)', border: '1px solid var(--color-danger, #ef4444)',
|
||||
borderRadius: '8px', padding: '0.25rem 0.5rem', cursor: 'pointer', fontSize: '0.8rem',
|
||||
}}>Удалить</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,18 +13,27 @@ import { Input } from "../UI/Input";
|
|||
import { Select } from "../UI/Select";
|
||||
import { Panel } from "../UI/Panel";
|
||||
|
||||
const CHEVRON_DOWN = (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
);
|
||||
const CHEVRON_RIGHT = (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<Panel className="space-y-3 p-5">
|
||||
|
|
@ -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 (
|
||||
<Panel key={group.date} className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 text-left"
|
||||
onClick={() => toggleDate(group.date)}
|
||||
>
|
||||
<span className="shrink-0 text-[var(--color-text-muted)] transition-transform">
|
||||
{isCollapsed ? CHEVRON_RIGHT : CHEVRON_DOWN}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-lg font-semibold capitalize">
|
||||
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
|
|
@ -305,56 +368,52 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
weekday: "long",
|
||||
}) || "Без даты"}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="neutral">
|
||||
{(() => {
|
||||
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}`;
|
||||
})()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{sortedBuckets.map(([statusValue, { label, tone, items }]) => (
|
||||
<div key={statusValue} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={tone}>{label}</Badge>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {items.length === 1 ? "группа" : "группы"}</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||
onClick={() => onOpenOrder?.(item.id)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-[var(--color-text)]">
|
||||
{item.displayTitle || item.customerName || item.groupKey}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{item.customerDate} · {item.customerPhone}
|
||||
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
{statusCounts.map(({ status, label, tone, count }) => (
|
||||
<Badge key={status} tone={tone}>{count} {label}</Badge>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-4 pt-1">
|
||||
{sortedBuckets.map(([statusValue, { label, tone, items }]) => (
|
||||
<div key={statusValue} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={tone}>{label}</Badge>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {pluralGroups(items.length)}</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||
onClick={() => onOpenOrder?.(item.id)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-[var(--color-text)]">
|
||||
{item.displayTitle || item.customerName || item.groupKey}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{item.customerDate} · {item.customerPhone}
|
||||
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<Panel className="space-y-3 p-5">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h3 className="text-lg font-semibold">Мои доставки</h3>
|
||||
<Badge tone="neutral">{deliveryCountLabel}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Показываем только назначенные вам группы доставки. Выберите дату и город.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label className="flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Дата
|
||||
</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.selectedDate}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, selectedDate: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Статус
|
||||
</span>
|
||||
<Select
|
||||
value={filters.deliveryStatus}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, deliveryStatus: event.target.value }))
|
||||
}
|
||||
>
|
||||
{DRIVER_DELIVERY_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Date pills */}
|
||||
{sortedDeliveryDates.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
|
||||
className={[
|
||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
||||
!filters.selectedDate
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
>
|
||||
Все даты
|
||||
</button>
|
||||
{sortedDeliveryDates.map((date) => {
|
||||
const count = dateDeliveryMap.get(date) || 0;
|
||||
const selected = isDateSelected(date);
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
|
||||
className={[
|
||||
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
||||
selected
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</span>
|
||||
{count > 0 && (
|
||||
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* City pills */}
|
||||
{sortedCities.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
|
||||
className={[
|
||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
||||
!filters.selectedCity
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
>
|
||||
Все города
|
||||
</button>
|
||||
{sortedCities.map((city) => {
|
||||
const count = cityDeliveryMap.get(city) || 0;
|
||||
const selected = filters.selectedCity === city;
|
||||
return (
|
||||
<button
|
||||
key={city}
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
|
||||
className={[
|
||||
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
||||
selected
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{city}</span>
|
||||
{count > 0 && (
|
||||
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{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 (
|
||||
<Panel key={group.date} className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold capitalize">
|
||||
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
}) || "Без даты"}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="neutral">
|
||||
{(() => {
|
||||
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}`;
|
||||
})()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{sortedBuckets.map(([statusValue, { label, tone, items }]) => (
|
||||
<div key={statusValue} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={tone}>{label}</Badge>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {items.length === 1 ? "группа" : "группы"}</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||
onClick={() => onOpenOrder?.(item.id)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-[var(--color-text)]">
|
||||
{item.displayTitle || item.customerName || item.groupKey}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{item.customerDate} · {item.customerPhone}
|
||||
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Panel className="p-6">
|
||||
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
Сейчас у вас нет назначенных групп доставки.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,70 +29,104 @@ 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 (
|
||||
<Panel className="p-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,1.6fr)] md:items-end">
|
||||
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isStatusOpen}
|
||||
className={[
|
||||
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||
isStatusOpen
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
onClick={() => setIsStatusOpen((current) => !current)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
|
||||
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isStatusOpen ? (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Row 1: Status + Search */}
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,1.6fr)] md:items-end">
|
||||
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isStatusOpen}
|
||||
className={[
|
||||
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||
isStatusOpen
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
onClick={() => setIsStatusOpen((current) => !current)}
|
||||
>
|
||||
{statusOptions.map((option) => {
|
||||
const isSelected = option.value === statusValue;
|
||||
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
|
||||
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">▾</span>
|
||||
</button>
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={[
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition",
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
updateFilter("displayStatus", option.value);
|
||||
setIsStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<span className="ml-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-xs font-semibold text-[var(--color-text)]">
|
||||
{option.count || 0}
|
||||
</span>
|
||||
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{isStatusOpen ? (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
|
||||
>
|
||||
{statusOptions.map((option) => {
|
||||
const isSelected = option.value === statusValue;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={[
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition",
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
updateFilter("displayStatus", option.value);
|
||||
setIsStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<span className="ml-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-xs font-semibold text-[var(--color-text)]">
|
||||
{option.count || 0}
|
||||
</span>
|
||||
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="h-[46px] py-0"
|
||||
placeholder="Поиск по группе, клиенту или телефону"
|
||||
value={filters.query}
|
||||
onChange={(event) => updateFilter("query", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date range */}
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<DatePicker
|
||||
value={filters.dateFrom || ""}
|
||||
onChange={(v) => updateFilter("dateFrom", v)}
|
||||
placeholder="С даты"
|
||||
label="Период: с"
|
||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
||||
/>
|
||||
<DatePicker
|
||||
value={filters.dateTo || ""}
|
||||
onChange={(v) => updateFilter("dateTo", v)}
|
||||
placeholder="По дату"
|
||||
label="по"
|
||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
||||
/>
|
||||
{hasDateFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDateFilter}
|
||||
className="mb-0.5 flex h-[46px] items-center gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)] transition"
|
||||
>
|
||||
✕ Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
className="h-[46px] py-0"
|
||||
placeholder="Поиск по группе, клиенту или телефону"
|
||||
value={filters.query}
|
||||
onChange={(event) => updateFilter("query", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
|
|
|
|||
|
|
@ -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 <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const renderManagerWorkspace = () => (
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<OrdersTable
|
||||
orderGroups={filteredOrderGroups}
|
||||
selectedOrderGroupId={selectedOrderGroupId}
|
||||
onOpenOrder={openGroupPage}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
statusOptions={statusOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLogisticsWorkspace = () => (
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDriverWorkspace = () => (
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<DriverDeliveryPlanner
|
||||
orderGroups={allOrderGroups}
|
||||
onOpenOrder={openGroupPage}
|
||||
currentUser={user}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderActiveSection = () => {
|
||||
if (activeSection === "guide") {
|
||||
return <ProductGuidePanel />;
|
||||
}
|
||||
if (activeSection === "guide") return <ProductGuidePanel />;
|
||||
if (activeSection === "analytics") return <div className="space-y-6 xl:space-y-8"><AdminDashboard /></div>;
|
||||
if (activeSection === "users") return <div className="space-y-6 xl:space-y-8"><UserManagementPanel /></div>;
|
||||
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
||||
|
||||
if (userRole === "driver") {
|
||||
return renderDriverWorkspace();
|
||||
return (
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<DriverDeliveryPlanner orderGroups={allOrderGroups} onOpenOrder={openGroupPage} currentUser={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (userRole === "logistician") {
|
||||
return renderLogisticsWorkspace();
|
||||
return (
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderManagerWorkspace();
|
||||
return (
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -160,17 +141,16 @@ export const DashboardPage = () => {
|
|||
onMarkNotificationRead={markNotificationRead}
|
||||
onMarkAllNotificationsRead={markAllNotificationsRead}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading && (
|
||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
Загружаем данные...
|
||||
</Panel>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
)}
|
||||
{loadError && (
|
||||
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
|
||||
Не удалось загрузить данные. Обратитесь к администратору.
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
)}
|
||||
{renderActiveSection()}
|
||||
</AppShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
Loading…
Reference in New Issue