0302-отрефакторил в нормальный вид на мобилу и десктоп

сразу выкинул пути в импортах и добавил алиас для корня
This commit is contained in:
2026-02-03 12:49:20 +03:00
parent ea1e5bbf6a
commit 51bc5bf8f0
50 changed files with 12878 additions and 490 deletions

View File

@@ -0,0 +1,33 @@
import React from "react";
import { Routes, Route, Navigate, Outlet } from "react-router-dom";
import { SessionGuard } from "./components/SessionGuard";
import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout";
import { DesktopAuthScreen } from "./pages/auth/DesktopAuthScreen";
import { InvoicesDashboard } from "./pages/dashboard/InvoicesDashboard";
import { useAuthStore } from "@/shared/stores/authStore";
const ProtectedRoute = () => {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) return <Navigate to="/web" replace />;
return <Outlet />;
};
export const DesktopApp: React.FC = () => {
return (
<SessionGuard>
<Routes>
{/* Этот путь теперь соответствует /web */}
<Route path="/" element={<DesktopAuthScreen />} />
{/* Этот путь теперь соответствует /web/dashboard */}
<Route element={<ProtectedRoute />}>
<Route path="dashboard" element={<DesktopLayout />}>
<Route index element={<InvoicesDashboard />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SessionGuard>
);
};

View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Spin } from "antd";
import { api } from "@/shared/api";
import { useAuthStore } from "@/shared/stores/authStore";
interface SessionGuardProps {
children: React.ReactNode;
}
/**
* Компонент для проверки сессии при загрузке десктопного приложения.
* Если пользователь авторизован через куки - редиректит на dashboard.
* В Telegram Mini App проверка не требуется - сразу отдаём children.
*/
export function SessionGuard({ children }: SessionGuardProps) {
// Проверяем, находимся ли мы в Telegram - если да, пропускаем без проверки
const isTelegram = !!window.Telegram?.WebApp?.initData;
const { isAuthenticated } = useAuthStore();
const [isChecking, setIsChecking] = useState(true);
const navigate = useNavigate();
useEffect(() => {
// В Telegram проверка сессии не требуется
if (isTelegram) {
return;
}
const checkSession = async () => {
// Если пользователь уже авторизован в store - пропускаем
if (isAuthenticated) {
setIsChecking(false);
return;
}
try {
// Проверяем сессию через API
const user = await api.getMe();
// Если успех - сохраняем в store и редиректим
useAuthStore.getState().setUser(user);
useAuthStore.getState().setToken("cookie");
navigate("/web/dashboard", { replace: true });
} catch {
// 401 - нет сессии, остаёмся на странице входа
// Другие ошибки - тоже остаёмся
} finally {
setIsChecking(false);
}
};
checkSession();
}, [isTelegram, isAuthenticated, navigate]);
// В Telegram сразу пропускаем
if (isTelegram) {
return <>{children}</>;
}
// Если проверяем - показываем лоадер
if (isChecking) {
return (
<div
style={{
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
}}
>
<Spin size="large" tip="Проверка сессии..." />
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,110 @@
import React, { useEffect } from "react";
import { Layout, Space, Avatar, Dropdown, Select } from "antd";
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
import { useAuthStore } from "@/shared/stores/authStore";
import { useServerStore } from "@/shared/stores/serverStore";
import { api } from "@/shared/api";
const { Header } = Layout;
/**
* Header для десктопной версии
* Содержит логотип, заглушку выбора сервера и аватар пользователя
*/
export const DesktopHeader: React.FC = () => {
const { user, logout } = useAuthStore();
const { servers, activeServer, isLoading, fetchServers, setActiveServer } =
useServerStore();
// Загружаем список серверов при маунте компонента
useEffect(() => {
fetchServers();
}, [fetchServers]);
const handleLogout = async () => {
await api.logout();
logout();
window.location.href = "/web";
};
const handleServerChange = (serverId: string) => {
setActiveServer(serverId);
};
const userMenuItems = [
{
key: "logout",
label: "Выйти",
icon: <LogoutOutlined />,
onClick: handleLogout,
},
];
return (
<Header
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "#ffffff",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
padding: "0 24px",
height: "64px",
position: "fixed",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "24px" }}>
{/* Логотип */}
<div
style={{
fontSize: "20px",
fontWeight: "bold",
color: "#1890ff",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>RMSer</span>
</div>
{/* Выбор сервера */}
<Select
placeholder="Выберите сервер"
value={activeServer?.id || undefined}
onChange={handleServerChange}
loading={isLoading}
disabled={isLoading}
style={{ minWidth: "200px" }}
options={servers.map((server) => ({
label: server.name,
value: server.id,
}))}
/>
</div>
{/* Аватар пользователя */}
<Space>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Avatar size="default" icon={<UserOutlined />} />
<span style={{ color: "#262626" }}>
{user?.username || "Пользователь"}
</span>
</div>
</Dropdown>
</Space>
</Header>
);
};

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Layout } from 'antd';
import { Outlet } from 'react-router-dom';
import { DesktopHeader } from './DesktopHeader.tsx';
const { Content } = Layout;
/**
* Основной layout для десктопной версии
* Использует Ant Design Layout с фиксированным Header
*/
export const DesktopLayout: React.FC = () => {
return (
<Layout style={{ minHeight: '100vh', backgroundColor: '#f0f2f5' }}>
<DesktopHeader />
<Content style={{ padding: '24px' }}>
<div
style={{
minHeight: 'calc(100vh - 64px - 48px)',
backgroundColor: '#ffffff',
borderRadius: '8px',
padding: '24px',
}}
>
<Outlet />
</div>
</Content>
</Layout>
);
};

View File

@@ -0,0 +1,221 @@
import React, { useEffect, useState, useCallback } from "react";
import { Card, Typography, Spin, Alert, message, Button } from "antd";
import { QRCodeSVG } from "qrcode.react";
import { SendOutlined, ReloadOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useWebSocket } from "@/shared/hooks/useWebSocket";
import { useAuthStore } from "@/shared/stores/authStore";
import { api } from "@/shared/api";
const { Title, Paragraph } = Typography;
interface AuthSuccessData {
token: string;
user: {
id: string;
username: string;
email?: string;
role?: string;
};
}
/**
* Экран авторизации для десктопной версии
* Отображает QR код для авторизации через мобильное приложение
* Реализует самовосстановление при разрыве соединения
*/
export const DesktopAuthScreen: React.FC = () => {
const navigate = useNavigate();
const { setToken, setUser } = useAuthStore();
const [sessionId, setSessionId] = useState<string | null>(null);
const [qrLink, setQrLink] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Функция обновления сессии QR
const refreshSession = useCallback(async () => {
try {
setIsRefreshing(true);
setError(null);
const data = await api.initDesktopAuth();
setSessionId(data.session_id);
setQrLink(data.qr_url);
console.log("🔄 Session refreshed:", data.session_id);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: "Неизвестная ошибка при обновлении QR";
setError(errorMessage);
console.error("❌ Refresh error:", err);
} finally {
setIsRefreshing(false);
}
}, []);
// Обработка разрыва соединения - автообновление QR
const handleDisconnect = useCallback(() => {
console.log("⚠️ WebSocket disconnected, refreshing QR...");
refreshSession();
}, [refreshSession]);
// Инициализация WebSocket с отключенным автореконнектом
const { isConnected, lastError, lastMessage } = useWebSocket(sessionId, {
autoReconnect: false,
onDisconnect: handleDisconnect,
});
// Инициализация сессии авторизации при маунте
useEffect(() => {
refreshSession();
}, [refreshSession]);
// Обработка события успешной авторизации через WebSocket
useEffect(() => {
if (lastMessage && lastMessage.event === "auth_success") {
const handleAuthSuccess = async () => {
const data = lastMessage.data as AuthSuccessData;
const { token, user } = data;
console.log("🎉 Auth Success:", user);
// Создаём сессию на сервере (установка HttpOnly куки)
await api.createSession();
// Устанавливаем токен и данные пользователя
setToken(token);
setUser(user);
message.success("Вход выполнен!");
navigate("/web/dashboard");
};
handleAuthSuccess();
}
}, [lastMessage, setToken, setUser, navigate]);
// Отображение лоадера при обновлении QR
if (isRefreshing) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: "#f0f2f5",
}}
>
<Spin size="large" tip="Обновление QR..." />
</div>
);
}
if (error || lastError) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: "#f0f2f5",
padding: "24px",
}}
>
<Alert
message="Ошибка"
description={error || lastError || "Произошла ошибка при подключении"}
type="error"
showIcon
style={{ maxWidth: "400px" }}
action={
<Button size="small" type="primary" danger onClick={refreshSession}>
Повторить
</Button>
}
/>
</div>
);
}
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: "#f0f2f5",
padding: "24px",
}}
>
<Card
style={{
width: "100%",
maxWidth: "400px",
textAlign: "center",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
}}
>
<Title level={3}>Авторизация</Title>
<Paragraph type="secondary">
Отсканируйте QR код для авторизации через Телеграмм
</Paragraph>
{qrLink && (
<div
style={{
margin: "24px 0",
display: "flex",
justifyContent: "center",
}}
>
<QRCodeSVG value={qrLink} size={200} />
</div>
)}
{qrLink && (
<Button
type="primary"
href={qrLink}
target="_blank"
icon={<SendOutlined />}
style={{ marginTop: 16 }}
>
Открыть в Telegram Desktop
</Button>
)}
<div
style={{
marginTop: 24,
fontSize: 12,
color: "#888",
textAlign: "left",
}}
>
<p>
Status:{" "}
{isConnected ? (
<span style={{ color: "green" }}>Connected</span>
) : (
<span style={{ color: "red" }}>Disconnected</span>
)}
</p>
<p>Session: {sessionId?.slice(0, 8)}...</p>
{lastError && <p style={{ color: "red" }}>Error: {lastError}</p>}
</div>
{/* Кнопка ручного обновления QR */}
<Button
type="link"
icon={<ReloadOutlined />}
onClick={refreshSession}
style={{ marginTop: 12 }}
>
Обновить QR
</Button>
</Card>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import React from "react";
import { Result, Button } from "antd";
import { MobileOutlined } from "@ant-design/icons";
/**
* Заглушка для мобильных браузеров
* Отображается когда пользователь пытается открыть десктопную версию на мобильном устройстве
*/
export const MobileBrowserStub: React.FC = () => {
const handleRedirect = () => {
window.location.href = "/";
};
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: "#f0f2f5",
padding: "24px",
}}
>
<Result
icon={<MobileOutlined style={{ fontSize: "72px", color: "#1890ff" }} />}
title="Десктопная версия недоступна"
subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
extra={[
<Button type="primary" key="mobile" onClick={handleRedirect}>
Перейти к мобильной версии
</Button>,
]}
/>
</div>
);
};

