317 lines
12 KiB
React
317 lines
12 KiB
React
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 [selectedCompany, setSelectedCompany] = 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. Company filter
|
|
if (selectedCompany && emp.company_id !== selectedCompany) return false;
|
|
|
|
// 2. Department filter
|
|
if (selectedDept && emp.department_id !== selectedDept) return false;
|
|
|
|
// 3. 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);
|
|
|
|
// Match internal phone
|
|
const empInternalPhoneDigits = getPhoneDigits(emp.internal_phone);
|
|
const internalPhoneMatch = queryDigits.length > 0 && empInternalPhoneDigits.includes(queryDigits);
|
|
const internalPhoneStringMatch = emp.internal_phone && emp.internal_phone.toLowerCase().includes(queryClean);
|
|
|
|
// 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);
|
|
|
|
// Match email fragment (by start, middle, domain, part before @)
|
|
const emailMatch = emp.email && emp.email.toLowerCase().includes(queryClean);
|
|
|
|
return nameMatch || phoneMatch || internalPhoneMatch || internalPhoneStringMatch || titleMatch || companyMatch || deptMatch || buildingMatch || emailMatch;
|
|
}
|
|
|
|
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
|
|
companies={companies}
|
|
selectedCompany={selectedCompany}
|
|
setSelectedCompany={setSelectedCompany}
|
|
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;
|