Compare commits

...

1 Commits

Author SHA1 Message Date
root c52171c4a8 feat: add skeleton loading states across all pages
- New Loading.jsx component library (Skeleton, SkeletonPanel, SkeletonPage, SkeletonTable, Spinner, LoadingBlock)
- Dashboard: SkeletonTable/SkeletonPage during isLoading
- OrdersTable, LogisticsReadinessBoard, DriverDeliveryPlanner: show skeleton instead of empty state
- ChatTimeline, DeliverySlotsPicker, GroupDetailPage: skeleton while loading
- AdminDashboard, StopWordsPanel, UserManagementPanel, ErrorLogPanel, ActionLogPanel: skeleton during initial load
- NotificationSettings: skeleton for push toggle and notification preferences
- ClientDeliveryPage: skeleton bars instead of text-only loading
2026-06-12 07:45:19 +00:00
15 changed files with 388 additions and 23 deletions

View File

@ -0,0 +1,166 @@
import React from "react";
import { cn } from "../../lib/cn";
/**
* Skeleton bar a pulsing placeholder that mimics content shape.
*
* @param {'text'|'heading'|'avatar'|'card'|'table-row'} variant - Preset sizes
* @param {string} [className] - Additional CSS classes
* @param {number} [lines=1] - Number of skeleton lines (for text variant)
*/
export const Skeleton = ({ variant = "text", className, lines = 1 }) => {
const variantClasses = {
text: "h-4 rounded-lg",
heading: "h-6 rounded-lg",
avatar: "h-10 w-10 rounded-full",
card: "h-28 rounded-[24px]",
"table-row": "h-14 rounded-[16px]",
};
if (lines > 1) {
return (
<div className={cn("space-y-3", className)}>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={cn(
"animate-pulse bg-[var(--color-surface-strong)] rounded-lg",
variantClasses[variant] || variantClasses.text,
i === lines - 1 ? "w-3/4" : "w-full",
)}
/>
))}
</div>
);
}
return (
<div
className={cn(
"animate-pulse bg-[var(--color-surface-strong)] rounded-lg",
variantClasses[variant] || variantClasses.text,
className,
)}
/>
);
};
/**
* Skeleton block that mimics a Panel with header + body lines.
*
* @param {number} [lines=4] - Number of body lines
* @param {string} [className]
*/
export const SkeletonPanel = ({ lines = 4, className }) => (
<div
className={cn(
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft space-y-4",
className,
)}
>
<Skeleton variant="heading" className="w-1/3" />
<div className="space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className={i === lines - 1 ? "w-2/3" : "w-full"} />
))}
</div>
</div>
);
/**
* Full-page skeleton loader with title bar + content panels.
*
* @param {number} [panels=2] - Number of skeleton panels
* @param {string} [className]
*/
export const SkeletonPage = ({ panels = 2, className }) => (
<div className={cn("space-y-6", className)}>
<div className="space-y-2">
<Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-1/2" />
</div>
{Array.from({ length: panels }).map((_, i) => (
<SkeletonPanel key={i} />
))}
</div>
);
/**
* Skeleton that mimics a table with header + rows.
*
* @param {number} [rows=4] - Number of skeleton rows
* @param {number} [cols=4] - Number of columns
* @param {string} [className]
*/
export const SkeletonTable = ({ rows = 4, cols = 4, className }) => (
<div
className={cn(
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft overflow-hidden",
className,
)}
>
{/* Header */}
<div className="flex gap-4 bg-[var(--color-surface-strong)] px-5 py-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="flex-1 h-4" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div
key={rowIdx}
className="flex gap-4 border-t border-[var(--color-border)] px-5 py-4"
>
{Array.from({ length: cols }).map((_, colIdx) => (
<Skeleton key={colIdx} className="flex-1 h-4" />
))}
</div>
))}
</div>
);
/**
* Inline spinner a compact rotating indicator for buttons and small areas.
*
* @param {string} [size='sm'] - 'xs' | 'sm' | 'md' | 'lg'
* @param {string} [className]
*/
export const Spinner = ({ size = "sm", className }) => {
const sizeClasses = {
xs: "h-3 w-3 border-[1.5px]",
sm: "h-4 w-4 border-2",
md: "h-6 w-6 border-2",
lg: "h-8 w-8 border-[3px]",
};
return (
<span
className={cn(
"inline-block animate-spin rounded-full border-[var(--color-border)] border-t-[var(--color-accent)]",
sizeClasses[size] || sizeClasses.sm,
className,
)}
role="status"
aria-label="Загрузка"
/>
);
};
/**
* Centered loading block with spinner + optional label.
*
* @param {string} [label='Загрузка...']
* @param {string} [size] - Spinner size
* @param {string} [className]
*/
export const LoadingBlock = ({ label = "Загрузка...", size = "md", className }) => (
<div
className={cn(
"flex flex-col items-center justify-center gap-3 py-8 text-[var(--color-text-muted)]",
className,
)}
>
<Spinner size={size} />
{label && <span className="text-sm">{label}</span>}
</div>
);

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { Skeleton } from "../UI/Loading";
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
@ -351,7 +352,13 @@ export const ActionLogPanel = ({ orderGroupId = null }) => {
</table>
</div>
{loading && <div className="py-4 text-center text-sm text-[var(--color-text-muted)]">Загрузка...</div>}
{loading && !filteredLogs.length && (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="w-full h-10" />
))}
</div>
)}
</Panel>
);
};

