mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-05 03:12:34 -06:00
0302-отрефакторил в нормальный вид на мобилу и десктоп
сразу выкинул пути в импортах и добавил алиас для корня
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
78
rmser-view/src/modules/desktop/components/SessionGuard.tsx
Normal file
78
rmser-view/src/modules/desktop/components/SessionGuard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Spin } from "antd";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/shared/stores/authStore";
|
||||
|
||||
interface SessionGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для проверки сессии при загрузке десктопного приложения.
|
||||
* Если пользователь авторизован через куки - редиректит на dashboard.
|
||||
* В Telegram Mini App проверка не требуется - сразу отдаём children.
|
||||
*/
|
||||
export function SessionGuard({ children }: SessionGuardProps) {
|
||||
// Проверяем, находимся ли мы в Telegram - если да, пропускаем без проверки
|
||||
const isTelegram = !!window.Telegram?.WebApp?.initData;
|
||||
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// В Telegram проверка сессии не требуется
|
||||
if (isTelegram) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSession = async () => {
|
||||
// Если пользователь уже авторизован в store - пропускаем
|
||||
if (isAuthenticated) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем сессию через API
|
||||
const user = await api.getMe();
|
||||
// Если успех - сохраняем в store и редиректим
|
||||
useAuthStore.getState().setUser(user);
|
||||
useAuthStore.getState().setToken("cookie");
|
||||
navigate("/web/dashboard", { replace: true });
|
||||
} catch {
|
||||
// 401 - нет сессии, остаёмся на странице входа
|
||||
// Другие ошибки - тоже остаёмся
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSession();
|
||||
}, [isTelegram, isAuthenticated, navigate]);
|
||||
|
||||
// В Telegram сразу пропускаем
|
||||
if (isTelegram) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Если проверяем - показываем лоадер
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Проверка сессии..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Layout, Space, Avatar, Dropdown, Select } from "antd";
|
||||
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { useAuthStore } from "@/shared/stores/authStore";
|
||||
import { useServerStore } from "@/shared/stores/serverStore";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
/**
|
||||
* Header для десктопной версии
|
||||
* Содержит логотип, заглушку выбора сервера и аватар пользователя
|
||||
*/
|
||||
export const DesktopHeader: React.FC = () => {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { servers, activeServer, isLoading, fetchServers, setActiveServer } =
|
||||
useServerStore();
|
||||
|
||||
// Загружаем список серверов при маунте компонента
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await api.logout();
|
||||
logout();
|
||||
window.location.href = "/web";
|
||||
};
|
||||
|
||||
const handleServerChange = (serverId: string) => {
|
||||
setActiveServer(serverId);
|
||||
};
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: "logout",
|
||||
label: "Выйти",
|
||||
icon: <LogoutOutlined />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: "#ffffff",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
|
||||
padding: "0 24px",
|
||||
height: "64px",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "24px" }}>
|
||||
{/* Логотип */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
color: "#1890ff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span>RMSer</span>
|
||||
</div>
|
||||
|
||||
{/* Выбор сервера */}
|
||||
<Select
|
||||
placeholder="Выберите сервер"
|
||||
value={activeServer?.id || undefined}
|
||||
onChange={handleServerChange}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
style={{ minWidth: "200px" }}
|
||||
options={servers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Аватар пользователя */}
|
||||
<Space>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<Avatar size="default" icon={<UserOutlined />} />
|
||||
<span style={{ color: "#262626" }}>
|
||||
{user?.username || "Пользователь"}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { DesktopHeader } from './DesktopHeader.tsx';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
/**
|
||||
* Основной layout для десктопной версии
|
||||
* Использует Ant Design Layout с фиксированным Header
|
||||
*/
|
||||
export const DesktopLayout: React.FC = () => {
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', backgroundColor: '#f0f2f5' }}>
|
||||
<DesktopHeader />
|
||||
<Content style={{ padding: '24px' }}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 64px - 48px)',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
221
rmser-view/src/modules/desktop/pages/auth/DesktopAuthScreen.tsx
Normal file
221
rmser-view/src/modules/desktop/pages/auth/DesktopAuthScreen.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Card, Typography, Spin, Alert, message, Button } from "antd";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { SendOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useWebSocket } from "@/shared/hooks/useWebSocket";
|
||||
import { useAuthStore } from "@/shared/stores/authStore";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
interface AuthSuccessData {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Экран авторизации для десктопной версии
|
||||
* Отображает QR код для авторизации через мобильное приложение
|
||||
* Реализует самовосстановление при разрыве соединения
|
||||
*/
|
||||
export const DesktopAuthScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToken, setUser } = useAuthStore();
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [qrLink, setQrLink] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Функция обновления сессии QR
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const data = await api.initDesktopAuth();
|
||||
setSessionId(data.session_id);
|
||||
setQrLink(data.qr_url);
|
||||
console.log("🔄 Session refreshed:", data.session_id);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Неизвестная ошибка при обновлении QR";
|
||||
setError(errorMessage);
|
||||
console.error("❌ Refresh error:", err);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Обработка разрыва соединения - автообновление QR
|
||||
const handleDisconnect = useCallback(() => {
|
||||
console.log("⚠️ WebSocket disconnected, refreshing QR...");
|
||||
refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
// Инициализация WebSocket с отключенным автореконнектом
|
||||
const { isConnected, lastError, lastMessage } = useWebSocket(sessionId, {
|
||||
autoReconnect: false,
|
||||
onDisconnect: handleDisconnect,
|
||||
});
|
||||
|
||||
// Инициализация сессии авторизации при маунте
|
||||
useEffect(() => {
|
||||
refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
// Обработка события успешной авторизации через WebSocket
|
||||
useEffect(() => {
|
||||
if (lastMessage && lastMessage.event === "auth_success") {
|
||||
const handleAuthSuccess = async () => {
|
||||
const data = lastMessage.data as AuthSuccessData;
|
||||
const { token, user } = data;
|
||||
console.log("🎉 Auth Success:", user);
|
||||
|
||||
// Создаём сессию на сервере (установка HttpOnly куки)
|
||||
await api.createSession();
|
||||
|
||||
// Устанавливаем токен и данные пользователя
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
message.success("Вход выполнен!");
|
||||
navigate("/web/dashboard");
|
||||
};
|
||||
|
||||
handleAuthSuccess();
|
||||
}
|
||||
}, [lastMessage, setToken, setUser, navigate]);
|
||||
|
||||
// Отображение лоадера при обновлении QR
|
||||
if (isRefreshing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f0f2f5",
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Обновление QR..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || lastError) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f0f2f5",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
message="Ошибка"
|
||||
description={error || lastError || "Произошла ошибка при подключении"}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ maxWidth: "400px" }}
|
||||
action={
|
||||
<Button size="small" type="primary" danger onClick={refreshSession}>
|
||||
Повторить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f0f2f5",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "400px",
|
||||
textAlign: "center",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
<Title level={3}>Авторизация</Title>
|
||||
<Paragraph type="secondary">
|
||||
Отсканируйте QR код для авторизации через Телеграмм
|
||||
</Paragraph>
|
||||
|
||||
{qrLink && (
|
||||
<div
|
||||
style={{
|
||||
margin: "24px 0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={qrLink} size={200} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrLink && (
|
||||
<Button
|
||||
type="primary"
|
||||
href={qrLink}
|
||||
target="_blank"
|
||||
icon={<SendOutlined />}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Открыть в Telegram Desktop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
fontSize: 12,
|
||||
color: "#888",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Status:{" "}
|
||||
{isConnected ? (
|
||||
<span style={{ color: "green" }}>Connected</span>
|
||||
) : (
|
||||
<span style={{ color: "red" }}>Disconnected</span>
|
||||
)}
|
||||
</p>
|
||||
<p>Session: {sessionId?.slice(0, 8)}...</p>
|
||||
{lastError && <p style={{ color: "red" }}>Error: {lastError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Кнопка ручного обновления QR */}
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={refreshSession}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
Обновить QR
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { Result, Button } from "antd";
|
||||
import { MobileOutlined } from "@ant-design/icons";
|
||||
|
||||
/**
|
||||
* Заглушка для мобильных браузеров
|
||||
* Отображается когда пользователь пытается открыть десктопную версию на мобильном устройстве
|
||||
*/
|
||||
export const MobileBrowserStub: React.FC = () => {
|
||||
const handleRedirect = () => {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f0f2f5",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
icon={<MobileOutlined style={{ fontSize: "72px", color: "#1890ff" }} />}
|
||||
title="Десктопная версия недоступна"
|
||||
subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
|
||||
extra={[
|
||||
<Button type="primary" key="mobile" onClick={handleRedirect}>
|
||||
Перейти к мобильной версии
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useState } from "react";
|
||||
import { Typography, Card, List, Empty, Tag, Spin } from "antd";
|
||||
import { UploadOutlined } from "@ant-design/icons";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { DraftVerificationModal } from "@/shared/features/invoices/DraftVerificationModal";
|
||||
import { api } from "@/shared/api";
|
||||
import { useServerStore } from "@/shared/stores/serverStore";
|
||||
import type { UnifiedInvoice } from "@/shared/types";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
/**
|
||||
* Дашборд черновиков для десктопной версии
|
||||
* Содержит зону для загрузки файлов и список черновиков
|
||||
*/
|
||||
export const InvoicesDashboard: React.FC = () => {
|
||||
const { activeServer } = useServerStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Состояние для Drag-n-Drop
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [verificationDraftId, setVerificationDraftId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Загружаем список черновиков через useQuery
|
||||
const { data: drafts, isLoading } = useQuery({
|
||||
queryKey: ["drafts", activeServer?.id],
|
||||
queryFn: () => api.getDrafts(),
|
||||
enabled: !!activeServer, // Запрос выполняется только если выбран сервер
|
||||
});
|
||||
|
||||
// Обработчики Drag-n-Drop
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Проверяем, что перетаскивается файл
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Проверяем, что мы действительно покидаем элемент, а не просто переходим к дочернему
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Разрешаем drop
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Берем только первый файл
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const draft = await api.uploadFile(file);
|
||||
|
||||
// Обновляем список черновиков
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts", activeServer?.id] });
|
||||
|
||||
// Открываем модальное окно проверки
|
||||
setVerificationDraftId(draft.id);
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки файла:", error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseVerification = () => {
|
||||
setVerificationDraftId(null);
|
||||
};
|
||||
|
||||
// Если сервер не выбран, показываем сообщение
|
||||
if (!activeServer) {
|
||||
return (
|
||||
<div>
|
||||
<Title level={2}>Черновики</Title>
|
||||
<Card>
|
||||
<Empty
|
||||
description="Выберите сервер сверху"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: "relative" }}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Title level={2}>Черновики</Title>
|
||||
|
||||
{/* Список черновиков */}
|
||||
<Card title="Последние черновики" loading={isLoading}>
|
||||
<List
|
||||
dataSource={drafts || []}
|
||||
renderItem={(draft: UnifiedInvoice) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span>{draft.document_number}</span>
|
||||
{draft.type === "DRAFT" && <Tag color="blue">Черновик</Tag>}
|
||||
{draft.type === "SYNCED" && (
|
||||
<Tag color="green">Синхронизировано</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={`${draft.date_incoming} • ${
|
||||
draft.store_name || "Склад не указан"
|
||||
} • ${draft.items_count} позиций`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description="Нет черновиков"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Overlay для Drag-n-Drop */}
|
||||
{isDragOver && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
zIndex: 100,
|
||||
border: "3px dashed #1890ff",
|
||||
borderRadius: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<UploadOutlined
|
||||
style={{ fontSize: "64px", color: "#1890ff", marginBottom: "16px" }}
|
||||
/>
|
||||
<Typography.Text style={{ fontSize: "18px", color: "#1890ff" }}>
|
||||
Отпустите файл для загрузки
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Лоадер загрузки файла */}
|
||||
{isUploading && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<Typography.Text style={{ color: "#fff", marginTop: "16px" }}>
|
||||
Загрузка файла...
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно проверки черновика */}
|
||||
{verificationDraftId && (
|
||||
<DraftVerificationModal
|
||||
draftId={verificationDraftId}
|
||||
visible={!!verificationDraftId}
|
||||
onClose={handleCloseVerification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
113
rmser-view/src/modules/mobile/components/AppLayout.tsx
Normal file
113
rmser-view/src/modules/mobile/components/AppLayout.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from "react";
|
||||
import { Layout, theme } from "antd";
|
||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
ScanOutlined,
|
||||
FileTextOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
export const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
token: { colorBgContainer, colorPrimary, colorTextSecondary },
|
||||
} = theme.useToken();
|
||||
|
||||
const path = location.pathname;
|
||||
let activeKey = "invoices";
|
||||
if (path.startsWith("/ocr")) activeKey = "ocr";
|
||||
else if (path.startsWith("/settings")) activeKey = "settings";
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: "invoices",
|
||||
icon: <FileTextOutlined style={{ fontSize: 20 }} />,
|
||||
label: "Накладные",
|
||||
path: "/invoices",
|
||||
},
|
||||
{
|
||||
key: "ocr",
|
||||
icon: <ScanOutlined style={{ fontSize: 20 }} />,
|
||||
label: "Обучение",
|
||||
path: "/ocr",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 20 }} />,
|
||||
label: "Настройки",
|
||||
path: "/settings",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Content
|
||||
style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: colorBgContainer,
|
||||
minHeight: "100%",
|
||||
padding: "12px 12px 80px 12px",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
zIndex: 1000,
|
||||
background: "#fff",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
display: "flex",
|
||||
justifyContent: "space-around",
|
||||
alignItems: "center",
|
||||
padding: "8px 0",
|
||||
height: 60,
|
||||
boxShadow: "0 -2px 8px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item) => {
|
||||
const isActive = activeKey === item.key;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "33%",
|
||||
cursor: "pointer",
|
||||
color: isActive ? colorPrimary : colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
marginTop: 2,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { Result, Button, Spin } from "antd";
|
||||
import { StopOutlined, CameraOutlined } from "@ant-design/icons";
|
||||
|
||||
interface Props {
|
||||
serverName?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const OperatorRestricted: React.FC<Props> = ({
|
||||
serverName,
|
||||
loading = false,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Загрузка..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
padding: 24,
|
||||
background: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
icon={<StopOutlined style={{ color: "#faad14" }} />}
|
||||
title="Доступ ограничен"
|
||||
subTitle={
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p>
|
||||
Вы вошли как <strong>Оператор</strong>
|
||||
{serverName && (
|
||||
<>
|
||||
{" "}
|
||||
на сервере <strong>{serverName}</strong>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
Операторы могут загружать фото накладных только через
|
||||
Telegram-бота.
|
||||
</p>
|
||||
<p style={{ marginTop: 16, color: "#666" }}>
|
||||
Для доступа к полному интерфейсу обратитесь к администратору
|
||||
сервера.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CameraOutlined />}
|
||||
size="large"
|
||||
onClick={() => {
|
||||
window.location.href = "https://t.me/RmserBot";
|
||||
}}
|
||||
>
|
||||
Открыть бота в Telegram
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperatorRestricted;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Image,
|
||||
Button,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Pagination,
|
||||
Empty,
|
||||
Spin,
|
||||
message,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
FileImageOutlined,
|
||||
CheckCircleOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getStaticUrl } from "@/shared/api";
|
||||
import { AxiosError } from "axios";
|
||||
import type { ReceiptPhoto, PhotoStatus } from "@/shared/types";
|
||||
|
||||
export const PhotoStorageTab: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["photos", page],
|
||||
queryFn: () => api.getPhotos(page, 18),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, force }: { id: string; force: boolean }) =>
|
||||
api.deletePhoto(id, force),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["photos"] });
|
||||
message.success("Фото удалено");
|
||||
},
|
||||
onError: (error: AxiosError<{ error: string }>) => {
|
||||
if (error.response?.status === 409) {
|
||||
message.warning("Это фото связано с черновиком.");
|
||||
} else {
|
||||
message.error(error.response?.data?.error || "Ошибка удаления");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
|
||||
onSuccess: () => message.success("Черновик восстановлен"),
|
||||
onError: () => message.error("Ошибка восстановления"),
|
||||
});
|
||||
|
||||
const getStatusTag = (status: PhotoStatus) => {
|
||||
switch (status) {
|
||||
case "ORPHAN":
|
||||
return <Tag color="default">Без привязки</Tag>;
|
||||
case "HAS_DRAFT":
|
||||
return (
|
||||
<Tag icon={<FileTextOutlined />} color="processing">
|
||||
Черновик
|
||||
</Tag>
|
||||
);
|
||||
case "HAS_INVOICE":
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
В iiko
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return <Tag>{status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 40 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Empty description="Ошибка загрузки фото" />;
|
||||
}
|
||||
|
||||
if (!data?.photos?.length) {
|
||||
return <Empty description="Нет загруженных фото" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{data.photos.map((photo: ReceiptPhoto) => (
|
||||
<Card
|
||||
key={photo.id}
|
||||
hoverable
|
||||
size="small"
|
||||
cover={
|
||||
<div
|
||||
style={{
|
||||
height: 160,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={getStaticUrl(photo.file_url)}
|
||||
alt={photo.file_name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
preview={{ mask: <FileImageOutlined /> }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
photo.can_regenerate ? (
|
||||
<Tooltip title="Создать черновик заново">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => regenerateMutation.mutate(photo.id)}
|
||||
loading={regenerateMutation.isPending}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
),
|
||||
photo.can_delete ? (
|
||||
<Popconfirm
|
||||
title="Удалить фото?"
|
||||
description={
|
||||
photo.status === "HAS_DRAFT"
|
||||
? "Черновик тоже будет удален."
|
||||
: "Восстановить будет невозможно."
|
||||
}
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate({
|
||||
id: photo.id,
|
||||
force: photo.status === "HAS_DRAFT",
|
||||
})
|
||||
}
|
||||
okText="Удалить"
|
||||
cancelText="Отмена"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
/>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Tooltip title="Нельзя удалить: накладная уже в iiko">
|
||||
<Button
|
||||
type="text"
|
||||
disabled
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{new Date(photo.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
}
|
||||
description={getStatusTag(photo.status)}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
current={page}
|
||||
total={data.total}
|
||||
pageSize={data.limit}
|
||||
onChange={setPage}
|
||||
showSizeChanger={false}
|
||||
style={{ textAlign: "center" }}
|
||||
simple
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { Card, Button, Typography, Space, Tooltip } from "antd";
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import "dayjs/locale/ru";
|
||||
import type { UserRole } from "@/shared/types";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale("ru");
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SyncBlockProps {
|
||||
lastSyncAt: string | null;
|
||||
userRole: UserRole;
|
||||
onSync: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const SyncBlock: React.FC<SyncBlockProps> = ({
|
||||
lastSyncAt,
|
||||
userRole,
|
||||
onSync,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const canSync = userRole === "OWNER" || userRole === "ADMIN";
|
||||
|
||||
const formatLastSync = (dateStr: string | null): string => {
|
||||
if (!dateStr) return "Никогда";
|
||||
const date = dayjs(dateStr);
|
||||
return `${date.format("DD.MM.YYYY HH:mm")} (${date.fromNow()})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: 16, display: "block", marginBottom: 8 }}
|
||||
>
|
||||
Синхронизация данных
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
Последняя синхронизация: {formatLastSync(lastSyncAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip title={!canSync ? "Только для администраторов" : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined spin={isLoading} />}
|
||||
onClick={onSync}
|
||||
loading={isLoading}
|
||||
disabled={!canSync}
|
||||
block
|
||||
>
|
||||
Синхронизировать
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Загружает справочники, накладные и пересчитывает рекомендации
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
196
rmser-view/src/modules/mobile/components/settings/TeamList.tsx
Normal file
196
rmser-view/src/modules/mobile/components/settings/TeamList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from "react";
|
||||
import {
|
||||
List,
|
||||
Avatar,
|
||||
Tag,
|
||||
Button,
|
||||
Select,
|
||||
Popconfirm,
|
||||
message,
|
||||
Spin,
|
||||
Alert,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { DeleteOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import type { ServerUser, UserRole } from "@/shared/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
currentUserRole: UserRole;
|
||||
}
|
||||
|
||||
export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["serverUsers"],
|
||||
queryFn: api.getUsers,
|
||||
});
|
||||
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
|
||||
api.updateUserRole(userId, newRole),
|
||||
onSuccess: () => {
|
||||
message.success("Роль пользователя обновлена");
|
||||
queryClient.invalidateQueries({ queryKey: ["serverUsers"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Не удалось изменить роль");
|
||||
},
|
||||
});
|
||||
|
||||
const removeUserMutation = useMutation({
|
||||
mutationFn: (userId: string) => api.removeUser(userId),
|
||||
onSuccess: () => {
|
||||
message.success("Пользователь удален из команды");
|
||||
queryClient.invalidateQueries({ queryKey: ["serverUsers"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Не удалось удалить пользователя");
|
||||
},
|
||||
});
|
||||
|
||||
const getRoleColor = (role: UserRole) => {
|
||||
switch (role) {
|
||||
case "OWNER":
|
||||
return "gold";
|
||||
case "ADMIN":
|
||||
return "blue";
|
||||
case "OPERATOR":
|
||||
return "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleName = (role: UserRole) => {
|
||||
switch (role) {
|
||||
case "OWNER":
|
||||
return "Владелец";
|
||||
case "ADMIN":
|
||||
return "Админ";
|
||||
case "OPERATOR":
|
||||
return "Оператор";
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
};
|
||||
|
||||
const canDelete = (targetUser: ServerUser) => {
|
||||
if (targetUser.is_me) return false;
|
||||
if (targetUser.role === "OWNER") return false;
|
||||
if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN")
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 20 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Alert type="error" message="Не удалось загрузить список команды" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="Приглашение сотрудников"
|
||||
description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение."
|
||||
/>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={users || []}
|
||||
renderItem={(user) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
currentUserRole === "OWNER" && !user.is_me ? (
|
||||
<Select
|
||||
key="role-select"
|
||||
defaultValue={user.role}
|
||||
size="small"
|
||||
style={{ width: 110 }}
|
||||
disabled={updateRoleMutation.isPending}
|
||||
onChange={(val) =>
|
||||
updateRoleMutation.mutate({
|
||||
userId: user.user_id,
|
||||
newRole: val,
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: "ADMIN", label: "Админ" },
|
||||
{ value: "OPERATOR", label: "Оператор" },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Tag key="role-tag" color={getRoleColor(user.role)}>
|
||||
{getRoleName(user.role)}
|
||||
</Tag>
|
||||
),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Закрыть доступ?"
|
||||
description={`Вы уверены, что хотите удалить ${user.first_name}?`}
|
||||
onConfirm={() => removeUserMutation.mutate(user.user_id)}
|
||||
disabled={!canDelete(user)}
|
||||
okText="Да"
|
||||
cancelText="Нет"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!canDelete(user) || removeUserMutation.isPending}
|
||||
/>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar src={user.photo_url} icon={<UserOutlined />}>
|
||||
{user.first_name?.[0]}
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<span>
|
||||
{user.first_name} {user.last_name}{" "}
|
||||
{user.is_me && <Text type="secondary">(Вы)</Text>}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
user.username ? (
|
||||
<a
|
||||
href={`https://t.me/${user.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
@{user.username}
|
||||
</a>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Нет username
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
512
rmser-view/src/modules/mobile/pages/DraftsList.tsx
Normal file
512
rmser-view/src/modules/mobile/pages/DraftsList.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
List,
|
||||
Typography,
|
||||
Tag,
|
||||
Spin,
|
||||
Empty,
|
||||
Flex,
|
||||
Button,
|
||||
Select,
|
||||
DatePicker,
|
||||
} from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LoadingOutlined,
|
||||
CloseCircleOutlined,
|
||||
StopOutlined,
|
||||
SyncOutlined,
|
||||
CloudServerOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/ru";
|
||||
import { api } from "@/shared/api";
|
||||
import type { UnifiedInvoice } from "@/shared/types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type FilterType = "ALL" | "DRAFT" | "SYNCED";
|
||||
|
||||
dayjs.locale("ru");
|
||||
|
||||
const DayDivider: React.FC<{ date: string }> = ({ date }) => {
|
||||
const d = dayjs(date);
|
||||
const dayOfWeek = d.format("dddd");
|
||||
const formattedDate = d.format("D MMMM YYYY");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "16px 0 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 14, color: "#1890ff" }}>
|
||||
{formattedDate}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{dayOfWeek}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DraftsList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
const [filterType, setFilterType] = useState<FilterType>("ALL");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [startDate, setStartDate] = useState<dayjs.Dayjs | null>(
|
||||
dayjs().subtract(30, "day")
|
||||
);
|
||||
const [endDate, setEndDate] = useState<dayjs.Dayjs | null>(dayjs());
|
||||
|
||||
const {
|
||||
data: invoices,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"drafts",
|
||||
startDate?.format("YYYY-MM-DD"),
|
||||
endDate?.format("YYYY-MM-DD"),
|
||||
],
|
||||
queryFn: () =>
|
||||
api.getDrafts(
|
||||
startDate?.format("YYYY-MM-DD"),
|
||||
endDate?.format("YYYY-MM-DD")
|
||||
),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncLoading(true);
|
||||
try {
|
||||
await api.syncInvoices();
|
||||
refetch();
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (item: UnifiedInvoice) => {
|
||||
switch (item.status) {
|
||||
case "PROCESSING":
|
||||
return (
|
||||
<Tag icon={<LoadingOutlined />} color="blue">
|
||||
Обработка
|
||||
</Tag>
|
||||
);
|
||||
case "READY_TO_VERIFY":
|
||||
return (
|
||||
<Tag icon={<ExclamationCircleOutlined />} color="orange">
|
||||
Проверка
|
||||
</Tag>
|
||||
);
|
||||
case "COMPLETED":
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="green">
|
||||
Готово
|
||||
</Tag>
|
||||
);
|
||||
case "ERROR":
|
||||
return (
|
||||
<Tag icon={<CloseCircleOutlined />} color="red">
|
||||
Ошибка
|
||||
</Tag>
|
||||
);
|
||||
case "CANCELED":
|
||||
return (
|
||||
<Tag icon={<StopOutlined />} color="default">
|
||||
Отменен
|
||||
</Tag>
|
||||
);
|
||||
case "NEW":
|
||||
return (
|
||||
<Tag icon={<PlusOutlined />} color="blue">
|
||||
Новая
|
||||
</Tag>
|
||||
);
|
||||
case "PROCESSED":
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="green">
|
||||
Проведена
|
||||
</Tag>
|
||||
);
|
||||
case "DELETED":
|
||||
return (
|
||||
<Tag icon={<DeleteOutlined />} color="red">
|
||||
Удалена
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return <Tag>{item.status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvoiceClick = (item: UnifiedInvoice) => {
|
||||
if (item.type === "DRAFT") {
|
||||
navigate("/invoice/draft/" + item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === "SYNCED") {
|
||||
if (item.draft_id) {
|
||||
navigate("/invoice/draft/" + item.draft_id);
|
||||
} else {
|
||||
navigate("/invoice/view/" + item.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (value: FilterType) => {
|
||||
setFilterType(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (value: number) => {
|
||||
setPageSize(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const getItemDate = (item: UnifiedInvoice) =>
|
||||
item.type === "DRAFT" ? item.created_at : item.date_incoming;
|
||||
|
||||
const filteredAndSortedInvoices = useMemo(() => {
|
||||
if (!invoices || invoices.length === 0) return [];
|
||||
|
||||
let result = [...invoices];
|
||||
|
||||
if (filterType !== "ALL") {
|
||||
result = result.filter((item) => item.type === filterType);
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
const dateA = dayjs(getItemDate(a)).startOf("day");
|
||||
const dateB = dayjs(getItemDate(b)).startOf("day");
|
||||
|
||||
if (!dateA.isSame(dateB)) {
|
||||
return dateB.valueOf() - dateA.valueOf();
|
||||
}
|
||||
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "DRAFT" ? -1 : 1;
|
||||
}
|
||||
|
||||
return (b.document_number || "").localeCompare(
|
||||
a.document_number || "",
|
||||
"ru",
|
||||
{ numeric: true }
|
||||
);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [invoices, filterType]);
|
||||
|
||||
const paginatedInvoices = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return filteredAndSortedInvoices.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredAndSortedInvoices, currentPage, pageSize]);
|
||||
|
||||
const groupedInvoices = useMemo(() => {
|
||||
const groups: { [key: string]: UnifiedInvoice[] } = {};
|
||||
paginatedInvoices.forEach((item) => {
|
||||
const dateKey = dayjs(getItemDate(item)).format("YYYY-MM-DD");
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(item);
|
||||
});
|
||||
return groups;
|
||||
}, [paginatedInvoices]);
|
||||
|
||||
const filterCounts = useMemo(() => {
|
||||
if (!invoices) return { all: 0, draft: 0, synced: 0 };
|
||||
return {
|
||||
all: invoices.length,
|
||||
draft: invoices.filter((item) => item.type === "DRAFT").length,
|
||||
synced: invoices.filter((item) => item.type === "SYNCED").length,
|
||||
};
|
||||
}, [invoices]);
|
||||
|
||||
const totalPages = Math.ceil(
|
||||
(filteredAndSortedInvoices.length || 0) / pageSize
|
||||
);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Text type="danger">Ошибка загрузки списка накладных</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 4px 20px" }}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ marginTop: 16, marginBottom: 16 }}
|
||||
>
|
||||
<Flex align="center" gap={8}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Накладные
|
||||
</Title>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncLoading}
|
||||
onClick={handleSync}
|
||||
/>
|
||||
</Flex>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={handleFilterChange}
|
||||
options={[
|
||||
{ label: `Все (${filterCounts.all})`, value: "ALL" },
|
||||
{ label: `Черновики (${filterCounts.draft})`, value: "DRAFT" },
|
||||
{ label: `Накладные (${filterCounts.synced})`, value: "SYNCED" },
|
||||
]}
|
||||
size="small"
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
background: "#fff",
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Flex vertical gap={8}>
|
||||
<Text style={{ fontSize: 13 }}>Период:</Text>
|
||||
<Flex align="center" gap={8}>
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
format="DD.MM.YYYY"
|
||||
size="small"
|
||||
placeholder="Начало"
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
<Text type="secondary">—</Text>
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
format="DD.MM.YYYY"
|
||||
size="small"
|
||||
placeholder="Конец"
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: 40 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !invoices || invoices.length === 0 ? (
|
||||
<Empty description="Нет данных" />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
{Object.entries(groupedInvoices).map(([dateKey, items]) => (
|
||||
<div key={dateKey}>
|
||||
<DayDivider date={dateKey} />
|
||||
{items.map((item) => {
|
||||
const isSynced = item.type === "SYNCED";
|
||||
const displayDate =
|
||||
item.type === "DRAFT"
|
||||
? item.created_at
|
||||
: item.date_incoming;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
style={{
|
||||
background: isSynced ? "#fafafa" : "#fff",
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
borderRadius: 12,
|
||||
cursor: "pointer",
|
||||
border: isSynced
|
||||
? "1px solid #f0f0f0"
|
||||
: "1px solid #e6f7ff",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
|
||||
display: "block",
|
||||
position: "relative",
|
||||
}}
|
||||
onClick={() => handleInvoiceClick(item)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
{getStatusTag(item)}
|
||||
</div>
|
||||
|
||||
<Flex vertical gap={4}>
|
||||
<Flex align="center" gap={8}>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{item.document_number || "Без номера"}
|
||||
</Text>
|
||||
{item.type === "SYNCED" && (
|
||||
<CloudServerOutlined style={{ color: "gray" }} />
|
||||
)}
|
||||
{item.is_app_created && (
|
||||
<span title="Создано в RMSer">📱</span>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex vertical gap={2}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{item.items_count} поз.
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{dayjs(displayDate).format("DD.MM.YYYY")}
|
||||
</Text>
|
||||
</Flex>
|
||||
{item.incoming_number && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Вх. № {item.incoming_number}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Flex justify="space-between" align="center">
|
||||
<div></div>
|
||||
{item.store_name && (
|
||||
<Tag
|
||||
style={{
|
||||
margin: 0,
|
||||
maxWidth: 120,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{item.store_name}
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 17,
|
||||
color: isSynced ? "#595959" : "#1890ff",
|
||||
}}
|
||||
>
|
||||
{item.total_sum.toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</Text>
|
||||
{item.items_preview && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "right",
|
||||
maxWidth: 150,
|
||||
}}
|
||||
>
|
||||
{item.items_preview
|
||||
.split(", ")
|
||||
.slice(0, 3)
|
||||
.map((previewItem, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{previewItem}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "8px 12px",
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap={8}>
|
||||
<Text style={{ fontSize: 12 }}>На странице:</Text>
|
||||
<Select
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
options={[
|
||||
{ label: "10", value: 10 },
|
||||
{ label: "20", value: 20 },
|
||||
{ label: "50", value: 50 },
|
||||
]}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex align="center" gap={8}>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
Стр. {currentPage} из {totalPages}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((prev) => prev - 1)}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
rmser-view/src/modules/mobile/pages/InvoiceDraftPage.tsx
Normal file
18
rmser-view/src/modules/mobile/pages/InvoiceDraftPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { DraftEditor } from "@/shared/features/invoices/DraftEditor";
|
||||
|
||||
export const InvoiceDraftPage: React.FC = () => {
|
||||
const { id: draftId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!draftId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
<DraftEditor draftId={draftId} onBack={() => navigate("/invoices")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
rmser-view/src/modules/mobile/pages/InvoiceViewPage.tsx
Normal file
19
rmser-view/src/modules/mobile/pages/InvoiceViewPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { InvoiceViewer } from "@/shared/features/invoices/InvoiceViewer";
|
||||
|
||||
export const InvoiceViewPage: React.FC = () => {
|
||||
const { id: invoiceId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
{invoiceId && (
|
||||
<InvoiceViewer
|
||||
invoiceId={invoiceId}
|
||||
onBack={() => navigate("/invoices")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
rmser-view/src/modules/mobile/pages/MaintenancePage.tsx
Normal file
27
rmser-view/src/modules/mobile/pages/MaintenancePage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Result, Button } from "antd";
|
||||
|
||||
const MaintenancePage = () => (
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#fff",
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="warning"
|
||||
title="Сервис на техническом обслуживании"
|
||||
subTitle="Мы скоро вернемся с новыми функциями!"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Попробовать снова
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MaintenancePage;
|
||||
99
rmser-view/src/modules/mobile/pages/OcrLearning.tsx
Normal file
99
rmser-view/src/modules/mobile/pages/OcrLearning.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from "react";
|
||||
import { Spin, Alert } from "antd";
|
||||
import { useOcr } from "@/shared/hooks/useOcr";
|
||||
import { AddMatchForm } from "@/shared/features/ocr/AddMatchForm";
|
||||
import { MatchList } from "@/shared/features/ocr/MatchList";
|
||||
|
||||
export const OcrLearning: React.FC = () => {
|
||||
const {
|
||||
catalog,
|
||||
matches,
|
||||
unmatched,
|
||||
isLoading,
|
||||
isError,
|
||||
createMatch,
|
||||
isCreating,
|
||||
deleteMatch,
|
||||
isDeletingMatch,
|
||||
deleteUnmatched,
|
||||
} = useOcr();
|
||||
|
||||
const [editingMatch, setEditingMatch] = React.useState<string | null>(null);
|
||||
|
||||
const currentEditingMatch = React.useMemo(() => {
|
||||
if (!editingMatch) return undefined;
|
||||
return matches.find((match) => match.raw_name === editingMatch);
|
||||
}, [editingMatch, matches]);
|
||||
|
||||
if (isLoading && matches.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "50vh",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: "#888" }}>Загрузка справочников...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert
|
||||
message="Ошибка"
|
||||
description="Не удалось загрузить данные."
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ margin: 16 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 20 }}>
|
||||
<AddMatchForm
|
||||
catalog={catalog}
|
||||
unmatched={unmatched}
|
||||
onSave={(raw, prodId, qty, contId) => {
|
||||
if (currentEditingMatch) {
|
||||
createMatch({
|
||||
raw_name: raw,
|
||||
product_id: prodId,
|
||||
quantity: qty,
|
||||
container_id: contId,
|
||||
});
|
||||
setEditingMatch(null);
|
||||
} else {
|
||||
createMatch({
|
||||
raw_name: raw,
|
||||
product_id: prodId,
|
||||
quantity: qty,
|
||||
container_id: contId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDeleteUnmatched={deleteUnmatched}
|
||||
isLoading={isCreating}
|
||||
initialValues={currentEditingMatch}
|
||||
onCancelEdit={() => setEditingMatch(null)}
|
||||
/>
|
||||
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>
|
||||
Обученные позиции ({matches.length})
|
||||
</h3>
|
||||
<MatchList
|
||||
matches={matches}
|
||||
onDeleteMatch={deleteMatch}
|
||||
onEditMatch={(match) => {
|
||||
setEditingMatch(match.raw_name);
|
||||
}}
|
||||
isDeleting={isDeletingMatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
322
rmser-view/src/modules/mobile/pages/SettingsPage.tsx
Normal file
322
rmser-view/src/modules/mobile/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Form,
|
||||
Select,
|
||||
Switch,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
TreeSelect,
|
||||
Spin,
|
||||
message,
|
||||
Tabs,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import {
|
||||
SaveOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
FolderOpenOutlined,
|
||||
TeamOutlined,
|
||||
CameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/shared/api";
|
||||
import type { UserSettings, Store } from "@/shared/types";
|
||||
import { TeamList } from "../components/settings/TeamList";
|
||||
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
|
||||
import { SyncBlock } from "../components/settings/SyncBlock";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["stats"],
|
||||
queryFn: api.getStats,
|
||||
});
|
||||
|
||||
const dictQuery = useQuery({
|
||||
queryKey: ["dictionaries"],
|
||||
queryFn: api.getDictionaries,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ["productGroups"],
|
||||
queryFn: api.getProductGroups,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (vals: UserSettings) => api.updateSettings(vals),
|
||||
onSuccess: () => {
|
||||
message.success("Настройки сохранены");
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Не удалось сохранить настройки");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAllDraftsMutation = useMutation({
|
||||
mutationFn: () => api.deleteAllDrafts(),
|
||||
onSuccess: (data) => {
|
||||
message.success(`Удалено черновиков: ${data.count}`);
|
||||
queryClient.invalidateQueries({ queryKey: ["stats"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Не удалось удалить черновики");
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.syncAll(true),
|
||||
onSuccess: () => {
|
||||
message.success("Синхронизация запущена в фоне");
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Ошибка запуска синхронизации");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsQuery.data) {
|
||||
form.setFieldsValue(settingsQuery.data);
|
||||
}
|
||||
}, [settingsQuery.data, form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
saveMutation.mutate({
|
||||
...values,
|
||||
auto_conduct: !!values.auto_conduct,
|
||||
});
|
||||
} catch {
|
||||
// validation errors
|
||||
}
|
||||
};
|
||||
|
||||
if (settingsQuery.isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 50 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentUserRole = settingsQuery.data?.role || "OPERATOR";
|
||||
const showTeamSettings =
|
||||
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
|
||||
|
||||
const tabsItems = [
|
||||
{
|
||||
key: "general",
|
||||
label: "Общие",
|
||||
icon: <SettingOutlined />,
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<SyncBlock
|
||||
lastSyncAt={settingsQuery.data?.last_sync_at || null}
|
||||
userRole={currentUserRole}
|
||||
onSync={() => syncMutation.mutate()}
|
||||
isLoading={syncMutation.isPending}
|
||||
/>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
label="Склад по умолчанию"
|
||||
name="default_store_id"
|
||||
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
|
||||
>
|
||||
<Select
|
||||
placeholder="Не выбрано"
|
||||
allowClear
|
||||
loading={dictQuery.isLoading}
|
||||
options={dictQuery.data?.stores.map((s: Store) => ({
|
||||
label: s.name,
|
||||
value: s.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Корневая группа товаров"
|
||||
name="root_group_id"
|
||||
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
|
||||
>
|
||||
<TreeSelect
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
||||
placeholder="Выберите папку"
|
||||
allowClear
|
||||
treeDefaultExpandAll={false}
|
||||
treeData={groupsQuery.data}
|
||||
fieldNames={{
|
||||
label: "title",
|
||||
value: "value",
|
||||
children: "children",
|
||||
}}
|
||||
treeNodeFilterProp="title"
|
||||
suffixIcon={<FolderOpenOutlined />}
|
||||
loading={groupsQuery.isLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text>Проводить накладные автоматически</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Если выключено, накладные в iiko будут создаваться как
|
||||
"Непроведенные"
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item name="auto_conduct" valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
block
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={saveMutation.isPending}
|
||||
>
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
|
||||
{currentUserRole === "OWNER" && (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginTop: 24,
|
||||
borderColor: "#ff4d4f",
|
||||
borderWidth: 2,
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ color: "#ff4d4f", marginBottom: 16 }}>
|
||||
Опасная зона
|
||||
</Title>
|
||||
<Popconfirm
|
||||
title="Вы уверены?"
|
||||
description="Это удалит ВСЕ черновики, которые еще не были отправлены в iiko. Это действие необратимо."
|
||||
onConfirm={() => deleteAllDraftsMutation.mutate()}
|
||||
okText="Удалить"
|
||||
cancelText="Отмена"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
block
|
||||
loading={deleteAllDraftsMutation.isPending}
|
||||
>
|
||||
Удалить все черновики
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (showTeamSettings) {
|
||||
tabsItems.push({
|
||||
key: "team",
|
||||
label: "Команда",
|
||||
icon: <TeamOutlined />,
|
||||
children: (
|
||||
<Card size="small">
|
||||
<TeamList currentUserRole={currentUserRole} />
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (currentUserRole === "OWNER") {
|
||||
tabsItems.push({
|
||||
key: "photos",
|
||||
label: "Архив фото",
|
||||
icon: <CameraOutlined />,
|
||||
children: <PhotoStorageTab />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 16px 80px" }}>
|
||||
<Title level={4} style={{ marginTop: 16 }}>
|
||||
<SettingOutlined /> Настройки
|
||||
</Title>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: "#f0f5ff",
|
||||
borderColor: "#d6e4ff",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<BarChartOutlined style={{ color: "#1890ff" }} />
|
||||
<Text strong>Статистика накладных</Text>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="За 24ч"
|
||||
value={statsQuery.data?.last_24h || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="Месяц"
|
||||
value={statsQuery.data?.last_month || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="Всего"
|
||||
value={statsQuery.data?.total || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultActiveKey="general" items={tabsItems} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user