fix: automation funnel — correct data logic, remove emojis, rename to Воронка согласования

This commit is contained in:
root 2026-05-25 12:31:58 +00:00
parent 89d6a01b68
commit 40b28be0ee
17 changed files with 2107 additions and 217 deletions

View File

@ -4,6 +4,12 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --prefer-offline RUN npm install --prefer-offline
COPY . . 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 RUN npm run build
# Serve stage # Serve stage

View File

@ -3,6 +3,10 @@ services:
build: build:
context: /opt/supersam context: /opt/supersam
dockerfile: Dockerfile 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 container_name: supersam-app
restart: unless-stopped restart: unless-stopped
networks: networks:
@ -15,13 +19,11 @@ services:
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt - traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
- traefik.http.routers.supersam-app.service=supersam-app - traefik.http.routers.supersam-app.service=supersam-app
- traefik.http.services.supersam-app.loadbalancer.server.port=80 - 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.rule=Host(`dost.supersamsev.ru`)
- traefik.http.routers.supersam-app-http.entryPoints=http - traefik.http.routers.supersam-app-http.entryPoints=http
- traefik.http.routers.supersam-app-http.middlewares=redirect-to-https - 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.scheme=https
- traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true - 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-Content-Type-Options=nosniff
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY - 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 - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin

View File

