feat(ui): фаза 4 — скелетоны PageSkeleton/ListSkeleton/CardSkeleton/FormSkeleton (мигрированы ~19 страниц вместо спиннеров), PageTransition на framer-motion (fade 150ms, MotionConfig reducedMotion=user, гейт skinComponents.motion.enabled), StaggerList для списков задач/диалогов/справочников

This commit is contained in:
2026-09-08 21:10:08 +03:00
parent c4fdfe4eab
commit 113ed458b6
24 changed files with 282 additions and 92 deletions

View File

@@ -17,7 +17,9 @@ import { useGpsTracking } from "@/hooks/useGpsTracking";
import { invalidateHomeQueriesThrottled } from "@/lib/throttledInvalidate";
import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary";
import { AppShell } from "@/components/AppShell";
import { Loader2 } from "lucide-react";
import { PageTransition } from "@/components/transitions/PageTransition";
import { PageSkeleton } from "@/components/ui/skeleton";
import { MotionConfig } from "framer-motion";
type LazyModule<T = any> = { default: ComponentType<T> };
@@ -108,12 +110,9 @@ const ShareTarget = safeLazy(() => import("@/pages/ShareTarget"));
const QrHandler = safeLazy(() => import("@/pages/QrHandler"));
const NotFound = safeLazy(() => import("@/pages/not-found"));
// Fallback подгрузки lazy-чанка: скелетон страницы вместо чистого экрана со спиннером
function PageLoader() {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
return <PageSkeleton rows={8} />;
}
// Маппинг основных роутов на русские названия для document.title (порядок важен: более специфичные — раньше)
@@ -164,6 +163,7 @@ function Router() { return (
// (Sidebar/навигация) остаётся смонтированным, fallback показывается внутри.
<AppShell>
<Suspense fallback={<PageLoader />}>
<PageTransition>
<Switch>
<Route path="/" component={Login} />
<Route path="/login" component={Login} />
@@ -225,6 +225,7 @@ function Router() { return (
<Route path="/superadmin" component={SuperAdminDashboard} />
<Route component={NotFound} />
</Switch>
</PageTransition>
</Suspense>
</AppShell>
);
@@ -342,9 +343,12 @@ function App() {
<GlobalEventListener />
<ForegroundPushHandler />
{auth.isAuthenticated && <GpsTrackingRunner />}
{/* reducedMotion="user": при «уменьшить движение» в ОС transform-анимации отключаются */}
<MotionConfig reducedMotion="user">
<GlobalErrorBoundary>
<Router />
</GlobalErrorBoundary>
</MotionConfig>
</TooltipProvider>
</PrefetchWrapper>
</OfflineSyncProvider>

View File

@@ -0,0 +1,36 @@
import { motion, useReducedMotion } from 'framer-motion';
import { useLocation } from 'wouter';
import { useSkinComponents } from '@/themes/ThemeProvider';
/**
* Переход между страницами: появление контента роута fade + лёгкий подъём
* (opacity 0→1, y 4→0) за 150 мс (шкала --duration-base / --ease-standard).
* key = location: при смене пути контент перемонтируется и проигрывает появление.
* Хром (Sidebar) снаружи — не анимируется.
* Отключается скином (components.motion.enabled === false) и при включённом
* «уменьшить движение» в ОС (useReducedMotion) — рендер без motion.
*/
export function PageTransition({ children }: { children: React.ReactNode }) {
const [location] = useLocation();
const { motion: motionCfg } = useSkinComponents();
const reducedMotion = useReducedMotion();
if (motionCfg.enabled === false || reducedMotion) {
return <>{children}</>;
}
return (
// flex-1/min-h-0 — чтобы страницы с h-full/flex-цепочками (Layout, чаты) не ломались
<motion.div
key={location}
className="flex flex-col flex-1 min-h-0"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, ease: [0.2, 0, 0, 1] }}
>
{children}
</motion.div>
);
}
export default PageTransition;

View File

@@ -0,0 +1,51 @@
import { motion, type Variants } from 'framer-motion';
import { useSkinComponents } from '@/themes/ThemeProvider';
// Появление списка каскадом: каждый элемент — fade + подъём (opacity 0→1, y 4→0),
// задержка между элементами 25 мс. reducedMotion («уменьшить движение» в ОС)
// учитывается автоматически через MotionConfig reducedMotion="user" в App:
// transform-анимации отключаются, остаётся только мгновенный fade.
const listVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: 0.025 } },
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 4 },
visible: { opacity: 1, y: 0, transition: { duration: 0.15, ease: [0.2, 0, 0, 1] } },
};
// HTML-пропсы без drag/animation-хендлеров: у motion.div они имеют другую сигнатуру
interface StaggerProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onDragEnter' | 'onDragLeave' | 'onDragOver' | 'onDrop' |
'onAnimationStart' | 'onAnimationEnd' | 'onAnimationIteration' | 'onTransitionEnd'> {
children: React.ReactNode;
}
/** Контейнер списка с каскадным появлением элементов (StaggerItem внутри). */
export function StaggerList({ children, className, ...props }: StaggerProps) {
const { motion: motionCfg } = useSkinComponents();
if (motionCfg.enabled === false) {
return <div className={className} {...props}>{children}</div>;
}
return (
<motion.div className={className} variants={listVariants} initial="hidden" animate="visible" {...props}>
{children}
</motion.div>
);
}
/** Элемент каскадного списка. Должен быть прямым потомком StaggerList. */
export function StaggerItem({ children, className, ...props }: StaggerProps) {
const { motion: motionCfg } = useSkinComponents();
if (motionCfg.enabled === false) {
return <div className={className} {...props}>{children}</div>;
}
return (
<motion.div className={className} variants={itemVariants} {...props}>
{children}
</motion.div>
);
}
export default StaggerList;

