Files
geo/frontend/src/App.tsx
2026-02-04 00:11:19 +05:00

410 lines
16 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Chat } from './components/Chat/Chat';
import { Constructor } from './components/Constructor/Constructor';
import { Archive } from './components/Archive/Archive';
import { AdminPanel } from './components/AdminPanel/AdminPanel';
import { RightSidebar } from './components/RightSidebar/RightSidebar';
import { AuthForm } from './components/Auth/AuthForm';
import { ShareModal } from './components/Archive/ShareModal';
import { Settings } from './components/Settings/Settings';
import { useAuth } from './contexts/AuthContext';
import { useTheme } from './contexts/ThemeContext';
import { api } from './api/client';
import { Moon, Sun, LogOut } from 'lucide-react';
export interface Estimate {
id: string;
number: string;
direction: { code: string; name: string; shortName: string };
objectName: string;
customer: string;
executor: string;
items: EstimateItem[];
totals: EstimateTotal[];
totalFieldWorks?: number;
totalOfficeWorks?: number;
totalLaboratory?: number;
subtotal?: number;
regionalCoef?: number;
regionalCoefDocRef?: string;
inflationIndex?: number;
inflationDocRef?: string;
companyCoef?: number;
companyCoefDocRef?: string;
executorCoef?: number;
executorCoefDocRef?: string;
totalWithoutVat?: number;
vatRate?: number;
vatAmount?: number;
totalWithVat?: number;
withVat?: boolean;
status: string;
}
export interface EstimateItem {
id: string;
orderNumber: number;
sectionType: string;
workName: string;
justification?: string;
basePrice: number;
quantity: number;
unit?: string;
coef1?: number;
coef1Desc?: string;
coef2?: number;
coef2Desc?: string;
coef3?: number;
coef3Desc?: string;
totalPrice: number;
}
export interface EstimateTotal {
id: string;
orderNumber: number;
label: string;
description?: string;
resultValue: number;
}
export interface ExtractedData {
direction?: string;
customer?: string;
objectName?: string;
executor?: string;
works?: Array<{
name: string;
volume: number;
unit: string;
priceItemId?: string;
justification?: string;
}>;
}
export interface EstimateWithShared extends Estimate {
sharedWithMe?: boolean;
}
function App() {
const { user, loading, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [showArchive, setShowArchive] = useState(false);
const [manualMode, setManualMode] = useState(false);
const [currentEstimate, setCurrentEstimate] = useState<EstimateWithShared | null>(null);
const [extractedData, setExtractedData] = useState<ExtractedData | null>(null);
const [chatSessionId, setChatSessionId] = useState<string | null>(null);
const [showShareModal, setShowShareModal] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settings, setSettings] = useState<any>(null);
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [externalMessageTrigger, setExternalMessageTrigger] = useState(0);
useEffect(() => {
if (showSettings) {
api.getSettings().then(setSettings).catch(() => setSettings(null));
}
}, [showSettings]);
useEffect(() => {
if (!user || currentEstimate) return;
let cancelled = false;
const loadOrCreateEstimate = async () => {
try {
const list = await api.getEstimates();
const lastId = typeof localStorage !== 'undefined' ? localStorage.getItem('smeta-last-estimate-id') : null;
if (list?.length) {
const toOpen = list.find((e: { id: string }) => e.id === lastId) ?? list[0];
const full = await api.getEstimate(toOpen.id);
if (!cancelled) setCurrentEstimate(full);
} else {
const directions = await api.getSurveyDirections();
const firstDir = directions?.[0];
const estimate = await api.createEstimate({
directionCode: firstDir?.code ?? 'geodesy',
objectName: 'Новая смета',
customer: '—',
});
if (!cancelled) setCurrentEstimate(estimate);
}
} catch (err) {
console.error('Failed to load initial estimate:', err);
}
};
loadOrCreateEstimate();
return () => { cancelled = true; };
}, [user, currentEstimate]);
useEffect(() => {
setExtractedData(null);
}, [currentEstimate?.id]);
useEffect(() => {
if (currentEstimate?.id && typeof localStorage !== 'undefined') {
localStorage.setItem('smeta-last-estimate-id', currentEstimate.id);
}
}, [currentEstimate?.id]);
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-slate-100 dark:bg-slate-900">
<div className="w-8 h-8 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) {
return <AuthForm />;
}
/** Заполняет текущую смету (справа) данными из загруженного в чат файла. Обновляет только смету, чат не трогает. */
const fillCurrentEstimateFromData = async (estimateId: string, data: ExtractedData) => {
try {
const updates: { objectName?: string; customer?: string; executor?: string } = {};
if (data.objectName?.trim()) updates.objectName = data.objectName.trim();
if (data.customer?.trim()) updates.customer = data.customer.trim();
if (data.executor?.trim()) updates.executor = data.executor.trim();
if (Object.keys(updates).length > 0) {
await api.updateEstimate(estimateId, updates);
}
if (data.works?.length) {
for (const work of data.works) {
await api.addEstimateItem(estimateId, {
sectionType: 'field',
workName: work.name,
justification: work.justification,
basePrice: 0,
quantity: work.volume,
unit: work.unit,
});
}
}
if (Object.keys(updates).length > 0 || (data.works?.length ?? 0) > 0) {
try {
await api.recalculateEstimate(estimateId);
} catch (_) {
// Пересчёт мог упасть (например vatRate null) — всё равно подтягиваем сохранённые данные
}
const fullEstimate = await api.getEstimate(estimateId);
setCurrentEstimate(fullEstimate);
}
} catch (err) {
console.error('Failed to fill estimate from file:', err);
}
};
const handleExtractedData = (data: ExtractedData) => {
setExtractedData(prev => {
const next: ExtractedData = prev ? { ...prev } : {};
if (data.direction != null && data.direction !== '') next.direction = data.direction;
if (data.customer != null && data.customer !== '') next.customer = data.customer;
if (data.objectName != null && data.objectName !== '') next.objectName = data.objectName;
if (data.executor != null && data.executor !== '') next.executor = data.executor;
if (data.works != null && data.works.length > 0) next.works = data.works;
return next;
});
};
const handleCreateEstimate = async () => {
if (!extractedData?.direction || !extractedData?.customer || !extractedData?.objectName) return;
try {
const estimate = await api.createEstimate({
directionCode: extractedData.direction,
customer: extractedData.customer,
objectName: extractedData.objectName,
executor: extractedData.executor,
});
if (extractedData.works?.length) {
for (const work of extractedData.works) {
await api.addEstimateItem(estimate.id, {
sectionType: 'field',
workName: work.name,
justification: work.justification,
basePrice: 0,
quantity: work.volume,
unit: work.unit,
});
}
await api.recalculateEstimate(estimate.id);
}
const fullEstimate = await api.getEstimate(estimate.id);
setCurrentEstimate(fullEstimate);
if (chatSessionId) {
await api.updateChatSession(chatSessionId, { estimateId: fullEstimate.id });
}
} catch (error) {
console.error('Failed to create estimate:', error);
}
};
const handleEstimateUpdate = async (estimateId: string) => {
try {
const estimate = await api.getEstimate(estimateId);
setCurrentEstimate(estimate);
} catch (error) {
console.error('Failed to update estimate:', error);
}
};
const handleCreateManualEstimate = async (data: {
directionCode: string;
objectName: string;
customer: string;
executor?: string;
}) => {
try {
const estimate = await api.createEstimate(data);
const fullEstimate = await api.getEstimate(estimate.id);
setCurrentEstimate(fullEstimate);
} catch (error) {
console.error('Failed to create estimate:', error);
}
};
return (
<Routes>
<Route path="/geo" element={<AdminPanel />} />
<Route path="/*" element={
<div className="flex flex-col h-screen bg-slate-100 dark:bg-slate-900">
<header className="flex items-center justify-between gap-4 px-4 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-slate-800 dark:text-slate-200 truncate">Портал смет</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => toggleTheme()}
className="min-w-[40px] min-h-[40px] flex items-center justify-center p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors cursor-pointer select-none"
title={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
aria-label={theme === 'dark' ? 'Включить светлую тему' : 'Включить тёмную тему'}
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
<div className="h-6 w-px bg-slate-200 dark:bg-slate-600" />
<div className="flex items-center gap-2">
<span
className="text-sm text-slate-600 dark:text-slate-300 truncate max-w-[180px]"
title={user.email}
>
{user.name || user.email}
</span>
<button
type="button"
onClick={() => logout()}
className="min-w-[40px] min-h-[40px] flex items-center justify-center p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors cursor-pointer"
title="Выйти"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
<RightSidebar
expanded={sidebarExpanded}
onToggle={() => setSidebarExpanded(v => !v)}
currentEstimate={currentEstimate}
onOpenEstimate={estimate => {
setCurrentEstimate(estimate);
setShowArchive(false);
}}
onShowArchive={() => setShowArchive(true)}
onShowShare={() => setShowShareModal(true)}
onShowSettings={() => setShowSettings(true)}
/>
<main className="flex-1 flex overflow-hidden min-w-0 bg-slate-50 dark:bg-slate-800">
{showArchive ? (
<div className="flex-1 flex flex-col overflow-hidden">
<Archive
onOpenEstimate={estimate => {
setCurrentEstimate(estimate);
setShowArchive(false);
}}
onClose={() => setShowArchive(false)}
/>
</div>
) : manualMode ? (
<div className="flex-1 flex flex-col overflow-hidden">
<Constructor
estimate={currentEstimate}
extractedData={extractedData}
onEstimateUpdate={handleEstimateUpdate}
manualMode={true}
onSetManualMode={setManualMode}
onCreateManualEstimate={handleCreateManualEstimate}
onAskAboutSelection={async (question, contextText) => {
if (!chatSessionId) return;
try {
await api.sendMessage(chatSessionId, `[Вопрос по выделенному]\n\nКонтекст: ${contextText}\n\nВопрос: ${question}`, 'discuss');
setExternalMessageTrigger(t => t + 1);
} catch (e) {
console.error('Failed to ask about selection:', e);
}
}}
/>
</div>
) : (
<>
<div className="w-[400px] border-r border-slate-200 dark:border-slate-700 flex flex-col bg-slate-50 dark:bg-slate-800/50 shrink-0">
<Chat
currentEstimate={currentEstimate}
onExtractedData={handleExtractedData}
onCreateEstimate={handleCreateEstimate}
onFillEstimate={fillCurrentEstimateFromData}
onSessionReady={setChatSessionId}
extractedData={extractedData}
externalMessageTrigger={externalMessageTrigger}
/>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<Constructor
estimate={currentEstimate}
extractedData={extractedData}
onEstimateUpdate={handleEstimateUpdate}
manualMode={false}
onSetManualMode={setManualMode}
onCreateManualEstimate={handleCreateManualEstimate}
onAskAboutSelection={async (question, contextText) => {
if (!chatSessionId) return;
try {
await api.sendMessage(chatSessionId, `[Вопрос по выделенному]\n\nКонтекст: ${contextText}\n\nВопрос: ${question}`, 'discuss');
setExternalMessageTrigger(t => t + 1);
} catch (e) {
console.error('Failed to ask about selection:', e);
}
}}
/>
</div>
</>
)}
</main>
</div>
{showShareModal && currentEstimate && !currentEstimate.sharedWithMe && (
<ShareModal
estimate={currentEstimate}
onClose={() => setShowShareModal(false)}
onShared={() => {}}
/>
)}
{showSettings && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowSettings(false)}>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden" onClick={e => e.stopPropagation()}>
<Settings
settings={settings}
onClose={() => setShowSettings(false)}
onSave={() => { api.getSettings().then(setSettings); }}
/>
</div>
</div>
)}
</div>
} />
</Routes>
);
}
export default App;