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