View File

@@ -12,4 +12,92 @@ function Skeleton({
)
}
export { Skeleton }
/** Скелетон списка: N строк «иконка + две строки текста» в рамке с разделителями. */
function ListSkeleton({
rows = 6,
withAvatar = false,
className,
}: {
rows?: number
withAvatar?: boolean
className?: string
}) {
return (
<div className={cn("divide-y divide-border border border-border rounded-md overflow-hidden", className)}>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
{withAvatar && <Skeleton className="h-8 w-8 rounded-full shrink-0" />}
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3.5 w-2/5" />
<Skeleton className="h-3 w-3/4" />
</div>
<Skeleton className="h-3 w-10 shrink-0 hidden sm:block" />
</div>
))}
</div>
)
}
/** Скелетон страницы: шапка (заголовок + действие) и список строк под ней. */
function PageSkeleton({
rows = 6,
className,
}: {
rows?: number
className?: string
}) {
return (
<div className={cn("flex flex-col flex-1 min-h-0", className)}>
<div className="px-4 pt-4 pb-3 border-b border-border flex items-center justify-between gap-2 shrink-0">
<div className="space-y-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-7 w-24" />
</div>
<div className="p-4">
<ListSkeleton rows={rows} />
</div>
</div>
)
}
/** Скелетон карточки: заголовок и несколько строк контента. */
function CardSkeleton({
lines = 3,
className,
}: {
lines?: number
className?: string
}) {
return (
<div className={cn("rounded-md border bg-card p-4 space-y-3", className)}>
<Skeleton className="h-4 w-1/3" />
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className={cn("h-3", i % 2 === 0 ? "w-full" : "w-2/3")} />
))}
</div>
)
}
/** Скелетон формы: сетка «label + поле», как в форме создания/редактирования. */
function FormSkeleton({
fields = 6,
className,
}: {
fields?: number
className?: string
}) {
return (
<div className={cn("grid md:grid-cols-2 gap-4", className)}>
{Array.from({ length: fields }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-8 w-full" />
</div>
))}
</div>
)
}
export { Skeleton, PageSkeleton, ListSkeleton, CardSkeleton, FormSkeleton }

