feat(web): loading skeletons вместо «Загрузка…» в DataTable + edit-pages
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Item 4 Sprint 7 — shimmer-плейсхолдеры вместо текстовых лоадеров.

Компоненты (src/components/Skeleton.tsx):
- <Skeleton variant='line'|'block'|'circle' /> — базовый pulse-блок.
- <TableSkeleton rows cols /> — 8 строк × N колонок с псевдослучайной
  шириной плейсхолдеров, чтобы превью таблицы выглядело естественно.
- <FormSkeleton /> — заголовок + 2 секции по 6 полей.

DataTable: при isLoading=true теперь рендерит TableSkeleton (а не
«Загрузка…»). На list-страницах layout остаётся стабильным.

Edit-pages: добавил guard
  if (!isNew && existing.isLoading) return <FormSkeleton />
на 9 doc-edit pages (ProductEdit, DemandEdit, EnterEdit, InventoryEdit,
LossEdit, SupplierReturnEdit, TransferEdit, SupplyEdit, RetailSaleEdit) +
OrganizationSettingsPage. До этого они показывали пустые поля formы или
«Загрузка…».

DashboardPage: график выручки во время загрузки теперь Skeleton block
72rem высоты (вместо текста «Загрузка…»).

tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 11:03:08 +05:00
parent 56dd9fb639
commit faa13521e8
13 changed files with 139 additions and 7 deletions

View file

@ -1,6 +1,7 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TableSkeleton } from '@/components/Skeleton'
export type SortOrder = 'asc' | 'desc' export type SortOrder = 'asc' | 'desc'
@ -80,11 +81,10 @@ export function DataTable<T>({
</thead> </thead>
<tbody> <tbody>
{isLoading ? ( {isLoading ? (
<tr> // Shimmer skeleton вместо «Загрузка…»: 8 строк с псевдо-случайной
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400"> // шириной плейсхолдеров, чтобы превью таблицы выглядело
Загрузка // естественно, пока приходят данные с сервера.
</td> <TableSkeleton rows={8} columns={columns.length} />
</tr>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400"> <td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">

View file

@ -0,0 +1,92 @@
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
/**
* Реюзабельный shimmer-плейсхолдер. Заменяет «Загрузка» в data-таблицах,
* карточках и edit-страницах на анимированный серый блок пользователь
* видит будущую структуру вместо пустого текста.
*
* Примеры:
* <Skeleton className="h-4 w-32" /> линия (текст)
* <Skeleton className="h-9 w-full" /> input
* <Skeleton variant="circle" className="w-10 h-10" /> аватар
* <Skeleton variant="block" className="h-48 w-full" /> карточка
*
* Под капотом: bg-slate-200 + анимация pulse. На dark slate-700.
*/
interface SkeletonProps {
className?: string
variant?: 'line' | 'block' | 'circle'
children?: ReactNode
}
export function Skeleton({ className, variant = 'line', children }: SkeletonProps) {
return (
<div
aria-busy="true"
role="status"
className={cn(
'animate-pulse bg-slate-200/80 dark:bg-slate-700/60',
variant === 'circle' ? 'rounded-full' : variant === 'block' ? 'rounded-lg' : 'rounded',
className,
)}
>
{children}
</div>
)
}
/**
* Готовый шаблон для скелета таблицы: N строк × M колонок, ширина рандомная
* по seed чтобы выглядело естественно. Заменяет «Загрузка» в DataTable.
*/
export function TableSkeleton({ rows = 8, columns = 5 }: { rows?: number; columns?: number }) {
// Псевдо-random ширины — стабильные между рендерами и реалистичные.
const widths = ['w-24', 'w-32', 'w-40', 'w-20', 'w-16', 'w-28']
return (
<>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: columns }).map((__, c) => (
<td
key={c}
className="px-3 sm:px-4 py-2.5 border-b border-slate-100 dark:border-slate-800"
>
<Skeleton className={cn('h-4', widths[(r + c) % widths.length])} />
</td>
))}
</tr>
))}
</>
)
}
/**
* Скелет для edit-страниц: 1 заголовок + 2 «секции» с полями.
* Используется когда `existing.isLoading` пока тащит данные.
*/
export function FormSkeleton() {
return (
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-32" />
</div>
{[0, 1].map((s) => (
<section
key={s}
className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-9 w-full" />
</div>
))}
</div>
</section>
))}
</div>
)
}