View File

@@ -0,0 +1,214 @@
import React, { useState } from "react";
import { Typography, Card, List, Empty, Tag, Spin } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DraftVerificationModal } from "@/shared/features/invoices/DraftVerificationModal";
import { api } from "@/shared/api";
import { useServerStore } from "@/shared/stores/serverStore";
import type { UnifiedInvoice } from "@/shared/types";
const { Title } = Typography;
/**
* Дашборд черновиков для десктопной версии
* Содержит зону для загрузки файлов и список черновиков
*/
export const InvoicesDashboard: React.FC = () => {
const { activeServer } = useServerStore();
const queryClient = useQueryClient();
// Состояние для Drag-n-Drop
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [verificationDraftId, setVerificationDraftId] = useState<string | null>(
null
);
// Загружаем список черновиков через useQuery
const { data: drafts, isLoading } = useQuery({
queryKey: ["drafts", activeServer?.id],
queryFn: () => api.getDrafts(),
enabled: !!activeServer, // Запрос выполняется только если выбран сервер
});
// Обработчики Drag-n-Drop
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Проверяем, что перетаскивается файл
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Проверяем, что мы действительно покидаем элемент, а не просто переходим к дочернему
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Разрешаем drop
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// Берем только первый файл
const file = files[0];
try {
setIsUploading(true);
const draft = await api.uploadFile(file);
// Обновляем список черновиков
queryClient.invalidateQueries({ queryKey: ["drafts", activeServer?.id] });
// Открываем модальное окно проверки
setVerificationDraftId(draft.id);
} catch (error) {
console.error("Ошибка загрузки файла:", error);
} finally {
setIsUploading(false);
}
};
const handleCloseVerification = () => {
setVerificationDraftId(null);
};
// Если сервер не выбран, показываем сообщение
if (!activeServer) {
return (
<div>
<Title level={2}>Черновики</Title>
<Card>
<Empty
description="Выберите сервер сверху"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
</div>
);
}
return (
<div
style={{ position: "relative" }}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Title level={2}>Черновики</Title>
{/* Список черновиков */}
<Card title="Последние черновики" loading={isLoading}>
<List
dataSource={drafts || []}
renderItem={(draft: UnifiedInvoice) => (
<List.Item>
<List.Item.Meta
title={
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>{draft.document_number}</span>
{draft.type === "DRAFT" && <Tag color="blue">Черновик</Tag>}
{draft.type === "SYNCED" && (
<Tag color="green">Синхронизировано</Tag>
)}
</div>
}
description={`${draft.date_incoming}${
draft.store_name || "Склад не указан"
}${draft.items_count} позиций`}
/>
</List.Item>
)}
locale={{
emptyText: (
<Empty
description="Нет черновиков"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
),
}}
/>
</Card>
{/* Overlay для Drag-n-Drop */}
{isDragOver && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(255, 255, 255, 0.9)",
zIndex: 100,
border: "3px dashed #1890ff",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
<UploadOutlined
style={{ fontSize: "64px", color: "#1890ff", marginBottom: "16px" }}
/>
<Typography.Text style={{ fontSize: "18px", color: "#1890ff" }}>
Отпустите файл для загрузки
</Typography.Text>
</div>
)}
{/* Лоадер загрузки файла */}
{isUploading && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)",
zIndex: 1000,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<Spin size="large" />
<Typography.Text style={{ color: "#fff", marginTop: "16px" }}>
Загрузка файла...
</Typography.Text>
</div>
)}
{/* Модальное окно проверки черновика */}
{verificationDraftId && (
<DraftVerificationModal
draftId={verificationDraftId}
visible={!!verificationDraftId}
onClose={handleCloseVerification}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useState } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { Result, Spin } from "antd";
import { AppLayout } from "./components/AppLayout";
import { DraftsList } from "./pages/DraftsList";
import { OcrLearning } from "./pages/OcrLearning";
import { SettingsPage } from "./pages/SettingsPage";
import { InvoiceDraftPage } from "./pages/InvoiceDraftPage";
import { InvoiceViewPage } from "./pages/InvoiceViewPage";
import MaintenancePage from "./pages/MaintenancePage";
import OperatorRestricted from "./components/OperatorRestricted";
import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "@/shared/api";
import { useServerStore } from "@/shared/stores/serverStore";
import type { UserRole } from "@/shared/types";
export const MobileApp: React.FC = () => {
const [isUnauthorized, setIsUnauthorized] = useState(false);
const [isMaintenance, setIsMaintenance] = useState(false);
const [userRole, setUserRole] = useState<UserRole | null>(null);
const [isLoadingRole, setIsLoadingRole] = useState(true);
const { activeServer, fetchServers } = useServerStore();
const tg = window.Telegram?.WebApp;
useEffect(() => {
const handleUnauthorized = () => setIsUnauthorized(true);
const handleMaintenance = () => setIsMaintenance(true);
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
if (tg) tg.expand();
const loadUserData = async () => {
try {
await fetchServers();
const currentServer = useServerStore.getState().activeServer;
if (currentServer) setUserRole(currentServer.role);
} catch (error) {
console.error("User load error", error);
} finally {
setIsLoadingRole(false);
}
};
loadUserData();
return () => {
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
window.removeEventListener(MAINTENANCE_EVENT, handleMaintenance);
};
}, [fetchServers, tg]);
if (isLoadingRole)
return (
<div
style={{
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Spin size="large" />
</div>
);
if (isUnauthorized)
return (
<Result
status="403"
title="Ошибка доступа"
subTitle="Перезапустите бота."
/>
);
if (isMaintenance) return <MaintenancePage />;
if (userRole === "OPERATOR")
return <OperatorRestricted serverName={activeServer?.name} />;
return (
<Routes>
<Route path="/" element={<AppLayout />}>
<Route index element={<Navigate to="/invoices" replace />} />
<Route path="invoices" element={<DraftsList />} />
<Route path="ocr" element={<OcrLearning />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/invoice/draft/:id" element={<InvoiceDraftPage />} />
<Route path="/invoice/view/:id" element={<InvoiceViewPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
};

View File

@@ -0,0 +1,113 @@
import React from "react";
import { Layout, theme } from "antd";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import {
ScanOutlined,
FileTextOutlined,
SettingOutlined,
} from "@ant-design/icons";
const { Content } = Layout;
export const AppLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const {
token: { colorBgContainer, colorPrimary, colorTextSecondary },
} = theme.useToken();
const path = location.pathname;
let activeKey = "invoices";
if (path.startsWith("/ocr")) activeKey = "ocr";
else if (path.startsWith("/settings")) activeKey = "settings";
const menuItems = [
{
key: "invoices",
icon: <FileTextOutlined style={{ fontSize: 20 }} />,
label: "Накладные",
path: "/invoices",
},
{
key: "ocr",
icon: <ScanOutlined style={{ fontSize: 20 }} />,
label: "Обучение",
path: "/ocr",
},
{
key: "settings",
icon: <SettingOutlined style={{ fontSize: 20 }} />,
label: "Настройки",
path: "/settings",
},
];
return (
<Layout
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
>
<Content
style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }}
>
<div
style={{
background: colorBgContainer,
minHeight: "100%",
padding: "12px 12px 80px 12px",
borderRadius: 0,
}}
>
<Outlet />
</div>
</Content>
<div
style={{
position: "fixed",
bottom: 0,
width: "100%",
zIndex: 1000,
background: "#fff",
borderTop: "1px solid #f0f0f0",
display: "flex",
justifyContent: "space-around",
alignItems: "center",
padding: "8px 0",
height: 60,
boxShadow: "0 -2px 8px rgba(0,0,0,0.05)",
}}
>
{menuItems.map((item) => {
const isActive = activeKey === item.key;
return (
<div
key={item.key}
onClick={() => navigate(item.path)}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "33%",
cursor: "pointer",
color: isActive ? colorPrimary : colorTextSecondary,
}}
>
{item.icon}
<span
style={{
fontSize: 10,
marginTop: 2,
fontWeight: isActive ? 500 : 400,
}}
>
{item.label}
</span>
</div>
);
})}
</div>
</Layout>
);
};

View File

@@ -0,0 +1,83 @@
import React from "react";
import { Result, Button, Spin } from "antd";
import { StopOutlined, CameraOutlined } from "@ant-design/icons";
interface Props {
serverName?: string;
loading?: boolean;
}
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={() => {
window.location.href = "https://t.me/RmserBot";
}}
>
Открыть бота в Telegram
</Button>
}
/>
</div>
);
};
export default OperatorRestricted;

