diff --git a/frontend/src/components/Acts.tsx b/frontend/src/components/Acts.tsx index de03f9b..cf08554 100644 --- a/frontend/src/components/Acts.tsx +++ b/frontend/src/components/Acts.tsx @@ -6,7 +6,6 @@ import { Button, Space, App, - Empty, Modal, DatePicker, Select, @@ -28,6 +27,7 @@ import { InfoCircleOutlined, FileDoneOutlined, } from '@ant-design/icons'; +import { TableSkeleton, EmptyState } from './ui'; import { actsApi, contractsApi, @@ -393,15 +393,26 @@ const Acts: React.FC = () => { - - rowKey="id" - columns={columns} - dataSource={acts} - loading={loading} - pagination={{ pageSize: 12, showSizeChanger: true, showTotal: (t) => `Всего: ${t}` }} - locale={{ emptyText: }} - onRow={(r) => ({ onClick: () => openDetail(r) })} - /> + {loading && acts.length === 0 ? ( + + ) : ( + + rowKey="id" + columns={columns} + dataSource={acts} + loading={loading} + pagination={{ pageSize: 12, showSizeChanger: true, showTotal: (t) => `Всего: ${t}` }} + locale={{ + emptyText: ( + + ), + }} + onRow={(r) => ({ onClick: () => openDetail(r) })} + /> + )} {/* Модалка карточки акта */} diff --git a/frontend/src/components/Applications.tsx b/frontend/src/components/Applications.tsx index 3559102..50a0181 100644 --- a/frontend/src/components/Applications.tsx +++ b/frontend/src/components/Applications.tsx @@ -2,8 +2,9 @@ import React, { useState, useCallback, useEffect } from 'react'; import styled from '@emotion/styled'; import dayjs from 'dayjs'; import { - Table, Button, Space, App, Empty, Modal, Input, DatePicker, Popconfirm, Tag, + Table, Button, Space, App, Modal, Input, DatePicker, Popconfirm, Tag, } from 'antd'; +import { TableSkeleton, EmptyState } from './ui'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, ReloadOutlined, DeleteOutlined, FileTextOutlined, @@ -106,15 +107,26 @@ const Applications: React.FC = () => { - - rowKey="id" - dataSource={data} - columns={columns} - loading={loading} - pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (t) => `Всего: ${t}` }} - locale={{ emptyText: }} - size="middle" - /> + {loading && data.length === 0 ? ( + + ) : ( + + rowKey="id" + dataSource={data} + columns={columns} + loading={loading} + pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (t) => `Всего: ${t}` }} + locale={{ + emptyText: ( + + ), + }} + size="middle" + /> + )} { if (loading) { return (
-
Загрузка записей...
+
); } diff --git a/frontend/src/components/Arrivals.tsx b/frontend/src/components/Arrivals.tsx index 3bf8154..a1da896 100644 --- a/frontend/src/components/Arrivals.tsx +++ b/frontend/src/components/Arrivals.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import styled from '@emotion/styled'; import { Table, Select, Button, Space, App, Input, Tabs } from 'antd'; +import { TableSkeleton, EmptyState } from './ui'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { apiInstance } from '../shared/api/instance'; @@ -360,16 +361,27 @@ const Arrivals: React.FC = () => { {/* ─── Primary ─── */} {tab === 'primary' && ( - - dataSource={primaryVisits} - columns={primaryCols} - rowKey="id" - size="small" - pagination={false} - loading={loading} - locale={{ emptyText: 'Нет первичных приходов' }} - scroll={{ x: 900 }} - /> + {loading && primaryVisits.length === 0 ? ( + + ) : ( + + dataSource={primaryVisits} + columns={primaryCols} + rowKey="id" + size="small" + pagination={false} + loading={loading} + locale={{ + emptyText: ( + + ), + }} + scroll={{ x: 900 }} + /> + )} )} @@ -408,16 +420,27 @@ const Arrivals: React.FC = () => { )} - - dataSource={existingVisits} - columns={existingCols} - rowKey="id" - size="small" - pagination={false} - loading={loading} - locale={{ emptyText: 'Нет приходов действующих клиентов' }} - scroll={{ x: 600 }} - /> + {loading && existingVisits.length === 0 ? ( + + ) : ( + + dataSource={existingVisits} + columns={existingCols} + rowKey="id" + size="small" + pagination={false} + loading={loading} + locale={{ + emptyText: ( + + ), + }} + scroll={{ x: 600 }} + /> + )} )} diff --git a/frontend/src/components/Cases.tsx b/frontend/src/components/Cases.tsx index 940c839..0c1c2c3 100644 --- a/frontend/src/components/Cases.tsx +++ b/frontend/src/components/Cases.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { Table, Tag, Button, Modal, Select, message, Drawer, Descriptions, List, Typography, Space, Empty, Tabs } from 'antd'; +import { Table, Tag, Button, Modal, Select, message, Drawer, Descriptions, List, Typography, Space, Tabs } from 'antd'; +import { TableSkeleton, EmptyState } from './ui'; import { useAuth } from '../shared/lib/hooks/useAuth'; import { buildApiUrl, getAuthHeaders } from '../shared/utils/apiUtils'; @@ -358,14 +359,25 @@ const Cases: React.FC = () => { // ---------- Docs cases table ---------- const renderDocsTab = () => ( <> - + {loading && rows.length === 0 ? ( + + ) : ( +
+ ), + }} + /> + )} { // ---------- Representation tab ---------- const renderRepresentationTab = () => ( <> -
+ {repLoading && repRows.length === 0 ? ( + + ) : ( +
+ ), + }} + /> + )} { @@ -825,7 +826,7 @@ const Documents: React.FC = ({ contractId, headless = false }) = )} {!headless && (loading ? ( -
Загрузка договоров...
+ ) : filteredDocuments.length > 0 ? (
@@ -880,9 +881,10 @@ const Documents: React.FC = ({ contractId, headless = false }) =
) : ( -
-

Документы не найдены

-
+ ))} {/* Модальное окно для создания нового договора */} diff --git a/frontend/src/components/Employees.tsx b/frontend/src/components/Employees.tsx index c67b162..af064da 100644 --- a/frontend/src/components/Employees.tsx +++ b/frontend/src/components/Employees.tsx @@ -6,6 +6,7 @@ import { useAuth } from "../shared/lib/hooks/useAuth"; import "./Lawyers.css"; import "./Experts.css"; import "./Employees.css"; +import { TableSkeleton, EmptyState } from "./ui"; import "./EmployeesPolish.css"; interface StaffMember { @@ -596,7 +597,7 @@ const Employees = () => { {loading ? ( -

Загрузка…

+ ) : filteredEmployees.length > 0 ? (
{filteredEmployees.map((emp) => ( @@ -606,7 +607,10 @@ const Employees = () => { ))}
) : ( -

Сотрудники не найдены

+ )} {selectedEmployee && ( diff --git a/frontend/src/components/Expenses.tsx b/frontend/src/components/Expenses.tsx index eb413bd..3d8fa9a 100644 --- a/frontend/src/components/Expenses.tsx +++ b/frontend/src/components/Expenses.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { buildApiUrl, getAuthHeaders } from '../shared/utils/apiUtils'; import { useAuth } from '../shared/lib/hooks/useAuth'; +import { TableSkeleton } from './ui'; import "./Expenses.css"; interface SalaryDetail { @@ -427,7 +428,7 @@ const Expenses: React.FC = () => { )} {loading ? ( -
{'\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0440\u0430\u0441\u0445\u043e\u0434\u043e\u0432...'}
+ ) : summary ? (
diff --git a/frontend/src/components/Materials.tsx b/frontend/src/components/Materials.tsx index 8a14067..fefd811 100644 --- a/frontend/src/components/Materials.tsx +++ b/frontend/src/components/Materials.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import "./Materials.css"; import { buildApiUrl, getAuthHeaders } from "../shared/utils/apiUtils"; import { useAuth } from "../shared/lib/hooks/useAuth"; +import { TableSkeleton } from "./ui"; interface Employee { id: number; @@ -401,7 +402,7 @@ const Materials: React.FC = () => { {loading ? ( -
Загрузка материалов дел...
+ ) : (
{cases.length > 0 ? ( diff --git a/frontend/src/components/MyCases.tsx b/frontend/src/components/MyCases.tsx index 44d7787..976a204 100644 --- a/frontend/src/components/MyCases.tsx +++ b/frontend/src/components/MyCases.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState, useCallback } from 'react'; import { Table, Tag, Button, Modal, Select, Input, DatePicker, message, - Drawer, Descriptions, Timeline, Space, Empty, Card, Statistic, Form, Popconfirm + Drawer, Descriptions, Timeline, Space, Card, Statistic, Form, Popconfirm } from 'antd'; +import { TableSkeleton, EmptyState } from './ui'; import type { ColumnsType } from 'antd/es/table'; import { FileTextOutlined, PlusOutlined, ReloadOutlined, @@ -281,15 +282,26 @@ const MyCases: React.FC = () => {
-
`Всего: ${total}` }} - locale={{ emptyText: }} - size="middle" - /> + {loading && cases.length === 0 ? ( + + ) : ( +
`Всего: ${total}` }} + locale={{ + emptyText: ( + + ), + }} + size="middle" + /> + )} {/* Drawer — детали дела */} when the section is genuinely empty + * (not loading). Renders ~140-180px tall so users immediately see "пусто" + * rather than wondering if it's still loading. + */ +type Props = { + title?: string; + description?: string; + icon?: React.ReactNode; + action?: React.ReactNode; + className?: string; +}; + +const EmptyState: React.FC = ({ + title = 'Пока пусто', + description, + icon, + action, + className, +}) => { + return ( +
+ +
{title}
+ {description && ( +
{description}
+ )} + {action &&
{action}
} +
+ ); +}; + +export default EmptyState; diff --git a/frontend/src/components/ui/TableSkeleton.css b/frontend/src/components/ui/TableSkeleton.css new file mode 100644 index 0000000..cb35113 --- /dev/null +++ b/frontend/src/components/ui/TableSkeleton.css @@ -0,0 +1,104 @@ +/* Shimmering placeholder bars with light/dark theme support. */ + +.skeleton-table { + width: 100%; + display: flex; + flex-direction: column; + gap: 14px; + padding: 4px 0; +} + +.skeleton-table__toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 4px; +} + +.skeleton-table__toolbar-search { + flex: 1; + max-width: 320px; + height: 32px; + border-radius: 8px; +} + +.skeleton-table__toolbar-btn { + width: 110px; + height: 32px; + border-radius: 8px; +} + +.skeleton-table__grid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.skeleton-table__row { + display: grid; + grid-template-columns: repeat(var(--cols, 5), 1fr); + gap: 14px; + align-items: center; + padding: 10px 4px; + border-bottom: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.06)); +} + +.skeleton-table__row--head { + padding-bottom: 14px; + border-bottom: 1px solid var(--border-strong, rgba(0, 0, 0, 0.1)); +} + +.skeleton-table__cell { + height: 14px; + width: var(--bar-w, 80%); + border-radius: 6px; +} + +.skeleton-table__cell--head { + height: 12px; + width: 60%; + opacity: 0.85; +} + +/* Shimmer keyframes — single source of truth, reused in EmptyState too. */ +.skeleton-shimmer { + display: inline-block; + position: relative; + overflow: hidden; + background: linear-gradient( + 90deg, + rgba(140, 140, 160, 0.12) 0%, + rgba(140, 140, 160, 0.22) 50%, + rgba(140, 140, 160, 0.12) 100% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.4s ease-in-out infinite; +} + +@keyframes skeleton-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* Dark theme — slightly brighter band over a darker base. */ +:root[data-theme='dark'] .skeleton-shimmer, +.dark .skeleton-shimmer { + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.06) 0%, + rgba(255, 255, 255, 0.14) 50%, + rgba(255, 255, 255, 0.06) 100% + ); + background-size: 200% 100%; +} + +/* Respect reduced-motion users. */ +@media (prefers-reduced-motion: reduce) { + .skeleton-shimmer { + animation: none; + } +} diff --git a/frontend/src/components/ui/TableSkeleton.test.tsx b/frontend/src/components/ui/TableSkeleton.test.tsx new file mode 100644 index 0000000..29e182f --- /dev/null +++ b/frontend/src/components/ui/TableSkeleton.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import TableSkeleton from './TableSkeleton'; +import EmptyState from './EmptyState'; + +describe('TableSkeleton', () => { + it('renders default 6 rows × 5 cols including a header row', () => { + const { container } = render(); + const rows = container.querySelectorAll('.skeleton-table__row'); + expect(rows.length).toBe(6 + 1); + expect( + container.querySelectorAll('.skeleton-table__row--head').length + ).toBe(1); + }); + + it('respects rows/cols overrides and skips header when withHeader=false', () => { + const { container } = render( + + ); + const rows = container.querySelectorAll('.skeleton-table__row'); + expect(rows.length).toBe(3); + expect( + container.querySelectorAll('.skeleton-table__row--head').length + ).toBe(0); + const firstRowCells = rows[0].querySelectorAll('.skeleton-table__cell'); + expect(firstRowCells.length).toBe(4); + }); + + it('exposes aria-busy and aria-live for screen readers', () => { + const { getByRole } = render(); + const status = getByRole('status'); + expect(status.getAttribute('aria-busy')).toBe('true'); + expect(status.getAttribute('aria-live')).toBe('polite'); + }); + + it('renders a toolbar shimmer block when withToolbar=true', () => { + const { container } = render(); + expect( + container.querySelectorAll('.skeleton-table__toolbar').length + ).toBe(1); + }); +}); + +describe('EmptyState', () => { + it('renders title and description', () => { + const { getByText } = render( + + ); + expect(getByText('Нет данных')).toBeTruthy(); + expect(getByText('Пока ничего нет.')).toBeTruthy(); + }); + + it('uses default title when none provided', () => { + const { getByText } = render(); + expect(getByText('Пока пусто')).toBeTruthy(); + }); + + it('renders custom action when provided', () => { + const { getByText } = render( + Создать} /> + ); + expect(getByText('Создать')).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ui/TableSkeleton.tsx b/frontend/src/components/ui/TableSkeleton.tsx new file mode 100644 index 0000000..0f21523 --- /dev/null +++ b/frontend/src/components/ui/TableSkeleton.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import './TableSkeleton.css'; + +/** + * Skeleton placeholder for a data table while it's loading. + * Renders shimmering bars in a grid shape (rows × cols), so the user + * sees the future content layout, not an opaque spinner. + * + * Works for both Antd-Table tables and plain HTML
sections. + * Drop in like: + * {loading ? :
} + */ +type Props = { + rows?: number; + cols?: number; + /** Show a header row of slightly darker bars at the top. */ + withHeader?: boolean; + /** Show a faint "tool row" (search/buttons) above the header. */ + withToolbar?: boolean; + /** Optional className for outer wrapper. */ + className?: string; +}; + +const TableSkeleton: React.FC = ({ + rows = 6, + cols = 5, + withHeader = true, + withToolbar = false, + className, +}) => { + return ( +
+ {withToolbar && ( +
+ + + +
+ )} + +
+ {withHeader && ( +
+ {Array.from({ length: cols }).map((_, i) => ( + + ))} +
+ )} + + {Array.from({ length: rows }).map((_, r) => ( +
+ {Array.from({ length: cols }).map((_, c) => ( + + ))} +
+ ))} +
+
+ ); +}; + +export default TableSkeleton; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index e69de29..505695d 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,2 @@ +export { default as TableSkeleton } from './TableSkeleton'; +export { default as EmptyState } from './EmptyState';