mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
0302-отрефакторил в нормальный вид на мобилу и десктоп
сразу выкинул пути в импортах и добавил алиас для корня
This commit is contained in:
12451
rmser-view/project_context.md
Normal file
12451
rmser-view/project_context.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
30
rmser-view/src/app/Providers.tsx
Normal file
30
rmser-view/src/app/Providers.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
33
rmser-view/src/modules/desktop/DesktopApp.tsx
Normal file
33
rmser-view/src/modules/desktop/DesktopApp.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={[
|
||||
@@ -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;
|
||||
|
||||
90
rmser-view/src/modules/mobile/MobileApp.tsx
Normal file
90
rmser-view/src/modules/mobile/MobileApp.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
@@ -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";
|
||||
}}
|
||||
>
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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="Закрыть доступ?"
|
||||
@@ -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",
|
||||
@@ -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 }>();
|
||||
@@ -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 }>();
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Result, Button } from "antd";
|
||||
|
||||
// Страница-заглушка для режима технического обслуживания
|
||||
const MaintenancePage = () => (
|
||||
<div
|
||||
style={{
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -39,7 +39,7 @@ import type {
|
||||
InvoiceDetails,
|
||||
GetPhotosResponse,
|
||||
ServerShort
|
||||
} from './types';
|
||||
} from '../types';
|
||||
|
||||
// Интерфейс для ответа метода инициализации десктопной авторизации
|
||||
export interface InitDesktopAuthResponse {
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
ProductSearchResult,
|
||||
ProductContainer,
|
||||
Recommendation,
|
||||
} from "../../services/types";
|
||||
} from "@/shared/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
ProductSearchResult,
|
||||
ProductContainer,
|
||||
ProductMatch,
|
||||
} from "../../services/types";
|
||||
} from "../../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -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>({
|
||||
@@ -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 если не загружено */
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DraftItem } from '../services/types';
|
||||
import type { DraftItem } from '../types';
|
||||
|
||||
/**
|
||||
* Пересчитывает значения полей элемента черновика на основе измененного поля.
|
||||
@@ -16,6 +16,12 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user