sync: migrate erp-mvp to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user