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
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2424
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+650
View File
@@ -0,0 +1,650 @@
import React, { useState } from 'react';
import { PlusIcon, EditIcon, DeleteIcon, CloseIcon } from '../utils/svgs';
import ImportExport from './ImportExport';
const AdminPanel = ({ token, employees, departments, companies, onRefreshData }) => {
const [activeTab, setActiveTab] = useState('employees'); // 'employees', 'departments', 'companies', 'import-export'
const [showEmpModal, setShowEmpModal] = useState(false);
const [editingEmp, setEditingEmp] = useState(null);
// Employee form state (Updated fields matching new dictionary company schema)
const [empForm, setEmpForm] = useState({
name: '',
company_id: '',
job_title: '',
department_id: '',
phone: '',
email: '',
building: '',
floor: ''
});
// Department form state
const [newDeptName, setNewDeptName] = useState('');
// Company form state (New dictionary)
const [newCompanyName, setNewCompanyName] = useState('');
// Notification states
const [alert, setAlert] = useState({ type: '', message: '' });
// Search within admin employee table
const [adminSearch, setAdminSearch] = useState('');
const showAlert = (type, message) => {
setAlert({ type, message });
setTimeout(() => setAlert({ type: '', message: '' }), 5000);
};
// ----------------------------------------------------
// Department Actions
// ----------------------------------------------------
const handleAddDept = async (e) => {
e.preventDefault();
if (!newDeptName.trim()) return;
try {
const res = await fetch('/api/departments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: newDeptName.trim() })
});
const data = await res.json();
if (res.ok) {
setNewDeptName('');
onRefreshData();
showAlert('success', 'Подразделение успешно добавлено!');
} else {
showAlert('error', data.error || 'Ошибка при добавлении подразделения');
}
} catch (err) {
showAlert('error', 'Ошибка сети при добавлении подразделения');
}
};
const handleDeleteDept = async (id, name) => {
if (!window.confirm(`Вы уверены, что хотите удалить подразделение "${name}"? У привязанных сотрудников сбросится отдел.`)) {
return;
}
try {
const res = await fetch(`/api/departments/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
onRefreshData();
showAlert('success', 'Подразделение удалено!');
} else {
showAlert('error', 'Ошибка при удалении подразделения');
}
} catch (err) {
showAlert('error', 'Ошибка сети при удалении');
}
};
// ----------------------------------------------------
// Company Actions (New dictionary)
// ----------------------------------------------------
const handleAddCompany = async (e) => {
e.preventDefault();
if (!newCompanyName.trim()) return;
try {
const res = await fetch('/api/companies', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: newCompanyName.trim() })
});
const data = await res.json();
if (res.ok) {
setNewCompanyName('');
onRefreshData();
showAlert('success', 'Компания успешно добавлена!');
} else {
showAlert('error', data.error || 'Ошибка при добавлении компании');
}
} catch (err) {
showAlert('error', 'Ошибка сети при добавлении компании');
}
};
const handleDeleteCompany = async (id, name) => {
if (!window.confirm(`Вы уверены, что хотите удалить компанию "${name}"? У привязанных сотрудников сбросится место работы.`)) {
return;
}
try {
const res = await fetch(`/api/companies/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
onRefreshData();
showAlert('success', 'Компания удалена!');
} else {
showAlert('error', 'Ошибка при удалении компании');
}
} catch (err) {
showAlert('error', 'Ошибка сети при удалении');
}
};
// ----------------------------------------------------
// Employee Actions
// ----------------------------------------------------
const openAddEmpModal = () => {
setEditingEmp(null);
setEmpForm({
name: '',
company_id: companies[0]?.id || '',
job_title: '',
department_id: departments[0]?.id || '',
phone: '',
email: '',
building: '',
floor: ''
});
setShowEmpModal(true);
};
const openEditEmpModal = (emp) => {
setEditingEmp(emp);
setEmpForm({
name: emp.name,
company_id: emp.company_id || '',
job_title: emp.job_title,
department_id: emp.department_id || '',
phone: emp.phone || '',
email: emp.email || '',
building: emp.building || '',
floor: emp.floor || ''
});
setShowEmpModal(true);
};
const handleEmpFormSubmit = async (e) => {
e.preventDefault();
if (!empForm.name.trim() || !empForm.job_title.trim()) {
showAlert('error', 'Поля ФИО и Должность обязательны для заполнения!');
return;
}
try {
const employeePayload = {
...empForm,
company_id: empForm.company_id ? parseInt(empForm.company_id, 10) : null,
department_id: empForm.department_id ? parseInt(empForm.department_id, 10) : null
};
const url = editingEmp ? `/api/employees/${editingEmp.id}` : '/api/employees';
const method = editingEmp ? 'PUT' : 'POST';
const res = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(employeePayload)
});
const data = await res.json();
if (res.ok) {
setShowEmpModal(false);
onRefreshData();
showAlert('success', editingEmp ? 'Данные сотрудника обновлены!' : 'Сотрудник добавлен в базу!');
} else {
showAlert('error', data.error || 'Ошибка при сохранении данных сотрудника');
}
} catch (err) {
showAlert('error', 'Сетевая ошибка при сохранении данных');
}
};
const handleDeleteEmp = async (id, name) => {
if (!window.confirm(`Вы уверены, что хотите удалить сотрудника "${name}" из адресной книги?`)) {
return;
}
try {
const res = await fetch(`/api/employees/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
onRefreshData();
showAlert('success', 'Сотрудник удален из базы данных');
} else {
showAlert('error', 'Ошибка при удалении сотрудника');
}
} catch (err) {
showAlert('error', 'Сетевая ошибка при удалении сотрудника');
}
};
// Filter employees within the admin table
const filteredEmployees = employees.filter(emp =>
emp.name.toLowerCase().includes(adminSearch.toLowerCase()) ||
emp.job_title.toLowerCase().includes(adminSearch.toLowerCase()) ||
(emp.company_name && emp.company_name.toLowerCase().includes(adminSearch.toLowerCase())) ||
(emp.department_name && emp.department_name.toLowerCase().includes(adminSearch.toLowerCase()))
);
return (
<div className="admin-layout">
{/* Navigation tabs */}
<div className="admin-header">
<div className="admin-tabs">
<button
className={`admin-tab ${activeTab === 'employees' ? 'active' : ''}`}
onClick={() => setActiveTab('employees')}
>
Сотрудники ({employees.length})
</button>
<button
className={`admin-tab ${activeTab === 'companies' ? 'active' : ''}`}
onClick={() => setActiveTab('companies')}
>
Компании ({companies.length})
</button>
<button
className={`admin-tab ${activeTab === 'departments' ? 'active' : ''}`}
onClick={() => setActiveTab('departments')}
>
Подразделения ({departments.length})
</button>
<button
className={`admin-tab ${activeTab === 'import-export' ? 'active' : ''}`}
onClick={() => setActiveTab('import-export')}
>
Импорт и Экспорт
</button>
</div>
{activeTab === 'employees' && (
<button className="btn btn-primary" onClick={openAddEmpModal}>
<PlusIcon style={{ width: '1rem', height: '1rem' }} /> Добавить сотрудника
</button>
)}
</div>
{alert.message && (
<div className={`alert-banner alert-${alert.type}`}>
{alert.message}
</div>
)}
{/* ---------------------------------------------------- */}
{/* Tab: Employees */}
{/* ---------------------------------------------------- */}
{activeTab === 'employees' && (
<div className="admin-card">
<div className="admin-table-actions">
<input
type="text"
className="form-control"
placeholder="Быстрый поиск в таблице..."
value={adminSearch}
onChange={(e) => setAdminSearch(e.target.value)}
style={{ maxWidth: '300px' }}
/>
</div>
<div className="table-responsive">
<table className="employee-table">
<thead>
<tr>
<th>ФИО</th>
<th>Компания</th>
<th>Отдел</th>
<th>Должность</th>
<th>Размещение</th>
<th>Контакты</th>
<th style={{ textAlign: 'right' }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredEmployees.length === 0 ? (
<tr>
<td colSpan="7" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
Список пуст или ничего не найдено
</td>
</tr>
) : (
filteredEmployees.map((emp) => (
<tr key={emp.id} style={{ cursor: 'default' }}>
<td>
<div className="table-user-name">{emp.name}</div>
</td>
<td>
<span style={{ fontWeight: 500 }}>{emp.company_name || '—'}</span>
</td>
<td>
<span className="dept-badge" style={{ marginBottom: 0 }}>
{emp.department_name || 'Без отдела'}
</span>
</td>
<td>{emp.job_title}</td>
<td>
<div className="location-cell">
{emp.building ? emp.building : '—'}
{emp.floor && <span className="location-subtext">, {emp.floor} этаж</span>}
</div>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.15rem', fontSize: '0.8rem' }}>
{emp.phone && <div>📞 {emp.phone}</div>}
{emp.email && <div> {emp.email}</div>}
</div>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary"
style={{ padding: '0.35rem 0.65rem', fontSize: '0.8rem' }}
onClick={() => openEditEmpModal(emp)}
>
<EditIcon style={{ width: '0.9rem', height: '0.9rem' }} />
</button>
<button
className="btn btn-danger"
style={{ padding: '0.35rem 0.65rem', fontSize: '0.8rem' }}
onClick={() => handleDeleteEmp(emp.id, emp.name)}
>
<DeleteIcon style={{ width: '0.9rem', height: '0.9rem' }} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* ---------------------------------------------------- */}
{/* Tab: Companies (New tab interface) */}
{/* ---------------------------------------------------- */}
{activeTab === 'companies' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '2rem' }}>
<div className="admin-card">
<h3 style={{ marginBottom: '1.25rem' }}>Новая компания</h3>
<form onSubmit={handleAddCompany}>
<div className="form-group">
<label className="form-label">Название организации</label>
<input
type="text"
className="form-control"
placeholder="Например, АО КМУ Гидромонтаж"
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '0.5rem' }}>
<PlusIcon style={{ width: '1rem', height: '1rem' }} /> Добавить компанию
</button>
</form>
</div>
<div className="admin-card">
<h3 style={{ marginBottom: '1.25rem' }}>Справочник компаний</h3>
<div className="table-responsive">
<table className="employee-table">
<thead>
<tr>
<th>Название организации</th>
<th style={{ textAlign: 'right' }}>Действия</th>
</tr>
</thead>
<tbody>
{companies.length === 0 ? (
<tr>
<td colSpan="2" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
Список компаний пуст
</td>
</tr>
) : (
companies.map((comp) => (
<tr key={comp.id} style={{ cursor: 'default' }}>
<td style={{ fontWeight: 600 }}>{comp.name}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-danger"
style={{ padding: '0.35rem 0.65rem', fontSize: '0.8rem' }}
onClick={() => handleDeleteCompany(comp.id, comp.name)}
>
<DeleteIcon style={{ width: '0.9rem', height: '0.9rem' }} /> Удалить
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ---------------------------------------------------- */}
{/* Tab: Departments */}
{/* ---------------------------------------------------- */}
{activeTab === 'departments' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '2rem' }}>
<div className="admin-card">
<h3 style={{ marginBottom: '1.25rem' }}>Новое подразделение</h3>
<form onSubmit={handleAddDept}>
<div className="form-group">
<label className="form-label">Название отдела</label>
<input
type="text"
className="form-control"
placeholder="Например, Бухгалтерия"
value={newDeptName}
onChange={(e) => setNewDeptName(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '0.5rem' }}>
<PlusIcon style={{ width: '1rem', height: '1rem' }} /> Добавить отдел
</button>
</form>
</div>
<div className="admin-card">
<h3 style={{ marginBottom: '1.25rem' }}>Список подразделений</h3>
<div className="table-responsive">
<table className="employee-table">
<thead>
<tr>
<th>Название подразделения</th>
<th style={{ textAlign: 'right' }}>Действия</th>
</tr>
</thead>
<tbody>
{departments.length === 0 ? (
<tr>
<td colSpan="2" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
Список отделов пуст
</td>
</tr>
) : (
departments.map((dept) => (
<tr key={dept.id} style={{ cursor: 'default' }}>
<td style={{ fontWeight: 600 }}>{dept.name}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-danger"
style={{ padding: '0.35rem 0.65rem', fontSize: '0.8rem' }}
onClick={() => handleDeleteDept(dept.id, dept.name)}
>
<DeleteIcon style={{ width: '0.9rem', height: '0.9rem' }} /> Удалить
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ---------------------------------------------------- */}
{/* Tab: Import/Export */}
{/* ---------------------------------------------------- */}
{activeTab === 'import-export' && (
<ImportExport token={token} onRefreshData={onRefreshData} />
)}
{/* ---------------------------------------------------- */}
{/* Edit/Add Employee Modal Dialog */}
{/* ---------------------------------------------------- */}
{showEmpModal && (
<div className="modal-overlay">
<div className="modal-content" style={{ maxWidth: '600px', transform: 'none', animation: 'none' }}>
<button className="modal-close-btn" onClick={() => setShowEmpModal(false)}>
<CloseIcon />
</button>
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--border)' }}>
<h2>{editingEmp ? 'Редактировать сотрудника' : 'Добавить сотрудника'}</h2>
</div>
<form onSubmit={handleEmpFormSubmit}>
<div className="modal-body" style={{ padding: '1.25rem 1.5rem', maxHeight: '75vh', overflowY: 'auto' }}>
<div className="form-grid">
<div className="form-group form-grid-full">
<label className="form-label">ФИО сотрудника *</label>
<input
type="text"
className="form-control"
placeholder="Иванов Иван Иванович"
value={empForm.name}
onChange={(e) => setEmpForm({ ...empForm, name: e.target.value })}
required
/>
</div>
<div className="form-group">
<label className="form-label">Место работы (Компания) *</label>
<select
className="form-control"
value={empForm.company_id}
onChange={(e) => setEmpForm({ ...empForm, company_id: e.target.value })}
required
>
<option value="">Выберите компанию...</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Должность *</label>
<input
type="text"
className="form-control"
placeholder="Например, Инженер-конструктор"
value={empForm.job_title}
onChange={(e) => setEmpForm({ ...empForm, job_title: e.target.value })}
required
/>
</div>
<div className="form-group">
<label className="form-label">Подразделение (Отдел)</label>
<select
className="form-control"
value={empForm.department_id}
onChange={(e) => setEmpForm({ ...empForm, department_id: e.target.value })}
>
<option value="">Без отдела</option>
{departments.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Рабочий телефон</label>
<input
type="text"
className="form-control"
placeholder="+7 (391) 123-45-67"
value={empForm.phone}
onChange={(e) => setEmpForm({ ...empForm, phone: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Электронная почта (Email)</label>
<input
type="email"
className="form-control"
placeholder="i.ivanov@company.ru"
value={empForm.email}
onChange={(e) => setEmpForm({ ...empForm, email: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Здание / Корпус</label>
<input
type="text"
className="form-control"
placeholder="Например, Административный корпус"
value={empForm.building}
onChange={(e) => setEmpForm({ ...empForm, building: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Этаж</label>
<input
type="text"
className="form-control"
placeholder="Например, 3"
value={empForm.floor}
onChange={(e) => setEmpForm({ ...empForm, floor: e.target.value })}
/>
</div>
</div>
</div>
<div className="modal-actions" style={{ padding: '1rem 1.5rem', borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowEmpModal(false)}>
Отмена
</button>
<button type="submit" className="btn btn-primary">
Сохранить
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default AdminPanel;
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import { CloseIcon, PhoneIcon, EmailIcon, OfficeIcon, DownloadIcon } from '../utils/svgs';
const DetailModal = ({ employee, onClose }) => {
if (!employee) return null;
const { name, company_name, job_title, department_name, phone, email, building, floor } = employee;
// Generate and download vCard (VCF)
const handleDownloadVCard = () => {
const nameParts = name.trim().split(/\s+/);
let lastName = '';
let firstName = '';
let middleName = '';
if (nameParts.length > 0) lastName = nameParts[0];
if (nameParts.length > 1) firstName = nameParts[1];
if (nameParts.length > 2) middleName = nameParts.slice(2).join(' ');
const noteParts = [];
if (building) noteParts.push(`Здание: ${building}`);
if (floor) noteParts.push(`Этаж: ${floor}`);
const vCardLines = [
'BEGIN:VCARD',
'VERSION:3.0',
`N:${lastName};${firstName};${middleName};;`,
`FN:${name}`,
`ORG:${company_name || 'АО «КМУ «Гидромонтаж»'}`,
`TITLE:${job_title}`,
phone ? `TEL;TYPE=CELL,VOICE:${phone}` : '',
email ? `EMAIL;TYPE=PREF,INTERNET:${email}` : '',
noteParts.length > 0 ? `NOTE;CHARSET=UTF-8:${noteParts.join(', ')}` : '',
'END:VCARD'
].filter(Boolean);
const vCardContent = vCardLines.join('\r\n');
const blob = new Blob([vCardContent], { type: 'text/vcard;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${name.replace(/\s+/g, '_')}.vcf`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-btn" onClick={onClose} aria-label="Закрыть">
<CloseIcon />
</button>
<div className="modal-header-bg"></div>
<div className="modal-body">
<div className="modal-profile-header">
<div className="modal-title-info">
<h2 className="modal-name">{name}</h2>
<p className="modal-job-title">{job_title}</p>
<span className="dept-badge" style={{ marginTop: '0.25rem' }}>
{department_name || 'Без отдела'}
</span>
</div>
</div>
<div className="modal-details-grid">
<div className="detail-block">
<span className="detail-label">Компания / Фирма</span>
<span className="detail-value">{company_name || 'АО «КМУ «Гидромонтаж»'}</span>
</div>
<div className="detail-block">
<span className="detail-label">Телефон</span>
<span className="detail-value">
{phone ? (
<a href={`tel:${phone}`} style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
<PhoneIcon style={{ width: '1rem', height: '1rem' }} /> {phone}
</a>
) : 'Не указан'}
</span>
</div>
<div className="detail-block">
<span className="detail-label">Здание</span>
<span className="detail-value" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
<OfficeIcon style={{ width: '1rem', height: '1rem' }} /> {building || 'Не указано'}
</span>
</div>
<div className="detail-block">
<span className="detail-label">Этаж</span>
<span className="detail-value">{floor ? `${floor} этаж` : 'Не указан'}</span>
</div>
<div className="detail-block">
<span className="detail-label">Электронная почта</span>
<span className="detail-value">
{email ? (
<a href={`mailto:${email}`} style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
<EmailIcon style={{ width: '1rem', height: '1rem' }} /> {email}
</a>
) : 'Не указана'}
</span>
</div>
</div>
<div className="modal-actions">
<button className="btn btn-secondary" onClick={handleDownloadVCard}>
<DownloadIcon style={{ width: '1.1rem', height: '1.1rem' }} /> Скачать визитку vCard
</button>
<button className="btn btn-primary" onClick={onClose}>
Закрыть
</button>
</div>
</div>
</div>
</div>
);
};
export default DetailModal;
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { PhoneIcon, EmailIcon } from '../utils/svgs';
const EmployeeTable = ({ employees, onEmployeeClick }) => {
const handleLinkClick = (e) => {
e.stopPropagation(); // Prevent opening the detail modal when clicking a direct contact link
};
return (
<div className="table-responsive">
<table className="employee-table">
<thead>
<tr>
<th>ФИО</th>
<th>Компания</th>
<th>Отдел</th>
<th>Должность</th>
<th>Размещение</th>
<th>Телефон</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{employees.length === 0 ? (
<tr>
<td colSpan="7" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
Сотрудники не найдены
</td>
</tr>
) : (
employees.map((emp) => (
<tr key={emp.id} onClick={() => onEmployeeClick(emp)}>
<td>
<div className="table-user-name">{emp.name}</div>
</td>
<td>
<span style={{ fontWeight: 500 }}>{emp.company_name || '—'}</span>
</td>
<td>
<span className="dept-badge">
{emp.department_name || 'Без отдела'}
</span>
</td>
<td>{emp.job_title}</td>
<td>
<div className="location-cell">
{emp.building ? emp.building : '—'}
{emp.floor && (
<span className="location-subtext">
{emp.building ? `, ${emp.floor} этаж` : `${emp.floor} этаж`}
</span>
)}
</div>
</td>
<td>
{emp.phone ? (
<a
href={`tel:${emp.phone}`}
className="table-contact-link"
onClick={handleLinkClick}
>
<PhoneIcon />
<span>{emp.phone}</span>
</a>
) : (
<span style={{ color: 'var(--text-muted)' }}></span>
)}
</td>
<td>
{emp.email ? (
<a
href={`mailto:${emp.email}`}
className="table-contact-link"
onClick={handleLinkClick}
>
<EmailIcon />
<span>{emp.email}</span>
</a>
) : (
<span style={{ color: 'var(--text-muted)' }}></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
};
export default EmployeeTable;
+275
View File
@@ -0,0 +1,275 @@
import React, { useState, useRef } from 'react';
import { DownloadIcon, UploadIcon } from '../utils/svgs';
const ImportExport = ({ token, onRefreshData }) => {
const [dragActive, setDragActive] = useState(false);
const [status, setStatus] = useState({ type: '', message: '' });
const fileInputRef = useRef(null);
const onButtonClick = () => {
fileInputRef.current.click();
};
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFileInputChange = (e) => {
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
const handleFile = (file) => {
const reader = new FileReader();
const fileType = file.name.split('.').pop().toLowerCase();
reader.onload = async (e) => {
const text = e.target.result;
try {
let payload = null;
if (fileType === 'json') {
payload = JSON.parse(text);
if (!payload.employees || !payload.departments || !payload.companies) {
throw new Error('Некорректный формат JSON. Ожидались массивы employees, departments и companies.');
}
} else if (fileType === 'csv') {
const rows = text.split('\n').map(row => row.trim()).filter(Boolean);
if (rows.length < 2) {
throw new Error('CSV файл пуст или содержит недостаточно строк.');
}
// Expected Headers: ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж
// Parse semicolon separated columns
const employees = [];
const departmentsSet = new Set();
const companiesSet = new Set();
for (let i = 1; i < rows.length; i++) {
// Split by semicolon and clean quotes
const values = rows[i].split(';').map(v => v.trim().replace(/^"|"$/g, ''));
const emp = {
name: values[0] || '',
company_name: values[1] || '',
department_name: values[2] || '',
job_title: values[3] || '',
phone: values[4] || '',
email: values[5] || '',
building: values[6] || '',
floor: values[7] || ''
};
if (emp.name && emp.job_title) {
employees.push(emp);
if (emp.department_name) {
departmentsSet.add(emp.department_name);
}
if (emp.company_name) {
companiesSet.add(emp.company_name);
}
}
}
const departments = Array.from(departmentsSet).map((name, index) => ({
id: index + 1,
name
}));
const companies = Array.from(companiesSet).map((name, index) => ({
id: index + 1,
name
}));
const mappedEmployees = employees.map(emp => {
const dept = departments.find(d => d.name === emp.department_name);
const comp = companies.find(c => c.name === emp.company_name);
return {
name: emp.name,
job_title: emp.job_title,
phone: emp.phone,
email: emp.email,
building: emp.building,
floor: emp.floor,
department_id: dept ? dept.id : null,
company_id: comp ? comp.id : null
};
});
payload = { employees: mappedEmployees, departments, companies };
} else {
throw new Error('Поддерживаются только файлы .json и .csv');
}
setStatus({ type: 'info', message: 'Импортирование данных...' });
const res = await fetch('/api/admin/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok) {
setStatus({ type: 'success', message: `Импорт завершен! Загружено компаний: ${payload.companies.length}, отделов: ${payload.departments.length}, сотрудников: ${payload.employees.length}` });
onRefreshData();
} else {
setStatus({ type: 'error', message: data.error || 'Ошибка при импорте на сервер.' });
}
} catch (err) {
setStatus({ type: 'error', message: `Ошибка чтения файла: ${err.message}` });
}
};
// Explicitly read file in UTF-8
reader.readAsText(file, "UTF-8");
};
const handleExportJSON = async () => {
try {
const res = await fetch('/api/admin/export', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Ошибка экспорта данных с сервера');
const data = await res.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `addressbook_backup_${new Date().toISOString().slice(0, 10)}.json`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
setStatus({ type: 'error', message: err.message });
}
};
const handleExportCSV = async () => {
try {
const res = await fetch('/api/admin/export', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Ошибка экспорта данных с сервера');
const data = await res.json();
const headers = ['ФИО', 'Компания', 'Отдел', 'Должность', 'Телефон', 'Email', 'Здание', 'Этаж'];
// Semicolon separator
const csvRows = [headers.join(';')];
for (const emp of data.employees) {
const dept = data.departments.find(d => d.id === emp.department_id);
const comp = data.companies.find(c => c.id === emp.company_id);
const row = [
`"${emp.name.replace(/"/g, '""')}"`,
`"${(comp ? comp.name : '').replace(/"/g, '""')}"`,
`"${(dept ? dept.name : '').replace(/"/g, '""')}"`,
`"${(emp.job_title || '').replace(/"/g, '""')}"`,
`"${(emp.phone || '').replace(/"/g, '""')}"`,
`"${(emp.email || '').replace(/"/g, '""')}"`,
`"${(emp.building || '').replace(/"/g, '""')}"`,
`"${(emp.floor || '').replace(/"/g, '""')}"`
];
csvRows.push(row.join(';'));
}
const blob = new Blob(['\uFEFF' + csvRows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }); // Excel BOM
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `addressbook_roster_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
setStatus({ type: 'error', message: err.message });
}
};
return (
<div className="import-export-section">
<div className="admin-card">
<h3 style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<UploadIcon style={{ width: '1.25rem', height: '1.25rem' }} /> Импорт данных (Бэкап / CSV)
</h3>
{status.message && (
<div className={`alert-banner alert-${status.type}`}>
{status.message}
</div>
)}
<form id="form-file-upload" onDragEnter={handleDrag} onSubmit={(e) => e.preventDefault()}>
<input
ref={fileInputRef}
type="file"
id="input-file-upload"
multiple={false}
accept=".json,.csv"
onChange={handleFileInputChange}
style={{ display: 'none' }}
/>
<div
className={`dropzone ${dragActive ? "drag-active" : ""}`}
onClick={onButtonClick}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<p>Перетащите сюда файл резервной копии <strong>.json</strong> или список сотрудников <strong>.csv</strong></p>
<button className="btn btn-secondary" style={{ marginTop: '1.1rem' }} type="button">
Выбрать файл на компьютере
</button>
</div>
</form>
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.75rem' }}>
* При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - <strong>точка с запятой ";"</strong>): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж. Кодировка UTF-8.
</p>
</div>
<div className="admin-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<h3 style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<DownloadIcon style={{ width: '1.25rem', height: '1.25rem' }} /> Экспорт данных (Бэкап)
</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem', fontSize: '0.875rem' }}>
Экспортируйте базу данных в любой момент. Скачайте структурированный бэкап в формате JSON для восстановления на другом компьютере, или скачайте плоский список сотрудников в формате CSV для редактирования в Excel.
</p>
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={handleExportJSON} style={{ flex: '1' }}>
<DownloadIcon style={{ width: '1.1rem', height: '1.1rem' }} /> Скачать JSON Бэкап
</button>
<button className="btn btn-secondary" onClick={handleExportCSV} style={{ flex: '1' }}>
<DownloadIcon style={{ width: '1.1rem', height: '1.1rem' }} /> Скачать CSV Таблицу
</button>
</div>
</div>
</div>
);
};
export default ImportExport;
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { SearchIcon } from '../utils/svgs';
const SearchFilters = ({ searchQuery, setSearchQuery }) => {
return (
<div className="directory-filters-wrapper">
<div className="search-controls">
<div className="search-input-wrapper">
<SearchIcon className="search-input-icon" />
<input
type="text"
className="search-input"
placeholder="Поиск по ФИО, компании, отделу, должности, телефону, почте или кабинету..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
</div>
);
};
export default SearchFilters;
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => {
// Count employees in a department
const getDeptCount = (deptId) => {
if (!deptId) return employees.length;
return employees.filter(emp => emp.department_id === deptId).length;
};
return (
<aside className="filters-sidebar">
<div className="filters-title">
<span>Подразделения</span>
</div>
<div className="filters-section">
<ul className="dept-list">
<li>
<button
className={`dept-item ${selectedDept === null ? 'active' : ''}`}
onClick={() => setSelectedDept(null)}
>
<span>Все сотрудники</span>
<span className="dept-count">{getDeptCount(null)}</span>
</button>
</li>
{departments.map((dept) => (
<li key={dept.id}>
<button
className={`dept-item ${selectedDept === dept.id ? 'active' : ''}`}
onClick={() => setSelectedDept(dept.id)}
>
<span>{dept.name}</span>
<span className="dept-count">{getDeptCount(dept.id)}</span>
</button>
</li>
))}
</ul>
</div>
</aside>
);
};
export default Sidebar;
+810
View File
@@ -0,0 +1,810 @@
:root {
/* Modernized corporate color palette (inspired by gidrom24.ru) */
--bg-app: #f4f6f9;
--bg-card: #ffffff;
--bg-header: #1b1e26; /* Midnight/Slate Header */
--text-main: #1e293b; /* Slate-800 */
--text-muted: #64748b; /* Slate-500 */
--text-on-header: #ffffff;
--primary: #0073aa; /* Classic corporate blue */
--primary-hover: #005a87;
--accent: #5583ff; /* Vibrant modern royal blue */
--accent-hover: #3b66df;
--accent-light: rgba(85, 131, 255, 0.06);
--success: #10b981; /* Emerald green (active status) */
--success-light: rgba(16, 185, 129, 0.1);
--warning: #f59e0b; /* Amber */
--warning-light: rgba(245, 158, 11, 0.1);
--danger: #ef4444; /* Rose */
--danger-light: rgba(239, 68, 68, 0.1);
--border: #e2e8f0; /* Slate-200 */
--border-focus: #5583ff;
--input-bg: #ffffff;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.03);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--transition-fast: 0.15s ease;
--transition-normal: 0.2s ease;
--container-width: 1300px;
}
[data-theme="dark"] {
--bg-app: #0f172a; /* Slate-900 */
--bg-card: #1e293b; /* Slate-800 */
--bg-header: #0b0f19;
--text-main: #f1f5f9; /* Slate-100 */
--text-muted: #94a3b8; /* Slate-400 */
--primary: #3b82f6;
--primary-hover: #60a5fa;
--accent: #5583ff;
--accent-hover: #7b9eff;
--accent-light: rgba(85, 131, 255, 0.12);
--border: #334155; /* Slate-700 */
--border-focus: #5583ff;
--input-bg: #1e293b;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
}
/* Reset and Base Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
background-color: var(--bg-app);
color: var(--text-main);
min-height: 100vh;
line-height: 1.5;
transition: background-color var(--transition-normal), color var(--transition-normal);
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--accent-hover);
}
button, input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
}
/* App Layout */
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header Styles */
.app-header {
background-color: var(--bg-header);
color: var(--text-on-header);
padding: 0.75rem 1.5rem;
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: var(--container-width);
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.logo-icon {
width: 2.2rem;
height: 2.2rem;
fill: var(--accent);
background: rgba(255, 255, 255, 0.08);
padding: 0.4rem;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
}
.logo-text h1 {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.5px;
line-height: 1.1;
}
.logo-text span {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Button Component */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.45rem 0.9rem;
font-weight: 500;
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
font-size: 0.875rem;
}
.btn-primary {
background-color: var(--accent);
color: white;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: var(--bg-card);
color: var(--text-main);
border-color: var(--border);
}
.btn-secondary:hover {
background-color: var(--bg-app);
}
.btn-danger {
background-color: var(--danger-light);
color: var(--danger);
border-color: rgba(239, 68, 68, 0.1);
}
.btn-danger:hover {
background-color: var(--danger);
color: white;
}
.btn-icon {
padding: 0.4rem;
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-on-header);
}
[data-theme="dark"] .btn-icon:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Main Area Layout */
.main-content {
flex: 1;
max-width: var(--container-width);
width: 100%;
margin: 0 auto;
padding: 1.5rem;
}
/* Directory Page Grid */
.directory-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 768px) {
.directory-layout {
grid-template-columns: 1fr;
gap: 1.25rem;
}
}
/* Filters Sidebar */
.filters-sidebar {
background-color: var(--bg-card);
padding: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.filters-title {
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.5px;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.dept-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.dept-item {
width: 100%;
padding: 0.4rem 0.5rem;
border-radius: var(--radius-sm);
background: transparent;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-main);
transition: all var(--transition-fast);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.dept-item:hover {
background-color: var(--bg-app);
color: var(--accent);
}
.dept-item.active {
background-color: var(--accent-light);
color: var(--accent);
font-weight: 600;
}
.dept-count {
font-size: 0.7rem;
background-color: var(--border);
color: var(--text-muted);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.dept-item.active .dept-count {
background-color: var(--accent);
color: white;
}
/* Search Bar Area */
.search-controls {
background-color: var(--bg-card);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: center;
}
.search-input-wrapper {
position: relative;
flex: 1;
}
.search-input-icon {
position: absolute;
left: 0.85rem;
top: 50%;
transform: translateY(-50%);
width: 1.1rem;
height: 1.1rem;
stroke: var(--text-muted);
}
.search-input {
width: 100%;
padding: 0.55rem 1rem 0.55rem 2.4rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background-color: var(--input-bg);
font-size: 0.875rem;
transition: all var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(85, 131, 255, 0.1);
}
.dept-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
background-color: var(--bg-app);
color: var(--text-muted);
}
/* Table List Layout (High density strict tabular view) */
.table-responsive {
width: 100%;
overflow-x: auto;
background-color: var(--bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.employee-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.85rem;
}
.employee-table th {
background-color: var(--bg-app);
padding: 0.75rem;
font-weight: 700;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.5px;
}
.employee-table td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
transition: background-color var(--transition-fast);
}
.employee-table tr:last-child td {
border-bottom: none;
}
/* Alternating Row Colors (Zebra theme) */
.employee-table tbody tr:nth-child(even) td {
background-color: rgba(0, 0, 0, 0.015);
}
[data-theme="dark"] .employee-table tbody tr:nth-child(even) td {
background-color: rgba(255, 255, 255, 0.01);
}
.employee-table tbody tr:hover td {
background-color: var(--accent-light) !important;
cursor: pointer;
}
.table-user-name {
font-weight: 600;
color: var(--text-main);
}
.table-contact-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--text-main);
font-size: 0.8rem;
}
.table-contact-link:hover {
color: var(--accent);
}
.table-contact-link svg {
width: 0.85rem;
height: 0.85rem;
fill: currentColor;
}
/* Location labels in table */
.location-cell {
color: var(--text-main);
font-size: 0.8rem;
}
.location-subtext {
font-size: 0.7rem;
color: var(--text-muted);
}
/* Modal Window Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
animation: fadeIn var(--transition-fast) forwards;
}
.modal-content {
background-color: var(--bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 500px;
border: 1px solid var(--border);
overflow: hidden;
position: relative;
transform: translateY(10px);
animation: slideUp var(--transition-normal) forwards;
}
.modal-close-btn {
position: absolute;
right: 1rem;
top: 1rem;
background: rgba(15, 23, 42, 0.05);
color: var(--text-main);
border: none;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-full);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
z-index: 10;
}
.modal-close-btn:hover {
background-color: rgba(15, 23, 42, 0.1);
transform: rotate(90deg);
}
[data-theme="dark"] .modal-close-btn {
background: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .modal-close-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.modal-header-bg {
height: 80px;
background: linear-gradient(135deg, var(--bg-header) 0%, var(--primary) 100%);
position: relative;
}
.modal-body {
padding: 1.5rem;
}
.modal-profile-header {
margin-top: -40px;
margin-bottom: 1.25rem;
position: relative;
z-index: 2;
}
.modal-title-info {
background: var(--bg-card);
padding: 0.5rem 0;
border-radius: var(--radius-sm);
}
.modal-name {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-main);
line-height: 1.2;
}
.modal-job-title {
font-size: 0.9rem;
color: var(--text-muted);
font-weight: 500;
margin-top: 0.15rem;
}
.modal-details-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 1.25rem 0;
margin-bottom: 1.25rem;
}
@media (max-width: 480px) {
.modal-details-grid {
grid-template-columns: 1fr;
}
}
.detail-block {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.detail-label {
font-size: 0.7rem;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 700;
letter-spacing: 0.5px;
}
.detail-value {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-main);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Login Card Layout */
.login-overlay {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 120px);
}
.login-card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 2rem;
width: 100%;
max-width: 360px;
}
.login-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.25rem;
text-align: center;
}
.login-subtitle {
font-size: 0.8rem;
color: var(--text-muted);
text-align: center;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.form-label {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-main);
}
.form-control {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background-color: var(--input-bg);
font-size: 0.85rem;
transition: all var(--transition-fast);
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(85, 131, 255, 0.1);
}
/* Admin Dashboard Layout */
.admin-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.admin-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
padding-bottom: 1px;
}
.admin-tab {
background: transparent;
border: none;
padding: 0.6rem 1rem;
font-weight: 700;
cursor: pointer;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: all var(--transition-fast);
font-size: 0.85rem;
}
.admin-tab:hover {
color: var(--accent);
}
.admin-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.admin-card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: 1.25rem;
}
.admin-table-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
/* Import/Export Utilities Layout */
.import-export-section {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
margin-top: 0.5rem;
}
@media (max-width: 768px) {
.import-export-section {
grid-template-columns: 1fr;
}
}
.dropzone {
border: 2px dashed var(--border);
border-radius: var(--radius-sm);
padding: 1.5rem;
text-align: center;
cursor: pointer;
background-color: var(--bg-app);
transition: all var(--transition-fast);
font-size: 0.85rem;
}
.dropzone:hover {
border-color: var(--accent);
background-color: var(--accent-light);
}
/* Keyframe Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Alert Notification Banner */
.alert-banner {
padding: 0.6rem 0.85rem;
border-radius: var(--radius-sm);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
animation: slideUp var(--transition-fast);
}
.alert-error {
background-color: var(--danger-light);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.1);
}
.alert-success {
background-color: var(--success-light);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.1);
}
/* Form Grid for Modals */
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
@media (max-width: 480px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.form-grid-full {
grid-column: span 2;
}
@media (max-width: 480px) {
.form-grid-full {
grid-column: span 1;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+132
View File
@@ -0,0 +1,132 @@
import React from 'react';
export const SearchIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
);
export const GridIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect>
</svg>
);
export const ListIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
);
export const PhoneIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
</svg>
);
export const EmailIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline>
</svg>
);
export const TelegramIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
);
export const OfficeIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle>
</svg>
);
export const BirthdayIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="11" width="18" height="10" rx="2"></rect>
<path d="M12 2v9M8 5h8M12 11a3 3 0 0 1 3-3V6H9v2a3 3 0 0 1 3 3z"></path>
</svg>
);
export const HireDateIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><line x1="16" y1="21" x2="16" y2="19"></line>
<line x1="8" y1="21" x2="8" y2="19"></line><line x1="12" y1="5" x2="12" y2="7"></line>
<line x1="12" y1="3" x2="12" y2="5"></line><path d="M16 3H8a2 2 0 0 0-2 2v2h12V5a2 2 0 0 0-2-2z"></path>
</svg>
);
export const CloseIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);
export const EditIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
);
export const DeleteIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
);
export const PlusIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
);
export const LoginIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
);
export const LogoutIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
);
export const SunIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
);
export const MoonIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
);
export const DownloadIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
);
export const UploadIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
);
export const AvatarIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>
</svg>
);
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
'/uploads': 'http://localhost:3000'
}
}
})