Настройки работают

Иерархия групп работает
Полностью завязано на пользователя и серверы
This commit is contained in:
2025-12-18 07:21:31 +03:00
parent 542beafe0e
commit 4e4571b3db
23 changed files with 1572 additions and 385 deletions

View File

@@ -1,15 +1,37 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Spin, Alert, Button, Form, Select, DatePicker, Input,
Typography, message, Row, Col, Affix, Modal, Tag
} from 'antd';
import { ArrowLeftOutlined, CheckOutlined, DeleteOutlined, ExclamationCircleFilled, RestOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '../services/api';
import { DraftItemRow } from '../components/invoices/DraftItemRow';
import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types';
import React, { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Spin,
Alert,
Button,
Form,
Select,
DatePicker,
Input,
Typography,
message,
Row,
Col,
Affix,
Modal,
Tag,
} from "antd";
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
ExclamationCircleFilled,
RestOutlined,
PlusOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { api } from "../services/api";
import { DraftItemRow } from "../components/invoices/DraftItemRow";
import type {
UpdateDraftItemRequest,
CommitDraftRequest,
} from "../services/types";
const { Text } = Typography;
const { TextArea } = Input;
@@ -20,27 +42,29 @@ export const InvoiceDraftPage: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// --- ЗАПРОСЫ ---
// Получаем сразу все справочники одним запросом
const dictQuery = useQuery({
queryKey: ['dictionaries'],
const dictQuery = useQuery({
queryKey: ["dictionaries"],
queryFn: api.getDictionaries,
staleTime: 1000 * 60 * 5 // Кэшируем на 5 минут
staleTime: 1000 * 60 * 5,
});
const recommendationsQuery = useQuery({
queryKey: ["recommendations"],
queryFn: api.getRecommendations,
});
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
const draftQuery = useQuery({
queryKey: ['draft', id],
queryKey: ["draft", id],
queryFn: () => api.getDraft(id!),
enabled: !!id,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'PROCESSING' ? 3000 : false;
return status === "PROCESSING" ? 3000 : false;
},
});
@@ -48,111 +72,154 @@ export const InvoiceDraftPage: React.FC = () => {
const stores = dictQuery.data?.stores || [];
const suppliers = dictQuery.data?.suppliers || [];
// --- МУТАЦИИ ---
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload),
onMutate: async ({ itemId }) => {
setUpdatingItems(prev => new Set(prev).add(itemId));
setUpdatingItems((prev) => new Set(prev).add(itemId));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['draft', id] });
queryClient.invalidateQueries({ queryKey: ["draft", id] });
},
onError: () => {
message.error('Не удалось сохранить строку');
message.error("Не удалось сохранить строку");
},
onSettled: (_data, _err, vars) => {
setUpdatingItems(prev => {
setUpdatingItems((prev) => {
const next = new Set(prev);
next.delete(vars.itemId);
return next;
});
}
},
});
// ДОБАВЛЕНО: Добавление строки
const addItemMutation = useMutation({
mutationFn: () => api.addDraftItem(id!),
onSuccess: () => {
message.success("Строка добавлена");
queryClient.invalidateQueries({ queryKey: ["draft", id] });
// Можно сделать скролл вниз, но пока оставим как есть
},
onError: () => {
message.error("Ошибка создания строки");
},
});
// ДОБАВЛЕНО: Удаление строки
const deleteItemMutation = useMutation({
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
onSuccess: () => {
message.success("Строка удалена");
queryClient.invalidateQueries({ queryKey: ["draft", id] });
},
onError: () => {
message.error("Ошибка удаления строки");
},
});
const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
onSuccess: (data) => {
message.success(`Накладная ${data.document_number} создана!`);
navigate('/invoices');
navigate("/invoices");
},
onError: () => {
message.error('Ошибка при создании накладной');
}
message.error("Ошибка при создании накладной");
},
});
const deleteDraftMutation = useMutation({
mutationFn: () => api.deleteDraft(id!),
onSuccess: () => {
if (draft?.status === 'CANCELED') {
message.info('Черновик удален окончательно');
navigate('/invoices');
if (draft?.status === "CANCELED") {
message.info("Черновик удален окончательно");
navigate("/invoices");
} else {
message.warning('Черновик отменен');
queryClient.invalidateQueries({ queryKey: ['draft', id] });
message.warning("Черновик отменен");
queryClient.invalidateQueries({ queryKey: ["draft", id] });
}
},
onError: () => {
message.error('Ошибка при удалении');
}
message.error("Ошибка при удалении");
},
});
// --- ЭФФЕКТЫ ---
useEffect(() => {
if (draft) {
const currentValues = form.getFieldsValue();
if (!currentValues.store_id && draft.store_id) form.setFieldValue('store_id', draft.store_id);
if (!currentValues.supplier_id && draft.supplier_id) form.setFieldValue('supplier_id', draft.supplier_id);
if (!currentValues.comment && draft.comment) form.setFieldValue('comment', draft.comment);
if (!currentValues.date_incoming) form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
if (!currentValues.store_id && draft.store_id)
form.setFieldValue("store_id", draft.store_id);
if (!currentValues.supplier_id && draft.supplier_id)
form.setFieldValue("supplier_id", draft.supplier_id);
if (!currentValues.comment && draft.comment)
form.setFieldValue("comment", draft.comment);
if (!currentValues.date_incoming)
form.setFieldValue(
"date_incoming",
draft.date_incoming ? dayjs(draft.date_incoming) : dayjs()
);
}
}, [draft, form]);
// --- ХЕЛПЕРЫ ---
const totalSum = useMemo(() => {
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
}, [draft?.items]);
const invalidItemsCount = useMemo(() => {
return draft?.items.filter(i => !i.product_id).length || 0;
return (
draft?.items.reduce(
(acc, item) => acc + Number(item.quantity) * Number(item.price),
0
) || 0
);
}, [draft?.items]);
const handleItemUpdate = (itemId: string, changes: UpdateDraftItemRequest) => {
const invalidItemsCount = useMemo(() => {
return draft?.items.filter((i) => !i.product_id).length || 0;
}, [draft?.items]);
const handleItemUpdate = (
itemId: string,
changes: UpdateDraftItemRequest
) => {
updateItemMutation.mutate({ itemId, payload: changes });
};
const handleCommit = async () => {
try {
// Валидируем форму (включая нового поставщика)
const values = await form.validateFields();
if (invalidItemsCount > 0) {
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
message.warning(
`Осталось ${invalidItemsCount} нераспознанных товаров!`
);
return;
}
commitMutation.mutate({
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
date_incoming: values.date_incoming.format("YYYY-MM-DD"),
store_id: values.store_id,
supplier_id: values.supplier_id,
comment: values.comment || '',
comment: values.comment || "",
});
} catch {
message.error('Заполните обязательные поля (Склад, Поставщик)');
message.error("Заполните обязательные поля (Склад, Поставщик)");
}
};
const isCanceled = draft?.status === 'CANCELED';
const isCanceled = draft?.status === "CANCELED";
const handleDelete = () => {
confirm({
title: isCanceled ? 'Удалить окончательно?' : 'Отменить черновик?',
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
content: isCanceled
? 'Черновик пропадет из списка навсегда.'
title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?",
icon: <ExclamationCircleFilled style={{ color: "red" }} />,
content: isCanceled
? "Черновик пропадет из списка навсегда."
: 'Черновик получит статус "Отменен", но останется в списке.',
okText: isCanceled ? 'Удалить навсегда' : 'Отменить',
okType: 'danger',
cancelText: 'Назад',
okText: isCanceled ? "Удалить навсегда" : "Отменить",
okType: "danger",
cancelText: "Назад",
onOk() {
deleteDraftMutation.mutate();
},
@@ -160,10 +227,17 @@ export const InvoiceDraftPage: React.FC = () => {
};
// --- RENDER ---
const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0));
const showSpinner =
draftQuery.isLoading ||
(draft?.status === "PROCESSING" &&
(!draft?.items || draft.items.length === 0));
if (showSpinner) {
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (draftQuery.isError || !draft) {
@@ -173,118 +247,235 @@ export const InvoiceDraftPage: React.FC = () => {
return (
<div style={{ paddingBottom: 60 }}>
{/* Header */}
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 18, fontWeight: 'bold', whiteSpace: 'nowrap' }}>
{draft.document_number ? `${draft.document_number}` : 'Черновик'}
</span>
{draft.status === 'PROCESSING' && <Spin size="small" />}
{isCanceled && <Tag color="red" style={{ margin: 0 }}>ОТМЕНЕН</Tag>}
<div
style={{
marginBottom: 12,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/invoices")}
size="small"
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
}}
>
<span
style={{ fontSize: 18, fontWeight: "bold", whiteSpace: "nowrap" }}
>
{draft.document_number ? `${draft.document_number}` : "Черновик"}
</span>
{draft.status === "PROCESSING" && <Spin size="small" />}
{isCanceled && (
<Tag color="red" style={{ margin: 0 }}>
ОТМЕНЕН
</Tag>
)}
</div>
</div>
<Button
danger={isCanceled}
type={isCanceled ? 'primary' : 'default'}
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
<Button
danger={isCanceled}
type={isCanceled ? "primary" : "default"}
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
size="small"
>
{isCanceled ? 'Удалить' : 'Отмена'}
{isCanceled ? "Удалить" : "Отмена"}
</Button>
</div>
{/* Form: Склады и Поставщики */}
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
<Row gutter={10}>
<Col span={12}>
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" size="middle" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]} style={{ marginBottom: 8 }}>
<Select
placeholder="Куда?"
loading={dictQuery.isLoading}
options={stores.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
</Col>
</Row>
{/* Поле Поставщика (Обязательное) */}
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]} style={{ marginBottom: 8 }}>
<Select
placeholder="От кого?"
loading={dictQuery.isLoading}
options={suppliers.map(s => ({ label: s.name, value: s.id }))}
size="middle"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
<TextArea rows={1} placeholder="Комментарий..." style={{ fontSize: 13 }} />
</Form.Item>
<div
style={{
background: "#fff",
padding: 12,
borderRadius: 8,
marginBottom: 12,
opacity: isCanceled ? 0.6 : 1,
}}
>
<Form
form={form}
layout="vertical"
initialValues={{ date_incoming: dayjs() }}
>
<Row gutter={10}>
<Col span={12}>
<Form.Item
label="Дата"
name="date_incoming"
rules={[{ required: true }]}
style={{ marginBottom: 8 }}
>
<DatePicker
style={{ width: "100%" }}
format="DD.MM.YYYY"
size="middle"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Склад"
name="store_id"
rules={[{ required: true, message: "Выберите склад" }]}
style={{ marginBottom: 8 }}
>
<Select
placeholder="Куда?"
loading={dictQuery.isLoading}
options={stores.map((s) => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
</Col>
</Row>
<Form.Item
label="Поставщик"
name="supplier_id"
rules={[{ required: true, message: "Выберите поставщика" }]}
style={{ marginBottom: 8 }}
>
<Select
placeholder="От кого?"
loading={dictQuery.isLoading}
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
size="middle"
showSearch
filterOption={(input, option) =>
(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item
label="Комментарий"
name="comment"
style={{ marginBottom: 0 }}
>
<TextArea
rows={1}
placeholder="Комментарий..."
style={{ fontSize: 13 }}
/>
</Form.Item>
</Form>
</div>
{/* Items Header */}
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px' }}>
<Text strong>Позиции ({draft.items.length})</Text>
{invalidItemsCount > 0 && <Text type="danger" style={{ fontSize: 12 }}>{invalidItemsCount} нераспознано</Text>}
<div
style={{
marginBottom: 8,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0 4px",
}}
>
<Text strong>Позиции ({draft.items.length})</Text>
{invalidItemsCount > 0 && (
<Text type="danger" style={{ fontSize: 12 }}>
{invalidItemsCount} нераспознано
</Text>
)}
</div>
{/* Items List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{draft.items.map(item => (
<DraftItemRow
key={item.id}
item={item}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{draft.items.map((item) => (
<DraftItemRow
key={item.id}
item={item}
onUpdate={handleItemUpdate}
// Передаем обработчик удаления
onDelete={(itemId) => deleteItemMutation.mutate(itemId)}
isUpdating={updatingItems.has(item.id)}
recommendations={recommendationsQuery.data || []}
/>
))}
</div>
{/* Кнопка добавления позиции */}
<Button
type="dashed"
block
icon={<PlusOutlined />}
style={{ marginTop: 12, marginBottom: 80, height: 48 }} // Увеличенный margin bottom для Affix
onClick={() => addItemMutation.mutate()}
loading={addItemMutation.isPending}
disabled={isCanceled}
>
Добавить товар
</Button>
{/* Footer Actions */}
<Affix offsetBottom={60}>
<div style={{
background: '#fff',
padding: '8px 16px',
borderTop: '1px solid #eee',
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderRadius: '8px 8px 0 0'
}}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff', lineHeight: 1.2 }}>
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 })}
</span>
</div>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 40, padding: '0 24px' }}
>
{isCanceled ? 'Восстановить' : 'Отправить'}
</Button>
<div
style={{
background: "#fff",
padding: "8px 16px",
borderTop: "1px solid #eee",
boxShadow: "0 -2px 10px rgba(0,0,0,0.05)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderRadius: "8px 8px 0 0",
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 11, color: "#888", lineHeight: 1 }}>
Итого:
</span>
<span
style={{
fontSize: 18,
fontWeight: "bold",
color: "#1890ff",
lineHeight: 1.2,
}}
>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
})}
</span>
</div>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 40, padding: "0 24px" }}
>
{isCanceled ? "Восстановить" : "Отправить"}
</Button>
</div>
</Affix>
</div>
);
};
};