Files
erp-mvp/frontend/src/pages/StockBalancesPage.tsx
T

198 lines
6.9 KiB
TypeScript

import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { catalogApi, type Product, type Warehouse } from '../api/catalogApi';
import { getApiErrorMessage } from '../api/errors';
import type { PageResponse } from '../api/types';
import { type ManualStockAdjustmentPayload, type StockBalance, warehouseApi } from '../api/warehouseApi';
import { useAuth } from '../auth/AuthContext';
import { DataTable, type TableColumn } from '../components/DataTable';
import { Layout } from '../components/Layout';
import { StockAdjustmentForm } from '../components/StockAdjustmentForm';
import { Button } from '../components/ui/Button';
import { ErrorState } from '../components/ui/ErrorState';
import { Input } from '../components/ui/Input';
import { LoadingState } from '../components/ui/LoadingState';
import { PageHeader } from '../components/ui/PageHeader';
import { Pagination } from '../components/ui/Pagination';
import { Select } from '../components/ui/Select';
import { formatDateTime, formatQuantity } from '../utils/formatters';
const PAGE_SIZE = 20;
export function StockBalancesPage() {
const { user } = useAuth();
const canAdjust = user?.role === 'ADMIN' || user?.role === 'WAREHOUSE';
const [pageData, setPageData] = useState<PageResponse<StockBalance> | null>(null);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [warehouseId, setWarehouseId] = useState('');
const [productId, setProductId] = useState('');
const [showAdjustmentForm, setShowAdjustmentForm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let isMounted = true;
async function loadFilters() {
try {
const [warehousePage, productPage] = await Promise.all([
catalogApi.listWarehouses({ page: 0, size: 100, active: true }),
catalogApi.listProducts({ page: 0, size: 100, active: true }),
]);
if (!isMounted) {
return;
}
setWarehouses(warehousePage.items);
setProducts(productPage.items);
} catch (caughtError) {
if (isMounted) {
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить фильтры'));
}
}
}
void loadFilters();
return () => {
isMounted = false;
};
}, []);
const loadData = useCallback(async () => {
setIsLoading(true);
setError('');
try {
const data = await warehouseApi.listStockBalances({
page,
size: PAGE_SIZE,
search,
warehouseId,
productId,
});
setPageData(data);
} catch (caughtError) {
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить остатки'));
} finally {
setIsLoading(false);
}
}, [page, productId, search, warehouseId]);
useEffect(() => {
void loadData();
}, [loadData]);
const columns = useMemo<TableColumn<StockBalance>[]>(
() => [
{ key: 'warehouse', label: 'Склад', render: (item) => `${item.warehouse.code} / ${item.warehouse.name}` },
{ key: 'sku', label: 'SKU товара', render: (item) => item.product.sku },
{ key: 'product', label: 'Товар', render: (item) => item.product.name },
{ key: 'unit', label: 'Ед. изм.', render: (item) => item.product.unit },
{ key: 'quantityOnHand', label: 'Остаток', render: (item) => formatQuantity(item.quantityOnHand) },
{ key: 'updatedAt', label: 'Обновлено', render: (item) => formatDateTime(item.updatedAt) },
],
[],
);
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPage(0);
setSearch(searchInput.trim());
};
const handleAdjustment = async (payload: ManualStockAdjustmentPayload) => {
await warehouseApi.manualStockAdjustment(payload);
setShowAdjustmentForm(false);
await loadData();
};
return (
<Layout title="Остатки">
<div className="space-y-6">
<PageHeader
title="Остатки"
description="Текущее количество товара по складам."
actions={
canAdjust ? (
<Button type="button" onClick={() => setShowAdjustmentForm((current) => !current)}>
Ручная корректировка
</Button>
) : null
}
/>
<form onSubmit={handleSearchSubmit} className="grid gap-3 rounded-md border border-slate-200 bg-white p-4 shadow-sm lg:grid-cols-[1fr_220px_260px_auto]">
<Input
type="search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Поиск товара или склада"
className="min-w-0"
/>
<Select
value={warehouseId}
onChange={(event) => {
setPage(0);
setWarehouseId(event.target.value);
}}
>
<option value="">Все склады</option>
{warehouses.map((warehouse) => (
<option key={warehouse.id} value={warehouse.id}>
{warehouse.code} / {warehouse.name}
</option>
))}
</Select>
<Select
value={productId}
onChange={(event) => {
setPage(0);
setProductId(event.target.value);
}}
>
<option value="">Все товары</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.sku} / {product.name}
</option>
))}
</Select>
<Button type="submit" variant="secondary">
Найти
</Button>
</form>
{showAdjustmentForm && canAdjust && (
<StockAdjustmentForm onSubmit={handleAdjustment} onCancel={() => setShowAdjustmentForm(false)} />
)}
{error && <ErrorState message={error} />}
{isLoading ? (
<LoadingState />
) : (
<DataTable items={pageData?.items ?? []} columns={columns} getRowKey={(item) => item.id} emptyTitle="Остатки не найдены" />
)}
{pageData && (
<Pagination
page={pageData.page}
totalPages={pageData.totalPages}
totalElements={pageData.totalElements}
hasPrevious={pageData.hasPrevious}
hasNext={pageData.hasNext}
onPrevious={() => setPage((current) => Math.max(current - 1, 0))}
onNext={() => setPage((current) => current + 1)}
/>
)}
</div>
</Layout>
);
}