View File

@@ -0,0 +1,63 @@
import React from "react";
import { Card, Tag, Typography, Button } from "antd";
import { WarningOutlined, InfoCircleOutlined } from "@ant-design/icons";
import type { Recommendation } from "@/shared/types";
const { Text, Paragraph } = Typography;
interface Props {
item: Recommendation;
}
export const RecommendationCard: React.FC<Props> = ({ item }) => {
const getTagColor = (type: string) => {
switch (type) {
case "UNUSED_IN_RECIPES":
return "volcano";
case "NO_INCOMING":
return "gold";
default:
return "blue";
}
};
const getIcon = (type: string) => {
return type === "UNUSED_IN_RECIPES" ? (
<WarningOutlined />
) : (
<InfoCircleOutlined />
);
};
return (
<Card
size="small"
title={
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{getIcon(item.Type)}
<Text strong ellipsis>
{item.ProductName}
</Text>
</div>
}
extra={<Tag color={getTagColor(item.Type)}>{item.Type}</Tag>}
style={{ marginBottom: 12, boxShadow: "0 2px 8px rgba(0,0,0,0.05)" }}
>
<Paragraph style={{ marginBottom: 8 }}>{item.Reason}</Paragraph>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(item.CreatedAt).toLocaleDateString()}
</Text>
<Button size="small" type="link">
Исправить
</Button>
</div>
</Card>
);
};

