410 lines
16 KiB
TypeScript
Executable File
410 lines
16 KiB
TypeScript
Executable File
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;
|