sync: migrate erp-mvp to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { CustomersPage } from './pages/CustomersPage';
|
||||
import { CustomerOrderDetailsPage } from './pages/CustomerOrderDetailsPage';
|
||||
import { CustomerOrdersPage } from './pages/CustomerOrdersPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { DocumentsPage } from './pages/DocumentsPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { PurchaseOrderDetailsPage } from './pages/PurchaseOrderDetailsPage';
|
||||
import { PurchaseOrdersPage } from './pages/PurchaseOrdersPage';
|
||||
import { ProductsPage } from './pages/ProductsPage';
|
||||
import { StockBalancesPage } from './pages/StockBalancesPage';
|
||||
import { StockMovementsPage } from './pages/StockMovementsPage';
|
||||
import { SuppliersPage } from './pages/SuppliersPage';
|
||||
import { WarehousesPage } from './pages/WarehousesPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/catalog/products" element={<ProductsPage />} />
|
||||
<Route path="/catalog/suppliers" element={<SuppliersPage />} />
|
||||
<Route path="/catalog/customers" element={<CustomersPage />} />
|
||||
<Route path="/catalog/warehouses" element={<WarehousesPage />} />
|
||||
<Route path="/procurement/purchase-orders" element={<PurchaseOrdersPage />} />
|
||||
<Route path="/procurement/purchase-orders/:id" element={<PurchaseOrderDetailsPage />} />
|
||||
<Route path="/sales/customer-orders" element={<CustomerOrdersPage />} />
|
||||
<Route path="/sales/customer-orders/:id" element={<CustomerOrderDetailsPage />} />
|
||||
<Route path="/warehouse/stock-balances" element={<StockBalancesPage />} />
|
||||
<Route path="/warehouse/stock-movements" element={<StockMovementsPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { apiClient } from './client';
|
||||
import type { ApiResponse, PageResponse } from './types';
|
||||
|
||||
export type CatalogListParams = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
unit: string;
|
||||
barcode: string | null;
|
||||
description: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ProductPayload = {
|
||||
sku?: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
unit: string;
|
||||
barcode?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type Supplier = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
bin: string | null;
|
||||
contactName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
address: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type SupplierPayload = {
|
||||
companyName: string;
|
||||
bin?: string;
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export type Customer = Supplier;
|
||||
export type CustomerPayload = SupplierPayload;
|
||||
|
||||
export type Warehouse = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WarehousePayload = {
|
||||
code?: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
async function listResource<T>(path: string, params: CatalogListParams) {
|
||||
const response = await apiClient.get<ApiResponse<PageResponse<T>>>(path, {
|
||||
params: {
|
||||
page: params.page ?? 0,
|
||||
size: params.size ?? 20,
|
||||
search: params.search ?? '',
|
||||
active: params.active ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error?.message ?? 'Не удалось загрузить данные справочника');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async function createResource<TPayload, TResponse>(path: string, payload: TPayload) {
|
||||
const response = await apiClient.post<ApiResponse<TResponse>>(path, payload);
|
||||
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error?.message ?? 'Не удалось создать запись');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async function updateResource<TPayload, TResponse>(path: string, id: string, payload: TPayload) {
|
||||
const response = await apiClient.put<ApiResponse<TResponse>>(`${path}/${id}`, payload);
|
||||
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error?.message ?? 'Не удалось обновить запись');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async function deleteResource(path: string, id: string) {
|
||||
await apiClient.delete(`${path}/${id}`);
|
||||
}
|
||||
|
||||
export const catalogApi = {
|
||||
listProducts: (params: CatalogListParams) => listResource<Product>('/api/catalog/products', params),
|
||||
createProduct: (payload: ProductPayload) => createResource<ProductPayload, Product>('/api/catalog/products', payload),
|
||||
updateProduct: (id: string, payload: Omit<ProductPayload, 'sku'>) =>
|
||||
updateResource<Omit<ProductPayload, 'sku'>, Product>('/api/catalog/products', id, payload),
|
||||
deleteProduct: (id: string) => deleteResource('/api/catalog/products', id),
|
||||
|
||||
listSuppliers: (params: CatalogListParams) => listResource<Supplier>('/api/catalog/suppliers', params),
|
||||
createSupplier: (payload: SupplierPayload) => createResource<SupplierPayload, Supplier>('/api/catalog/suppliers', payload),
|
||||
updateSupplier: (id: string, payload: SupplierPayload) =>
|
||||
updateResource<SupplierPayload, Supplier>('/api/catalog/suppliers', id, payload),
|
||||
deleteSupplier: (id: string) => deleteResource('/api/catalog/suppliers', id),
|
||||
|
||||
listCustomers: (params: CatalogListParams) => listResource<Customer>('/api/catalog/customers', params),
|
||||
createCustomer: (payload: CustomerPayload) => createResource<CustomerPayload, Customer>('/api/catalog/customers', payload),
|
||||
updateCustomer: (id: string, payload: CustomerPayload) =>
|
||||
updateResource<CustomerPayload, Customer>('/api/catalog/customers', id, payload),
|
||||
deleteCustomer: (id: string) => deleteResource('/api/catalog/customers', id),
|
||||
|
||||
listWarehouses: (params: CatalogListParams) => listResource<Warehouse>('/api/catalog/warehouses', params),
|
||||
createWarehouse: (payload: WarehousePayload) =>
|
||||
createResource<WarehousePayload, Warehouse>('/api/catalog/warehouses', payload),
|
||||
updateWarehouse: (id: string, payload: Omit<WarehousePayload, 'code'>) =>
|
||||
updateResource<Omit<WarehousePayload, 'code'>, Warehouse>('/api/catalog/warehouses', id, payload),
|
||||
deleteWarehouse: (id: string) => deleteResource('/api/catalog/warehouses', id),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
});
|
||||
|
||||
let unauthorizedHandler: (() => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
unauthorizedHandler = handler;
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
if (token) {
|
||||
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
return;
|
||||
}
|
||||
|
||||
delete apiClient.defaults.headers.common.Authorization;
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
setAuthToken(null);
|
||||
unauthorizedHandler?.();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,171 @@
|
||||
import { apiClient } from './client';
|
||||
import type { ApiResponse, PageResponse } from './types';
|
||||
|
||||
export type StatusAmountMetric = {
|
||||
status: string;
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
|
||||
export type TypeCountMetric = {
|
||||
type: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DailyAmountMetric = {
|
||||
date: string;
|
||||
ordersCount: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
|
||||
export type TopPartyMetric = {
|
||||
partyId: string;
|
||||
companyName: string;
|
||||
ordersCount: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
|
||||
export type WarehouseStockMetric = {
|
||||
warehouseId: string;
|
||||
warehouseCode: string;
|
||||
warehouseName: string;
|
||||
productsCount: number;
|
||||
totalQuantityOnHand: number;
|
||||
};
|
||||
|
||||
export type MovementTypeMetric = {
|
||||
movementType: string;
|
||||
count: number;
|
||||
totalQuantity: number;
|
||||
};
|
||||
|
||||
export type RecentActivity = {
|
||||
id: string;
|
||||
type: 'CUSTOMER_ORDER' | 'PURCHASE_ORDER' | 'STOCK_MOVEMENT' | 'DOCUMENT';
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
link: string | null;
|
||||
};
|
||||
|
||||
export type LowStockItem = {
|
||||
warehouseId: string;
|
||||
warehouseCode: string;
|
||||
warehouseName: string;
|
||||
productId: string;
|
||||
productSku: string;
|
||||
productName: string;
|
||||
unit: string;
|
||||
quantityOnHand: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export type DashboardSummary = {
|
||||
productsCount: number;
|
||||
activeProductsCount: number;
|
||||
suppliersCount: number;
|
||||
customersCount: number;
|
||||
warehousesCount: number;
|
||||
customerOrdersCount: number;
|
||||
activeCustomerOrdersCount: number;
|
||||
totalSalesAmount: number;
|
||||
salesOrdersByStatus: StatusAmountMetric[];
|
||||
purchaseOrdersCount: number;
|
||||
activePurchaseOrdersCount: number;
|
||||
totalProcurementAmount: number;
|
||||
purchaseOrdersByStatus: StatusAmountMetric[];
|
||||
stockItemsCount: number;
|
||||
totalQuantityOnHand: number;
|
||||
lowStockItemsCount: number;
|
||||
stockMovementsCount: number;
|
||||
documentsCount: number;
|
||||
documentsByType: TypeCountMetric[];
|
||||
recentCustomerOrders: RecentActivity[];
|
||||
recentPurchaseOrders: RecentActivity[];
|
||||
recentStockMovements: RecentActivity[];
|
||||
recentDocuments: RecentActivity[];
|
||||
};
|
||||
|
||||
export type DashboardSales = {
|
||||
totalOrders: number;
|
||||
totalAmount: number;
|
||||
averageOrderAmount: number;
|
||||
ordersByStatus: StatusAmountMetric[];
|
||||
dailySales: DailyAmountMetric[];
|
||||
topCustomers: TopPartyMetric[];
|
||||
};
|
||||
|
||||
export type DashboardProcurement = {
|
||||
totalPurchaseOrders: number;
|
||||
totalAmount: number;
|
||||
averagePurchaseOrderAmount: number;
|
||||
purchaseOrdersByStatus: StatusAmountMetric[];
|
||||
dailyProcurement: DailyAmountMetric[];
|
||||
topSuppliers: TopPartyMetric[];
|
||||
};
|
||||
|
||||
export type DashboardWarehouse = {
|
||||
stockItemsCount: number;
|
||||
totalQuantityOnHand: number;
|
||||
lowStockItemsCount: number;
|
||||
movementsCount: number;
|
||||
inboundQuantity: number;
|
||||
outboundQuantity: number;
|
||||
adjustmentInQuantity: number;
|
||||
adjustmentOutQuantity: number;
|
||||
stockByWarehouse: WarehouseStockMetric[];
|
||||
movementsByType: MovementTypeMetric[];
|
||||
};
|
||||
|
||||
export type PeriodParams = {
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
};
|
||||
|
||||
function unwrap<T>(response: ApiResponse<T>, fallback: string) {
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? fallback);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
async getDashboardSummary() {
|
||||
const response = await apiClient.get<ApiResponse<DashboardSummary>>('/api/dashboard/summary');
|
||||
return unwrap(response.data, 'Не удалось загрузить сводку панели управления');
|
||||
},
|
||||
|
||||
async getSalesDashboard(params: PeriodParams = {}) {
|
||||
const response = await apiClient.get<ApiResponse<DashboardSales>>('/api/dashboard/sales', {
|
||||
params,
|
||||
});
|
||||
return unwrap(response.data, 'Не удалось загрузить аналитику продаж');
|
||||
},
|
||||
|
||||
async getProcurementDashboard(params: PeriodParams = {}) {
|
||||
const response = await apiClient.get<ApiResponse<DashboardProcurement>>('/api/dashboard/procurement', {
|
||||
params,
|
||||
});
|
||||
return unwrap(response.data, 'Не удалось загрузить аналитику закупок');
|
||||
},
|
||||
|
||||
async getWarehouseDashboard() {
|
||||
const response = await apiClient.get<ApiResponse<DashboardWarehouse>>('/api/dashboard/warehouse');
|
||||
return unwrap(response.data, 'Не удалось загрузить аналитику склада');
|
||||
},
|
||||
|
||||
async getRecentActivities(limit = 20) {
|
||||
const response = await apiClient.get<ApiResponse<RecentActivity[]>>('/api/dashboard/recent-activities', {
|
||||
params: { limit },
|
||||
});
|
||||
return unwrap(response.data, 'Не удалось загрузить последние события');
|
||||
},
|
||||
|
||||
async getLowStock(threshold = 10, page = 0, size = 20) {
|
||||
const response = await apiClient.get<ApiResponse<PageResponse<LowStockItem>>>('/api/dashboard/low-stock', {
|
||||
params: { threshold, page, size },
|
||||
});
|
||||
return unwrap(response.data, 'Не удалось загрузить низкие остатки');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { apiClient } from './client';
|
||||
import type { ApiResponse, PageResponse } from './types';
|
||||
|
||||
export type DocumentType = 'INVOICE' | 'CONTRACT' | 'DELIVERY_NOTE';
|
||||
export type DocumentSourceType = 'CUSTOMER_ORDER';
|
||||
export type DocumentStatus = 'GENERATED';
|
||||
|
||||
export type DocumentRecord = {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
documentType: DocumentType;
|
||||
sourceType: DocumentSourceType;
|
||||
sourceId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
fileSize: number;
|
||||
status: DocumentStatus;
|
||||
generatedByUserId: string | null;
|
||||
generatedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DocumentListParams = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
documentType?: DocumentType | '';
|
||||
sourceType?: DocumentSourceType | '';
|
||||
sourceId?: string;
|
||||
status?: DocumentStatus | '';
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
};
|
||||
|
||||
function unwrap<T>(response: ApiResponse<T>, fallback: string) {
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? fallback);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function fileNameFromContentDisposition(value: string | undefined, fallback: string) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const match = value.match(/filename="?([^"]+)"?/i);
|
||||
return match?.[1] || fallback;
|
||||
}
|
||||
|
||||
export const documentsApi = {
|
||||
async listDocuments(params: DocumentListParams) {
|
||||
const response = await apiClient.get<ApiResponse<PageResponse<DocumentRecord>>>('/api/documents', {
|
||||
params: {
|
||||
page: params.page ?? 0,
|
||||
size: params.size ?? 20,
|
||||
documentType: params.documentType || undefined,
|
||||
sourceType: params.sourceType || undefined,
|
||||
sourceId: params.sourceId || undefined,
|
||||
status: params.status || undefined,
|
||||
fromDate: params.fromDate || undefined,
|
||||
toDate: params.toDate || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return unwrap(response.data, 'Не удалось загрузить документы');
|
||||
},
|
||||
|
||||
async getDocument(id: string) {
|
||||
const response = await apiClient.get<ApiResponse<DocumentRecord>>(`/api/documents/${id}`);
|
||||
return unwrap(response.data, 'Не удалось загрузить документ');
|
||||
},
|
||||
|
||||
async getCustomerOrderDocuments(customerOrderId: string) {
|
||||
const response = await apiClient.get<ApiResponse<DocumentRecord[]>>(`/api/documents/customer-orders/${customerOrderId}`);
|
||||
return unwrap(response.data, 'Не удалось загрузить документы заказа');
|
||||
},
|
||||
|
||||
async generateCustomerOrderDocument(customerOrderId: string, documentType: DocumentType) {
|
||||
const response = await apiClient.post<ApiResponse<DocumentRecord>>(`/api/documents/customer-orders/${customerOrderId}/generate`, {
|
||||
documentType,
|
||||
});
|
||||
return unwrap(response.data, 'Не удалось сформировать документ');
|
||||
},
|
||||
|
||||
async downloadDocument(id: string) {
|
||||
const response = await apiClient.get<Blob>(`/api/documents/${id}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
return {
|
||||
blob: response.data,
|
||||
fileName: fileNameFromContentDisposition(response.headers['content-disposition'], `document-${id}.pdf`),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function saveBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import type { ApiResponse } from './types';
|
||||
|
||||
export function getApiErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof AxiosError) {
|
||||
const response = error.response?.data as ApiResponse<unknown> | undefined;
|
||||
const backendMessage = response?.error?.message;
|
||||
|
||||
if (backendMessage) {
|
||||
return translateBackendMessage(backendMessage);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
return 'Сессия истекла. Войдите снова.';
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function translateBackendMessage(message: string) {
|
||||
const exact: Record<string, string> = {
|
||||
'Receiving warehouse is required before marking purchase order as RECEIVED':
|
||||
'Перед приемкой закупки нужно выбрать склад поступления.',
|
||||
'Warehouse is required before marking customer order as SHIPPED':
|
||||
'Перед отгрузкой клиентского заказа нужно выбрать склад.',
|
||||
'Cannot generate documents for cancelled order': 'Нельзя формировать документы для отмененного заказа.',
|
||||
'Delivery note can be generated only for SHIPPED or CLOSED orders':
|
||||
'Накладную можно сформировать только для отгруженного или закрытого заказа.',
|
||||
};
|
||||
|
||||
if (exact[message]) {
|
||||
return exact[message];
|
||||
}
|
||||
|
||||
if (message.startsWith('Insufficient stock for product')) {
|
||||
return message
|
||||
.replace('Insufficient stock for product', 'Недостаточно остатка по товару')
|
||||
.replace('in warehouse', 'на складе');
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { apiClient } from './client';
|
||||
import type { ApiResponse, PageResponse } from './types';
|
||||
|
||||
export type PurchaseOrderStatus = 'DRAFT' | 'APPROVED' | 'ORDERED' | 'RECEIVED' | 'CANCELLED';
|
||||
|
||||
export type PurchaseOrderListParams = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
supplierId?: string;
|
||||
status?: PurchaseOrderStatus | '';
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
};
|
||||
|
||||
export type PurchaseOrderSupplier = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
bin: string | null;
|
||||
};
|
||||
|
||||
export type PurchaseOrderWarehouse = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
|
||||
export type PurchaseOrderProduct = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export type PurchaseOrderItem = {
|
||||
id: string;
|
||||
product: PurchaseOrderProduct;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
lineTotal: number;
|
||||
};
|
||||
|
||||
export type PurchaseOrder = {
|
||||
id: string;
|
||||
poNumber: string;
|
||||
supplier: PurchaseOrderSupplier;
|
||||
warehouse: PurchaseOrderWarehouse;
|
||||
status: PurchaseOrderStatus;
|
||||
expectedDeliveryDate: string | null;
|
||||
notes: string | null;
|
||||
totalAmount: number;
|
||||
createdByUserId: string | null;
|
||||
approvedByUserId: string | null;
|
||||
orderedAt: string | null;
|
||||
receivedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
items: PurchaseOrderItem[];
|
||||
};
|
||||
|
||||
export type PurchaseOrderItemPayload = {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
};
|
||||
|
||||
export type PurchaseOrderPayload = {
|
||||
supplierId: string;
|
||||
warehouseId?: string;
|
||||
expectedDeliveryDate?: string;
|
||||
notes?: string;
|
||||
items: PurchaseOrderItemPayload[];
|
||||
};
|
||||
|
||||
export type PurchaseOrderStatusHistory = {
|
||||
id: string;
|
||||
oldStatus: PurchaseOrderStatus | null;
|
||||
newStatus: PurchaseOrderStatus;
|
||||
changedByUserId: string | null;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function unwrap<T>(response: ApiResponse<T>, fallback: string) {
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? fallback);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const procurementApi = {
|
||||
async listPurchaseOrders(params: PurchaseOrderListParams) {
|
||||
const response = await apiClient.get<ApiResponse<PageResponse<PurchaseOrder>>>('/api/procurement/purchase-orders', {
|
||||
params: {
|
||||
page: params.page ?? 0,
|
||||
size: params.size ?? 20,
|
||||
search: params.search || undefined,
|
||||
supplierId: params.supplierId || undefined,
|
||||
status: params.status || undefined,
|
||||
fromDate: params.fromDate || undefined,
|
||||
toDate: params.toDate || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return unwrap(response.data, 'Не удалось загрузить закупки');
|
||||
},
|
||||
|
||||
async getPurchaseOrder(id: string) {
|
||||
const response = await apiClient.get<ApiResponse<PurchaseOrder>>(`/api/procurement/purchase-orders/${id}`);
|
||||
return unwrap(response.data, 'Не удалось загрузить закупку');
|
||||
},
|
||||
|
||||
async createPurchaseOrder(payload: PurchaseOrderPayload) {
|
||||
const response = await apiClient.post<ApiResponse<PurchaseOrder>>('/api/procurement/purchase-orders', payload);
|
||||
return unwrap(response.data, 'Не удалось создать закупку');
|
||||
},
|
||||
|
||||
async updatePurchaseOrder(id: string, payload: PurchaseOrderPayload) {
|
||||
const response = await apiClient.put<ApiResponse<PurchaseOrder>>(`/api/procurement/purchase-orders/${id}`, payload);
|
||||
return unwrap(response.data, 'Не удалось обновить закупку');
|
||||
},
|
||||
|
||||
async changePurchaseOrderStatus(id: string, status: PurchaseOrderStatus, comment?: string) {
|
||||
const response = await apiClient.patch<ApiResponse<PurchaseOrder>>(`/api/procurement/purchase-orders/${id}/status`, {
|
||||
status,
|
||||
comment,
|
||||
});
|
||||
return unwrap(response.data, 'Не удалось изменить статус закупки');
|
||||
},
|
||||
|
||||
async getPurchaseOrderStatusHistory(id: string) {
|
||||
const response = await apiClient.get<ApiResponse<PurchaseOrderStatusHistory[]>>(
|
||||
`/api/procurement/purchase-orders/${id}/status-history`,
|
||||
);
|
||||
return unwrap(response.data, 'Не удалось загрузить историю статусов');
|
||||
},
|
||||
|
||||
async deletePurchaseOrder(id: string) {
|
||||
await apiClient.delete(`/api/procurement/purchase-orders/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { apiClient } from './client';
|
||||
import type { ApiResponse, PageResponse } from './types';
|
||||
|
||||
export type CustomerOrderStatus = 'NEW' | 'CONFIRMED' | 'IN_PROGRESS' | 'SHIPPED' | 'CLOSED' | 'CANCELLED';
|
||||
|
||||
export type CustomerOrderListParams = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
customerId?: string;
|
||||
warehouseId?: string;
|
||||
status?: CustomerOrderStatus | '';
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
};
|
||||
|
||||
export type CustomerOrderCustomer = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
bin: string | null;
|
||||
};
|
||||
|
||||
export type CustomerOrderWarehouse = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
|
||||
export type CustomerOrderProduct = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export type CustomerOrderItem = {
|
||||
id: string;
|
||||
product: CustomerOrderProduct;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
lineTotal: number;
|
||||
};
|
||||
|
||||
export type CustomerOrder = {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
customer: CustomerOrderCustomer;
|
||||
warehouse: CustomerOrderWarehouse;
|
||||
status: CustomerOrderStatus;
|
||||
requestedDeliveryDate: string | null;
|
||||
notes: string | null;
|
||||
totalAmount: number;
|
||||
createdByUserId: string | null;
|
||||
confirmedByUserId: string | null;
|
||||
confirmedAt: string | null;
|
||||
inProgressAt: string | null;
|
||||
shippedAt: string | null;
|
||||
closedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
items: CustomerOrderItem[];
|
||||
};
|
||||
|
||||
export type CustomerOrderItemPayload = {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
};
|
||||
|
||||
export type CustomerOrderPayload = {
|
||||
customerId: string;
|
||||
warehouseId?: string;
|
||||
requestedDeliveryDate?: string;
|
||||
notes?: string;
|
||||
items: CustomerOrderItemPayload[];
|
||||
};
|
||||
|
||||
export type CustomerOrderStatusHistory = {
|
||||
id: string;
|
||||
oldStatus: CustomerOrderStatus | null;
|
||||
newStatus: CustomerOrderStatus;
|
||||
changedByUserId: string | null;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function unwrap<T>(response: ApiResponse<T>, fallback: string) {
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? fallback);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const salesApi = {
|
||||
async listCustomerOrders(params: CustomerOrderListParams) {
|
||||
const response = await apiClient.get<ApiResponse<PageResponse<CustomerOrder>>>('/api/sales/customer-orders', {
|
||||
params: {
|
||||
page: params.page ?? 0,
|
||||
size: params.size ?? 20,
|
||||
search: params.search || undefined,
|
||||
customerId: params.customerId || undefined,
|
||||
warehouseId: params.warehouseId || undefined,
|
||||
status: params.status || undefined,
|
||||
fromDate: params.fromDate || undefined,
|
||||
toDate: params.toDate || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return unwrap(response.data, 'Не удалось загрузить клиентские заказы');
|
||||
},
|
||||
|
||||
async getCustomerOrder(id: string) {
|
||||
const response = await apiClient.get<ApiResponse<CustomerOrder>>(`/api/sales/customer-orders/${id}`);
|
||||
return unwrap(response.data, 'Не удалось загрузить клиентский заказ');
|
||||
},
|
||||
|
||||
async createCustomerOrder(payload: CustomerOrderPayload) {
|
||||
const response = await apiClient.post<ApiResponse<CustomerOrder>>('/api/sales/customer-orders', payload);
|
||||
return unwrap(response.data, 'Не удалось создать клиентский заказ');
|
||||
},
|
||||
|
||||
async updateCustomerOrder(id: string, payload: CustomerOrderPayload) {
|
||||
const response = await apiClient.put<ApiResponse<CustomerOrder>>(`/api/sales/customer-orders/${id}`, payload);
|
||||
return unwrap(response.data, 'Не удалось обновить клиентский заказ');
|
||||
},
|
||||
|
||||
async changeCustomerOrderStatus(id: string, status: CustomerOrderStatus, comment?: string) {
|
||||
const response = await apiClient.patch<ApiResponse<CustomerOrder>>(`/api/sales/customer-orders/${id}/status`, {
|
||||
status,
|
||||
comment,
|
||||
});
|
||||
return unwrap(response.data, 'Не удалось изменить статус клиентского заказа');
|
||||
},
|
||||
|
||||
async getCustomerOrderStatusHistory(id: string) {
|
||||
const response = await apiClient.get<ApiResponse<CustomerOrderStatusHistory[]>>(
|
||||
`/api/sales/customer-orders/${id}/status-history`,
|
||||
);
|
||||
return unwrap(response.data, 'Не удалось загрузить историю статусов');
|
||||
},
|
||||
|
||||
async deleteCustomerOrder(id: string) {
|
||||
await apiClient.delete(`/api/sales/customer-orders/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details: unknown[];
|
||||
} | null;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type PageResponse<T> = {
|
||||
items: T[];
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { apiClient } from './client';
|
||||
import type { ApiResponse, PageResponse } from './types';
|
||||
|
||||
export type StockMovementType = 'INBOUND' | 'OUTBOUND' | 'ADJUSTMENT_IN' | 'ADJUSTMENT_OUT';
|
||||
export type StockMovementSourceType = 'PURCHASE_ORDER' | 'CUSTOMER_ORDER' | 'MANUAL_ADJUSTMENT';
|
||||
|
||||
export type StockWarehouse = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type StockProduct = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export type StockBalance = {
|
||||
id: string;
|
||||
warehouse: StockWarehouse;
|
||||
product: StockProduct;
|
||||
quantityOnHand: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type StockMovement = {
|
||||
id: string;
|
||||
movementNumber: string;
|
||||
warehouse: StockWarehouse;
|
||||
product: StockProduct;
|
||||
movementType: StockMovementType;
|
||||
quantity: number;
|
||||
quantityBefore: number;
|
||||
quantityAfter: number;
|
||||
sourceType: StockMovementSourceType | null;
|
||||
sourceId: string | null;
|
||||
comment: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type StockBalanceListParams = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
warehouseId?: string;
|
||||
productId?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type StockMovementListParams = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
warehouseId?: string;
|
||||
productId?: string;
|
||||
movementType?: StockMovementType | '';
|
||||
sourceType?: StockMovementSourceType | '';
|
||||
sourceId?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
};
|
||||
|
||||
export type ManualStockAdjustmentPayload = {
|
||||
warehouseId: string;
|
||||
productId: string;
|
||||
type: 'ADJUSTMENT_IN' | 'ADJUSTMENT_OUT';
|
||||
quantity: number;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
function unwrap<T>(response: ApiResponse<T>, fallback: string) {
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? fallback);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const warehouseApi = {
|
||||
async listStockBalances(params: StockBalanceListParams) {
|
||||
const response = await apiClient.get<ApiResponse<PageResponse<StockBalance>>>('/api/warehouse/stock-balances', {
|
||||
params: {
|
||||
page: params.page ?? 0,
|
||||
size: params.size ?? 20,
|
||||
warehouseId: params.warehouseId || undefined,
|
||||
productId: params.productId || undefined,
|
||||
search: params.search || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return unwrap(response.data, 'Не удалось загрузить остатки');
|
||||
},
|
||||
|
||||
async listStockMovements(params: StockMovementListParams) {
|
||||
const response = await apiClient.get<ApiResponse<PageResponse<StockMovement>>>('/api/warehouse/stock-movements', {
|
||||
params: {
|
||||
page: params.page ?? 0,
|
||||
size: params.size ?? 20,
|
||||
warehouseId: params.warehouseId || undefined,
|
||||
productId: params.productId || undefined,
|
||||
movementType: params.movementType || undefined,
|
||||
sourceType: params.sourceType || undefined,
|
||||
sourceId: params.sourceId || undefined,
|
||||
fromDate: params.fromDate || undefined,
|
||||
toDate: params.toDate || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return unwrap(response.data, 'Не удалось загрузить движения склада');
|
||||
},
|
||||
|
||||
async manualStockAdjustment(payload: ManualStockAdjustmentPayload) {
|
||||
const response = await apiClient.post<ApiResponse<StockMovement>>('/api/warehouse/stock-adjustments', payload);
|
||||
return unwrap(response.data, 'Не удалось создать корректировку');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { apiClient, setAuthToken, setUnauthorizedHandler } from '../api/client';
|
||||
import type { ApiResponse } from '../api/types';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'erp_mvp_access_token';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: 'ADMIN' | 'MANAGER' | 'WAREHOUSE' | 'FINANCE';
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
type LoginResponse = {
|
||||
accessToken: string;
|
||||
tokenType: 'Bearer';
|
||||
expiresInMinutes: number;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshCurrentUser: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_STORAGE_KEY));
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const storeToken = useCallback((nextToken: string | null) => {
|
||||
setToken(nextToken);
|
||||
setAuthToken(nextToken);
|
||||
|
||||
if (nextToken) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, nextToken);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
const refreshCurrentUser = useCallback(async () => {
|
||||
const response = await apiClient.get<ApiResponse<AuthUser>>('/api/auth/me');
|
||||
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error?.message ?? 'Не удалось загрузить текущего пользователя');
|
||||
}
|
||||
|
||||
setUser(response.data.data);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
storeToken(null);
|
||||
setUser(null);
|
||||
}, [storeToken]);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const response = await apiClient.post<ApiResponse<LoginResponse>>('/api/auth/login', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error?.message ?? 'Не удалось войти');
|
||||
}
|
||||
|
||||
storeToken(response.data.data.accessToken);
|
||||
setUser(response.data.data.user);
|
||||
},
|
||||
[storeToken],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
storeToken(null);
|
||||
setUser(null);
|
||||
navigate('/login', { replace: true });
|
||||
});
|
||||
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [navigate, storeToken]);
|
||||
|
||||
useEffect(() => {
|
||||
setAuthToken(token);
|
||||
|
||||
if (!token) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshCurrentUser()
|
||||
.catch(() => logout())
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [logout, refreshCurrentUser, token]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
token,
|
||||
user,
|
||||
isAuthenticated: Boolean(token && user),
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
refreshCurrentUser,
|
||||
}),
|
||||
[isLoading, login, logout, refreshCurrentUser, token, user],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used inside AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from './AuthContext';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-50 text-slate-600">
|
||||
<LoadingState message="Загрузка сессии..." />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type DocumentRecord,
|
||||
type DocumentType,
|
||||
documentsApi,
|
||||
saveBlob,
|
||||
} from '../api/documentsApi';
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { formatDateTime, formatDocumentType } from '../utils/formatters';
|
||||
import { DataTable, type TableColumn } from './DataTable';
|
||||
import { Button } from './ui/Button';
|
||||
import { ErrorState } from './ui/ErrorState';
|
||||
import { LoadingState } from './ui/LoadingState';
|
||||
|
||||
type CustomerOrderDocumentsProps = {
|
||||
customerOrderId: string;
|
||||
};
|
||||
|
||||
const documentTypes: Array<{ label: string; value: DocumentType }> = [
|
||||
{ label: 'Сформировать счет', value: 'INVOICE' },
|
||||
{ label: 'Сформировать договор', value: 'CONTRACT' },
|
||||
{ label: 'Сформировать накладную', value: 'DELIVERY_NOTE' },
|
||||
];
|
||||
|
||||
export function CustomerOrderDocuments({ customerOrderId }: CustomerOrderDocumentsProps) {
|
||||
const { user } = useAuth();
|
||||
const [documents, setDocuments] = useState<DocumentRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentType | null>(null);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await documentsApi.getCustomerOrderDocuments(customerOrderId);
|
||||
setDocuments(data);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить документы'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [customerOrderId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDocuments();
|
||||
}, [loadDocuments]);
|
||||
|
||||
const generateDocument = async (documentType: DocumentType) => {
|
||||
setActiveDocumentType(documentType);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const document = await documentsApi.generateCustomerOrderDocument(customerOrderId, documentType);
|
||||
setSuccess(`${formatDocumentType(document.documentType)} сформирован`);
|
||||
await loadDocuments();
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось сформировать документ'));
|
||||
} finally {
|
||||
setActiveDocumentType(null);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadDocument = async (document: DocumentRecord) => {
|
||||
setDownloadingId(document.id);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { blob, fileName } = await documentsApi.downloadDocument(document.id);
|
||||
saveBlob(blob, fileName || document.fileName);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось скачать документ'));
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canGenerate = (documentType: DocumentType) => {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (documentType === 'DELIVERY_NOTE') {
|
||||
return user.role === 'ADMIN' || user.role === 'MANAGER' || user.role === 'WAREHOUSE';
|
||||
}
|
||||
|
||||
return user.role === 'ADMIN' || user.role === 'MANAGER' || user.role === 'FINANCE';
|
||||
};
|
||||
|
||||
const columns: TableColumn<DocumentRecord>[] = [
|
||||
{ key: 'documentNumber', label: 'Номер документа', render: (document) => document.documentNumber },
|
||||
{ key: 'documentType', label: 'Тип', render: (document) => formatDocumentType(document.documentType) },
|
||||
{ key: 'fileName', label: 'Файл', render: (document) => document.fileName },
|
||||
{ key: 'generatedAt', label: 'Сформирован', render: (document) => formatDateTime(document.generatedAt) },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-slate-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">Документы</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{documentTypes.map((documentType) =>
|
||||
canGenerate(documentType.value) ? (
|
||||
<Button
|
||||
key={documentType.value}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => generateDocument(documentType.value)}
|
||||
disabled={activeDocumentType === documentType.value}
|
||||
>
|
||||
{activeDocumentType === documentType.value ? 'Формирование...' : documentType.label}
|
||||
</Button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<ErrorState message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p className="mt-4 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{success}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4">
|
||||
<LoadingState message="Загрузка документов..." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<DataTable
|
||||
items={documents}
|
||||
columns={columns}
|
||||
getRowKey={(document) => document.id}
|
||||
emptyTitle="Документы еще не сформированы"
|
||||
actions={(document) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadDocument(document)}
|
||||
disabled={downloadingId === document.id}
|
||||
>
|
||||
{downloadingId === document.id ? 'Скачивание...' : 'Скачать'}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { catalogApi, type Customer, type Product, type Warehouse } from '../api/catalogApi';
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import type { CustomerOrder, CustomerOrderPayload } from '../api/salesApi';
|
||||
import { formatMoney } from '../utils/formatters';
|
||||
import { Button } from './ui/Button';
|
||||
import { ErrorState } from './ui/ErrorState';
|
||||
import { LoadingState } from './ui/LoadingState';
|
||||
|
||||
type FormItem = {
|
||||
productId: string;
|
||||
quantity: string;
|
||||
unitPrice: string;
|
||||
};
|
||||
|
||||
type CustomerOrderFormProps = {
|
||||
initialOrder?: CustomerOrder;
|
||||
submitLabel: string;
|
||||
onSubmit: (payload: CustomerOrderPayload) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export function CustomerOrderForm({ initialOrder, submitLabel, onSubmit, onCancel }: CustomerOrderFormProps) {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [customerId, setCustomerId] = useState(initialOrder?.customer.id ?? '');
|
||||
const [warehouseId, setWarehouseId] = useState(initialOrder?.warehouse?.id ?? '');
|
||||
const [requestedDeliveryDate, setRequestedDeliveryDate] = useState(initialOrder?.requestedDeliveryDate ?? '');
|
||||
const [notes, setNotes] = useState(initialOrder?.notes ?? '');
|
||||
const [items, setItems] = useState<FormItem[]>(
|
||||
initialOrder?.items.map((item) => ({
|
||||
productId: item.product.id,
|
||||
quantity: String(item.quantity),
|
||||
unitPrice: String(item.unitPrice),
|
||||
})) ?? [{ productId: '', quantity: '1', unitPrice: '0' }],
|
||||
);
|
||||
const [isLoadingCatalog, setIsLoadingCatalog] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadCatalogData() {
|
||||
setIsLoadingCatalog(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [customerPage, warehousePage, productPage] = await Promise.all([
|
||||
catalogApi.listCustomers({ page: 0, size: 100, active: true }),
|
||||
catalogApi.listWarehouses({ page: 0, size: 100, active: true }),
|
||||
catalogApi.listProducts({ page: 0, size: 100, active: true }),
|
||||
]);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomers(customerPage.items);
|
||||
setWarehouses(warehousePage.items);
|
||||
setProducts(productPage.items);
|
||||
setCustomerId((current) => current || customerPage.items[0]?.id || '');
|
||||
setItems((current) =>
|
||||
current.map((item, index) => ({
|
||||
...item,
|
||||
productId: item.productId || (index === 0 ? productPage.items[0]?.id ?? '' : ''),
|
||||
})),
|
||||
);
|
||||
} catch (caughtError) {
|
||||
if (isMounted) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить клиентов, склады и товары'));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoadingCatalog(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCatalogData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const productById = useMemo(() => new Map(products.map((product) => [product.id, product])), [products]);
|
||||
const hasCatalogData = customers.length > 0 && products.length > 0;
|
||||
const totalAmount = items.reduce((sum, item) => sum + lineTotal(item), 0);
|
||||
|
||||
const updateItem = (index: number, patch: Partial<FormItem>) => {
|
||||
setItems((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setItems((current) => [
|
||||
...current,
|
||||
{
|
||||
productId: products[0]?.id ?? '',
|
||||
quantity: '1',
|
||||
unitPrice: '0',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setItems((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
};
|
||||
|
||||
const lineTotal = (item: FormItem) => {
|
||||
const quantity = Number(item.quantity || 0);
|
||||
const unitPrice = Number(item.unitPrice || 0);
|
||||
return quantity * unitPrice;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
customerId,
|
||||
warehouseId: warehouseId || undefined,
|
||||
requestedDeliveryDate: requestedDeliveryDate || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
items: items.map((item) => ({
|
||||
productId: item.productId,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unitPrice),
|
||||
})),
|
||||
});
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось сохранить клиентский заказ'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingCatalog) {
|
||||
return <LoadingState message="Загрузка справочников..." />;
|
||||
}
|
||||
|
||||
if (!hasCatalogData) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Сначала создайте клиентов и товары в справочниках.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-md border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Клиент <span className="text-red-600">*</span></span>
|
||||
<select
|
||||
value={customerId}
|
||||
onChange={(event) => setCustomerId(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.companyName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Склад</span>
|
||||
<select
|
||||
value={warehouseId}
|
||||
onChange={(event) => setWarehouseId(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
>
|
||||
<option value="">Склад не выбран</option>
|
||||
{warehouses.map((warehouse) => (
|
||||
<option key={warehouse.id} value={warehouse.id}>
|
||||
{warehouse.code} / {warehouse.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Желаемая дата доставки</span>
|
||||
<input
|
||||
type="date"
|
||||
value={requestedDeliveryDate}
|
||||
onChange={(event) => setRequestedDeliveryDate(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Примечание</span>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
className="min-h-20 w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Позиции</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
>
|
||||
Добавить позицию
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => {
|
||||
const product = productById.get(item.productId);
|
||||
|
||||
return (
|
||||
<div key={index} className="grid gap-3 rounded-md border border-slate-200 p-3 lg:grid-cols-[2fr_1fr_1fr_1fr_auto]">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-xs font-medium text-slate-600">Товар <span className="text-red-600">*</span></span>
|
||||
<select
|
||||
value={item.productId}
|
||||
onChange={(event) => updateItem(index, { productId: event.target.value })}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
>
|
||||
{products.map((productOption) => (
|
||||
<option key={productOption.id} value={productOption.id}>
|
||||
{productOption.sku} / {productOption.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-xs font-medium text-slate-600">Количество <span className="text-red-600">*</span></span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
value={item.quantity}
|
||||
onChange={(event) => updateItem(index, { quantity: event.target.value })}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-xs font-medium text-slate-600">Цена продажи <span className="text-red-600">*</span></span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={item.unitPrice}
|
||||
onChange={(event) => updateItem(index, { unitPrice: event.target.value })}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="block text-xs font-medium text-slate-600">Сумма строки</span>
|
||||
<div className="flex h-10 items-center rounded-md bg-slate-100 px-3 text-sm text-slate-700">
|
||||
{formatMoney(lineTotal(item))} {product?.unit ? `/ ${product.unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={items.length === 1}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-slate-100 pt-4">
|
||||
<div className="rounded-md bg-slate-50 px-4 py-3 text-right">
|
||||
<p className="text-xs font-medium uppercase text-slate-500">Итого по форме</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-950">{formatMoney(totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : submitLabel}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { EmptyState } from './ui/EmptyState';
|
||||
|
||||
export type TableColumn<TItem> = {
|
||||
key: string;
|
||||
label: string;
|
||||
render: (item: TItem) => ReactNode;
|
||||
};
|
||||
|
||||
type DataTableProps<TItem> = {
|
||||
items: TItem[];
|
||||
columns: TableColumn<TItem>[];
|
||||
getRowKey: (item: TItem) => string;
|
||||
actions?: (item: TItem) => ReactNode;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
};
|
||||
|
||||
export function DataTable<TItem>({
|
||||
items,
|
||||
columns,
|
||||
getRowKey,
|
||||
actions,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: DataTableProps<TItem>) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
{actions && <th className="whitespace-nowrap px-4 py-3 text-right text-xs font-semibold uppercase text-slate-500">Действия</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (actions ? 1 : 0)}>
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<tr key={getRowKey(item)} className="align-top transition hover:bg-slate-50">
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-4 py-3 text-slate-700">
|
||||
{column.render(item)}
|
||||
</td>
|
||||
))}
|
||||
{actions && <td className="px-4 py-3 text-right">{actions(item)}</td>}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { apiClient } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { formatStatus } from '../utils/formatters';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Badge } from './ui/Badge';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
type LayoutProps = {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/dashboard': 'Панель управления',
|
||||
'/catalog/products': 'Товары',
|
||||
'/catalog/suppliers': 'Поставщики',
|
||||
'/catalog/customers': 'Клиенты',
|
||||
'/catalog/warehouses': 'Склады',
|
||||
'/procurement/purchase-orders': 'Закупки',
|
||||
'/sales/customer-orders': 'Клиентские заказы',
|
||||
'/warehouse/stock-balances': 'Остатки на складе',
|
||||
'/warehouse/stock-movements': 'Движения склада',
|
||||
'/documents': 'Документы',
|
||||
};
|
||||
|
||||
function getRouteTitle(pathname: string) {
|
||||
if (pathname.startsWith('/procurement/purchase-orders/')) {
|
||||
return 'Детали закупки';
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/sales/customer-orders/')) {
|
||||
return 'Детали клиентского заказа';
|
||||
}
|
||||
|
||||
return routeTitles[pathname] ?? 'Рабочее пространство ERP';
|
||||
}
|
||||
|
||||
export function Layout({ children, title, actions }: LayoutProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pageTitle = title ?? getRouteTitle(location.pathname);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await apiClient.post('/api/auth/logout');
|
||||
} finally {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 text-slate-950">
|
||||
<div className="flex min-h-screen flex-col md:flex-row">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 px-4 py-3 backdrop-blur sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-950">{pageTitle}</p>
|
||||
<p className="text-xs text-slate-500">Рабочее пространство ERP MVP</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{actions}
|
||||
{user && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<Badge variant="neutral">{formatStatus(user.role)}</Badge>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-slate-800">{user.fullName}</p>
|
||||
<p className="truncate text-xs text-slate-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="secondary" size="sm" onClick={handleLogout}>
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<section className="flex-1 px-4 py-6 sm:px-6 lg:px-8">{children}</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { catalogApi, type Product, type Supplier, type Warehouse } from '../api/catalogApi';
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import type { PurchaseOrder, PurchaseOrderPayload } from '../api/procurementApi';
|
||||
import { formatMoney } from '../utils/formatters';
|
||||
import { Button } from './ui/Button';
|
||||
import { ErrorState } from './ui/ErrorState';
|
||||
import { LoadingState } from './ui/LoadingState';
|
||||
|
||||
type FormItem = {
|
||||
productId: string;
|
||||
quantity: string;
|
||||
unitPrice: string;
|
||||
};
|
||||
|
||||
type PurchaseOrderFormProps = {
|
||||
initialOrder?: PurchaseOrder;
|
||||
submitLabel: string;
|
||||
onSubmit: (payload: PurchaseOrderPayload) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export function PurchaseOrderForm({ initialOrder, submitLabel, onSubmit, onCancel }: PurchaseOrderFormProps) {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [supplierId, setSupplierId] = useState(initialOrder?.supplier.id ?? '');
|
||||
const [warehouseId, setWarehouseId] = useState(initialOrder?.warehouse?.id ?? '');
|
||||
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState(initialOrder?.expectedDeliveryDate ?? '');
|
||||
const [notes, setNotes] = useState(initialOrder?.notes ?? '');
|
||||
const [items, setItems] = useState<FormItem[]>(
|
||||
initialOrder?.items.map((item) => ({
|
||||
productId: item.product.id,
|
||||
quantity: String(item.quantity),
|
||||
unitPrice: String(item.unitPrice),
|
||||
})) ?? [{ productId: '', quantity: '1', unitPrice: '0' }],
|
||||
);
|
||||
const [isLoadingCatalog, setIsLoadingCatalog] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadCatalogData() {
|
||||
setIsLoadingCatalog(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [supplierPage, warehousePage, productPage] = await Promise.all([
|
||||
catalogApi.listSuppliers({ page: 0, size: 100, active: true }),
|
||||
catalogApi.listWarehouses({ page: 0, size: 100, active: true }),
|
||||
catalogApi.listProducts({ page: 0, size: 100, active: true }),
|
||||
]);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSuppliers(supplierPage.items);
|
||||
setWarehouses(warehousePage.items);
|
||||
setProducts(productPage.items);
|
||||
|
||||
setSupplierId((current) => current || supplierPage.items[0]?.id || '');
|
||||
|
||||
setItems((current) =>
|
||||
current.map((item, index) => ({
|
||||
...item,
|
||||
productId: item.productId || (index === 0 ? productPage.items[0]?.id ?? '' : ''),
|
||||
})),
|
||||
);
|
||||
} catch (caughtError) {
|
||||
if (isMounted) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить поставщиков, склады и товары'));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoadingCatalog(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCatalogData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const productById = useMemo(() => new Map(products.map((product) => [product.id, product])), [products]);
|
||||
const hasCatalogData = suppliers.length > 0 && products.length > 0;
|
||||
const totalAmount = items.reduce((sum, item) => sum + lineTotal(item), 0);
|
||||
|
||||
const updateItem = (index: number, patch: Partial<FormItem>) => {
|
||||
setItems((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setItems((current) => [
|
||||
...current,
|
||||
{
|
||||
productId: products[0]?.id ?? '',
|
||||
quantity: '1',
|
||||
unitPrice: '0',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setItems((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
};
|
||||
|
||||
const lineTotal = (item: FormItem) => {
|
||||
const quantity = Number(item.quantity || 0);
|
||||
const unitPrice = Number(item.unitPrice || 0);
|
||||
return quantity * unitPrice;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
supplierId,
|
||||
warehouseId: warehouseId || undefined,
|
||||
expectedDeliveryDate: expectedDeliveryDate || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
items: items.map((item) => ({
|
||||
productId: item.productId,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unitPrice),
|
||||
})),
|
||||
});
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось сохранить закупку'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingCatalog) {
|
||||
return <LoadingState message="Загрузка справочников..." />;
|
||||
}
|
||||
|
||||
if (!hasCatalogData) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Сначала создайте поставщиков и товары в справочниках.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-md border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Поставщик <span className="text-red-600">*</span></span>
|
||||
<select
|
||||
value={supplierId}
|
||||
onChange={(event) => setSupplierId(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
>
|
||||
{suppliers.map((supplier) => (
|
||||
<option key={supplier.id} value={supplier.id}>
|
||||
{supplier.companyName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Склад поступления</span>
|
||||
<select
|
||||
value={warehouseId}
|
||||
onChange={(event) => setWarehouseId(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
>
|
||||
<option value="">Склад не выбран</option>
|
||||
{warehouses.map((warehouse) => (
|
||||
<option key={warehouse.id} value={warehouse.id}>
|
||||
{warehouse.code} / {warehouse.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Ожидаемая дата поставки</span>
|
||||
<input
|
||||
type="date"
|
||||
value={expectedDeliveryDate}
|
||||
onChange={(event) => setExpectedDeliveryDate(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700">Примечание</span>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
className="min-h-20 w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Позиции</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
>
|
||||
Добавить позицию
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => {
|
||||
const product = productById.get(item.productId);
|
||||
|
||||
return (
|
||||
<div key={index} className="grid gap-3 rounded-md border border-slate-200 p-3 lg:grid-cols-[2fr_1fr_1fr_1fr_auto]">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-xs font-medium text-slate-600">Товар <span className="text-red-600">*</span></span>
|
||||
<select
|
||||
value={item.productId}
|
||||
onChange={(event) => updateItem(index, { productId: event.target.value })}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
>
|
||||
{products.map((productOption) => (
|
||||
<option key={productOption.id} value={productOption.id}>
|
||||
{productOption.sku} / {productOption.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-xs font-medium text-slate-600">Количество <span className="text-red-600">*</span></span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
value={item.quantity}
|
||||
onChange={(event) => updateItem(index, { quantity: event.target.value })}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-xs font-medium text-slate-600">Цена за ед. <span className="text-red-600">*</span></span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={item.unitPrice}
|
||||
onChange={(event) => updateItem(index, { unitPrice: event.target.value })}
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-sm outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="block text-xs font-medium text-slate-600">Сумма строки</span>
|
||||
<div className="flex h-10 items-center rounded-md bg-slate-100 px-3 text-sm text-slate-700">
|
||||
{formatMoney(lineTotal(item))} {product?.unit ? `/ ${product.unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={items.length === 1}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-slate-100 pt-4">
|
||||
<div className="rounded-md bg-slate-50 px-4 py-3 text-right">
|
||||
<p className="text-xs font-medium uppercase text-slate-500">Итого по форме</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-950">{formatMoney(totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : submitLabel}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { apiClient } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { formatStatus } from '../utils/formatters';
|
||||
import { Badge } from './ui/Badge';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
const navGroups = [
|
||||
{
|
||||
label: 'Главное',
|
||||
items: [{ label: 'Панель управления', to: '/dashboard' }],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ label: 'Закупки', to: '/procurement/purchase-orders' },
|
||||
{ label: 'Клиентские заказы', to: '/sales/customer-orders' },
|
||||
{ label: 'Остатки', to: '/warehouse/stock-balances' },
|
||||
{ label: 'Движения склада', to: '/warehouse/stock-movements' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Справочники',
|
||||
items: [
|
||||
{ label: 'Товары', to: '/catalog/products' },
|
||||
{ label: 'Поставщики', to: '/catalog/suppliers' },
|
||||
{ label: 'Клиенты', to: '/catalog/customers' },
|
||||
{ label: 'Склады', to: '/catalog/warehouses' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Документы',
|
||||
items: [{ label: 'Документы', to: '/documents' }],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const { logout, user } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await apiClient.post('/api/auth/logout');
|
||||
} finally {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex w-full shrink-0 flex-col border-r border-slate-200 bg-white md:min-h-screen md:w-72">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<p className="text-lg font-semibold tracking-normal text-slate-950">ERP MVP</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Операционная панель</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-5 overflow-y-auto p-3">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-normal text-slate-400">{group.label}</p>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/dashboard'}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'block rounded-md px-3 py-2 text-sm font-medium transition',
|
||||
isActive ? 'bg-slate-900 text-white shadow-sm' : 'text-slate-700 hover:bg-slate-100 hover:text-slate-950',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-3 border-t border-slate-200 p-3">
|
||||
{user && (
|
||||
<div className="rounded-md bg-slate-50 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium text-slate-900">{user.fullName}</p>
|
||||
<Badge variant="neutral">{formatStatus(user.role)}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-slate-500">{user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="secondary" className="w-full justify-start" onClick={handleLogout}>
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { formatStatus } from '../utils/formatters';
|
||||
import { Badge } from './ui/Badge';
|
||||
|
||||
const variants: Record<string, 'neutral' | 'success' | 'info' | 'warning' | 'danger' | 'dark'> = {
|
||||
DRAFT: 'neutral',
|
||||
NEW: 'neutral',
|
||||
APPROVED: 'info',
|
||||
CONFIRMED: 'info',
|
||||
ORDERED: 'warning',
|
||||
IN_PROGRESS: 'warning',
|
||||
RECEIVED: 'success',
|
||||
SHIPPED: 'info',
|
||||
CLOSED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
GENERATED: 'success',
|
||||
INBOUND: 'success',
|
||||
OUTBOUND: 'danger',
|
||||
ADJUSTMENT_IN: 'info',
|
||||
ADJUSTMENT_OUT: 'warning',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={variants[status] ?? 'neutral'}>{formatStatus(status)}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { catalogApi, type Product, type Warehouse } from '../api/catalogApi';
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import type { ManualStockAdjustmentPayload } from '../api/warehouseApi';
|
||||
import { Button } from './ui/Button';
|
||||
import { ConfirmDialog } from './ui/ConfirmDialog';
|
||||
import { ErrorState } from './ui/ErrorState';
|
||||
import { FormField } from './ui/FormField';
|
||||
import { Input } from './ui/Input';
|
||||
import { LoadingState } from './ui/LoadingState';
|
||||
import { Select } from './ui/Select';
|
||||
import { Textarea } from './ui/Textarea';
|
||||
|
||||
type StockAdjustmentFormProps = {
|
||||
onSubmit: (payload: ManualStockAdjustmentPayload) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export function StockAdjustmentForm({ onSubmit, onCancel }: StockAdjustmentFormProps) {
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [productId, setProductId] = useState('');
|
||||
const [type, setType] = useState<ManualStockAdjustmentPayload['type']>('ADJUSTMENT_IN');
|
||||
const [quantity, setQuantity] = useState('1');
|
||||
const [comment, setComment] = useState('');
|
||||
const [isLoadingCatalog, setIsLoadingCatalog] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingOutboundPayload, setPendingOutboundPayload] = useState<ManualStockAdjustmentPayload | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadCatalogData() {
|
||||
setIsLoadingCatalog(true);
|
||||
setError('');
|
||||
|
||||
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);
|
||||
setWarehouseId((current) => current || warehousePage.items[0]?.id || '');
|
||||
setProductId((current) => current || productPage.items[0]?.id || '');
|
||||
} catch (caughtError) {
|
||||
if (isMounted) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить склады и товары'));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoadingCatalog(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCatalogData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedProduct = useMemo(() => products.find((product) => product.id === productId), [productId, products]);
|
||||
const hasCatalogData = warehouses.length > 0 && products.length > 0;
|
||||
|
||||
const submitAdjustment = async (payload: ManualStockAdjustmentPayload) => {
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSubmit(payload);
|
||||
setComment('');
|
||||
setQuantity('1');
|
||||
setPendingOutboundPayload(null);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось создать корректировку'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const payload: ManualStockAdjustmentPayload = {
|
||||
warehouseId,
|
||||
productId,
|
||||
type,
|
||||
quantity: Number(quantity),
|
||||
comment: comment.trim(),
|
||||
};
|
||||
|
||||
if (payload.type === 'ADJUSTMENT_OUT') {
|
||||
setPendingOutboundPayload(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
void submitAdjustment(payload);
|
||||
};
|
||||
|
||||
if (isLoadingCatalog) {
|
||||
return <LoadingState message="Загрузка справочников..." />;
|
||||
}
|
||||
|
||||
if (!hasCatalogData) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Сначала создайте склады и товары в справочниках.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-md border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<FormField label="Склад" required>
|
||||
<Select
|
||||
value={warehouseId}
|
||||
onChange={(event) => setWarehouseId(event.target.value)}
|
||||
required
|
||||
disabled={isSaving}
|
||||
>
|
||||
{warehouses.map((warehouse) => (
|
||||
<option key={warehouse.id} value={warehouse.id}>
|
||||
{warehouse.code} / {warehouse.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Товар" required>
|
||||
<Select
|
||||
value={productId}
|
||||
onChange={(event) => setProductId(event.target.value)}
|
||||
required
|
||||
disabled={isSaving}
|
||||
>
|
||||
{products.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.sku} / {product.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Тип" required>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(event) => setType(event.target.value as ManualStockAdjustmentPayload['type'])}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="ADJUSTMENT_IN">Корректировка +</option>
|
||||
<option value="ADJUSTMENT_OUT">Корректировка -</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={`Количество ${selectedProduct?.unit ? `(${selectedProduct.unit})` : ''}`} required>
|
||||
<Input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
required
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Комментарий" required>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
required
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : 'Создать корректировку'}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingOutboundPayload)}
|
||||
title="Подтвердить уменьшение остатка"
|
||||
message="Эта корректировка уменьшит остаток. Сервер отклонит запрос, если остаток станет отрицательным."
|
||||
confirmLabel="Создать корректировку"
|
||||
isConfirming={isSaving}
|
||||
onCancel={() => setPendingOutboundPayload(null)}
|
||||
onConfirm={() => {
|
||||
if (pendingOutboundPayload) {
|
||||
void submitAdjustment(pendingOutboundPayload);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type KpiCardProps = {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function KpiCard({ label, value, hint }: KpiCardProps) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-xs font-medium uppercase text-slate-500">{label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-normal text-slate-950">{value}</p>
|
||||
{hint && <p className="mt-1 text-xs text-slate-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { LowStockItem } from '../../api/dashboardApi';
|
||||
import { formatQuantity } from '../../utils/formatters';
|
||||
import { Card } from '../ui/Card';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
|
||||
type LowStockTableProps = {
|
||||
items: LowStockItem[];
|
||||
};
|
||||
|
||||
export function LowStockTable({ items }: LowStockTableProps) {
|
||||
return (
|
||||
<Card className="border-amber-200 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">Низкие остатки</h2>
|
||||
<Link to="/warehouse/stock-balances" className="text-sm font-medium text-slate-600 hover:text-slate-950">
|
||||
Остатки
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title="Низких остатков нет" description="Текущие остатки выше порога панели управления." />
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">Склад</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">Товар</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Количество</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Порог</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{items.map((item) => (
|
||||
<tr key={`${item.warehouseId}-${item.productId}`} className="bg-amber-50/40">
|
||||
<td className="px-4 py-3 text-slate-700">{item.warehouseCode} / {item.warehouseName}</td>
|
||||
<td className="px-4 py-3 text-slate-700">{item.productSku} / {item.productName}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">
|
||||
{formatQuantity(item.quantityOnHand)} {item.unit}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">{formatQuantity(item.threshold)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { RecentActivity } from '../../api/dashboardApi';
|
||||
import { formatActivityDescription, formatActivityTitle, formatDateTime, formatStatus } from '../../utils/formatters';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { Card } from '../ui/Card';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
|
||||
type RecentActivitiesProps = {
|
||||
activities: RecentActivity[];
|
||||
};
|
||||
|
||||
export function RecentActivities({ activities }: RecentActivitiesProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">Последние события</h2>
|
||||
{activities.length === 0 ? (
|
||||
<EmptyState title="Событий пока нет" />
|
||||
) : (
|
||||
<div className="mt-4 divide-y divide-slate-100">
|
||||
{activities.map((activity) => {
|
||||
const content = (
|
||||
<div className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="neutral">{formatStatus(activity.type)}</Badge>
|
||||
<span className="text-sm font-medium text-slate-900">{formatActivityTitle(activity.title)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{formatActivityDescription(activity.description)}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{formatDateTime(activity.createdAt)}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return activity.link ? (
|
||||
<Link key={`${activity.type}-${activity.id}`} to={activity.link} className="block hover:bg-slate-50">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={`${activity.type}-${activity.id}`}>{content}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { StatusAmountMetric } from '../../api/dashboardApi';
|
||||
import { formatMoney } from '../../utils/formatters';
|
||||
import { StatusBadge } from '../StatusBadge';
|
||||
import { Card } from '../ui/Card';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
|
||||
type StatusSummaryTableProps = {
|
||||
title: string;
|
||||
metrics: StatusAmountMetric[];
|
||||
};
|
||||
|
||||
export function StatusSummaryTable({ title, metrics }: StatusSummaryTableProps) {
|
||||
const maxCount = Math.max(...metrics.map((metric) => metric.count), 1);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
||||
{metrics.length === 0 ? (
|
||||
<EmptyState title="Данных пока нет" />
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="pb-2 font-medium">Статус</th>
|
||||
<th className="pb-2 font-medium">Количество</th>
|
||||
<th className="pb-2 font-medium">Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{metrics.map((metric) => (
|
||||
<tr key={metric.status}>
|
||||
<td className="py-3">
|
||||
<div className="flex min-w-44 items-center gap-3">
|
||||
<span className="w-32"><StatusBadge status={metric.status} /></span>
|
||||
<span className="h-2 flex-1 overflow-hidden rounded-sm bg-slate-100">
|
||||
<span
|
||||
className="block h-full rounded-sm bg-slate-800"
|
||||
style={{ width: `${Math.max((metric.count / maxCount) * 100, 4)}%` }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-slate-700">{metric.count}</td>
|
||||
<td className="py-3 text-slate-700">{formatMoney(metric.totalAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type BadgeVariant = 'neutral' | 'success' | 'info' | 'warning' | 'danger' | 'dark';
|
||||
|
||||
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: BadgeVariant;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
neutral: 'bg-slate-100 text-slate-700 ring-slate-200',
|
||||
success: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
|
||||
info: 'bg-sky-50 text-sky-700 ring-sky-200',
|
||||
warning: 'bg-amber-50 text-amber-700 ring-amber-200',
|
||||
danger: 'bg-red-50 text-red-700 ring-red-200',
|
||||
dark: 'bg-slate-900 text-white ring-slate-900',
|
||||
};
|
||||
|
||||
export function Badge({ variant = 'neutral', className = '', children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
variantClasses[variant],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
type ButtonSize = 'sm' | 'md';
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
|
||||
const baseClass =
|
||||
'inline-flex items-center justify-center rounded-md font-medium tracking-normal transition focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:cursor-not-allowed disabled:opacity-60';
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-slate-900 text-white hover:bg-slate-700',
|
||||
secondary: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-100',
|
||||
danger: 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-950',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'min-h-8 px-3 py-1.5 text-xs',
|
||||
md: 'min-h-10 px-4 py-2 text-sm',
|
||||
};
|
||||
|
||||
export function buttonClassName({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
} = {}) {
|
||||
return [baseClass, variantClasses[variant], sizeClasses[size], className].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'primary', size = 'md', className = '', type = 'button', ...props }, ref) => (
|
||||
<button ref={ref} type={type} className={buttonClassName({ variant, size, className })} {...props} />
|
||||
),
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type CardProps = HTMLAttributes<HTMLDivElement> & {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
export function Card({ title, description, actions, className = '', children, ...props }: CardProps) {
|
||||
return (
|
||||
<section className={['rounded-md border border-slate-200 bg-white shadow-sm', className].filter(Boolean).join(' ')} {...props}>
|
||||
{(title || description || actions) && (
|
||||
<div className="flex flex-col gap-3 border-b border-slate-100 px-4 py-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
{title && <h2 className="text-base font-semibold tracking-normal text-slate-950">{title}</h2>}
|
||||
{description && <p className="mt-1 text-sm text-slate-600">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button } from './Button';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'danger' | 'primary';
|
||||
isConfirming?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
variant = 'danger',
|
||||
isConfirming = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 px-4 py-6" role="dialog" aria-modal="true">
|
||||
<div className="w-full max-w-md rounded-md border border-slate-200 bg-white p-5 shadow-lg">
|
||||
<h2 className="text-base font-semibold text-slate-950">{title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">{message}</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={isConfirming}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button type="button" variant={variant === 'danger' ? 'danger' : 'primary'} onClick={onConfirm} disabled={isConfirming}>
|
||||
{isConfirming ? 'Выполняется...' : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type EmptyStateProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
export function EmptyState({ title = 'Записи не найдены', description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-slate-800">{title}</p>
|
||||
{description && <p className="mt-1 max-w-md text-sm text-slate-500">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
type ErrorStateProps = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function ErrorState({ message }: ErrorStateProps) {
|
||||
return (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type FormFieldProps = {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function FormField({ label, htmlFor, required = false, error, children }: FormFieldProps) {
|
||||
return (
|
||||
<label className="block space-y-2" htmlFor={htmlFor}>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</span>
|
||||
{children}
|
||||
{error && <span className="block text-xs text-red-600">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { InputHTMLAttributes } from 'react';
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const inputClass =
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-900 focus:ring-2 focus:ring-slate-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500';
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className = '', ...props }, ref) => (
|
||||
<input ref={ref} className={[inputClass, className].filter(Boolean).join(' ')} {...props} />
|
||||
));
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -0,0 +1,11 @@
|
||||
type LoadingStateProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function LoadingState({ message = 'Загрузка...' }: LoadingStateProps) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-white px-4 py-8 text-center text-sm text-slate-500 shadow-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-slate-950">{title}</h1>
|
||||
{description && <p className="mt-1 max-w-3xl text-sm text-slate-600">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button } from './Button';
|
||||
|
||||
type PaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
totalElements,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
onPrevious,
|
||||
onNext,
|
||||
}: PaginationProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm text-slate-600 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
Страница {page + 1} из {Math.max(totalPages, 1)} / записей: {totalElements}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" disabled={!hasPrevious} onClick={onPrevious}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" disabled={!hasNext} onClick={onNext}>
|
||||
Далее
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
|
||||
type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
const selectClass =
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 outline-none transition focus:border-slate-900 focus:ring-2 focus:ring-slate-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500';
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({ className = '', ...props }, ref) => (
|
||||
<select ref={ref} className={[selectClass, className].filter(Boolean).join(' ')} {...props} />
|
||||
));
|
||||
|
||||
Select.displayName = 'Select';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { TextareaHTMLAttributes } from 'react';
|
||||
|
||||
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const textareaClass =
|
||||
'min-h-24 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-900 focus:ring-2 focus:ring-slate-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500';
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({ className = '', ...props }, ref) => (
|
||||
<textarea ref={ref} className={[textareaClass, className].filter(Boolean).join(' ')} {...props} />
|
||||
));
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import App from './App';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,314 @@
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CatalogListParams } from '../api/catalogApi';
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import type { PageResponse } from '../api/types';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { DataTable, type TableColumn } from '../components/DataTable';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { Badge } from '../components/ui/Badge';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { ConfirmDialog } from '../components/ui/ConfirmDialog';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { FormField } from '../components/ui/FormField';
|
||||
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 { Textarea } from '../components/ui/Textarea';
|
||||
|
||||
type CatalogItem = {
|
||||
id: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type CatalogFormState = Record<string, string>;
|
||||
|
||||
export type CatalogField = {
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
type?: 'text' | 'email' | 'textarea';
|
||||
disabledOnEdit?: boolean;
|
||||
};
|
||||
|
||||
type CatalogPageProps<TItem extends CatalogItem> = {
|
||||
title: string;
|
||||
description: string;
|
||||
columns: TableColumn<TItem>[];
|
||||
fields: CatalogField[];
|
||||
emptyForm: CatalogFormState;
|
||||
list: (params: CatalogListParams) => Promise<PageResponse<TItem>>;
|
||||
create: (form: CatalogFormState) => Promise<unknown>;
|
||||
update: (id: string, form: CatalogFormState, item: TItem) => Promise<unknown>;
|
||||
remove: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function CatalogPage<TItem extends CatalogItem>({
|
||||
title,
|
||||
description,
|
||||
columns,
|
||||
fields,
|
||||
emptyForm,
|
||||
list,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
}: CatalogPageProps<TItem>) {
|
||||
const { user } = useAuth();
|
||||
const canWrite = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
const [pageData, setPageData] = useState<PageResponse<TItem> | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState<'true' | 'false'>('true');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<CatalogFormState>(emptyForm);
|
||||
const [editingItem, setEditingItem] = useState<TItem | null>(null);
|
||||
const [deactivateItem, setDeactivateItem] = useState<TItem | null>(null);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await list({
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
search,
|
||||
active: activeFilter === 'true',
|
||||
});
|
||||
setPageData(data);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить записи справочника'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [activeFilter, list, page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const tableColumns = useMemo<TableColumn<TItem>[]>(
|
||||
() => [
|
||||
...columns,
|
||||
{
|
||||
key: 'active',
|
||||
label: 'Статус',
|
||||
render: (item) => <Badge variant={item.active ? 'success' : 'neutral'}>{item.active ? 'Активен' : 'Неактивен'}</Badge>,
|
||||
},
|
||||
],
|
||||
[columns],
|
||||
);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setEditingItem(null);
|
||||
setForm(emptyForm);
|
||||
setIsFormOpen(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const openEditForm = (item: TItem) => {
|
||||
const nextForm = fields.reduce<CatalogFormState>((acc, field) => {
|
||||
const value = item[field.name as keyof TItem];
|
||||
acc[field.name] = value == null ? '' : String(value);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setEditingItem(item);
|
||||
setForm(nextForm);
|
||||
setIsFormOpen(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setEditingItem(null);
|
||||
setForm(emptyForm);
|
||||
setIsFormOpen(false);
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPage(0);
|
||||
setSearch(searchInput.trim());
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (editingItem) {
|
||||
await update(editingItem.id, form, editingItem);
|
||||
} else {
|
||||
await create(form);
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
closeForm();
|
||||
await loadData();
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось сохранить запись'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivateItem) {
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await remove(deactivateItem.id);
|
||||
setDeactivateItem(null);
|
||||
await loadData();
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось деактивировать запись'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title={title}>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button type="button" onClick={openCreateForm}>
|
||||
Создать
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 rounded-md border border-slate-200 bg-white p-4 shadow-sm sm:flex-row">
|
||||
<Input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Поиск"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={activeFilter}
|
||||
onChange={(event) => {
|
||||
setPage(0);
|
||||
setActiveFilter(event.target.value as 'true' | 'false');
|
||||
}}
|
||||
>
|
||||
<option value="true">Только активные</option>
|
||||
<option value="false">Только неактивные</option>
|
||||
</Select>
|
||||
<Button type="submit" variant="secondary">
|
||||
Найти
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{isFormOpen && (
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-base font-semibold">{editingItem ? 'Редактировать запись' : 'Создать запись'}</h2>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={closeForm}>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{fields.map((field) => {
|
||||
const disabled = Boolean(editingItem && field.disabledOnEdit);
|
||||
|
||||
return (
|
||||
<FormField key={field.name} label={field.label} required={field.required}>
|
||||
{field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={form[field.name] ?? ''}
|
||||
onChange={(event) => setForm((current) => ({ ...current, [field.name]: event.target.value }))}
|
||||
required={field.required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type ?? 'text'}
|
||||
value={form[field.name] ?? ''}
|
||||
onChange={(event) => setForm((current) => ({ ...current, [field.name]: event.target.value }))}
|
||||
required={field.required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable
|
||||
items={pageData?.items ?? []}
|
||||
columns={tableColumns}
|
||||
getRowKey={(item) => item.id}
|
||||
emptyTitle="Записи не найдены"
|
||||
actions={
|
||||
canWrite
|
||||
? (item) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => openEditForm(item)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button type="button" variant="danger" size="sm" onClick={() => setDeactivateItem(item)}>
|
||||
Деактивировать
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deactivateItem)}
|
||||
title="Деактивировать запись"
|
||||
message="Запись будет помечена как неактивная. История и связанные данные останутся доступными."
|
||||
confirmLabel="Деактивировать"
|
||||
isConfirming={isSaving}
|
||||
onCancel={() => setDeactivateItem(null)}
|
||||
onConfirm={handleDeactivate}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import { salesApi, type CustomerOrder, type CustomerOrderPayload, type CustomerOrderStatus, type CustomerOrderStatusHistory } from '../api/salesApi';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { CustomerOrderForm } from '../components/CustomerOrderForm';
|
||||
import { CustomerOrderDocuments } from '../components/CustomerOrderDocuments';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { ConfirmDialog } from '../components/ui/ConfirmDialog';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { formatDate, formatDateTime, formatMoney, formatQuantity } from '../utils/formatters';
|
||||
|
||||
export function CustomerOrderDetailsPage() {
|
||||
const { id } = useParams();
|
||||
const { user } = useAuth();
|
||||
const canManage = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
const canWarehouseProcess = canManage || user?.role === 'WAREHOUSE';
|
||||
const [customerOrder, setCustomerOrder] = useState<CustomerOrder | null>(null);
|
||||
const [history, setHistory] = useState<CustomerOrderStatusHistory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [orderData, historyData] = await Promise.all([
|
||||
salesApi.getCustomerOrder(id),
|
||||
salesApi.getCustomerOrderStatusHistory(id),
|
||||
]);
|
||||
setCustomerOrder(orderData);
|
||||
setHistory(historyData);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить клиентский заказ'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const changeStatus = async (status: CustomerOrderStatus) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingStatus(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await salesApi.changeCustomerOrderStatus(id, status);
|
||||
await loadData();
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось изменить статус'));
|
||||
} finally {
|
||||
setIsChangingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrder = async (payload: CustomerOrderPayload) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await salesApi.updateCustomerOrder(id, payload);
|
||||
setIsEditing(false);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const cancelOrder = async () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingStatus(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await salesApi.changeCustomerOrderStatus(id, 'CANCELLED', 'Отменено из интерфейса');
|
||||
setIsCancelDialogOpen(false);
|
||||
await loadData();
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось отменить клиентский заказ'));
|
||||
} finally {
|
||||
setIsChangingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout title="Детали клиентского заказа">
|
||||
<LoadingState />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!customerOrder) {
|
||||
return (
|
||||
<Layout title="Детали клиентского заказа">
|
||||
<ErrorState message={error || 'Клиентский заказ не найден'} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={customerOrder.orderNumber}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Link to="/sales/customer-orders" className="text-sm font-medium text-slate-600 hover:text-slate-950">
|
||||
Назад к клиентским заказам
|
||||
</Link>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-normal">{customerOrder.orderNumber}</h1>
|
||||
<StatusBadge status={customerOrder.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-600">{customerOrder.customer.companyName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customerOrder.status === 'NEW' && canManage && (
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={() => setIsEditing(true)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button type="button" onClick={() => changeStatus('CONFIRMED')} disabled={isChangingStatus}>
|
||||
Подтвердить
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={() => setIsCancelDialogOpen(true)} disabled={isChangingStatus}>
|
||||
Отменить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{customerOrder.status === 'CONFIRMED' && (
|
||||
<>
|
||||
{canWarehouseProcess && (
|
||||
<Button type="button" onClick={() => changeStatus('IN_PROGRESS')} disabled={isChangingStatus}>
|
||||
Взять в работу
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button type="button" variant="danger" onClick={() => setIsCancelDialogOpen(true)} disabled={isChangingStatus}>
|
||||
Отменить
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{customerOrder.status === 'IN_PROGRESS' && (
|
||||
<>
|
||||
{canWarehouseProcess && (
|
||||
<Button type="button" onClick={() => changeStatus('SHIPPED')} disabled={isChangingStatus}>
|
||||
Отгрузить
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button type="button" variant="danger" onClick={() => setIsCancelDialogOpen(true)} disabled={isChangingStatus}>
|
||||
Отменить
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{customerOrder.status === 'SHIPPED' && canManage && (
|
||||
<Button type="button" onClick={() => changeStatus('CLOSED')} disabled={isChangingStatus}>
|
||||
Закрыть
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{isEditing && customerOrder.status === 'NEW' && (
|
||||
<CustomerOrderForm
|
||||
initialOrder={customerOrder}
|
||||
submitLabel="Сохранить заказ"
|
||||
onSubmit={updateOrder}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-5">
|
||||
<InfoBox label="Склад" value={customerOrder.warehouse ? `${customerOrder.warehouse.code} / ${customerOrder.warehouse.name}` : '-'} />
|
||||
<InfoBox label="Желаемая доставка" value={formatDate(customerOrder.requestedDeliveryDate)} />
|
||||
<InfoBox label="Сумма" value={formatMoney(customerOrder.totalAmount)} />
|
||||
<InfoBox label="Создано" value={formatDateTime(customerOrder.createdAt)} />
|
||||
<InfoBox label="Обновлено" value={formatDateTime(customerOrder.updatedAt)} />
|
||||
</section>
|
||||
|
||||
{customerOrder.notes && (
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-slate-800">Примечание</h2>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-slate-600">{customerOrder.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="overflow-x-auto rounded-md border border-slate-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">SKU</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">Товар</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Количество</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Цена продажи</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Сумма строки</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{customerOrder.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3 text-slate-700">{item.product.sku}</td>
|
||||
<td className="px-4 py-3 text-slate-700">{item.product.name}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">
|
||||
{formatQuantity(item.quantity)} {item.product.unit}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">{formatMoney(item.unitPrice)}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">{formatMoney(item.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<CustomerOrderDocuments customerOrderId={customerOrder.id} />
|
||||
|
||||
<section className="rounded-md border border-slate-200 bg-white p-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">История статусов</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{history.map((entry) => (
|
||||
<div key={entry.id} className="rounded-md border border-slate-200 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={entry.oldStatus ?? 'NEW'} />
|
||||
<span className="text-slate-400">→</span>
|
||||
<StatusBadge status={entry.newStatus} />
|
||||
<span className="ml-auto text-slate-500">{formatDateTime(entry.createdAt)}</span>
|
||||
</div>
|
||||
{entry.comment && <p className="mt-2 text-slate-600">{entry.comment}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isCancelDialogOpen}
|
||||
title="Отменить клиентский заказ"
|
||||
message="Статус заказа изменится на «Отменен». После отмены заказ нельзя будет продолжить."
|
||||
confirmLabel="Отменить заказ"
|
||||
isConfirming={isChangingStatus}
|
||||
onCancel={() => setIsCancelDialogOpen(false)}
|
||||
onConfirm={cancelOrder}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-white p-4">
|
||||
<p className="text-xs font-medium uppercase text-slate-500">{label}</p>
|
||||
<p className="mt-2 break-words text-sm font-medium text-slate-900">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import { salesApi, type CustomerOrder, type CustomerOrderPayload, type CustomerOrderStatus } from '../api/salesApi';
|
||||
import type { PageResponse } from '../api/types';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { CustomerOrderForm } from '../components/CustomerOrderForm';
|
||||
import { DataTable, type TableColumn } from '../components/DataTable';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { buttonClassName, 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, formatMoney, formatStatus } from '../utils/formatters';
|
||||
|
||||
const statuses: Array<CustomerOrderStatus | ''> = ['', 'NEW', 'CONFIRMED', 'IN_PROGRESS', 'SHIPPED', 'CLOSED', 'CANCELLED'];
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function CustomerOrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canWrite = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
const [pageData, setPageData] = useState<PageResponse<CustomerOrder> | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState<CustomerOrderStatus | ''>('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await salesApi.listCustomerOrders({
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
search,
|
||||
status,
|
||||
});
|
||||
setPageData(data);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить клиентские заказы'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [page, search, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const columns = useMemo<TableColumn<CustomerOrder>[]>(
|
||||
() => [
|
||||
{ key: 'orderNumber', label: 'Номер заказа', render: (item) => item.orderNumber },
|
||||
{ key: 'customer', label: 'Клиент', render: (item) => item.customer.companyName },
|
||||
{ key: 'warehouse', label: 'Склад', render: (item) => (item.warehouse ? `${item.warehouse.code} / ${item.warehouse.name}` : '-') },
|
||||
{ key: 'status', label: 'Статус', render: (item) => <StatusBadge status={item.status} /> },
|
||||
{ key: 'requestedDeliveryDate', label: 'Желаемая доставка', render: (item) => item.requestedDeliveryDate || '-' },
|
||||
{ key: 'totalAmount', label: 'Сумма', render: (item) => formatMoney(item.totalAmount) },
|
||||
{ key: 'createdAt', label: 'Создано', render: (item) => formatDateTime(item.createdAt) },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPage(0);
|
||||
setSearch(searchInput.trim());
|
||||
};
|
||||
|
||||
const handleCreate = async (payload: CustomerOrderPayload) => {
|
||||
const created = await salesApi.createCustomerOrder(payload);
|
||||
navigate(`/sales/customer-orders/${created.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Клиентские заказы">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Клиентские заказы"
|
||||
description="Создание и контроль B2B-заказов. При статусе «Отгружен» товар списывается со склада."
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button type="button" onClick={() => setShowCreateForm((current) => !current)}>
|
||||
Создать заказ
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 rounded-md border border-slate-200 bg-white p-4 shadow-sm sm:flex-row">
|
||||
<Input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Поиск по номеру заказа"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(event) => {
|
||||
setPage(0);
|
||||
setStatus(event.target.value as CustomerOrderStatus | '');
|
||||
}}
|
||||
>
|
||||
{statuses.map((statusOption) => (
|
||||
<option key={statusOption || 'all'} value={statusOption}>
|
||||
{statusOption ? formatStatus(statusOption) : 'Все статусы'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button type="submit" variant="secondary">
|
||||
Найти
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{showCreateForm && canWrite && (
|
||||
<CustomerOrderForm
|
||||
submitLabel="Создать заказ"
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable
|
||||
items={pageData?.items ?? []}
|
||||
columns={columns}
|
||||
getRowKey={(item) => item.id}
|
||||
emptyTitle="Клиентские заказы не найдены"
|
||||
actions={(item) => (
|
||||
<Link
|
||||
to={`/sales/customer-orders/${item.id}`}
|
||||
className={buttonClassName({ variant: 'secondary', size: 'sm' })}
|
||||
>
|
||||
Открыть
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { catalogApi, type Customer } from '../api/catalogApi';
|
||||
import type { TableColumn } from '../components/DataTable';
|
||||
import { CatalogPage, type CatalogField, type CatalogFormState } from './CatalogPage';
|
||||
|
||||
const fields: CatalogField[] = [
|
||||
{ name: 'companyName', label: 'Название компании', required: true },
|
||||
{ name: 'bin', label: 'БИН' },
|
||||
{ name: 'contactName', label: 'Контактное лицо' },
|
||||
{ name: 'phone', label: 'Телефон' },
|
||||
{ name: 'email', label: 'Email', type: 'email' },
|
||||
{ name: 'address', label: 'Адрес', type: 'textarea' },
|
||||
];
|
||||
|
||||
const columns: TableColumn<Customer>[] = [
|
||||
{ key: 'companyName', label: 'Компания', render: (item) => item.companyName },
|
||||
{ key: 'bin', label: 'БИН', render: (item) => item.bin || '-' },
|
||||
{ key: 'contactName', label: 'Контакт', render: (item) => item.contactName || '-' },
|
||||
{ key: 'email', label: 'Email', render: (item) => item.email || '-' },
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
companyName: '',
|
||||
bin: '',
|
||||
contactName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
};
|
||||
|
||||
export function CustomersPage() {
|
||||
return (
|
||||
<CatalogPage<Customer>
|
||||
title="Клиенты"
|
||||
description="Справочник B2B-клиентов для продаж."
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
emptyForm={emptyForm}
|
||||
list={catalogApi.listCustomers}
|
||||
create={(form) => catalogApi.createCustomer(toPayload(form))}
|
||||
update={(id, form) => catalogApi.updateCustomer(id, toPayload(form))}
|
||||
remove={catalogApi.deleteCustomer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function toPayload(form: CatalogFormState) {
|
||||
return {
|
||||
companyName: form.companyName.trim(),
|
||||
bin: optional(form.bin),
|
||||
contactName: optional(form.contactName),
|
||||
phone: optional(form.phone),
|
||||
email: optional(form.email),
|
||||
address: optional(form.address),
|
||||
};
|
||||
}
|
||||
|
||||
function optional(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
dashboardApi,
|
||||
type DashboardSummary,
|
||||
type DashboardWarehouse,
|
||||
type LowStockItem,
|
||||
type RecentActivity,
|
||||
} from '../api/dashboardApi';
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import { KpiCard } from '../components/dashboard/KpiCard';
|
||||
import { LowStockTable } from '../components/dashboard/LowStockTable';
|
||||
import { RecentActivities } from '../components/dashboard/RecentActivities';
|
||||
import { StatusSummaryTable } from '../components/dashboard/StatusSummaryTable';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { EmptyState } from '../components/ui/EmptyState';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { formatDocumentType, formatMovementType, formatMoney, formatQuantity } from '../utils/formatters';
|
||||
|
||||
export function DashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [warehouse, setWarehouse] = useState<DashboardWarehouse | null>(null);
|
||||
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([]);
|
||||
const [lowStockItems, setLowStockItems] = useState<LowStockItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [summaryData, warehouseData, recentData, lowStockData] = await Promise.all([
|
||||
dashboardApi.getDashboardSummary(),
|
||||
dashboardApi.getWarehouseDashboard(),
|
||||
dashboardApi.getRecentActivities(20),
|
||||
dashboardApi.getLowStock(10, 0, 10),
|
||||
]);
|
||||
|
||||
setSummary(summaryData);
|
||||
setWarehouse(warehouseData);
|
||||
setRecentActivities(recentData);
|
||||
setLowStockItems(lowStockData.items);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить панель управления'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
return (
|
||||
<Layout title="Панель управления">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Панель управления ERP MVP"
|
||||
description="Операционная картина по справочникам, продажам, закупкам, складу и документам."
|
||||
actions={
|
||||
<Button type="button" variant="secondary" onClick={() => loadDashboard()} disabled={isLoading}>
|
||||
{isLoading ? 'Обновление...' : 'Обновить'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{isLoading && !summary ? (
|
||||
<LoadingState message="Загрузка панели управления..." />
|
||||
) : summary ? (
|
||||
<>
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<KpiCard label="Товары" value={summary.productsCount} hint={`Активных: ${summary.activeProductsCount}`} />
|
||||
<KpiCard label="Клиенты" value={summary.customersCount} hint={`Поставщиков: ${summary.suppliersCount}`} />
|
||||
<KpiCard label="Заказы клиентов" value={summary.customerOrdersCount} hint={`Активных: ${summary.activeCustomerOrdersCount}`} />
|
||||
<KpiCard label="Сумма продаж" value={formatMoney(summary.totalSalesAmount)} />
|
||||
<KpiCard label="Закупки" value={summary.purchaseOrdersCount} hint={`Активных: ${summary.activePurchaseOrdersCount}`} />
|
||||
<KpiCard label="Сумма закупок" value={formatMoney(summary.totalProcurementAmount)} />
|
||||
<KpiCard label="Складские позиции" value={summary.stockItemsCount} hint={`${formatQuantity(summary.totalQuantityOnHand)} ед.`} />
|
||||
<KpiCard label="Низкий остаток" value={summary.lowStockItemsCount} hint="Порог: 10" />
|
||||
<KpiCard label="Документы" value={summary.documentsCount} hint={formatDocumentHint(summary)} />
|
||||
<KpiCard label="Склады" value={summary.warehousesCount} hint={`Движений: ${summary.stockMovementsCount}`} />
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<StatusSummaryTable title="Продажи по статусам" metrics={summary.salesOrdersByStatus} />
|
||||
<StatusSummaryTable title="Закупки по статусам" metrics={summary.purchaseOrdersByStatus} />
|
||||
</div>
|
||||
|
||||
{warehouse && (
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">Обзор склада</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Движений: {warehouse.movementsCount} / на остатке: {formatQuantity(warehouse.totalQuantityOnHand)} ед.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-6 xl:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-800">Остатки по складам</h3>
|
||||
{warehouse.stockByWarehouse.length === 0 ? (
|
||||
<EmptyState title="Остатков на складах пока нет" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
{warehouse.stockByWarehouse.map((item) => (
|
||||
<MetricBar
|
||||
key={item.warehouseId}
|
||||
label={`${item.warehouseCode} / ${item.warehouseName}`}
|
||||
value={`${formatQuantity(item.totalQuantityOnHand)} ед.`}
|
||||
ratio={ratio(item.totalQuantityOnHand, warehouse.totalQuantityOnHand)}
|
||||
hint={`Товаров: ${item.productsCount}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-800">Движения по типам</h3>
|
||||
{warehouse.movementsByType.length === 0 ? (
|
||||
<EmptyState title="Движений склада пока нет" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
{warehouse.movementsByType.map((item) => (
|
||||
<MetricBar
|
||||
key={item.movementType}
|
||||
label={formatMovementType(item.movementType)}
|
||||
value={`${formatQuantity(item.totalQuantity)} ед.`}
|
||||
ratio={ratio(item.count, warehouse.movementsCount)}
|
||||
hint={`Движений: ${item.count}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<LowStockTable items={lowStockItems} />
|
||||
<RecentActivities activities={recentActivities} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<EmptyState title="Данные панели управления недоступны" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricBar({ label, value, ratio, hint }: { label: string; value: string; ratio: number; hint: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-slate-700">{label}</span>
|
||||
<span className="text-slate-500">{value}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-sm bg-slate-100">
|
||||
<div className="h-full rounded-sm bg-slate-800" style={{ width: `${Math.max(ratio * 100, 3)}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ratio(value: number, total: number) {
|
||||
if (!total) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(value / total, 1);
|
||||
}
|
||||
|
||||
function formatDocumentHint(summary: DashboardSummary) {
|
||||
if (summary.documentsByType.length === 0) {
|
||||
return 'Документов нет';
|
||||
}
|
||||
|
||||
return summary.documentsByType.map((item) => `${formatDocumentType(item.type)}: ${item.count}`).join(' / ');
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type DocumentRecord,
|
||||
type DocumentSourceType,
|
||||
type DocumentStatus,
|
||||
type DocumentType,
|
||||
documentsApi,
|
||||
saveBlob,
|
||||
} from '../api/documentsApi';
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import type { PageResponse } from '../api/types';
|
||||
import { DataTable, type TableColumn } from '../components/DataTable';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
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, formatDocumentType, formatStatus } from '../utils/formatters';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const documentTypes: Array<DocumentType | ''> = ['', 'INVOICE', 'CONTRACT', 'DELIVERY_NOTE'];
|
||||
const sourceTypes: Array<DocumentSourceType | ''> = ['', 'CUSTOMER_ORDER'];
|
||||
const statuses: Array<DocumentStatus | ''> = ['', 'GENERATED'];
|
||||
|
||||
export function DocumentsPage() {
|
||||
const [pageData, setPageData] = useState<PageResponse<DocumentRecord> | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [documentType, setDocumentType] = useState<DocumentType | ''>('');
|
||||
const [sourceType, setSourceType] = useState<DocumentSourceType | ''>('');
|
||||
const [status, setStatus] = useState<DocumentStatus | ''>('');
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await documentsApi.listDocuments({
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
documentType,
|
||||
sourceType,
|
||||
status,
|
||||
});
|
||||
setPageData(data);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить документы'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [documentType, page, sourceType, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const downloadDocument = async (document: DocumentRecord) => {
|
||||
setDownloadingId(document.id);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { blob, fileName } = await documentsApi.downloadDocument(document.id);
|
||||
saveBlob(blob, fileName || document.fileName);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось скачать документ'));
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<TableColumn<DocumentRecord>[]>(
|
||||
() => [
|
||||
{ key: 'documentNumber', label: 'Номер документа', render: (item) => item.documentNumber },
|
||||
{ key: 'documentType', label: 'Тип', render: (item) => formatDocumentType(item.documentType) },
|
||||
{ key: 'source', label: 'Источник', render: (item) => `${formatStatus(item.sourceType)} / ${item.sourceId.slice(0, 8)}` },
|
||||
{ key: 'fileName', label: 'Файл', render: (item) => item.fileName },
|
||||
{ key: 'status', label: 'Статус', render: (item) => <StatusBadge status={item.status} /> },
|
||||
{ key: 'generatedAt', label: 'Сформирован', render: (item) => formatDateTime(item.generatedAt) },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const resetPageAnd = (callback: () => void) => {
|
||||
setPage(0);
|
||||
callback();
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Документы">
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Документы" description="PDF-заглушки, сформированные по клиентским заказам." />
|
||||
|
||||
<div className="grid gap-3 rounded-md border border-slate-200 bg-white p-4 shadow-sm md:grid-cols-3">
|
||||
<Select
|
||||
value={documentType}
|
||||
onChange={(event) => resetPageAnd(() => setDocumentType(event.target.value as DocumentType | ''))}
|
||||
>
|
||||
{documentTypes.map((type) => (
|
||||
<option key={type || 'all'} value={type}>
|
||||
{type ? formatDocumentType(type) : 'Все типы документов'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sourceType}
|
||||
onChange={(event) => resetPageAnd(() => setSourceType(event.target.value as DocumentSourceType | ''))}
|
||||
>
|
||||
{sourceTypes.map((type) => (
|
||||
<option key={type || 'all'} value={type}>
|
||||
{type ? formatStatus(type) : 'Все типы источников'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(event) => resetPageAnd(() => setStatus(event.target.value as DocumentStatus | ''))}
|
||||
>
|
||||
{statuses.map((statusOption) => (
|
||||
<option key={statusOption || 'all'} value={statusOption}>
|
||||
{statusOption ? formatStatus(statusOption) : 'Все статусы'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable
|
||||
items={pageData?.items ?? []}
|
||||
columns={columns}
|
||||
getRowKey={(item) => item.id}
|
||||
emptyTitle="Документы не найдены"
|
||||
actions={(item) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadDocument(item)}
|
||||
disabled={downloadingId === item.id}
|
||||
>
|
||||
{downloadingId === item.id ? 'Скачивание...' : 'Скачать'}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { FormField } from '../components/ui/FormField';
|
||||
import { Input } from '../components/ui/Input';
|
||||
|
||||
type LocationState = {
|
||||
from?: {
|
||||
pathname?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const demoCredentials = [
|
||||
{ label: 'Администратор', email: 'admin@erp.local', password: 'admin12345' },
|
||||
{ label: 'Менеджер', email: 'manager@erp.local', password: 'manager12345' },
|
||||
{ label: 'Склад', email: 'warehouse@erp.local', password: 'warehouse12345' },
|
||||
{ label: 'Финансы', email: 'finance@erp.local', password: 'finance12345' },
|
||||
];
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoading, login } = useAuth();
|
||||
const [email, setEmail] = useState('admin@erp.local');
|
||||
const [password, setPassword] = useState('admin12345');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const state = location.state as LocationState | null;
|
||||
const redirectTo = state?.from?.pathname ?? '/dashboard';
|
||||
|
||||
if (!isLoading && isAuthenticated) {
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate(redirectTo, { replace: true });
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось войти'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 text-slate-950">
|
||||
<section className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-10">
|
||||
<Card className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">ERP MVP</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">Войдите, чтобы продолжить работу</p>
|
||||
</div>
|
||||
|
||||
<FormField label="Email" required>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="h-11"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Пароль" required>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="h-11"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
{isSubmitting ? 'Вход...' : 'Войти'}
|
||||
</Button>
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="text-sm font-semibold text-slate-900">Демо-доступы</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{demoCredentials.map((credential) => (
|
||||
<div
|
||||
key={credential.email}
|
||||
className="flex flex-col gap-2 rounded-md bg-white px-3 py-2 ring-1 ring-inset ring-slate-200 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-slate-800">{credential.label}</p>
|
||||
<p className="truncate text-xs text-slate-500">
|
||||
{credential.email} / {credential.password}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEmail(credential.email);
|
||||
setPassword(credential.password);
|
||||
}}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { catalogApi, type Product } from '../api/catalogApi';
|
||||
import type { TableColumn } from '../components/DataTable';
|
||||
import { CatalogPage, type CatalogField } from './CatalogPage';
|
||||
|
||||
const fields: CatalogField[] = [
|
||||
{ name: 'sku', label: 'SKU', required: true, disabledOnEdit: true },
|
||||
{ name: 'name', label: 'Наименование', required: true },
|
||||
{ name: 'category', label: 'Категория' },
|
||||
{ name: 'unit', label: 'Ед. изм.', required: true },
|
||||
{ name: 'barcode', label: 'Штрихкод' },
|
||||
{ name: 'description', label: 'Описание', type: 'textarea' },
|
||||
];
|
||||
|
||||
const columns: TableColumn<Product>[] = [
|
||||
{ key: 'sku', label: 'SKU', render: (item) => item.sku },
|
||||
{ key: 'name', label: 'Наименование', render: (item) => item.name },
|
||||
{ key: 'category', label: 'Категория', render: (item) => item.category || '-' },
|
||||
{ key: 'unit', label: 'Ед. изм.', render: (item) => item.unit },
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
sku: '',
|
||||
name: '',
|
||||
category: '',
|
||||
unit: 'pcs',
|
||||
barcode: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export function ProductsPage() {
|
||||
return (
|
||||
<CatalogPage<Product>
|
||||
title="Товары"
|
||||
description="Справочник товаров и SKU."
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
emptyForm={emptyForm}
|
||||
list={catalogApi.listProducts}
|
||||
create={(form) =>
|
||||
catalogApi.createProduct({
|
||||
sku: required(form.sku),
|
||||
name: required(form.name),
|
||||
category: optional(form.category),
|
||||
unit: required(form.unit),
|
||||
barcode: optional(form.barcode),
|
||||
description: optional(form.description),
|
||||
})
|
||||
}
|
||||
update={(id, form) =>
|
||||
catalogApi.updateProduct(id, {
|
||||
name: required(form.name),
|
||||
category: optional(form.category),
|
||||
unit: required(form.unit),
|
||||
barcode: optional(form.barcode),
|
||||
description: optional(form.description),
|
||||
})
|
||||
}
|
||||
remove={catalogApi.deleteProduct}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function required(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optional(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import {
|
||||
procurementApi,
|
||||
type PurchaseOrder,
|
||||
type PurchaseOrderPayload,
|
||||
type PurchaseOrderStatus,
|
||||
type PurchaseOrderStatusHistory,
|
||||
} from '../api/procurementApi';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { PurchaseOrderForm } from '../components/PurchaseOrderForm';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { ConfirmDialog } from '../components/ui/ConfirmDialog';
|
||||
import { ErrorState } from '../components/ui/ErrorState';
|
||||
import { LoadingState } from '../components/ui/LoadingState';
|
||||
import { formatDate, formatDateTime, formatMoney, formatQuantity } from '../utils/formatters';
|
||||
|
||||
export function PurchaseOrderDetailsPage() {
|
||||
const { id } = useParams();
|
||||
const { user } = useAuth();
|
||||
const canManage = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
const canReceive = canManage || user?.role === 'WAREHOUSE';
|
||||
const [purchaseOrder, setPurchaseOrder] = useState<PurchaseOrder | null>(null);
|
||||
const [history, setHistory] = useState<PurchaseOrderStatusHistory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [orderData, historyData] = await Promise.all([
|
||||
procurementApi.getPurchaseOrder(id),
|
||||
procurementApi.getPurchaseOrderStatusHistory(id),
|
||||
]);
|
||||
setPurchaseOrder(orderData);
|
||||
setHistory(historyData);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить закупку'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const changeStatus = async (status: PurchaseOrderStatus) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingStatus(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await procurementApi.changePurchaseOrderStatus(id, status);
|
||||
await loadData();
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось изменить статус'));
|
||||
} finally {
|
||||
setIsChangingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrder = async (payload: PurchaseOrderPayload) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await procurementApi.updatePurchaseOrder(id, payload);
|
||||
setIsEditing(false);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const cancelOrder = async () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingStatus(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await procurementApi.changePurchaseOrderStatus(id, 'CANCELLED', 'Отменено из интерфейса');
|
||||
setIsCancelDialogOpen(false);
|
||||
await loadData();
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось отменить закупку'));
|
||||
} finally {
|
||||
setIsChangingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout title="Детали закупки">
|
||||
<LoadingState />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!purchaseOrder) {
|
||||
return (
|
||||
<Layout title="Детали закупки">
|
||||
<ErrorState message={error || 'Закупка не найдена'} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title={purchaseOrder.poNumber}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Link to="/procurement/purchase-orders" className="text-sm font-medium text-slate-600 hover:text-slate-950">
|
||||
Назад к закупкам
|
||||
</Link>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-normal">{purchaseOrder.poNumber}</h1>
|
||||
<StatusBadge status={purchaseOrder.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-600">{purchaseOrder.supplier.companyName}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Склад поступления: {purchaseOrder.warehouse ? `${purchaseOrder.warehouse.code} / ${purchaseOrder.warehouse.name}` : 'не выбран'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{purchaseOrder.status === 'DRAFT' && canManage && (
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={() => setIsEditing(true)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button type="button" onClick={() => changeStatus('APPROVED')} disabled={isChangingStatus}>
|
||||
Согласовать
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={() => setIsCancelDialogOpen(true)} disabled={isChangingStatus}>
|
||||
Отменить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{purchaseOrder.status === 'APPROVED' && canManage && (
|
||||
<>
|
||||
<Button type="button" onClick={() => changeStatus('ORDERED')} disabled={isChangingStatus}>
|
||||
Отметить заказанной
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={() => setIsCancelDialogOpen(true)} disabled={isChangingStatus}>
|
||||
Отменить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{purchaseOrder.status === 'ORDERED' && (
|
||||
<>
|
||||
{canReceive && (
|
||||
<Button type="button" onClick={() => changeStatus('RECEIVED')} disabled={isChangingStatus}>
|
||||
Принять на склад
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button type="button" variant="danger" onClick={() => setIsCancelDialogOpen(true)} disabled={isChangingStatus}>
|
||||
Отменить
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{isEditing && purchaseOrder.status === 'DRAFT' && (
|
||||
<PurchaseOrderForm
|
||||
initialOrder={purchaseOrder}
|
||||
submitLabel="Сохранить закупку"
|
||||
onSubmit={updateOrder}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
<InfoBox
|
||||
label="Склад поступления"
|
||||
value={purchaseOrder.warehouse ? `${purchaseOrder.warehouse.code} / ${purchaseOrder.warehouse.name}` : '-'}
|
||||
/>
|
||||
<InfoBox label="Ожидаемая поставка" value={formatDate(purchaseOrder.expectedDeliveryDate)} />
|
||||
<InfoBox label="Сумма" value={formatMoney(purchaseOrder.totalAmount)} />
|
||||
<InfoBox label="Создано" value={formatDateTime(purchaseOrder.createdAt)} />
|
||||
</section>
|
||||
|
||||
{purchaseOrder.notes && (
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-slate-800">Примечание</h2>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-slate-600">{purchaseOrder.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="overflow-x-auto rounded-md border border-slate-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">SKU</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">Товар</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Количество</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Цена за ед.</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">Сумма строки</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{purchaseOrder.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3 text-slate-700">{item.product.sku}</td>
|
||||
<td className="px-4 py-3 text-slate-700">{item.product.name}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">
|
||||
{formatQuantity(item.quantity)} {item.product.unit}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">{formatMoney(item.unitPrice)}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">{formatMoney(item.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border border-slate-200 bg-white p-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">История статусов</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{history.map((entry) => (
|
||||
<div key={entry.id} className="rounded-md border border-slate-200 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={entry.oldStatus ?? 'NEW'} />
|
||||
<span className="text-slate-400">→</span>
|
||||
<StatusBadge status={entry.newStatus} />
|
||||
<span className="ml-auto text-slate-500">{formatDateTime(entry.createdAt)}</span>
|
||||
</div>
|
||||
{entry.comment && <p className="mt-2 text-slate-600">{entry.comment}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isCancelDialogOpen}
|
||||
title="Отменить закупку"
|
||||
message="Статус закупки изменится на «Отменен». После отмены закупку нельзя будет продолжить."
|
||||
confirmLabel="Отменить закупку"
|
||||
isConfirming={isChangingStatus}
|
||||
onCancel={() => setIsCancelDialogOpen(false)}
|
||||
onConfirm={cancelOrder}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-white p-4">
|
||||
<p className="text-xs font-medium uppercase text-slate-500">{label}</p>
|
||||
<p className="mt-2 break-words text-sm font-medium text-slate-900">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getApiErrorMessage } from '../api/errors';
|
||||
import {
|
||||
procurementApi,
|
||||
type PurchaseOrder,
|
||||
type PurchaseOrderPayload,
|
||||
type PurchaseOrderStatus,
|
||||
} from '../api/procurementApi';
|
||||
import type { PageResponse } from '../api/types';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { DataTable, type TableColumn } from '../components/DataTable';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { PurchaseOrderForm } from '../components/PurchaseOrderForm';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { buttonClassName, 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, formatMoney, formatStatus } from '../utils/formatters';
|
||||
|
||||
const statuses: Array<PurchaseOrderStatus | ''> = ['', 'DRAFT', 'APPROVED', 'ORDERED', 'RECEIVED', 'CANCELLED'];
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function PurchaseOrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canWrite = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
const [pageData, setPageData] = useState<PageResponse<PurchaseOrder> | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState<PurchaseOrderStatus | ''>('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await procurementApi.listPurchaseOrders({
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
search,
|
||||
status,
|
||||
});
|
||||
setPageData(data);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить закупки'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [page, search, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const columns = useMemo<TableColumn<PurchaseOrder>[]>(
|
||||
() => [
|
||||
{ key: 'poNumber', label: 'Номер закупки', render: (item) => item.poNumber },
|
||||
{ key: 'supplier', label: 'Поставщик', render: (item) => item.supplier.companyName },
|
||||
{ key: 'warehouse', label: 'Склад поступления', render: (item) => (item.warehouse ? `${item.warehouse.code} / ${item.warehouse.name}` : '-') },
|
||||
{ key: 'status', label: 'Статус', render: (item) => <StatusBadge status={item.status} /> },
|
||||
{ key: 'expectedDeliveryDate', label: 'Ожидаемая поставка', render: (item) => item.expectedDeliveryDate || '-' },
|
||||
{ key: 'totalAmount', label: 'Сумма', render: (item) => formatMoney(item.totalAmount) },
|
||||
{ key: 'createdAt', label: 'Создано', render: (item) => formatDateTime(item.createdAt) },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPage(0);
|
||||
setSearch(searchInput.trim());
|
||||
};
|
||||
|
||||
const handleCreate = async (payload: PurchaseOrderPayload) => {
|
||||
const created = await procurementApi.createPurchaseOrder(payload);
|
||||
navigate(`/procurement/purchase-orders/${created.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Закупки">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Закупки"
|
||||
description="Создание и контроль закупок. При статусе «Принят» товар поступает на склад."
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button type="button" onClick={() => setShowCreateForm((current) => !current)}>
|
||||
Создать закупку
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 rounded-md border border-slate-200 bg-white p-4 shadow-sm sm:flex-row">
|
||||
<Input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Поиск по номеру закупки"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(event) => {
|
||||
setPage(0);
|
||||
setStatus(event.target.value as PurchaseOrderStatus | '');
|
||||
}}
|
||||
>
|
||||
{statuses.map((statusOption) => (
|
||||
<option key={statusOption || 'all'} value={statusOption}>
|
||||
{statusOption ? formatStatus(statusOption) : 'Все статусы'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button type="submit" variant="secondary">
|
||||
Найти
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{showCreateForm && canWrite && (
|
||||
<PurchaseOrderForm
|
||||
submitLabel="Создать закупку"
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable
|
||||
items={pageData?.items ?? []}
|
||||
columns={columns}
|
||||
getRowKey={(item) => item.id}
|
||||
emptyTitle="Закупки не найдены"
|
||||
actions={(item) => (
|
||||
<Link
|
||||
to={`/procurement/purchase-orders/${item.id}`}
|
||||
className={buttonClassName({ variant: 'secondary', size: 'sm' })}
|
||||
>
|
||||
Открыть
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { 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 StockMovement,
|
||||
type StockMovementSourceType,
|
||||
type StockMovementType,
|
||||
warehouseApi,
|
||||
} from '../api/warehouseApi';
|
||||
import { DataTable, type TableColumn } from '../components/DataTable';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
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, formatMovementType, formatQuantity } from '../utils/formatters';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const movementTypes: Array<StockMovementType | ''> = ['', 'INBOUND', 'OUTBOUND', 'ADJUSTMENT_IN', 'ADJUSTMENT_OUT'];
|
||||
const sourceTypes: Array<StockMovementSourceType | ''> = ['', 'PURCHASE_ORDER', 'CUSTOMER_ORDER', 'MANUAL_ADJUSTMENT'];
|
||||
|
||||
export function StockMovementsPage() {
|
||||
const [pageData, setPageData] = useState<PageResponse<StockMovement> | null>(null);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [productId, setProductId] = useState('');
|
||||
const [movementType, setMovementType] = useState<StockMovementType | ''>('');
|
||||
const [sourceType, setSourceType] = useState<StockMovementSourceType | ''>('');
|
||||
const [sourceIdInput, setSourceIdInput] = useState('');
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
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.listStockMovements({
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
warehouseId,
|
||||
productId,
|
||||
movementType,
|
||||
sourceType,
|
||||
sourceId,
|
||||
});
|
||||
setPageData(data);
|
||||
} catch (caughtError) {
|
||||
setError(getApiErrorMessage(caughtError, 'Не удалось загрузить движения склада'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [movementType, page, productId, sourceId, sourceType, warehouseId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const columns = useMemo<TableColumn<StockMovement>[]>(
|
||||
() => [
|
||||
{ key: 'movementNumber', label: 'Номер движения', render: (item) => item.movementNumber },
|
||||
{ key: 'warehouse', label: 'Склад', render: (item) => `${item.warehouse.code} / ${item.warehouse.name}` },
|
||||
{ key: 'product', label: 'Товар', render: (item) => `${item.product.sku} / ${item.product.name}` },
|
||||
{ key: 'movementType', label: 'Тип', render: (item) => <StatusBadge status={item.movementType} /> },
|
||||
{ key: 'quantity', label: 'Количество', render: (item) => formatQuantity(item.quantity) },
|
||||
{ key: 'quantityBefore', label: 'До', render: (item) => formatQuantity(item.quantityBefore) },
|
||||
{ key: 'quantityAfter', label: 'После', render: (item) => formatQuantity(item.quantityAfter) },
|
||||
{ key: 'source', label: 'Источник', render: (item) => formatSource(item) },
|
||||
{ key: 'comment', label: 'Комментарий', render: (item) => item.comment || '-' },
|
||||
{ key: 'createdAt', label: 'Создано', render: (item) => formatDateTime(item.createdAt) },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const resetPageAnd = (callback: () => void) => {
|
||||
setPage(0);
|
||||
callback();
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Движения склада">
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Движения склада" description="История приходов, расходов и ручных корректировок." />
|
||||
|
||||
<div className="grid gap-3 rounded-md border border-slate-200 bg-white p-4 shadow-sm lg:grid-cols-3">
|
||||
<Select
|
||||
value={warehouseId}
|
||||
onChange={(event) => resetPageAnd(() => 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) => resetPageAnd(() => setProductId(event.target.value))}
|
||||
>
|
||||
<option value="">Все товары</option>
|
||||
{products.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.sku} / {product.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={movementType}
|
||||
onChange={(event) => resetPageAnd(() => setMovementType(event.target.value as StockMovementType | ''))}
|
||||
>
|
||||
{movementTypes.map((type) => (
|
||||
<option key={type || 'all'} value={type}>
|
||||
{type ? formatMovementType(type) : 'Все типы движений'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sourceType}
|
||||
onChange={(event) => resetPageAnd(() => setSourceType(event.target.value as StockMovementSourceType | ''))}
|
||||
>
|
||||
{sourceTypes.map((type) => (
|
||||
<option key={type || 'all'} value={type}>
|
||||
{type ? formatMovementType(type) : 'Все типы источников'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
value={sourceIdInput}
|
||||
onChange={(event) => setSourceIdInput(event.target.value)}
|
||||
placeholder="ID источника"
|
||||
className="min-w-0"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setPage(0);
|
||||
setSourceId(sourceIdInput.trim());
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSource(item: StockMovement) {
|
||||
if (!item.sourceType) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return item.sourceId ? `${formatMovementType(item.sourceType)} / ${item.sourceId.slice(0, 8)}` : formatMovementType(item.sourceType);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { catalogApi, type Supplier } from '../api/catalogApi';
|
||||
import type { TableColumn } from '../components/DataTable';
|
||||
import { CatalogPage, type CatalogField, type CatalogFormState } from './CatalogPage';
|
||||
|
||||
const fields: CatalogField[] = [
|
||||
{ name: 'companyName', label: 'Название компании', required: true },
|
||||
{ name: 'bin', label: 'БИН' },
|
||||
{ name: 'contactName', label: 'Контактное лицо' },
|
||||
{ name: 'phone', label: 'Телефон' },
|
||||
{ name: 'email', label: 'Email', type: 'email' },
|
||||
{ name: 'address', label: 'Адрес', type: 'textarea' },
|
||||
];
|
||||
|
||||
const columns: TableColumn<Supplier>[] = [
|
||||
{ key: 'companyName', label: 'Компания', render: (item) => item.companyName },
|
||||
{ key: 'bin', label: 'БИН', render: (item) => item.bin || '-' },
|
||||
{ key: 'contactName', label: 'Контакт', render: (item) => item.contactName || '-' },
|
||||
{ key: 'email', label: 'Email', render: (item) => item.email || '-' },
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
companyName: '',
|
||||
bin: '',
|
||||
contactName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
};
|
||||
|
||||
export function SuppliersPage() {
|
||||
return (
|
||||
<CatalogPage<Supplier>
|
||||
title="Поставщики"
|
||||
description="Справочник поставщиков для закупок."
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
emptyForm={emptyForm}
|
||||
list={catalogApi.listSuppliers}
|
||||
create={(form) => catalogApi.createSupplier(toPayload(form))}
|
||||
update={(id, form) => catalogApi.updateSupplier(id, toPayload(form))}
|
||||
remove={catalogApi.deleteSupplier}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function toPayload(form: CatalogFormState) {
|
||||
return {
|
||||
companyName: form.companyName.trim(),
|
||||
bin: optional(form.bin),
|
||||
contactName: optional(form.contactName),
|
||||
phone: optional(form.phone),
|
||||
email: optional(form.email),
|
||||
address: optional(form.address),
|
||||
};
|
||||
}
|
||||
|
||||
function optional(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { catalogApi, type Warehouse } from '../api/catalogApi';
|
||||
import type { TableColumn } from '../components/DataTable';
|
||||
import { CatalogPage, type CatalogField } from './CatalogPage';
|
||||
|
||||
const fields: CatalogField[] = [
|
||||
{ name: 'code', label: 'Код', required: true, disabledOnEdit: true },
|
||||
{ name: 'name', label: 'Название', required: true },
|
||||
{ name: 'address', label: 'Адрес', type: 'textarea' },
|
||||
];
|
||||
|
||||
const columns: TableColumn<Warehouse>[] = [
|
||||
{ key: 'code', label: 'Код', render: (item) => item.code },
|
||||
{ key: 'name', label: 'Название', render: (item) => item.name },
|
||||
{ key: 'address', label: 'Адрес', render: (item) => item.address || '-' },
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
code: '',
|
||||
name: '',
|
||||
address: '',
|
||||
};
|
||||
|
||||
export function WarehousesPage() {
|
||||
return (
|
||||
<CatalogPage<Warehouse>
|
||||
title="Склады"
|
||||
description="Справочник складов для учета остатков."
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
emptyForm={emptyForm}
|
||||
list={catalogApi.listWarehouses}
|
||||
create={(form) =>
|
||||
catalogApi.createWarehouse({
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
address: optional(form.address),
|
||||
})
|
||||
}
|
||||
update={(id, form) =>
|
||||
catalogApi.updateWarehouse(id, {
|
||||
name: form.name.trim(),
|
||||
address: optional(form.address),
|
||||
})
|
||||
}
|
||||
remove={catalogApi.deleteWarehouse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function optional(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
function toNumber(value: number | string | null | undefined) {
|
||||
const numericValue = Number(value ?? 0);
|
||||
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||
}
|
||||
|
||||
export function formatMoney(value: number | string | null | undefined) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(toNumber(value));
|
||||
}
|
||||
|
||||
export function formatQuantity(value: number | string | null | undefined) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
}).format(toNumber(value));
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
export function formatStatus(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
ADMIN: 'Администратор',
|
||||
MANAGER: 'Менеджер',
|
||||
WAREHOUSE: 'Склад',
|
||||
FINANCE: 'Финансы',
|
||||
DRAFT: 'Черновик',
|
||||
APPROVED: 'Согласован',
|
||||
ORDERED: 'Заказан',
|
||||
RECEIVED: 'Принят',
|
||||
CANCELLED: 'Отменен',
|
||||
NEW: 'Новый',
|
||||
CONFIRMED: 'Подтвержден',
|
||||
IN_PROGRESS: 'В работе',
|
||||
SHIPPED: 'Отгружен',
|
||||
CLOSED: 'Закрыт',
|
||||
GENERATED: 'Сформирован',
|
||||
INBOUND: 'Приход',
|
||||
OUTBOUND: 'Расход',
|
||||
ADJUSTMENT_IN: 'Корректировка +',
|
||||
ADJUSTMENT_OUT: 'Корректировка -',
|
||||
CUSTOMER_ORDER: 'Клиентский заказ',
|
||||
PURCHASE_ORDER: 'Закупка',
|
||||
STOCK_MOVEMENT: 'Движение склада',
|
||||
DOCUMENT: 'Документ',
|
||||
MANUAL_ADJUSTMENT: 'Ручная корректировка',
|
||||
};
|
||||
|
||||
return labels[value] ?? value;
|
||||
}
|
||||
|
||||
export function formatDocumentType(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
INVOICE: 'Счет',
|
||||
CONTRACT: 'Договор',
|
||||
DELIVERY_NOTE: 'Накладная',
|
||||
};
|
||||
|
||||
return labels[value] ?? formatStatus(value);
|
||||
}
|
||||
|
||||
export function formatMovementType(value: string | null | undefined) {
|
||||
return formatStatus(value);
|
||||
}
|
||||
|
||||
export function formatActivityTitle(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/^Customer order (.+) created$/, 'Клиентский заказ $1 создан')
|
||||
.replace(/^Purchase order (.+) status (.+)$/, (_match, number: string, status: string) => `Закупка ${number}: статус ${formatStatus(status)}`)
|
||||
.replace(/^Stock movement (.+) (.+)$/, (_match, number: string, type: string) => `Движение склада ${number}: ${formatMovementType(type)}`)
|
||||
.replace(/^Document (.+) generated$/, 'Документ $1 сформирован');
|
||||
}
|
||||
|
||||
export function formatActivityDescription(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/^Customer order for (.+)$/, 'Клиентский заказ для $1')
|
||||
.replace(/^Purchase order from (.+)$/, 'Закупка у поставщика $1')
|
||||
.replace(/^(.+) \/ (.+)$/, '$1 / $2')
|
||||
.replace(/^Document type (.+)$/, (_match, type: string) => `Тип документа: ${formatDocumentType(type)}`);
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
Reference in New Issue
Block a user