mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил редактируемую сумму и пересчет треугольником
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Card,
|
||||
Flex,
|
||||
@@ -37,6 +37,8 @@ interface Props {
|
||||
recommendations?: Recommendation[];
|
||||
}
|
||||
|
||||
type FieldType = "quantity" | "price" | "sum";
|
||||
|
||||
export const DraftItemRow: React.FC<Props> = ({
|
||||
item,
|
||||
onUpdate,
|
||||
@@ -46,32 +48,129 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// State Input
|
||||
const [localQuantity, setLocalQuantity] = useState<string | null>(
|
||||
item.quantity?.toString() ?? null
|
||||
);
|
||||
const [localPrice, setLocalPrice] = useState<string | null>(
|
||||
item.price?.toString() ?? null
|
||||
);
|
||||
// --- Локальное состояние значений (строки для удобства ввода) ---
|
||||
const [localQty, setLocalQty] = useState<number | null>(item.quantity);
|
||||
const [localPrice, setLocalPrice] = useState<number | null>(item.price);
|
||||
const [localSum, setLocalSum] = useState<number | null>(item.sum);
|
||||
|
||||
// Sync Effect
|
||||
// --- История редактирования (Stack) ---
|
||||
// Храним 2 последних отредактированных поля.
|
||||
// Инициализируем из пропсов или дефолтно ['quantity', 'price'], чтобы пересчитывалась сумма.
|
||||
const editStack = useRef<FieldType[]>([
|
||||
(item.last_edited_field_1 as FieldType) || "quantity",
|
||||
(item.last_edited_field_2 as FieldType) || "price",
|
||||
]);
|
||||
|
||||
// Храним ссылку на предыдущую версию item, чтобы сравнивать изменения
|
||||
|
||||
// --- Синхронизация с сервером ---
|
||||
useEffect(() => {
|
||||
const serverQty = item.quantity;
|
||||
const currentLocal = parseFloat(localQuantity?.replace(",", ".") || "0");
|
||||
if (Math.abs(serverQty - currentLocal) > 0.001)
|
||||
setLocalQuantity(serverQty.toString().replace(".", ","));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [item.quantity]);
|
||||
// Если мы ждем ответа от сервера, не сбиваем локальный ввод
|
||||
if (isUpdating) return;
|
||||
|
||||
useEffect(() => {
|
||||
const serverPrice = item.price;
|
||||
const currentLocal = parseFloat(localPrice?.replace(",", ".") || "0");
|
||||
if (Math.abs(serverPrice - currentLocal) > 0.001)
|
||||
setLocalPrice(serverPrice.toString().replace(".", ","));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [item.price]);
|
||||
// Обновляем локальные стейты только когда меняются конкретные поля в item
|
||||
setLocalQty(item.quantity);
|
||||
setLocalPrice(item.price);
|
||||
setLocalSum(item.sum);
|
||||
|
||||
// Product Logic
|
||||
// Обновляем стек редактирования
|
||||
if (item.last_edited_field_1 && item.last_edited_field_2) {
|
||||
editStack.current = [
|
||||
item.last_edited_field_1 as FieldType,
|
||||
item.last_edited_field_2 as FieldType,
|
||||
];
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
// Зависим ТОЛЬКО от примитивов. Если объект item изменится, но цифры те же - эффект не сработает.
|
||||
item.quantity,
|
||||
item.price,
|
||||
item.sum,
|
||||
item.last_edited_field_1,
|
||||
item.last_edited_field_2,
|
||||
isUpdating,
|
||||
]);
|
||||
|
||||
// --- Логика пересчета (Треугольник) ---
|
||||
const recalculateLocally = (changedField: FieldType, newVal: number) => {
|
||||
// 1. Обновляем стек истории
|
||||
// Удаляем поле, если оно уже было в стеке, и добавляем в начало (LIFO для важности)
|
||||
const currentStack = editStack.current.filter((f) => f !== changedField);
|
||||
currentStack.unshift(changedField);
|
||||
// Оставляем только 2 последних
|
||||
if (currentStack.length > 2) currentStack.pop();
|
||||
editStack.current = currentStack;
|
||||
|
||||
// 2. Определяем, какое поле нужно пересчитать (то, которого НЕТ в стеке)
|
||||
const allFields: FieldType[] = ["quantity", "price", "sum"];
|
||||
const fieldToRecalc = allFields.find((f) => !currentStack.includes(f));
|
||||
|
||||
// 3. Выполняем расчет
|
||||
let q = changedField === "quantity" ? newVal : localQty || 0;
|
||||
let p = changedField === "price" ? newVal : localPrice || 0;
|
||||
let s = changedField === "sum" ? newVal : localSum || 0;
|
||||
|
||||
switch (fieldToRecalc) {
|
||||
case "sum":
|
||||
s = q * p;
|
||||
setLocalSum(s);
|
||||
break;
|
||||
case "quantity":
|
||||
if (p !== 0) {
|
||||
q = s / p;
|
||||
setLocalQty(q);
|
||||
} else {
|
||||
setLocalQty(0);
|
||||
}
|
||||
break;
|
||||
case "price":
|
||||
if (q !== 0) {
|
||||
p = s / q;
|
||||
setLocalPrice(p);
|
||||
} else {
|
||||
setLocalPrice(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Обработчики ввода ---
|
||||
|
||||
const handleValueChange = (field: FieldType, val: number | null) => {
|
||||
// Обновляем само поле
|
||||
if (field === "quantity") setLocalQty(val);
|
||||
if (field === "price") setLocalPrice(val);
|
||||
if (field === "sum") setLocalSum(val);
|
||||
|
||||
if (val !== null) {
|
||||
recalculateLocally(field, val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (field: FieldType) => {
|
||||
// Отправляем на сервер только измененное поле + маркер edited_field.
|
||||
// Сервер сам проведет пересчет и вернет точные данные.
|
||||
// Важно: отправляем текущее локальное значение.
|
||||
|
||||
let val: number | null = null;
|
||||
if (field === "quantity") val = localQty;
|
||||
if (field === "price") val = localPrice;
|
||||
if (field === "sum") val = localSum;
|
||||
|
||||
if (val === null) return;
|
||||
|
||||
// Сравниваем с текущим item, чтобы не спамить запросами, если число не поменялось
|
||||
const serverVal = item[field];
|
||||
// Используем эпсилон для сравнения float
|
||||
if (Math.abs(val - serverVal) > 0.0001) {
|
||||
onUpdate(item.id, {
|
||||
[field]: val,
|
||||
edited_field: field,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// --- Product & Container Logic (как было) ---
|
||||
const [searchedProduct, setSearchedProduct] =
|
||||
useState<ProductSearchResult | null>(null);
|
||||
const [addedContainers, setAddedContainers] = useState<
|
||||
@@ -148,53 +247,25 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
const parseToNum = (val: string | null | undefined): number => {
|
||||
if (!val) return 0;
|
||||
return parseFloat(val.replace(",", "."));
|
||||
};
|
||||
|
||||
const getUpdatePayload = (
|
||||
overrides: Partial<UpdateDraftItemRequest>
|
||||
): UpdateDraftItemRequest => {
|
||||
const currentQty =
|
||||
localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
|
||||
const currentPrice =
|
||||
localPrice !== null ? parseToNum(localPrice) : item.price;
|
||||
|
||||
return {
|
||||
product_id: item.product_id || undefined,
|
||||
container_id: item.container_id,
|
||||
quantity: currentQty ?? 1,
|
||||
price: currentPrice ?? 0,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
// --- Handlers ---
|
||||
const handleProductChange = (
|
||||
prodId: string,
|
||||
productObj?: ProductSearchResult
|
||||
) => {
|
||||
if (productObj) setSearchedProduct(productObj);
|
||||
onUpdate(
|
||||
item.id,
|
||||
getUpdatePayload({ product_id: prodId, container_id: null })
|
||||
);
|
||||
onUpdate(item.id, {
|
||||
product_id: prodId,
|
||||
container_id: null, // Сбрасываем фасовку
|
||||
// При смене товара логично оставить Qty и Sum, пересчитав Price?
|
||||
// Или оставить Qty и Price? Обычно цена меняется.
|
||||
// Пока не трогаем числа, пусть остаются как были.
|
||||
});
|
||||
};
|
||||
|
||||
const handleContainerChange = (val: string) => {
|
||||
const newVal = val === "BASE_UNIT" ? null : val;
|
||||
onUpdate(item.id, getUpdatePayload({ container_id: newVal }));
|
||||
};
|
||||
|
||||
const handleBlur = (field: "quantity" | "price") => {
|
||||
const localVal = field === "quantity" ? localQuantity : localPrice;
|
||||
if (localVal === null) return;
|
||||
const numVal = parseToNum(localVal);
|
||||
if (numVal !== item[field]) {
|
||||
onUpdate(item.id, getUpdatePayload({ [field]: numVal }));
|
||||
}
|
||||
// "" пустая строка приходит при выборе "Базовая" (мы так настроим value)
|
||||
const newVal = val === "BASE_UNIT" ? "" : val;
|
||||
onUpdate(item.id, { container_id: newVal });
|
||||
};
|
||||
|
||||
const handleContainerCreated = (newContainer: ProductContainer) => {
|
||||
@@ -205,7 +276,7 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
[activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer],
|
||||
}));
|
||||
}
|
||||
onUpdate(item.id, getUpdatePayload({ container_id: newContainer.id }));
|
||||
onUpdate(item.id, { container_id: newContainer.id });
|
||||
};
|
||||
|
||||
const cardBorderColor = !item.product_id
|
||||
@@ -213,7 +284,6 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
: item.is_matched
|
||||
? "#b7eb8f"
|
||||
: "#d9d9d9";
|
||||
const uiSum = parseToNum(localQuantity) * parseToNum(localPrice);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -229,7 +299,6 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
<Flex vertical gap={10}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<div style={{ flex: 1 }}>
|
||||
{/* Показываем raw_name только если это OCR строка. Если создана вручную и пустая - плейсхолдер */}
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, lineHeight: 1.2, display: "block" }}
|
||||
@@ -255,7 +324,6 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
>
|
||||
{isUpdating && <SyncOutlined spin style={{ color: "#1890ff" }} />}
|
||||
|
||||
{/* Warning Icon */}
|
||||
{activeWarning && (
|
||||
<WarningFilled
|
||||
style={{ color: "#faad14", fontSize: 16, cursor: "pointer" }}
|
||||
@@ -269,7 +337,6 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* Кнопка удаления */}
|
||||
<Popconfirm
|
||||
title="Удалить строку?"
|
||||
onConfirm={() => onDelete(item.id)}
|
||||
@@ -332,38 +399,44 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
borderBottomRightRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<InputNumber<string>
|
||||
style={{ width: 60 }}
|
||||
controls={false}
|
||||
placeholder="Кол"
|
||||
stringMode
|
||||
decimalSeparator=","
|
||||
value={localQuantity || ""}
|
||||
onChange={(val) => setLocalQuantity(val)}
|
||||
onBlur={() => handleBlur("quantity")}
|
||||
/>
|
||||
<Text type="secondary">x</Text>
|
||||
<InputNumber<string>
|
||||
<div
|
||||
style={{ display: "flex", gap: 8, alignItems: "center", flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
controls={false}
|
||||
placeholder="Кол"
|
||||
min={0}
|
||||
value={localQty}
|
||||
onChange={(val) => handleValueChange("quantity", val)}
|
||||
onBlur={() => handleBlur("quantity")}
|
||||
precision={3}
|
||||
/>
|
||||
<Text type="secondary">x</Text>
|
||||
<InputNumber
|
||||
style={{ width: 80 }}
|
||||
controls={false}
|
||||
placeholder="Цена"
|
||||
stringMode
|
||||
decimalSeparator=","
|
||||
value={localPrice || ""}
|
||||
onChange={(val) => setLocalPrice(val)}
|
||||
min={0}
|
||||
value={localPrice}
|
||||
onChange={(val) => handleValueChange("price", val)}
|
||||
onBlur={() => handleBlur("price")}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{uiSum.toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<Text type="secondary">=</Text>
|
||||
<InputNumber
|
||||
style={{ width: 90, fontWeight: "bold" }}
|
||||
controls={false}
|
||||
placeholder="Сумма"
|
||||
min={0}
|
||||
value={localSum}
|
||||
onChange={(val) => handleValueChange("sum", val)}
|
||||
onBlur={() => handleBlur("sum")}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
@@ -185,8 +185,12 @@ export interface DraftItem {
|
||||
|
||||
// Мета-данные
|
||||
is_matched: boolean;
|
||||
product?: CatalogItem; // Развернутый объект для UI
|
||||
container?: ProductContainer; // Развернутый объект для UI
|
||||
product?: CatalogItem;
|
||||
container?: ProductContainer;
|
||||
|
||||
// Поля для синхронизации состояния (опционально, если бэкенд их отдает)
|
||||
last_edited_field_1?: string;
|
||||
last_edited_field_2?: string;
|
||||
}
|
||||
|
||||
// --- Список Черновиков (Summary) ---
|
||||
@@ -218,9 +222,11 @@ export interface DraftInvoice {
|
||||
// DTO для обновления строки
|
||||
export interface UpdateDraftItemRequest {
|
||||
product_id?: UUID;
|
||||
container_id?: UUID | null; // null если сбросили фасовку
|
||||
container_id?: UUID | null;
|
||||
quantity?: number;
|
||||
price?: number;
|
||||
sum?: number;
|
||||
edited_field?: string; // ('quantity' | 'price' | 'sum')
|
||||
}
|
||||
|
||||
// DTO для коммита
|
||||
|
||||
Reference in New Issue
Block a user