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
This commit is contained in:
parent
69a2023ec1
commit
55422ec65a
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,10 +12,12 @@ 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';
|
||||
import { usePickupStats } from '../../hooks/usePickupStats';
|
||||
import { PickupStatsPanel } from './PickupStatsPanel';
|
||||
|
||||
// ── Mobile Detection Hook ───────────────────────────────────────────────────
|
||||
const useIsMobile = () => {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
|
|
@ -22,6 +30,7 @@ const useIsMobile = () => {
|
|||
return mobile;
|
||||
};
|
||||
|
||||
// ── Status Colour & Label Maps ─────────────────────────────────────────────
|
||||
const STATUS_COLORS = {
|
||||
pending_confirmation: '#94a3b8',
|
||||
manual_confirmation_required: '#eab308',
|
||||
|
|
@ -50,6 +59,7 @@ const STATUS_LABELS = {
|
|||
pickup: 'Самовывоз',
|
||||
};
|
||||
|
||||
// ── Period Selector Options ────────────────────────────────────────────────
|
||||
const PERIOD_OPTIONS = [
|
||||
{ key: '1d', label: 'Сегодня' },
|
||||
{ key: '7d', label: '7 дней' },
|
||||
|
|
@ -57,6 +67,7 @@ const PERIOD_OPTIONS = [
|
|||
{ key: 'all', label: 'Все' },
|
||||
];
|
||||
|
||||
// ── Custom Recharts Tooltip ─────────────────────────────────────────────────
|
||||
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
|
|
@ -74,17 +85,37 @@ 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);
|
||||
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
|
||||
|
||||
// ── Loading / Error States ─────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Panel>
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted)' }}>Загрузка...</div>
|
||||
<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) {
|
||||
|
|
@ -95,6 +126,7 @@ export const AdminDashboard = () => {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Data Preparation ──────────────────────────────────────────────────────
|
||||
const sv = stats || {};
|
||||
const totalGroups = sv.total || 0;
|
||||
const econ = economics || {};
|
||||
|
|
@ -105,6 +137,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,
|
||||
|
|
@ -116,6 +149,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' },
|
||||
|
|
@ -125,7 +159,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';
|
||||
|
|
@ -133,6 +167,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' }}>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
@ -162,7 +163,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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -64,7 +65,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">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,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,
|
||||
|
|
@ -316,7 +317,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>
|
||||
|
|
|
|||
|
|
@ -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, useLocation } from "react-router-dom";
|
||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||
|
|
@ -10,6 +16,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
|||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||
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";
|
||||
|
|
@ -17,6 +24,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 },
|
||||
|
|
@ -27,6 +35,7 @@ const MEGA_ADMIN_NAV = [
|
|||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||
];
|
||||
|
||||
// ── Role → Default Section Map ─────────────────────────────────────────────
|
||||
const ROLE_SECTION = {
|
||||
mega_admin: { key: "analytics", label: "Аналитика" },
|
||||
admin: { key: "analytics", label: "Аналитика", description: "Статистика доставки." },
|
||||
|
|
@ -35,6 +44,7 @@ const ROLE_SECTION = {
|
|||
driver: { key: "deliveries", label: "Мои доставки", description: "Группы доставки по датам и статусам." },
|
||||
};
|
||||
|
||||
// ── Dashboard Component ────────────────────────────────────────────────────
|
||||
export const DashboardPage = () => {
|
||||
const { user, signOut, isSessionLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
|
@ -55,6 +65,7 @@ export const DashboardPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────────────────────
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
|
|
@ -71,8 +82,10 @@ export const DashboardPage = () => {
|
|||
}
|
||||
}, [isSupported, isSubscribed, user?.id, subscribe]);
|
||||
|
||||
// ── PWA ────────────────────────────────────────────────────────────────────
|
||||
const { isInstalled, isInstallAvailable, installApp: onInstallApp } = usePwaStatus();
|
||||
|
||||
// ── Order Groups ─────────────────────────────────────────────────────────
|
||||
const {
|
||||
orderGroups,
|
||||
allOrderGroups,
|
||||
|
|
@ -86,6 +99,7 @@ export const DashboardPage = () => {
|
|||
loadError,
|
||||
} = useOrderGroups();
|
||||
|
||||
// ── Derived City List ─────────────────────────────────────────────────────
|
||||
const cities = React.useMemo(() => {
|
||||
const set = new Set();
|
||||
for (const g of allOrderGroups) {
|
||||
|
|
@ -94,6 +108,7 @@ export const DashboardPage = () => {
|
|||
return [...set].sort();
|
||||
}, [allOrderGroups]);
|
||||
|
||||
// ── Navigation Builder ────────────────────────────────────────────────────
|
||||
const openGroupPage = React.useCallback((groupId) => {
|
||||
navigate("/dashboard/group/" + groupId);
|
||||
}, [navigate]);
|
||||
|
|
@ -120,6 +135,8 @@ export const DashboardPage = () => {
|
|||
];
|
||||
|
||||
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
|
||||
|
||||
// ── Auth Guard ────────────────────────────────────────────────────────────
|
||||
const isGuideOpen = false;
|
||||
|
||||
const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"];
|
||||
|
|
@ -137,6 +154,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
|||
return <Navigate to="/forbidden" replace />;
|
||||
}
|
||||
|
||||
// ── Section Renderer ──────────────────────────────────────────────────────
|
||||
const renderActiveSection = () => {
|
||||
|
||||
if (activeSection === "analytics") return <div className="space-y-6 xl:space-y-8"><AdminDashboard /></div>;
|
||||
|
|
@ -146,10 +164,17 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
|||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></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>
|
||||
);
|
||||
}
|
||||
|
|
@ -163,17 +188,18 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
|||
}
|
||||
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}
|
||||
|
|
@ -192,11 +218,6 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
|||
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)]">
|
||||
Не удалось загрузить данные. Обратитесь к администратору.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
/**
|
||||
* @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 { Navigate, 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";
|
||||
|
|
@ -10,6 +16,7 @@ import { useOrderGroups } from "../hooks/useOrderGroups";
|
|||
const ALLOWED_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"];
|
||||
|
||||
export const GroupDetailPage = () => {
|
||||
// ── Route Params & Auth ───────────────────────────────────────────────────
|
||||
const { groupId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
|
@ -25,8 +32,10 @@ export const GroupDetailPage = () => {
|
|||
isSavingDeliveryChoice,
|
||||
assignDriver,
|
||||
changeDeliveryStatus,
|
||||
isLoading,
|
||||
} = useOrderGroups();
|
||||
|
||||
// ── Drivers ────────────────────────────────────────────────────────────────
|
||||
const [drivers, setDrivers] = React.useState([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -49,6 +58,11 @@ export const GroupDetailPage = () => {
|
|||
}, []);
|
||||
|
||||
// ALL hooks must be called before any early return (Rules of Hooks)
|
||||
const order = isLoading ? null : (allOrderGroups.find((g) => g.id === groupId) ||
|
||||
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
|
||||
null);
|
||||
|
||||
// Preserve the tab the user came from when going back
|
||||
const handleGoBack = React.useCallback(() => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
|
|
@ -84,7 +98,9 @@ export const GroupDetailPage = () => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{order ? (
|
||||
{isLoading ? (
|
||||
<SkeletonPanel lines={6} />
|
||||
) : order ? (
|
||||
<OrderDetailPanel
|
||||
order={order}
|
||||
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue