From fc4a283c0a2c95990af569eb614e2d9d0ae5bd8f Mon Sep 17 00:00:00 2001 From: fabritsky Date: Tue, 26 May 2026 09:08:34 +0700 Subject: [PATCH] feat: add company sidebar filters, internal phone, mail icon fix, and update script --- backend/db.js | 9 ++ backend/server.js | 39 ++++++--- frontend/src/App.jsx | 18 +++- frontend/src/components/AdminPanel.jsx | 29 ++++++- frontend/src/components/DetailModal.jsx | 8 +- frontend/src/components/EmployeeTable.jsx | 10 ++- frontend/src/components/ImportExport.jsx | 13 +-- frontend/src/components/Sidebar.jsx | 48 ++++++++++- frontend/src/index.css | 3 +- update.sh | 100 ++++++++++++++++++++++ 10 files changed, 250 insertions(+), 27 deletions(-) create mode 100755 update.sh diff --git a/backend/db.js b/backend/db.js index 6006b8c..015162b 100644 --- a/backend/db.js +++ b/backend/db.js @@ -78,11 +78,20 @@ async function initializeTables() { email TEXT, building TEXT, floor TEXT, + internal_phone TEXT, FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL, FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL ) `); + // Dynamic schema migration: add internal_phone column if it does not exist + const columns = await all('PRAGMA table_info(employees)'); + const hasInternalPhone = columns.some((col) => col.name === 'internal_phone'); + if (!hasInternalPhone) { + console.log('Migrating database: adding internal_phone column to employees table...'); + await run('ALTER TABLE employees ADD COLUMN internal_phone TEXT'); + } + // Seed data if database is fresh const deptCount = await get('SELECT COUNT(*) as count FROM departments'); if (deptCount.count === 0) { diff --git a/backend/server.js b/backend/server.js index c6e62fc..d305f27 100644 --- a/backend/server.js +++ b/backend/server.js @@ -190,7 +190,7 @@ app.put('/api/companies/:id', authenticateToken, async (req, res) => { // 4. Employees Endpoints (Search & List) app.get('/api/employees', async (req, res) => { - const { search, departmentId } = req.query; + const { search, departmentId, companyId } = req.query; let sql = ` SELECT e.*, d.name as department_name, c.name as company_name @@ -206,6 +206,11 @@ app.get('/api/employees', async (req, res) => { params.push(departmentId); } + if (companyId) { + conditions.push('e.company_id = ?'); + params.push(companyId); + } + if (conditions.length > 0) { sql += ' WHERE ' + conditions.join(' AND '); } @@ -237,6 +242,7 @@ app.get('/api/employees', async (req, res) => { 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); + const internalPhoneMatch = e.internal_phone && e.internal_phone.includes(cleanSearch); let phoneMatch = false; if (searchDigits.length > 0 && e.phone) { @@ -246,7 +252,7 @@ app.get('/api/employees', async (req, res) => { } } - return nameMatch || jobMatch || emailMatch || deptMatch || compMatch || buildMatch || floorMatch || phoneMatch; + return nameMatch || jobMatch || emailMatch || deptMatch || compMatch || buildMatch || floorMatch || phoneMatch || internalPhoneMatch; }); } @@ -258,16 +264,20 @@ app.get('/api/employees', async (req, res) => { // Create Employee app.post('/api/employees', authenticateToken, async (req, res) => { - const { name, company_id, job_title, department_id, phone, email, building, floor } = req.body; + const { name, company_id, job_title, department_id, phone, email, building, floor, internal_phone } = req.body; if (!name || !job_title) { return res.status(400).json({ error: 'Name and Job Title are required fields.' }); } + if (internal_phone && !/^\d{3}$/.test(internal_phone.trim())) { + return res.status(400).json({ error: 'Внутренний телефон должен состоять ровно из 3 цифр.' }); + } + try { const result = await db.run(` - INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor, internal_phone) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ name.trim(), company_id || null, @@ -276,7 +286,8 @@ app.post('/api/employees', authenticateToken, async (req, res) => { phone ? phone.trim() : '', email ? email.trim() : '', building ? building.trim() : '', - floor ? floor.trim() : '' + floor ? floor.trim() : '', + internal_phone ? internal_phone.trim() : '' ]); const newEmp = await db.get(` @@ -296,16 +307,20 @@ app.post('/api/employees', authenticateToken, async (req, res) => { // 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; + const { name, company_id, job_title, department_id, phone, email, building, floor, internal_phone } = req.body; if (!name || !job_title) { return res.status(400).json({ error: 'Name and Job Title are required fields.' }); } + if (internal_phone && !/^\d{3}$/.test(internal_phone.trim())) { + return res.status(400).json({ error: 'Внутренний телефон должен состоять ровно из 3 цифр.' }); + } + try { await db.run(` UPDATE employees - SET name = ?, company_id = ?, job_title = ?, department_id = ?, phone = ?, email = ?, building = ?, floor = ? + SET name = ?, company_id = ?, job_title = ?, department_id = ?, phone = ?, email = ?, building = ?, floor = ?, internal_phone = ? WHERE id = ? `, [ name.trim(), @@ -316,6 +331,7 @@ app.put('/api/employees/:id', authenticateToken, async (req, res) => { email ? email.trim() : '', building ? building.trim() : '', floor ? floor.trim() : '', + internal_phone ? internal_phone.trim() : '', id ]); @@ -393,8 +409,8 @@ app.post('/api/admin/import', authenticateToken, async (req, res) => { 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 (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor, internal_phone) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ e.name, newCompId, @@ -403,7 +419,8 @@ app.post('/api/admin/import', authenticateToken, async (req, res) => { e.phone || '', e.email || '', e.building || '', - e.floor || '' + e.floor || '', + e.internal_phone || '' ]); } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6d27fac..adbaaf7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,6 +34,7 @@ const App = () => { const [searchQuery, setSearchQuery] = useState(''); const [selectedDept, setSelectedDept] = useState(null); + const [selectedCompany, setSelectedCompany] = useState(null); const [activeView, setActiveView] = useState('directory'); // 'directory', 'login', 'admin' const [selectedEmployee, setSelectedEmployee] = useState(null); @@ -117,10 +118,13 @@ const App = () => { // Client-side search and filtering const filteredEmployees = employees.filter((emp) => { - // 1. Department filter + // 1. Company filter + if (selectedCompany && emp.company_id !== selectedCompany) return false; + + // 2. Department filter if (selectedDept && emp.department_id !== selectedDept) return false; - // 2. Search query filter (Case-insensitive, fragments, normalized phone) + // 3. Search query filter (Case-insensitive, fragments, normalized phone) if (searchQuery.trim()) { const queryClean = searchQuery.toLowerCase().trim(); const queryDigits = getQueryPhoneDigits(queryClean); @@ -132,13 +136,18 @@ const App = () => { const empPhoneDigits = getPhoneDigits(emp.phone); const phoneMatch = queryDigits.length > 0 && empPhoneDigits.includes(queryDigits); + // Match internal phone + const empInternalPhoneDigits = getPhoneDigits(emp.internal_phone); + const internalPhoneMatch = queryDigits.length > 0 && empInternalPhoneDigits.includes(queryDigits); + const internalPhoneStringMatch = emp.internal_phone && emp.internal_phone.toLowerCase().includes(queryClean); + // Secondary match parameters (Job title, Company, Location) const titleMatch = emp.job_title.toLowerCase().includes(queryClean); const companyMatch = emp.company_name && emp.company_name.toLowerCase().includes(queryClean); const deptMatch = emp.department_name && emp.department_name.toLowerCase().includes(queryClean); const buildingMatch = emp.building && emp.building.toLowerCase().includes(queryClean); - return nameMatch || phoneMatch || titleMatch || companyMatch || deptMatch || buildingMatch; + return nameMatch || phoneMatch || internalPhoneMatch || internalPhoneStringMatch || titleMatch || companyMatch || deptMatch || buildingMatch; } return true; @@ -203,6 +212,9 @@ const App = () => { {activeView === 'directory' && (
{emp.phone &&
📞 {emp.phone}
} + {emp.internal_phone &&
📞 Вн.: {emp.internal_phone}
} {emp.email &&
✉️ {emp.email}
}
@@ -751,6 +760,20 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData }) />
+
+ + setEmpForm({ ...empForm, internal_phone: e.target.value })} + /> +
+
{ if (!employee) return null; - const { name, company_name, job_title, department_name, phone, email, building, floor } = employee; + const { name, company_name, job_title, department_name, phone, email, building, floor, internal_phone } = employee; // Generate and download vCard (VCF) const handleDownloadVCard = () => { @@ -19,6 +19,7 @@ const DetailModal = ({ employee, onClose }) => { const noteParts = []; if (building) noteParts.push(`Здание: ${building}`); if (floor) noteParts.push(`Этаж: ${floor}`); + if (internal_phone) noteParts.push(`Внутренний телефон: ${internal_phone}`); const vCardLines = [ 'BEGIN:VCARD', @@ -82,6 +83,11 @@ const DetailModal = ({ employee, onClose }) => { ) : 'Не указан'}
+ +
+ Внутренний телефон + {internal_phone || 'Не указан'} +
Здание diff --git a/frontend/src/components/EmployeeTable.jsx b/frontend/src/components/EmployeeTable.jsx index 1557107..f4f4df3 100644 --- a/frontend/src/components/EmployeeTable.jsx +++ b/frontend/src/components/EmployeeTable.jsx @@ -17,13 +17,14 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => { Должность Размещение Телефон + Вн. тел. Email {employees.length === 0 ? ( - + Сотрудники не найдены @@ -66,6 +67,13 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => { )} + + {emp.internal_phone ? ( + {emp.internal_phone} + ) : ( + + )} + {emp.email ? ( { throw new Error('CSV файл пуст или содержит недостаточно строк.'); } - // Expected Headers: ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж + // Expected Headers: ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж;Внутренний телефон // Parse semicolon separated columns const employees = []; const departmentsSet = new Set(); @@ -74,7 +74,8 @@ const ImportExport = ({ token, onRefreshData }) => { phone: values[4] || '', email: values[5] || '', building: values[6] || '', - floor: values[7] || '' + floor: values[7] || '', + internal_phone: values[8] || '' }; if (emp.name && emp.job_title) { @@ -108,6 +109,7 @@ const ImportExport = ({ token, onRefreshData }) => { email: emp.email, building: emp.building, floor: emp.floor, + internal_phone: emp.internal_phone, department_id: dept ? dept.id : null, company_id: comp ? comp.id : null }; @@ -174,7 +176,7 @@ const ImportExport = ({ token, onRefreshData }) => { if (!res.ok) throw new Error('Ошибка экспорта данных с сервера'); const data = await res.json(); - const headers = ['ФИО', 'Компания', 'Отдел', 'Должность', 'Телефон', 'Email', 'Здание', 'Этаж']; + const headers = ['ФИО', 'Компания', 'Отдел', 'Должность', 'Телефон', 'Email', 'Здание', 'Этаж', 'Внутренний телефон']; // Semicolon separator const csvRows = [headers.join(';')]; @@ -189,7 +191,8 @@ const ImportExport = ({ token, onRefreshData }) => { `"${(emp.phone || '').replace(/"/g, '""')}"`, `"${(emp.email || '').replace(/"/g, '""')}"`, `"${(emp.building || '').replace(/"/g, '""')}"`, - `"${(emp.floor || '').replace(/"/g, '""')}"` + `"${(emp.floor || '').replace(/"/g, '""')}"`, + `"${(emp.internal_phone || '').replace(/"/g, '""')}"` ]; csvRows.push(row.join(';')); } @@ -246,7 +249,7 @@ const ImportExport = ({ token, onRefreshData }) => {

- * При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - точка с запятой ";"): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж. Кодировка UTF-8. + * При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - точка с запятой ";"): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж;Внутренний телефон. Кодировка UTF-8.

diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 299fda6..2489e67 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,6 +1,20 @@ import React from 'react'; -const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => { +const Sidebar = ({ + companies, + selectedCompany, + setSelectedCompany, + departments, + selectedDept, + setSelectedDept, + employees +}) => { + // Count employees in a company + const getCompanyCount = (companyId) => { + if (!companyId) return employees.length; + return employees.filter(emp => emp.company_id === companyId).length; + }; + // Count employees in a department const getDeptCount = (deptId) => { if (!deptId) return employees.length; @@ -9,6 +23,36 @@ const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => { return (