View File

@@ -14,6 +14,7 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Zap, Plus, Pencil, Trash2, Play, ChevronRight, Circle, X, Terminal } from 'lucide-react';
import { ListSkeleton } from '@/components/ui/skeleton';
type Automation = {
@@ -364,7 +365,7 @@ export function AutomationsContent() {
</details>
{isLoading ? (
<div className="text-xs text-muted-foreground py-4 text-center">Загрузка...</div>
<ListSkeleton rows={4} />
) : items.length === 0 ? (
<div className="border border-dashed rounded-md py-8 text-center text-xs text-muted-foreground">
<Zap className="w-5 h-5 mx-auto mb-2 opacity-40" />

View File

@@ -21,6 +21,7 @@ import { ru } from "date-fns/locale";
import { apiRequest } from "@/lib/queryClient";
import { formatAmount } from "@/lib/format";
import { useToast } from "@/hooks/use-toast";
import { ListSkeleton } from "@/components/ui/skeleton";
interface BillingSummary {
balance: string;
@@ -275,7 +276,7 @@ export default function Billing() {
</CardHeader>
<CardContent className="p-0">
{loadingPayments ? (
<div className="p-8 text-center text-muted-foreground">Загрузка...</div>
<div className="p-4"><ListSkeleton rows={3} /></div>
) : payments.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">Платежей пока нет</div>
) : (
@@ -320,7 +321,7 @@ export default function Billing() {
</CardHeader>
<CardContent className="p-0">
{loadingTx ? (
<div className="p-8 text-center text-muted-foreground">Загрузка...</div>
<div className="p-4"><ListSkeleton rows={3} /></div>
) : transactions.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">Операций пока нет</div>
) : (

View File

@@ -21,6 +21,7 @@ import { HighlightMatch } from '@/components/chat/HighlightMatch';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { formatDateHeader } from '@/components/chat/chat-utils';
import { useChatController } from '@/components/chat/useChatController';
import { StaggerList, StaggerItem } from '@/components/transitions/StaggerList';
// ── Main Chat Page ────────────────────────────────────────────────────────────
@@ -237,7 +238,7 @@ export default function ChatPage() {
Здесь появятся ваши диалоги. Начните новый чат кнопкой «+».
</div>
)}
<div className="p-1 space-y-0.5">
<StaggerList className="p-1 space-y-0.5">
{conversations.map(conv => {
const isActive = conv.id === activeConvId;
const lastMsgText = conv.lastMessage?.isDeleted
@@ -245,7 +246,7 @@ export default function ChatPage() {
: (conv.lastMessage?.message?.trim() || (conv.lastMessage?.attachments?.length ? '📎 Вложение' : ''));
return (
<div
<StaggerItem
key={conv.id}
onClick={() => handleSelectConv(conv.id)}
className={cn(
@@ -313,10 +314,10 @@ export default function ChatPage() {
<p className="text-xs text-muted-foreground truncate">{lastMsgText}</p>
)}
</div>
</div>
</StaggerItem>
);
})}
</div>
</StaggerList>
</ScrollArea>
</div>

View File

@@ -1,7 +1,8 @@
import { useParams, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
import { Loader2, AlertTriangle, FileCode } from 'lucide-react';
import { AlertTriangle, FileCode } from 'lucide-react';
import { PageSkeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/hooks/useAuth';
import { useEffect } from 'react';
import Layout from '@/components/Layout';
@@ -74,9 +75,7 @@ export default function CustomPageView() {
if (pageLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
<PageSkeleton />
</Layout>
);
}

View File

@@ -9,6 +9,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ArrowLeft, Download, Pencil, Smartphone } from 'lucide-react';
import { FormSkeleton, Skeleton } from '@/components/ui/skeleton';
import FormAccessTab from '@/components/FormAccessTab';
import { useFormEditorController } from '@/components/form-editor/useFormEditorController';
import { MainTab } from '@/components/form-editor/MainTab';
@@ -50,8 +51,13 @@ const FormEditor = () => {
if (isLoading) {
return (
<Layout>
<div className="p-6">
<div className="text-center">Загрузка...</div>
<div className="p-6 space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-64" />
</div>
<Skeleton className="h-9 w-full max-w-xl" />
<FormSkeleton fields={8} />
</div>
</Layout>
);

View File

@@ -12,6 +12,7 @@ import { Plus, Search, Folder, X, FileText } from 'lucide-react';
import { BatchTaskQrDialog } from '@/components/BatchTaskQrDialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FolderTreeWithItems } from '@/components/folders/FolderTreeWithItems';
import { ListSkeleton } from '@/components/ui/skeleton';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -357,7 +358,7 @@ const Forms = () => {
</div>
<div className="flex-1 overflow-auto p-0">
{isLoading ? (
<div className="text-center py-6 text-muted-foreground">Загрузка форм...</div>
<div className="p-1.5"><ListSkeleton rows={6} /></div>
) : mobileFilteredForms.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
{searchQuery ? 'Нет результатов по заданным фильтрам' : 'Формы не найдены'}
@@ -443,7 +444,7 @@ const Forms = () => {
{/* Tree */}
<div className="flex-1 overflow-auto p-1.5">
{isLoading ? (
<div className="text-center py-6 text-muted-foreground">Загрузка форм...</div>
<div className="p-1.5"><ListSkeleton rows={6} /></div>
) : forms.length === 0 && folders.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
{searchQuery ? 'Нет результатов по заданным фильтрам' : 'Формы не найдены'}

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useLocation } from 'wouter';
import { Loader2 } from 'lucide-react';
import Layout from '@/components/Layout';
import { PageSkeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/hooks/useAuth';
import UserProfilePage from '@/pages/UserProfilePage';
@@ -23,9 +23,7 @@ export default function MyProfilePage() {
if (isLoading || !user) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<PageSkeleton />
</Layout>
);
}

View File

@@ -11,7 +11,8 @@ import {
useMarkAllNotificationsRead,
} from '@/lib/notification-utils';
import { Button } from '@/components/ui/button';
import { Bell, Check, CheckCheck, Loader2 } from 'lucide-react';
import { Bell, Check, CheckCheck } from 'lucide-react';
import { ListSkeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
// Сколько уведомлений показываем за раз (кнопка «Показать ещё»)
@@ -97,10 +98,7 @@ export default function NotificationsPage() {
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" aria-hidden="true" />
<span className="text-xs">Загрузка уведомлений...</span>
</div>
<ListSkeleton rows={6} withAvatar />
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Bell className="w-8 h-8 mb-2 opacity-40" aria-hidden="true" />

View File

@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { Settings as SettingsIcon, Loader2, ArrowLeft, Key, Copy, Check, Bot, BookOpen, ExternalLink, Trash2, Zap, Link2, Plus, Database, RefreshCw, BrainCircuit, Eye, EyeOff, CheckCircle2, XCircle, ServerCog, Pencil, Download, HardDrive, ChevronDown, ChevronUp, Palette, ScrollText } from 'lucide-react';
import { FormSkeleton, ListSkeleton, PageSkeleton, Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Label } from '@/components/ui/label';
@@ -218,7 +219,10 @@ function OrgConnectionsContent() {
</div>
<div className="p-3">
{isLoading ? (
<div className="flex items-center justify-center py-4"><Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /></div>
<div className="space-y-2 py-1">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : connections.length === 0 ? (
<div className="text-center py-4 text-xs text-muted-foreground">Нет подключений</div>
) : (
@@ -552,7 +556,7 @@ function RagSettingsContent() {
const isOllama = provider === 'ollama';
if (isLoading) {
return <div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
return <FormSkeleton fields={6} />;
}
return (
@@ -1404,7 +1408,7 @@ function LlmProvidersContent() {
};
if (isLoading) {
return <div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
return <FormSkeleton fields={6} />;
}
const needsBaseUrl = form.providerType === 'openai_compatible' || form.providerType === 'ollama';
@@ -1701,8 +1705,8 @@ const Settings = () => {
return (
<Layout>
<main className="flex-1 overflow-auto p-6">
<div className="max-w-4xl mx-auto flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<div className="max-w-4xl mx-auto">
<PageSkeleton />
</div>
</main>
</Layout>
@@ -1844,8 +1848,9 @@ const Settings = () => {
Эти ключи созданы ранее и продолжают работать с полным доступом.
</p>
{isLoadingApiKeys ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<div className="space-y-2 py-1">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-4 text-xs text-muted-foreground">Нет ранее созданных ключей</div>

View File

@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Shield, LogOut, Building2, Users, FileText, Clock, ToggleLeft, ToggleRight, ChevronRight, RefreshCw, Wallet, AlertTriangle, UserPlus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { ListSkeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
@@ -254,7 +255,7 @@ export default function SuperAdminDashboard() {
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 text-center text-slate-400">Загрузка...</div>
<div className="p-4"><ListSkeleton rows={6} /></div>
) : isError ? (
<div className="p-8 text-center text-red-400">Ошибка загрузки данных</div>
) : orgs.length === 0 ? (
@@ -383,7 +384,7 @@ export default function SuperAdminDashboard() {
</CardHeader>
<CardContent className="p-0">
{adminsLoading ? (
<div className="p-8 text-center text-slate-400">Загрузка...</div>
<div className="p-4"><ListSkeleton rows={4} /></div>
) : adminsError ? (
<div className="p-8 text-center text-red-400">Ошибка загрузки данных</div>
) : admins.length === 0 ? (

View File

@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { CardSkeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { formatAmount } from "@/lib/format";
@@ -203,7 +204,10 @@ export default function SuperAdminOrgDetail() {
<main className="p-6 max-w-4xl mx-auto space-y-6">
{isLoading ? (
<div className="text-center text-slate-400 py-12">Загрузка...</div>
<div className="space-y-6 py-2">
<CardSkeleton lines={4} />
<CardSkeleton lines={3} />
</div>
) : isError || !org ? (
<div className="text-center text-red-400 py-12">Ошибка загрузки данных</div>
) : (

View File

@@ -35,6 +35,8 @@ import {
import { Plus, Search, Table2, Trash2, MoreHorizontal, Folder, X, GripVertical, FileText } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FolderTreeWithItems } from '@/components/folders/FolderTreeWithItems';
import { ListSkeleton } from '@/components/ui/skeleton';
import { StaggerList, StaggerItem } from '@/components/transitions/StaggerList';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import type { DataTable } from '@shared/schema';
@@ -444,7 +446,7 @@ export default function Tables() {
</div>
<div className="flex-1 overflow-auto p-0">
{isLoading ? (
<div className="text-center py-6 text-muted-foreground">Загрузка справочников...</div>
<div className="p-1.5"><ListSkeleton rows={6} /></div>
) : mobileFilteredTables.length === 0 ? (
<div className="border border-dashed border-border rounded m-4 p-8 text-center">
<Table2 className="w-10 h-10 mx-auto text-muted-foreground mb-3" />
@@ -460,10 +462,10 @@ export default function Tables() {
)}
</div>
) : (
<div className="divide-y divide-border">
<StaggerList className="divide-y divide-border">
{mobileFilteredTables.map((table) => (
<StaggerItem key={table.id}>
<button
key={table.id}
className="w-full text-left px-3 py-2.5 hover:bg-muted flex items-center gap-2 text-sm"
onClick={() => setLocation(`/directories/${table.id}`)}
>
@@ -471,8 +473,9 @@ export default function Tables() {
<span className="flex-1 truncate">{table.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0">{table.columns.length} кол.</span>
</button>
</StaggerItem>
))}
</div>
</StaggerList>
)}
</div>
</div>
@@ -578,7 +581,7 @@ export default function Tables() {
<div className="flex-1 overflow-auto p-0">
{isLoading ? (
<div className="text-center py-6 text-muted-foreground">Загрузка справочников...</div>
<div className="p-1.5"><ListSkeleton rows={6} /></div>
) : filteredTables.length === 0 ? (
<div className="border border-dashed border-border rounded m-4 p-8 text-center">
<Table2 className="w-10 h-10 mx-auto text-muted-foreground mb-3" />

View File

@@ -5,7 +5,9 @@ import Layout from '@/components/Layout';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Plus, ChevronRight, FileText, Loader2, CheckSquare, ArrowRight } from 'lucide-react';
import { Plus, ChevronRight, FileText, CheckSquare, ArrowRight } from 'lucide-react';
import { ListSkeleton, Skeleton } from '@/components/ui/skeleton';
import { StaggerList, StaggerItem } from '@/components/transitions/StaggerList';
interface TaskItem {
id: number;
@@ -62,9 +64,9 @@ const Tasks = () => {
<div className="flex items-center justify-between">
<div>
<h1 className="text-sm font-medium text-foreground">Задачи</h1>
<p className="text-[10px] text-muted-foreground">
{isLoading ? 'Загрузка...' : `${tasks.length} задач в системе`}
</p>
<div className="text-[10px] text-muted-foreground">
{isLoading ? <Skeleton className="h-3 w-24" /> : `${tasks.length} задач в системе`}
</div>
</div>
<Button
className="h-7 px-2.5 text-xs"
@@ -78,17 +80,15 @@ const Tasks = () => {
</div>
{isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<ListSkeleton rows={8} />
) : tasks.length === 0 ? (
<EmptyState forms={activeForms} onCreateClick={() => setCreateDialogOpen(true)} />
) : (
<div className="border border-border rounded divide-y divide-border overflow-hidden">
<StaggerList className="border border-border rounded divide-y divide-border overflow-hidden">
{tasks.map(task => {
const form = formMap.get(task.formId);
return (
<div
<StaggerItem
key={task.id}
className="flex items-center gap-2 p-2.5 hover:bg-muted/40 cursor-pointer transition-colors"
onClick={() => setLocation(`/forms/${task.formId}/tasks/${task.id}`)}
@@ -113,10 +113,10 @@ const Tasks = () => {
{timeAgo(task.updatedAt)}
</div>
<ChevronRight className="w-3 h-3 text-muted-foreground shrink-0" />
</div>
</StaggerItem>
);
})}
</div>
</StaggerList>
)}
</div>

View File

@@ -15,6 +15,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { useToast } from '@/hooks/use-toast';
import { FIELD_TYPE_DEFINITIONS } from '@shared/field-types';
import { ArrowLeft, Plus, Trash2, Save, Loader2, GripVertical } from 'lucide-react';
import { FormSkeleton, Skeleton } from '@/components/ui/skeleton';
import { Link } from 'wouter';
import {
DndContext,
@@ -362,8 +363,12 @@ export default function UserFieldsEditor() {
if (tabsLoading || fieldsLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="max-w-5xl mx-auto p-4 md:p-6 space-y-6">
<div className="space-y-1.5">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-3 w-96 max-w-full" />
</div>
<FormSkeleton fields={8} />
</div>
</Layout>
);

View File

@@ -14,6 +14,7 @@ import { isAdmin } from '@/lib/permissions';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { cn } from '@/lib/utils';
import { ArrowLeft, Loader2, User as UserIcon, Mail, Phone, Shield, FileText, History, Calendar, Clock, CheckCircle2, Circle, UserCheck, Plus } from 'lucide-react';
import { ListSkeleton, PageSkeleton } from '@/components/ui/skeleton';
import { Link } from 'wouter';
import {
ProfileSystemTextRow,
@@ -457,9 +458,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<PageSkeleton />
</Layout>
);
}
@@ -843,9 +842,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
{/* Dossier tab */}
<TabsContent value="dossier" className="space-y-4">
{linkedTasksLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<ListSkeleton rows={4} />
) : (
<DossierTab userId={userId} tasks={linkedTasksData?.tasks || []} />
)}
@@ -854,9 +851,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
{/* History tab */}
<TabsContent value="history" className="space-y-4">
{auditLogLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<ListSkeleton rows={4} />
) : (
<HistoryTab auditLog={auditLogData?.auditLog || []} />
)}

View File

@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Pencil, Plus, Trash2, BellRing, Satellite } from 'lucide-react';
import { ListSkeleton, Skeleton } from '@/components/ui/skeleton';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { invalidateGps, useGpsAssetSubscribers, useGpsAssets, useOrgUsers } from '@/hooks/useGps';
@@ -142,9 +143,7 @@ export function GpsAssetsTab() {
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div className="p-3"><ListSkeleton rows={5} /></div>
) : assets.length === 0 ? (
<p className="text-center text-sm text-muted-foreground py-8">
Нет объектов. Добавьте машину или человека для отслеживания.
@@ -416,8 +415,10 @@ function SubscribersDialog({ asset, onClose }: { asset: GpsAsset | null; onClose
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center h-24">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<div className="space-y-2 py-1">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-2/3" />
</div>
) : (
<div className="space-y-3">

View File

@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Pencil, Trash2, Undo2, Check, Hexagon } from 'lucide-react';
import { ListSkeleton } from '@/components/ui/skeleton';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap';
@@ -209,9 +210,7 @@ export function GpsGeozonesTab() {
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center h-24">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
<ListSkeleton rows={4} />
) : geozones.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">
Геозон пока нет. Нажмите «Нарисовать зону» и кликайте по карте.
@@ -417,9 +416,7 @@ function GeozoneEventsJournal({ geozones }: { geozones: GpsGeozone[] }) {
</Select>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-24">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
<ListSkeleton rows={4} />
) : events.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">Событий пока нет</p>
) : (

View File

@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
import { ListSkeleton } from '@/components/ui/skeleton';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { invalidateGps, useGpsAssets, useGpsGroups } from '@/hooks/useGps';
@@ -89,9 +90,7 @@ export function GpsGroupsTab() {
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div className="p-3"><ListSkeleton rows={4} /></div>
) : groups.length === 0 ? (
<p className="text-center text-sm text-muted-foreground py-8">
Групп пока нет. Группы удобны для фильтрации объектов на карте.

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, MapPin, Save, SlidersHorizontal, X } from 'lucide-react';
import { FormSkeleton } from '@/components/ui/skeleton';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
@@ -103,9 +104,7 @@ export function GpsSettingsTab() {
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center h-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
<FormSkeleton fields={2} className="grid-cols-1 md:grid-cols-1" />
) : (
<>
<div className="space-y-1">
@@ -306,9 +305,7 @@ function DefaultCityCard({
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center h-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
<FormSkeleton fields={2} className="grid-cols-1 md:grid-cols-1" />
) : (
<>
{hasCenter && (

View File

@@ -1,8 +1,9 @@
import { useLocation } from 'wouter';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent } from '@/components/ui/card';
import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings, ShieldAlert, Loader2 } from 'lucide-react';
import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings, ShieldAlert } from 'lucide-react';
import Layout from '@/components/Layout';
import { PageSkeleton } from '@/components/ui/skeleton';
import { useGpsConfig } from '@/hooks/useGps';
import { GpsMapTab } from './MapTab';
import { GpsAssetsTab } from './AssetsTab';
@@ -39,9 +40,7 @@ export default function GpsPage() {
if (configLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<PageSkeleton />
</Layout>
);
}