feat: add company sidebar filters, internal phone, mail icon fix, and update script

This commit is contained in:
2026-05-26 09:08:34 +07:00
parent 7e11d42bbb
commit fc4a283c0a
10 changed files with 250 additions and 27 deletions
+15 -3
View File
@@ -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' && (
<div className="directory-layout">
<Sidebar
companies={companies}
selectedCompany={selectedCompany}
setSelectedCompany={setSelectedCompany}
departments={departments}
selectedDept={selectedDept}
setSelectedDept={setSelectedDept}
+26 -3
View File
@@ -16,7 +16,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
phone: '',
email: '',
building: '',
floor: ''
floor: '',
internal_phone: ''
});
// Department form state
@@ -215,7 +216,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
phone: '',
email: '',
building: '',
floor: ''
floor: '',
internal_phone: ''
});
setShowEmpModal(true);
};
@@ -230,7 +232,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
phone: emp.phone || '',
email: emp.email || '',
building: emp.building || '',
floor: emp.floor || ''
floor: emp.floor || '',
internal_phone: emp.internal_phone || ''
});
setShowEmpModal(true);
};
@@ -242,6 +245,11 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
return;
}
if (empForm.internal_phone && !/^\d{3}$/.test(empForm.internal_phone.trim())) {
showAlert('error', 'Внутренний телефон должен состоять ровно из 3 цифр!');
return;
}
try {
const employeePayload = {
...empForm,
@@ -408,6 +416,7 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.15rem', fontSize: '0.8rem' }}>
{emp.phone && <div>📞 {emp.phone}</div>}
{emp.internal_phone && <div>📞 Вн.: {emp.internal_phone}</div>}
{emp.email && <div> {emp.email}</div>}
</div>
</td>
@@ -751,6 +760,20 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
/>
</div>
<div className="form-group">
<label className="form-label">Внутренний телефон</label>
<input
type="text"
className="form-control"
placeholder="123"
maxLength={3}
pattern="^[0-9]{3}$|^$"
title="Внутренний телефон должен состоять ровно из 3 цифр"
value={empForm.internal_phone}
onChange={(e) => setEmpForm({ ...empForm, internal_phone: e.target.value })}
/>
</div>
<div className="form-group">
<label className="form-label">Электронная почта (Email)</label>
<input
+7 -1
View File
@@ -4,7 +4,7 @@ import { CloseIcon, PhoneIcon, EmailIcon, OfficeIcon, DownloadIcon } from '../ut
const DetailModal = ({ employee, onClose }) => {
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 }) => {
) : 'Не указан'}
</span>
</div>
<div className="detail-block">
<span className="detail-label">Внутренний телефон</span>
<span className="detail-value">{internal_phone || 'Не указан'}</span>
</div>
<div className="detail-block">
<span className="detail-label">Здание</span>
+9 -1
View File
@@ -17,13 +17,14 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => {
<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 colSpan="8" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
Сотрудники не найдены
</td>
</tr>
@@ -66,6 +67,13 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => {
<span style={{ color: 'var(--text-muted)' }}></span>
)}
</td>
<td>
{emp.internal_phone ? (
<span style={{ fontWeight: 600 }}>{emp.internal_phone}</span>
) : (
<span style={{ color: 'var(--text-muted)' }}></span>
)}
</td>
<td>
{emp.email ? (
<a
+8 -5
View File
@@ -56,7 +56,7 @@ const ImportExport = ({ token, onRefreshData }) => {
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 }) => {
</div>
</form>
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.75rem' }}>
* При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - <strong>точка с запятой ";"</strong>): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж. Кодировка UTF-8.
* При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - <strong>точка с запятой ";"</strong>): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж;Внутренний телефон. Кодировка UTF-8.
</p>
</div>
+46 -2
View File
@@ -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 (
<aside className="filters-sidebar">
{/* Organizations section */}
<div className="filters-title">
<span>Организации</span>
</div>
<div className="filters-section" style={{ marginBottom: '1.5rem' }}>
<ul className="dept-list">
<li>
<button
className={`dept-item ${selectedCompany === null ? 'active' : ''}`}
onClick={() => setSelectedCompany(null)}
>
<span>Все организации</span>
<span className="dept-count">{getCompanyCount(null)}</span>
</button>
</li>
{companies.map((comp) => (
<li key={comp.id}>
<button
className={`dept-item ${selectedCompany === comp.id ? 'active' : ''}`}
onClick={() => setSelectedCompany(comp.id)}
>
<span>{comp.name}</span>
<span className="dept-count">{getCompanyCount(comp.id)}</span>
</button>
</li>
))}
</ul>
</div>
{/* Departments section */}
<div className="filters-title">
<span>Подразделения</span>
</div>
@@ -19,7 +63,7 @@ const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => {
className={`dept-item ${selectedDept === null ? 'active' : ''}`}
onClick={() => setSelectedDept(null)}
>
<span>Все сотрудники</span>
<span>Все подразделения</span>
<span className="dept-count">{getDeptCount(null)}</span>
</button>
</li>
+2 -1
View File
@@ -446,7 +446,8 @@ button, input, select, textarea {
.table-contact-link svg {
width: 0.85rem;
height: 0.85rem;
fill: currentColor;
fill: none;
stroke: currentColor;
}
/* Location labels in table */