diff --git a/src/food-market.web/src/components/DataTable.tsx b/src/food-market.web/src/components/DataTable.tsx index 96cfc78..e1d4b59 100644 --- a/src/food-market.web/src/components/DataTable.tsx +++ b/src/food-market.web/src/components/DataTable.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import { cn } from '@/lib/utils' +import { TableSkeleton } from '@/components/Skeleton' export type SortOrder = 'asc' | 'desc' @@ -80,11 +81,10 @@ export function DataTable({ {isLoading ? ( - - - Загрузка… - - + // Shimmer skeleton вместо «Загрузка…»: 8 строк с псевдо-случайной + // шириной плейсхолдеров, чтобы превью таблицы выглядело + // естественно, пока приходят данные с сервера. + ) : rows.length === 0 ? ( diff --git a/src/food-market.web/src/components/Skeleton.tsx b/src/food-market.web/src/components/Skeleton.tsx new file mode 100644 index 0000000..4abb082 --- /dev/null +++ b/src/food-market.web/src/components/Skeleton.tsx @@ -0,0 +1,92 @@ +import { cn } from '@/lib/utils' +import type { ReactNode } from 'react' + +/** + * Реюзабельный shimmer-плейсхолдер. Заменяет «Загрузка…» в data-таблицах, + * карточках и edit-страницах на анимированный серый блок — пользователь + * видит будущую структуру вместо пустого текста. + * + * Примеры: + * — линия (текст) + * — input + * — аватар + * — карточка + * + * Под капотом: 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 ( +
+ {children} +
+ ) +} + +/** + * Готовый шаблон для скелета таблицы: 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) => ( + + {Array.from({ length: columns }).map((__, c) => ( + + + + ))} + + ))} + + ) +} + +/** + * Скелет для edit-страниц: 1 заголовок + 2 «секции» с полями. + * Используется когда `existing.isLoading` пока тащит данные. + */ +export function FormSkeleton() { + return ( +
+
+ + +
+ {[0, 1].map((s) => ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ))} +
+ ) +} diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index 1a740c0..b07159d 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { SalesChart } from '@/components/SalesChart' +import { Skeleton } from '@/components/Skeleton' import { api } from '@/lib/api' import type { PagedResult, SalesStatsResponse } from '@/lib/types' @@ -138,7 +139,8 @@ export function DashboardPage() { {stats.isLoading ? ( -
Загрузка…
+ // Shimmer на месте графика: примерно той же высоты, чтобы layout не прыгал. + ) : !hasAnySales ? (
diff --git a/src/food-market.web/src/pages/DemandEditPage.tsx b/src/food-market.web/src/pages/DemandEditPage.tsx index ed79117..5aac9ae 100644 --- a/src/food-market.web/src/pages/DemandEditPage.tsx +++ b/src/food-market.web/src/pages/DemandEditPage.tsx @@ -8,6 +8,7 @@ import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox import { DateField } from '@/components/DateField' import { ProductPicker } from '@/components/ProductPicker' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores, useCurrencies } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -209,6 +210,9 @@ export function DemandEditPage() { ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } : { maximumFractionDigits: 0 } + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return (
diff --git a/src/food-market.web/src/pages/EnterEditPage.tsx b/src/food-market.web/src/pages/EnterEditPage.tsx index b912fd4..97c79ac 100644 --- a/src/food-market.web/src/pages/EnterEditPage.tsx +++ b/src/food-market.web/src/pages/EnterEditPage.tsx @@ -8,6 +8,7 @@ import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/co import { DateField } from '@/components/DateField' import { ProductPicker } from '@/components/ProductPicker' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores, useCurrencies } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -189,6 +190,9 @@ export function EnterEditPage() { ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } : { maximumFractionDigits: 0 } + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return (
diff --git a/src/food-market.web/src/pages/InventoryEditPage.tsx b/src/food-market.web/src/pages/InventoryEditPage.tsx index 1aa9e84..a92cfd3 100644 --- a/src/food-market.web/src/pages/InventoryEditPage.tsx +++ b/src/food-market.web/src/pages/InventoryEditPage.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/Button' import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Field' import { DateField } from '@/components/DateField' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores } from '@/lib/useLookups' 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 + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return (
diff --git a/src/food-market.web/src/pages/LossEditPage.tsx b/src/food-market.web/src/pages/LossEditPage.tsx index 885b8cf..c99af54 100644 --- a/src/food-market.web/src/pages/LossEditPage.tsx +++ b/src/food-market.web/src/pages/LossEditPage.tsx @@ -8,6 +8,7 @@ import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/co import { DateField } from '@/components/DateField' import { ProductPicker } from '@/components/ProductPicker' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores, useCurrencies } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -192,6 +193,9 @@ export function LossEditPage() { ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } : { maximumFractionDigits: 0 } + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return (
diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index 451371e..7228e67 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -5,6 +5,7 @@ import { api } from '@/lib/api' import { PageHeader } from '@/components/PageHeader' import { Button } from '@/components/Button' import { Field, TextInput, Select, Checkbox } from '@/components/Field' +import { FormSkeleton } from '@/components/Skeleton' import { useCountries } from '@/lib/useLookups' import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings' @@ -66,7 +67,7 @@ export function OrganizationSettingsPage() { meta: { successMessage: 'Настройки сохранены' }, }) - if (!form) return
Загрузка…
+ if (!form) return return (
diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 6db8c17..774d6bf 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -12,6 +12,7 @@ import { useOrgSettings } from '@/lib/useOrgSettings' import { BarcodeType, Packaging, type Product } from '@/lib/types' import { ProductImageGallery } from '@/components/ProductImageGallery' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode' @@ -220,6 +221,10 @@ export function ProductEditPage() { && form.barcodes.length > 0 && missingRequiredPrices.length === 0 + // На редактировании пока тащим существующий товар — показываем скелет + // вместо пустых полей формы, чтобы не путать пользователя. + if (!isNew && existing.isLoading) return + return ( {/* Sticky top bar */} diff --git a/src/food-market.web/src/pages/RetailSaleEditPage.tsx b/src/food-market.web/src/pages/RetailSaleEditPage.tsx index 8d3aaa6..d9ed513 100644 --- a/src/food-market.web/src/pages/RetailSaleEditPage.tsx +++ b/src/food-market.web/src/pages/RetailSaleEditPage.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/Button' import { Field, TextInput, TextArea, Select, AsyncSelect, MoneyInput, NumberInput } from '@/components/Field' import { ProductPicker } from '@/components/ProductPicker' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores, useCurrencies } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -208,6 +209,9 @@ export function RetailSaleEditPage() { // кнопка disabled с подсказкой. const canSave = !!form.storeId && !!form.currencyId && isDraft && form.lines.length > 0 + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return (
diff --git a/src/food-market.web/src/pages/SupplierReturnEditPage.tsx b/src/food-market.web/src/pages/SupplierReturnEditPage.tsx index 75c9c88..4172750 100644 --- a/src/food-market.web/src/pages/SupplierReturnEditPage.tsx +++ b/src/food-market.web/src/pages/SupplierReturnEditPage.tsx @@ -8,6 +8,7 @@ import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox import { DateField } from '@/components/DateField' import { ProductPicker } from '@/components/ProductPicker' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores, useCurrencies } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -195,6 +196,9 @@ export function SupplierReturnEditPage() { ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } : { maximumFractionDigits: 0 } + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return (
diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index 6b2db8b..653f425 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -9,6 +9,7 @@ import { DateField } from '@/components/DateField' import { ProductPicker } from '@/components/ProductPicker' import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -268,6 +269,9 @@ export function SupplyEditPage() { && form.lines.length > 0 && isDraft + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return ( {/* Sticky top bar */} diff --git a/src/food-market.web/src/pages/TransferEditPage.tsx b/src/food-market.web/src/pages/TransferEditPage.tsx index 977405f..e572189 100644 --- a/src/food-market.web/src/pages/TransferEditPage.tsx +++ b/src/food-market.web/src/pages/TransferEditPage.tsx @@ -8,6 +8,7 @@ import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/co import { DateField } from '@/components/DateField' import { ProductPicker } from '@/components/ProductPicker' import { ConfirmDialog } from '@/components/ConfirmDialog' +import { FormSkeleton } from '@/components/Skeleton' import { useConfirm } from '@/lib/useConfirm' import { useStores } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -177,6 +178,9 @@ export function TransferEditPage() { ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } : { maximumFractionDigits: 0 } + // На редактировании пока тащим документ — показываем скелет. + if (!isNew && existing.isLoading) return + return (