Полноценно редактируются черновики

Добавляются фасовки как в черновике, так и в обучении
Исправил внешний вид
This commit is contained in:
2025-12-17 22:00:21 +03:00
parent e2df2350f7
commit c8aab42e8e
24 changed files with 1313 additions and 433 deletions

View File

@@ -3,16 +3,17 @@ 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
Typography, message, Row, Col, Affix, Modal, Tag
} from 'antd';
import { ArrowLeftOutlined, CheckOutlined } from '@ant-design/icons';
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';
const { Title, Text } = Typography;
const { Text } = Typography;
const { TextArea } = Input;
const { confirm } = Modal;
export const InvoiceDraftPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -20,27 +21,26 @@ export const InvoiceDraftPage: React.FC = () => {
const queryClient = useQueryClient();
const [form] = Form.useForm();
// Локальное состояние для отслеживания какие строки сейчас обновляются
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// 1. Загрузка справочников
// --- ЗАПРОСЫ ---
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
const catalogQuery = useQuery({ queryKey: ['catalog'], queryFn: api.getCatalogItems, staleTime: 1000 * 60 * 10 });
// 2. Загрузка черновика
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
const draftQuery = useQuery({
queryKey: ['draft', id],
queryFn: () => api.getDraft(id!),
enabled: !!id,
refetchInterval: (query) => {
const status = query.state.data?.status;
// Продолжаем опрашивать, пока статус PROCESSING, чтобы подтянуть новые товары, если они долетают
return status === 'PROCESSING' ? 3000 : false;
},
});
// 3. Мутация обновления строки
const draft = draftQuery.data;
// ... (МУТАЦИИ оставляем без изменений) ...
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload),
@@ -62,7 +62,6 @@ export const InvoiceDraftPage: React.FC = () => {
}
});
// 4. Мутация коммита
const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
onSuccess: (data) => {
@@ -74,33 +73,35 @@ export const InvoiceDraftPage: React.FC = () => {
}
});
const draft = draftQuery.data;
const deleteDraftMutation = useMutation({
mutationFn: () => api.deleteDraft(id!),
onSuccess: () => {
if (draft?.status === 'CANCELED') {
message.info('Черновик удален окончательно');
navigate('/invoices');
} else {
message.warning('Черновик отменен');
queryClient.invalidateQueries({ queryKey: ['draft', id] });
}
},
onError: () => {
message.error('Ошибка при удалении');
}
});
// Инициализация формы.
// Убрали проверку status !== 'PROCESSING', чтобы форма заполнялась сразу, как пришли данные.
// --- ЭФФЕКТЫ ---
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]);
// Вычисляемые данные для UI
// --- ХЕЛПЕРЫ ---
const totalSum = useMemo(() => {
// Добавил Number(), так как API возвращает строки ("250"), а нам нужны числа
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
}, [draft?.items]);
@@ -116,10 +117,9 @@ export const InvoiceDraftPage: React.FC = () => {
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'),
store_id: values.store_id,
@@ -127,115 +127,147 @@ export const InvoiceDraftPage: React.FC = () => {
comment: values.comment || '',
});
} catch {
message.error('Заполните обязательные поля в шапке (Склад, Поставщик)');
message.error('Заполните обязательные поля');
}
};
// --- Рендер ---
const isCanceled = draft?.status === 'CANCELED';
// Показываем спиннер ТОЛЬКО если данных нет вообще, или статус PROCESSING и список пуст.
// Если статус PROCESSING, но items уже пришли — показываем интерфейс.
const handleDelete = () => {
confirm({
title: isCanceled ? 'Удалить окончательно?' : 'Отменить черновик?',
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
content: isCanceled
? 'Черновик пропадет из списка навсегда.'
: 'Черновик получит статус "Отменен", но останется в списке.',
okText: isCanceled ? 'Удалить навсегда' : 'Отменить',
okType: 'danger',
cancelText: 'Назад',
onOk() {
deleteDraftMutation.mutate();
},
});
};
// --- RENDER ---
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" tip="Обработка чека..." />
<div style={{ marginTop: 16, color: '#888' }}>ИИ читает ваш чек, подождите...</div>
</div>
);
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
}
if (draftQuery.isError || !draft) {
return <Alert type="error" message="Ошибка загрузки черновика" description="Попробуйте обновить страницу" />;
return <Alert type="error" message="Ошибка загрузки черновика" />;
}
return (
<div style={{ paddingBottom: 80 }}>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} />
<Title level={4} style={{ margin: 0 }}>
Черновик {draft.document_number ? `${draft.document_number}` : ''}
{draft.status === 'PROCESSING' && <Spin size="small" style={{ marginLeft: 8 }} />}
</Title>
<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>
</div>
<Button
danger={isCanceled}
type={isCanceled ? 'primary' : 'default'}
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
size="small"
>
{isCanceled ? 'Удалить' : 'Отмена'}
</Button>
</div>
<div style={{ background: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 }}>
{/* Form: Compact margins */}
<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={12}>
<Row gutter={10}>
<Col span={12}>
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
<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: 'Выберите склад' }]}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<Select
placeholder="Куда?"
loading={storesQuery.isLoading}
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]}>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<Select
placeholder="От кого?"
loading={suppliersQuery.isLoading}
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
<Form.Item label="Комментарий" name="comment">
<TextArea rows={1} placeholder="Прим: Довоз за пятницу" />
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
<TextArea rows={1} placeholder="Комментарий..." style={{ fontSize: 13 }} />
</Form.Item>
</Form>
</div>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={5} style={{ margin: 0 }}>Позиции ({draft.items.length})</Title>
{invalidItemsCount > 0 && <Text type="danger">{invalidItemsCount} нераспознано</Text>}
{/* 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>
{/* Items List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{draft.items.map(item => (
<DraftItemRow
key={item.id}
item={item}
catalog={catalogQuery.data || []}
onUpdate={handleItemUpdate}
isUpdating={updatingItems.has(item.id)}
recommendations={recommendationsQuery.data || []}
/>
))}
</div>
<Affix offsetBottom={0}>
{/* Footer Actions */}
<Affix offsetBottom={60} /* Высота нижнего меню */>
<div style={{
background: '#fff',
padding: '12px 16px',
padding: '8px 16px',
borderTop: '1px solid #eee',
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderRadius: '8px 8px 0 0' // Скругление сверху
}}>
<div>
<div style={{ fontSize: 12, color: '#888' }}>Итого:</div>
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</div>
<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"
size="large"
icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0}
disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 40, padding: '0 24px' }}
>
Отправить в iiko
{isCanceled ? 'Восстановить' : 'Отправить'}
</Button>
</div>
</Affix>