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 ./
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 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,70 +29,104 @@ 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>,
|
||||||
|
|
|
||||||
|
|
@ -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,17 +141,16 @@ 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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