View file

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react' import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { SalesChart } from '@/components/SalesChart' import { SalesChart } from '@/components/SalesChart'
import { Skeleton } from '@/components/Skeleton'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { PagedResult, SalesStatsResponse } from '@/lib/types' import type { PagedResult, SalesStatsResponse } from '@/lib/types'
@ -138,7 +139,8 @@ export function DashboardPage() {
</div> </div>
</div> </div>
{stats.isLoading ? ( {stats.isLoading ? (
<div className="h-72 flex items-center justify-center text-slate-400 text-sm">Загрузка</div> // Shimmer на месте графика: примерно той же высоты, чтобы layout не прыгал.
<Skeleton variant="block" className="h-72 w-full" />
) : !hasAnySales ? ( ) : !hasAnySales ? (
<div className="h-72 flex flex-col items-center justify-center text-slate-400 text-sm gap-2"> <div className="h-72 flex flex-col items-center justify-center text-slate-400 text-sm gap-2">
<Receipt className="w-8 h-8 text-slate-300" /> <Receipt className="w-8 h-8 text-slate-300" />

View file

@ -8,6 +8,7 @@ import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups' import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -209,6 +210,9 @@ export function DemandEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 } ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 } : { maximumFractionDigits: 0 }
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">

View file

@ -8,6 +8,7 @@ import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/co
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups' import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -189,6 +190,9 @@ export function EnterEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 } ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 } : { maximumFractionDigits: 0 }
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">

View file

@ -7,6 +7,7 @@ import { Button } from '@/components/Button'
import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Field' import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores } from '@/lib/useLookups' import { useStores } from '@/lib/useLookups'
import { InventoryStatus, type InventoryDto } from '@/lib/types' import { InventoryStatus, type InventoryDto } from '@/lib/types'
@ -205,6 +206,9 @@ export function InventoryEditPage() {
const canPost = isDraft && form.lines.some((l) => l.diff !== 0) && !isNew const canPost = isDraft && form.lines.some((l) => l.diff !== 0) && !isNew
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">

View file

@ -8,6 +8,7 @@ import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/co
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups' import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -192,6 +193,9 @@ export function LossEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 } ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 } : { maximumFractionDigits: 0 }
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">

View file

@ -5,6 +5,7 @@ import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Field, TextInput, Select, Checkbox } from '@/components/Field' import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { FormSkeleton } from '@/components/Skeleton'
import { useCountries } from '@/lib/useLookups' import { useCountries } from '@/lib/useLookups'
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
@ -66,7 +67,7 @@ export function OrganizationSettingsPage() {
meta: { successMessage: 'Настройки сохранены' }, meta: { successMessage: 'Настройки сохранены' },
}) })
if (!form) return <div className="p-6 text-sm text-slate-500">Загрузка</div> if (!form) return <FormSkeleton />
return ( return (
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">

View file

@ -12,6 +12,7 @@ import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types' import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery' import { ProductImageGallery } from '@/components/ProductImageGallery'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode' import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
@ -220,6 +221,10 @@ export function ProductEditPage() {
&& form.barcodes.length > 0 && form.barcodes.length > 0
&& missingRequiredPrices.length === 0 && missingRequiredPrices.length === 0
// На редактировании пока тащим существующий товар — показываем скелет
// вместо пустых полей формы, чтобы не путать пользователя.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
{/* Sticky top bar */} {/* Sticky top bar */}

View file

@ -7,6 +7,7 @@ import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, AsyncSelect, MoneyInput, NumberInput } from '@/components/Field' import { Field, TextInput, TextArea, Select, AsyncSelect, MoneyInput, NumberInput } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups' import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -208,6 +209,9 @@ export function RetailSaleEditPage() {
// кнопка disabled с подсказкой. // кнопка disabled с подсказкой.
const canSave = !!form.storeId && !!form.currencyId && isDraft && form.lines.length > 0 const canSave = !!form.storeId && !!form.currencyId && isDraft && form.lines.length > 0
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">

View file

@ -8,6 +8,7 @@ import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups' import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -195,6 +196,9 @@ export function SupplierReturnEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 } ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 } : { maximumFractionDigits: 0 }
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">

View file

@ -9,6 +9,7 @@ import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd' import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups' import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -268,6 +269,9 @@ export function SupplyEditPage() {
&& form.lines.length > 0 && form.lines.length > 0
&& isDraft && isDraft
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
{/* Sticky top bar */} {/* Sticky top bar */}

View file

@ -8,6 +8,7 @@ import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/co
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useStores } from '@/lib/useLookups' import { useStores } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -177,6 +178,9 @@ export function TransferEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 } ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 } : { maximumFractionDigits: 0 }
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">