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

12451
rmser-view/project_context.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { SessionGuard } from "./components/layout/SessionGuard"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { import { Result, Button } from "antd";
BrowserRouter, import { Providers } from "@/app/Providers";
Routes, import { usePlatform } from "@/shared/hooks/usePlatform";
Route, import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "@/shared/api";
Navigate, import { MobileBrowserStub } from "@/modules/desktop/pages/auth/MobileBrowserStub";
useLocation, import { DesktopApp } from "@/modules/desktop/DesktopApp"; // Используем модуль
} from "react-router-dom"; import { MobileApp } from "@/modules/mobile/MobileApp";
import { Result, Button, Spin } from "antd"; import MaintenancePage from "@/modules/mobile/pages/MaintenancePage";
import { Providers } from "./components/layout/Providers";
import { AppLayout } from "./components/layout/AppLayout";
import { OcrLearning } from "./pages/OcrLearning";
import { InvoiceDraftPage } from "./pages/InvoiceDraftPage";
import { InvoiceViewPage } from "./pages/InvoiceViewPage";
import { DraftsList } from "./pages/DraftsList";
import { SettingsPage } from "./pages/SettingsPage";
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 = () => ( const NotInTelegramScreen = () => (
<div <div
style={{ style={{
@@ -42,7 +23,7 @@ const NotInTelegramScreen = () => (
<Result <Result
status="warning" status="warning"
title="Доступ ограничен" title="Доступ ограничен"
subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot для корректной работы." subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot."
extra={ extra={
<Button type="primary" href="https://t.me/RmserBot" target="_blank"> <Button type="primary" href="https://t.me/RmserBot" target="_blank">
Перейти в бота Перейти в бота
@@ -52,56 +33,15 @@ const NotInTelegramScreen = () => (
</div> </div>
); );
// Protected Route для десктопной версии
const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = useAuthStore();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/web" state={{ from: location }} replace />;
}
return <>{children}</>;
};
// Внутренний компонент с логикой, которая требует контекста роутера
const AppContent = () => { const AppContent = () => {
const [isUnauthorized, setIsUnauthorized] = useState(false); const [isUnauthorized, setIsUnauthorized] = useState(false);
const [isMaintenance, setIsMaintenance] = 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 tg = window.Telegram?.WebApp;
const platform = usePlatform(); const platform = usePlatform();
const location = useLocation();
const { activeServer, fetchServers } = useServerStore();
// Проверяем, есть ли данные от Telegram
const isInTelegram = !!tg?.initData; const isInTelegram = !!tg?.initData;
// Простая проверка: если URL начинается с /web или мы в обычном браузере на десктопе
// Проверяем, находимся ли мы на десктопном роуте const isDesktopRoute = window.location.pathname.startsWith("/web");
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(() => { useEffect(() => {
const handleUnauthorized = () => setIsUnauthorized(true); const handleUnauthorized = () => setIsUnauthorized(true);
@@ -109,9 +49,7 @@ const AppContent = () => {
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance); window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
if (tg) { if (tg) tg.expand();
tg.expand();
}
return () => { return () => {
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
@@ -119,39 +57,7 @@ const AppContent = () => {
}; };
}, [tg]); }, [tg]);
// Показываем лоадер пока загружается роль // 1. Глобальные ошибки (401, 503)
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 />;
}
// Если это десктопный роут и платформа - мобильный браузер
if (isDesktopRoute && platform === "MobileBrowser") {
return <MobileBrowserStub />;
}
// Заглушка для операторов (только для мобильной версии в Telegram)
if (userRole === 'OPERATOR' && isInTelegram) {
return <OperatorRestricted serverName={activeServer?.name} />;
}
// Если бэкенд вернул 401
if (isUnauthorized) { if (isUnauthorized) {
return ( return (
<div <div
@@ -165,56 +71,50 @@ const AppContent = () => {
<Result <Result
status="403" status="403"
title="Ошибка доступа" title="Ошибка доступа"
subTitle="Не удалось подтвердить вашу личность. Попробуйте обновить страницу внутри Telegram." subTitle="Не удалось подтвердить вашу личность."
/> />
</div> </div>
); );
} }
// Если бэкенд вернул 503 (режим технического обслуживания)
if (isMaintenance) { if (isMaintenance) {
return <MaintenancePage />; return <MaintenancePage />;
} }
// 2. ВЕТКА MOBILE (TELEGRAM)
// Рендерим MobileApp напрямую, чтобы его внутренний Router работал от корня
if (isInTelegram) {
return <MobileApp />;
}
// 3. ПРОВЕРКИ ОКРУЖЕНИЯ ДЛЯ WEB
if (!isDesktopRoute && !isInTelegram) {
// Если открыли корень / в браузере — редирект на /web (точку входа десктопа)
// Но если это мобильный браузер — покажем заглушку ниже
if (window.location.pathname === "/") {
return <Navigate to="/web" replace />;
}
}
if (isDesktopRoute && platform === "MobileBrowser") {
return <MobileBrowserStub />;
}
// 4. ВЕТКА DESKTOP (BROWSER)
// Оборачиваем DesktopApp в Route с /*, чтобы он перехватывал все пути, начинающиеся с /web
return ( return (
<Routes> <Routes>
{/* Мобильные роуты (существующие) */} <Route path="/web/*" element={<DesktopApp />} />
<Route path="/" element={<AppLayout />}> <Route path="*" element={<NotInTelegramScreen />} />
<Route index element={<Navigate to="/invoices" replace />} />
<Route path="ocr" element={<OcrLearning />} />
<Route path="invoices" element={<DraftsList />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
{/* Роуты для детальных страниц накладных (без AppLayout - на весь экран) */}
<Route path="/invoice/draft/:id" element={<InvoiceDraftPage />} />
<Route path="/invoice/view/:id" element={<InvoiceViewPage />} />
{/* Десктопные роуты */}
<Route path="/web" element={<DesktopAuthScreen />} />
<Route path="/web" element={<DesktopLayout />}>
<Route
path="dashboard"
element={
<ProtectedDesktopRoute>
<InvoicesDashboard />
</ProtectedDesktopRoute>
}
/>
</Route>
</Routes> </Routes>
); );
}; };
// Главный компонент-обертка
function App() { function App() {
return ( return (
<Providers> <Providers>
<BrowserRouter> <BrowserRouter>
<SessionGuard> <AppContent />
<AppContent />
</SessionGuard>
</BrowserRouter> </BrowserRouter>
</Providers> </Providers>
); );

View File

@@ -0,0 +1,30 @@
import React from "react";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import WebApp from "@twa-dev/sdk";
// Настройка клиента React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
interface ProvidersProps {
children: React.ReactNode;
}
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
useEffect(() => {
WebApp.ready();
WebApp.expand();
WebApp.setHeaderColor("secondary_bg_color");
}, []);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

View File

@@ -1,43 +0,0 @@
import React, { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import WebApp from '@twa-dev/sdk';
// Настройка клиента React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false, // Не перезапрашивать при переключении вкладок
retry: 1,
},
},
});
interface ProvidersProps {
children: React.ReactNode;
}
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Инициализация Telegram Mini App
WebApp.ready();
WebApp.expand(); // Разворачиваем на весь экран
// Подстраиваем цвет хедера под тему Telegram
WebApp.setHeaderColor('secondary_bg_color');
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsReady(true);
}, []);
if (!isReady) {
return <div style={{ padding: 20, textAlign: 'center' }}>Loading Telegram SDK...</div>;
}
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};

View File

@@ -1,50 +0,0 @@
import React from 'react';
import { Card, Tag, Typography, Button } from 'antd';
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
import type { Recommendation } from '../../services/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,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

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Spin } from "antd"; import { Spin } from "antd";
import { api } from "../../services/api"; import { api } from "@/shared/api";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "@/shared/stores/authStore";
interface SessionGuardProps { interface SessionGuardProps {
children: React.ReactNode; children: React.ReactNode;

View File

@@ -1,9 +1,9 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Layout, Space, Avatar, Dropdown, Select } from "antd"; import { Layout, Space, Avatar, Dropdown, Select } from "antd";
import { UserOutlined, LogoutOutlined } from "@ant-design/icons"; import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "@/shared/stores/authStore";
import { useServerStore } from "../../stores/serverStore"; import { useServerStore } from "@/shared/stores/serverStore";
import { api } from "../../services/api"; import { api } from "@/shared/api";
const { Header } = Layout; const { Header } = Layout;

View File

@@ -3,9 +3,9 @@ import { Card, Typography, Spin, Alert, message, Button } from "antd";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { SendOutlined, ReloadOutlined } from "@ant-design/icons"; import { SendOutlined, ReloadOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useWebSocket } from "../../../hooks/useWebSocket"; import { useWebSocket } from "@/shared/hooks/useWebSocket";
import { useAuthStore } from "../../../stores/authStore"; import { useAuthStore } from "@/shared/stores/authStore";
import { api } from "../../../services/api"; import { api } from "@/shared/api";
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
@@ -43,7 +43,10 @@ export const DesktopAuthScreen: React.FC = () => {
setQrLink(data.qr_url); setQrLink(data.qr_url);
console.log("🔄 Session refreshed:", data.session_id); console.log("🔄 Session refreshed:", data.session_id);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Неизвестная ошибка при обновлении QR"; const errorMessage =
err instanceof Error
? err.message
: "Неизвестная ошибка при обновлении QR";
setError(errorMessage); setError(errorMessage);
console.error("❌ Refresh error:", err); console.error("❌ Refresh error:", err);
} finally { } finally {
@@ -78,14 +81,14 @@ export const DesktopAuthScreen: React.FC = () => {
// Создаём сессию на сервере (установка HttpOnly куки) // Создаём сессию на сервере (установка HttpOnly куки)
await api.createSession(); await api.createSession();
// Устанавливаем токен и данные пользователя // Устанавливаем токен и данные пользователя
setToken(token); setToken(token);
setUser(user); setUser(user);
message.success("Вход выполнен!"); message.success("Вход выполнен!");
navigate("/web/dashboard"); navigate("/web/dashboard");
}; };
handleAuthSuccess(); handleAuthSuccess();
} }
}, [lastMessage, setToken, setUser, navigate]); }, [lastMessage, setToken, setUser, navigate]);
@@ -102,10 +105,7 @@ export const DesktopAuthScreen: React.FC = () => {
backgroundColor: "#f0f2f5", backgroundColor: "#f0f2f5",
}} }}
> >
<Spin <Spin size="large" tip="Обновление QR..." />
size="large"
tip="Обновление QR..."
/>
</div> </div>
); );
} }

