sync: migrate erp-mvp to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:55 +00:00
commit 1b792f02ae
449 changed files with 17419 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.vite
npm-debug.log
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+19
View File
@@ -0,0 +1,19 @@
FROM node:22-alpine AS build
WORKDIR /app
ARG VITE_API_BASE_URL=https://erp.konturai.kz
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.server.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ERP MVP</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
}
+2443
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "erp-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"axios": "^1.16.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"vite": "^8.0.12"
}
}
+7
View File
@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+42
View File
@@ -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;
+140
View File
@@ -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),
};
+34
View File
@@ -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);
},
);
+171
View File
@@ -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, 'Не удалось загрузить низкие остатки');
},
};
+106
View File
@@ -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);
}
+50
View File
@@ -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;
}
+143
View File
@@ -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}`);
},
};
+147
View File
@@ -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}`);
},
};
+21
View File
@@ -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;
};
+118
View File
@@ -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, 'Не удалось создать корректировку');
},
};
+141
View File
@@ -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;
}
+23
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+45
View File
@@ -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';
+24
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+11
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+13
View File
@@ -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';
+13
View File
@@ -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';
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
}
+17
View File
@@ -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>,
);
+314
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+60
View File
@@ -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;
}
+191
View File
@@ -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(' / ');
}
+172
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}
+197
View File
@@ -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>
);
}
+221
View File
@@ -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);
}
+60
View File
@@ -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;
}
+53
View File
@@ -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;
}
+113
View File
@@ -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)}`);
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
});