Fix: estimates geo v2

This commit is contained in:
Arsen
2026-02-04 00:11:19 +05:00
commit 3f0086f88e
22567 changed files with 4348823 additions and 0 deletions

409
frontend/src/App.tsx Executable file
View File

@@ -0,0 +1,409 @@
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;

326
frontend/src/api/client.ts Executable file
View File

@@ -0,0 +1,326 @@
const API_BASE = '/api';
const TOKEN_KEY = 'smeta-token';
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthError';
}
}
let onUnauthorized: (() => void) | null = null;
export function setOnUnauthorized(cb: () => void) {
onUnauthorized = cb;
}
export function getStoredToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setStoredToken(token: string) {
if (typeof window !== 'undefined') localStorage.setItem(TOKEN_KEY, token);
}
export function clearStoredToken() {
if (typeof window !== 'undefined') localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
const token = getStoredToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const config: RequestInit = {
credentials: 'include',
headers,
...options,
};
const response = await fetch(url, config);
if (response.status === 401) {
clearStoredToken();
onUnauthorized?.();
throw new AuthError('Требуется авторизация');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Request failed');
}
if (response.status === 204) {
return {} as T;
}
return response.json();
}
export const api = {
// Settings
getSettings: () => request<any>('/settings'),
updateSetting: (key: string, value: any) =>
request<any>(`/settings/${key}`, {
method: 'PUT',
body: JSON.stringify({ value }),
}),
// Chat
createChatSession: (estimateId?: string) =>
request<any>('/chat/sessions', {
method: 'POST',
body: JSON.stringify({ estimateId }),
}),
getChatSession: (sessionId: string) =>
request<any>(`/chat/sessions/${sessionId}`),
getChatSessionByEstimate: (estimateId: string) =>
request<any>(`/chat/sessions/by-estimate/${estimateId}`),
updateChatSession: (sessionId: string, data: { estimateId?: string }) =>
request<any>(`/chat/sessions/${sessionId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
sendMessage: (sessionId: string, content: string, agentType?: string) =>
request<any>(`/chat/sessions/${sessionId}/messages`, {
method: 'POST',
body: JSON.stringify({ content, agentType }),
}),
uploadFile: async (sessionId: string, file: File) => {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
const token = getStoredToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/chat/sessions/${sessionId}/upload`, {
method: 'POST',
credentials: 'include',
headers,
body: formData,
});
if (response.status === 401) throw new AuthError('Требуется авторизация');
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Upload failed');
}
return response.json();
},
// Auth
login: (email: string, password: string) =>
request<{ user: { id: string; email: string; name: string | null }; token?: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
register: (email: string, password: string, name?: string) =>
request<{ user: { id: string; email: string; name: string | null }; token?: string }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, name }),
}),
logout: () =>
request<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
getMe: () =>
request<{ user: { id: string; email: string; name: string | null } }>('/auth/me'),
// Estimates
getEstimates: () => request<any[]>('/estimates'),
getEstimate: (id: string) => request<any>(`/estimates/${id}`),
createEstimate: (data: {
directionCode: string;
objectName: string;
customer: string;
executor?: string;
vatRate?: number;
withVat?: boolean;
}) =>
request<any>('/estimates', {
method: 'POST',
body: JSON.stringify(data),
}),
updateEstimate: (id: string, data: any) =>
request<any>(`/estimates/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
addEstimateItem: (estimateId: string, data: any) =>
request<any>(`/estimates/${estimateId}/items`, {
method: 'POST',
body: JSON.stringify(data),
}),
updateEstimateItem: (estimateId: string, itemId: string, data: any) =>
request<any>(`/estimates/${estimateId}/items/${itemId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteEstimateItem: (estimateId: string, itemId: string) =>
request<void>(`/estimates/${estimateId}/items/${itemId}`, {
method: 'DELETE',
}),
recalculateEstimate: (id: string) =>
request<any>(`/estimates/${id}/recalculate`, {
method: 'POST',
}),
downloadPdf: async (id: string) => {
const headers: Record<string, string> = {};
const token = getStoredToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/estimates/${id}/pdf`, {
credentials: 'include',
headers,
});
if (response.status === 401) {
clearStoredToken();
throw new AuthError('Требуется авторизация');
}
if (!response.ok) throw new Error('Failed to download PDF');
return response.blob();
},
deleteEstimate: (id: string) =>
request<void>(`/estimates/${id}`, {
method: 'DELETE',
}),
// Estimate versions (history)
getEstimateVersions: (estimateId: string) =>
request<Array<{ id: string; versionNumber: number; createdAt: string }>>(`/estimates/${estimateId}/versions`),
getEstimateVersion: (estimateId: string, versionId: string) =>
request<{ snapshot: any }>(`/estimates/${estimateId}/versions/${versionId}`),
restoreEstimateVersion: (estimateId: string, versionId: string) =>
request<any>(`/estimates/${estimateId}/versions/${versionId}/restore`, {
method: 'POST',
}),
// Share
shareEstimate: (estimateId: string, email: string) =>
request<{ sharedWith: { id: string; email: string; name: string | null } }>(`/estimates/${estimateId}/share`, {
method: 'POST',
body: JSON.stringify({ email }),
}),
unshareEstimate: (estimateId: string, userId: string) =>
request<void>(`/estimates/${estimateId}/share/${userId}`, { method: 'DELETE' }),
getEstimateShares: (estimateId: string) =>
request<Array<{ id: string; sharedWith: { id: string; email: string; name: string | null }; createdAt: string }>>(`/estimates/${estimateId}/shares`),
getShareNotes: (estimateId: string) =>
request<Array<{ id: string; shareId: string; content: string; createdAt: string; author: { id: string; email: string; name: string | null } }>>(`/estimates/${estimateId}/share-notes`),
addShareNote: (estimateId: string, content: string, shareId?: string) =>
request<any>(`/estimates/${estimateId}/share-notes`, { method: 'POST', body: JSON.stringify(shareId ? { content, shareId } : { content }) }),
// Price books
getPriceBooks: () => request<any[]>('/price-books'),
getSbcRef: (directionCode: string) =>
request<{ name: string | null }>(`/price-books/sbc-ref?directionCode=${encodeURIComponent(directionCode)}`),
getPriceBook: (id: string) => request<any>(`/price-books/${id}`),
searchPriceItems: (query: string, priceBookId?: string, directionCode?: string) =>
request<any[]>(`/price-books/items/search?query=${encodeURIComponent(query)}${priceBookId ? `&priceBookId=${priceBookId}` : ''}${directionCode ? `&directionCode=${encodeURIComponent(directionCode)}` : ''}`),
getCoefficients: (type?: string) =>
request<any[]>(`/price-books/coefficients/all${type ? `?type=${type}` : ''}`),
getInflationIndices: () => request<any[]>('/price-books/inflation-indices'),
getSurveyDirections: () => request<any[]>('/price-books/directions/all'),
// Admin / Reference editors (справочники)
getPriceBooksAdmin: () => request<any[]>('/admin/price-books'),
deletePriceBook: (id: string) =>
request<void>(`/admin/price-books/${id}`, { method: 'DELETE' }),
importPriceBookJson: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
const token = getStoredToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/admin/price-books/import-json`, {
method: 'POST',
credentials: 'include',
headers,
body: formData,
});
if (response.status === 401) {
clearStoredToken();
throw new AuthError('Требуется авторизация');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Import failed');
}
return response.json();
},
getCoefficientsAdmin: (type?: string) =>
request<any[]>(`/admin/coefficients${type ? `?type=${encodeURIComponent(type)}` : ''}`),
createCoefficient: (data: { type: string; code: string; name: string; value: number; description?: string; conditions?: any }) =>
request<any>('/admin/coefficients', { method: 'POST', body: JSON.stringify(data) }),
updateCoefficient: (id: string, data: { name?: string; value?: number; description?: string; conditions?: any; isActive?: boolean }) =>
request<any>(`/admin/coefficients/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteCoefficient: (id: string) =>
request<void>(`/admin/coefficients/${id}`, { method: 'DELETE' }),
importCoefficientsJson: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const token = getStoredToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/admin/coefficients/import`, {
method: 'POST',
credentials: 'include',
headers,
body: formData,
});
if (response.status === 401) {
clearStoredToken();
throw new AuthError('Требуется авторизация');
}
if (!response.ok) {
const err = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(err.error || 'Import failed');
}
return response.json();
},
getInflationIndicesAdmin: () => request<any[]>('/admin/inflation-indices'),
createInflationIndex: (data: {
baseDate: string;
effectiveFrom: string;
effectiveTo?: string;
indexValue: number;
documentRef?: string;
}) =>
request<any>('/admin/inflation-indices', { method: 'POST', body: JSON.stringify(data) }),
updateInflationIndex: (
id: string,
data: {
baseDate?: string;
effectiveFrom?: string;
effectiveTo?: string | null;
indexValue?: number;
documentRef?: string;
isActive?: boolean;
}
) =>
request<any>(`/admin/inflation-indices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteInflationIndex: (id: string) =>
request<void>(`/admin/inflation-indices/${id}`, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,55 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { Settings } from '../Settings/Settings';
import { api } from '../../api/client';
/**
* Админ-панель по маршруту /geo.
* Содержит настройки приложения (компания, AI, справочники СБЦ, коэффициенты, индексы).
*/
export function AdminPanel() {
const [settings, setSettings] = useState<any>(null);
const loadSettings = async () => {
try {
const data = await api.getSettings();
setSettings(data);
} catch (error) {
console.error('Failed to load settings:', error);
}
};
useEffect(() => {
loadSettings();
}, []);
return (
<div className="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-900">
{/* Header */}
<header className="h-14 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between px-4 bg-white dark:bg-slate-900 shrink-0">
<div className="flex items-center gap-3">
<Link
to="/"
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Назад
</Link>
<div className="h-6 w-px bg-slate-200 dark:bg-slate-600" />
<h1 className="text-lg font-semibold text-slate-800 dark:text-slate-100">Админ-панель</h1>
</div>
</header>
{/* Settings as full page */}
<main className="flex-1 overflow-auto p-4 md:p-6">
<Settings
settings={settings}
variant="page"
onClose={() => {}}
onSave={loadSettings}
/>
</main>
</div>
);
}

View File

@@ -0,0 +1,426 @@
import React, { useState, useEffect, useMemo } from 'react';
import { FolderArchive, Download, Trash2, FileText, Loader2, Share2, Search, CheckSquare, Square } from 'lucide-react';
import { api } from '../../api/client';
import { Estimate } from '../../App';
import { ShareModal } from './ShareModal';
interface EstimateItemRow {
workName?: string;
}
interface EstimateListItem {
id: string;
number: string;
objectName: string;
customer: string;
executor: string;
direction?: { code: string; name: string; shortName: string };
items?: EstimateItemRow[];
totals?: unknown[];
createdAt?: string;
sharedWithMe?: boolean;
}
interface ArchiveProps {
onOpenEstimate: (estimate: Estimate) => void;
onClose: () => void;
}
function matchSearch(item: EstimateListItem, q: string): boolean {
if (!q.trim()) return true;
const s = q.trim().toLowerCase();
const num = (item.number ?? '').toLowerCase();
const obj = (item.objectName ?? '').toLowerCase();
const cust = (item.customer ?? '').toLowerCase();
const exec = (item.executor ?? '').toLowerCase();
const dir = (item.direction?.name ?? '').toLowerCase();
const works = (item.items ?? []).map((i: EstimateItemRow) => (i.workName ?? '').toLowerCase()).join(' ');
return [num, obj, cust, exec, dir, works].some(t => t.includes(s));
}
export function Archive({ onOpenEstimate, onClose }: ArchiveProps) {
const [estimates, setEstimates] = useState<EstimateListItem[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [shareEstimate, setShareEstimate] = useState<Estimate | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deletingSelected, setDeletingSelected] = useState(false);
type FilterBy = { type: 'customer'; value: string } | { type: 'object'; value: string } | null;
const [filterBy, setFilterBy] = useState<FilterBy>(null);
useEffect(() => {
loadEstimates();
}, []);
const loadEstimates = async () => {
try {
setLoading(true);
const data = await api.getEstimates();
setEstimates(data);
} catch (error) {
console.error('Failed to load estimates:', error);
} finally {
setLoading(false);
}
};
const handleOpen = async (id: string) => {
try {
const estimate = await api.getEstimate(id);
onOpenEstimate(estimate);
onClose();
} catch (error) {
console.error('Failed to open estimate:', error);
}
};
const handleDownloadPdf = async (item: EstimateListItem) => {
try {
setDownloadingId(item.id);
const blob = await api.downloadPdf(item.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `smeta-${item.number}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download PDF:', error);
} finally {
setDownloadingId(null);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Удалить эту смету?')) return;
try {
setDeletingId(id);
await api.deleteEstimate(id);
setEstimates(prev => prev.filter(e => e.id !== id));
} catch (error) {
console.error('Failed to delete estimate:', error);
} finally {
setDeletingId(null);
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '—';
const d = new Date(dateStr);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(d);
};
const applyFilter = (list: EstimateListItem[]) => {
let out = list.filter(e => matchSearch(e, searchQuery));
if (filterBy) {
if (filterBy.type === 'customer') out = out.filter(e => (e.customer ?? '').trim() === filterBy!.value);
else if (filterBy.type === 'object') out = out.filter(e => (e.objectName ?? '').trim() === filterBy!.value);
}
return out;
};
const myEstimates = useMemo(
() => applyFilter(estimates.filter(e => e.sharedWithMe !== true)),
[estimates, searchQuery, filterBy]
);
const sharedWithMe = useMemo(
() => applyFilter(estimates.filter(e => e.sharedWithMe === true)),
[estimates, searchQuery, filterBy]
);
const allMyIds = useMemo(() => new Set(estimates.filter(e => e.sharedWithMe !== true).map(e => e.id)), [estimates]);
const selectAll = () => setSelectedIds(new Set(myEstimates.map(e => e.id)));
const deselectAll = () => setSelectedIds(new Set());
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const isAllSelected = myEstimates.length > 0 && myEstimates.every(e => selectedIds.has(e.id));
const handleDeleteSelected = async () => {
const ids = Array.from(selectedIds).filter(id => allMyIds.has(id));
if (ids.length === 0) return;
if (!window.confirm(`Удалить выбранные сметы (${ids.length})?`)) return;
const toRemove = new Set(ids);
try {
setDeletingSelected(true);
for (const id of ids) {
await api.deleteEstimate(id);
}
setEstimates(prev => prev.filter(e => !toRemove.has(e.id)));
setSelectedIds(new Set());
setSelectionMode(false);
} catch (e) {
console.error('Failed to delete estimates:', e);
} finally {
setDeletingSelected(false);
}
};
const renderRow = (item: EstimateListItem) => {
const selected = selectedIds.has(item.id);
const canSelect = item.sharedWithMe !== true;
const objectName = (item.objectName ?? '').trim() || 'Без названия';
const customerName = (item.customer ?? '').trim() || '—';
return (
<div
key={item.id}
className={`flex items-start gap-3 p-3 border rounded-xl transition-colors ${
selected ? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20' : 'border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800/50'
}`}
>
{selectionMode && canSelect ? (
<button
type="button"
onClick={() => toggleSelect(item.id)}
className="flex items-center justify-center w-8 h-8 shrink-0 text-primary-600 dark:text-primary-400 rounded mt-0.5"
title={selected ? 'Снять выбор' : 'Выбрать'}
>
{selected ? <CheckSquare className="w-5 h-5" /> : <Square className="w-5 h-5" />}
</button>
) : null}
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => !selectionMode && handleOpen(item.id)}
role="button"
tabIndex={0}
onKeyDown={e => { if (!selectionMode && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); handleOpen(item.id); } }}
aria-label={`Открыть смету №${item.number}`}
>
<div className="flex items-start gap-2 flex-wrap">
<span className="font-semibold text-primary-600 dark:text-primary-400">{item.number}</span>
<span className="text-slate-800 dark:text-slate-200">
{!selectionMode ? (
<button
type="button"
onClick={e => { e.stopPropagation(); const v = (item.objectName ?? '').trim(); setFilterBy(prev => (prev?.type === 'object' && prev?.value === v) ? null : { type: 'object', value: v }); }}
className="text-left hover:underline focus:outline-none focus:underline rounded"
title="Показать все сметы по этому объекту"
>
{objectName}
</button>
) : (
objectName
)}
</span>
</div>
<div className="mt-1.5 text-xs text-slate-500 dark:text-slate-400">
{formatDate(item.createdAt)}
</div>
<div className="mt-1 text-sm text-slate-600 dark:text-slate-300 space-y-0.5">
<div>
<span className="text-slate-500 dark:text-slate-400">Заказчик: </span>
{!selectionMode ? (
<button
type="button"
onClick={e => { e.stopPropagation(); const v = (item.customer ?? '').trim() || '—'; setFilterBy(prev => (prev?.type === 'customer' && prev?.value === v) ? null : { type: 'customer', value: v }); }}
className="hover:underline focus:outline-none focus:underline rounded font-medium"
title="Показать все сметы по этому заказчику"
>
{customerName}
</button>
) : (
<span>{customerName}</span>
)}
</div>
<div>
<span className="text-slate-500 dark:text-slate-400">Вид работ: </span>
<span>{item.direction?.name || '—'}</span>
</div>
</div>
</div>
{!selectionMode && (
<div className="flex items-center gap-1 flex-shrink-0" onClick={e => e.stopPropagation()}>
<button
type="button"
onClick={() => handleOpen(item.id)}
className="p-2 text-slate-600 dark:text-slate-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-300 rounded-lg transition-colors"
title="Открыть"
>
<FileText className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDownloadPdf(item)}
disabled={downloadingId === item.id}
className="p-2 text-slate-600 dark:text-slate-400 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg transition-colors disabled:opacity-50"
title="Скачать PDF"
>
{downloadingId === item.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</button>
{item.sharedWithMe !== true && (
<>
<button
type="button"
onClick={async () => {
const est = await api.getEstimate(item.id);
setShareEstimate(est);
}}
className="p-2 text-slate-600 dark:text-slate-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
title="Поделиться"
>
<Share2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDelete(item.id)}
disabled={deletingId === item.id}
className="p-2 text-slate-600 dark:text-slate-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400 rounded-lg transition-colors disabled:opacity-50"
title="Удалить"
>
{deletingId === item.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</>
)}
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-slate-50">
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-slate-900">
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<FolderArchive className="w-5 h-5 text-slate-600 dark:text-slate-400" />
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">Архив смет</h2>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Поиск — минимализм */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Поиск по номеру, заказчику, работам..."
className="w-48 sm:w-56 pl-8 pr-3 py-1.5 text-sm border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{!selectionMode ? (
<button
type="button"
onClick={() => { setSelectionMode(true); setSelectedIds(new Set()); }}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors flex items-center gap-1.5"
>
<CheckSquare className="w-4 h-4" />
Выбрать
</button>
) : (
<>
<button
type="button"
onClick={isAllSelected ? deselectAll : selectAll}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
{isAllSelected ? 'Снять выбор' : 'Выбрать все'}
</button>
<button
type="button"
onClick={handleDeleteSelected}
disabled={selectedIds.size === 0 || deletingSelected}
className="px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-1.5"
>
{deletingSelected ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Удалить выбранные {selectedIds.size > 0 ? `(${selectedIds.size})` : ''}
</button>
<button
type="button"
onClick={() => { setSelectionMode(false); setSelectedIds(new Set()); }}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
Отмена
</button>
</>
)}
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
К конструктору
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 space-y-6">
{filterBy && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-slate-600 dark:text-slate-400">
{filterBy.type === 'customer' ? 'По заказчику:' : 'По объекту:'}
</span>
<span className="px-2.5 py-1 rounded-lg bg-primary-100 dark:bg-primary-900/40 text-primary-800 dark:text-primary-200 text-sm font-medium truncate max-w-[200px] sm:max-w-xs" title={filterBy.type === 'object' && !filterBy.value ? 'Без названия' : filterBy.value}>
{filterBy.type === 'object' && !filterBy.value ? 'Без названия' : filterBy.value}
</span>
<button
type="button"
onClick={() => setFilterBy(null)}
className="text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:underline"
>
Сбросить фильтр
</button>
</div>
)}
<>
<section>
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">Мои сметы</h3>
{myEstimates.length > 0 ? (
<div className="space-y-2">
{myEstimates.map(renderRow)}
</div>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400 py-3">
{searchQuery.trim() ? 'Ничего не найдено по поиску' : 'Нет смет. Создайте в чате или через кнопку создания сметы.'}
</p>
)}
</section>
<section>
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">Поделились с вами</h3>
{sharedWithMe.length > 0 ? (
<div className="space-y-2">
{sharedWithMe.map(renderRow)}
</div>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400 py-3">
{searchQuery.trim() ? 'Ничего не найдено по поиску' : 'Вам пока не делились сметами'}
</p>
)}
</section>
</>
</div>
{shareEstimate && (
<ShareModal
estimate={shareEstimate}
onClose={() => setShareEstimate(null)}
onShared={loadEstimates}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
import React, { useState } from 'react';
import { X, UserPlus, Loader2 } from 'lucide-react';
import { api } from '../../api/client';
import { Estimate } from '../../App';
interface ShareModalProps {
estimate: Estimate;
onClose: () => void;
onShared: () => void;
}
interface ShareEntry {
id: string;
sharedWith: { id: string; email: string; name: string | null };
createdAt: string;
}
export function ShareModal({ estimate, onClose, onShared }: ShareModalProps) {
const [email, setEmail] = useState('');
const [shares, setShares] = useState<ShareEntry[]>([]);
const [loadingShares, setLoadingShares] = useState(true);
const [sharing, setSharing] = useState(false);
const [error, setError] = useState<string | null>(null);
React.useEffect(() => {
loadShares();
}, [estimate.id]);
const loadShares = async () => {
try {
setLoadingShares(true);
const data = await api.getEstimateShares(estimate.id);
setShares(data);
} catch {
setShares([]);
} finally {
setLoadingShares(false);
}
};
const handleShare = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const trimmed = email.trim().toLowerCase();
if (!trimmed) {
setError('Введите email');
return;
}
setSharing(true);
try {
await api.shareEstimate(estimate.id, trimmed);
setEmail('');
await loadShares();
onShared();
} catch (e: any) {
setError(e?.message || 'Не удалось поделиться');
} finally {
setSharing(false);
}
};
const handleUnshare = async (userId: string) => {
try {
await api.unshareEstimate(estimate.id, userId);
await loadShares();
onShared();
} catch (e) {
console.error('Unshare failed:', e);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="bg-white rounded-xl shadow-xl border border-slate-200 w-full max-w-md"
onClick={e => e.stopPropagation()}
>
<div className="p-4 border-b border-slate-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-800">Поделиться сметой</h3>
<button
onClick={onClose}
className="p-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-4">
<p className="text-sm text-slate-600">
{estimate.number} {estimate.objectName}
</p>
<form onSubmit={handleShare} className="flex gap-2">
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="email@example.com"
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none text-sm"
/>
<button
type="submit"
disabled={sharing}
className="px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
>
{sharing ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Поделиться
</button>
</form>
{error && <p className="text-sm text-red-600">{error}</p>}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-2">Доступ открыт:</h4>
{loadingShares ? (
<div className="flex justify-center py-4"><Loader2 className="w-6 h-6 text-slate-400 animate-spin" /></div>
) : shares.length === 0 ? (
<p className="text-sm text-slate-500">Никому не передано</p>
) : (
<ul className="space-y-2">
{shares.map(s => (
<li
key={s.id}
className="flex items-center justify-between py-2 px-3 bg-slate-50 rounded-lg text-sm"
>
<span className="text-slate-700">{s.sharedWith.name || s.sharedWith.email}</span>
<button
type="button"
onClick={() => handleUnshare(s.sharedWith.id)}
className="text-slate-500 hover:text-red-600 text-xs"
>
Отменить
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
export function AuthForm() {
const { login, register, error, clearError } = useAuth();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
setLocalError(null);
if (!email.trim()) {
setLocalError('Введите email');
return;
}
if (!password) {
setLocalError('Введите пароль');
return;
}
if (password.length < 4) {
setLocalError('Пароль не менее 4 символов');
return;
}
setSubmitting(true);
try {
if (mode === 'login') {
await login(email.trim().toLowerCase(), password);
} else {
await register(email.trim().toLowerCase(), password, name.trim() || undefined);
}
} catch {
// error set in context
} finally {
setSubmitting(false);
}
};
const err = error || localError;
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100 dark:bg-slate-900 p-4">
<div className="w-full max-w-sm bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-primary-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold">СА</span>
</div>
<h1 className="text-xl font-semibold text-slate-800 dark:text-slate-100">Смета Ассистент</h1>
</div>
<h2 className="text-lg font-medium text-slate-700 dark:text-slate-300 mb-4">
{mode === 'login' ? 'Вход' : 'Регистрация'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Email</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none placeholder:text-slate-500 dark:placeholder:text-slate-400"
placeholder="user@example.com"
autoComplete="email"
/>
</div>
{mode === 'register' && (
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Имя (необязательно)</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none placeholder:text-slate-500 dark:placeholder:text-slate-400"
placeholder="Иван Иванов"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none placeholder:text-slate-500 dark:placeholder:text-slate-400"
placeholder="••••••••"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
/>
</div>
{err && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-3 py-2 rounded-lg">{err}</div>
)}
<button
type="submit"
disabled={submitting}
className="w-full py-2.5 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 disabled:opacity-50 transition-colors"
>
{submitting ? '...' : mode === 'login' ? 'Войти' : 'Зарегистрироваться'}
</button>
</form>
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400 text-center">
{mode === 'login' ? (
<>
Нет аккаунта?{' '}
<button
type="button"
onClick={() => { setMode('register'); setLocalError(null); clearError(); }}
className="text-primary-600 dark:text-primary-400 hover:underline font-medium"
>
Зарегистрироваться
</button>
</>
) : (
<>
Уже есть аккаунт?{' '}
<button
type="button"
onClick={() => { setMode('login'); setLocalError(null); clearError(); }}
className="text-primary-600 dark:text-primary-400 hover:underline font-medium"
>
Войти
</button>
</>
)}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,488 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, Loader2, FileText, X, Bot } from 'lucide-react';
import { api } from '../../api/client';
import { ExtractedData, Estimate } from '../../App';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
metadata?: any;
}
const WELCOME_MESSAGE: Message = {
id: 'welcome',
role: 'assistant',
content: `Здравствуйте! Я помогу вам составить смету на изыскательские работы.
Вы можете:
• Написать текст технического задания
• Загрузить PDF или Excel файл с ТЗ или сметой — данные из файла автоматически подставятся в смету справа
• Прикрепить существующую смету для обсуждения
• Описать объект и требуемые работы
С чего начнём?`,
};
interface ChatProps {
currentEstimate: Estimate | null;
onExtractedData: (data: ExtractedData) => void;
onCreateEstimate: () => void;
onFillEstimate?: (estimateId: string, data: ExtractedData) => void;
onSessionReady?: (sessionId: string) => void;
onAttachEstimate?: (estimate: Estimate | null) => void;
extractedData: ExtractedData | null;
/** При изменении — перезагрузить сообщения (напр. после отправки из Constructor) */
externalMessageTrigger?: number;
}
function messagesFromSession(session: { messages: Array<{ id: string; role: string; content: string; metadata?: any }> }): Message[] {
if (!session.messages || session.messages.length === 0) {
return [WELCOME_MESSAGE];
}
return session.messages.map(m => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content,
metadata: m.metadata,
}));
}
export function Chat({
currentEstimate,
onExtractedData,
onCreateEstimate,
onFillEstimate,
onSessionReady,
onAttachEstimate,
extractedData,
externalMessageTrigger = 0,
}: ChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionLoading, setSessionLoading] = useState(true);
const [estimatePickerOpen, setEstimatePickerOpen] = useState(false);
const [estimateList, setEstimateList] = useState<Estimate[]>([]);
const [agentMode, setAgentMode] = useState<'fill' | 'add_works' | 'discuss'>('fill');
const [agentDropdownOpen, setAgentDropdownOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const agentDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
setSessionLoading(true);
setSessionId(null);
setMessages([]);
const initSession = async () => {
try {
if (currentEstimate) {
try {
const { session } = await api.getChatSessionByEstimate(currentEstimate.id);
if (session && !cancelled) {
setSessionId(session.id);
setMessages(messagesFromSession(session));
onSessionReady?.(session.id);
} else if (!cancelled) {
const newSession = await api.createChatSession(currentEstimate.id);
setSessionId(newSession.id);
setMessages([WELCOME_MESSAGE]);
onSessionReady?.(newSession.id);
}
} catch {
if (!cancelled) {
try {
const newSession = await api.createChatSession(currentEstimate.id);
setSessionId(newSession.id);
setMessages([WELCOME_MESSAGE]);
onSessionReady?.(newSession.id);
} catch {
/* fallthrough to finally */
}
}
}
} else {
const session = await api.createChatSession();
if (!cancelled) {
setSessionId(session.id);
setMessages([WELCOME_MESSAGE]);
onSessionReady?.(session.id);
}
}
} catch (error) {
if (!cancelled) {
console.error('Failed to init session:', error);
}
} finally {
if (!cancelled) {
setSessionLoading(false);
}
}
};
initSession();
return () => { cancelled = true; };
}, [currentEstimate?.id ?? null]);
useEffect(() => {
if (externalMessageTrigger <= 0 || !sessionId) return;
let cancelled = false;
api.getChatSession(sessionId).then((session) => {
if (!cancelled) {
setMessages(messagesFromSession(session));
}
}).catch(() => {});
return () => { cancelled = true; };
}, [externalMessageTrigger, sessionId]);
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (pickerRef.current && !pickerRef.current.contains(target)) setEstimatePickerOpen(false);
if (agentDropdownRef.current && !agentDropdownRef.current.contains(target)) setAgentDropdownOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const loadEstimatesForPicker = async () => {
try {
const data = await api.getEstimates();
const sorted = [...data].sort(
(a, b) =>
new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime()
);
setEstimateList(sorted.slice(0, 20));
} catch (error) {
console.error('Failed to load estimates:', error);
}
};
const handleAttachEstimate = async (estimate: { id: string }) => {
try {
const fullEstimate = await api.getEstimate(estimate.id);
if (sessionId && onAttachEstimate) {
await api.updateChatSession(sessionId, { estimateId: fullEstimate.id });
}
onAttachEstimate?.(fullEstimate);
} catch (error) {
console.error('Failed to attach estimate:', error);
}
setEstimatePickerOpen(false);
};
const handleDetachEstimate = () => {
onAttachEstimate?.(null);
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const handleSend = async () => {
if (!input.trim() || !sessionId || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input.trim(),
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const response = await api.sendMessage(sessionId, userMessage.content, agentMode === 'fill' ? undefined : agentMode);
const assistantMessage: Message = {
id: response.assistantMessage.id,
role: 'assistant',
content: response.assistantMessage.content,
metadata: response.assistantMessage.metadata,
};
setMessages(prev => [...prev, assistantMessage]);
if (response.extractedData) {
onExtractedData(response.extractedData);
// Авто-заполнение сметы при полных данных — обновляется только правая часть
const d = response.extractedData;
if (currentEstimate && onFillEstimate && d?.direction && d?.customer && d?.objectName && (d?.works?.length ?? 0) > 0) {
onFillEstimate(currentEstimate.id, d);
}
}
} catch (error) {
console.error('Failed to send message:', error);
setMessages(prev => [
...prev,
{
id: 'error-' + Date.now(),
role: 'assistant',
content: 'Произошла ошибка. Попробуйте ещё раз.',
},
]);
} finally {
setIsLoading(false);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !sessionId) return;
setIsLoading(true);
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: `📎 Загружен файл: ${file.name}`,
};
setMessages(prev => [...prev, userMessage]);
try {
const response = await api.uploadFile(sessionId, file);
const assistantMessage: Message = {
id: response.assistantMessage.id,
role: 'assistant',
content: response.assistantMessage.content,
metadata: response.assistantMessage.metadata,
};
setMessages(prev => [...prev, assistantMessage]);
if (response.extractedData) {
onExtractedData(response.extractedData);
// Авто-заполнение сметы при полных данных — обновляется только правая часть
const d = response.extractedData;
if (currentEstimate && onFillEstimate && d?.direction && d?.customer && d?.objectName && (d?.works?.length ?? 0) > 0) {
onFillEstimate(currentEstimate.id, d);
}
}
} catch (error) {
console.error('Failed to upload file:', error);
setMessages(prev => [
...prev,
{
id: 'error-' + Date.now(),
role: 'assistant',
content: 'Не удалось обработать файл. Попробуйте другой формат.',
},
]);
} finally {
setIsLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const canCreateEstimate = !currentEstimate &&
extractedData?.direction &&
extractedData?.customer &&
extractedData?.objectName;
return (
<div className="flex flex-col h-full">
{/* Chat header */}
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<h2 className="font-medium text-slate-800 dark:text-slate-200">Чат с ассистентом</h2>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{currentEstimate
? `Обсуждаем смету №${currentEstimate.number} — задайте вопрос или попросите рекомендации`
: 'Опишите объект, загрузите ТЗ/смету (PDF, Excel) или прикрепите смету'}
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
{sessionLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 text-primary-600 animate-spin" />
</div>
) : (
messages.map(message => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2.5 ${
message.role === 'user'
? 'bg-primary-600 text-white rounded-br-md'
: 'bg-white text-slate-800 shadow-sm border border-slate-200 rounded-bl-md'
}`}
>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
</div>
</div>
))
)}
{!sessionLoading && isLoading && (
<div className="flex justify-start">
<div className="bg-white rounded-2xl rounded-bl-md px-4 py-3 shadow-sm border border-slate-200">
<Loader2 className="w-5 h-5 text-primary-600 animate-spin" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Create / Fill estimate button */}
{canCreateEstimate && (
<div className="px-4 py-2 border-t border-slate-200 bg-white">
<button
onClick={onCreateEstimate}
className="w-full py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Создать смету
</button>
</div>
)}
{/* Attached estimate card */}
{currentEstimate && onAttachEstimate && (
<div className="px-4 py-2 border-t border-slate-200 bg-primary-50/50">
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-white border border-primary-200">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-4 h-4 text-primary-600 flex-shrink-0" />
<span className="text-sm font-medium text-slate-800 truncate">
Смета {currentEstimate.number} {currentEstimate.objectName}
</span>
</div>
<button
onClick={handleDetachEstimate}
className="p-1 hover:bg-slate-100 rounded text-slate-500"
title="Открепить смету"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Input area */}
<div className="p-4 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-center gap-2 min-h-[44px]">
<div className="relative" ref={agentDropdownRef}>
<button
type="button"
onClick={() => setAgentDropdownOpen(v => !v)}
className={`p-2 rounded-lg transition-colors ${
agentDropdownOpen ? 'bg-primary-100 dark:bg-primary-500/30 text-primary-600 dark:text-primary-400' : 'hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400'
}`}
title={`Режим: ${agentMode === 'fill' ? 'Заполнение сметы' : agentMode === 'add_works' ? 'Добавить работы' : 'Обсуждение'}`}
>
<Bot className="w-5 h-5" />
</button>
{agentDropdownOpen && (
<div className="absolute bottom-full left-0 mb-1 py-1 w-48 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-lg shadow-lg z-20">
<div className="px-2 py-1.5 text-xs font-medium text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-600">Режим агента</div>
<button onClick={() => { setAgentMode('fill'); setAgentDropdownOpen(false); }} className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 ${agentMode === 'fill' ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300' : 'text-slate-700 dark:text-slate-300'}`}>
Заполнение сметы
</button>
<button onClick={() => { setAgentMode('add_works'); setAgentDropdownOpen(false); }} className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 ${agentMode === 'add_works' ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300' : 'text-slate-700 dark:text-slate-300'}`}>
Добавить работы
</button>
<button onClick={() => { setAgentMode('discuss'); setAgentDropdownOpen(false); }} className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 ${agentMode === 'discuss' ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300' : 'text-slate-700 dark:text-slate-300'}`}>
Обсуждение
</button>
</div>
)}
</div>
{onAttachEstimate && (
<div className="relative" ref={pickerRef}>
<button
onClick={() => {
setEstimatePickerOpen(!estimatePickerOpen);
if (!estimatePickerOpen) loadEstimatesForPicker();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors text-slate-500"
title="Прикрепить смету"
>
<FileText className="w-5 h-5" />
</button>
{estimatePickerOpen && (
<div className="absolute bottom-full left-0 mb-1 w-72 max-h-64 overflow-auto bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-lg shadow-lg z-20">
<div className="p-2 text-xs font-medium text-slate-500 border-b border-slate-200">
Выберите смету
</div>
<div className="p-1">
{estimateList.length === 0 ? (
<div className="px-2 py-4 text-sm text-slate-500 text-center">Загрузка...</div>
) : (
estimateList.map(item => (
<button
key={item.id}
onClick={() => handleAttachEstimate(item)}
className={`w-full text-left px-2 py-2 rounded text-sm hover:bg-slate-100 ${
currentEstimate?.id === item.id ? 'bg-primary-50 text-primary-800' : ''
}`}
>
{item.number} {item.objectName || 'Без названия'}
</button>
))
)}
</div>
</div>
)}
</div>
)}
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors text-slate-500 dark:text-slate-400"
title="Загрузить файл"
>
<Paperclip className="w-5 h-5" />
</button>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.txt,.xlsx,.xls"
onChange={handleFileUpload}
className="hidden"
/>
<div className="flex-1 flex items-center min-h-[44px]">
<div className="flex-1 relative flex items-center min-h-[44px]">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Напишите текстом..."
rows={1}
className="w-full resize-none border border-slate-300 dark:border-slate-600 rounded-xl px-4 py-2.5 pr-12 text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 align-middle"
style={{ minHeight: '44px', maxHeight: '120px' }}
/>
<button
type="button"
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 bg-primary-600 hover:bg-primary-700 disabled:bg-slate-300 dark:disabled:bg-slate-600 text-white rounded-lg transition-colors"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
import React, { useState, useEffect } from 'react';
import { X, Loader2, RotateCcw } from 'lucide-react';
import { api } from '../../api/client';
export interface EstimateVersionEntry {
id: string;
versionNumber: number;
createdAt: string;
}
interface EstimateHistoryModalProps {
estimateId: string;
onClose: () => void;
onSelectVersion?: (versionId: string) => void;
onRestored?: () => void;
}
function formatDateTime(iso: string): string {
try {
const d = new Date(iso);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(d);
} catch {
return iso;
}
}
export function EstimateHistoryModal({
estimateId,
onClose,
onSelectVersion,
onRestored,
}: EstimateHistoryModalProps) {
const [versions, setVersions] = useState<EstimateVersionEntry[]>([]);
const [loading, setLoading] = useState(true);
const [restoringId, setRestoringId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
api
.getEstimateVersions(estimateId)
.then((data) => {
if (!cancelled) setVersions(data);
})
.catch((e) => {
if (!cancelled) setError(e?.message || 'Не удалось загрузить версии');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [estimateId]);
const handleRestore = async (versionId: string) => {
setRestoringId(versionId);
setError(null);
try {
await api.restoreEstimateVersion(estimateId, versionId);
onRestored?.();
onClose();
} catch (e: any) {
setError(e?.message || 'Не удалось восстановить версию');
} finally {
setRestoringId(null);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-800 dark:text-white">
История версий
</h3>
<button
type="button"
onClick={onClose}
className="p-2 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 max-h-[70vh] overflow-y-auto">
{error && (
<p className="text-sm text-red-600 dark:text-red-400 mb-3">{error}</p>
)}
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
</div>
) : versions.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400 py-4">
Нет сохранённых версий. Версии создаются при сохранении и пересчёте сметы.
</p>
) : (
<ul className="space-y-2">
{versions.map((v) => (
<li
key={v.id}
className="flex items-center justify-between gap-3 py-3 px-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-sm"
>
<button
type="button"
onClick={() => {
onSelectVersion?.(v.id);
onClose();
}}
className="flex-1 text-left min-w-0"
>
<span className="font-medium text-slate-800 dark:text-slate-200">
Версия {v.versionNumber}
</span>
<span className="block text-slate-500 dark:text-slate-400 text-xs mt-0.5">
{formatDateTime(v.createdAt)}
</span>
</button>
{onRestored && (
<button
type="button"
onClick={() => handleRestore(v.id)}
disabled={restoringId !== null}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-slate-200 hover:bg-slate-300 dark:hover:bg-slate-500 rounded-lg transition-colors disabled:opacity-50"
>
{restoringId === v.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
Восстановить
</button>
)}
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,374 @@
import React, { useState, useEffect } from 'react';
import {
ChevronLeft,
ChevronRight,
FolderArchive,
FileText,
Loader2,
Share2,
Settings,
Plus,
X,
MessageSquare,
Send,
} from 'lucide-react';
import { api } from '../../api/client';
import { Estimate } from '../../App';
interface RightSidebarProps {
expanded: boolean;
onToggle: () => void;
currentEstimate: Estimate | null;
onOpenEstimate: (estimate: Estimate) => void;
onShowArchive: () => void;
onShowShare?: () => void;
onShowSettings?: () => void;
}
interface EstimateListItem {
id: string;
number: string;
objectName: string;
customer?: string;
direction?: { code: string; name: string; shortName: string };
createdAt?: string;
updatedAt?: string;
sharedWithMe?: boolean;
}
export function RightSidebar({
expanded,
onToggle,
currentEstimate,
onOpenEstimate,
onShowArchive,
onShowShare,
onShowSettings,
}: RightSidebarProps) {
const [estimates, setEstimates] = useState<EstimateListItem[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [notesEstimateId, setNotesEstimateId] = useState<string | null>(null);
const [shareNotes, setShareNotes] = useState<Array<{ id: string; content: string; createdAt: string; author: { id: string; email: string; name: string | null } }>>([]);
const [shareNotesLoading, setShareNotesLoading] = useState(false);
const [newNoteText, setNewNoteText] = useState('');
const [addingNote, setAddingNote] = useState(false);
useEffect(() => {
loadEstimates();
}, [currentEstimate?.id]);
useEffect(() => {
if (!notesEstimateId) {
setShareNotes([]);
return;
}
setShareNotesLoading(true);
api.getShareNotes(notesEstimateId)
.then(setShareNotes)
.catch(() => setShareNotes([]))
.finally(() => setShareNotesLoading(false));
}, [notesEstimateId]);
const handleNewEstimate = async () => {
if (creating) return;
setCreateError(null);
try {
setCreating(true);
const directions = await api.getSurveyDirections();
const firstDir = directions?.[0];
if (!firstDir?.code) {
setCreateError('Нет направлений. Добавьте направление в настройках.');
return;
}
const estimate = await api.createEstimate({
directionCode: firstDir.code,
objectName: '',
customer: '—',
});
const full = await api.getEstimate(estimate.id);
onOpenEstimate(full);
loadEstimates();
} catch (e) {
console.error('Failed to create estimate:', e);
setCreateError(e instanceof Error ? e.message : 'Не удалось создать смету');
} finally {
setCreating(false);
}
};
const loadEstimates = async () => {
try {
setLoading(true);
const data = await api.getEstimates();
const sorted = [...data].sort(
(a, b) =>
new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime()
);
setEstimates(sorted.slice(0, 30));
} catch (error) {
console.error('Failed to load estimates:', error);
} finally {
setLoading(false);
}
};
const handleSelectEstimate = async (id: string) => {
try {
const estimate = await api.getEstimate(id);
onOpenEstimate(estimate);
} catch (error) {
console.error('Failed to open estimate:', error);
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(d);
};
const formatNoteDate = (dateStr: string) => {
const d = new Date(dateStr);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(d);
};
const handleAddShareNote = async () => {
if (!notesEstimateId || !newNoteText.trim() || addingNote) return;
setAddingNote(true);
try {
await api.addShareNote(notesEstimateId, newNoteText.trim());
setNewNoteText('');
const list = await api.getShareNotes(notesEstimateId);
setShareNotes(list);
} catch (e) {
console.error(e);
} finally {
setAddingNote(false);
}
};
return (
<div
className={`flex flex-col border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shrink-0 transition-all duration-200 ${
expanded ? 'w-64' : 'w-12'
}`}
>
{/* Стрелка сверху — выдвигает/сворачивает меню и толкает надпись «Смета Ассистент» в основной колонке */}
<button
type="button"
onClick={onToggle}
className="flex items-center justify-center h-14 shrink-0 w-full 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 transition-colors border-b border-slate-200 dark:border-slate-700"
title={expanded ? 'Свернуть меню' : 'Развернуть меню'}
aria-label={expanded ? 'Свернуть меню' : 'Развернуть меню'}
>
{expanded ? <ChevronLeft className="w-5 h-5" /> : <ChevronRight className="w-5 h-5" />}
</button>
{expanded ? (
<>
{/* Верх меню — Создать смету, затем Архив */}
<div className="shrink-0 p-2 space-y-1 border-b border-slate-200 dark:border-slate-700">
<div className="space-y-1">
<button
type="button"
onClick={handleNewEstimate}
disabled={creating}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors disabled:opacity-50"
title="Создать смету"
>
{creating ? (
<Loader2 className="w-4 h-4 flex-shrink-0 animate-spin" />
) : (
<Plus className="w-4 h-4 flex-shrink-0" />
)}
<span>Создать смету</span>
</button>
{createError && (
<p className="px-2 text-xs text-red-600 dark:text-red-400" role="alert">
{createError}
</p>
)}
</div>
<button
type="button"
onClick={onShowArchive}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
title="Архив смет"
>
<FolderArchive className="w-4 h-4 flex-shrink-0" />
<span>Архив</span>
</button>
{currentEstimate && (currentEstimate as { sharedWithMe?: boolean }).sharedWithMe !== true && onShowShare && (
<button
onClick={onShowShare}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
title="Поделиться сметой"
>
<Share2 className="w-4 h-4 flex-shrink-0" />
<span>Поделиться</span>
</button>
)}
</div>
{/* Заголовок «Последние сметы» — фиксирован, не скроллится */}
<h3 className="shrink-0 px-3 py-2 text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide border-b border-slate-200 dark:border-slate-700">
Последние сметы
</h3>
{/* Только список смет скроллится */}
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
{loading ? (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 text-slate-400 animate-spin" />
</div>
) : estimates.length === 0 ? (
<p className="px-2 py-4 text-sm text-slate-500 dark:text-slate-400">Нет смет</p>
) : (
<div className="space-y-1">
{estimates.map(item => {
const objectName = (item.objectName ?? '').trim() || 'Без названия';
const dateStr = item.updatedAt || item.createdAt;
return (
<button
key={item.id}
onClick={() => handleSelectEstimate(item.id)}
className={`w-full text-left px-2 py-2 rounded-xl transition-colors border border-transparent ${
currentEstimate?.id === item.id
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-800 dark:text-primary-200 border-primary-200 dark:border-primary-700'
: 'hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300 hover:border-slate-200 dark:hover:border-slate-700'
}`}
>
<div className="flex items-start gap-2">
<FileText className="w-4 h-4 flex-shrink-0 mt-0.5 text-primary-500 dark:text-primary-400" />
<div className="min-w-0 flex-1">
<div className="text-xs text-slate-700 dark:text-slate-300 truncate leading-tight" title={`${item.number}${objectName}${item.direction?.name || '—'}`}>
<span className="font-semibold text-primary-600 dark:text-primary-400">{item.number}</span>
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-800 dark:text-slate-200">{objectName}</span>
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-600 dark:text-slate-300">{item.direction?.name || '—'}</span>
</div>
{dateStr && (
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{formatDate(dateStr)}
</div>
)}
<div className="text-xs text-slate-600 dark:text-slate-300 truncate mt-0.5" title={item.customer || '—'}>
<span className="text-slate-500 dark:text-slate-400">Заказчик: </span>
{item.customer?.trim() || '—'}
</div>
{item.sharedWithMe === true && (
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">Поделились</span>
<button
type="button"
onClick={e => { e.stopPropagation(); setNotesEstimateId(item.id); }}
className="text-xs text-slate-500 dark:text-slate-400 hover:text-primary-600 dark:hover:text-primary-400 underline"
>
Заметки
</button>
</div>
)}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Модалка заметок к шарингу */}
{notesEstimateId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => setNotesEstimateId(null)}>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-md w-full max-h-[80vh] flex flex-col border border-slate-200 dark:border-slate-600" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-600">
<h3 className="font-medium text-slate-800 dark:text-slate-200 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Заметки
</h3>
<button type="button" onClick={() => setNotesEstimateId(null)} className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
{shareNotesLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-slate-400" /></div>
) : shareNotes.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Нет заметок. Добавьте первую.</p>
) : (
shareNotes.map(n => (
<div key={n.id} className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-100 dark:border-slate-600">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">
{n.author?.name || n.author?.email || '—'} · {formatNoteDate(n.createdAt)}
</p>
<p className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap">{n.content}</p>
</div>
))
)}
</div>
<div className="p-4 border-t border-slate-200 dark:border-slate-600 flex gap-2">
<input
type="text"
value={newNoteText}
onChange={e => setNewNoteText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddShareNote(); } }}
placeholder="Новая заметка..."
className="flex-1 px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
/>
<button
type="button"
onClick={handleAddShareNote}
disabled={!newNoteText.trim() || addingNote}
className="px-3 py-2 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium flex items-center gap-1"
>
{addingNote ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
Отправить
</button>
</div>
</div>
</div>
)}
{/* Внизу меню — «Настройки» фиксированы, не скроллятся */}
<div className="shrink-0 border-t border-slate-200 dark:border-slate-700 p-2">
<button
onClick={() => onShowSettings?.()}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
title="Настройки"
>
<Settings className="w-4 h-4 flex-shrink-0" />
<span>Настройки</span>
</button>
</div>
</>
) : (
/* Свёрнутое меню — только шестерёнка внизу */
<>
<div className="flex-1 min-h-0" aria-hidden />
<div className="shrink-0 p-2 border-t border-slate-200 dark:border-slate-700">
<button
onClick={() => onShowSettings?.()}
className="w-full 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"
title="Настройки"
>
<Settings className="w-5 h-5" />
</button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,698 @@
import React, { useState, useEffect } from 'react';
import { X, Save, BookOpen, Percent, TrendingUp, Plus, Trash2, Pencil, Upload, Loader2 } from 'lucide-react';
import { api } from '../../api/client';
type MainTab = 'main' | 'refs';
type RefSubTab = 'sbc' | 'coefficients' | 'indices';
interface SettingsProps {
settings: any;
onClose: () => void;
onSave: () => void;
/** Режим страницы (для /geo) — без оверлея, встроенный блок */
variant?: 'modal' | 'page';
}
const COEFFICIENT_TYPES = [
{ value: 'regional', label: 'Районный' },
{ value: 'company', label: 'Компания (фирма)' },
{ value: 'transport_internal', label: 'Транспорт (внутренний)' },
{ value: 'transport_external', label: 'Транспорт (внешний)' },
{ value: 'seasonal', label: 'Сезонный' },
{ value: 'special', label: 'Специальный' },
];
interface CoefficientFormProps {
coefficient: any;
onSave: (data: { type: string; code: string; name: string; value: number; description?: string }) => void;
onCancel: () => void;
types: { value: string; label: string }[];
}
interface InflationIndexFormProps {
index: any;
onSave: (data: { baseDate: string; effectiveFrom: string; effectiveTo?: string; indexValue: number; documentRef?: string }) => void;
onCancel: () => void;
}
function formatDate(d: string | Date) {
const x = typeof d === 'string' ? new Date(d) : d;
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(x);
}
function num(v: unknown): number {
if (v == null) return 0;
if (typeof v === 'number') return v;
return Number(v);
}
export function Settings({ settings, onClose, onSave, variant = 'modal' }: SettingsProps) {
const [activeTab, setActiveTab] = useState<MainTab>('main');
const [refSubTab, setRefSubTab] = useState<RefSubTab>('sbc');
const [form, setForm] = useState({
default_executor: '',
default_vat_rate: 20,
company_coefficient: 0.2092,
ai_provider: 'iieasy',
iieasy_model: 'google/gemma-3n-e4b',
lmstudio_url: 'http://localhost:1234/v1',
});
const [saving, setSaving] = useState(false);
// Reference data
const [priceBooks, setPriceBooks] = useState<any[]>([]);
const [coefficients, setCoefficients] = useState<any[]>([]);
const [inflationIndices, setInflationIndices] = useState<any[]>([]);
const [refLoading, setRefLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [uploadingCoefficients, setUploadingCoefficients] = useState(false);
const [coefficientTypeFilter, setCoefficientTypeFilter] = useState<string>('');
// Edit modals
const [editingCoefficient, setEditingCoefficient] = useState<any | null>(null);
const [editingIndex, setEditingIndex] = useState<any | null>(null);
const [newCoefficient, setNewCoefficient] = useState(false);
const [newIndex, setNewIndex] = useState(false);
useEffect(() => {
if (settings) {
setForm({
default_executor: settings.company?.default_executor?.value || '',
default_vat_rate: settings.company?.default_vat_rate?.value || 20,
company_coefficient: settings.company?.company_coefficient?.value || 0.2092,
ai_provider: settings.ai?.ai_provider?.value || 'iieasy',
iieasy_model: settings.ai?.iieasy_model?.value || 'google/gemma-3n-e4b',
lmstudio_url: settings.ai?.lmstudio_url?.value || 'http://localhost:1234/v1',
});
}
}, [settings]);
useEffect(() => {
if (activeTab !== 'refs') return;
loadRefs();
}, [activeTab, refSubTab, coefficientTypeFilter]);
const loadRefs = async () => {
setRefLoading(true);
try {
if (refSubTab === 'sbc') {
const data = await api.getPriceBooksAdmin();
setPriceBooks(data);
} else if (refSubTab === 'coefficients') {
const data = await api.getCoefficientsAdmin(coefficientTypeFilter || undefined);
setCoefficients(data);
} else {
const data = await api.getInflationIndicesAdmin();
setInflationIndices(data);
}
} catch (e) {
console.error(e);
} finally {
setRefLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const updates = {
default_executor: form.default_executor,
default_vat_rate: form.default_vat_rate,
company_coefficient: form.company_coefficient,
ai_provider: form.ai_provider,
iieasy_model: form.iieasy_model,
lmstudio_url: form.lmstudio_url,
};
for (const [key, value] of Object.entries(updates)) {
await api.updateSetting(key, value);
}
onSave();
onClose();
} catch (error) {
console.error('Failed to save settings:', error);
} finally {
setSaving(false);
}
};
const handleImportJson = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
try {
await api.importPriceBookJson(file);
await loadRefs();
} catch (err: any) {
alert(err?.message || 'Ошибка импорта');
} finally {
setImporting(false);
e.target.value = '';
}
};
const handleDeletePriceBook = async (id: string) => {
if (!confirm('Удалить этот справочник и все его таблицы и позиции?')) return;
try {
await api.deletePriceBook(id);
setPriceBooks(prev => prev.filter(p => p.id !== id));
} catch (err: any) {
alert(err?.message || 'Ошибка удаления');
}
};
const handleSaveCoefficient = async (data: { type: string; code: string; name: string; value: number; description?: string }) => {
try {
if (editingCoefficient) {
await api.updateCoefficient(editingCoefficient.id, {
name: data.name,
value: data.value,
description: data.description,
});
} else {
await api.createCoefficient(data);
}
setEditingCoefficient(null);
setNewCoefficient(false);
loadRefs();
} catch (err: any) {
alert(err?.message || 'Ошибка сохранения');
}
};
const handleDeleteCoefficient = async (id: string) => {
if (!confirm('Удалить коэффициент?')) return;
try {
await api.deleteCoefficient(id);
setCoefficients(prev => prev.filter(c => c.id !== id));
setEditingCoefficient(null);
} catch (err: any) {
alert(err?.message || 'Ошибка удаления');
}
};
const handleUploadCoefficients = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingCoefficients(true);
try {
const result = await api.importCoefficientsJson(file);
await loadRefs();
alert(typeof result.imported === 'number' ? `Загружено коэффициентов: ${result.imported}` : 'Готово');
} catch (err: any) {
alert(err?.message || 'Ошибка загрузки');
} finally {
setUploadingCoefficients(false);
e.target.value = '';
}
};
const handleSaveIndex = async (data: {
baseDate: string;
effectiveFrom: string;
effectiveTo?: string;
indexValue: number;
documentRef?: string;
}) => {
try {
if (editingIndex) {
await api.updateInflationIndex(editingIndex.id, {
baseDate: data.baseDate,
effectiveFrom: data.effectiveFrom,
effectiveTo: data.effectiveTo || null,
indexValue: data.indexValue,
documentRef: data.documentRef,
});
} else {
await api.createInflationIndex(data);
}
setEditingIndex(null);
setNewIndex(false);
loadRefs();
} catch (err: any) {
alert(err?.message || 'Ошибка сохранения');
}
};
const handleDeleteIndex = async (id: string) => {
if (!confirm('Удалить индекс?')) return;
try {
await api.deleteInflationIndex(id);
setInflationIndices(prev => prev.filter(i => i.id !== id));
setEditingIndex(null);
} catch (err: any) {
alert(err?.message || 'Ошибка удаления');
}
};
const isWide = activeTab === 'refs';
const modalClass = isWide ? 'max-w-4xl' : 'max-w-lg';
const isPage = variant === 'page';
const wrapperClass = isPage
? 'w-full max-w-4xl mx-auto'
: 'fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4';
const contentClass = isPage
? `bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 w-full flex flex-col min-h-0`
: `bg-white dark:bg-slate-900 rounded-xl shadow-xl w-full ${modalClass} max-h-[90vh] flex flex-col`;
return (
<div className={wrapperClass}>
<div className={contentClass}>
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">Настройки</h2>
{!isPage && (
<button onClick={onClose} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors">
<X className="w-5 h-5 text-slate-500" />
</button>
)}
</div>
{/* Tabs */}
<div className="flex border-b border-slate-200 dark:border-slate-700 px-6">
<button
onClick={() => setActiveTab('main')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'main'
? 'border-primary-600 text-primary-700 dark:text-primary-300'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'
}`}
>
Основные
</button>
<button
onClick={() => setActiveTab('refs')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'refs'
? 'border-primary-600 text-primary-700 dark:text-primary-300'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'
}`}
>
Справочники
</button>
</div>
<div className="flex-1 overflow-auto">
{activeTab === 'main' && (
<div className="p-6 space-y-6">
<div>
<h3 className="text-sm font-medium text-slate-700 mb-3">Компания</h3>
<div className="space-y-3">
<div>
<label className="block text-sm text-slate-600 mb-1">Исполнитель по умолчанию</label>
<input
type="text"
value={form.default_executor}
onChange={e => setForm({ ...form, default_executor: e.target.value })}
placeholder='ООО "Компания"'
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-slate-600 mb-1">Ставка НДС (%)</label>
<input
type="number"
value={form.default_vat_rate}
onChange={e => setForm({ ...form, default_vat_rate: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm text-slate-600 mb-1">Коэффициент компании</label>
<input
type="number"
step="0.0001"
value={form.company_coefficient}
onChange={e => setForm({ ...form, company_coefficient: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-slate-700 mb-3">Искусственный интеллект</h3>
<div className="space-y-3">
<div>
<label className="block text-sm text-slate-600 mb-1">AI Провайдер</label>
<select
value={form.ai_provider}
onChange={e => setForm({ ...form, ai_provider: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="iieasy">ai.iieasy.ru</option>
<option value="lmstudio">LM Studio (локальный)</option>
</select>
</div>
{form.ai_provider === 'iieasy' && (
<div>
<label className="block text-sm text-slate-600 mb-1">Модель iieasy</label>
<input
type="text"
value={form.iieasy_model}
onChange={e => setForm({ ...form, iieasy_model: e.target.value })}
placeholder="google/gemma-3n-e4b"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
)}
{form.ai_provider === 'lmstudio' && (
<div>
<label className="block text-sm text-slate-600 mb-1">URL LM Studio</label>
<input
type="text"
value={form.lmstudio_url}
onChange={e => setForm({ ...form, lmstudio_url: e.target.value })}
placeholder="http://localhost:1234/v1"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
)}
</div>
</div>
</div>
)}
{activeTab === 'refs' && (
<div className="p-6">
<div className="flex gap-2 mb-4">
{(['sbc', 'coefficients', 'indices'] as const).map(tab => (
<button
key={tab}
onClick={() => setRefSubTab(tab)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
refSubTab === tab ? 'bg-primary-100 text-primary-800' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{tab === 'sbc' && <BookOpen className="w-4 h-4" />}
{tab === 'coefficients' && <Percent className="w-4 h-4" />}
{tab === 'indices' && <TrendingUp className="w-4 h-4" />}
{tab === 'sbc' && 'СБЦ'}
{tab === 'coefficients' && 'Коэффициенты'}
{tab === 'indices' && 'Индексы'}
</button>
))}
</div>
{refLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-slate-400 animate-spin" />
</div>
) : refSubTab === 'sbc' ? (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-slate-600">Справочники базовых цен (СБЦ). Импорт из JSON.</p>
<label className="flex items-center gap-2 px-3 py-2 bg-slate-100 hover:bg-slate-200 rounded-lg text-sm font-medium cursor-pointer">
<Upload className="w-4 h-4" />
{importing ? 'Импорт...' : 'Импорт JSON'}
<input
type="file"
accept=".json"
className="hidden"
disabled={importing}
onChange={handleImportJson}
/>
</label>
</div>
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="text-left px-3 py-2 font-medium text-slate-700">Код</th>
<th className="text-left px-3 py-2 font-medium text-slate-700">Название</th>
<th className="text-right px-3 py-2 font-medium text-slate-700">Таблиц</th>
<th className="text-right px-3 py-2 font-medium text-slate-700">Позиций</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{priceBooks.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-slate-500">
Нет справочников. Загрузите JSON через «Импорт JSON».
</td>
</tr>
) : (
priceBooks.map(pb => (
<tr key={pb.id} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 font-mono text-slate-800">{pb.code}</td>
<td className="px-3 py-2 text-slate-700">{pb.name}</td>
<td className="px-3 py-2 text-right text-slate-600">{pb._count?.tables ?? 0}</td>
<td className="px-3 py-2 text-right text-slate-600">{pb._count?.items ?? 0}</td>
<td className="px-3 py-2">
<button
onClick={() => handleDeletePriceBook(pb.id)}
className="p-1.5 text-slate-400 hover:text-red-600 rounded"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
) : refSubTab === 'coefficients' ? (
<div>
<p className="text-sm text-slate-600 mb-3">Районные коэффициенты и коэффициенты компании задаются отдельно. Можно загрузить из JSON.</p>
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<select
value={coefficientTypeFilter}
onChange={e => setCoefficientTypeFilter(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Все типы</option>
{COEFFICIENT_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<div className="flex gap-2">
<label className="flex items-center gap-2 px-3 py-2 bg-slate-100 hover:bg-slate-200 rounded-lg text-sm font-medium cursor-pointer">
<Upload className="w-4 h-4" />
{uploadingCoefficients ? 'Загрузка...' : 'Загрузить JSON'}
<input
type="file"
accept=".json"
className="hidden"
disabled={uploadingCoefficients}
onChange={handleUploadCoefficients}
/>
</label>
<button
onClick={() => { setNewCoefficient(true); setEditingCoefficient(null); }}
className="flex items-center gap-2 px-3 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
</div>
{(newCoefficient || editingCoefficient) && (
<CoefficientForm
coefficient={editingCoefficient}
onSave={handleSaveCoefficient}
onCancel={() => { setNewCoefficient(false); setEditingCoefficient(null); }}
types={COEFFICIENT_TYPES}
/>
)}
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="text-left px-3 py-2 font-medium text-slate-700">Тип</th>
<th className="text-left px-3 py-2 font-medium text-slate-700">Код</th>
<th className="text-left px-3 py-2 font-medium text-slate-700">Название</th>
<th className="text-right px-3 py-2 font-medium text-slate-700">Значение</th>
<th className="w-20"></th>
</tr>
</thead>
<tbody>
{coefficients.map(c => (
<tr key={c.id} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 text-slate-600">{COEFFICIENT_TYPES.find(t => t.value === c.type)?.label || c.type}</td>
<td className="px-3 py-2 font-mono text-slate-800">{c.code}</td>
<td className="px-3 py-2 text-slate-700">{c.name}</td>
<td className="px-3 py-2 text-right text-slate-700">{num(c.value)}</td>
<td className="px-3 py-2 flex gap-1">
<button onClick={() => { setEditingCoefficient(c); setNewCoefficient(false); }} className="p-1.5 text-slate-400 hover:text-slate-600 rounded" title="Изменить"><Pencil className="w-4 h-4" /></button>
<button onClick={() => handleDeleteCoefficient(c.id)} className="p-1.5 text-slate-400 hover:text-red-600 rounded" title="Удалить"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
{coefficients.length === 0 && !newCoefficient && !editingCoefficient && (
<tr><td colSpan={5} className="px-3 py-6 text-center text-slate-500">Нет коэффициентов</td></tr>
)}
</tbody>
</table>
</div>
</div>
) : (
<div>
<div className="flex justify-end mb-4">
<button
onClick={() => { setNewIndex(true); setEditingIndex(null); }}
className="flex items-center gap-2 px-3 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700"
>
<Plus className="w-4 h-4" />
Добавить индекс
</button>
</div>
{(newIndex || editingIndex) && (
<InflationIndexForm
index={editingIndex}
onSave={handleSaveIndex}
onCancel={() => { setNewIndex(false); setEditingIndex(null); }}
/>
)}
<div className="border border-slate-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="text-left px-3 py-2 font-medium text-slate-700">Базовая дата</th>
<th className="text-left px-3 py-2 font-medium text-slate-700">Действует с</th>
<th className="text-left px-3 py-2 font-medium text-slate-700">Действует по</th>
<th className="text-right px-3 py-2 font-medium text-slate-700">Индекс</th>
<th className="text-left px-3 py-2 font-medium text-slate-700">Документ</th>
<th className="w-20"></th>
</tr>
</thead>
<tbody>
{inflationIndices.map(idx => (
<tr key={idx.id} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 text-slate-700">{formatDate(idx.baseDate)}</td>
<td className="px-3 py-2 text-slate-700">{formatDate(idx.effectiveFrom)}</td>
<td className="px-3 py-2 text-slate-600">{idx.effectiveTo ? formatDate(idx.effectiveTo) : '—'}</td>
<td className="px-3 py-2 text-right font-medium text-slate-800">{num(idx.indexValue)}</td>
<td className="px-3 py-2 text-slate-600 text-xs max-w-[180px] truncate" title={idx.documentRef}>{idx.documentRef || '—'}</td>
<td className="px-3 py-2 flex gap-1">
<button onClick={() => { setEditingIndex(idx); setNewIndex(false); }} className="p-1.5 text-slate-400 hover:text-slate-600 rounded" title="Изменить"><Pencil className="w-4 h-4" /></button>
<button onClick={() => handleDeleteIndex(idx.id)} className="p-1.5 text-slate-400 hover:text-red-600 rounded" title="Удалить"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
{inflationIndices.length === 0 && !newIndex && !editingIndex && (
<tr><td colSpan={6} className="px-3 py-6 text-center text-slate-500">Нет индексов инфляции</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 rounded-b-xl">
{!isPage && (
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors">
Отмена
</button>
)}
{activeTab === 'main' && (
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-slate-300 text-white text-sm font-medium rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
)}
</div>
</div>
</div>
);
}
function CoefficientForm({ coefficient, onSave, onCancel, types }: CoefficientFormProps) {
const [type, setType] = useState(coefficient?.type || 'regional');
const [code, setCode] = useState(coefficient?.code || '');
const [name, setName] = useState(coefficient?.name || '');
const [value, setValue] = useState(coefficient != null ? Number(coefficient.value) : 1);
const [description, setDescription] = useState(coefficient?.description || '');
return (
<div className="mb-4 p-4 bg-slate-50 rounded-lg border border-slate-200 space-y-3">
<h4 className="font-medium text-slate-800">{coefficient ? 'Редактировать коэффициент' : 'Новый коэффициент'}</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Тип</label>
<select value={type} onChange={e => setType(e.target.value)} disabled={!!coefficient} className="w-full px-3 py-2 border border-slate-300 rounded text-sm">
{types.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Код</label>
<input value={code} onChange={e => setCode(e.target.value)} disabled={!!coefficient} placeholder="regional_1" className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Название</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="Название" className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Значение</label>
<input type="number" step="0.0001" value={value} onChange={e => setValue(parseFloat(e.target.value) || 0)} className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Описание</label>
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="Описание" className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
</div>
<div className="flex gap-2">
<button onClick={() => onSave({ type, code, name, value, description: description || undefined })} className="px-3 py-1.5 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700">Сохранить</button>
<button onClick={onCancel} className="px-3 py-1.5 bg-slate-200 text-slate-700 text-sm rounded-lg hover:bg-slate-300">Отмена</button>
</div>
</div>
);
}
function InflationIndexForm({ index, onSave, onCancel }: InflationIndexFormProps) {
const toDateStr = (d: string | Date | undefined) => {
if (!d) return '';
const x = typeof d === 'string' ? new Date(d) : d;
return x.toISOString().slice(0, 10);
};
const [baseDate, setBaseDate] = useState(toDateStr(index?.baseDate) || '2001-01-01');
const [effectiveFrom, setEffectiveFrom] = useState(toDateStr(index?.effectiveFrom) || new Date().toISOString().slice(0, 10));
const [effectiveTo, setEffectiveTo] = useState(toDateStr(index?.effectiveTo) || '');
const [indexValue, setIndexValue] = useState(index != null ? Number(index.indexValue) : 1);
const [documentRef, setDocumentRef] = useState(index?.documentRef || '');
return (
<div className="mb-4 p-4 bg-slate-50 rounded-lg border border-slate-200 space-y-3">
<h4 className="font-medium text-slate-800">{index ? 'Редактировать индекс' : 'Новый индекс инфляции'}</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Базовая дата цен</label>
<input type="date" value={baseDate} onChange={e => setBaseDate(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Действует с</label>
<input type="date" value={effectiveFrom} onChange={e => setEffectiveFrom(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Действует по (необяз.)</label>
<input type="date" value={effectiveTo} onChange={e => setEffectiveTo(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Значение индекса</label>
<input type="number" step="0.0001" value={indexValue} onChange={e => setIndexValue(parseFloat(e.target.value) || 0)} className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Ссылка на документ</label>
<input value={documentRef} onChange={e => setDocumentRef(e.target.value)} placeholder="Письмо Минстроя №..." className="w-full px-3 py-2 border border-slate-300 rounded text-sm" />
</div>
<div className="flex gap-2">
<button onClick={() => onSave({ baseDate, effectiveFrom, effectiveTo: effectiveTo || undefined, indexValue, documentRef: documentRef || undefined })} className="px-3 py-1.5 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700">Сохранить</button>
<button onClick={onCancel} className="px-3 py-1.5 bg-slate-200 text-slate-700 text-sm rounded-lg hover:bg-slate-300">Отмена</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { api, AuthError, setOnUnauthorized, setStoredToken, clearStoredToken } from '../api/client';
export interface User {
id: string;
email: string;
name: string | null;
}
interface AuthContextValue {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => Promise<void>;
error: string | null;
clearError: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadUser = useCallback(async () => {
try {
const data = await api.getMe();
setUser(data.user);
} catch (e) {
if (e instanceof AuthError) {
setUser(null);
} else {
setUser(null);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadUser();
}, [loadUser]);
useEffect(() => {
setOnUnauthorized(() => setUser(null));
return () => setOnUnauthorized(() => {});
}, []);
const login = useCallback(async (email: string, password: string) => {
setError(null);
try {
const data = await api.login(email, password);
if (data.token) setStoredToken(data.token);
setUser(data.user);
} catch (e: any) {
setError(e?.message || 'Ошибка входа');
throw e;
}
}, []);
const register = useCallback(async (email: string, password: string, name?: string) => {
setError(null);
try {
const data = await api.register(email, password, name);
if (data.token) setStoredToken(data.token);
setUser(data.user);
} catch (e: any) {
setError(e?.message || 'Ошибка регистрации');
throw e;
}
}, []);
const logout = useCallback(async () => {
try {
clearStoredToken();
await api.logout();
} finally {
setUser(null);
}
}, []);
const clearError = useCallback(() => setError(null), []);
return (
<AuthContext.Provider
value={{
user,
loading,
login,
register,
logout,
error,
clearError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,61 @@
import React, { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
const STORAGE_KEY = 'smeta-theme';
type Theme = 'light' | 'dark';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function applyTheme(theme: Theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch (_) {}
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light';
try {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored === 'dark' || stored === 'light') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} catch {
return 'light';
}
});
useLayoutEffect(() => {
applyTheme(theme);
}, [theme]);
const toggleTheme = useCallback(() => {
const next = theme === 'light' ? 'dark' : 'light';
applyTheme(next);
setTheme(next);
}, [theme]);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

176
frontend/src/index.css Executable file
View File

@@ -0,0 +1,176 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
background: #f8fafc;
}
html.dark {
background: #1A1A1A;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: inherit;
color: #1e293b;
}
html.dark body {
color: #FFFFFF;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
html.dark ::-webkit-scrollbar-track {
background: #1A1A1A;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
html.dark ::-webkit-scrollbar-thumb {
background: #333538;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #282A2E;
}
/* Custom table styles */
.estimate-table th,
.estimate-table td {
@apply border border-slate-200 dark:border-slate-600 px-2 py-1.5 text-sm;
}
.estimate-table th {
@apply bg-slate-50 dark:bg-slate-700 font-medium text-slate-700 dark:text-slate-200;
}
.estimate-table tr:hover td {
@apply bg-blue-50/50 dark:bg-slate-700/70;
}
/* Input focus styles */
input:focus, textarea:focus, select:focus {
@apply outline-none ring-2 ring-primary-500 ring-opacity-50;
}
/* Тёмная тема по референсу Gemini: основной фон #1A1A1A, панели #282A2E, акцент #8AB4F8 */
html.dark #root,
html.dark #root > div {
background-color: #1A1A1A !important;
}
html.dark header {
background-color: #1A1A1A !important;
border-color: #333538 !important;
}
html.dark main {
background-color: #1A1A1A !important;
}
html.dark #root .bg-white,
html.dark #root .bg-slate-50,
html.dark #root .bg-slate-800,
html.dark #root .bg-slate-900 {
background-color: #282A2E !important;
}
html.dark #root .text-slate-800,
html.dark #root .text-slate-700,
html.dark #root .text-slate-100,
html.dark #root .text-slate-200,
html.dark #root .text-slate-300 {
color: #FFFFFF !important;
}
html.dark #root .text-slate-600,
html.dark #root .text-slate-500,
html.dark #root .text-slate-400 {
color: #CCCCCC !important;
}
html.dark #root .border-slate-200,
html.dark #root .border-slate-600,
html.dark #root .border-slate-700 {
border-color: #333538 !important;
}
html.dark #root input,
html.dark #root textarea,
html.dark #root select {
background-color: #282A2E !important;
border-color: #333538 !important;
color: #FFFFFF !important;
}
html.dark #root input::placeholder,
html.dark #root textarea::placeholder {
color: #AAAAAA !important;
}
/* Акцентные кнопки и ссылки — единый синий акцент в тёмной теме */
html.dark #root .bg-primary-600,
html.dark #root .bg-primary-500 {
background-color: #8AB4F8 !important;
}
html.dark #root .bg-primary-600:hover,
html.dark #root .bg-primary-500:hover,
html.dark #root .hover\:bg-primary-700:hover,
html.dark #root .dark\:hover\:bg-primary-400:hover {
background-color: #7aa3e8 !important;
}
html.dark .estimate-table th {
background-color: #282A2E !important;
border-color: #333538 !important;
color: #FFFFFF !important;
}
html.dark .estimate-table td {
border-color: #333538 !important;
color: #FFFFFF !important;
}
html.dark .estimate-table tr:hover td {
background-color: #333538 !important;
}
/* Ховер на кнопках и пунктах меню в тёмной теме */
html.dark #root .hover\:bg-slate-800:hover,
html.dark #root .hover\:bg-slate-100:hover,
html.dark #root .dark\:hover\:bg-slate-800:hover {
background-color: #333538 !important;
}
/* Блок сводки (итоги) — тёмный фон в одной гамме с интерфейсом */
html.dark .estimate-summary {
background-color: #282A2E !important;
border-color: #333538 !important;
}
html.dark .estimate-summary .text-slate-600,
html.dark .estimate-summary .dark\:text-slate-300 {
color: #CCCCCC !important;
}
html.dark .estimate-summary .text-slate-400,
html.dark .estimate-summary .dark\:text-slate-500 {
color: #AAAAAA !important;
}
html.dark .estimate-summary .text-slate-800,
html.dark .estimate-summary .dark\:text-slate-100 {
color: #FFFFFF !important;
}
html.dark .estimate-summary .text-primary-700,
html.dark .estimate-summary .dark\:text-primary-300 {
color: #8AB4F8 !important;
}
/* Акцентный текст итога (Всего с НДС) в тёмной теме */
html.dark #root .text-primary-700 {
color: #8AB4F8 !important;
}
html.dark #root .text-primary-300 {
color: #8AB4F8 !important;
}

19
frontend/src/main.tsx Executable file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
</React.StrictMode>,
);