@ -13,13 +13,7 @@
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" /> <link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Construction Delivery Control</title> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -20,7 +20,8 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router-dom": "7.3.0", "react-router-dom": "7.3.0",
"tailwind-merge": "3.3.0" "tailwind-merge": "3.3.0",
"recharts": "^2.15.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
@ -37,4 +38,4 @@
"vite": "^6.2.0", "vite": "^6.2.0",
"vitest": "^3.0.9" "vitest": "^3.0.9"
} }
} }

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -13,18 +13,27 @@ import { Input } from "../UI/Input";
import { Select } from "../UI/Select"; import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel"; 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) => { const extractCity = (address) => {
if (!address || typeof address !== "string") return null; if (!address || typeof address !== "string") return null;
const trimmed = address.trim(); const trimmed = address.trim();
if (!trimmed) return null; if (!trimmed) return null;
// Patterns: "г.Ялта", "г. Ялта", "г Ялта", "г Ялта ", "Ялта,", "г.Севастополь", etc.
const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i); const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i);
if (cityMatch) { if (cityMatch) {
return cityMatch[1].trim(); return cityMatch[1].trim();
} }
// Try common city names directly in the address
const knownCities = [ 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(); const firstSegment = trimmed.split(/[,;]/)[0].trim();
if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) { if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) {
return 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 }) => { export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
const [filters, setFilters] = React.useState({ const [filters, setFilters] = React.useState({
selectedDate: "", selectedDate: "",
deliveryStatus: "all", deliveryStatus: "all",
selectedCity: "", selectedCity: "",
}); });
const [collapsedDates, setCollapsedDates] = React.useState({});
const toggleDate = (date) => {
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] }));
};
const driverOrderGroups = React.useMemo( const driverOrderGroups = React.useMemo(
() => orderGroups.filter((group) => { () => orderGroups.filter((group) => {
@ -74,7 +122,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
[orderGroups, currentUser], [orderGroups, currentUser],
); );
// Build map of date -> count
const dateDeliveryMap = React.useMemo(() => { const dateDeliveryMap = React.useMemo(() => {
const map = new Map(); const map = new Map();
driverOrderGroups.forEach((group) => { driverOrderGroups.forEach((group) => {
@ -90,7 +137,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
return Array.from(dateDeliveryMap.keys()).sort(); return Array.from(dateDeliveryMap.keys()).sort();
}, [dateDeliveryMap]); }, [dateDeliveryMap]);
// Build map of city -> count
const cityDeliveryMap = React.useMemo(() => { const cityDeliveryMap = React.useMemo(() => {
const map = new Map(); const map = new Map();
driverOrderGroups.forEach((group) => { driverOrderGroups.forEach((group) => {
@ -102,7 +148,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
const sortedCities = React.useMemo(() => { const sortedCities = React.useMemo(() => {
return Array.from(cityDeliveryMap.keys()).sort((a, b) => { return Array.from(cityDeliveryMap.keys()).sort((a, b) => {
// Севастополь first, then alphabetical
if (a === "Севастополь") return -1; if (a === "Севастополь") return -1;
if (b === "Севастополь") return 1; if (b === "Севастополь") return 1;
return a.localeCompare(b, "ru"); return a.localeCompare(b, "ru");
@ -137,6 +182,15 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
const isDateSelected = (date) => filters.selectedDate === date; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Panel className="space-y-3 p-5"> <Panel className="space-y-3 p-5">
@ -271,6 +325,9 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
{groupedOrderGroups.length ? ( {groupedOrderGroups.length ? (
groupedOrderGroups.map((group) => { groupedOrderGroups.map((group) => {
const isCollapsed = collapsedDates[group.date];
const statusCounts = dateStatusSummary[group.date] || [];
// Group items by delivery status within each date // Group items by delivery status within each date
const statusBuckets = new Map(); const statusBuckets = new Map();
for (const item of group.items) { for (const item of group.items) {
@ -283,7 +340,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
statusBuckets.get(s).items.push(item); statusBuckets.get(s).items.push(item);
} }
// Sort status buckets in driver-relevant order
const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"]; const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"];
const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => { const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => {
const ia = statusOrder.indexOf(a); const ia = statusOrder.indexOf(a);
@ -296,8 +352,15 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
return ( return (
<Panel key={group.date} className="space-y-4 p-5"> <Panel key={group.date} className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <button
<div> 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"> <h4 className="text-lg font-semibold capitalize">
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", { {parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
day: "numeric", day: "numeric",
@ -305,56 +368,52 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
weekday: "long", weekday: "long",
}) || "Без даты"} }) || "Без даты"}
</h4> </h4>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
</p>
</div> </div>
<Badge tone="neutral"> <div className="flex shrink-0 flex-wrap items-center gap-1.5">
{(() => { {statusCounts.map(({ status, label, tone, count }) => (
const d = parseGroupDate(group.date); <Badge key={status} tone={tone}>{count} {label}</Badge>
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> </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> </Panel>
); );
}) })

View File

@ -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>
);
};

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { Input } from "../UI/Input"; import { Input } from "../UI/Input";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { DatePicker } from "../UI/DatePicker";
export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => { export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
const statusValue = filters.displayStatus || filters.status || "all"; const statusValue = filters.displayStatus || filters.status || "all";
@ -9,25 +10,15 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
const statusMenuRef = React.useRef(null); const statusMenuRef = React.useRef(null);
React.useEffect(() => { React.useEffect(() => {
if (!isStatusOpen) { if (!isStatusOpen) return undefined;
return undefined;
}
const handlePointerDown = (event) => { const handlePointerDown = (event) => {
if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) { if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) setIsStatusOpen(false);
setIsStatusOpen(false);
}
}; };
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
if (event.key === "Escape") { if (event.key === "Escape") setIsStatusOpen(false);
setIsStatusOpen(false);
}
}; };
document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => { return () => {
document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
@ -38,71 +29,105 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
setFilters((current) => ({ ...current, [key]: value })); setFilters((current) => ({ ...current, [key]: value }));
}; };
const hasDateFilter = filters.dateFrom || filters.dateTo;
const clearDateFilter = () => {
setFilters((current) => ({ ...current, dateFrom: "", dateTo: "" }));
};
return ( return (
<Panel className="p-4"> <Panel className="p-4">
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,1.6fr)] md:items-end"> <div className="flex flex-col gap-3">
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2"> {/* Row 1: Status + Search */}
<button <div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,1.6fr)] md:items-end">
type="button" <div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
aria-haspopup="listbox" <button
aria-expanded={isStatusOpen} type="button"
className={[ aria-haspopup="listbox"
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition", aria-expanded={isStatusOpen}
isStatusOpen className={[
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]" "flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]", isStatusOpen
].join(" ")} ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
onClick={() => setIsStatusOpen((current) => !current)} : "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
> ].join(" ")}
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span> onClick={() => setIsStatusOpen((current) => !current)}
<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"
> >
{statusOptions.map((option) => { <span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
const isSelected = option.value === statusValue; <span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]"></span>
</button>
return ( {isStatusOpen ? (
<button <div
key={option.value} role="listbox"
type="button" 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"
role="option" >
aria-selected={isSelected} {statusOptions.map((option) => {
className={[ const isSelected = option.value === statusValue;
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition", return (
isSelected <button
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]" key={option.value}
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]", type="button"
].join(" ")} role="option"
onClick={() => { aria-selected={isSelected}
updateFilter("displayStatus", option.value); className={[
setIsStatusOpen(false); "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)]"
<span className="min-w-0 flex-1 truncate">{option.label}</span> : "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
<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)]"> ].join(" ")}
{option.count || 0} onClick={() => {
</span> updateFilter("displayStatus", option.value);
{isSelected ? <span className="ml-3 text-[var(--color-accent)]"></span> : null} setIsStatusOpen(false);
</button> }}
); >
})} <span className="min-w-0 flex-1 truncate">{option.label}</span>
</div> <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)]">
) : null} {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> </div>
<Input
className="h-[46px] py-0"
placeholder="Поиск по группе, клиенту или телефону"
value={filters.query}
onChange={(event) => updateFilter("query", event.target.value)}
/>
</div> </div>
</Panel> </Panel>
); );
}; };

View File

@ -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 };
};

View File

@ -4,16 +4,21 @@ import { RouterProvider } from "react-router-dom";
import { router } from "./router"; import { router } from "./router";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import ErrorBoundary from "./components/ErrorBoundary";
import { initErrorLogging } from "./utils/errorLogger";
import { registerPwaServiceWorker } from "./hooks/usePwaStatus"; import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
import "./index.css"; import "./index.css";
registerPwaServiceWorker(); registerPwaServiceWorker();
initErrorLogging();
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<RouterProvider router={router} /> <ErrorBoundary>
<RouterProvider router={router} />
</ErrorBoundary>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode>, </React.StrictMode>,

View File

@ -3,6 +3,9 @@ import { Navigate, useNavigate } from "react-router-dom";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner"; import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { OrdersTable } from "../components/orders/OrdersTable"; 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 { Panel } from "../components/UI/Panel";
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel"; import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@ -12,28 +15,26 @@ import { usePwaStatus } from "../hooks/usePwaStatus";
import { useOrderGroups } from "../hooks/useOrderGroups"; import { useOrderGroups } from "../hooks/useOrderGroups";
import { AppShell } from "../layouts/AppShell"; 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 = { const ROLE_SECTION = {
manager: { mega_admin: { key: "analytics", label: "Аналитика" },
key: "orders", admin: { key: "analytics", label: "Аналитика", description: "Статистика доставки." },
label: "Группы", manager: { key: "orders", label: "Группы", description: "Реестр групп доставки, поиск и просмотр карточки." },
description: "Реестр групп доставки, поиск и просмотр карточки.", logistician: { key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению." },
}, driver: { key: "deliveries", label: "Мои доставки", description: "Группы доставки по датам и статусам." },
logistician: {
key: "logistics",
label: "Логистика",
description: "Группы доставки по готовности к уведомлению.",
},
driver: {
key: "deliveries",
label: "Мои доставки",
description: "Группы доставки по датам и статусам.",
},
}; };
export const DashboardPage = () => { export const DashboardPage = () => {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const userRole = user?.role; const userRole = user?.role;
const isMegaAdmin = userRole === "mega_admin";
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager; const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
const [activeSection, setActiveSection] = React.useState(section.key); const [activeSection, setActiveSection] = React.useState(section.key);
@ -45,7 +46,6 @@ export const DashboardPage = () => {
markAllAsRead: markAllNotificationsRead, markAllAsRead: markAllNotificationsRead,
} = useNotifications(user?.id); } = useNotifications(user?.id);
// Auto-restore push subscription on login
const { isSupported, isSubscribed, subscribe } = usePushNotifications(user?.id); const { isSupported, isSubscribed, subscribe } = usePushNotifications(user?.id);
React.useEffect(() => { React.useEffect(() => {
@ -77,69 +77,50 @@ export const DashboardPage = () => {
navigate("/dashboard/group/" + groupId); navigate("/dashboard/group/" + groupId);
}, [navigate]); }, [navigate]);
const navItems = [ const navItems = isMegaAdmin
{ ? MEGA_ADMIN_NAV
key: section.key, : userRole === "admin"
label: section.label, ? [
description: section.description, { key: "analytics", label: "Аналитика", description: "Статистика доставки.", badge: null },
badge: String(allOrderGroups.length || orderGroups.length || 0), { key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
}, ]
]; : [
const guideSectionMeta = { { key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
key: "guide", ];
label: "Справка",
description: "Карта продукта, роли, сценарии и частые вопросы.", const guideSectionMeta = { key: "guide", label: "Справка", description: "Карта продукта, роли, сценарии и частые вопросы." };
}; const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems.find((n) => n.key === activeSection) || navItems[0];
const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
const isGuideOpen = activeSection === "guide"; const isGuideOpen = activeSection === "guide";
if (!user) { if (!user) {
return <Navigate to="/login" replace />; 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 = () => { const renderActiveSection = () => {
if (activeSection === "guide") { if (activeSection === "guide") return <ProductGuidePanel />;
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") { 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") { if (userRole === "logistician") {
return renderLogisticsWorkspace(); return (
<div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
</div>
);
} }
return (
return renderManagerWorkspace(); <div className="space-y-6 xl:space-y-8">
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} />
</div>
);
}; };
return ( return (
@ -160,18 +141,17 @@ export const DashboardPage = () => {
onMarkNotificationRead={markNotificationRead} onMarkNotificationRead={markNotificationRead}
onMarkAllNotificationsRead={markAllNotificationsRead} 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 className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Загружаем данные... Загружаем данные...
</Panel> </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 className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
Не удалось загрузить данные. Обратитесь к администратору. Не удалось загрузить данные. Обратитесь к администратору.
</Panel> </Panel>
) : null} )}
{renderActiveSection()} {renderActiveSection()}
</AppShell> </AppShell>
); );
}; };

View File

@ -316,6 +316,10 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem }, { value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage }, { value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled }, { 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) => export const getOrderGroupStatusLabel = (status) =>
@ -334,7 +338,7 @@ export const getOrderGroupDeliveryStatusTone = (status) => {
case "loaded": case "loaded":
return "info"; return "info";
case "on_route": case "on_route":
return "accent"; return "warning";
case "delivered": case "delivered":
return "accent"; return "accent";
case "paid_storage": case "paid_storage":
@ -362,7 +366,7 @@ export const groupOrderGroupsByDate = (groups) => {
const rightTime = parseGroupDate(rightDate)?.getTime(); const rightTime = parseGroupDate(rightDate)?.getTime();
if (leftTime != null && rightTime != null && leftTime !== rightTime) { if (leftTime != null && rightTime != null && leftTime !== rightTime) {
return leftTime - rightTime; return rightTime - leftTime;
} }
return leftDate.localeCompare(rightDate); return leftDate.localeCompare(rightDate);

185
src/utils/errorLogger.js Normal file
View File

@ -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 };