Initial commit

This commit is contained in:
fabritsky
2026-05-25 09:45:08 +00:00
commit 662842019c
34 changed files with 10033 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
import React, { useState, useEffect } from 'react';
import Sidebar from './components/Sidebar';
import SearchFilters from './components/SearchFilters';
import EmployeeTable from './components/EmployeeTable';
import DetailModal from './components/DetailModal';
import AdminPanel from './components/AdminPanel';
import { SunIcon, MoonIcon, LoginIcon, LogoutIcon } from './utils/svgs';
// Normalize Russian phone numbers to 10 digits (strip +7, 7, 8, spaces, dashes, brackets)
const getPhoneDigits = (phone) => {
if (!phone) return '';
let digits = phone.replace(/\D/g, '');
// If it starts with 7 or 8 and is 11 digits, strip the first digit (country code)
if (digits.length === 11 && (digits.startsWith('7') || digits.startsWith('8'))) {
return digits.substring(1);
}
return digits;
};
// Normalize search query for phone matching
const getQueryPhoneDigits = (q) => {
let digits = q.replace(/\D/g, '');
// Strip leading 7 or 8 if the query starts with them to allow matching the 10-digit local format
if (digits.startsWith('7') || digits.startsWith('8')) {
return digits.substring(1);
}
return digits;
};
const App = () => {
const [employees, setEmployees] = useState([]);
const [departments, setDepartments] = useState([]);
const [companies, setCompanies] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedDept, setSelectedDept] = useState(null);
const [activeView, setActiveView] = useState('directory'); // 'directory', 'login', 'admin'
const [selectedEmployee, setSelectedEmployee] = useState(null);
// Auth state
const [token, setToken] = useState(localStorage.getItem('token') || '');
const [username, setUsername] = useState(localStorage.getItem('username') || '');
// Login form state
const [loginForm, setLoginForm] = useState({ username: '', password: '' });
const [loginError, setLoginError] = useState('');
// Theme state
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');
// Fetch initial data from server
const fetchData = async () => {
try {
const empRes = await fetch('/api/employees');
const empData = await empRes.json();
if (empRes.ok) setEmployees(empData);
const deptRes = await fetch('/api/departments');
const deptData = await deptRes.json();
if (deptRes.ok) setDepartments(deptData);
const compRes = await fetch('/api/companies');
const compData = await compRes.json();
if (compRes.ok) setCompanies(compData);
} catch (error) {
console.error('Error fetching data:', error);
}
};
useEffect(() => {
fetchData();
}, []);
// Update HTML data-theme attribute for CSS styling
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
// Auth Handlers
const handleLoginSubmit = async (e) => {
e.preventDefault();
setLoginError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loginForm)
});
const data = await res.json();
if (res.ok) {
setToken(data.token);
setUsername(data.username);
localStorage.setItem('token', data.token);
localStorage.setItem('username', data.username);
setLoginForm({ username: '', password: '' });
setActiveView('admin');
} else {
setLoginError(data.error || 'Ошибка входа');
}
} catch (err) {
setLoginError('Сетевая ошибка при авторизации');
}
};
const handleLogout = () => {
setToken('');
setUsername('');
localStorage.removeItem('token');
localStorage.removeItem('username');
setActiveView('directory');
};
// Client-side search and filtering
const filteredEmployees = employees.filter((emp) => {
// 1. Department filter
if (selectedDept && emp.department_id !== selectedDept) return false;
// 2. Search query filter (Case-insensitive, fragments, normalized phone)
if (searchQuery.trim()) {
const queryClean = searchQuery.toLowerCase().trim();
const queryDigits = getQueryPhoneDigits(queryClean);
// Match FIO fragments (full name parts)
const nameMatch = emp.name.toLowerCase().includes(queryClean);
// Match phone digits normalized
const empPhoneDigits = getPhoneDigits(emp.phone);
const phoneMatch = queryDigits.length > 0 && empPhoneDigits.includes(queryDigits);
// Secondary match parameters (Job title, Company, Location)
const titleMatch = emp.job_title.toLowerCase().includes(queryClean);
const companyMatch = emp.company_name && emp.company_name.toLowerCase().includes(queryClean);
const deptMatch = emp.department_name && emp.department_name.toLowerCase().includes(queryClean);
const buildingMatch = emp.building && emp.building.toLowerCase().includes(queryClean);
return nameMatch || phoneMatch || titleMatch || companyMatch || deptMatch || buildingMatch;
}
return true;
});
return (
<div className="app-container">
{/* Header bar */}
<header className="app-header">
<div className="header-content">
<div className="logo-section" onClick={() => setActiveView('directory')}>
<svg className="logo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2L2 22h20L12 2zm0 3.99L19.53 19H4.47L12 5.99zM11 10v4h2v-4h-2zm0 6v2h2v-2h-2z" />
</svg>
<div className="logo-text">
<h1>ГИДРОМОНТАЖ</h1>
<span>Адресная книга</span>
</div>
</div>
<div className="header-actions">
{/* Theme Toggle Button */}
<button className="btn-icon" onClick={toggleTheme} title={theme === 'light' ? 'Темная тема' : 'Светлая тема'}>
{theme === 'light' ? <MoonIcon style={{ width: '1.25rem', height: '1.25rem', fill: 'white' }} /> : <SunIcon style={{ width: '1.25rem', height: '1.25rem', fill: 'white' }} />}
</button>
{/* Admin Portal Toggle */}
{token ? (
<>
{activeView === 'admin' ? (
<button className="btn btn-secondary" onClick={() => setActiveView('directory')}>
Справочник
</button>
) : (
<button className="btn btn-primary" onClick={() => setActiveView('admin')}>
Админка
</button>
)}
<button className="btn btn-danger btn-icon" onClick={handleLogout} title="Выйти">
<LogoutIcon style={{ width: '1.25rem', height: '1.25rem', fill: 'currentColor' }} />
</button>
</>
) : (
activeView === 'login' ? (
<button className="btn btn-secondary" onClick={() => setActiveView('directory')}>
Справочник
</button>
) : (
<button className="btn btn-secondary" onClick={() => setActiveView('login')} style={{ display: 'flex', gap: '0.4rem' }}>
<LoginIcon style={{ width: '1.1rem', height: '1.1rem' }} /> Войти
</button>
)
)}
</div>
</div>
</header>
{/* Main View Area */}
<main className="main-content">
{/* VIEW 1: Public Directory Search */}
{activeView === 'directory' && (
<div className="directory-layout">
<Sidebar
departments={departments}
selectedDept={selectedDept}
setSelectedDept={setSelectedDept}
employees={employees}
/>
<div className="directory-results">
<SearchFilters
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<EmployeeTable
employees={filteredEmployees}
onEmployeeClick={setSelectedEmployee}
/>
</div>
</div>
)}
{/* VIEW 2: Admin Authentication Form */}
{activeView === 'login' && (
<div className="login-overlay">
<div className="login-card">
<h2 className="login-title">Вход в панель управления</h2>
<p className="login-subtitle">Доступ только для администраторов сети</p>
{loginError && (
<div className="alert-banner alert-error">
{loginError}
</div>
)}
<form onSubmit={handleLoginSubmit}>
<div className="form-group">
<label className="form-label">Имя пользователя</label>
<input
type="text"
className="form-control"
placeholder="Например, admin"
value={loginForm.username}
onChange={(e) => setLoginForm({ ...loginForm, username: e.target.value })}
required
/>
</div>
<div className="form-group">
<label className="form-label">Пароль</label>
<input
type="password"
className="form-control"
placeholder="••••••••"
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
required
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1.25rem' }}>
Войти
</button>
</form>
</div>
</div>
)}
{/* VIEW 3: Admin CRUD Dashboard */}
{activeView === 'admin' && token && (
<AdminPanel
token={token}
employees={employees}
departments={departments}
companies={companies}
onRefreshData={fetchData}
/>
)}
</main>
{/* Details Profile View Modal */}
{selectedEmployee && (
<DetailModal
employee={selectedEmployee}
onClose={() => setSelectedEmployee(null)}
/>
)}
{/* Corporate Footer */}
<footer style={{ borderTop: '1px solid var(--border)', padding: '1rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)', backgroundColor: 'var(--bg-card)', marginTop: 'auto' }}>
<div style={{ maxWidth: '1300px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div>© {new Date().getFullYear()} АО КМУ «Гидромонтаж». Все права защищены.</div>
<div>Внутренний корпоративный портал адресной книги.</div>
</div>
</footer>
</div>
);
};
export default App;