View File

@@ -0,0 +1,205 @@
import React, { useState } from "react";
import {
Card,
Image,
Button,
Popconfirm,
Tag,
Pagination,
Empty,
Spin,
message,
Tooltip,
} from "antd";
import {
DeleteOutlined,
ReloadOutlined,
FileImageOutlined,
CheckCircleOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, getStaticUrl } from "@/shared/api";
import { AxiosError } from "axios";
import type { ReceiptPhoto, PhotoStatus } from "@/shared/types";
export const PhotoStorageTab: React.FC = () => {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ["photos", page],
queryFn: () => api.getPhotos(page, 18),
});
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: string; force: boolean }) =>
api.deletePhoto(id, force),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["photos"] });
message.success("Фото удалено");
},
onError: (error: AxiosError<{ error: string }>) => {
if (error.response?.status === 409) {
message.warning("Это фото связано с черновиком.");
} else {
message.error(error.response?.data?.error || "Ошибка удаления");
}
},
});
const regenerateMutation = useMutation({
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
onSuccess: () => message.success("Черновик восстановлен"),
onError: () => message.error("Ошибка восстановления"),
});
const getStatusTag = (status: PhotoStatus) => {
switch (status) {
case "ORPHAN":
return <Tag color="default">Без привязки</Tag>;
case "HAS_DRAFT":
return (
<Tag icon={<FileTextOutlined />} color="processing">
Черновик
</Tag>
);
case "HAS_INVOICE":
return (
<Tag icon={<CheckCircleOutlined />} color="success">
В iiko
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin size="large" />
</div>
);
}
if (isError) {
return <Empty description="Ошибка загрузки фото" />;
}
if (!data?.photos?.length) {
return <Empty description="Нет загруженных фото" />;
}
return (
<div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: 16,
marginBottom: 24,
}}
>
{data.photos.map((photo: ReceiptPhoto) => (
<Card
key={photo.id}
hoverable
size="small"
cover={
<div
style={{
height: 160,
overflow: "hidden",
position: "relative",
}}
>
<Image
src={getStaticUrl(photo.file_url)}
alt={photo.file_name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
preview={{ mask: <FileImageOutlined /> }}
/>
</div>
}
actions={[
photo.can_regenerate ? (
<Tooltip title="Создать черновик заново">
<Button
type="text"
icon={<ReloadOutlined />}
onClick={() => regenerateMutation.mutate(photo.id)}
loading={regenerateMutation.isPending}
size="small"
/>
</Tooltip>
) : (
<span />
),
photo.can_delete ? (
<Popconfirm
title="Удалить фото?"
description={
photo.status === "HAS_DRAFT"
? "Черновик тоже будет удален."
: "Восстановить будет невозможно."
}
onConfirm={() =>
deleteMutation.mutate({
id: photo.id,
force: photo.status === "HAS_DRAFT",
})
}
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
/>
</Popconfirm>
) : (
<Tooltip title="Нельзя удалить: накладная уже в iiko">
<Button
type="text"
disabled
icon={<DeleteOutlined />}
size="small"
/>
</Tooltip>
),
]}
>
<Card.Meta
title={
<div
style={{
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{new Date(photo.created_at).toLocaleDateString()}
</div>
}
description={getStatusTag(photo.status)}
/>
</Card>
))}
</div>
<Pagination
current={page}
total={data.total}
pageSize={data.limit}
onChange={setPage}
showSizeChanger={false}
style={{ textAlign: "center" }}
simple
/>
</div>
);
};

View File

@@ -0,0 +1,67 @@
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 "@/shared/types";
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);
return `${date.format("DD.MM.YYYY HH:mm")} (${date.fromNow()})`;
};
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,196 @@
import React from "react";
import {
List,
Avatar,
Tag,
Button,
Select,
Popconfirm,
message,
Spin,
Alert,
Typography,
} from "antd";
import { DeleteOutlined, UserOutlined } from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import type { ServerUser, UserRole } from "@/shared/types";
const { Text } = Typography;
interface Props {
currentUserRole: UserRole;
}
export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
const queryClient = useQueryClient();
const {
data: users,
isLoading,
isError,
} = useQuery({
queryKey: ["serverUsers"],
queryFn: api.getUsers,
});
const updateRoleMutation = useMutation({
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
api.updateUserRole(userId, newRole),
onSuccess: () => {
message.success("Роль пользователя обновлена");
queryClient.invalidateQueries({ queryKey: ["serverUsers"] });
},
onError: () => {
message.error("Не удалось изменить роль");
},
});
const removeUserMutation = useMutation({
mutationFn: (userId: string) => api.removeUser(userId),
onSuccess: () => {
message.success("Пользователь удален из команды");
queryClient.invalidateQueries({ queryKey: ["serverUsers"] });
},
onError: () => {
message.error("Не удалось удалить пользователя");
},
});
const getRoleColor = (role: UserRole) => {
switch (role) {
case "OWNER":
return "gold";
case "ADMIN":
return "blue";
case "OPERATOR":
return "default";
default:
return "default";
}
};
const getRoleName = (role: UserRole) => {
switch (role) {
case "OWNER":
return "Владелец";
case "ADMIN":
return "Админ";
case "OPERATOR":
return "Оператор";
default:
return role;
}
};
const canDelete = (targetUser: ServerUser) => {
if (targetUser.is_me) return false;
if (targetUser.role === "OWNER") return false;
if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN")
return false;
return true;
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 20 }}>
<Spin />
</div>
);
}
if (isError) {
return <Alert type="error" message="Не удалось загрузить список команды" />;
}
return (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Приглашение сотрудников"
description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение."
/>
<List
itemLayout="horizontal"
dataSource={users || []}
renderItem={(user) => (
<List.Item
actions={[
currentUserRole === "OWNER" && !user.is_me ? (
<Select
key="role-select"
defaultValue={user.role}
size="small"
style={{ width: 110 }}
disabled={updateRoleMutation.isPending}
onChange={(val) =>
updateRoleMutation.mutate({
userId: user.user_id,
newRole: val,
})
}
options={[
{ value: "ADMIN", label: "Админ" },
{ value: "OPERATOR", label: "Оператор" },
]}
/>
) : (
<Tag key="role-tag" color={getRoleColor(user.role)}>
{getRoleName(user.role)}
</Tag>
),
<Popconfirm
key="delete"
title="Закрыть доступ?"
description={`Вы уверены, что хотите удалить ${user.first_name}?`}
onConfirm={() => removeUserMutation.mutate(user.user_id)}
disabled={!canDelete(user)}
okText="Да"
cancelText="Нет"
>
<Button
danger
type="text"
icon={<DeleteOutlined />}
disabled={!canDelete(user) || removeUserMutation.isPending}
/>
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={
<Avatar src={user.photo_url} icon={<UserOutlined />}>
{user.first_name?.[0]}
</Avatar>
}
title={
<span>
{user.first_name} {user.last_name}{" "}
{user.is_me && <Text type="secondary">(Вы)</Text>}
</span>
}
description={
user.username ? (
<a
href={`https://t.me/${user.username}`}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12 }}
>
@{user.username}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
Нет username
</Text>
)
}
/>
</List.Item>
)}
/>
</>
);
};

