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
+53
View File
@@ -0,0 +1,53 @@
# Dependencies
node_modules/
backend/node_modules/
frontend/node_modules/
# Production builds
dist/
frontend/dist/
backend/public/
# Environment configurations
.env
.env.local
.env.*.local
# Databases and persistent files
data/
backend/database.db
*.db
*.db-journal
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# System files
.DS_Store
Thumbs.db
# extra safety
.env
*.env
data/
*.db
*.sqlite
*.sqlite3
*.pem
*.key
*.crt
*.token
*secret*
dist/
build/
frontend/dist/
node_modules/
__pycache__/
.venv/
venv/
# local backups
.gitignore.bak-*
+32
View File
@@ -0,0 +1,32 @@
# Stage 1: Build the frontend React app
FROM node:20-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: Setup the backend and copy build output
FROM node:20-slim
WORKDIR /app
COPY backend/package*.json ./backend/
WORKDIR /app/backend
RUN npm install --only=production
# Copy backend source
COPY backend/ ./
# Copy built frontend from Stage 1 into the backend public static directory
COPY --from=frontend-builder /app/frontend/dist ./public
# Set default env variables
ENV PORT=3000
ENV NODE_ENV=production
ENV DATABASE_PATH=/app/data/database.db
# Create persistent storage folder for SQLite database
RUN mkdir -p /app/data
EXPOSE 3000
CMD ["node", "server.js"]
+246
View File
@@ -0,0 +1,246 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'database.db');
// Ensure database directory exists
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// Database schema is initialized safely using CREATE TABLE IF NOT EXISTS below.
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Error opening database:', err.message);
} else {
console.log('Connected to SQLite database at:', dbPath);
initializeTables();
}
});
function run(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve({ id: this.lastID, changes: this.changes });
});
});
}
function get(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function all(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
async function initializeTables() {
try {
// 1. Create Departments Table
await run(`
CREATE TABLE IF NOT EXISTS departments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
)
`);
// 2. Create Companies Table (New dictionary table)
await run(`
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
)
`);
// 3. Create Employees Table (Updated Schema with foreign keys)
await run(`
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
company_id INTEGER,
job_title TEXT NOT NULL,
department_id INTEGER,
phone TEXT,
email TEXT,
building TEXT,
floor TEXT,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL
)
`);
// Seed data if database is fresh
const deptCount = await get('SELECT COUNT(*) as count FROM departments');
if (deptCount.count === 0) {
console.log('Seeding initial departments, companies, and employees...');
await seedData();
}
} catch (error) {
console.error('Error initializing tables:', error);
}
}
async function seedData() {
// Seed Departments
const depts = [
'Руководство',
'Производственно-технический отдел',
'Бухгалтерия',
'Отдел кадров',
'Информационные технологии',
'Монтажный участок',
'Цех металлоконструкций',
'Служба безопасности'
];
const deptIds = {};
for (const dept of depts) {
const res = await run('INSERT INTO departments (name) VALUES (?)', [dept]);
deptIds[dept] = res.id;
}
// Seed Companies (Melto Raboty)
const companiesList = [
'АО КМУ Гидромонтаж',
'ООО Гидромонтаж-Услуги',
'ООО Спецтранс'
];
const companyIds = {};
for (const comp of companiesList) {
const res = await run('INSERT INTO companies (name) VALUES (?)', [comp]);
companyIds[comp] = res.id;
}
// Seed Employees
const employees = [
{
name: 'Иванов Александр Сергеевич',
company: 'АО КМУ Гидромонтаж',
job_title: 'Генеральный директор',
department: 'Руководство',
phone: '+7 (391) 234-56-78',
email: 'a.ivanov@gidrom24.ru',
building: 'Административный корпус',
floor: '3'
},
{
name: 'Петров Дмитрий Васильевич',
company: 'АО КМУ Гидромонтаж',
job_title: 'Руководитель IT-отдела',
department: 'Информационные технологии',
phone: '+7 (391) 234-56-79',
email: 'd.petrov@gidrom24.ru',
building: 'Административный корпус',
floor: '2'
},
{
name: 'Сидоров Алексей Михайлович',
company: 'АО КМУ Гидромонтаж',
job_title: 'Системный администратор',
department: 'Информационные технологии',
phone: '+7 (923) 456-78-90',
email: 'sysadmin@gidrom24.ru',
building: 'Административный корпус',
floor: '2'
},
{
name: 'Смирнова Елена Николаевна',
company: 'ООО Гидромонтаж-Услуги',
job_title: 'Главный бухгалтер',
department: 'Бухгалтерия',
phone: '+7 (391) 234-56-80',
email: 'e.smirnova@gidrom24.ru',
building: 'Административный корпус',
floor: '3'
},
{
name: 'Кузнецов Сергей Петрович',
company: 'АО КМУ Гидромонтаж',
job_title: 'Начальник ПТО',
department: 'Производственно-технический отдел',
phone: '+7 (391) 234-56-81',
email: 's.kuznetsov@gidrom24.ru',
building: 'Производственный корпус №1',
floor: '2'
},
{
name: 'Козлова Мария Игоревна',
company: 'АО КМУ Гидромонтаж',
job_title: 'Начальник отдела кадров',
department: 'Отдел кадров',
phone: '+7 (391) 234-56-82',
email: 'm.kozlova@gidrom24.ru',
building: 'Административный корпус',
floor: '3'
},
{
name: 'Морозов Артем Александрович',
company: 'ООО Спецтранс',
job_title: 'Начальник цеха металлоконструкций',
department: 'Цех металлоконструкций',
phone: '+7 (913) 765-43-21',
email: 'a.morozov@gidrom24.ru',
building: 'Производственный корпус №1',
floor: '1'
},
{
name: 'Васильев Игорь Олегович',
company: 'ООО Гидромонтаж-Услуги',
job_title: 'Бригадир монтажников',
department: 'Монтажный участок',
phone: '+7 (902) 123-45-67',
email: 'i.vasiliev@gidrom24.ru',
building: 'Складской терминал',
floor: '1'
},
{
name: 'Федоров Роман Геннадьевич',
company: 'ООО Спецтранс',
job_title: 'Начальник службы безопасности',
department: 'Служба безопасности',
phone: '+7 (391) 234-56-99',
email: 'security@gidrom24.ru',
building: 'Административный корпус',
floor: '1'
}
];
for (const emp of employees) {
await run(`
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
emp.name,
companyIds[emp.company],
emp.job_title,
deptIds[emp.department],
emp.phone,
emp.email,
emp.building,
emp.floor
]);
}
console.log('Database initialized and seeded successfully.');
}
module.exports = {
db,
run,
get,
all
};
+3011
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "corporate-address-book-backend",
"version": "1.0.0",
"description": "Intranet Corporate Address Book Backend Server",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
+381
View File
@@ -0,0 +1,381 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const db = require('./db');
const app = express();
const PORT = process.env.PORT || 8180;
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_corporate_token_key_123!';
// Setup default admin credentials
const ADMIN_USER = process.env.ADMIN_USERNAME || 'admin';
let ADMIN_PASS = process.env.ADMIN_PASSWORD || 'adminpass';
const ADMIN_PASS_HASH = bcrypt.hashSync(ADMIN_PASS, 10);
console.log(`=========================================`);
console.log(`Intranet Address Book Server starting...`);
console.log(`Admin Username: ${ADMIN_USER}`);
console.log(`Admin Password: ${process.env.ADMIN_PASSWORD ? '****** (From Env)' : 'adminpass (Default)'}`);
console.log(`Default Port: ${PORT}`);
console.log(`=========================================`);
app.use(cors());
app.use(express.json());
// Serve React static build files
const publicDir = path.join(__dirname, 'public');
if (fs.existsSync(publicDir)) {
app.use(express.static(publicDir));
}
// Authentication Middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Access denied. No token provided.' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid or expired token.' });
req.user = user;
next();
});
}
// ==========================================
// API ROUTES
// ==========================================
// 1. Auth Endpoint
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (username === ADMIN_USER && bcrypt.compareSync(password, ADMIN_PASS_HASH)) {
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '24h' });
return res.json({ token, username });
}
return res.status(401).json({ error: 'Incorrect username or password' });
});
// 2. Departments Endpoints
app.get('/api/departments', async (req, res) => {
try {
const rows = await db.all('SELECT * FROM departments ORDER BY name ASC');
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/departments', authenticateToken, async (req, res) => {
const { name } = req.body;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Department name is required' });
}
try {
const result = await db.run('INSERT INTO departments (name) VALUES (?)', [name.trim()]);
res.status(201).json({ id: result.id, name: name.trim() });
} catch (error) {
if (error.message.includes('UNIQUE')) {
return res.status(400).json({ error: 'Department already exists' });
}
res.status(500).json({ error: error.message });
}
});
app.delete('/api/departments/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await db.run('UPDATE employees SET department_id = NULL WHERE department_id = ?', [id]);
const result = await db.run('DELETE FROM departments WHERE id = ?', [id]);
res.json({ success: true, changes: result.changes });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 3. Companies Endpoints (New dictionary CRUD)
app.get('/api/companies', async (req, res) => {
try {
const rows = await db.all('SELECT * FROM companies ORDER BY name ASC');
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/companies', authenticateToken, async (req, res) => {
const { name } = req.body;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Company name is required' });
}
try {
const result = await db.run('INSERT INTO companies (name) VALUES (?)', [name.trim()]);
res.status(201).json({ id: result.id, name: name.trim() });
} catch (error) {
if (error.message.includes('UNIQUE')) {
return res.status(400).json({ error: 'Company already exists' });
}
res.status(500).json({ error: error.message });
}
});
app.delete('/api/companies/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await db.run('UPDATE employees SET company_id = NULL WHERE company_id = ?', [id]);
const result = await db.run('DELETE FROM companies WHERE id = ?', [id]);
res.json({ success: true, changes: result.changes });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 4. Employees Endpoints (Search & List)
app.get('/api/employees', async (req, res) => {
const { search, departmentId } = req.query;
let sql = `
SELECT e.*, d.name as department_name, c.name as company_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN companies c ON e.company_id = c.id
`;
const params = [];
const conditions = [];
if (departmentId) {
conditions.push('e.department_id = ?');
params.push(departmentId);
}
if (conditions.length > 0) {
sql += ' WHERE ' + conditions.join(' AND ');
}
sql += ' ORDER BY e.name ASC';
try {
let rows = await db.all(sql, params);
if (search && search.trim() !== '') {
const cleanSearch = search.trim().toLowerCase();
const normalizePhone = (p) => {
if (!p) return '';
const digits = p.replace(/\D/g, '');
if (digits.length === 11 && (digits.startsWith('7') || digits.startsWith('8'))) {
return digits.slice(1);
}
return digits;
};
const searchDigits = normalizePhone(cleanSearch);
rows = rows.filter(e => {
const nameMatch = e.name && e.name.toLowerCase().includes(cleanSearch);
const jobMatch = e.job_title && e.job_title.toLowerCase().includes(cleanSearch);
const emailMatch = e.email && e.email.toLowerCase().includes(cleanSearch);
const deptMatch = e.department_name && e.department_name.toLowerCase().includes(cleanSearch);
const compMatch = e.company_name && e.company_name.toLowerCase().includes(cleanSearch);
const buildMatch = e.building && e.building.toLowerCase().includes(cleanSearch);
const floorMatch = e.floor && e.floor.toLowerCase().includes(cleanSearch);
let phoneMatch = false;
if (searchDigits.length > 0 && e.phone) {
const empPhoneDigits = normalizePhone(e.phone);
if (empPhoneDigits.includes(searchDigits)) {
phoneMatch = true;
}
}
return nameMatch || jobMatch || emailMatch || deptMatch || compMatch || buildMatch || floorMatch || phoneMatch;
});
}
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create Employee
app.post('/api/employees', authenticateToken, async (req, res) => {
const { name, company_id, job_title, department_id, phone, email, building, floor } = req.body;
if (!name || !job_title) {
return res.status(400).json({ error: 'Name and Job Title are required fields.' });
}
try {
const result = await db.run(`
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
name.trim(),
company_id || null,
job_title.trim(),
department_id || null,
phone ? phone.trim() : '',
email ? email.trim() : '',
building ? building.trim() : '',
floor ? floor.trim() : ''
]);
const newEmp = await db.get(`
SELECT e.*, d.name as department_name, c.name as company_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN companies c ON e.company_id = c.id
WHERE e.id = ?
`, [result.id]);
res.status(201).json(newEmp);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update Employee
app.put('/api/employees/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { name, company_id, job_title, department_id, phone, email, building, floor } = req.body;
if (!name || !job_title) {
return res.status(400).json({ error: 'Name and Job Title are required fields.' });
}
try {
await db.run(`
UPDATE employees
SET name = ?, company_id = ?, job_title = ?, department_id = ?, phone = ?, email = ?, building = ?, floor = ?
WHERE id = ?
`, [
name.trim(),
company_id || null,
job_title.trim(),
department_id || null,
phone ? phone.trim() : '',
email ? email.trim() : '',
building ? building.trim() : '',
floor ? floor.trim() : '',
id
]);
const updatedEmp = await db.get(`
SELECT e.*, d.name as department_name, c.name as company_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id
LEFT JOIN companies c ON e.company_id = c.id
WHERE e.id = ?
`, [id]);
res.json(updatedEmp);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete Employee
app.delete('/api/employees/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
const result = await db.run('DELETE FROM employees WHERE id = ?', [id]);
res.json({ success: true, changes: result.changes });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Bulk Export (JSON dump of departments, companies, and employees)
app.get('/api/admin/export', authenticateToken, async (req, res) => {
try {
const employees = await db.all('SELECT * FROM employees');
const departments = await db.all('SELECT * FROM departments');
const companies = await db.all('SELECT * FROM companies');
res.json({ employees, departments, companies });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Bulk Import
app.post('/api/admin/import', authenticateToken, async (req, res) => {
const { employees, departments, companies } = req.body;
if (!Array.isArray(employees) || !Array.isArray(departments) || !Array.isArray(companies)) {
return res.status(400).json({ error: 'Invalid data format. Expected arrays for employees, departments, and companies.' });
}
try {
// Truncate existing data
await db.run('DELETE FROM employees');
await db.run('DELETE FROM departments');
await db.run('DELETE FROM companies');
// Reset autoincrement sequence
await db.run("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'employees'");
await db.run("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'departments'");
await db.run("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'companies'");
// Insert departments
const deptIdMapping = {};
for (const d of departments) {
const result = await db.run('INSERT INTO departments (name) VALUES (?)', [d.name]);
deptIdMapping[d.id] = result.id;
}
// Insert companies
const compIdMapping = {};
for (const c of companies) {
const result = await db.run('INSERT INTO companies (name) VALUES (?)', [c.name]);
compIdMapping[c.id] = result.id;
}
// Insert employees
for (const e of employees) {
const newDeptId = e.department_id ? (deptIdMapping[e.department_id] || null) : null;
const newCompId = e.company_id ? (compIdMapping[e.company_id] || null) : null;
await db.run(`
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
e.name,
newCompId,
e.job_title,
newDeptId,
e.phone || '',
e.email || '',
e.building || '',
e.floor || ''
]);
}
res.json({ success: true, message: `Imported ${departments.length} departments, ${companies.length} companies and ${employees.length} employees.` });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Catch-all route to serve Index.html
if (fs.existsSync(publicDir)) {
app.get('*', (req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
}
// Start Server
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server is running on http://localhost:${PORT}`);
console.log(`Ready for Intranet users!`);
});
+20
View File
@@ -0,0 +1,20 @@
version: '3.8'
services:
address-book:
build:
context: .
dockerfile: Dockerfile
container_name: corporate-address-book
restart: unless-stopped
ports:
- "8180:3000"
environment:
- PORT=3000
- NODE_ENV=production
- DATABASE_PATH=/app/data/database.db
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-adminpass}
- JWT_SECRET=${JWT_SECRET:-corporate-address-book-secret-key-987654321!}
volumes:
- ./data:/app/data
+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'
}
}
})
Executable
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# Color codes for output formatting
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}=====================================================${NC}"
echo -e "${GREEN} АО КМУ «Гидромонтаж» - Адресная книга ${NC}"
echo -e "${GREEN} Проверка окружения и безопасности ${NC}"
echo -e "${GREEN}=====================================================${NC}"
# 1. Check if folder already exists and is not empty
INSTALL_DIR="/opt/corp-address-book"
if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then
echo -e "${RED}[X] ОШИБКА: Директория установки $INSTALL_DIR уже существует и не пуста!${NC}"
echo -e "${RED} Во избежание перезаписи чужих файлов установка остановлена.${NC}"
exit 1
fi
# 2. Check if port 8180 is busy on the server
if command -v lsof >/dev/null 2>&1; then
if lsof -i :8180 >/dev/null 2>&1; then
echo -e "${RED}[X] ОШИБКА: Порт 8180 уже занят другим процессом!${NC}"
lsof -i :8180
exit 1
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tuln | grep -q ":8180 "; then
echo -e "${RED}[X] ОШИБКА: Порт 8180 уже занят другим процессом!${NC}"
exit 1
fi
fi
# 3. Check if docker container with name 'corporate-address-book' already exists
if command -v docker >/dev/null 2>&1; then
if docker ps -a --format '{{.Names}}' | grep -Eq "^corporate-address-book$"; then
echo -e "${RED}[X] ОШИБКА: Контейнер с именем 'corporate-address-book' уже существует!${NC}"
echo -e "${RED} Пожалуйста, удалите или переименуйте существующий контейнер перед установкой.${NC}"
exit 1
fi
fi
echo -e "${GREEN}[✓] Проверка пройдена. Конфликтов не обнаружено.${NC}"
echo -e "${YELLOW}[i] Начинаем установку...${NC}"
# Verify if Docker is installed
if ! [ -x "$(command -v docker)" ]; then
echo -e "${YELLOW}[!] Docker не установлен. Устанавливаем Docker...${NC}"
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt-get update
sudo apt-get install -y docker-ce
sudo systemctl start docker
sudo systemctl enable docker
echo -e "${GREEN}[✓] Docker успешно установлен!${NC}"
else
echo -e "${GREEN}[✓] Docker обнаружен.${NC}"
fi
# Verify if Docker Compose is installed
if ! docker compose version >/dev/null 2>&1 && ! [ -x "$(command -v docker-compose)" ]; then
echo -e "${YELLOW}[!] Docker Compose не установлен. Устанавливаем...${NC}"
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
echo -e "${GREEN}[✓] Docker Compose установлен!${NC}"
else
echo -e "${GREEN}[✓] Docker Compose обнаружен.${NC}"
fi
# Create directory structure
sudo mkdir -p "$INSTALL_DIR"
sudo cp -r . "$INSTALL_DIR"
cd "$INSTALL_DIR"
# Create .env file with secure settings if it doesn't exist
if [ ! -f .env ]; then
echo -e "${YELLOW}[!] Файл настроек .env отсутствует. Создаем новый...${NC}"
# Generate random admin password and JWT secret
RANDOM_PASS=$(openssl rand -base64 12 | tr -d '/+=')
JWT_SEC=$(openssl rand -base64 32 | tr -d '/+=')
cat <<EOT > .env
# Настройки учетных данных администратора
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$RANDOM_PASS
# Секретный ключ для JWT токенов
JWT_SECRET=$JWT_SEC
EOT
echo -e "${GREEN}[✓] Файл .env успешно сгенерирован.${NC}"
else
echo -e "${GREEN}[✓] Обнаружен существующий файл настроек .env.${NC}"
fi
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Start docker compose containers with project name
echo -e "${YELLOW}[i] Сборка и запуск контейнеров в фоновом режиме...${NC}"
if docker compose version >/dev/null 2>&1; then
sudo docker compose -p corp-address-book up -d --build
else
sudo docker-compose -p corp-address-book up -d --build
fi
if [ $? -eq 0 ]; then
echo -e "${GREEN}=====================================================${NC}"
echo -e "${GREEN}[✓] УСТАНОВКА ЗАВЕРШЕНА УСПЕШНО!${NC}"
echo -e "${GREEN}=====================================================${NC}"
echo -e "Сайт адресной книги доступен по адресу:"
echo -e "🔗 ${YELLOW}http://192.168.1.250:8180/${NC} (или по IP-адресу вашего сервера на порту 8180)"
echo -e ""
echo -e "Данные для входа в панель администратора:"
echo -e "Логин: ${YELLOW}$ADMIN_USERNAME${NC}"
echo -e "Пароль: ${YELLOW}$ADMIN_PASSWORD${NC}"
echo -e ""
echo -e "${RED}ВАЖНО: Сохраните эти учетные данные! Вы можете изменить их в файле .env${NC}"
echo -e "====================================================="
else
echo -e "${RED}[X] Произошла ошибка во время сборки или запуска контейнеров.${NC}"
exit 1
fi
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Color codes
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
echo -e "${GREEN}=====================================================${NC}"
echo -e "${GREEN} АО КМУ «Гидромонтаж» - Адресная книга ${NC}"
echo -e "${GREEN} Проверка окружения и безопасности ${NC}"
echo -e "${GREEN}=====================================================${NC}"
# Check running as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}[X] Этот скрипт должен быть запущен с правами root (sudo).${NC}"
exit 1
fi
INSTALL_DIR="/opt/corp-address-book"
# 1. Check if folder already exists and is not empty
if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then
echo -e "${RED}[X] ОШИБКА: Директория установки $INSTALL_DIR уже существует и не пуста!${NC}"
echo -e "${RED} Во избежание перезаписи чужих файлов установка остановлена.${NC}"
exit 1
fi
# 2. Check if port 8180 is busy on the server
if command -v lsof >/dev/null 2>&1; then
if lsof -i :8180 >/dev/null 2>&1; then
echo -e "${RED}[X] ОШИБКА: Порт 8180 уже занят другим процессом!${NC}"
lsof -i :8180
exit 1
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tuln | grep -q ":8180 "; then
echo -e "${RED}[X] ОШИБКА: Порт 8180 уже занят другим процессом!${NC}"
exit 1
fi
fi
# 3. Check if systemd service already exists
if [ -f /etc/systemd/system/corp-address-book.service ]; then
echo -e "${RED}[X] ОШИБКА: Системная служба 'corp-address-book.service' уже существует!${NC}"
echo -e "${RED} Пожалуйста, удалите или переименуйте существующую службу перед установкой.${NC}"
exit 1
fi
echo -e "${GREEN}[✓] Проверка пройдена. Конфликтов не обнаружено.${NC}"
echo -e "${YELLOW}[i] Начинаем установку...${NC}"
echo -e "${YELLOW}[i] Копируем проект в директорию установки: $INSTALL_DIR...${NC}"
mkdir -p "$INSTALL_DIR"
cp -r . "$INSTALL_DIR"
cd "$INSTALL_DIR"
# Install Node.js if not present
if ! [ -x "$(command -v node)" ]; then
echo -e "${YELLOW}[!] Node.js не установлен. Устанавливаем Node.js...${NC}"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs git build-essential
echo -e "${GREEN}[✓] Node.js успешно установлен!${NC}"
else
echo -e "${GREEN}[✓] Node.js обнаружен ($(node -v)).${NC}"
fi
# Setup environment variables
if [ ! -f .env ]; then
echo -e "${YELLOW}[!] Файл настроек .env отсутствует. Создаем новый...${NC}"
RANDOM_PASS=$(openssl rand -base64 12 | tr -d '/+=')
JWT_SEC=$(openssl rand -base64 32 | tr -d '/+=')
cat <<EOT > .env
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$RANDOM_PASS
JWT_SECRET=$JWT_SEC
PORT=8180
DATABASE_PATH=$INSTALL_DIR/data/database.db
EOT
echo -e "${GREEN}[✓] Файл .env сгенерирован.${NC}"
else
echo -e "${GREEN}[✓] Обнаружен существующий файл настроек .env.${NC}"
fi
# Make sure SQLite directory exists
mkdir -p "$INSTALL_DIR/data"
# Build frontend assets
echo -e "${YELLOW}[i] Установка пакетов и сборка фронтенда...${NC}"
cd "$INSTALL_DIR/frontend"
npm install
npm run build
# Install backend dependencies and bundle public folder
echo -e "${YELLOW}[i] Установка пакетов бэкенда...${NC}"
cd "$INSTALL_DIR/backend"
npm install --only=production
rm -rf public
cp -r ../frontend/dist ./public
# Create Systemd Service File
echo -e "${YELLOW}[i] Создание системной службы Systemd...${NC}"
cat <<EOT > /etc/systemd/system/corp-address-book.service
[Unit]
Description=Corporate Address Book Web App
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR/backend
ExecStart=/usr/bin/node server.js
Restart=on-failure
Environment=NODE_ENV=production
EnvironmentFile=$INSTALL_DIR/.env
[Install]
WantedBy=multi-user.target
EOT
# Reload systemd and start service
systemctl daemon-reload
systemctl enable corp-address-book
systemctl restart corp-address-book
# Fetch credentials from .env
ADMIN_USERNAME=$(grep ADMIN_USERNAME $INSTALL_DIR/.env | cut -d '=' -f2)
ADMIN_PASSWORD=$(grep ADMIN_PASSWORD $INSTALL_DIR/.env | cut -d '=' -f2)
PORT=$(grep PORT $INSTALL_DIR/.env | cut -d '=' -f2)
if [ $? -eq 0 ]; then
echo -e "${GREEN}=====================================================${NC}"
echo -e "${GREEN}[✓] СИСТЕМНАЯ СЛУЖБА ЗАПУЩЕНА УСПЕШНО!${NC}"
echo -e "${GREEN}=====================================================${NC}"
echo -e "Сайт адресной книги запущен локально на порту $PORT."
echo -e "🔗 Адрес: ${YELLOW}http://192.168.1.250:${PORT}/${NC} (или IP-адрес вашего сервера)"
echo -e ""
echo -e "Данные для входа в панель администратора:"
echo -e "Логин: ${YELLOW}$ADMIN_USERNAME${NC}"
echo -e "Пароль: ${YELLOW}$ADMIN_PASSWORD${NC}"
echo -e ""
echo -e "${RED}Сохраните эти учетные данные! Вы можете отредактировать их в $INSTALL_DIR/.env${NC}"
echo -e "====================================================="
else
echo -e "${RED}[X] Ошибка при запуске службы corp-address-book.${NC}"
exit 1
fi
+747
View File
@@ -0,0 +1,747 @@
import sys
import os
import ctypes
import math
import random
import json
import datetime
import traceback
import requests
from PySide6 import QtCore, QtGui, QtWidgets
from PySide6.QtGui import QPixmap, QPainter, QColor, QIcon, QFont
# Hide console window on Windows immediately if running as compiled executable
def hide_console():
try:
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
if hwnd:
ctypes.windll.user32.ShowWindow(hwnd, 0) # SW_HIDE = 0
except Exception:
pass
if getattr(sys, 'frozen', False):
hide_console()
base_dir = os.path.dirname(sys.executable)
else:
base_dir = os.path.dirname(os.path.abspath(__file__))
# Redirect stdout and stderr to a log file
log_path = os.path.join(base_dir, "widget_output.log")
try:
log_file = open(log_path, "a", encoding="utf-8")
sys.stdout = log_file
sys.stderr = log_file
except Exception:
class DummyWriter:
def write(self, x): pass
def flush(self): pass
sys.stdout = DummyWriter()
sys.stderr = DummyWriter()
# Weather descriptions in Russian
WEATHER_DESCRIPTIONS = {
0: "Ясно",
1: "Преимущественно ясно",
2: "Переменная облачность",
3: "Пасмурно",
45: "Туман",
48: "Осаждающийся туман",
51: "Слабая морось",
53: "Умеренная морось",
55: "Плотная морось",
56: "Слабая ледяная морось",
57: "Плотная ледяная морось",
61: "Слабый дождь",
63: "Умеренный дождь",
65: "Сильный дождь",
66: "Слабый ледяной дождь",
67: "Сильный ледяной дождь",
71: "Слабый снегопад",
73: "Умеренный снегопад",
75: "Сильный снегопад",
77: "Снежные зерна",
80: "Слабый ливневый дождь",
81: "Умеренный ливневый дождь",
82: "Сильный ливневый дождь",
85: "Слабый снежный ливень",
86: "Сильный снежный ливень",
95: "Гроза",
96: "Гроза со слабым градом",
99: "Гроза с сильным градом"
}
# Drawing helpers for weather elements
def draw_cloud(painter, x, y, size, color, opacity=1.0):
painter.save()
painter.setPen(QtCore.Qt.PenStyle.NoPen)
c = QColor(color)
if opacity < 1.0:
c.setAlpha(int(opacity * 255))
painter.setBrush(QtGui.QBrush(c))
r1 = size * 0.28
r2 = size * 0.38
r3 = size * 0.28
painter.drawEllipse(x + size*0.05, y + size*0.25, r1*2, r1*2)
painter.drawEllipse(x + size*0.2, y + size*0.05, r2*2, r2*2)
painter.drawEllipse(x + size*0.48, y + size*0.25, r3*2, r3*2)
painter.drawRoundedRect(x + size*0.1, y + size*0.3, size*0.55, size*0.26, size*0.13, size*0.13)
painter.restore()
def draw_sun(painter, cx, cy, r, angle):
painter.save()
# Center circle
grad = QtGui.QLinearGradient(cx - r, cy - r, cx + r, cy + r)
grad.setColorAt(0, QColor("#ffe259"))
grad.setColorAt(1, QColor("#ffa751"))
painter.setPen(QtCore.Qt.PenStyle.NoPen)
painter.setBrush(grad)
painter.drawEllipse(cx - r, cy - r, r*2, r*2)
# Rays
painter.setPen(QtGui.QPen(QColor("#ffa751"), max(1.5, r * 0.18), QtCore.Qt.PenStyle.SolidLine, QtCore.Qt.PenCapStyle.RoundCap))
for i in range(8):
deg = angle + i * 45
rad = math.radians(deg)
x1 = cx + (r * 1.28) * math.cos(rad)
y1 = cy + (r * 1.28) * math.sin(rad)
x2 = cx + (r * 1.58) * math.cos(rad)
y2 = cy + (r * 1.58) * math.sin(rad)
painter.drawLine(x1, y1, x2, y2)
painter.restore()
def draw_moon(painter, cx, cy, r, float_y):
painter.save()
path = QtGui.QPainterPath()
path.addEllipse(cx - r, cy - r + float_y, r*2, r*2)
cut_path = QtGui.QPainterPath()
cut_path.addEllipse(cx - r * 0.3, cy - r + float_y, r*2, r*2)
moon_path = path.subtracted(cut_path)
painter.setPen(QtCore.Qt.PenStyle.NoPen)
painter.setBrush(QColor("#e2e8f0"))
painter.drawPath(moon_path)
painter.restore()
# Vector-animated custom weather icon widget
class AnimatedWeatherIcon(QtWidgets.QWidget):
def __init__(self, parent=None, size=48, auto_start=True):
super().__init__(parent)
self.setFixedSize(size, size)
self.size_val = size
self.weather_code = 3
self.is_day = True
self.time_val = random.random() * 100.0 # offset start to desynchronize clouds
self.sun_angle = random.randint(0, 360)
# Slanted Rain drop offsets
self.rain_offsets = [0.0, 5.0, 10.0]
# Snow flake offsets
self.snow_offsets = [0.0, 6.0, 3.0]
self.lightning_timer = 0
self.lightning_visible = False
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self.animate)
if auto_start:
self.timer.start(33)
def set_weather(self, code, is_day):
self.weather_code = code
self.is_day = is_day
self.update()
def start_animation(self):
if not self.timer.isActive():
self.timer.start(33)
def stop_animation(self):
if self.timer.isActive():
self.timer.stop()
def animate(self):
self.time_val += 0.06
self.sun_angle = (self.sun_angle + 0.8) % 360.0
# Rain animation
for i in range(len(self.rain_offsets)):
self.rain_offsets[i] += 0.5
if self.rain_offsets[i] > 12.0:
self.rain_offsets[i] = 0.0
# Snow animation
for i in range(len(self.snow_offsets)):
self.snow_offsets[i] += 0.3
if self.snow_offsets[i] > 12.0:
self.snow_offsets[i] = 0.0
# Lightning flash animation
self.lightning_timer += 1
if self.lightning_visible:
if self.lightning_timer > 3:
self.lightning_visible = False
self.lightning_timer = 0
else:
if self.lightning_timer > 80 and random.random() < 0.04:
self.lightning_visible = True
self.lightning_timer = 0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w = self.width()
h = self.height()
cx = w / 2
cy = h / 2
code = self.weather_code
if code == 0:
weather_type = "clear"
elif code in (1, 2):
weather_type = "partly_cloudy"
elif code == 3:
weather_type = "cloudy"
elif code in (45, 48):
weather_type = "fog"
elif code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82):
weather_type = "rain"
elif code in (71, 73, 75, 77, 85, 86):
weather_type = "snow"
elif code in (95, 96, 99):
weather_type = "storm"
else:
weather_type = "cloudy"
float_y = math.sin(self.time_val) * (w * 0.05)
if weather_type == "clear":
if self.is_day:
draw_sun(painter, cx, cy, w * 0.28, self.sun_angle)
else:
draw_moon(painter, cx, cy, w * 0.28, float_y)
elif weather_type == "partly_cloudy":
if self.is_day:
draw_sun(painter, cx + w*0.14, cy - h*0.12, w * 0.22, self.sun_angle)
else:
draw_moon(painter, cx + w*0.14, cy - h*0.12, w * 0.2, float_y)
draw_cloud(painter, cx - w*0.3, cy - h*0.2 + float_y, w * 0.64, "#e0f2fe")
elif weather_type == "cloudy":
draw_cloud(painter, cx - w*0.14, cy - h*0.28 + float_y*0.5, w * 0.58, "#bae6fd", opacity=0.8)
draw_cloud(painter, cx - w*0.3, cy - h*0.18 + float_y, w * 0.66, "#ffffff")
elif weather_type == "fog":
draw_cloud(painter, cx - w*0.24, cy - h*0.26 + float_y, w * 0.6, "#e0f2fe", opacity=0.8)
painter.save()
painter.setPen(QtGui.QPen(QColor("#bae6fd"), max(1.5, w * 0.06), QtCore.Qt.PenStyle.SolidLine, QtCore.Qt.PenCapStyle.RoundCap))
drift = math.sin(self.time_val * 0.6) * (w * 0.06)
painter.drawLine(cx - w*0.3 + drift, cy + h*0.18, cx + w*0.3 + drift, cy + h*0.18)
painter.drawLine(cx - w*0.2 - drift, cy + h*0.28, cx + w*0.2 - drift, cy + h*0.28)
painter.restore()
elif weather_type == "rain":
draw_cloud(painter, cx - w*0.3, cy - h*0.25 + float_y, w * 0.64, "#bae6fd")
painter.save()
painter.setPen(QtGui.QPen(QColor("#38bdf8"), max(1.5, w * 0.05), QtCore.Qt.PenStyle.SolidLine, QtCore.Qt.PenCapStyle.RoundCap))
for i, offset in enumerate(self.rain_offsets):
rx = cx - w*0.15 + i * (w*0.15)
ry = cy + h*0.14 + offset
if ry < cy + h*0.42:
painter.drawLine(rx, ry, rx - w*0.03, ry + h*0.12)
painter.restore()
elif weather_type == "snow":
draw_cloud(painter, cx - w*0.3, cy - h*0.25 + float_y, w * 0.64, "#e0f2fe")
painter.save()
painter.setPen(QtCore.Qt.PenStyle.NoPen)
painter.setBrush(QColor("#38bdf8"))
for i, offset in enumerate(self.snow_offsets):
sx = cx - w*0.15 + i * (w*0.15) + math.sin(self.time_val + i) * (w * 0.03)
sy = cy + h*0.14 + offset
if sy < cy + h*0.42:
painter.drawEllipse(sx - w*0.04, sy - w*0.04, w*0.08, w*0.08)
painter.restore()
elif weather_type == "storm":
draw_cloud(painter, cx - w*0.3, cy - h*0.25 + float_y, w * 0.64, "#94a3b8")
if self.lightning_visible:
painter.save()
painter.setPen(QtCore.Qt.PenStyle.NoPen)
painter.setBrush(QColor("#fbbf24"))
scale = w / 48.0
points = [
QtCore.QPoint(cx - 2*scale, cy + 6*scale),
QtCore.QPoint(cx - 10*scale, cy + 18*scale),
QtCore.QPoint(cx - 3*scale, cy + 18*scale),
QtCore.QPoint(cx - 7*scale, cy + 30*scale),
QtCore.QPoint(cx + 5*scale, cy + 14*scale),
QtCore.QPoint(cx - 1*scale, cy + 14*scale),
]
painter.drawPolygon(points)
painter.restore()
# Color themes list: Background gradients + Borders
THEMES = [
# Theme 0: Deep Violet/Indigo (Default)
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #121026, stop:1 #1c183a); border: 1px solid rgba(255, 255, 255, 0.08);",
# Theme 1: Deep Navy Blue
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #09111e, stop:1 #14244a); border: 1px solid rgba(255, 255, 255, 0.08);",
# Theme 2: Forest Emerald Green
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #04160f, stop:1 #0c2d1e); border: 1px solid rgba(255, 255, 255, 0.08);",
# Theme 3: Dark Ruby Red
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #1b0707, stop:1 #351212); border: 1px solid rgba(255, 255, 255, 0.08);",
# Theme 4: Sleek Charcoal Black
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #121212, stop:1 #242424); border: 1px solid rgba(255, 255, 255, 0.08);"
]
class WeatherWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.drag_position = QtCore.QPoint()
self.press_pos = QtCore.QPoint()
self.press_time = datetime.datetime.now()
self.expanded = False
self.theme_index = 0
self.init_ui()
self.load_config()
# Setup tray icon
self.tray_icon = QtWidgets.QSystemTrayIcon(self)
self.tray_icon.setIcon(self.create_tray_icon())
tray_menu = QtWidgets.QMenu()
refresh_action = tray_menu.addAction("Обновить")
refresh_action.triggered.connect(self.update_weather)
exit_action = tray_menu.addAction("Выход")
exit_action.triggered.connect(self.close_app)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.show()
# Setup update timer (10 minutes)
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self.update_weather)
self.timer.start(10 * 60 * 1000)
# Setup startup fade-in animation
self.setWindowOpacity(0.0)
self.fade_in_animation = QtCore.QPropertyAnimation(self, b"windowOpacity")
self.fade_in_animation.setDuration(400)
self.fade_in_animation.setStartValue(0.0)
self.fade_in_animation.setEndValue(1.0)
self.fade_in_animation.start()
# Initial weather load
self.update_weather()
def get_config_path(self):
if getattr(sys, 'frozen', False):
base_dir = os.path.dirname(sys.executable)
else:
base_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_dir, "widget_config.json")
def save_config(self):
try:
config = {
"x": self.x(),
"y": self.y(),
"theme_index": self.theme_index
}
with open(self.get_config_path(), "w") as f:
json.dump(config, f)
except Exception as e:
print(f"Error saving config: {e}")
def load_config(self):
# Balanced proportions: width 280 x height 102
widget_width = 280
widget_height = 102
screen = QtWidgets.QApplication.primaryScreen()
screen_geom = screen.availableGeometry()
# Default position: bottom-right
default_x = screen_geom.right() - widget_width - 20
default_y = screen_geom.bottom() - widget_height - 20
config_path = self.get_config_path()
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
config = json.load(f)
x = config.get("x", default_x)
y = config.get("y", default_y)
self.theme_index = config.get("theme_index", 0)
# Check if position is within current screen boundaries
if 0 <= x < screen_geom.right() and 0 <= y < screen_geom.bottom():
self.setGeometry(x, y, widget_width, widget_height)
self.apply_theme()
return
except Exception as e:
print(f"Error loading config: {e}")
self.setGeometry(default_x, default_y, widget_width, widget_height)
self.apply_theme()
def apply_theme(self):
theme_style = THEMES[self.theme_index]
self.container.setStyleSheet(f"""
#container {{
{theme_style}
border-radius: 22px;
}}
#container:hover {{
border-width: 1px;
border-style: solid;
border-color: rgba(255, 255, 255, 0.18);
}}
QLabel {{
color: #ffffff;
}}
""")
def cycle_theme(self):
self.theme_index = (self.theme_index + 1) % len(THEMES)
self.apply_theme()
self.save_config()
def create_tray_icon(self):
# Dynamically draw a tray icon (stylized clouds/sun)
pixmap = QPixmap(16, 16)
pixmap.fill(QtCore.Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(QtCore.Qt.PenStyle.NoPen)
# Background cloud circles
painter.setBrush(QColor("#38bdf8"))
painter.drawEllipse(1, 6, 8, 8)
painter.drawEllipse(6, 4, 9, 9)
painter.drawEllipse(4, 2, 8, 8)
painter.end()
return QIcon(pixmap)
def init_ui(self):
# Frameless, non-always-on-top, tool window (no taskbar button)
self.setWindowFlags(
QtCore.Qt.WindowType.FramelessWindowHint |
QtCore.Qt.WindowType.Tool |
QtCore.Qt.WindowType.Window
)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
# Outer layout to provide space for the drop shadow
outer_layout = QtWidgets.QVBoxLayout(self)
outer_layout.setContentsMargins(12, 12, 12, 12)
# Container frame
self.container = QtWidgets.QFrame()
self.container.setObjectName("container")
self.apply_theme()
# Soft Drop Shadow
shadow = QtWidgets.QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(15)
shadow.setXOffset(0)
shadow.setYOffset(4)
shadow.setColor(QColor(0, 0, 0, 120))
self.container.setGraphicsEffect(shadow)
# Layout inside container (QVBoxLayout to hold current weather + divider + forecast)
container_layout = QtWidgets.QVBoxLayout(self.container)
container_layout.setContentsMargins(16, 8, 16, 8)
container_layout.setSpacing(6)
# Top Row: Current weather layout (QHBoxLayout)
top_layout = QtWidgets.QHBoxLayout()
top_layout.setSpacing(12)
# Left Side: Weather Icon (beautiful vector-animated)
self.icon_label = AnimatedWeatherIcon(self.container, size=48, auto_start=True)
top_layout.addWidget(self.icon_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
# Right Side: Content
right_layout = QtWidgets.QVBoxLayout()
right_layout.setSpacing(0)
# Row 1: Updated Time (aligned to top-right)
time_layout = QtWidgets.QHBoxLayout()
time_layout.addStretch()
self.update_time_label = QtWidgets.QLabel("обновление...")
self.update_time_label.setFont(QFont("Segoe UI", 7))
self.update_time_label.setStyleSheet("color: rgba(255, 255, 255, 0.45);")
time_layout.addWidget(self.update_time_label)
right_layout.addLayout(time_layout)
# Row 2: Temperature
self.temp_label = QtWidgets.QLabel("--°")
self.temp_label.setFont(QFont("Segoe UI", 24, QFont.Weight.Bold))
self.temp_label.setStyleSheet("line-height: 1;")
right_layout.addWidget(self.temp_label)
# Row 3: Condition text
self.condition_label = QtWidgets.QLabel("Загрузка...")
self.condition_label.setFont(QFont("Segoe UI", 9.5, QFont.Weight.DemiBold))
self.condition_label.setStyleSheet("color: rgba(255, 255, 255, 0.9);")
right_layout.addWidget(self.condition_label)
# Row 4: Detailed info (apparent, wind, humidity)
self.details_label = QtWidgets.QLabel("ощущ. --° | -- м/с | --%")
self.details_label.setFont(QFont("Segoe UI", 8))
self.details_label.setStyleSheet("color: rgba(255, 255, 255, 0.55);")
right_layout.addWidget(self.details_label)
top_layout.addLayout(right_layout)
container_layout.addLayout(top_layout)
# Divider Line
self.divider = QtWidgets.QFrame()
self.divider.setFrameShape(QtWidgets.QFrame.Shape.HLine)
self.divider.setStyleSheet("background-color: rgba(255, 255, 255, 0.1); max-height: 1px; border: none; margin-top: 2px; margin-bottom: 2px;")
container_layout.addWidget(self.divider)
# Forecast Panel
self.forecast_panel = QtWidgets.QWidget()
self.forecast_panel.setObjectName("forecast_panel")
forecast_layout = QtWidgets.QHBoxLayout(self.forecast_panel)
forecast_layout.setContentsMargins(0, 4, 0, 4)
forecast_layout.setSpacing(8)
self.forecast_days = []
for i in range(3):
# Premium Glassmorphic cards for forecast ("нано-банану")
day_widget = QtWidgets.QFrame()
day_widget.setObjectName("forecast_card")
day_widget.setStyleSheet("""
#forecast_card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
#forecast_card:hover {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
}
""")
day_widget_layout = QtWidgets.QVBoxLayout(day_widget)
day_widget_layout.setContentsMargins(4, 6, 4, 6)
day_widget_layout.setSpacing(2)
day_widget_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
day_label = QtWidgets.QLabel("---")
day_label.setFont(QFont("Segoe UI", 8, QFont.Weight.Bold))
day_label.setStyleSheet("color: rgba(255, 255, 255, 0.6);")
day_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
# Forecast icon (stops drawing CPU when collapsed)
icon_widget = AnimatedWeatherIcon(day_widget, size=32, auto_start=False)
temp_label = QtWidgets.QLabel("--° / --°")
temp_label.setFont(QFont("Segoe UI", 7.5))
temp_label.setStyleSheet("color: rgba(255, 255, 255, 0.95);")
temp_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
day_widget_layout.addWidget(day_label)
day_widget_layout.addWidget(icon_widget, alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
day_widget_layout.addWidget(temp_label)
forecast_layout.addWidget(day_widget)
self.forecast_days.append({
"day_label": day_label,
"icon_label": icon_widget,
"temp_label": temp_label
})
container_layout.addWidget(self.forecast_panel)
# Set forecast initial state
self.forecast_panel.setVisible(False)
self.divider.setVisible(False)
outer_layout.addWidget(self.container)
# Mouse dragging and closing implementation
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.MouseButton.RightButton:
self.close_app()
event.accept()
elif event.button() == QtCore.Qt.MouseButton.LeftButton:
self.press_pos = event.globalPosition().toPoint()
self.press_time = datetime.datetime.now()
self.drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
event.accept()
def mouseMoveEvent(self, event):
if event.buttons() == QtCore.Qt.MouseButton.LeftButton:
self.move(event.globalPosition().toPoint() - self.drag_position)
event.accept()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton:
self.save_config()
# Detect left click for expand/collapse
release_pos = event.globalPosition().toPoint()
distance = (release_pos - self.press_pos).manhattanLength()
duration = (datetime.datetime.now() - self.press_time).total_seconds()
if distance < 5 and duration < 0.25:
self.toggle_expand()
event.accept()
def mouseDoubleClickEvent(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton:
self.cycle_theme()
event.accept()
def toggle_expand(self):
self.expanded = not self.expanded
# Animate geometry height change (collapsed 102 -> expanded 192)
start_geom = self.geometry()
target_height = 192 if self.expanded else 102
end_geom = QtCore.QRect(start_geom.x(), start_geom.y(), start_geom.width(), target_height)
self.resize_anim = QtCore.QPropertyAnimation(self, b"geometry")
self.resize_anim.setDuration(220)
self.resize_anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutQuad)
self.resize_anim.setStartValue(start_geom)
self.resize_anim.setEndValue(end_geom)
if self.expanded:
# Show widgets and start forecast animations
self.divider.setVisible(True)
self.forecast_panel.setVisible(True)
for item in self.forecast_days:
item["icon_label"].start_animation()
self.resize_anim.start()
else:
# Hide forecast elements FIRST to release size constraints, allowing successful window shrink
self.divider.setVisible(False)
self.forecast_panel.setVisible(False)
for item in self.forecast_days:
item["icon_label"].stop_animation()
self.resize_anim.start()
# Update weather fetch (with fade transition)
def update_weather(self):
self.fade_anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
self.fade_anim.setDuration(250)
self.fade_anim.setStartValue(self.windowOpacity())
self.fade_anim.setEndValue(0.3)
def on_fade_out():
self.perform_weather_fetch()
self.fade_in_anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
self.fade_in_anim.setDuration(250)
self.fade_in_anim.setStartValue(0.3)
self.fade_in_anim.setEndValue(1.0)
self.fade_in_anim.start()
self.fade_anim.finished.connect(on_fade_out)
self.fade_anim.start()
def perform_weather_fetch(self):
# API URL for Krasnoyarsk coordinates with daily variables included
url = "https://api.open-meteo.com/v1/forecast?latitude=56.0184&longitude=92.8672&current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&wind_speed_unit=ms&timezone=Asia%2FKrasnoyarsk"
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
current = data.get("current", {})
# Current Temperature
temp = round(current.get("temperature_2m", 0))
temp_str = f"+{temp}°" if temp > 0 else f"{temp}°"
self.temp_label.setText(temp_str)
# Current Weather code & description
code = current.get("weather_code", 3)
condition = WEATHER_DESCRIPTIONS.get(code, "Пасмурно")
self.condition_label.setText(condition)
# Current Apparent temp, wind, humidity
app_temp = round(current.get("apparent_temperature", 0))
app_temp_str = f"+{app_temp}°" if app_temp > 0 else f"{app_temp}°"
wind = round(current.get("wind_speed_10m", 0))
humidity = current.get("relative_humidity_2m", 0)
self.details_label.setText(f"ощущ. {app_temp_str} | {wind} м/с | {humidity}%")
# Current Animated Weather Icon
is_day = current.get("is_day", 1) == 1
self.icon_label.set_weather(code, is_day)
# Last updated timestamp
now = datetime.datetime.now()
self.update_time_label.setText(f"обновлено {now.strftime('%H:%M')}")
# Parse 3-day forecast (indices 1, 2, 3 in daily data)
daily = data.get("daily", {})
daily_times = daily.get("time", [])
daily_codes = daily.get("weather_code", [])
daily_maxs = daily.get("temperature_2m_max", [])
daily_mins = daily.get("temperature_2m_min", [])
for i in range(1, 4):
if i < len(daily_times):
date_str = daily_times[i]
f_code = daily_codes[i]
max_temp = round(daily_maxs[i])
min_temp = round(daily_mins[i])
# Get weekday name in Russian
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
weekdays_ru = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
day_name = weekdays_ru[dt.weekday()]
# Format forecast temps
max_str = f"+{max_temp}°" if max_temp > 0 else f"{max_temp}°"
min_str = f"+{min_temp}°" if min_temp > 0 else f"{min_temp}°"
# Apply to forecast labels
self.forecast_days[i-1]["day_label"].setText(day_name)
self.forecast_days[i-1]["temp_label"].setText(f"{max_str} / {min_str}")
self.forecast_days[i-1]["icon_label"].set_weather(f_code, True)
else:
self.set_offline_status("Ошибка сервера")
except Exception as e:
print(f"Fetch error: {e}")
self.set_offline_status("Нет сети")
def set_offline_status(self, message):
self.condition_label.setText(message)
self.temp_label.setText("--°")
self.details_label.setText("ощущ. --° | -- м/с | --%")
self.icon_label.set_weather(3, True) # Show cloud
for item in self.forecast_days:
item["day_label"].setText("---")
item["temp_label"].setText("--° / --°")
item["icon_label"].set_weather(3, True)
def close_app(self):
self.tray_icon.hide()
QtWidgets.QApplication.quit()
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
# Ensure app doesn't close when main window is hidden/minimized
app.setQuitOnLastWindowClosed(False)
widget = WeatherWidget()
widget.show()
sys.exit(app.exec())
+38
View File
@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['weather_widget.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='weather_widget',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)