View File

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

View File

@@ -2,10 +2,10 @@ import React, { useState } from "react";
import { Typography, Card, List, Empty, Tag, Spin } from "antd"; import { Typography, Card, List, Empty, Tag, Spin } from "antd";
import { UploadOutlined } from "@ant-design/icons"; import { UploadOutlined } from "@ant-design/icons";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DraftVerificationModal } from "../../../components/invoices/DraftVerificationModal"; import { DraftVerificationModal } from "@/shared/features/invoices/DraftVerificationModal";
import { api } from "../../../services/api"; import { api } from "@/shared/api";
import { useServerStore } from "../../../stores/serverStore"; import { useServerStore } from "@/shared/stores/serverStore";
import type { UnifiedInvoice } from "../../../services/types"; import type { UnifiedInvoice } from "@/shared/types";
const { Title } = Typography; const { Title } = Typography;

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

@@ -47,25 +47,21 @@ export const AppLayout: React.FC = () => {
<Layout <Layout
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }} style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
> >
{/* Верхнюю шапку (Header) удалили для экономии места */}
<Content <Content
style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }} style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }}
> >
{/* Убрали лишние паддинги вокруг контента для мобилок */}
<div <div
style={{ style={{
background: colorBgContainer, background: colorBgContainer,
minHeight: "100%", minHeight: "100%",
padding: "12px 12px 80px 12px", // Добавили отступ снизу, чтобы контент не перекрывался меню padding: "12px 12px 80px 12px",
borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны borderRadius: 0,
}} }}
> >
<Outlet /> <Outlet />
</div> </div>
</Content> </Content>
{/* Нижний Таб-бар */}
<div <div
style={{ style={{
position: "fixed", position: "fixed",

View File

@@ -7,16 +7,10 @@ interface Props {
loading?: boolean; loading?: boolean;
} }
/**
* Компонент заглушки для операторов.
* Отображается вместо основного интерфейса приложения для пользователей с ролью OPERATOR.
* Операторы могут загружать фото накладных только через Telegram-бота.
*/
const OperatorRestricted: React.FC<Props> = ({ const OperatorRestricted: React.FC<Props> = ({
serverName, serverName,
loading = false, loading = false,
}) => { }) => {
// Показываем лоадер пока идёт загрузка настроек
if (loading) { if (loading) {
return ( return (
<div <div
@@ -75,7 +69,6 @@ const OperatorRestricted: React.FC<Props> = ({
icon={<CameraOutlined />} icon={<CameraOutlined />}
size="large" size="large"
onClick={() => { onClick={() => {
// Открываем Telegram-бота
window.location.href = "https://t.me/RmserBot"; window.location.href = "https://t.me/RmserBot";
}} }}
> >

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

@@ -19,9 +19,9 @@ import {
FileTextOutlined, FileTextOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, getStaticUrl } from "../../services/api"; import { api, getStaticUrl } from "@/shared/api";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import type { ReceiptPhoto, PhotoStatus } from "../../services/types"; import type { ReceiptPhoto, PhotoStatus } from "@/shared/types";
export const PhotoStorageTab: React.FC = () => { export const PhotoStorageTab: React.FC = () => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -29,7 +29,7 @@ export const PhotoStorageTab: React.FC = () => {
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ["photos", page], queryKey: ["photos", page],
queryFn: () => api.getPhotos(page, 18), // 18 - удобно делится на 2, 3, 6 колонок queryFn: () => api.getPhotos(page, 18),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -39,12 +39,9 @@ export const PhotoStorageTab: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ["photos"] }); queryClient.invalidateQueries({ queryKey: ["photos"] });
message.success("Фото удалено"); message.success("Фото удалено");
}, },
// Исправленная типизация:
onError: (error: AxiosError<{ error: string }>) => { onError: (error: AxiosError<{ error: string }>) => {
if (error.response?.status === 409) { if (error.response?.status === 409) {
message.warning( message.warning("Это фото связано с черновиком.");
"Это фото связано с черновиком. Используйте кнопку 'Удалить' с подтверждением."
);
} else { } else {
message.error(error.response?.data?.error || "Ошибка удаления"); message.error(error.response?.data?.error || "Ошибка удаления");
} }
@@ -53,13 +50,8 @@ export const PhotoStorageTab: React.FC = () => {
const regenerateMutation = useMutation({ const regenerateMutation = useMutation({
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id), mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
onSuccess: () => { onSuccess: () => message.success("Черновик восстановлен"),
message.success("Черновик восстановлен"); onError: () => message.error("Ошибка восстановления"),
// Можно редиректить, но пока просто обновим список
},
onError: () => {
message.error("Ошибка восстановления");
},
}); });
const getStatusTag = (status: PhotoStatus) => { const getStatusTag = (status: PhotoStatus) => {
@@ -143,14 +135,13 @@ export const PhotoStorageTab: React.FC = () => {
</Tooltip> </Tooltip>
) : ( ) : (
<span /> <span />
), // Placeholder для выравнивания ),
photo.can_delete ? ( photo.can_delete ? (
<Popconfirm <Popconfirm
title="Удалить фото?" title="Удалить фото?"
description={ description={
photo.status === "HAS_DRAFT" photo.status === "HAS_DRAFT"
? "Внимание! Черновик тоже будет удален." ? "Черновик тоже будет удален."
: "Восстановить будет невозможно." : "Восстановить будет невозможно."
} }
onConfirm={() => onConfirm={() =>
@@ -200,7 +191,6 @@ export const PhotoStorageTab: React.FC = () => {
</Card> </Card>
))} ))}
</div> </div>
<Pagination <Pagination
current={page} current={page}
total={data.total} total={data.total}

View File

@@ -4,9 +4,8 @@ import { SyncOutlined } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import type { UserRole } from "../../services/types"; import type { UserRole } from "@/shared/types";
// Настройка dayjs для русской локали и относительного времени
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.locale("ru"); dayjs.locale("ru");
@@ -25,18 +24,12 @@ export const SyncBlock: React.FC<SyncBlockProps> = ({
onSync, onSync,
isLoading = false, isLoading = false,
}) => { }) => {
// Проверяем, есть ли права на синхронизацию
const canSync = userRole === "OWNER" || userRole === "ADMIN"; const canSync = userRole === "OWNER" || userRole === "ADMIN";
// Форматируем дату последней синхронизации
const formatLastSync = (dateStr: string | null): string => { const formatLastSync = (dateStr: string | null): string => {
if (!dateStr) { if (!dateStr) return "Никогда";
return "Никогда";
}
const date = dayjs(dateStr); const date = dayjs(dateStr);
const formatted = date.format("DD.MM.YYYY HH:mm"); return `${date.format("DD.MM.YYYY HH:mm")} (${date.fromNow()})`;
const relative = date.fromNow();
return `${formatted} (${relative})`;
}; };
return ( return (
@@ -53,7 +46,6 @@ export const SyncBlock: React.FC<SyncBlockProps> = ({
Последняя синхронизация: {formatLastSync(lastSyncAt)} Последняя синхронизация: {formatLastSync(lastSyncAt)}
</Text> </Text>
</div> </div>
<Tooltip title={!canSync ? "Только для администраторов" : undefined}> <Tooltip title={!canSync ? "Только для администраторов" : undefined}>
<Button <Button
type="primary" type="primary"
@@ -66,7 +58,6 @@ export const SyncBlock: React.FC<SyncBlockProps> = ({
Синхронизировать Синхронизировать
</Button> </Button>
</Tooltip> </Tooltip>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
Загружает справочники, накладные и пересчитывает рекомендации Загружает справочники, накладные и пересчитывает рекомендации
</Text> </Text>

View File

@@ -13,8 +13,8 @@ import {
} from "antd"; } from "antd";
import { DeleteOutlined, UserOutlined } from "@ant-design/icons"; import { DeleteOutlined, UserOutlined } from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../../services/api"; import { api } from "@/shared/api";
import type { ServerUser, UserRole } from "../../services/types"; import type { ServerUser, UserRole } from "@/shared/types";
const { Text } = Typography; const { Text } = Typography;
@@ -25,7 +25,6 @@ interface Props {
export const TeamList: React.FC<Props> = ({ currentUserRole }) => { export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Запрос списка пользователей
const { const {
data: users, data: users,
isLoading, isLoading,
@@ -35,7 +34,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
queryFn: api.getUsers, queryFn: api.getUsers,
}); });
// Мутация изменения роли
const updateRoleMutation = useMutation({ const updateRoleMutation = useMutation({
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) => mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
api.updateUserRole(userId, newRole), api.updateUserRole(userId, newRole),
@@ -48,7 +46,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
}, },
}); });
// Мутация удаления пользователя
const removeUserMutation = useMutation({ const removeUserMutation = useMutation({
mutationFn: (userId: string) => api.removeUser(userId), mutationFn: (userId: string) => api.removeUser(userId),
onSuccess: () => { onSuccess: () => {
@@ -60,7 +57,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
}, },
}); });
// Хелперы для UI
const getRoleColor = (role: UserRole) => { const getRoleColor = (role: UserRole) => {
switch (role) { switch (role) {
case "OWNER": case "OWNER":
@@ -87,12 +83,11 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
} }
}; };
// Проверка прав на удаление
const canDelete = (targetUser: ServerUser) => { const canDelete = (targetUser: ServerUser) => {
if (targetUser.is_me) return false; // Себя удалить нельзя if (targetUser.is_me) return false;
if (targetUser.role === "OWNER") return false; // Владельца удалить нельзя if (targetUser.role === "OWNER") return false;
if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN") if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN")
return false; // Админ не может удалить админа return false;
return true; return true;
}; };
@@ -115,16 +110,14 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="Приглашение сотрудников" message="Приглашение сотрудников"
description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение. Ссылку можно сгенерировать в Telegram-боте в меню «Управление сервером»." description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение."
/> />
<List <List
itemLayout="horizontal" itemLayout="horizontal"
dataSource={users || []} dataSource={users || []}
renderItem={(user) => ( renderItem={(user) => (
<List.Item <List.Item
actions={[ actions={[
// Селектор роли (только для Владельца и не для себя)
currentUserRole === "OWNER" && !user.is_me ? ( currentUserRole === "OWNER" && !user.is_me ? (
<Select <Select
key="role-select" key="role-select"
@@ -148,8 +141,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
{getRoleName(user.role)} {getRoleName(user.role)}
</Tag> </Tag>
), ),
// Кнопка удаления
<Popconfirm <Popconfirm
key="delete" key="delete"
title="Закрыть доступ?" title="Закрыть доступ?"

View File

@@ -1,5 +1,3 @@
// src/pages/DraftsList.tsx
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
@@ -27,8 +25,8 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import { api } from "../services/api"; import { api } from "@/shared/api";
import type { UnifiedInvoice } from "../services/types"; import type { UnifiedInvoice } from "@/shared/types";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -161,19 +159,15 @@ export const DraftsList: React.FC = () => {
}; };
const handleInvoiceClick = (item: UnifiedInvoice) => { const handleInvoiceClick = (item: UnifiedInvoice) => {
// Если это черновик - используем его ID
if (item.type === "DRAFT") { if (item.type === "DRAFT") {
navigate("/invoice/draft/" + item.id); navigate("/invoice/draft/" + item.id);
return; return;
} }
// Если это синхронизированная накладная
if (item.type === "SYNCED") { if (item.type === "SYNCED") {
// Если у нее есть ссылка на черновик (пришла с бэка) - открываем редактор черновика
if (item.draft_id) { if (item.draft_id) {
navigate("/invoice/draft/" + item.draft_id); navigate("/invoice/draft/" + item.draft_id);
} else { } else {
// Иначе просто просмотр
navigate("/invoice/view/" + item.id); navigate("/invoice/view/" + item.id);
} }
} }
@@ -205,17 +199,14 @@ export const DraftsList: React.FC = () => {
const dateA = dayjs(getItemDate(a)).startOf("day"); const dateA = dayjs(getItemDate(a)).startOf("day");
const dateB = dayjs(getItemDate(b)).startOf("day"); const dateB = dayjs(getItemDate(b)).startOf("day");
// Сначала по дате DESC
if (!dateA.isSame(dateB)) { if (!dateA.isSame(dateB)) {
return dateB.valueOf() - dateA.valueOf(); return dateB.valueOf() - dateA.valueOf();
} }
// Внутри дня: DRAFT < SYNCED
if (a.type !== b.type) { if (a.type !== b.type) {
return a.type === "DRAFT" ? -1 : 1; return a.type === "DRAFT" ? -1 : 1;
} }
// Внутри типа: по номеру DESC
return (b.document_number || "").localeCompare( return (b.document_number || "").localeCompare(
a.document_number || "", a.document_number || "",
"ru", "ru",

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { DraftEditor } from "../components/invoices/DraftEditor"; import { DraftEditor } from "@/shared/features/invoices/DraftEditor";
export const InvoiceDraftPage: React.FC = () => { export const InvoiceDraftPage: React.FC = () => {
const { id: draftId } = useParams<{ id: string }>(); const { id: draftId } = useParams<{ id: string }>();

View File

@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { useParams } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom"; import { InvoiceViewer } from "@/shared/features/invoices/InvoiceViewer";
import { InvoiceViewer } from "../components/invoices/InvoiceViewer";
export const InvoiceViewPage: React.FC = () => { export const InvoiceViewPage: React.FC = () => {
const { id: invoiceId } = useParams<{ id: string }>(); const { id: invoiceId } = useParams<{ id: string }>();

View File

@@ -1,6 +1,5 @@
import { Result, Button } from "antd"; import { Result, Button } from "antd";
// Страница-заглушка для режима технического обслуживания
const MaintenancePage = () => ( const MaintenancePage = () => (
<div <div
style={{ style={{

View File

@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { Spin, Alert } from "antd"; import { Spin, Alert } from "antd";
import { useOcr } from "../hooks/useOcr"; import { useOcr } from "@/shared/hooks/useOcr";
import { AddMatchForm } from "../components/ocr/AddMatchForm"; import { AddMatchForm } from "@/shared/features/ocr/AddMatchForm";
import { MatchList } from "../components/ocr/MatchList"; import { MatchList } from "@/shared/features/ocr/MatchList";
export const OcrLearning: React.FC = () => { export const OcrLearning: React.FC = () => {
const { const {
@@ -18,10 +18,8 @@ export const OcrLearning: React.FC = () => {
deleteUnmatched, deleteUnmatched,
} = useOcr(); } = useOcr();
// Состояние для редактирования
const [editingMatch, setEditingMatch] = React.useState<string | null>(null); const [editingMatch, setEditingMatch] = React.useState<string | null>(null);
// Найти редактируемую связь
const currentEditingMatch = React.useMemo(() => { const currentEditingMatch = React.useMemo(() => {
if (!editingMatch) return undefined; if (!editingMatch) return undefined;
return matches.find((match) => match.raw_name === editingMatch); return matches.find((match) => match.raw_name === editingMatch);
@@ -62,10 +60,8 @@ export const OcrLearning: React.FC = () => {
<AddMatchForm <AddMatchForm
catalog={catalog} catalog={catalog}
unmatched={unmatched} unmatched={unmatched}
// Передаем containerId
onSave={(raw, prodId, qty, contId) => { onSave={(raw, prodId, qty, contId) => {
if (currentEditingMatch) { if (currentEditingMatch) {
// Обновление существующей связи
createMatch({ createMatch({
raw_name: raw, raw_name: raw,
product_id: prodId, product_id: prodId,
@@ -74,7 +70,6 @@ export const OcrLearning: React.FC = () => {
}); });
setEditingMatch(null); setEditingMatch(null);
} else { } else {
// Создание новой связи
createMatch({ createMatch({
raw_name: raw, raw_name: raw,
product_id: prodId, product_id: prodId,

View File

@@ -24,8 +24,8 @@ import {
CameraOutlined, CameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../services/api"; import { api } from "@/shared/api";
import type { UserSettings } from "../services/types"; import type { UserSettings, Store } from "@/shared/types";
import { TeamList } from "../components/settings/TeamList"; import { TeamList } from "../components/settings/TeamList";
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab"; import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
import { SyncBlock } from "../components/settings/SyncBlock"; import { SyncBlock } from "../components/settings/SyncBlock";
@@ -36,8 +36,6 @@ export const SettingsPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [form] = Form.useForm(); const [form] = Form.useForm();
// --- Запросы ---
const settingsQuery = useQuery({ const settingsQuery = useQuery({
queryKey: ["settings"], queryKey: ["settings"],
queryFn: api.getSettings, queryFn: api.getSettings,
@@ -60,8 +58,6 @@ export const SettingsPage: React.FC = () => {
staleTime: 1000 * 60 * 10, staleTime: 1000 * 60 * 10,
}); });
// --- Мутации ---
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (vals: UserSettings) => api.updateSettings(vals), mutationFn: (vals: UserSettings) => api.updateSettings(vals),
onSuccess: () => { onSuccess: () => {
@@ -95,31 +91,24 @@ export const SettingsPage: React.FC = () => {
}, },
}); });
// --- Эффекты ---
useEffect(() => { useEffect(() => {
if (settingsQuery.data) { if (settingsQuery.data) {
form.setFieldsValue(settingsQuery.data); form.setFieldsValue(settingsQuery.data);
} }
}, [settingsQuery.data, form]); }, [settingsQuery.data, form]);
// --- Хендлеры ---
const handleSave = async () => { const handleSave = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
console.log("Settings Form Values:", values);
saveMutation.mutate({ saveMutation.mutate({
...values, ...values,
auto_conduct: !!values.auto_conduct, auto_conduct: !!values.auto_conduct,
}); });
} catch { } catch {
// Ошибки валидации // validation errors
} }
}; };
// --- Рендер ---
if (settingsQuery.isLoading) { if (settingsQuery.isLoading) {
return ( return (
<div style={{ textAlign: "center", padding: 50 }}> <div style={{ textAlign: "center", padding: 50 }}>
@@ -128,7 +117,6 @@ export const SettingsPage: React.FC = () => {
); );
} }
// Определяем роль текущего пользователя
const currentUserRole = settingsQuery.data?.role || "OPERATOR"; const currentUserRole = settingsQuery.data?.role || "OPERATOR";
const showTeamSettings = const showTeamSettings =
currentUserRole === "ADMIN" || currentUserRole === "OWNER"; currentUserRole === "ADMIN" || currentUserRole === "OWNER";
@@ -157,7 +145,7 @@ export const SettingsPage: React.FC = () => {
placeholder="Не выбрано" placeholder="Не выбрано"
allowClear allowClear
loading={dictQuery.isLoading} loading={dictQuery.isLoading}
options={dictQuery.data?.stores.map((s) => ({ options={dictQuery.data?.stores.map((s: Store) => ({
label: s.name, label: s.name,
value: s.id, value: s.id,
}))} }))}
@@ -269,7 +257,6 @@ export const SettingsPage: React.FC = () => {
}); });
} }
// Добавляем вкладку с фото (доступна для OWNER)
if (currentUserRole === "OWNER") { if (currentUserRole === "OWNER") {
tabsItems.push({ tabsItems.push({
key: "photos", key: "photos",
@@ -285,7 +272,6 @@ export const SettingsPage: React.FC = () => {
<SettingOutlined /> Настройки <SettingOutlined /> Настройки
</Title> </Title>
{/* Статистика */}
<Card <Card
size="small" size="small"
style={{ style={{
@@ -330,7 +316,6 @@ export const SettingsPage: React.FC = () => {
</Row> </Row>
</Card> </Card>
{/* Табы настроек */}
<Tabs defaultActiveKey="general" items={tabsItems} /> <Tabs defaultActiveKey="general" items={tabsItems} />
</div> </div>
); );

View File

@@ -1,59 +0,0 @@
import React from 'react';
import { Typography, Row, Col, Statistic, Spin, Alert, Empty } from 'antd';
import { useRecommendations } from '../hooks/useRecommendations';
import { RecommendationCard } from '../components/recommendations/RecommendationCard';
const { Title } = Typography;
export const Dashboard: React.FC = () => {
const { data: recommendations, isPending, isError, error } = useRecommendations();
if (isPending) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Spin size="large" tip="Загрузка аналитики..." />
</div>
);
}
if (isError) {
return (
<Alert
message="Ошибка загрузки"
description={error?.message || 'Не удалось получить данные с сервера'}
type="error"
showIcon
/>
);
}
// Группировка для статистики
const unusedCount = recommendations?.filter(r => r.Type === 'UNUSED_IN_RECIPES').length || 0;
const noIncomingCount = recommendations?.filter(r => r.Type === 'NO_INCOMING').length || 0;
return (
<div>
<Title level={4} style={{ marginTop: 0 }}>Сводка проблем</Title>
{/* Блок статистики */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={12}>
<Statistic title="Без техкарт" value={unusedCount} valueStyle={{ color: '#cf1322' }} />
</Col>
<Col span={12}>
<Statistic title="Без закупок" value={noIncomingCount} valueStyle={{ color: '#d48806' }} />
</Col>
</Row>
<Title level={5}>Рекомендации ({recommendations?.length})</Title>
{recommendations && recommendations.length > 0 ? (
recommendations.map((rec) => (
<RecommendationCard key={rec.ID} item={rec} />
))
) : (
<Empty description="Проблем не обнаружено" />
)}
</div>
);
};

View File

@@ -39,7 +39,7 @@ import type {
InvoiceDetails, InvoiceDetails,
GetPhotosResponse, GetPhotosResponse,
ServerShort ServerShort
} from './types'; } from '../types';
// Интерфейс для ответа метода инициализации десктопной авторизации // Интерфейс для ответа метода инициализации десктопной авторизации
export interface InitDesktopAuthResponse { export interface InitDesktopAuthResponse {

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { Modal, Form, Input, InputNumber, Button, message } from 'antd'; import { Modal, Form, Input, InputNumber, Button, message } from "antd";
import { api } from '../../services/api'; import { api } from "@/shared/api";
import type { ProductContainer } from '../../services/types'; import type { ProductContainer } from "@/shared/types";
interface Props { interface Props {
visible: boolean; visible: boolean;
@@ -12,8 +12,12 @@ interface Props {
onSuccess: (container: ProductContainer) => void; onSuccess: (container: ProductContainer) => void;
} }
export const CreateContainerModal: React.FC<Props> = ({ export const CreateContainerModal: React.FC<Props> = ({
visible, onCancel, productId, productBaseUnit, onSuccess visible,
onCancel,
productId,
productBaseUnit,
onSuccess,
}) => { }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -22,30 +26,30 @@ export const CreateContainerModal: React.FC<Props> = ({
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
setLoading(true); setLoading(true);
// 1. Отправляем запрос на БЭКЕНД // 1. Отправляем запрос на БЭКЕНД
const res = await api.createContainer({ const res = await api.createContainer({
product_id: productId, product_id: productId,
name: values.name, name: values.name,
count: values.count count: values.count,
}); });
message.success('Фасовка создана'); message.success("Фасовка создана");
// 2. БЭКЕНД вернул ID. Теперь мы собираем объект для UI // 2. БЭКЕНД вернул ID. Теперь мы собираем объект для UI
// Мы не придумываем ID сами, мы берем res.container_id // Мы не придумываем ID сами, мы берем res.container_id
const newContainer: ProductContainer = { const newContainer: ProductContainer = {
id: res.container_id, // <--- ID от сервера id: res.container_id, // <--- ID от сервера
name: values.name, name: values.name,
count: values.count count: values.count,
}; };
// 3. Возвращаем полный объект родителю // 3. Возвращаем полный объект родителю
onSuccess(newContainer); onSuccess(newContainer);
form.resetFields(); form.resetFields();
} catch { } catch {
message.error('Ошибка создания фасовки'); message.error("Ошибка создания фасовки");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -57,28 +61,41 @@ export const CreateContainerModal: React.FC<Props> = ({
open={visible} open={visible}
onCancel={onCancel} onCancel={onCancel}
footer={[ footer={[
<Button key="back" onClick={onCancel}>Отмена</Button>, <Button key="back" onClick={onCancel}>
<Button key="submit" type="primary" loading={loading} onClick={handleOk}> Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={loading}
onClick={handleOk}
>
Создать Создать
</Button>, </Button>,
]} ]}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item <Form.Item
name="name" name="name"
label="Название" label="Название"
rules={[{ required: true, message: 'Введите название, например 0.5' }]} rules={[
{ required: true, message: "Введите название, например 0.5" },
]}
> >
<Input placeholder="Например: Бутылка 0.5" /> <Input placeholder="Например: Бутылка 0.5" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="count" name="count"
label={`Количество в базовых ед. (${productBaseUnit})`} label={`Количество в базовых ед. (${productBaseUnit})`}
rules={[{ required: true, message: 'Введите коэффициент' }]} rules={[{ required: true, message: "Введите коэффициент" }]}
> >
<InputNumber style={{ width: '100%' }} step={0.001} placeholder="0.5" /> <InputNumber
style={{ width: "100%" }}
step={0.001}
placeholder="0.5"
/>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
); );
}; };

View File

@@ -29,16 +29,20 @@ import {
SwapOutlined, SwapOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { api, getStaticUrl } from "../../services/api"; import { api, getStaticUrl } from "@/shared/api";
import { DraftItemRow } from "./DraftItemRow"; import { DraftItemRow } from "./DraftItemRow";
import ExcelPreviewModal from "../common/ExcelPreviewModal"; import ExcelPreviewModal from "@/shared/ui/ExcelPreviewModal";
import { useActiveDraftStore } from "../../stores/activeDraftStore"; import { useActiveDraftStore } from "@/shared/stores/activeDraftStore";
import type { import type {
DraftItem, DraftItem,
UpdateDraftRequest, UpdateDraftRequest,
CommitDraftRequest, CommitDraftRequest,
ReorderDraftItemsRequest, ReorderDraftItemsRequest,
} from "../../services/types"; Store,
Supplier,
Recommendation,
DictionariesResponse,
} from "@/shared/types";
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd"; import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
const { Text } = Typography; const { Text } = Typography;
@@ -111,8 +115,10 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
}); });
const draft = draftQuery.data; const draft = draftQuery.data;
const stores = dictQuery.data?.stores || []; const stores =
const suppliers = dictQuery.data?.suppliers || []; (dictQuery.data as DictionariesResponse | undefined)?.stores || [];
const suppliers =
(dictQuery.data as DictionariesResponse | undefined)?.suppliers || [];
// Определение типа файла по расширению // Определение типа файла по расширению
const isExcelFile = draft?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/); const isExcelFile = draft?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/);
@@ -134,7 +140,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
const commitMutation = useMutation({ const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) => mutationFn: (payload: CommitDraftRequest) =>
api.commitDraft(draftId, payload), api.commitDraft(draftId, payload),
onSuccess: (data) => { onSuccess: (data: { document_number: string }) => {
message.success(`Накладная ${data.document_number} создана!`); message.success(`Накладная ${data.document_number} создана!`);
queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts"] });
}, },
@@ -202,14 +208,15 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
const totalSum = useMemo(() => { const totalSum = useMemo(() => {
return ( return (
items.reduce( items.reduce(
(acc, item) => acc + Number(item.quantity) * Number(item.price), (acc: number, item: DraftItem) =>
acc + Number(item.quantity) * Number(item.price),
0 0
) || 0 ) || 0
); );
}, [items]); }, [items]);
const invalidItemsCount = useMemo(() => { const invalidItemsCount = useMemo(() => {
return items.filter((i) => !i.product_id).length || 0; return items.filter((i: DraftItem) => !i.product_id).length || 0;
}, [items]); }, [items]);
// Функция сохранения изменений на сервер // Функция сохранения изменений на сервер
@@ -230,7 +237,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
date_incoming: formValues.date_incoming date_incoming: formValues.date_incoming
? formValues.date_incoming.format("YYYY-MM-DD") ? formValues.date_incoming.format("YYYY-MM-DD")
: undefined, : undefined,
items: items.map((item) => ({ items: items.map((item: DraftItem) => ({
id: item.id, id: item.id,
product_id: item.product_id ?? "", product_id: item.product_id ?? "",
container_id: item.container_id ?? "", container_id: item.container_id ?? "",
@@ -362,7 +369,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
// Подготавливаем payload для API // Подготавливаем payload для API
const reorderPayload: ReorderDraftItemsRequest = { const reorderPayload: ReorderDraftItemsRequest = {
items: items.map((item, index) => ({ items: items.map((item: DraftItem, index: number) => ({
id: item.id, id: item.id,
order: index, order: index,
})), })),
@@ -565,7 +572,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
<Select <Select
placeholder="Выберите склад..." placeholder="Выберите склад..."
loading={dictQuery.isLoading} loading={dictQuery.isLoading}
options={stores.map((s) => ({ options={stores.map((s: Store) => ({
label: s.name, label: s.name,
value: s.id, value: s.id,
}))} }))}
@@ -582,13 +589,16 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
<Select <Select
placeholder="Поставщик..." placeholder="Поставщик..."
loading={dictQuery.isLoading} loading={dictQuery.isLoading}
options={suppliers.map((s) => ({ label: s.name, value: s.id }))} options={suppliers.map((s: Supplier) => ({
label: s.name,
value: s.id,
}))}
size="small" size="small"
showSearch showSearch
filterOption={(input, option) => filterOption={(input, option) =>
(option?.label ?? "") String(option?.label ?? "")
.toLowerCase() .toLowerCase()
.includes(input.toLowerCase()) .includes(String(input).toLowerCase())
} }
/> />
</Form.Item> </Form.Item>
@@ -645,7 +655,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
transition: "background-color 0.2s ease", transition: "background-color 0.2s ease",
}} }}
> >
{items.map((item, index) => ( {items.map((item: DraftItem, index: number) => (
<DraftItemRow <DraftItemRow
key={item.id} key={item.id}
item={item} item={item}
@@ -653,7 +663,11 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
onLocalUpdate={handleItemUpdate} onLocalUpdate={handleItemUpdate}
onDelete={(itemId) => deleteItem(itemId)} onDelete={(itemId) => deleteItem(itemId)}
isUpdating={false} isUpdating={false}
recommendations={recommendationsQuery.data || []} recommendations={
(recommendationsQuery.data as
| Recommendation[]
| undefined) || []
}
isReordering={isReordering} isReordering={isReordering}
/> />
))} ))}

View File

@@ -26,7 +26,7 @@ import type {
ProductSearchResult, ProductSearchResult,
ProductContainer, ProductContainer,
Recommendation, Recommendation,
} from "../../services/types"; } from "@/shared/types";
const { Text } = Typography; const { Text } = Typography;

View File

@@ -3,9 +3,9 @@ import { Modal, Spin, Button, Typography, Alert, message } from "antd";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DragDropContext, Droppable } from "@hello-pangea/dnd"; import { DragDropContext, Droppable } from "@hello-pangea/dnd";
import { api } from "../../services/api"; import { api } from "../../api";
import { DraftItemRow } from "./DraftItemRow"; import { DraftItemRow } from "./DraftItemRow";
import type { UpdateDraftItemRequest, DraftItem } from "../../services/types"; import type { UpdateDraftItemRequest, DraftItem } from "../../types";
const { Text } = Typography; const { Text } = Typography;
@@ -114,7 +114,7 @@ export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
}; };
const invalidItemsCount = const invalidItemsCount =
draft?.items.filter((i) => !i.product_id).length || 0; draft?.items.filter((i: DraftItem) => !i.product_id).length || 0;
return ( return (
<Modal <Modal
@@ -191,7 +191,7 @@ export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
<Droppable droppableId="modal-verification-list"> <Droppable droppableId="modal-verification-list">
{(provided) => ( {(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}> <div ref={provided.innerRef} {...provided.droppableProps}>
{draft.items.map((item, index) => ( {draft.items.map((item: DraftItem, index: number) => (
<DraftItemRow <DraftItemRow
key={item.id} key={item.id}
item={item} item={item}

View File

@@ -7,9 +7,9 @@ import {
HistoryOutlined, HistoryOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { api, getStaticUrl } from "../../services/api"; import { api, getStaticUrl } from "../../api";
import type { DraftStatus } from "../../services/types"; import type { DraftStatus } from "../../types";
import ExcelPreviewModal from "../common/ExcelPreviewModal"; import ExcelPreviewModal from "../../ui/ExcelPreviewModal";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -101,7 +101,7 @@ export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
]; ];
const totalSum = (invoice.items || []).reduce( const totalSum = (invoice.items || []).reduce(
(acc, item) => acc + item.total, (acc: number, item: { total: number }) => acc + item.total,
0 0
); );

View File

@@ -25,7 +25,7 @@ import type {
ProductSearchResult, ProductSearchResult,
ProductContainer, ProductContainer,
ProductMatch, ProductMatch,
} from "../../services/types"; } from "../../types";
const { Text } = Typography; const { Text } = Typography;

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Select, Spin } from "antd"; import { Select, Spin } from "antd";
import { api } from "../../services/api"; import { api } from "../../api";
import type { CatalogItem, ProductSearchResult } from "../../services/types"; import type { CatalogItem, ProductSearchResult } from "../../types";
interface Props { interface Props {
value: string | null; value: string | null;

View File

@@ -6,7 +6,7 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import type { ProductMatch } from "../../services/types"; import type { ProductMatch } from "../../types";
const { Text } = Typography; const { Text } = Typography;

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../services/api'; import { api } from '../api';
import type { MatchRequest, ProductMatch, CatalogItem, UnmatchedItem } from '../services/types'; import type { MatchRequest, ProductMatch, CatalogItem, UnmatchedItem } from '../types';
import { message } from 'antd'; import { message } from 'antd';
export const useOcr = () => { export const useOcr = () => {

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../services/api'; import { api } from '../api';
import type { Recommendation } from '../services/types'; import type { Recommendation } from '../types';
export const useRecommendations = () => { export const useRecommendations = () => {
return useQuery<Recommendation[], Error>({ return useQuery<Recommendation[], Error>({

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { api } from '../services/api'; import { api } from '../api';
import type { UserSettings, UserRole } from '../services/types'; import type { UserSettings, UserRole } from '../types';
interface UseUserRoleResult { interface UseUserRoleResult {
/** Роль текущего пользователя или null если не загружено */ /** Роль текущего пользователя или null если не загружено */

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer'; import { immer } from 'zustand/middleware/immer';
import type { DraftItem } from '../services/types'; import type { DraftItem } from '../types';
import { recalculateItem } from '../utils/calculations'; import { recalculateItem } from '../utils/calculations';
/** /**

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { ServerShort } from '../services/types'; import type { ServerShort } from '../types';
import { api } from '../services/api'; import { api } from '../api';
interface ServerState { interface ServerState {
servers: ServerShort[]; servers: ServerShort[];

View File

@@ -6,7 +6,7 @@ import {
UndoOutlined, UndoOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { apiClient } from "../../services/api"; import { apiClient } from "../api";
interface ExcelPreviewModalProps { interface ExcelPreviewModalProps {
visible: boolean; visible: boolean;

View File

@@ -1,4 +1,4 @@
import type { DraftItem } from '../services/types'; import type { DraftItem } from '../types';
/** /**
* Пересчитывает значения полей элемента черновика на основе измененного поля. * Пересчитывает значения полей элемента черновика на основе измененного поля.

View File

@@ -16,6 +16,12 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,

View File

@@ -1,13 +1,19 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: { server: {
host: true, host: true,
port: 5173, port: 5173,
// Разрешаем наш домен // Разрешаем наш домен
allowedHosts: ['rmser.serty.top'], allowedHosts: ['rmser.serty.top'],
} }
}) })