View File

@@ -0,0 +1,512 @@
import React, { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
List,
Typography,
Tag,
Spin,
Empty,
Flex,
Button,
Select,
DatePicker,
} from "antd";
import { useNavigate } from "react-router-dom";
import {
CheckCircleOutlined,
DeleteOutlined,
PlusOutlined,
ExclamationCircleOutlined,
LoadingOutlined,
CloseCircleOutlined,
StopOutlined,
SyncOutlined,
CloudServerOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import "dayjs/locale/ru";
import { api } from "@/shared/api";
import type { UnifiedInvoice } from "@/shared/types";
const { Title, Text } = Typography;
type FilterType = "ALL" | "DRAFT" | "SYNCED";
dayjs.locale("ru");
const DayDivider: React.FC<{ date: string }> = ({ date }) => {
const d = dayjs(date);
const dayOfWeek = d.format("dddd");
const formattedDate = d.format("D MMMM YYYY");
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "16px 0 8px",
borderBottom: "1px solid #f0f0f0",
marginBottom: 8,
}}
>
<Text strong style={{ fontSize: 14, color: "#1890ff" }}>
{formattedDate}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{dayOfWeek}
</Text>
</div>
);
};
export const DraftsList: React.FC = () => {
const navigate = useNavigate();
const [syncLoading, setSyncLoading] = useState(false);
const [filterType, setFilterType] = useState<FilterType>("ALL");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [startDate, setStartDate] = useState<dayjs.Dayjs | null>(
dayjs().subtract(30, "day")
);
const [endDate, setEndDate] = useState<dayjs.Dayjs | null>(dayjs());
const {
data: invoices,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: [
"drafts",
startDate?.format("YYYY-MM-DD"),
endDate?.format("YYYY-MM-DD"),
],
queryFn: () =>
api.getDrafts(
startDate?.format("YYYY-MM-DD"),
endDate?.format("YYYY-MM-DD")
),
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
});
const handleSync = async () => {
setSyncLoading(true);
try {
await api.syncInvoices();
refetch();
} finally {
setSyncLoading(false);
}
};
const getStatusTag = (item: UnifiedInvoice) => {
switch (item.status) {
case "PROCESSING":
return (
<Tag icon={<LoadingOutlined />} color="blue">
Обработка
</Tag>
);
case "READY_TO_VERIFY":
return (
<Tag icon={<ExclamationCircleOutlined />} color="orange">
Проверка
</Tag>
);
case "COMPLETED":
return (
<Tag icon={<CheckCircleOutlined />} color="green">
Готово
</Tag>
);
case "ERROR":
return (
<Tag icon={<CloseCircleOutlined />} color="red">
Ошибка
</Tag>
);
case "CANCELED":
return (
<Tag icon={<StopOutlined />} color="default">
Отменен
</Tag>
);
case "NEW":
return (
<Tag icon={<PlusOutlined />} color="blue">
Новая
</Tag>
);
case "PROCESSED":
return (
<Tag icon={<CheckCircleOutlined />} color="green">
Проведена
</Tag>
);
case "DELETED":
return (
<Tag icon={<DeleteOutlined />} color="red">
Удалена
</Tag>
);
default:
return <Tag>{item.status}</Tag>;
}
};
const handleInvoiceClick = (item: UnifiedInvoice) => {
if (item.type === "DRAFT") {
navigate("/invoice/draft/" + item.id);
return;
}
if (item.type === "SYNCED") {
if (item.draft_id) {
navigate("/invoice/draft/" + item.draft_id);
} else {
navigate("/invoice/view/" + item.id);
}
}
};
const handleFilterChange = (value: FilterType) => {
setFilterType(value);
setCurrentPage(1);
};
const handlePageSizeChange = (value: number) => {
setPageSize(value);
setCurrentPage(1);
};
const getItemDate = (item: UnifiedInvoice) =>
item.type === "DRAFT" ? item.created_at : item.date_incoming;
const filteredAndSortedInvoices = useMemo(() => {
if (!invoices || invoices.length === 0) return [];
let result = [...invoices];
if (filterType !== "ALL") {
result = result.filter((item) => item.type === filterType);
}
result.sort((a, b) => {
const dateA = dayjs(getItemDate(a)).startOf("day");
const dateB = dayjs(getItemDate(b)).startOf("day");
if (!dateA.isSame(dateB)) {
return dateB.valueOf() - dateA.valueOf();
}
if (a.type !== b.type) {
return a.type === "DRAFT" ? -1 : 1;
}
return (b.document_number || "").localeCompare(
a.document_number || "",
"ru",
{ numeric: true }
);
});
return result;
}, [invoices, filterType]);
const paginatedInvoices = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return filteredAndSortedInvoices.slice(startIndex, startIndex + pageSize);
}, [filteredAndSortedInvoices, currentPage, pageSize]);
const groupedInvoices = useMemo(() => {
const groups: { [key: string]: UnifiedInvoice[] } = {};
paginatedInvoices.forEach((item) => {
const dateKey = dayjs(getItemDate(item)).format("YYYY-MM-DD");
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(item);
});
return groups;
}, [paginatedInvoices]);
const filterCounts = useMemo(() => {
if (!invoices) return { all: 0, draft: 0, synced: 0 };
return {
all: invoices.length,
draft: invoices.filter((item) => item.type === "DRAFT").length,
synced: invoices.filter((item) => item.type === "SYNCED").length,
};
}, [invoices]);
const totalPages = Math.ceil(
(filteredAndSortedInvoices.length || 0) / pageSize
);
if (isError) {
return (
<div style={{ padding: 20 }}>
<Text type="danger">Ошибка загрузки списка накладных</Text>
</div>
);
}
return (
<div style={{ padding: "0 4px 20px" }}>
<Flex
align="center"
justify="space-between"
style={{ marginTop: 16, marginBottom: 16 }}
>
<Flex align="center" gap={8}>
<Title level={4} style={{ margin: 0 }}>
Накладные
</Title>
<Button
icon={<SyncOutlined />}
loading={syncLoading}
onClick={handleSync}
/>
</Flex>
<Select
value={filterType}
onChange={handleFilterChange}
options={[
{ label: `Все (${filterCounts.all})`, value: "ALL" },
{ label: `Черновики (${filterCounts.draft})`, value: "DRAFT" },
{ label: `Накладные (${filterCounts.synced})`, value: "SYNCED" },
]}
size="small"
style={{ width: 140 }}
/>
</Flex>
<div
style={{
marginBottom: 8,
background: "#fff",
padding: 8,
borderRadius: 8,
}}
>
<Flex vertical gap={8}>
<Text style={{ fontSize: 13 }}>Период:</Text>
<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>
{isLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin size="large" />
</div>
) : !invoices || invoices.length === 0 ? (
<Empty description="Нет данных" />
) : (
<>
<div>
{Object.entries(groupedInvoices).map(([dateKey, items]) => (
<div key={dateKey}>
<DayDivider date={dateKey} />
{items.map((item) => {
const isSynced = item.type === "SYNCED";
const displayDate =
item.type === "DRAFT"
? item.created_at
: item.date_incoming;
return (
<List.Item
key={item.id}
style={{
background: isSynced ? "#fafafa" : "#fff",
padding: 12,
marginBottom: 10,
borderRadius: 12,
cursor: "pointer",
border: isSynced
? "1px solid #f0f0f0"
: "1px solid #e6f7ff",
boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
display: "block",
position: "relative",
}}
onClick={() => handleInvoiceClick(item)}
>
<div
style={{
position: "absolute",
top: 12,
right: 12,
}}
>
{getStatusTag(item)}
</div>
<Flex vertical gap={4}>
<Flex align="center" gap={8}>
<Text strong style={{ fontSize: 16 }}>
{item.document_number || "Без номера"}
</Text>
{item.type === "SYNCED" && (
<CloudServerOutlined style={{ color: "gray" }} />
)}
{item.is_app_created && (
<span title="Создано в RMSer">📱</span>
)}
</Flex>
<Flex vertical gap={2}>
<Text type="secondary" style={{ fontSize: 13 }}>
{item.items_count} поз.
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{dayjs(displayDate).format("DD.MM.YYYY")}
</Text>
</Flex>
{item.incoming_number && (
<Text type="secondary" style={{ fontSize: 12 }}>
Вх. {item.incoming_number}
</Text>
)}
<Flex justify="space-between" align="center">
<div></div>
{item.store_name && (
<Tag
style={{
margin: 0,
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.store_name}
</Tag>
)}
</Flex>
<Flex
justify="space-between"
align="center"
style={{ marginTop: 8 }}
>
<Text
strong
style={{
fontSize: 17,
color: isSynced ? "#595959" : "#1890ff",
}}
>
{item.total_sum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
})}
</Text>
{item.items_preview && (
<div
style={{
textAlign: "right",
maxWidth: 150,
}}
>
{item.items_preview
.split(", ")
.slice(0, 3)
.map((previewItem, idx) => (
<div
key={idx}
style={{
fontSize: 12,
color: "#666",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{previewItem}
</div>
))}
</div>
)}
</Flex>
</Flex>
</List.Item>
);
})}
</div>
))}
</div>
{totalPages > 1 && (
<Flex
justify="space-between"
align="center"
style={{
marginTop: 16,
padding: "8px 12px",
background: "#fff",
borderRadius: 8,
}}
>
<Flex align="center" gap={8}>
<Text style={{ fontSize: 12 }}>На странице:</Text>
<Select
value={pageSize}
onChange={handlePageSizeChange}
options={[
{ label: "10", value: 10 },
{ label: "20", value: 20 },
{ label: "50", value: 50 },
]}
size="small"
style={{ width: 70 }}
/>
</Flex>
<Flex align="center" gap={8}>
<Text style={{ fontSize: 13 }}>
Стр. {currentPage} из {totalPages}
</Text>
<Button
size="small"
disabled={currentPage === 1}
onClick={() => setCurrentPage((prev) => prev - 1)}
>
</Button>
<Button
size="small"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((prev) => prev + 1)}
>
</Button>
</Flex>
</Flex>
)}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from "react";
import { useParams, useNavigate } from "react-router-dom";
import { DraftEditor } from "@/shared/features/invoices/DraftEditor";
export const InvoiceDraftPage: React.FC = () => {
const { id: draftId } = useParams<{ id: string }>();
const navigate = useNavigate();
if (!draftId) {
return null;
}
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<DraftEditor draftId={draftId} onBack={() => navigate("/invoices")} />
</div>
);
};

View File

@@ -0,0 +1,19 @@
import React from "react";
import { useParams, useNavigate } from "react-router-dom";
import { InvoiceViewer } from "@/shared/features/invoices/InvoiceViewer";
export const InvoiceViewPage: React.FC = () => {
const { id: invoiceId } = useParams<{ id: string }>();
const navigate = useNavigate();
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
{invoiceId && (
<InvoiceViewer
invoiceId={invoiceId}
onBack={() => navigate("/invoices")}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { Result, Button } from "antd";
const MaintenancePage = () => (
<div
style={{
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
padding: 20,
}}
>
<Result
status="warning"
title="Сервис на техническом обслуживании"
subTitle="Мы скоро вернемся с новыми функциями!"
extra={
<Button type="primary" onClick={() => window.location.reload()}>
Попробовать снова
</Button>
}
/>
</div>
);
export default MaintenancePage;

View File

@@ -0,0 +1,99 @@
import React from "react";
import { Spin, Alert } from "antd";
import { useOcr } from "@/shared/hooks/useOcr";
import { AddMatchForm } from "@/shared/features/ocr/AddMatchForm";
import { MatchList } from "@/shared/features/ocr/MatchList";
export const OcrLearning: React.FC = () => {
const {
catalog,
matches,
unmatched,
isLoading,
isError,
createMatch,
isCreating,
deleteMatch,
isDeletingMatch,
deleteUnmatched,
} = useOcr();
const [editingMatch, setEditingMatch] = React.useState<string | null>(null);
const currentEditingMatch = React.useMemo(() => {
if (!editingMatch) return undefined;
return matches.find((match) => match.raw_name === editingMatch);
}, [editingMatch, matches]);
if (isLoading && matches.length === 0) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "50vh",
flexDirection: "column",
gap: 16,
}}
>
<Spin size="large" />
<span style={{ color: "#888" }}>Загрузка справочников...</span>
</div>
);
}
if (isError) {
return (
<Alert
message="Ошибка"
description="Не удалось загрузить данные."
type="error"
showIcon
style={{ margin: 16 }}
/>
);
}
return (
<div style={{ paddingBottom: 20 }}>
<AddMatchForm
catalog={catalog}
unmatched={unmatched}
onSave={(raw, prodId, qty, contId) => {
if (currentEditingMatch) {
createMatch({
raw_name: raw,
product_id: prodId,
quantity: qty,
container_id: contId,
});
setEditingMatch(null);
} else {
createMatch({
raw_name: raw,
product_id: prodId,
quantity: qty,
container_id: contId,
});
}
}}
onDeleteUnmatched={deleteUnmatched}
isLoading={isCreating}
initialValues={currentEditingMatch}
onCancelEdit={() => setEditingMatch(null)}
/>
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>
Обученные позиции ({matches.length})
</h3>
<MatchList
matches={matches}
onDeleteMatch={deleteMatch}
onEditMatch={(match) => {
setEditingMatch(match.raw_name);
}}
isDeleting={isDeletingMatch}
/>
</div>
);
};