View File

@ -1,3 +1,9 @@
/**
* @file AdminDashboard.jsx
* @description Admin analytics dashboard. Displays KPI cards, status pie chart,
* daily trend line, confirmation funnel, SMS stats, and driver performance
* bar chart. Supports period selection (1d/7d/30d/all) and mobile layout.
*/
import React, { useState, useEffect } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
@ -6,8 +12,10 @@ import {
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { SegmentedTabs } from '../UI/SegmentedTabs';
import { Skeleton } from '../UI/Loading';
import { useAdminStats } from '../../hooks/useAdminStats';
// Mobile Detection Hook
const useIsMobile = () => {
const [mobile, setMobile] = useState(false);
useEffect(() => {
@ -20,6 +28,7 @@ const useIsMobile = () => {
return mobile;
};
// Status Colour & Label Maps
const STATUS_COLORS = {
pending_confirmation: '#94a3b8',
manual_confirmation_required: '#eab308',
@ -46,6 +55,7 @@ const STATUS_LABELS = {
cancelled: 'Отменено',
};
// Period Selector Options
const PERIOD_OPTIONS = [
{ key: '1d', label: 'Сегодня' },
{ key: '7d', label: '7 дней' },
@ -53,6 +63,7 @@ const PERIOD_OPTIONS = [
{ key: 'all', label: 'Все' },
];
// Custom Recharts Tooltip
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
if (!active || !payload?.length) return null;
return (
@ -70,16 +81,36 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
);
};
// AdminDashboard Component
export const AdminDashboard = () => {
// State & Hooks
const [period, setPeriod] = useState('7d');
const mobile = useIsMobile();
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
// Loading / Error States
if (isLoading) {
return (
<Panel>
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted)' }}>Загрузка...</div>
</Panel>
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-32 h-8" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(80px, 160px))`, gap: '0.4rem' }}>
{Array.from({ length: 6 }).map((_, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<Skeleton className="w-12 h-3 mb-1" />
<Skeleton className="w-8 h-5" />
</Panel>
))}
</div>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<Skeleton variant="heading" className="w-1/3 mb-3" />
<div style={{ height: chartHeight }} className="flex items-center justify-center">
<Skeleton className="w-3/4 h-40" />
</div>
</Panel>
</div>
);
}
if (error) {
@ -90,6 +121,7 @@ export const AdminDashboard = () => {
);
}
// Data Preparation
const sv = stats || {};
const totalGroups = sv.total || 0;
const econ = economics || {};
@ -100,6 +132,7 @@ export const AdminDashboard = () => {
status: s.delivery_status,
})).filter(d => d.value > 0);
// Trend & Driver Data
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,
@ -111,6 +144,7 @@ export const AdminDashboard = () => {
}));
// Funnel: ALWAYS show all steps, even with 0 values
// Funnel Data
const funnelSteps = [
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
@ -120,7 +154,7 @@ export const AdminDashboard = () => {
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
];
// Responsive values
// Responsive Layout Values
const chartHeight = mobile ? 200 : 240;
const kpiMin = mobile ? '80px' : '110px';
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
@ -128,6 +162,7 @@ export const AdminDashboard = () => {
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' }
: { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
// Render
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { Select } from '../UI/Select';
import { Skeleton } from '../UI/Loading';
import { supabase } from '../../supabaseClient';
@ -274,7 +275,11 @@ export default function ErrorLogPanel() {
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>Загрузка</div>
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="w-full h-12 rounded-lg" />
))}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
{errors.length === 0 && (

View File

@ -1,6 +1,7 @@
import React from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { Skeleton } from "../UI/Loading";
import { supabase } from "../../supabaseClient";
export const StopWordsPanel = () => {
@ -97,7 +98,11 @@ export const StopWordsPanel = () => {
)}
{isLoading ? (
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
<div className="flex flex-wrap gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="w-20 h-8 rounded-full" />
))}
</div>
) : !words.length ? (
<p className="text-sm text-[var(--color-text-muted)]">Стоп-слов пока нет. Добавьте первое.</p>
) : (

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { Input } from '../UI/Input';
import { Skeleton } from '../UI/Loading';
import { supabase, supabaseUrl } from '../../supabaseClient';
@ -274,7 +275,18 @@ export default function UserManagementPanel() {
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—';
if (loading) {
return <Panel className="p-5"><div className="text-center py-8 text-[var(--color-text-muted)]">Загрузка</div></Panel>;
return (
<Panel className="p-5">
<div className="space-y-3">
<Skeleton variant="heading" className="w-1/4" />
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="w-full h-14 rounded-[22px]" />
))}
</div>
</div>
</Panel>
);
}
return (

View File

@ -1,8 +1,22 @@
import React from "react";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Skeleton } from "../UI/Loading";
export const ChatTimeline = ({ messages, isLoading = false }) => {
if (isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<Skeleton variant="text" className="w-1/4 mb-2" />
<Skeleton variant="text" className="w-full" />
</div>
))}
</div>
);
}
export const ChatTimeline = ({ messages }) => {
if (!messages.length) {
return (
<div className="rounded-[24px] border border-dashed border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">

View File

@ -1,6 +1,7 @@
import React from "react";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { Skeleton } from "../UI/Loading";
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting";
const groupSlotsByDate = (slots) => {
@ -55,7 +56,20 @@ export const DeliverySlotsPicker = ({
onSelectSlot,
selectedSlotId,
referenceDate = new Date(),
isLoading = false,
}) => {
if (isLoading) {
return (
<Panel className="p-5 sm:p-6">
<div className="space-y-3">
<Skeleton variant="heading" className="w-1/2" />
<Skeleton variant="text" className="w-full" />
<Skeleton variant="text" className="w-3/4" />
</div>
</Panel>
);
}
if (!slots || !slots.length) {
return (
<Panel className="p-5 sm:p-6">

View File

@ -13,6 +13,7 @@ import { Badge } from "../UI/Badge";
import { Input } from "../UI/Input";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
const CHEVRON_DOWN = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -103,7 +104,7 @@ const countByStatus = (items) => {
return result;
};
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser, isLoading = false }) => {
const [filters, setFilters] = React.useState({
selectedDate: "",
deliveryStatus: "all",
@ -193,6 +194,10 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
return summary;
}, [groupedOrderGroups]);
if (isLoading) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-4">
<Panel className="space-y-3 p-5">

View File

@ -8,10 +8,11 @@ import {
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
import { OrderFilters } from "../orders/OrderFilters";
import { formatDateTime } from "../../utils/formatters";
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS }) => {
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
@ -72,6 +73,10 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
</thead>
);
if (isLoading) {
return <SkeletonPage panels={3} />;
}
return (
<div className="space-y-6">
<Panel className="space-y-4 p-5">

View File

@ -3,6 +3,7 @@ import { useNotificationPreferences } from "../../hooks/useNotifications";
import { usePushNotifications } from "../../hooks/usePushNotifications";
import { Panel } from "../UI/Panel";
import { Bell, Settings } from "../UI/Icons";
import { Skeleton } from "../UI/Loading";
const ALL_NOTIF_TYPES = [
{ key: "order_status_change", label: "Изменение статуса", description: "Статус заказа или доставки изменился", roles: ["manager", "logistician", "driver", "admin", "mega_admin"] },
@ -21,6 +22,47 @@ export function NotificationSettings({ userId, userRole, onBack }) {
const visibleTypes = ALL_NOTIF_TYPES.filter((t) => t.roles.includes(role));
const loading = prefsLoading || pushLoading;
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
{onBack && (
<button
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={onBack}
>
Назад
</button>
)}
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
</div>
<Panel className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="w-32 h-4" />
<Skeleton className="w-48 h-3" />
</div>
<Skeleton className="w-11 h-6 rounded-full" />
</div>
</Panel>
<Panel className="p-4">
<Skeleton className="w-40 h-4 mb-4" />
<div className="space-y-3">
{Array.from({ length: visibleTypes.length || 3 }).map((_, i) => (
<div key={i} className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Skeleton className="w-32 h-4" />
<Skeleton className="w-48 h-3" />
</div>
<Skeleton className="w-11 h-6 rounded-full" />
</div>
))}
</div>
</Panel>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">

View File

@ -1,6 +1,7 @@
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonTable } from "../UI/Loading";
import { OrderFilters } from "./OrderFilters";
import {
getOrderGroupDisplayStatusLabel,
@ -37,7 +38,12 @@ export const OrdersTable = ({
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
return (
<Panel className="p-0">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">

View File

@ -6,6 +6,7 @@ import { OrderCompositionPanel } from "../components/client/OrderCompositionPane
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
import { Panel } from "../components/UI/Panel";
import { Skeleton } from "../components/UI/Loading";
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
import {
confirmDeliveryChoice,
@ -282,7 +283,18 @@ export const ClientDeliveryPage = () => {
<Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Загрузка страницы</h1>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">Подтягиваем актуальные данные по заказу.</p>
<div className="space-y-2 mt-4">
<Skeleton className="w-full" />
<Skeleton className="w-3/4" />
<Skeleton className="w-1/2" />
</div>
</Panel>
<Panel className="p-5 sm:p-6">
<div className="space-y-3">
<Skeleton variant="heading" className="w-2/3" />
<Skeleton className="w-full" />
<Skeleton className="w-1/2" />
</div>
</Panel>
</div>
</main>

View File

@ -1,3 +1,9 @@
/**
* @file DashboardPage.jsx
* @description Main dashboard page. Dispatches to role-specific sections
* (analytics, orders, logistics, driver deliveries, admin panels).
* Manages navigation, notifications, PWA status, and order-group state.
*/
import React from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
@ -9,6 +15,7 @@ import ErrorLogPanel from "../components/admin/ErrorLogPanel";
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
import { useNotifications } from "../hooks/useNotifications";
import { usePushNotifications } from "../hooks/usePushNotifications";
@ -16,6 +23,7 @@ import { usePwaStatus } from "../hooks/usePwaStatus";
import { useOrderGroups } from "../hooks/useOrderGroups";
import { AppShell } from "../layouts/AppShell";
// Navigation Config
const MEGA_ADMIN_NAV = [
{ key: "analytics", label: "Аналитика", description: "Статистика доставки, графики и показатели.", badge: null },
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: null },
@ -25,6 +33,7 @@ const MEGA_ADMIN_NAV = [
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
];
// Role Default Section Map
const ROLE_SECTION = {
mega_admin: { key: "analytics", label: "Аналитика" },
admin: { key: "analytics", label: "Аналитика", description: "Статистика доставки." },
@ -33,7 +42,9 @@ const ROLE_SECTION = {
driver: { key: "deliveries", label: "Мои доставки", description: "Группы доставки по датам и статусам." },
};
// Dashboard Component
export const DashboardPage = () => {
// Auth & Navigation
const { user, signOut } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -52,6 +63,7 @@ export const DashboardPage = () => {
}
};
// Notifications
const {
notifications,
unreadCount,
@ -68,8 +80,10 @@ export const DashboardPage = () => {
}
}, [isSupported, isSubscribed, user?.id, subscribe]);
// PWA
const { isInstalled, isInstallAvailable, installApp: onInstallApp } = usePwaStatus();
// Order Groups
const {
orderGroups,
allOrderGroups,
@ -83,6 +97,7 @@ export const DashboardPage = () => {
loadError,
} = useOrderGroups();
// Derived City List
const cities = React.useMemo(() => {
const set = new Set();
for (const g of allOrderGroups) {
@ -91,6 +106,7 @@ export const DashboardPage = () => {
return [...set].sort();
}, [allOrderGroups]);
// Navigation Builder
const openGroupPage = React.useCallback((groupId) => {
navigate("/dashboard/group/" + groupId);
}, [navigate]);
@ -115,12 +131,15 @@ export const DashboardPage = () => {
];
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
// Auth Guard
const isGuideOpen = false;
if (!user) {
return <Navigate to="/login" replace />;
}
// Section Renderer
const renderActiveSection = () => {
if (activeSection === "analytics") return <div className="space-y-6 xl:space-y-8"><AdminDashboard /></div>;
@ -129,10 +148,17 @@ export const DashboardPage = () => {
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
if (isLoading) {
if (userRole === "driver") {
return <SkeletonPage panels={3} />;
}
return <SkeletonTable rows={6} cols={5} />;
}
if (userRole === "driver") {
return (
<div className="space-y-6 xl:space-y-8">
<DriverDeliveryPlanner orderGroups={allOrderGroups} onOpenOrder={openGroupPage} currentUser={user} />
<DriverDeliveryPlanner orderGroups={allOrderGroups} onOpenOrder={openGroupPage} currentUser={user} isLoading={isLoading} />
</div>
);
}
@ -146,17 +172,18 @@ export const DashboardPage = () => {
}
return (
<div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
</div>
);
}
return (
<div className="space-y-6 xl:space-y-8">
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} isLoading={isLoading} />
</div>
);
};
// Layout
return (
<AppShell
user={user}
@ -175,11 +202,6 @@ export const DashboardPage = () => {
onMarkNotificationRead={markNotificationRead}
onMarkAllNotificationsRead={markAllNotificationsRead}
>
{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>
)}
{loadError && (
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
Не удалось загрузить данные. Обратитесь к администратору.

View File

@ -1,19 +1,28 @@
/**
* @file GroupDetailPage.jsx
* @description Detail view for a single order group. Reads groupId from URL
* params, loads drivers, and renders the OrderDetailPanel.
*/
import React from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel";
import { SkeletonPanel } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
import { fetchDrivers } from "../services/supabase/userRepository";
import { useOrderGroups } from "../hooks/useOrderGroups";
// Component
export const GroupDetailPage = () => {
// Route Params & Auth
const { groupId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const userRole = user?.role;
// Order Groups Hook
const {
allOrderGroups,
selectedOrderGroupId,
@ -22,8 +31,10 @@ export const GroupDetailPage = () => {
isSavingDeliveryChoice,
assignDriver,
changeDeliveryStatus,
isLoading,
} = useOrderGroups();
// Drivers
const [drivers, setDrivers] = React.useState([]);
React.useEffect(() => {
@ -45,11 +56,13 @@ export const GroupDetailPage = () => {
return () => { cancelled = true; };
}, []);
const order = allOrderGroups.find((g) => g.id === groupId) ||
// Order Lookup
const order = isLoading ? null : (allOrderGroups.find((g) => g.id === groupId) ||
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
null;
null);
// Preserve the tab the user came from when going back
// Navigation
const handleGoBack = React.useCallback(() => {
if (window.history.length > 1) {
navigate(-1);
@ -66,7 +79,9 @@ export const GroupDetailPage = () => {
</Button>
</div>
{order ? (
{isLoading ? (
<SkeletonPanel lines={6} />
) : order ? (
<OrderDetailPanel
order={order}
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}