0202-финиш перед десктопом

пересчет поправил
редактирование с перепроведением
галка автопроведения работает
рекомендации починил
This commit is contained in:
2026-02-02 13:53:38 +03:00
parent 10882f55c8
commit 88620f3fb6
37 changed files with 1905 additions and 11162 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import {
Navigate,
useLocation,
} from "react-router-dom";
import { Result, Button } from "antd";
import { Result, Button, Spin } from "antd";
import { Providers } from "./components/layout/Providers";
import { AppLayout } from "./components/layout/AppLayout";
import { OcrLearning } from "./pages/OcrLearning";
@@ -18,10 +18,13 @@ import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api";
import MaintenancePage from "./pages/MaintenancePage";
import { usePlatform } from "./hooks/usePlatform";
import { useAuthStore } from "./stores/authStore";
import { useServerStore } from "./stores/serverStore";
import { DesktopAuthScreen } from "./pages/desktop/auth/DesktopAuthScreen";
import { MobileBrowserStub } from "./pages/desktop/auth/MobileBrowserStub";
import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout";
import { InvoicesDashboard } from "./pages/desktop/dashboard/InvoicesDashboard";
import OperatorRestricted from "./components/OperatorRestricted";
import type { UserRole } from "./services/types";
// Компонент-заглушка для внешних браузеров
const NotInTelegramScreen = () => (
@@ -64,9 +67,12 @@ const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => {
const AppContent = () => {
const [isUnauthorized, setIsUnauthorized] = useState(false);
const [isMaintenance, setIsMaintenance] = useState(false);
const [userRole, setUserRole] = useState<UserRole | null>(null);
const [isLoadingRole, setIsLoadingRole] = useState(true);
const tg = window.Telegram?.WebApp;
const platform = usePlatform();
const location = useLocation(); // Теперь это безопасно, т.к. мы внутри BrowserRouter
const location = useLocation();
const { activeServer, fetchServers } = useServerStore();
// Проверяем, есть ли данные от Telegram
const isInTelegram = !!tg?.initData;
@@ -74,6 +80,28 @@ const AppContent = () => {
// Проверяем, находимся ли мы на десктопном роуте
const isDesktopRoute = location.pathname.startsWith("/web");
// Загружаем роль пользователя и список серверов при монтировании
useEffect(() => {
const loadUserData = async () => {
try {
// Загружаем список серверов (там есть информация о роли)
await fetchServers();
// Если есть активный сервер, получаем роль из него
const currentServer = useServerStore.getState().activeServer;
if (currentServer) {
setUserRole(currentServer.role);
}
} catch (error) {
console.error('Ошибка при загрузке данных пользователя:', error);
} finally {
setIsLoadingRole(false);
}
};
loadUserData();
}, [fetchServers]);
useEffect(() => {
const handleUnauthorized = () => setIsUnauthorized(true);
const handleMaintenance = () => setIsMaintenance(true);
@@ -90,6 +118,23 @@ const AppContent = () => {
};
}, [tg]);
// Показываем лоадер пока загружается роль
if (isLoadingRole) {
return (
<div
style={{
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
}}
>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
// Если открыто не в Telegram и это не десктопный роут — блокируем всё
if (!isInTelegram && !isDesktopRoute) {
return <NotInTelegramScreen />;
@@ -100,6 +145,11 @@ const AppContent = () => {
return <MobileBrowserStub />;
}
// Заглушка для операторов (только для мобильной версии в Telegram)
if (userRole === 'OPERATOR' && isInTelegram) {
return <OperatorRestricted serverName={activeServer?.name} />;
}
// Если бэкенд вернул 401
if (isUnauthorized) {
return (

View File

@@ -0,0 +1,90 @@
import React from "react";
import { Result, Button, Spin } from "antd";
import { StopOutlined, CameraOutlined } from "@ant-design/icons";
interface Props {
serverName?: string;
loading?: boolean;
}
/**
* Компонент заглушки для операторов.
* Отображается вместо основного интерфейса приложения для пользователей с ролью OPERATOR.
* Операторы могут загружать фото накладных только через Telegram-бота.
*/
const OperatorRestricted: React.FC<Props> = ({
serverName,
loading = false,
}) => {
// Показываем лоадер пока идёт загрузка настроек
if (loading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
padding: 24,
background: "#f5f5f5",
}}
>
<Result
icon={<StopOutlined style={{ color: "#faad14" }} />}
title="Доступ ограничен"
subTitle={
<div style={{ textAlign: "center" }}>
<p>
Вы вошли как <strong>Оператор</strong>
{serverName && (
<>
{" "}
на сервере <strong>{serverName}</strong>
</>
)}
.
</p>
<p>
Операторы могут загружать фото накладных только через
Telegram-бота.
</p>
<p style={{ marginTop: 16, color: "#666" }}>
Для доступа к полному интерфейсу обратитесь к администратору
сервера.
</p>
</div>
}
extra={
<Button
type="primary"
icon={<CameraOutlined />}
size="large"
onClick={() => {
// Открываем Telegram-бота
window.location.href = "https://t.me/RmserBot";
}}
>
Открыть бота в Telegram
</Button>
}
/>
</div>
);
};
export default OperatorRestricted;

View File

@@ -6,6 +6,7 @@ import {
UndoOutlined,
} from "@ant-design/icons";
import * as XLSX from "xlsx";
import { apiClient } from "../../services/api";
interface ExcelPreviewModalProps {
visible: boolean;
@@ -38,18 +39,23 @@ const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
return;
}
console.log("ExcelPreviewModal: Start loading", fileUrl);
try {
// Загрузка файла как arrayBuffer
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Ошибка загрузки файла: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
// Загрузка файла через apiClient с авторизацией
const response = await apiClient.get(fileUrl, {
responseType: "arraybuffer",
});
console.log(
"ExcelPreviewModal: Got response",
response.status,
response.data.byteLength
);
const arrayBuffer = response.data;
// Чтение Excel файла
const workbook = XLSX.read(arrayBuffer, { type: "array" });
console.log("ExcelPreviewModal: Workbook parsed", workbook.SheetNames);
// Получение первого листа
const firstSheetName = workbook.SheetNames[0];
@@ -61,11 +67,26 @@ const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
}) as (string | number | boolean | null | undefined)[][];
setData(jsonData);
console.log("ExcelPreviewModal: Data set, rows:", jsonData.length);
// Сброс масштаба при загрузке нового файла
setScale(1);
} catch (error) {
console.error("Ошибка при загрузке Excel файла:", error);
message.error("Не удалось загрузить Excel файл");
console.error("ExcelPreviewModal Error:", error);
// Обработка ошибок авторизации (401) обрабатывается в интерсепторе apiClient
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as { response?: { status?: number } };
if (axiosError.response?.status === 401) {
message.error(
"Ошибка авторизации. Необходима повторная авторизация."
);
} else {
message.error("Не удалось загрузить Excel файл");
}
} else {
message.error("Не удалось загрузить Excel файл");
}
setData([]);
}
};
@@ -94,10 +115,18 @@ const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
setScale(1);
};
/**
* Обработчик закрытия модалки
*/
const handleCancel = () => {
setData([]);
onCancel();
};
return (
<Modal
visible={visible}
onCancel={onCancel}
open={visible}
onCancel={handleCancel}
width="90%"
footer={null}
title="Предпросмотр Excel"

View File

@@ -15,9 +15,10 @@ import {
Modal,
Tag,
Image,
Checkbox,
} from "antd";
import {
CheckOutlined,
// CheckOutlined,
DeleteOutlined,
ExclamationCircleFilled,
StopOutlined,
@@ -82,6 +83,9 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
// Состояние для просмотра Excel файла
const [excelPreviewVisible, setExcelPreviewVisible] = useState(false);
// Состояние для чекбокса "Проведено"
const [isProcessed, setIsProcessed] = useState(true); // По умолчанию true для MVP
// --- ЗАПРОСЫ ---
const dictQuery = useQuery({
@@ -282,6 +286,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
supplier_id: values.supplier_id,
comment: values.comment || "",
incoming_document_number: values.incoming_document_number || "",
is_processed: isProcessed,
});
} catch {
message.error("Заполните обязательные поля (Склад, Поставщик)");
@@ -289,6 +294,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
};
const isCanceled = draft?.status === "CANCELED";
const isCompleted = draft?.status === "COMPLETED";
const handleBack = () => {
if (isDirty) {
@@ -512,7 +518,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
<div
style={{
background: "#fff",
padding: 8,
padding: 6,
borderRadius: 8,
marginBottom: 12,
opacity: isCanceled ? 0.6 : 1,
@@ -523,17 +529,18 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
layout="vertical"
onValuesChange={() => markAsDirty()}
>
<Row gutter={[8, 8]}>
<Row gutter={[4, 4]}>
<Col span={12}>
<Form.Item
name="date_incoming"
label="Дата"
rules={[{ required: true }]}
style={{ marginBottom: 0 }}
>
<DatePicker
style={{ width: "100%" }}
format="DD.MM.YYYY"
placeholder="Дата..."
placeholder=""
size="small"
/>
</Form.Item>
@@ -541,13 +548,14 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
<Col span={12}>
<Form.Item
name="incoming_document_number"
label="№ входящего"
style={{ marginBottom: 0 }}
>
<Input placeholder="№ входящего..." size="small" />
<Input placeholder="" size="small" />
</Form.Item>
</Col>
</Row>
<Row gutter={[8, 8]}>
<Row gutter={[4, 4]}>
<Col span={24}>
<Form.Item
name="store_id"
@@ -705,14 +713,39 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
<Button
type="primary"
icon={<CheckOutlined />}
// icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 36, padding: "0 20px" }}
style={{
position: "relative",
paddingLeft: 40,
height: 36,
}}
size="small"
>
{isCanceled ? "Восстановить" : "Отправить"}
<Checkbox
checked={isProcessed}
onChange={(e) => setIsProcessed(e.target.checked)}
onClick={(e) => e.stopPropagation()}
disabled={invalidItemsCount > 0 || isCanceled}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "auto",
}}
/>
<span style={{ marginLeft: 8 }}>
{isCanceled
? "Восстановить"
: isCompleted
? "Обновить в iiko"
: isProcessed
? "Провести и отправить"
: "Сохранить (без проведения)"}
</span>
</Button>
</div>
@@ -736,7 +769,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
<ExcelPreviewModal
visible={excelPreviewVisible}
onCancel={() => setExcelPreviewVisible(false)}
fileUrl={draft.photo_url ? getStaticUrl(draft.photo_url) : ""}
fileUrl={draft.photo_url || ""}
/>
</div>
);

View File

@@ -5,7 +5,7 @@ import {
FileImageOutlined,
FileExcelOutlined,
HistoryOutlined,
RestOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
import { api, getStaticUrl } from "../../services/api";
import type { DraftStatus } from "../../services/types";
@@ -140,7 +140,7 @@ export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
{onBack && (
<Button
type="text"
icon={<RestOutlined />}
icon={<ArrowLeftOutlined />}
onClick={onBack}
size="small"
style={{ flexShrink: 0 }}
@@ -256,7 +256,7 @@ export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
<ExcelPreviewModal
visible={excelPreviewVisible}
onCancel={() => setExcelPreviewVisible(false)}
fileUrl={invoice.photo_url ? getStaticUrl(invoice.photo_url) : ""}
fileUrl={invoice.photo_url || ""}
/>
</div>
);

View File

@@ -0,0 +1,76 @@
import React from "react";
import { Card, Button, Typography, Space, Tooltip } from "antd";
import { SyncOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/ru";
import type { UserRole } from "../../services/types";
// Настройка dayjs для русской локали и относительного времени
dayjs.extend(relativeTime);
dayjs.locale("ru");
const { Text } = Typography;
interface SyncBlockProps {
lastSyncAt: string | null;
userRole: UserRole;
onSync: () => void;
isLoading?: boolean;
}
export const SyncBlock: React.FC<SyncBlockProps> = ({
lastSyncAt,
userRole,
onSync,
isLoading = false,
}) => {
// Проверяем, есть ли права на синхронизацию
const canSync = userRole === "OWNER" || userRole === "ADMIN";
// Форматируем дату последней синхронизации
const formatLastSync = (dateStr: string | null): string => {
if (!dateStr) {
return "Никогда";
}
const date = dayjs(dateStr);
const formatted = date.format("DD.MM.YYYY HH:mm");
const relative = date.fromNow();
return `${formatted} (${relative})`;
};
return (
<Card size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<div>
<Text
strong
style={{ fontSize: 16, display: "block", marginBottom: 8 }}
>
Синхронизация данных
</Text>
<Text type="secondary">
Последняя синхронизация: {formatLastSync(lastSyncAt)}
</Text>
</div>
<Tooltip title={!canSync ? "Только для администраторов" : undefined}>
<Button
type="primary"
icon={<SyncOutlined spin={isLoading} />}
onClick={onSync}
loading={isLoading}
disabled={!canSync}
block
>
Синхронизировать
</Button>
</Tooltip>
<Text type="secondary" style={{ fontSize: 12 }}>
Загружает справочники, накладные и пересчитывает рекомендации
</Text>
</Space>
</Card>
);
};

View File

@@ -0,0 +1,63 @@
import { useEffect, useState, useCallback } from 'react';
import { api } from '../services/api';
import type { UserSettings, UserRole } from '../services/types';
interface UseUserRoleResult {
/** Роль текущего пользователя или null если не загружено */
role: UserRole | null;
/** Полные настройки пользователя */
settings: UserSettings | null;
/** Состояние загрузки */
loading: boolean;
/** Ошибка загрузки */
error: string | null;
/** Функция для повторной загрузки настроек */
refetch: () => Promise<void>;
/** Является ли пользователь оператором */
isOperator: boolean;
/** Является ли пользователь админом или владельцем */
isAdminOrOwner: boolean;
}
/**
* Хук для получения роли пользователя и настроек.
* Автоматически загружает настройки при монтировании компонента.
*/
export const useUserRole = (): UseUserRoleResult => {
const [settings, setSettings] = useState<UserSettings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getSettings();
setSettings(data);
} catch (err) {
console.error('Ошибка при загрузке настроек:', err);
setError(err instanceof Error ? err.message : 'Не удалось загрузить настройки');
} finally {
setLoading(false);
}
}, []);
// Загружаем настройки при монтировании
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const role = settings?.role ?? null;
const isOperator = role === 'OPERATOR';
const isAdminOrOwner = role === 'ADMIN' || role === 'OWNER';
return {
role,
settings,
loading,
error,
refetch: fetchSettings,
isOperator,
isAdminOrOwner,
};
};

View File

@@ -161,10 +161,21 @@ export const DraftsList: React.FC = () => {
};
const handleInvoiceClick = (item: UnifiedInvoice) => {
// Если это черновик - используем его ID
if (item.type === "DRAFT") {
navigate("/invoice/draft/" + item.id);
} else if (item.type === "SYNCED") {
navigate("/invoice/view/" + item.id);
return;
}
// Если это синхронизированная накладная
if (item.type === "SYNCED") {
// Если у нее есть ссылка на черновик (пришла с бэка) - открываем редактор черновика
if (item.draft_id) {
navigate("/invoice/draft/" + item.draft_id);
} else {
// Иначе просто просмотр
navigate("/invoice/view/" + item.id);
}
}
};
@@ -285,31 +296,33 @@ export const DraftsList: React.FC = () => {
<div
style={{
marginBottom: 12,
marginBottom: 8,
background: "#fff",
padding: 12,
padding: 8,
borderRadius: 8,
}}
>
<Flex align="center" gap={8}>
<Flex vertical gap={8}>
<Text style={{ fontSize: 13 }}>Период:</Text>
<DatePicker
value={startDate}
onChange={setStartDate}
format="DD.MM.YYYY"
size="small"
placeholder="Начало"
style={{ width: 110 }}
/>
<Text type="secondary"></Text>
<DatePicker
value={endDate}
onChange={setEndDate}
format="DD.MM.YYYY"
size="small"
placeholder="Конец"
style={{ width: 110 }}
/>
<Flex align="center" gap={8}>
<DatePicker
value={startDate}
onChange={setStartDate}
format="DD.MM.YYYY"
size="small"
placeholder="Начало"
style={{ width: 110 }}
/>
<Text type="secondary"></Text>
<DatePicker
value={endDate}
onChange={setEndDate}
format="DD.MM.YYYY"
size="small"
placeholder="Конец"
style={{ width: 110 }}
/>
</Flex>
</Flex>
</div>

View File

@@ -28,6 +28,7 @@ import { api } from "../services/api";
import type { UserSettings } from "../services/types";
import { TeamList } from "../components/settings/TeamList";
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
import { SyncBlock } from "../components/settings/SyncBlock";
const { Title, Text } = Typography;
@@ -83,6 +84,17 @@ export const SettingsPage: React.FC = () => {
},
});
const syncMutation = useMutation({
mutationFn: () => api.syncAll(true),
onSuccess: () => {
message.success("Синхронизация запущена в фоне");
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
onError: () => {
message.error("Ошибка запуска синхронизации");
},
});
// --- Эффекты ---
useEffect(() => {
@@ -96,7 +108,11 @@ export const SettingsPage: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
saveMutation.mutate(values);
console.log("Settings Form Values:", values);
saveMutation.mutate({
...values,
auto_conduct: !!values.auto_conduct,
});
} catch {
// Ошибки валидации
}
@@ -117,121 +133,126 @@ export const SettingsPage: React.FC = () => {
const showTeamSettings =
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
// Сохраняем JSX в переменную вместо создания вложенного компонента
const generalSettingsContent = (
<Form form={form} layout="vertical">
<Card size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="Склад по умолчанию"
name="default_store_id"
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
>
<Select
placeholder="Не выбрано"
allowClear
loading={dictQuery.isLoading}
options={dictQuery.data?.stores.map((s) => ({
label: s.name,
value: s.id,
}))}
/>
</Form.Item>
<Form.Item
label="Корневая группа товаров"
name="root_group_id"
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
>
<TreeSelect
showSearch
style={{ width: "100%" }}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите папку"
allowClear
treeDefaultExpandAll={false}
treeData={groupsQuery.data}
fieldNames={{
label: "title",
value: "value",
children: "children",
}}
treeNodeFilterProp="title"
suffixIcon={<FolderOpenOutlined />}
loading={groupsQuery.isLoading}
/>
</Form.Item>
<Form.Item
name="auto_conduct"
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<Text>Проводить накладные автоматически</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Если выключено, накладные в iiko будут создаваться как
"Непроведенные"
</Text>
</div>
<Switch />
</div>
</Form.Item>
</Card>
<Button
type="primary"
icon={<SaveOutlined />}
block
size="large"
onClick={handleSave}
loading={saveMutation.isPending}
>
Сохранить настройки
</Button>
{currentUserRole === "OWNER" && (
<Card
size="small"
style={{
marginTop: 24,
borderColor: "#ff4d4f",
borderWidth: 2,
}}
>
<Title level={5} style={{ color: "#ff4d4f", marginBottom: 16 }}>
Опасная зона
</Title>
<Popconfirm
title="Вы уверены?"
description="Это удалит ВСЕ черновики, которые еще не были отправлены в iiko. Это действие необратимо."
onConfirm={() => deleteAllDraftsMutation.mutate()}
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
>
<Button danger block loading={deleteAllDraftsMutation.isPending}>
Удалить все черновики
</Button>
</Popconfirm>
</Card>
)}
</Form>
);
const tabsItems = [
{
key: "general",
label: "Общие",
icon: <SettingOutlined />,
children: generalSettingsContent,
children: (
<Form form={form} layout="vertical">
<SyncBlock
lastSyncAt={settingsQuery.data?.last_sync_at || null}
userRole={currentUserRole}
onSync={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
/>
<Card size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="Склад по умолчанию"
name="default_store_id"
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
>
<Select
placeholder="Не выбрано"
allowClear
loading={dictQuery.isLoading}
options={dictQuery.data?.stores.map((s) => ({
label: s.name,
value: s.id,
}))}
/>
</Form.Item>
<Form.Item
label="Корневая группа товаров"
name="root_group_id"
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
>
<TreeSelect
showSearch
style={{ width: "100%" }}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите папку"
allowClear
treeDefaultExpandAll={false}
treeData={groupsQuery.data}
fieldNames={{
label: "title",
value: "value",
children: "children",
}}
treeNodeFilterProp="title"
suffixIcon={<FolderOpenOutlined />}
loading={groupsQuery.isLoading}
/>
</Form.Item>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<div>
<Text>Проводить накладные автоматически</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Если выключено, накладные в iiko будут создаваться как
"Непроведенные"
</Text>
</div>
<Form.Item name="auto_conduct" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</div>
</Card>
<Button
type="primary"
icon={<SaveOutlined />}
block
size="large"
onClick={handleSave}
loading={saveMutation.isPending}
>
Сохранить настройки
</Button>
{currentUserRole === "OWNER" && (
<Card
size="small"
style={{
marginTop: 24,
borderColor: "#ff4d4f",
borderWidth: 2,
}}
>
<Title level={5} style={{ color: "#ff4d4f", marginBottom: 16 }}>
Опасная зона
</Title>
<Popconfirm
title="Вы уверены?"
description="Это удалит ВСЕ черновики, которые еще не были отправлены в iiko. Это действие необратимо."
onConfirm={() => deleteAllDraftsMutation.mutate()}
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
>
<Button
danger
block
loading={deleteAllDraftsMutation.isPending}
>
Удалить все черновики
</Button>
</Popconfirm>
</Card>
)}
</Form>
),
},
];

View File

@@ -59,7 +59,7 @@ export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
// Событие для режима технического обслуживания (503)
export const MAINTENANCE_EVENT = 'rms_maintenance';
const apiClient = axios.create({
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
@@ -264,7 +264,7 @@ export const api = {
},
getStats: async (): Promise<InvoiceStats> => {
const { data } = await apiClient.get<InvoiceStats>('/stats/invoices');
const { data } = await apiClient.get<InvoiceStats>('/invoices/stats');
return data;
},
@@ -298,6 +298,12 @@ export const api = {
syncInvoices: async (): Promise<void> => {
await apiClient.post('/invoices/sync');
},
syncAll: async (force = true): Promise<void> => {
await apiClient.post('/sync/all', null, {
params: { force }
});
},
getPhotos: async (page = 1, limit = 20): Promise<GetPhotosResponse> => {
const { data } = await apiClient.get<GetPhotosResponse>('/photos', {
params: { page, limit }

View File

@@ -156,6 +156,9 @@ export interface UserSettings {
default_store_id: UUID | null;
auto_conduct: boolean;
role: UserRole; // Добавляем поле роли в настройки текущего пользователя
last_sync_at: string | null; // Время последней синхронизации с iiko
last_activity_at: string | null; // Время последней активности
sync_interval: number; // Интервал синхронизации в минутах
}
export interface InvoiceStats {
@@ -255,6 +258,7 @@ export interface CommitDraftRequest {
supplier_id: UUID;
comment: string;
incoming_document_number?: string;
is_processed: boolean;
}
export interface ReorderDraftItemsRequest {
@@ -286,6 +290,7 @@ export interface UnifiedInvoice {
is_app_created: boolean; // Создано ли через наше приложение
items_preview: string; // Краткое содержание товаров
photo_url: string | null; // Ссылка на фото чека
draft_id?: string; // ID черновика для SYNCED накладных, созданных в приложении
}
export interface InvoiceDetails {

View File

@@ -3,17 +3,25 @@ import type { DraftItem } from '../services/types';
/**
* Пересчитывает значения полей элемента черновика на основе измененного поля.
*
* @param item - Исходный элемент черновика
* Логика "Треугольник": Q (Quantity) -> P (Price) -> S (Sum) -> Q...
* Правило: "Пересчитываем значение, следующее за редактируемым.
* Оставляем значение, предшествующее редактируемому."
*
* - Если меняем Quantity (Q): Previous=Sum (Keep), Next=Price (Recalc). Price = Sum / Quantity
* - Если меняем Price (P): Previous=Quantity (Keep), Next=Sum (Recalc). Sum = Quantity * Price
* - Если меняем Sum (S): Previous=Price (Keep), Next=Quantity (Recalc). Quantity = Sum / Price
*
* @param item - Исходный элемент черновика (содержит "предыдущие/сохраняемые" значения)
* @param changedField - Измененное поле ('quantity' | 'price' | 'sum')
* @param newValue - Новое значение измененного поля
* @returns Новый объект DraftItem с пересчитанными значениями
*
* @example
* // При изменении количества
* // При изменении количества (пересчитываем цену, сумма сохраняется)
* const updated = recalculateItem(item, 'quantity', 5);
*
* @example
* // При изменении суммы
* // При изменении суммы (пересчитываем количество, цена сохраняется)
* const updated = recalculateItem(item, 'sum', 100);
*/
export function recalculateItem(
@@ -23,38 +31,53 @@ export function recalculateItem(
): DraftItem {
switch (changedField) {
case 'quantity': {
// При изменении количества пересчитываем сумму: sum = qty * price
return {
...item,
quantity: newValue,
sum: newValue * item.price,
};
}
case 'price': {
// При изменении цены пересчитываем сумму: sum = qty * price
return {
...item,
price: newValue,
sum: item.quantity * newValue,
};
}
case 'sum': {
// При изменении суммы пересчитываем цену: price = sum / qty
// Обрабатываем случай деления на ноль
if (item.quantity === 0) {
// Меняем Quantity (Q): Previous=Sum (Keep), Next=Price (Recalc)
// Price = Sum / Quantity
// Обрабатываем деление на ноль
if (newValue === 0) {
return {
...item,
sum: newValue,
quantity: newValue,
price: 0,
};
}
const newPrice = item.sum / newValue;
return {
...item,
quantity: newValue,
price: newPrice,
};
}
case 'price': {
// Меняем Price (P): Previous=Quantity (Keep), Next=Sum (Recalc)
// Sum = Quantity * Price
const newSum = item.quantity * newValue;
return {
...item,
price: newValue,
sum: newSum,
};
}
case 'sum': {
// Меняем Sum (S): Previous=Price (Keep), Next=Quantity (Recalc)
// Quantity = Sum / Price
// Обрабатываем деление на ноль
if (item.price === 0) {
return {
...item,
sum: newValue,
quantity: 0,
};
}
const newQuantity = newValue / item.price;
return {
...item,
sum: newValue,
price: newValue / item.quantity,
quantity: newQuantity,
};
}