View File

@@ -0,0 +1,322 @@
import React, { useEffect } from "react";
import {
Typography,
Card,
Form,
Select,
Switch,
Button,
Row,
Col,
Statistic,
TreeSelect,
Spin,
message,
Tabs,
Popconfirm,
} from "antd";
import {
SaveOutlined,
BarChartOutlined,
SettingOutlined,
FolderOpenOutlined,
TeamOutlined,
CameraOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import type { UserSettings, Store } from "@/shared/types";
import { TeamList } from "../components/settings/TeamList";
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
import { SyncBlock } from "../components/settings/SyncBlock";
const { Title, Text } = Typography;
export const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const [form] = Form.useForm();
const settingsQuery = useQuery({
queryKey: ["settings"],
queryFn: api.getSettings,
});
const statsQuery = useQuery({
queryKey: ["stats"],
queryFn: api.getStats,
});
const dictQuery = useQuery({
queryKey: ["dictionaries"],
queryFn: api.getDictionaries,
staleTime: 1000 * 60 * 5,
});
const groupsQuery = useQuery({
queryKey: ["productGroups"],
queryFn: api.getProductGroups,
staleTime: 1000 * 60 * 10,
});
const saveMutation = useMutation({
mutationFn: (vals: UserSettings) => api.updateSettings(vals),
onSuccess: () => {
message.success("Настройки сохранены");
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
onError: () => {
message.error("Не удалось сохранить настройки");
},
});
const deleteAllDraftsMutation = useMutation({
mutationFn: () => api.deleteAllDrafts(),
onSuccess: (data) => {
message.success(`Удалено черновиков: ${data.count}`);
queryClient.invalidateQueries({ queryKey: ["stats"] });
},
onError: () => {
message.error("Не удалось удалить черновики");
},
});
const syncMutation = useMutation({
mutationFn: () => api.syncAll(true),
onSuccess: () => {
message.success("Синхронизация запущена в фоне");
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
onError: () => {
message.error("Ошибка запуска синхронизации");
},
});
useEffect(() => {
if (settingsQuery.data) {
form.setFieldsValue(settingsQuery.data);
}
}, [settingsQuery.data, form]);
const handleSave = async () => {
try {
const values = await form.validateFields();
saveMutation.mutate({
...values,
auto_conduct: !!values.auto_conduct,
});
} catch {
// validation errors
}
};
if (settingsQuery.isLoading) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
const currentUserRole = settingsQuery.data?.role || "OPERATOR";
const showTeamSettings =
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
const tabsItems = [
{
key: "general",
label: "Общие",
icon: <SettingOutlined />,
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: Store) => ({
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>
),
},
];
if (showTeamSettings) {
tabsItems.push({
key: "team",
label: "Команда",
icon: <TeamOutlined />,
children: (
<Card size="small">
<TeamList currentUserRole={currentUserRole} />
</Card>
),
});
}
if (currentUserRole === "OWNER") {
tabsItems.push({
key: "photos",
label: "Архив фото",
icon: <CameraOutlined />,
children: <PhotoStorageTab />,
});
}
return (
<div style={{ padding: "0 16px 80px" }}>
<Title level={4} style={{ marginTop: 16 }}>
<SettingOutlined /> Настройки
</Title>
<Card
size="small"
style={{
marginBottom: 16,
background: "#f0f5ff",
borderColor: "#d6e4ff",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
}}
>
<BarChartOutlined style={{ color: "#1890ff" }} />
<Text strong>Статистика накладных</Text>
</div>
<Row gutter={16}>
<Col span={8}>
<Statistic
title="За 24ч"
value={statsQuery.data?.last_24h || 0}
valueStyle={{ fontSize: 18 }}
/>
</Col>
<Col span={8}>
<Statistic
title="Месяц"
value={statsQuery.data?.last_month || 0}
valueStyle={{ fontSize: 18 }}
/>
</Col>
<Col span={8}>
<Statistic
title="Всего"
value={statsQuery.data?.total || 0}
valueStyle={{ fontSize: 18 }}
/>
</Col>
</Row>
</Card>
<Tabs defaultActiveKey="general" items={tabsItems} />
</div>
);
};