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 { SessionGuard } from "./components/layout/SessionGuard";
import {
BrowserRouter,
Routes,
Route,
Navigate,
useLocation,
} from "react-router-dom";
import { Result, Button, Spin } from "antd";
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";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Result, Button } from "antd";
import { Providers } from "@/app/Providers";
import { usePlatform } from "@/shared/hooks/usePlatform";
import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "@/shared/api";
import { MobileBrowserStub } from "@/modules/desktop/pages/auth/MobileBrowserStub";
import { DesktopApp } from "@/modules/desktop/DesktopApp"; // Используем модуль
import { MobileApp } from "@/modules/mobile/MobileApp";
import MaintenancePage from "@/modules/mobile/pages/MaintenancePage";
// Компонент-заглушка для внешних браузеров
const NotInTelegramScreen = () => (
<div
style={{
@@ -42,7 +23,7 @@ const NotInTelegramScreen = () => (
<Result
status="warning"
title="Доступ ограничен"
subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot для корректной работы."
subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot."
extra={
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
Перейти в бота
@@ -52,56 +33,15 @@ const NotInTelegramScreen = () => (
</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 [isUnauthorized, setIsUnauthorized] = useState(false);
const [isMaintenance, setIsMaintenance] = useState(false);
const [userRole, setUserRole] = useState<UserRole | null>(null);
const [isLoadingRole, setIsLoadingRole] = useState(true);
const tg = window.Telegram?.WebApp;
const platform = usePlatform();
const location = useLocation();
const { activeServer, fetchServers } = useServerStore();
// Проверяем, есть ли данные от Telegram
const isInTelegram = !!tg?.initData;
// Проверяем, находимся ли мы на десктопном роуте
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]);
// Простая проверка: если URL начинается с /web или мы в обычном браузере на десктопе
const isDesktopRoute = window.location.pathname.startsWith("/web");
useEffect(() => {
const handleUnauthorized = () => setIsUnauthorized(true);
@@ -109,9 +49,7 @@ const AppContent = () => {
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
if (tg) {
tg.expand();
}
if (tg) tg.expand();
return () => {
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
@@ -119,39 +57,7 @@ const AppContent = () => {
};
}, [tg]);
// Показываем лоадер пока загружается роль
if (isLoadingRole) {
return (
<div
style={{
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
}}
>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
// Если открыто не в Telegram и это не десктопный роут — блокируем всё
if (!isInTelegram && !isDesktopRoute) {
return <NotInTelegramScreen />;
}
// Если это десктопный роут и платформа - мобильный браузер
if (isDesktopRoute && platform === "MobileBrowser") {
return <MobileBrowserStub />;
}
// Заглушка для операторов (только для мобильной версии в Telegram)
if (userRole === 'OPERATOR' && isInTelegram) {
return <OperatorRestricted serverName={activeServer?.name} />;
}
// Если бэкенд вернул 401
// 1. Глобальные ошибки (401, 503)
if (isUnauthorized) {
return (
<div
@@ -165,56 +71,50 @@ const AppContent = () => {
<Result
status="403"
title="Ошибка доступа"
subTitle="Не удалось подтвердить вашу личность. Попробуйте обновить страницу внутри Telegram."
subTitle="Не удалось подтвердить вашу личность."
/>
</div>
);
}
// Если бэкенд вернул 503 (режим технического обслуживания)
if (isMaintenance) {
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 (
<Routes>
{/* Мобильные роуты (существующие) */}
<Route path="/" element={<AppLayout />}>
<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>
<Route path="/web/*" element={<DesktopApp />} />
<Route path="*" element={<NotInTelegramScreen />} />
</Routes>
);
};
// Главный компонент-обертка
function App() {
return (
<Providers>
<BrowserRouter>
<SessionGuard>
<AppContent />
</SessionGuard>
<AppContent />
</BrowserRouter>
</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 { useNavigate } from "react-router-dom";
import { Spin } from "antd";
import { api } from "../../services/api";
import { useAuthStore } from "../../stores/authStore";
import { api } from "@/shared/api";
import { useAuthStore } from "@/shared/stores/authStore";
interface SessionGuardProps {
children: React.ReactNode;

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,10 @@ 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 "../../../components/invoices/DraftVerificationModal";
import { api } from "../../../services/api";
import { useServerStore } from "../../../stores/serverStore";
import type { UnifiedInvoice } from "../../../services/types";
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;

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

View File

@@ -7,16 +7,10 @@ interface Props {
loading?: boolean;
}
/**
* Компонент заглушки для операторов.
* Отображается вместо основного интерфейса приложения для пользователей с ролью OPERATOR.
* Операторы могут загружать фото накладных только через Telegram-бота.
*/
const OperatorRestricted: React.FC<Props> = ({
serverName,
loading = false,
}) => {
// Показываем лоадер пока идёт загрузка настроек
if (loading) {
return (
<div
@@ -75,7 +69,6 @@ const OperatorRestricted: React.FC<Props> = ({
icon={<CameraOutlined />}
size="large"
onClick={() => {
// Открываем Telegram-бота
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,
} from "@ant-design/icons";
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 type { ReceiptPhoto, PhotoStatus } from "../../services/types";
import type { ReceiptPhoto, PhotoStatus } from "@/shared/types";
export const PhotoStorageTab: React.FC = () => {
const [page, setPage] = useState(1);
@@ -29,7 +29,7 @@ export const PhotoStorageTab: React.FC = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ["photos", page],
queryFn: () => api.getPhotos(page, 18), // 18 - удобно делится на 2, 3, 6 колонок
queryFn: () => api.getPhotos(page, 18),
});
const deleteMutation = useMutation({
@@ -39,12 +39,9 @@ export const PhotoStorageTab: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ["photos"] });
message.success("Фото удалено");
},
// Исправленная типизация:
onError: (error: AxiosError<{ error: string }>) => {
if (error.response?.status === 409) {
message.warning(
"Это фото связано с черновиком. Используйте кнопку 'Удалить' с подтверждением."
);
message.warning("Это фото связано с черновиком.");
} else {
message.error(error.response?.data?.error || "Ошибка удаления");
}
@@ -53,13 +50,8 @@ export const PhotoStorageTab: React.FC = () => {
const regenerateMutation = useMutation({
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
onSuccess: () => {
message.success("Черновик восстановлен");
// Можно редиректить, но пока просто обновим список
},
onError: () => {
message.error("Ошибка восстановления");
},
onSuccess: () => message.success("Черновик восстановлен"),
onError: () => message.error("Ошибка восстановления"),
});
const getStatusTag = (status: PhotoStatus) => {
@@ -143,14 +135,13 @@ export const PhotoStorageTab: React.FC = () => {
</Tooltip>
) : (
<span />
), // Placeholder для выравнивания
),
photo.can_delete ? (
<Popconfirm
title="Удалить фото?"
description={
photo.status === "HAS_DRAFT"
? "Внимание! Черновик тоже будет удален."
? "Черновик тоже будет удален."
: "Восстановить будет невозможно."
}
onConfirm={() =>
@@ -200,7 +191,6 @@ export const PhotoStorageTab: React.FC = () => {
</Card>
))}
</div>
<Pagination
current={page}
total={data.total}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from "react";
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 = () => {
const { id: draftId } = useParams<{ id: string }>();

View File

@@ -1,7 +1,6 @@
import React from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { InvoiceViewer } from "../components/invoices/InvoiceViewer";
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 }>();

View File

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

View File

@@ -1,8 +1,8 @@
import React from "react";
import { Spin, Alert } from "antd";
import { useOcr } from "../hooks/useOcr";
import { AddMatchForm } from "../components/ocr/AddMatchForm";
import { MatchList } from "../components/ocr/MatchList";
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 {
@@ -18,10 +18,8 @@ export const OcrLearning: React.FC = () => {
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);
@@ -62,10 +60,8 @@ export const OcrLearning: React.FC = () => {
<AddMatchForm
catalog={catalog}
unmatched={unmatched}
// Передаем containerId
onSave={(raw, prodId, qty, contId) => {
if (currentEditingMatch) {
// Обновление существующей связи
createMatch({
raw_name: raw,
product_id: prodId,
@@ -74,7 +70,6 @@ export const OcrLearning: React.FC = () => {
});
setEditingMatch(null);
} else {
// Создание новой связи
createMatch({
raw_name: raw,
product_id: prodId,

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ import type {
ProductSearchResult,
ProductContainer,
Recommendation,
} from "../../services/types";
} from "@/shared/types";
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 { useNavigate } from "react-router-dom";
import { DragDropContext, Droppable } from "@hello-pangea/dnd";
import { api } from "../../services/api";
import { api } from "../../api";
import { DraftItemRow } from "./DraftItemRow";
import type { UpdateDraftItemRequest, DraftItem } from "../../services/types";
import type { UpdateDraftItemRequest, DraftItem } from "../../types";
const { Text } = Typography;
@@ -114,7 +114,7 @@ export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
};
const invalidItemsCount =
draft?.items.filter((i) => !i.product_id).length || 0;
draft?.items.filter((i: DraftItem) => !i.product_id).length || 0;
return (
<Modal
@@ -191,7 +191,7 @@ export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
<Droppable droppableId="modal-verification-list">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{draft.items.map((item, index) => (
{draft.items.map((item: DraftItem, index: number) => (
<DraftItemRow
key={item.id}
item={item}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import {
UndoOutlined,
} from "@ant-design/icons";
import * as XLSX from "xlsx";
import { apiClient } from "../../services/api";
import { apiClient } from "../api";
interface ExcelPreviewModalProps {
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,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,

View File

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