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 { useEffect, useState } from "react";
|
||||||
import { SessionGuard } from "./components/layout/SessionGuard";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import {
|
import { Result, Button } from "antd";
|
||||||
BrowserRouter,
|
import { Providers } from "@/app/Providers";
|
||||||
Routes,
|
import { usePlatform } from "@/shared/hooks/usePlatform";
|
||||||
Route,
|
import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "@/shared/api";
|
||||||
Navigate,
|
import { MobileBrowserStub } from "@/modules/desktop/pages/auth/MobileBrowserStub";
|
||||||
useLocation,
|
import { DesktopApp } from "@/modules/desktop/DesktopApp"; // Используем модуль
|
||||||
} from "react-router-dom";
|
import { MobileApp } from "@/modules/mobile/MobileApp";
|
||||||
import { Result, Button, Spin } from "antd";
|
import MaintenancePage from "@/modules/mobile/pages/MaintenancePage";
|
||||||
import { Providers } from "./components/layout/Providers";
|
|
||||||
import { AppLayout } from "./components/layout/AppLayout";
|
|
||||||
import { OcrLearning } from "./pages/OcrLearning";
|
|
||||||
import { InvoiceDraftPage } from "./pages/InvoiceDraftPage";
|
|
||||||
import { InvoiceViewPage } from "./pages/InvoiceViewPage";
|
|
||||||
import { DraftsList } from "./pages/DraftsList";
|
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
|
||||||
import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api";
|
|
||||||
import MaintenancePage from "./pages/MaintenancePage";
|
|
||||||
import { usePlatform } from "./hooks/usePlatform";
|
|
||||||
import { useAuthStore } from "./stores/authStore";
|
|
||||||
import { useServerStore } from "./stores/serverStore";
|
|
||||||
import { DesktopAuthScreen } from "./pages/desktop/auth/DesktopAuthScreen";
|
|
||||||
import { MobileBrowserStub } from "./pages/desktop/auth/MobileBrowserStub";
|
|
||||||
import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout";
|
|
||||||
import { InvoicesDashboard } from "./pages/desktop/dashboard/InvoicesDashboard";
|
|
||||||
import OperatorRestricted from "./components/OperatorRestricted";
|
|
||||||
import type { UserRole } from "./services/types";
|
|
||||||
|
|
||||||
// Компонент-заглушка для внешних браузеров
|
|
||||||
const NotInTelegramScreen = () => (
|
const NotInTelegramScreen = () => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -42,7 +23,7 @@ const NotInTelegramScreen = () => (
|
|||||||
<Result
|
<Result
|
||||||
status="warning"
|
status="warning"
|
||||||
title="Доступ ограничен"
|
title="Доступ ограничен"
|
||||||
subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot для корректной работы."
|
subTitle="Пожалуйста, откройте это приложение через официального Telegram бота @RmserBot."
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
|
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
|
||||||
Перейти в бота
|
Перейти в бота
|
||||||
@@ -52,56 +33,15 @@ const NotInTelegramScreen = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Protected Route для десктопной версии
|
|
||||||
const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { isAuthenticated } = useAuthStore();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to="/web" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Внутренний компонент с логикой, которая требует контекста роутера
|
|
||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||||
const [isMaintenance, setIsMaintenance] = useState(false);
|
const [isMaintenance, setIsMaintenance] = useState(false);
|
||||||
const [userRole, setUserRole] = useState<UserRole | null>(null);
|
|
||||||
const [isLoadingRole, setIsLoadingRole] = useState(true);
|
|
||||||
const tg = window.Telegram?.WebApp;
|
const tg = window.Telegram?.WebApp;
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const location = useLocation();
|
|
||||||
const { activeServer, fetchServers } = useServerStore();
|
|
||||||
|
|
||||||
// Проверяем, есть ли данные от Telegram
|
|
||||||
const isInTelegram = !!tg?.initData;
|
const isInTelegram = !!tg?.initData;
|
||||||
|
// Простая проверка: если URL начинается с /web или мы в обычном браузере на десктопе
|
||||||
// Проверяем, находимся ли мы на десктопном роуте
|
const isDesktopRoute = window.location.pathname.startsWith("/web");
|
||||||
const isDesktopRoute = location.pathname.startsWith("/web");
|
|
||||||
|
|
||||||
// Загружаем роль пользователя и список серверов при монтировании
|
|
||||||
useEffect(() => {
|
|
||||||
const loadUserData = async () => {
|
|
||||||
try {
|
|
||||||
// Загружаем список серверов (там есть информация о роли)
|
|
||||||
await fetchServers();
|
|
||||||
|
|
||||||
// Если есть активный сервер, получаем роль из него
|
|
||||||
const currentServer = useServerStore.getState().activeServer;
|
|
||||||
if (currentServer) {
|
|
||||||
setUserRole(currentServer.role);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при загрузке данных пользователя:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingRole(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadUserData();
|
|
||||||
}, [fetchServers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUnauthorized = () => setIsUnauthorized(true);
|
const handleUnauthorized = () => setIsUnauthorized(true);
|
||||||
@@ -109,9 +49,7 @@ const AppContent = () => {
|
|||||||
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||||
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
|
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
|
||||||
|
|
||||||
if (tg) {
|
if (tg) tg.expand();
|
||||||
tg.expand();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||||
@@ -119,39 +57,7 @@ const AppContent = () => {
|
|||||||
};
|
};
|
||||||
}, [tg]);
|
}, [tg]);
|
||||||
|
|
||||||
// Показываем лоадер пока загружается роль
|
// 1. Глобальные ошибки (401, 503)
|
||||||
if (isLoadingRole) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "#f5f5f5",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spin size="large" tip="Загрузка..." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если открыто не в Telegram и это не десктопный роут — блокируем всё
|
|
||||||
if (!isInTelegram && !isDesktopRoute) {
|
|
||||||
return <NotInTelegramScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если это десктопный роут и платформа - мобильный браузер
|
|
||||||
if (isDesktopRoute && platform === "MobileBrowser") {
|
|
||||||
return <MobileBrowserStub />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Заглушка для операторов (только для мобильной версии в Telegram)
|
|
||||||
if (userRole === 'OPERATOR' && isInTelegram) {
|
|
||||||
return <OperatorRestricted serverName={activeServer?.name} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если бэкенд вернул 401
|
|
||||||
if (isUnauthorized) {
|
if (isUnauthorized) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -165,56 +71,50 @@ const AppContent = () => {
|
|||||||
<Result
|
<Result
|
||||||
status="403"
|
status="403"
|
||||||
title="Ошибка доступа"
|
title="Ошибка доступа"
|
||||||
subTitle="Не удалось подтвердить вашу личность. Попробуйте обновить страницу внутри Telegram."
|
subTitle="Не удалось подтвердить вашу личность."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если бэкенд вернул 503 (режим технического обслуживания)
|
|
||||||
if (isMaintenance) {
|
if (isMaintenance) {
|
||||||
return <MaintenancePage />;
|
return <MaintenancePage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. ВЕТКА MOBILE (TELEGRAM)
|
||||||
|
// Рендерим MobileApp напрямую, чтобы его внутренний Router работал от корня
|
||||||
|
if (isInTelegram) {
|
||||||
|
return <MobileApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ПРОВЕРКИ ОКРУЖЕНИЯ ДЛЯ WEB
|
||||||
|
if (!isDesktopRoute && !isInTelegram) {
|
||||||
|
// Если открыли корень / в браузере — редирект на /web (точку входа десктопа)
|
||||||
|
// Но если это мобильный браузер — покажем заглушку ниже
|
||||||
|
if (window.location.pathname === "/") {
|
||||||
|
return <Navigate to="/web" replace />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDesktopRoute && platform === "MobileBrowser") {
|
||||||
|
return <MobileBrowserStub />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ВЕТКА DESKTOP (BROWSER)
|
||||||
|
// Оборачиваем DesktopApp в Route с /*, чтобы он перехватывал все пути, начинающиеся с /web
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Мобильные роуты (существующие) */}
|
<Route path="/web/*" element={<DesktopApp />} />
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="*" element={<NotInTelegramScreen />} />
|
||||||
<Route index element={<Navigate to="/invoices" replace />} />
|
|
||||||
<Route path="ocr" element={<OcrLearning />} />
|
|
||||||
<Route path="invoices" element={<DraftsList />} />
|
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Роуты для детальных страниц накладных (без AppLayout - на весь экран) */}
|
|
||||||
<Route path="/invoice/draft/:id" element={<InvoiceDraftPage />} />
|
|
||||||
<Route path="/invoice/view/:id" element={<InvoiceViewPage />} />
|
|
||||||
|
|
||||||
{/* Десктопные роуты */}
|
|
||||||
<Route path="/web" element={<DesktopAuthScreen />} />
|
|
||||||
<Route path="/web" element={<DesktopLayout />}>
|
|
||||||
<Route
|
|
||||||
path="dashboard"
|
|
||||||
element={
|
|
||||||
<ProtectedDesktopRoute>
|
|
||||||
<InvoicesDashboard />
|
|
||||||
</ProtectedDesktopRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Главный компонент-обертка
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<SessionGuard>
|
<AppContent />
|
||||||
<AppContent />
|
|
||||||
</SessionGuard>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Providers>
|
</Providers>
|
||||||
);
|
);
|
||||||
|
|||||||
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 { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Spin } from "antd";
|
import { Spin } from "antd";
|
||||||
import { api } from "../../services/api";
|
import { api } from "@/shared/api";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "@/shared/stores/authStore";
|
||||||
|
|
||||||
interface SessionGuardProps {
|
interface SessionGuardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Layout, Space, Avatar, Dropdown, Select } from "antd";
|
import { Layout, Space, Avatar, Dropdown, Select } from "antd";
|
||||||
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
|
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "@/shared/stores/authStore";
|
||||||
import { useServerStore } from "../../stores/serverStore";
|
import { useServerStore } from "@/shared/stores/serverStore";
|
||||||
import { api } from "../../services/api";
|
import { api } from "@/shared/api";
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
@@ -3,9 +3,9 @@ import { Card, Typography, Spin, Alert, message, Button } from "antd";
|
|||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { SendOutlined, ReloadOutlined } from "@ant-design/icons";
|
import { SendOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useWebSocket } from "../../../hooks/useWebSocket";
|
import { useWebSocket } from "@/shared/hooks/useWebSocket";
|
||||||
import { useAuthStore } from "../../../stores/authStore";
|
import { useAuthStore } from "@/shared/stores/authStore";
|
||||||
import { api } from "../../../services/api";
|
import { api } from "@/shared/api";
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
@@ -43,7 +43,10 @@ export const DesktopAuthScreen: React.FC = () => {
|
|||||||
setQrLink(data.qr_url);
|
setQrLink(data.qr_url);
|
||||||
console.log("🔄 Session refreshed:", data.session_id);
|
console.log("🔄 Session refreshed:", data.session_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Неизвестная ошибка при обновлении QR";
|
const errorMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Неизвестная ошибка при обновлении QR";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error("❌ Refresh error:", err);
|
console.error("❌ Refresh error:", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,14 +81,14 @@ export const DesktopAuthScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Создаём сессию на сервере (установка HttpOnly куки)
|
// Создаём сессию на сервере (установка HttpOnly куки)
|
||||||
await api.createSession();
|
await api.createSession();
|
||||||
|
|
||||||
// Устанавливаем токен и данные пользователя
|
// Устанавливаем токен и данные пользователя
|
||||||
setToken(token);
|
setToken(token);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
message.success("Вход выполнен!");
|
message.success("Вход выполнен!");
|
||||||
navigate("/web/dashboard");
|
navigate("/web/dashboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
handleAuthSuccess();
|
handleAuthSuccess();
|
||||||
}
|
}
|
||||||
}, [lastMessage, setToken, setUser, navigate]);
|
}, [lastMessage, setToken, setUser, navigate]);
|
||||||
@@ -102,10 +105,7 @@ export const DesktopAuthScreen: React.FC = () => {
|
|||||||
backgroundColor: "#f0f2f5",
|
backgroundColor: "#f0f2f5",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Spin
|
<Spin size="large" tip="Обновление QR..." />
|
||||||
size="large"
|
|
||||||
tip="Обновление QR..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Result, Button } from 'antd';
|
import { Result, Button } from "antd";
|
||||||
import { MobileOutlined } from '@ant-design/icons';
|
import { MobileOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Заглушка для мобильных браузеров
|
* Заглушка для мобильных браузеров
|
||||||
@@ -8,22 +8,22 @@ import { MobileOutlined } from '@ant-design/icons';
|
|||||||
*/
|
*/
|
||||||
export const MobileBrowserStub: React.FC = () => {
|
export const MobileBrowserStub: React.FC = () => {
|
||||||
const handleRedirect = () => {
|
const handleRedirect = () => {
|
||||||
window.location.href = '/';
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
minHeight: '100vh',
|
minHeight: "100vh",
|
||||||
backgroundColor: '#f0f2f5',
|
backgroundColor: "#f0f2f5",
|
||||||
padding: '24px',
|
padding: "24px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Result
|
<Result
|
||||||
icon={<MobileOutlined style={{ fontSize: '72px', color: '#1890ff' }} />}
|
icon={<MobileOutlined style={{ fontSize: "72px", color: "#1890ff" }} />}
|
||||||
title="Десктопная версия недоступна"
|
title="Десктопная версия недоступна"
|
||||||
subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
|
subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
|
||||||
extra={[
|
extra={[
|
||||||
@@ -2,10 +2,10 @@ import React, { useState } from "react";
|
|||||||
import { Typography, Card, List, Empty, Tag, Spin } from "antd";
|
import { Typography, Card, List, Empty, Tag, Spin } from "antd";
|
||||||
import { UploadOutlined } from "@ant-design/icons";
|
import { UploadOutlined } from "@ant-design/icons";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { DraftVerificationModal } from "../../../components/invoices/DraftVerificationModal";
|
import { DraftVerificationModal } from "@/shared/features/invoices/DraftVerificationModal";
|
||||||
import { api } from "../../../services/api";
|
import { api } from "@/shared/api";
|
||||||
import { useServerStore } from "../../../stores/serverStore";
|
import { useServerStore } from "@/shared/stores/serverStore";
|
||||||
import type { UnifiedInvoice } from "../../../services/types";
|
import type { UnifiedInvoice } from "@/shared/types";
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
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
|
<Layout
|
||||||
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
|
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
|
||||||
>
|
>
|
||||||
{/* Верхнюю шапку (Header) удалили для экономии места */}
|
|
||||||
|
|
||||||
<Content
|
<Content
|
||||||
style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }}
|
style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }}
|
||||||
>
|
>
|
||||||
{/* Убрали лишние паддинги вокруг контента для мобилок */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: colorBgContainer,
|
background: colorBgContainer,
|
||||||
minHeight: "100%",
|
minHeight: "100%",
|
||||||
padding: "12px 12px 80px 12px", // Добавили отступ снизу, чтобы контент не перекрывался меню
|
padding: "12px 12px 80px 12px",
|
||||||
borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны
|
borderRadius: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
{/* Нижний Таб-бар */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -7,16 +7,10 @@ interface Props {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Компонент заглушки для операторов.
|
|
||||||
* Отображается вместо основного интерфейса приложения для пользователей с ролью OPERATOR.
|
|
||||||
* Операторы могут загружать фото накладных только через Telegram-бота.
|
|
||||||
*/
|
|
||||||
const OperatorRestricted: React.FC<Props> = ({
|
const OperatorRestricted: React.FC<Props> = ({
|
||||||
serverName,
|
serverName,
|
||||||
loading = false,
|
loading = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Показываем лоадер пока идёт загрузка настроек
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -75,7 +69,6 @@ const OperatorRestricted: React.FC<Props> = ({
|
|||||||
icon={<CameraOutlined />}
|
icon={<CameraOutlined />}
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Открываем Telegram-бота
|
|
||||||
window.location.href = "https://t.me/RmserBot";
|
window.location.href = "https://t.me/RmserBot";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -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,
|
FileTextOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, getStaticUrl } from "../../services/api";
|
import { api, getStaticUrl } from "@/shared/api";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import type { ReceiptPhoto, PhotoStatus } from "../../services/types";
|
import type { ReceiptPhoto, PhotoStatus } from "@/shared/types";
|
||||||
|
|
||||||
export const PhotoStorageTab: React.FC = () => {
|
export const PhotoStorageTab: React.FC = () => {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -29,7 +29,7 @@ export const PhotoStorageTab: React.FC = () => {
|
|||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ["photos", page],
|
queryKey: ["photos", page],
|
||||||
queryFn: () => api.getPhotos(page, 18), // 18 - удобно делится на 2, 3, 6 колонок
|
queryFn: () => api.getPhotos(page, 18),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -39,12 +39,9 @@ export const PhotoStorageTab: React.FC = () => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["photos"] });
|
queryClient.invalidateQueries({ queryKey: ["photos"] });
|
||||||
message.success("Фото удалено");
|
message.success("Фото удалено");
|
||||||
},
|
},
|
||||||
// Исправленная типизация:
|
|
||||||
onError: (error: AxiosError<{ error: string }>) => {
|
onError: (error: AxiosError<{ error: string }>) => {
|
||||||
if (error.response?.status === 409) {
|
if (error.response?.status === 409) {
|
||||||
message.warning(
|
message.warning("Это фото связано с черновиком.");
|
||||||
"Это фото связано с черновиком. Используйте кнопку 'Удалить' с подтверждением."
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
message.error(error.response?.data?.error || "Ошибка удаления");
|
message.error(error.response?.data?.error || "Ошибка удаления");
|
||||||
}
|
}
|
||||||
@@ -53,13 +50,8 @@ export const PhotoStorageTab: React.FC = () => {
|
|||||||
|
|
||||||
const regenerateMutation = useMutation({
|
const regenerateMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
|
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => message.success("Черновик восстановлен"),
|
||||||
message.success("Черновик восстановлен");
|
onError: () => message.error("Ошибка восстановления"),
|
||||||
// Можно редиректить, но пока просто обновим список
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error("Ошибка восстановления");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusTag = (status: PhotoStatus) => {
|
const getStatusTag = (status: PhotoStatus) => {
|
||||||
@@ -143,14 +135,13 @@ export const PhotoStorageTab: React.FC = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
), // Placeholder для выравнивания
|
),
|
||||||
|
|
||||||
photo.can_delete ? (
|
photo.can_delete ? (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Удалить фото?"
|
title="Удалить фото?"
|
||||||
description={
|
description={
|
||||||
photo.status === "HAS_DRAFT"
|
photo.status === "HAS_DRAFT"
|
||||||
? "Внимание! Черновик тоже будет удален."
|
? "Черновик тоже будет удален."
|
||||||
: "Восстановить будет невозможно."
|
: "Восстановить будет невозможно."
|
||||||
}
|
}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
@@ -200,7 +191,6 @@ export const PhotoStorageTab: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
current={page}
|
current={page}
|
||||||
total={data.total}
|
total={data.total}
|
||||||
@@ -4,9 +4,8 @@ import { SyncOutlined } from "@ant-design/icons";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import "dayjs/locale/ru";
|
import "dayjs/locale/ru";
|
||||||
import type { UserRole } from "../../services/types";
|
import type { UserRole } from "@/shared/types";
|
||||||
|
|
||||||
// Настройка dayjs для русской локали и относительного времени
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
dayjs.locale("ru");
|
dayjs.locale("ru");
|
||||||
|
|
||||||
@@ -25,18 +24,12 @@ export const SyncBlock: React.FC<SyncBlockProps> = ({
|
|||||||
onSync,
|
onSync,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Проверяем, есть ли права на синхронизацию
|
|
||||||
const canSync = userRole === "OWNER" || userRole === "ADMIN";
|
const canSync = userRole === "OWNER" || userRole === "ADMIN";
|
||||||
|
|
||||||
// Форматируем дату последней синхронизации
|
|
||||||
const formatLastSync = (dateStr: string | null): string => {
|
const formatLastSync = (dateStr: string | null): string => {
|
||||||
if (!dateStr) {
|
if (!dateStr) return "Никогда";
|
||||||
return "Никогда";
|
|
||||||
}
|
|
||||||
const date = dayjs(dateStr);
|
const date = dayjs(dateStr);
|
||||||
const formatted = date.format("DD.MM.YYYY HH:mm");
|
return `${date.format("DD.MM.YYYY HH:mm")} (${date.fromNow()})`;
|
||||||
const relative = date.fromNow();
|
|
||||||
return `${formatted} (${relative})`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +46,6 @@ export const SyncBlock: React.FC<SyncBlockProps> = ({
|
|||||||
Последняя синхронизация: {formatLastSync(lastSyncAt)}
|
Последняя синхронизация: {formatLastSync(lastSyncAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tooltip title={!canSync ? "Только для администраторов" : undefined}>
|
<Tooltip title={!canSync ? "Только для администраторов" : undefined}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -66,7 +58,6 @@ export const SyncBlock: React.FC<SyncBlockProps> = ({
|
|||||||
Синхронизировать
|
Синхронизировать
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
Загружает справочники, накладные и пересчитывает рекомендации
|
Загружает справочники, накладные и пересчитывает рекомендации
|
||||||
</Text>
|
</Text>
|
||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
} from "antd";
|
} from "antd";
|
||||||
import { DeleteOutlined, UserOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "../../services/api";
|
import { api } from "@/shared/api";
|
||||||
import type { ServerUser, UserRole } from "../../services/types";
|
import type { ServerUser, UserRole } from "@/shared/types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ interface Props {
|
|||||||
export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Запрос списка пользователей
|
|
||||||
const {
|
const {
|
||||||
data: users,
|
data: users,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -35,7 +34,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
|||||||
queryFn: api.getUsers,
|
queryFn: api.getUsers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Мутация изменения роли
|
|
||||||
const updateRoleMutation = useMutation({
|
const updateRoleMutation = useMutation({
|
||||||
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
|
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
|
||||||
api.updateUserRole(userId, newRole),
|
api.updateUserRole(userId, newRole),
|
||||||
@@ -48,7 +46,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Мутация удаления пользователя
|
|
||||||
const removeUserMutation = useMutation({
|
const removeUserMutation = useMutation({
|
||||||
mutationFn: (userId: string) => api.removeUser(userId),
|
mutationFn: (userId: string) => api.removeUser(userId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -60,7 +57,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Хелперы для UI
|
|
||||||
const getRoleColor = (role: UserRole) => {
|
const getRoleColor = (role: UserRole) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case "OWNER":
|
case "OWNER":
|
||||||
@@ -87,12 +83,11 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Проверка прав на удаление
|
|
||||||
const canDelete = (targetUser: ServerUser) => {
|
const canDelete = (targetUser: ServerUser) => {
|
||||||
if (targetUser.is_me) return false; // Себя удалить нельзя
|
if (targetUser.is_me) return false;
|
||||||
if (targetUser.role === "OWNER") return false; // Владельца удалить нельзя
|
if (targetUser.role === "OWNER") return false;
|
||||||
if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN")
|
if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN")
|
||||||
return false; // Админ не может удалить админа
|
return false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,16 +110,14 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
|||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="Приглашение сотрудников"
|
message="Приглашение сотрудников"
|
||||||
description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение. Ссылку можно сгенерировать в Telegram-боте в меню «Управление сервером»."
|
description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<List
|
<List
|
||||||
itemLayout="horizontal"
|
itemLayout="horizontal"
|
||||||
dataSource={users || []}
|
dataSource={users || []}
|
||||||
renderItem={(user) => (
|
renderItem={(user) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
actions={[
|
actions={[
|
||||||
// Селектор роли (только для Владельца и не для себя)
|
|
||||||
currentUserRole === "OWNER" && !user.is_me ? (
|
currentUserRole === "OWNER" && !user.is_me ? (
|
||||||
<Select
|
<Select
|
||||||
key="role-select"
|
key="role-select"
|
||||||
@@ -148,8 +141,6 @@ export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
|||||||
{getRoleName(user.role)}
|
{getRoleName(user.role)}
|
||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
|
|
||||||
// Кнопка удаления
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
key="delete"
|
key="delete"
|
||||||
title="Закрыть доступ?"
|
title="Закрыть доступ?"
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/pages/DraftsList.tsx
|
|
||||||
|
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -27,8 +25,8 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import "dayjs/locale/ru";
|
import "dayjs/locale/ru";
|
||||||
import { api } from "../services/api";
|
import { api } from "@/shared/api";
|
||||||
import type { UnifiedInvoice } from "../services/types";
|
import type { UnifiedInvoice } from "@/shared/types";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -161,19 +159,15 @@ export const DraftsList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInvoiceClick = (item: UnifiedInvoice) => {
|
const handleInvoiceClick = (item: UnifiedInvoice) => {
|
||||||
// Если это черновик - используем его ID
|
|
||||||
if (item.type === "DRAFT") {
|
if (item.type === "DRAFT") {
|
||||||
navigate("/invoice/draft/" + item.id);
|
navigate("/invoice/draft/" + item.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если это синхронизированная накладная
|
|
||||||
if (item.type === "SYNCED") {
|
if (item.type === "SYNCED") {
|
||||||
// Если у нее есть ссылка на черновик (пришла с бэка) - открываем редактор черновика
|
|
||||||
if (item.draft_id) {
|
if (item.draft_id) {
|
||||||
navigate("/invoice/draft/" + item.draft_id);
|
navigate("/invoice/draft/" + item.draft_id);
|
||||||
} else {
|
} else {
|
||||||
// Иначе просто просмотр
|
|
||||||
navigate("/invoice/view/" + item.id);
|
navigate("/invoice/view/" + item.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,17 +199,14 @@ export const DraftsList: React.FC = () => {
|
|||||||
const dateA = dayjs(getItemDate(a)).startOf("day");
|
const dateA = dayjs(getItemDate(a)).startOf("day");
|
||||||
const dateB = dayjs(getItemDate(b)).startOf("day");
|
const dateB = dayjs(getItemDate(b)).startOf("day");
|
||||||
|
|
||||||
// Сначала по дате DESC
|
|
||||||
if (!dateA.isSame(dateB)) {
|
if (!dateA.isSame(dateB)) {
|
||||||
return dateB.valueOf() - dateA.valueOf();
|
return dateB.valueOf() - dateA.valueOf();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Внутри дня: DRAFT < SYNCED
|
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
return a.type === "DRAFT" ? -1 : 1;
|
return a.type === "DRAFT" ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Внутри типа: по номеру DESC
|
|
||||||
return (b.document_number || "").localeCompare(
|
return (b.document_number || "").localeCompare(
|
||||||
a.document_number || "",
|
a.document_number || "",
|
||||||
"ru",
|
"ru",
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { DraftEditor } from "../components/invoices/DraftEditor";
|
import { DraftEditor } from "@/shared/features/invoices/DraftEditor";
|
||||||
|
|
||||||
export const InvoiceDraftPage: React.FC = () => {
|
export const InvoiceDraftPage: React.FC = () => {
|
||||||
const { id: draftId } = useParams<{ id: string }>();
|
const { id: draftId } = useParams<{ id: string }>();
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { InvoiceViewer } from "@/shared/features/invoices/InvoiceViewer";
|
||||||
import { InvoiceViewer } from "../components/invoices/InvoiceViewer";
|
|
||||||
|
|
||||||
export const InvoiceViewPage: React.FC = () => {
|
export const InvoiceViewPage: React.FC = () => {
|
||||||
const { id: invoiceId } = useParams<{ id: string }>();
|
const { id: invoiceId } = useParams<{ id: string }>();
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Result, Button } from "antd";
|
import { Result, Button } from "antd";
|
||||||
|
|
||||||
// Страница-заглушка для режима технического обслуживания
|
|
||||||
const MaintenancePage = () => (
|
const MaintenancePage = () => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Spin, Alert } from "antd";
|
import { Spin, Alert } from "antd";
|
||||||
import { useOcr } from "../hooks/useOcr";
|
import { useOcr } from "@/shared/hooks/useOcr";
|
||||||
import { AddMatchForm } from "../components/ocr/AddMatchForm";
|
import { AddMatchForm } from "@/shared/features/ocr/AddMatchForm";
|
||||||
import { MatchList } from "../components/ocr/MatchList";
|
import { MatchList } from "@/shared/features/ocr/MatchList";
|
||||||
|
|
||||||
export const OcrLearning: React.FC = () => {
|
export const OcrLearning: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -18,10 +18,8 @@ export const OcrLearning: React.FC = () => {
|
|||||||
deleteUnmatched,
|
deleteUnmatched,
|
||||||
} = useOcr();
|
} = useOcr();
|
||||||
|
|
||||||
// Состояние для редактирования
|
|
||||||
const [editingMatch, setEditingMatch] = React.useState<string | null>(null);
|
const [editingMatch, setEditingMatch] = React.useState<string | null>(null);
|
||||||
|
|
||||||
// Найти редактируемую связь
|
|
||||||
const currentEditingMatch = React.useMemo(() => {
|
const currentEditingMatch = React.useMemo(() => {
|
||||||
if (!editingMatch) return undefined;
|
if (!editingMatch) return undefined;
|
||||||
return matches.find((match) => match.raw_name === editingMatch);
|
return matches.find((match) => match.raw_name === editingMatch);
|
||||||
@@ -62,10 +60,8 @@ export const OcrLearning: React.FC = () => {
|
|||||||
<AddMatchForm
|
<AddMatchForm
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
unmatched={unmatched}
|
unmatched={unmatched}
|
||||||
// Передаем containerId
|
|
||||||
onSave={(raw, prodId, qty, contId) => {
|
onSave={(raw, prodId, qty, contId) => {
|
||||||
if (currentEditingMatch) {
|
if (currentEditingMatch) {
|
||||||
// Обновление существующей связи
|
|
||||||
createMatch({
|
createMatch({
|
||||||
raw_name: raw,
|
raw_name: raw,
|
||||||
product_id: prodId,
|
product_id: prodId,
|
||||||
@@ -74,7 +70,6 @@ export const OcrLearning: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setEditingMatch(null);
|
setEditingMatch(null);
|
||||||
} else {
|
} else {
|
||||||
// Создание новой связи
|
|
||||||
createMatch({
|
createMatch({
|
||||||
raw_name: raw,
|
raw_name: raw,
|
||||||
product_id: prodId,
|
product_id: prodId,
|
||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "../services/api";
|
import { api } from "@/shared/api";
|
||||||
import type { UserSettings } from "../services/types";
|
import type { UserSettings, Store } from "@/shared/types";
|
||||||
import { TeamList } from "../components/settings/TeamList";
|
import { TeamList } from "../components/settings/TeamList";
|
||||||
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
|
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
|
||||||
import { SyncBlock } from "../components/settings/SyncBlock";
|
import { SyncBlock } from "../components/settings/SyncBlock";
|
||||||
@@ -36,8 +36,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
// --- Запросы ---
|
|
||||||
|
|
||||||
const settingsQuery = useQuery({
|
const settingsQuery = useQuery({
|
||||||
queryKey: ["settings"],
|
queryKey: ["settings"],
|
||||||
queryFn: api.getSettings,
|
queryFn: api.getSettings,
|
||||||
@@ -60,8 +58,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
staleTime: 1000 * 60 * 10,
|
staleTime: 1000 * 60 * 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Мутации ---
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (vals: UserSettings) => api.updateSettings(vals),
|
mutationFn: (vals: UserSettings) => api.updateSettings(vals),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -95,31 +91,24 @@ export const SettingsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Эффекты ---
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsQuery.data) {
|
if (settingsQuery.data) {
|
||||||
form.setFieldsValue(settingsQuery.data);
|
form.setFieldsValue(settingsQuery.data);
|
||||||
}
|
}
|
||||||
}, [settingsQuery.data, form]);
|
}, [settingsQuery.data, form]);
|
||||||
|
|
||||||
// --- Хендлеры ---
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
console.log("Settings Form Values:", values);
|
|
||||||
saveMutation.mutate({
|
saveMutation.mutate({
|
||||||
...values,
|
...values,
|
||||||
auto_conduct: !!values.auto_conduct,
|
auto_conduct: !!values.auto_conduct,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ошибки валидации
|
// validation errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Рендер ---
|
|
||||||
|
|
||||||
if (settingsQuery.isLoading) {
|
if (settingsQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: "center", padding: 50 }}>
|
<div style={{ textAlign: "center", padding: 50 }}>
|
||||||
@@ -128,7 +117,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Определяем роль текущего пользователя
|
|
||||||
const currentUserRole = settingsQuery.data?.role || "OPERATOR";
|
const currentUserRole = settingsQuery.data?.role || "OPERATOR";
|
||||||
const showTeamSettings =
|
const showTeamSettings =
|
||||||
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
|
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
|
||||||
@@ -157,7 +145,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
placeholder="Не выбрано"
|
placeholder="Не выбрано"
|
||||||
allowClear
|
allowClear
|
||||||
loading={dictQuery.isLoading}
|
loading={dictQuery.isLoading}
|
||||||
options={dictQuery.data?.stores.map((s) => ({
|
options={dictQuery.data?.stores.map((s: Store) => ({
|
||||||
label: s.name,
|
label: s.name,
|
||||||
value: s.id,
|
value: s.id,
|
||||||
}))}
|
}))}
|
||||||
@@ -269,7 +257,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем вкладку с фото (доступна для OWNER)
|
|
||||||
if (currentUserRole === "OWNER") {
|
if (currentUserRole === "OWNER") {
|
||||||
tabsItems.push({
|
tabsItems.push({
|
||||||
key: "photos",
|
key: "photos",
|
||||||
@@ -285,7 +272,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<SettingOutlined /> Настройки
|
<SettingOutlined /> Настройки
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{/* Статистика */}
|
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
@@ -330,7 +316,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Табы настроек */}
|
|
||||||
<Tabs defaultActiveKey="general" items={tabsItems} />
|
<Tabs defaultActiveKey="general" items={tabsItems} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -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,
|
InvoiceDetails,
|
||||||
GetPhotosResponse,
|
GetPhotosResponse,
|
||||||
ServerShort
|
ServerShort
|
||||||
} from './types';
|
} from '../types';
|
||||||
|
|
||||||
// Интерфейс для ответа метода инициализации десктопной авторизации
|
// Интерфейс для ответа метода инициализации десктопной авторизации
|
||||||
export interface InitDesktopAuthResponse {
|
export interface InitDesktopAuthResponse {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Modal, Form, Input, InputNumber, Button, message } from 'antd';
|
import { Modal, Form, Input, InputNumber, Button, message } from "antd";
|
||||||
import { api } from '../../services/api';
|
import { api } from "@/shared/api";
|
||||||
import type { ProductContainer } from '../../services/types';
|
import type { ProductContainer } from "@/shared/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -12,8 +12,12 @@ interface Props {
|
|||||||
onSuccess: (container: ProductContainer) => void;
|
onSuccess: (container: ProductContainer) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateContainerModal: React.FC<Props> = ({
|
export const CreateContainerModal: React.FC<Props> = ({
|
||||||
visible, onCancel, productId, productBaseUnit, onSuccess
|
visible,
|
||||||
|
onCancel,
|
||||||
|
productId,
|
||||||
|
productBaseUnit,
|
||||||
|
onSuccess,
|
||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -22,30 +26,30 @@ export const CreateContainerModal: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 1. Отправляем запрос на БЭКЕНД
|
// 1. Отправляем запрос на БЭКЕНД
|
||||||
const res = await api.createContainer({
|
const res = await api.createContainer({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
count: values.count
|
count: values.count,
|
||||||
});
|
});
|
||||||
|
|
||||||
message.success('Фасовка создана');
|
message.success("Фасовка создана");
|
||||||
|
|
||||||
// 2. БЭКЕНД вернул ID. Теперь мы собираем объект для UI
|
// 2. БЭКЕНД вернул ID. Теперь мы собираем объект для UI
|
||||||
// Мы не придумываем ID сами, мы берем res.container_id
|
// Мы не придумываем ID сами, мы берем res.container_id
|
||||||
const newContainer: ProductContainer = {
|
const newContainer: ProductContainer = {
|
||||||
id: res.container_id, // <--- ID от сервера
|
id: res.container_id, // <--- ID от сервера
|
||||||
name: values.name,
|
name: values.name,
|
||||||
count: values.count
|
count: values.count,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Возвращаем полный объект родителю
|
// 3. Возвращаем полный объект родителю
|
||||||
onSuccess(newContainer);
|
onSuccess(newContainer);
|
||||||
|
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Ошибка создания фасовки');
|
message.error("Ошибка создания фасовки");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -57,28 +61,41 @@ export const CreateContainerModal: React.FC<Props> = ({
|
|||||||
open={visible}
|
open={visible}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="back" onClick={onCancel}>Отмена</Button>,
|
<Button key="back" onClick={onCancel}>
|
||||||
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>
|
Отмена
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleOk}
|
||||||
|
>
|
||||||
Создать
|
Создать
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label="Название"
|
label="Название"
|
||||||
rules={[{ required: true, message: 'Введите название, например 0.5' }]}
|
rules={[
|
||||||
|
{ required: true, message: "Введите название, например 0.5" },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="Например: Бутылка 0.5" />
|
<Input placeholder="Например: Бутылка 0.5" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="count"
|
name="count"
|
||||||
label={`Количество в базовых ед. (${productBaseUnit})`}
|
label={`Количество в базовых ед. (${productBaseUnit})`}
|
||||||
rules={[{ required: true, message: 'Введите коэффициент' }]}
|
rules={[{ required: true, message: "Введите коэффициент" }]}
|
||||||
>
|
>
|
||||||
<InputNumber style={{ width: '100%' }} step={0.001} placeholder="0.5" />
|
<InputNumber
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
step={0.001}
|
||||||
|
placeholder="0.5"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -29,16 +29,20 @@ import {
|
|||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { api, getStaticUrl } from "../../services/api";
|
import { api, getStaticUrl } from "@/shared/api";
|
||||||
import { DraftItemRow } from "./DraftItemRow";
|
import { DraftItemRow } from "./DraftItemRow";
|
||||||
import ExcelPreviewModal from "../common/ExcelPreviewModal";
|
import ExcelPreviewModal from "@/shared/ui/ExcelPreviewModal";
|
||||||
import { useActiveDraftStore } from "../../stores/activeDraftStore";
|
import { useActiveDraftStore } from "@/shared/stores/activeDraftStore";
|
||||||
import type {
|
import type {
|
||||||
DraftItem,
|
DraftItem,
|
||||||
UpdateDraftRequest,
|
UpdateDraftRequest,
|
||||||
CommitDraftRequest,
|
CommitDraftRequest,
|
||||||
ReorderDraftItemsRequest,
|
ReorderDraftItemsRequest,
|
||||||
} from "../../services/types";
|
Store,
|
||||||
|
Supplier,
|
||||||
|
Recommendation,
|
||||||
|
DictionariesResponse,
|
||||||
|
} from "@/shared/types";
|
||||||
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
|
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -111,8 +115,10 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const draft = draftQuery.data;
|
const draft = draftQuery.data;
|
||||||
const stores = dictQuery.data?.stores || [];
|
const stores =
|
||||||
const suppliers = dictQuery.data?.suppliers || [];
|
(dictQuery.data as DictionariesResponse | undefined)?.stores || [];
|
||||||
|
const suppliers =
|
||||||
|
(dictQuery.data as DictionariesResponse | undefined)?.suppliers || [];
|
||||||
|
|
||||||
// Определение типа файла по расширению
|
// Определение типа файла по расширению
|
||||||
const isExcelFile = draft?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/);
|
const isExcelFile = draft?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/);
|
||||||
@@ -134,7 +140,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
const commitMutation = useMutation({
|
const commitMutation = useMutation({
|
||||||
mutationFn: (payload: CommitDraftRequest) =>
|
mutationFn: (payload: CommitDraftRequest) =>
|
||||||
api.commitDraft(draftId, payload),
|
api.commitDraft(draftId, payload),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data: { document_number: string }) => {
|
||||||
message.success(`Накладная ${data.document_number} создана!`);
|
message.success(`Накладная ${data.document_number} создана!`);
|
||||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||||
},
|
},
|
||||||
@@ -202,14 +208,15 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
const totalSum = useMemo(() => {
|
const totalSum = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
items.reduce(
|
items.reduce(
|
||||||
(acc, item) => acc + Number(item.quantity) * Number(item.price),
|
(acc: number, item: DraftItem) =>
|
||||||
|
acc + Number(item.quantity) * Number(item.price),
|
||||||
0
|
0
|
||||||
) || 0
|
) || 0
|
||||||
);
|
);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const invalidItemsCount = useMemo(() => {
|
const invalidItemsCount = useMemo(() => {
|
||||||
return items.filter((i) => !i.product_id).length || 0;
|
return items.filter((i: DraftItem) => !i.product_id).length || 0;
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
// Функция сохранения изменений на сервер
|
// Функция сохранения изменений на сервер
|
||||||
@@ -230,7 +237,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
date_incoming: formValues.date_incoming
|
date_incoming: formValues.date_incoming
|
||||||
? formValues.date_incoming.format("YYYY-MM-DD")
|
? formValues.date_incoming.format("YYYY-MM-DD")
|
||||||
: undefined,
|
: undefined,
|
||||||
items: items.map((item) => ({
|
items: items.map((item: DraftItem) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
product_id: item.product_id ?? "",
|
product_id: item.product_id ?? "",
|
||||||
container_id: item.container_id ?? "",
|
container_id: item.container_id ?? "",
|
||||||
@@ -362,7 +369,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
|
|
||||||
// Подготавливаем payload для API
|
// Подготавливаем payload для API
|
||||||
const reorderPayload: ReorderDraftItemsRequest = {
|
const reorderPayload: ReorderDraftItemsRequest = {
|
||||||
items: items.map((item, index) => ({
|
items: items.map((item: DraftItem, index: number) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
order: index,
|
order: index,
|
||||||
})),
|
})),
|
||||||
@@ -565,7 +572,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
<Select
|
<Select
|
||||||
placeholder="Выберите склад..."
|
placeholder="Выберите склад..."
|
||||||
loading={dictQuery.isLoading}
|
loading={dictQuery.isLoading}
|
||||||
options={stores.map((s) => ({
|
options={stores.map((s: Store) => ({
|
||||||
label: s.name,
|
label: s.name,
|
||||||
value: s.id,
|
value: s.id,
|
||||||
}))}
|
}))}
|
||||||
@@ -582,13 +589,16 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
<Select
|
<Select
|
||||||
placeholder="Поставщик..."
|
placeholder="Поставщик..."
|
||||||
loading={dictQuery.isLoading}
|
loading={dictQuery.isLoading}
|
||||||
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
options={suppliers.map((s: Supplier) => ({
|
||||||
|
label: s.name,
|
||||||
|
value: s.id,
|
||||||
|
}))}
|
||||||
size="small"
|
size="small"
|
||||||
showSearch
|
showSearch
|
||||||
filterOption={(input, option) =>
|
filterOption={(input, option) =>
|
||||||
(option?.label ?? "")
|
String(option?.label ?? "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(input.toLowerCase())
|
.includes(String(input).toLowerCase())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -645,7 +655,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
transition: "background-color 0.2s ease",
|
transition: "background-color 0.2s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{items.map((item: DraftItem, index: number) => (
|
||||||
<DraftItemRow
|
<DraftItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -653,7 +663,11 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
onLocalUpdate={handleItemUpdate}
|
onLocalUpdate={handleItemUpdate}
|
||||||
onDelete={(itemId) => deleteItem(itemId)}
|
onDelete={(itemId) => deleteItem(itemId)}
|
||||||
isUpdating={false}
|
isUpdating={false}
|
||||||
recommendations={recommendationsQuery.data || []}
|
recommendations={
|
||||||
|
(recommendationsQuery.data as
|
||||||
|
| Recommendation[]
|
||||||
|
| undefined) || []
|
||||||
|
}
|
||||||
isReordering={isReordering}
|
isReordering={isReordering}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -26,7 +26,7 @@ import type {
|
|||||||
ProductSearchResult,
|
ProductSearchResult,
|
||||||
ProductContainer,
|
ProductContainer,
|
||||||
Recommendation,
|
Recommendation,
|
||||||
} from "../../services/types";
|
} from "@/shared/types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { DragDropContext, Droppable } from "@hello-pangea/dnd";
|
import { DragDropContext, Droppable } from "@hello-pangea/dnd";
|
||||||
import { api } from "../../services/api";
|
import { api } from "../../api";
|
||||||
import { DraftItemRow } from "./DraftItemRow";
|
import { DraftItemRow } from "./DraftItemRow";
|
||||||
import type { UpdateDraftItemRequest, DraftItem } from "../../services/types";
|
import type { UpdateDraftItemRequest, DraftItem } from "../../types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const invalidItemsCount =
|
const invalidItemsCount =
|
||||||
draft?.items.filter((i) => !i.product_id).length || 0;
|
draft?.items.filter((i: DraftItem) => !i.product_id).length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -191,7 +191,7 @@ export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
|
|||||||
<Droppable droppableId="modal-verification-list">
|
<Droppable droppableId="modal-verification-list">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div ref={provided.innerRef} {...provided.droppableProps}>
|
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
{draft.items.map((item, index) => (
|
{draft.items.map((item: DraftItem, index: number) => (
|
||||||
<DraftItemRow
|
<DraftItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { api, getStaticUrl } from "../../services/api";
|
import { api, getStaticUrl } from "../../api";
|
||||||
import type { DraftStatus } from "../../services/types";
|
import type { DraftStatus } from "../../types";
|
||||||
import ExcelPreviewModal from "../common/ExcelPreviewModal";
|
import ExcelPreviewModal from "../../ui/ExcelPreviewModal";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const totalSum = (invoice.items || []).reduce(
|
const totalSum = (invoice.items || []).reduce(
|
||||||
(acc, item) => acc + item.total,
|
(acc: number, item: { total: number }) => acc + item.total,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
ProductSearchResult,
|
ProductSearchResult,
|
||||||
ProductContainer,
|
ProductContainer,
|
||||||
ProductMatch,
|
ProductMatch,
|
||||||
} from "../../services/types";
|
} from "../../types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Select, Spin } from "antd";
|
import { Select, Spin } from "antd";
|
||||||
import { api } from "../../services/api";
|
import { api } from "../../api";
|
||||||
import type { CatalogItem, ProductSearchResult } from "../../services/types";
|
import type { CatalogItem, ProductSearchResult } from "../../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import type { ProductMatch } from "../../services/types";
|
import type { ProductMatch } from "../../types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../services/api';
|
import { api } from '../api';
|
||||||
import type { MatchRequest, ProductMatch, CatalogItem, UnmatchedItem } from '../services/types';
|
import type { MatchRequest, ProductMatch, CatalogItem, UnmatchedItem } from '../types';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
|
||||||
export const useOcr = () => {
|
export const useOcr = () => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../services/api';
|
import { api } from '../api';
|
||||||
import type { Recommendation } from '../services/types';
|
import type { Recommendation } from '../types';
|
||||||
|
|
||||||
export const useRecommendations = () => {
|
export const useRecommendations = () => {
|
||||||
return useQuery<Recommendation[], Error>({
|
return useQuery<Recommendation[], Error>({
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { api } from '../services/api';
|
import { api } from '../api';
|
||||||
import type { UserSettings, UserRole } from '../services/types';
|
import type { UserSettings, UserRole } from '../types';
|
||||||
|
|
||||||
interface UseUserRoleResult {
|
interface UseUserRoleResult {
|
||||||
/** Роль текущего пользователя или null если не загружено */
|
/** Роль текущего пользователя или null если не загружено */
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import type { DraftItem } from '../services/types';
|
import type { DraftItem } from '../types';
|
||||||
import { recalculateItem } from '../utils/calculations';
|
import { recalculateItem } from '../utils/calculations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { ServerShort } from '../services/types';
|
import type { ServerShort } from '../types';
|
||||||
import { api } from '../services/api';
|
import { api } from '../api';
|
||||||
|
|
||||||
interface ServerState {
|
interface ServerState {
|
||||||
servers: ServerShort[];
|
servers: ServerShort[];
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
UndoOutlined,
|
UndoOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { apiClient } from "../../services/api";
|
import { apiClient } from "../api";
|
||||||
|
|
||||||
interface ExcelPreviewModalProps {
|
interface ExcelPreviewModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DraftItem } from '../services/types';
|
import type { DraftItem } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пересчитывает значения полей элемента черновика на основе измененного поля.
|
* Пересчитывает значения полей элемента черновика на основе измененного поля.
|
||||||
@@ -16,6 +16,12 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
// Разрешаем наш домен
|
// Разрешаем наш домен
|
||||||
allowedHosts: ['rmser.serty.top'],
|
allowedHosts: ['rmser.serty.top'],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user