mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
0202-финиш перед десктопом
пересчет поправил редактирование с перепроведением галка автопроведения работает рекомендации починил
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
|
||||
90
rmser-view/src/components/OperatorRestricted.tsx
Normal file
90
rmser-view/src/components/OperatorRestricted.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
76
rmser-view/src/components/settings/SyncBlock.tsx
Normal file
76
rmser-view/src/components/settings/SyncBlock.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
63
rmser-view/src/hooks/useUserRole.ts
Normal file
63
rmser-view/src/hooks/useUserRole.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user