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:
@@ -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>
|
||||
|
||||
36
client/src/components/transitions/PageTransition.tsx
Normal file
36
client/src/components/transitions/PageTransition.tsx
Normal 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;
|
||||
51
client/src/components/transitions/StaggerList.tsx
Normal file
51
client/src/components/transitions/StaggerList.tsx
Normal 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;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? 'Нет результатов по заданным фильтрам' : 'Формы не найдены'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 || []} />
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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">
|
||||
Групп пока нет. Группы удобны для фильтрации объектов на карте.
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user