Files

410 lines
16 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:11:19 +05:00
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;