Initial commit
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user