Files
2026-10-07 13:41:38 +04:00

445 KiB
Raw Permalink Blame History

Project Structure

src/

src/App.jsx

import { StoreProvider } from './context/StoreContext';
import { ToastProvider } from './context/ToastContext';
import { useStore, useAuth } from './hooks';
import LoginScreen from './components/LoginScreen';
import MainLayout from './components/MainLayout';

function AppContent() {
  const { store, data, login, logout } = useStore();
  const { user } = useAuth();

  if (!user) {
    return <LoginScreen db={data} setDb={(fn) => { store._data = fn(store._data); store._notify(); }} onLogin={login} />;
  }
  return <MainLayout store={store} data={data} user={user} />;
}

export default function App() {
  return (
    <StoreProvider>
      <ToastProvider>
        <AppContent />
      </ToastProvider>
    </StoreProvider>
  );
}

src/index.jsx

import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import ErrorBoundary from './components/ErrorBoundary';
import './css/styles.css';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>
);

src/tree.txt

������� �����
��਩�� ����� ⮬�: A208-C61C
C:.
|   App.jsx
|   index.jsx
|   tree.txt
|   
+---assets
|       hero.png
|       react.svg
|       vite.svg
|       
+---components
|   |   Archive.jsx
|   |   Avatar.jsx
|   |   Cabinet.jsx
|   |   Calendar.jsx
|   |   CreateEmployeeModal.jsx
|   |   Discussion.jsx
|   |   EditEmployeeModal.jsx
|   |   ErrorBoundary.jsx
|   |   FormField.jsx
|   |   Gantt.jsx
|   |   Icons.jsx
|   |   Journal.jsx
|   |   Kanban.jsx
|   |   LoginScreen.jsx
|   |   MainLayout.jsx
|   |   Modal.jsx
|   |   ModalRenderer.jsx
|   |   NotifPanel.jsx
|   |   ProjectProgress.jsx
|   |   Projects.jsx
|   |   Reports.jsx
|   |   Requests.jsx
|   |   Staff.jsx
|   |   Table.jsx
|   |   
|   +---Modals
|   |       DelegationModal.jsx
|   |       DeptsModal.jsx
|   |       HoursRequestModal.jsx
|   |       index.jsx
|   |       ProjectModal.jsx
|   |       RolesModal.jsx
|   |       TaskModal.jsx
|   |       VacationModal.jsx
|   |       VacNowModal.jsx
|   |       
|   \---views
|           ArchiveView.jsx
|           CabinetView.jsx
|           index.js
|           JournalView.jsx
|           ProjectsView.jsx
|           ReportsView.jsx
|           RequestsView.jsx
|           StaffView.jsx
|           TasksList.jsx
|           TasksView.jsx
|           
+---context
|       StoreContext.jsx
|       ToastContext.jsx
|       
+---css
|       styles.css
|       
+---hooks
|       index.js
|       useAuth.js
|       useDataHelpers.js
|       useModals.js
|       useStore.js
|       useTaskFilters.js
|       
+---services
|       DataStore.js
|       mockData.js
|       
\---utils
        constants.js
        date.js
        helpers.js
        permissions.js
        projectHelpers.js
        

src/utils/

  • Folder: utils/

src/utils/constants.js

export const ROLES = {
  admin:            { label: "Суперадминистратор", short: "СУП", color: "#ef4444" },
  director:         { label: "Генеральный директор", short: "ГД", color: "#f59e0b" },
  economist:        { label: "Главный экономист", short: "ГЭ", color: "#8b5cf6" },
  kb_chief:         { label: "Главный конструктор КБ", short: "ГК", color: "#0ea5e9" },
  head:             { label: "Руководитель отдела", short: "РО", color: "#3b82f6" },
  project_lead:     { label: "Ответственный по проекту", short: "ОП", color: "#ec4899" },
  project_manager:  { label: "Менеджер проектов", short: "МП", color: "#f97316" },
  hr:               { label: "HR-менеджер", short: "HR", color: "#14b8a6" },
  executor:         { label: "Исполнитель", short: "ИСП", color: "#64748b" },
};

export const TASK_STATUSES = {
  new:      { label: "Новая", color: "#94a3b8" },
  inwork:   { label: "В работе", color: "#3b82f6" },
  review:   { label: "На проверке", color: "#f59e0b" },
  closed:   { label: "Закрыта", color: "#10b981" },
  cancelled:{ label: "Отменена", color: "#64748b" },
};
export const TASK_STATUS_ORDER = ["new", "inwork", "review", "closed", "cancelled"];

export const PRIORITIES = {
  low:  { label: "Низкий", color: "#10b981" },   // зелёный
  mid:  { label: "Средний", color: "#f59e0b" },  // жёлтый
  high: { label: "Высокий", color: "#f97316" },  // оранжевый
  crit: { label: "Критический", color: "#dc2626" }, // красный
};

export const PROJECT_PRIORITIES = {
  AOG:  { label: 'AOG',  color: '#dc2626', order: 1 },   // красный
  CRIT: { label: 'CRIT', color: '#f59e0b', order: 2 },   // жёлтый
  NORM: { label: 'NORM', color: '#10b981', order: 3 },   // зелёный
};

export const DEPENDENCY_TYPES = {
  FS: { label: "Окончание-Начало (FS)", desc: "Задача начнётся после завершения предыдущей" },
  SS: { label: "Начало-Начало (SS)", desc: "Задача начнётся одновременно с началом предыдущей" },
  FF: { label: "Окончание-Окончание (FF)", desc: "Задача завершится одновременно с завершением предыдущей" },
  SF: { label: "Начало-Окончание (SF)", desc: "Задача завершится после начала предыдущей" },
};

export const VACATION_TYPES = {
  annual: "Ежегодный",
  admin: "Административный",
  sick: "Больничный",
  other: "Другой",
};

export const PROJECT_STATUSES = {
  active:    "Активный",
  inactive:  "Неактивный",
  closed:    "Закрыт",
  cancelled: "Отменён"
};

export const PROJECT_STATUS_CONFIG = {
  active:    { label: 'Активный', color: '#10b981' },
  inactive:  { label: 'Неактивный', color: '#94a3b8' },
  closed:    { label: 'Закрыт', color: '#3b82f6' },
  cancelled: { label: 'Отменён', color: '#ef4444' },
};

export const ADMIN_PROJECT_PRIORITIES = {
  high: { label: 'Высокий', color: '#f97316', order: 1 },
  mid:  { label: 'Средний', color: '#f59e0b', order: 2 },
  low:  { label: 'Низкий',  color: '#10b981', order: 3 },
};

export const PROJECT_STATUS_ORDER = ['inactive', 'active', 'closed', 'cancelled'];

export const PROJECT_TYPES = { prod: "Производственный", admin: "Административный" };
export const COMMENT_EDIT_WINDOW = 15 * 60000; // 15 минут
export const DOMAIN = "aviahorizont.ru";

src/utils/date.js

export const pad2 = (n) => String(n).padStart(2, "0");
export const iso = (d) => `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`;
export const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate()+n); return x; };
export const addMonths = (d, n) => { const x = new Date(d); x.setMonth(x.getMonth() + n); return x; };
export const addYears = (d, n) => { const x = new Date(d); x.setFullYear(x.getFullYear() + n); return x; };
export const parseISO = (s) => { const [y,m,d] = String(s).split("-").map(Number); return new Date(y,m-1,d); };
export const TODAY = iso(new Date());
export const daysDiff = (a,b) => Math.round((parseISO(b)-parseISO(a))/86400000);
export const MS_SHORT = ["янв","фев","мар","апр","мая","июн","июл","авг","сен","окт","ноя","дек"];
export const MS_FULL = ["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
export const fmtD = (s) => { 
  if (!s) return "-";
  const d = new Date(s);
  if (isNaN(d.getTime())) return "-";
  return `${d.getDate()} ${MS_SHORT[d.getMonth()]}`;
};
export const fmtDMY = (s) => {
  if (!s) return "-";
  const d = new Date(s);
  if (isNaN(d.getTime())) return "-";
  return `${pad2(d.getDate())}.${pad2(d.getMonth()+1)}.${d.getFullYear()}`;
};
export const fmtDT = (ts) => {
  const d = new Date(ts);
  if (isNaN(d.getTime())) return "-";
  return `${fmtDMY(iso(d))} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
};
export const uid = () => Math.random().toString(36).slice(2,10);
export const initials = (f,l) => `${(f||"?")[0]}${(l||"?")[0]}`;

export const isTaskActive = (task) => {
  if (!task) return false;
  if (!task.archived) return true;
  if (task.closedAt) {
    const cutoff = addMonths(new Date(), -3);
    return task.closedAt >= iso(cutoff);
  }
  return false;
};


src/utils/helpers.js

// utils/helpers.js
export function getEmployeePrimaryDepartment(employee, db) {
  if (!employee || !db) return null;

  // Для главного конструктора возвращаем его КБ
  if (employee.roles && employee.roles.includes('kb_chief') && employee.kbIds && employee.kbIds.length > 0) {
    const kbs = employee.kbIds
      .map(id => db.kbs?.find(k => k.id === id))
      .filter(Boolean);
    if (kbs.length > 0) {
      // Если несколько КБ – возвращаем первый или объединяем через запятую
      return kbs.length === 1 ? kbs[0] : { id: kbs.map(k => k.id).join(','), name: kbs.map(k => k.name).join(', '), type: 'kb' };
    }
  }

  // Для остальных – ищем основное подразделение (primary)
  if (employee.departments && employee.departments.length > 0) {
    const primaryDept = employee.departments.find(d => d.primary) || employee.departments[0];
    if (primaryDept) {
      const dept = db.departments?.find(d => d.id === primaryDept.deptId);
      if (dept) {
        return dept;
      }
    }
  }
  return null;
}

// Упрощённая версия, возвращающая только название
export function getPrimaryDeptName(employee, db) {
  const dept = getEmployeePrimaryDepartment(employee, db);
  return dept ? dept.name : '-';
}

src/utils/permissions.js

import { ROLES } from './constants';

export const hasRole = (user, ...roles) => !!user && roles.some(r => user.roles.includes(r));
export const has = hasRole;

export const canEditDepartments = (user) => hasRole(user, "admin", "director", "hr");
export const canManageAllVacations = (user) => hasRole(user, "admin", "director", "hr");
export const canRestore = (user) => hasRole(user, "admin", "director");
export const canCreateTask = (user) => hasRole(user, "admin", "director", "economist", "kb_chief", "head", "project_lead", "project_manager");
export const canCreateProject = (user) => hasRole(user, "admin", "director", "kb_chief", "project_manager");
export const canManageManager = (user) => hasRole(user, "admin", "director", "kb_chief", "project_manager");
export const canExport = (user) => hasRole(user, "admin", "director", "economist");
export const canEditRoles = (user) => hasRole(user, "admin");
export const canFireEmployee = (user) => hasRole(user, "admin", "director", "hr");

// Право на изменение полей задачи (кроме статуса) – менеджер проектов не может редактировать
export const canEditTaskFields = (user, task, data) => {
  if (!user || !task || !data) return false;
  if (task.archived) return false;
  if (hasRole(user, "admin", "economist")) return true;
  // Менеджер проектов не может редактировать поля (только создавать)
  if (hasRole(user, "project_manager")) return false;
  return false;
};

// Право на изменение статуса задачи
export const canChangeTaskStatus = (user, task, newStatus, data) => {
  if (!user || !task || !data) return false;
  if (task.archived) return false;
  if (task.status === 'closed' || task.status === 'cancelled') {
    // Только администратор может reopening закрытые/отмененные задачи
    return hasRole(user, 'admin');
  }
  
  const project = data.projects.find(p => p.id === task.projectId);
  const isProdProject = project && project.ptype !== 'admin';
  const isAdminProject = project && project.ptype === 'admin';
  
  // Администратор может всё
  if (hasRole(user, "admin")) return true;
  
  // Генеральный директор может всё
  if (hasRole(user, "director")) return true;
  
  // Для административных проектов
  if (isAdminProject) {
    if (hasRole(user, "kb_chief") && project.kbId && (user.kbIds || []).includes(project.kbId)) {
      return true;
    }
    if (hasRole(user, "head") && (task.assigneeIds || []).some(id => {
      const e = data.employees.find(x => x.id === id);
      return e && e.departments.some(d => (user.headDeptIds || []).includes(d.deptId));
    })) return true;
    if (hasRole(user, "project_lead") && project.managerId === user.id) return true;
    if (task.assigneeIds && task.assigneeIds.includes(user.id)) {
      if (newStatus === 'closed' || newStatus === 'cancelled') return false;
      return true;
    }
    return false;
  }
  
  // Для производственных проектов строгая механика
  if (isProdProject) {
    // Руководитель КБ
    if (hasRole(user, "kb_chief") && project.kbId && (user.kbIds || []).includes(project.kbId)) {
      return true;
    }
    
    // Руководитель подразделения
    if (hasRole(user, "head") && (task.assigneeIds || []).some(id => {
      const e = data.employees.find(x => x.id === id);
      return e && e.departments.some(d => (user.headDeptIds || []).includes(d.deptId));
    })) return true;
    
    // Ведущий проекта
    if (hasRole(user, "project_lead") && project.managerId === user.id) return true;
    
    // Менеджер проектов не может менять статус задач (только проектов)
    if (hasRole(user, "project_manager")) return false;
    
    // Исполнитель может переводить:
    // - из "new" в "inwork"
    // - из "inwork" в "review"
    // - из "review" в "inwork" (возврат на доработку)
    // НЕ может закрывать задачу
    if (task.assigneeIds && task.assigneeIds.includes(user.id)) {
      if (newStatus === 'closed' || newStatus === 'cancelled') return false;
      if (task.status === 'new' && newStatus === 'inwork') return true;
      if (task.status === 'inwork' && newStatus === 'review') return true;
      if (task.status === 'review' && newStatus === 'inwork') return true; // <-- ДОБАВЛЕНО
      return false;
    }
    
    return false;
  }
  
  // Fallback для остальных случаев
  if (hasRole(user, "kb_chief") && project.kbId && (user.kbIds || []).includes(project.kbId)) {
    return true;
  }
  if (hasRole(user, "head") && (task.assigneeIds || []).some(id => {
    const e = data.employees.find(x => x.id === id);
    return e && e.departments.some(d => (user.headDeptIds || []).includes(d.deptId));
  })) return true;
  if (hasRole(user, "project_lead") && project.managerId === user.id) return true;
  if (task.assigneeIds && task.assigneeIds.includes(user.id)) {
    if (newStatus === 'closed' || newStatus === 'cancelled') return false;
    return true;
  }
  return false;
};

// Право на редактирование полей проекта – менеджер проектов не может редактировать поля, только статус
export const canEditProjectFields = (user, project) => {
  if (!user || !project) return false;
  if (project.archived) return false;
  if (hasRole(user, "admin", "director")) return true;
  // Менеджер проектов не может редактировать поля проекта (только статус)
  if (hasRole(user, "project_manager")) return false;
  return false;
};

// Право на изменение статуса проекта
export const canChangeProjectStatus = (user, project, newStatus) => {
  if (!user || !project) return false;
  if (project.archived) return false;
  
  // Администратор может всё
  if (hasRole(user, 'admin')) return true;
  
  // Генеральный директор может всё
  if (hasRole(user, 'director')) return true;
  
  // Менеджер проектов может менять статус (перетаскивать по канбану)
  if (hasRole(user, 'project_manager')) return true;
  
  // Для закрытия/отмены проекта также может создатель
  if (newStatus === 'closed' || newStatus === 'cancelled') {
    const creatorId = project.creatorId || (project.history?.find(h => h.who !== 'system')?.who);
    if (creatorId && creatorId === user.id) return true;
  }
  
  // Руководитель КБ для своих проектов
  if (hasRole(user, 'kb_chief') && project.kbId && (user.kbIds || []).includes(project.kbId)) return true;
  
  return false;
};

export const projectEditable = (user, project, data) => {
  return canEditProjectFields(user, project);
};

export const assigneeOptions = (user, data) => {
  if (!user || !data) return [];
  let list = [];
  let allEmployees = data.employees.filter(e => !e.fired);

  if (hasRole(user, "admin", "director", "economist", "project_lead", "project_manager")) {
    list = allEmployees;
  } else if (hasRole(user, "kb_chief") && (user.kbIds || []).length) {
    const deptIds = data.departments.filter(d => d.kbId && user.kbIds.includes(d.kbId)).map(d => d.id);
    list = allEmployees.filter(e => e.id === user.id || e.departments.some(x => deptIds.includes(x.deptId)) || hasRole(e, "director"));
  } else if (hasRole(user, "head")) {
    list = allEmployees.filter(e => e.id === user.id || e.departments.some(x => (user.headDeptIds || []).includes(x.deptId)) || hasRole(e, "director"));
  } else {
    list = allEmployees.filter(e => e.id === user.id);
  }
  return list.sort((a, b) => {
    const cmp = a.last.localeCompare(b.last);
    return cmp !== 0 ? cmp : a.first.localeCompare(b.first);
  });
};

export const canApproveVacation = (user, vacation, data) => {
  if (hasRole(user, "admin", "director")) return true;
  const emp = data.employees.find(e => e.id === vacation.empId);
  if (!emp || emp.id === user.id) return false;
  const primaryDeptId = emp.departments.find(x => x.primary)?.deptId;
  if (hasRole(user, "head") && primaryDeptId && (user.headDeptIds || []).includes(primaryDeptId)) return true;
  if (hasRole(user, "kb_chief")) {
    const dept = data.departments.find(d => d.id === primaryDeptId);
    if (dept && dept.kbId && (user.kbIds || []).includes(dept.kbId)) return true;
  }
  return false;
};

export function computeScope(u, db) {
  if (!u || !db) return { all: false, empIds: new Set(), projIds: new Set() };
  const allE = new Set(db.employees.filter(e => !e.fired).map(e => e.id));
  const allP = new Set(db.projects.map(p => p.id));
  if (hasRole(u, "admin", "director", "economist", "project_manager")) {
    return { all: true, empIds: allE, projIds: allP };
  }
  const empIds = new Set([u.id]);
  const projIds = new Set();
  if (hasRole(u, "kb_chief") && (u.kbIds || []).length) {
    const dIds = db.departments.filter(d => d.kbId && u.kbIds.includes(d.kbId)).map(d => d.id);
    db.employees.filter(e => !e.fired).forEach(e => { if (e.departments.some(x => dIds.includes(x.deptId))) empIds.add(e.id); });
    db.projects.forEach(p => { if (p.kbId && u.kbIds.includes(p.kbId)) projIds.add(p.id); });
  }
  if (hasRole(u, "head") && (u.headDeptIds || []).length) {
    db.employees.filter(e => !e.fired).forEach(e => { if (e.departments.some(x => u.headDeptIds.includes(x.deptId))) empIds.add(e.id); });
  }
  if (hasRole(u, "project_lead")) db.projects.forEach(p => { if (p.managerId === u.id) projIds.add(p.id); });
  db.tasks.forEach(t => {
    if (t.assigneeIds && t.assigneeIds.some(id => empIds.has(id))) projIds.add(t.projectId);
  });
  return { all: false, empIds, projIds };
}

export function taskVisible(u, scope, t, db) {
  if (!scope || !t) return false;
  if (scope.all) return true;
  if (t.assigneeIds && t.assigneeIds.some(id => scope.empIds.has(id))) return true;
  if (!scope.projIds.has(t.projectId)) return false;
  const proj = db.projects.find(p => p.id === t.projectId);
  if (!proj) return false;
  if (hasRole(u, "project_lead") && proj.managerId === u.id) return true;
  if (hasRole(u, "kb_chief") && proj.kbId && (u.kbIds || []).includes(proj.kbId)) return true;
  if (hasRole(u, "head")) return true;
  return false;
}

export function empName(db, id) {
  const e = db.employees.find(x => x.id === id);
  return e ? `${e.last} ${e.first}` : "-";
}
export function primaryDept(db, e) {
  if (!e) return null;
  const p = e.departments.find(x => x.primary) || e.departments[0];
  return p ? db.departments.find(d => d.id === p.deptId) : null;
}

src/utils/projectHelpers.js

import { PROJECT_PRIORITIES, ADMIN_PROJECT_PRIORITIES } from './constants';

export const getProjectColor = (project) => {
  if (!project) return '#64748b';
  const priorities = project.ptype === 'admin' ? ADMIN_PROJECT_PRIORITIES : PROJECT_PRIORITIES;
  return priorities[project.priority]?.color || '#64748b';
};

src/services/

  • Folder: services/

src/services/DataStore.js

import { TODAY, iso, addDays, addMonths, uid, fmtDMY } from '../utils/date';
import { TASK_STATUSES, TASK_STATUS_ORDER, PRIORITIES, VACATION_TYPES, PROJECT_STATUSES, PROJECT_TYPES, DEPENDENCY_TYPES } from '../utils/constants';
import { buildMockData } from './mockData';

export default class DataStore {
  constructor() {
    this._data = buildMockData();
    this._currentUser = null;
    this._listeners = [];
    this._archiveOldTasks(3);
    // Миграция старых задач: если есть assigneeIds, берём первого как assigneeId
    this._migrateTasks();
  }

  _migrateTasks() {
    let changed = false;
    this._data.tasks = this._data.tasks.map(t => {
      if (t.assigneeIds && t.assigneeIds.length > 0 && !t.assigneeId) {
        changed = true;
        return { ...t, assigneeId: t.assigneeIds[0], assigneeIds: undefined };
      }
      if (t.assigneeIds && t.assigneeIds.length === 0 && !t.assigneeId) {
        changed = true;
        return { ...t, assigneeId: null, assigneeIds: undefined };
      }
      return t;
    });
    if (changed) {
      this._notify();
      this.addAudit('Миграция данных', 'Задачи приведены к формату с одним исполнителем (assigneeId)');
    }
  }

  subscribe(callback) {
    this._listeners.push(callback);
    return () => { this._listeners = this._listeners.filter(cb => cb !== callback); };
  }
  _notify() {
    this._listeners.forEach(cb => cb(this._data));
  }

  get data() { return this._data; }
  getCurrentUser() { return this._currentUser; }

  login(email, password) {
    const found = this._data.employees.find(e => e.email.toLowerCase() === email.trim().toLowerCase());
    if (found && found.lockUntil && Date.now() < found.lockUntil) {
      const remainingMinutes = Math.ceil((found.lockUntil - Date.now()) / 60000);
      return `Учётная запись заблокирована на ${remainingMinutes} мин. после 5 неудачных попыток входа`;
    }
    if (found && found.pass === password && !found.fired) {
      if (found.failed > 0) {
        found.failed = 0;
        found.lockUntil = 0;
        this.upsertEmployee(found);
      }
      this._currentUser = found;
      this._notify();
      return true;
    }
    if (found) {
      found.failed = (found.failed || 0) + 1;
      if (found.failed >= 5) {
        found.lockUntil = Date.now() + 15 * 60 * 1000;
        this.upsertEmployee(found);
        return 'Учётная запись заблокирована на 15 мин. после 5 неудачных попыток входа';
      }
      this.upsertEmployee(found);
    }
    return 'Неправильно введен логин/пароль';
  }

  logout() {
    this._currentUser = null;
    this._notify();
  }

  _archiveOldTasks(months = 3) {
    const cutoff = addMonths(new Date(), -months);
    const cutoffIso = iso(cutoff);
    let changed = false;
    this._data.tasks = this._data.tasks.map(t => {
      if (t.archived) return t;
      if ((t.status === 'closed' || t.status === 'cancelled') && t.closedAt && t.closedAt < cutoffIso) {
        changed = true;
        return { ...t, archived: true, archivedAt: TODAY };
      }
      return t;
    });
    if (changed) {
      this._notify();
      this.addAudit('Автоматическая архивация задач', `Задачи, закрытые более ${months} мес., перемещены в архив`);
    }
  }

  _calcSummaryHours(taskId, visited = new Set()) {
    if (visited.has(taskId)) return 0;
    visited.add(taskId);

    const task = this._data.tasks.find(t => t.id === taskId);
    if (!task) return 0;
    if (!task.isSummary) return task.plannedHours || 0;

    const children = this._data.tasks.filter(t => t.parentTaskId === taskId && !t.archived);
    let sum = 0;
    for (const child of children) {
      sum += this._calcSummaryHours(child.id, visited);
    }
    return sum;
  }

  _recalcSummaryHoursChain(taskId) {
    let current = this._data.tasks.find(t => t.id === taskId);
    while (current) {
      if (current.isSummary) {
        const newHours = this._calcSummaryHours(current.id);
        if (current.plannedHours !== newHours) {
          current.plannedHours = newHours;
          const idx = this._data.tasks.findIndex(t => t.id === current.id);
          if (idx !== -1) {
            this._data.tasks[idx] = { ...current };
          }
        }
      }
      if (current.parentTaskId) {
        current = this._data.tasks.find(t => t.id === current.parentTaskId);
      } else {
        break;
      }
    }
  }

  /**
   * Проверяет, не превышает ли сумма плановых часов подзадач заданный бюджет родительской задачи.
   * @param {string} parentId - ID родительской задачи
   * @param {string|null} excludeTaskId - ID задачи, которую нужно исключить из суммы (при обновлении)
   * @throws {Error} если сумма подзадач превышает plannedHours родителя
   */
  _checkSubtaskBudget(parentId, excludeTaskId = null) {
    const parent = this._data.tasks.find(t => t.id === parentId);
    if (!parent) return;

    // Если родитель суммарный или у него не заданы часы – ограничение не применяем
    if (parent.isSummary || parent.plannedHours == null) return;

    // Суммируем часы всех непосредственных подзадач, исключая указанную
    const children = this._data.tasks.filter(t =>
      t.parentTaskId === parentId && t.id !== excludeTaskId && !t.archived
    );
    const sumChildren = children.reduce((acc, t) => acc + (t.plannedHours || 0), 0);

    // Если сумма подзадач превышает бюджет родителя
    if (sumChildren > parent.plannedHours) {
      throw new Error(
        `Сумма плановых часов подзадач (${sumChildren} ч) превышает бюджет родительской задачи "${parent.title}" (${parent.plannedHours} ч). Уменьшите часы подзадач или увеличьте бюджет родителя.`
      );
    }
  }

  upsertTask(task) {
    const idx = this._data.tasks.findIndex(t => t.id === task.id);
    let tasks;
    let auditMessage = '';

    // Проверка бюджета проекта
    const projectForBudget = this._data.projects.find(p => p.id === task.projectId);
    if (projectForBudget && projectForBudget.budget != null && projectForBudget.ptype !== 'admin' && !projectForBudget.archived) {
      const otherTasksSum = this._data.tasks
        .filter(t => t.projectId === task.projectId && t.id !== task.id)
        .reduce((sum, t) => sum + (t.plannedHours || 0), 0);
      const newTotal = otherTasksSum + (task.plannedHours || 0);
      if (newTotal > projectForBudget.budget) {
        throw new Error(
          `Превышение бюджета проекта! Бюджет: ${projectForBudget.budget} ч, сумма остальных задач: ${otherTasksSum} ч, запрошено: ${task.plannedHours || 0} ч.`
        );
      }
    }

    // ----- НОВАЯ ПРОВЕРКА: бюджет подзадач -----
    // 1. Если задача имеет родителя, проверяем, что сумма подзадач родителя (включая эту) не превышает его часы
    if (task.parentTaskId) {
      // При обновлении исключаем саму задачу, при создании – нет
      this._checkSubtaskBudget(task.parentTaskId, idx === -1 ? null : task.id);
    }

    // 2. Если обновляется существующая задача и она НЕ суммарная, и её plannedHours изменяется (или может быть изменён),
    //    проверяем, что сумма её существующих подзадач (если есть) не превышает новый plannedHours.
    //    Это нужно для случая, когда пользователь уменьшает часы родительской задачи, у которой уже есть подзадачи.
    if (idx !== -1) {
      const existingTask = this._data.tasks[idx];
      // Проверяем только если plannedHours задано, задача не суммарная, и plannedHours изменился (или мы просто хотим проверить)
      if (!task.isSummary && task.plannedHours != null) {
        // Суммируем часы всех подзадач этой задачи (исключая саму себя)
        const childrenSum = this._data.tasks
          .filter(t => t.parentTaskId === task.id && t.id !== task.id && !t.archived)
          .reduce((acc, t) => acc + (t.plannedHours || 0), 0);
        if (childrenSum > task.plannedHours) {
          throw new Error(
            `Сумма плановых часов подзадач (${childrenSum} ч) превышает новый бюджет задачи "${task.title}" (${task.plannedHours} ч). Уменьшите часы подзадач или увеличьте бюджет задачи.`
          );
        }
      }
    }

    // Далее идёт существующая логика
    if (idx === -1 && task.parentTaskId) {
      const parent = this._data.tasks.find(t => t.id === task.parentTaskId);
      if (parent) {
        if (!task.projectId) task.projectId = parent.projectId;
      } else {
        task.parentTaskId = null;
      }
    }

    if (idx >= 0) {
      const old = this._data.tasks[idx];
      const changes = [];
      if (old.title !== task.title) changes.push(`Название: "${old.title}" → "${task.title}"`);
      if (!task.isSummary && old.plannedHours !== task.plannedHours) {
        changes.push(`Плановые часы: ${old.plannedHours ?? '-'} → ${task.plannedHours ?? '-'}`);
      }
      // Сравниваем assigneeId
      if (old.assigneeId !== task.assigneeId) {
        const oldName = old.assigneeId ? this.empName(old.assigneeId) : '-';
        const newName = task.assigneeId ? this.empName(task.assigneeId) : '-';
        changes.push(`Исполнитель: ${oldName} → ${newName}`);
      }
      if (old.status !== task.status) {
        changes.push(`Статус: ${TASK_STATUSES[old.status].label} → ${TASK_STATUSES[task.status].label}`);
        if ((task.status === 'closed' || task.status === 'cancelled') && old.status !== task.status) {
          task.closedAt = TODAY;
          if (task.creatorId) {
            this.addNotification(task.creatorId, `Задача "${task.title}" ${task.status === 'closed' ? 'закрыта' : 'отменена'}`, { targetType: 'task', targetId: task.id });
          }
          const project = this._data.projects.find(p => p.id === task.projectId);
          if (project && project.managerId && project.managerId !== task.creatorId) {
            this.addNotification(project.managerId, `Задача "${task.title}" проекта ${project.code} ${task.status === 'closed' ? 'закрыта' : 'отменена'}`, { targetType: 'task', targetId: task.id });
          }
        }
      }
      if (old.dependencyId !== task.dependencyId || old.dependencyType !== task.dependencyType) {
        const oldDep = old.dependencyId ? this._data.tasks.find(t => t.id === old.dependencyId) : null;
        const newDep = task.dependencyId ? this._data.tasks.find(t => t.id === task.dependencyId) : null;
        const depTypeLabel = task.dependencyType ? DEPENDENCY_TYPES[task.dependencyType]?.label : '';
        const oldDepStr = oldDep ? `"${oldDep.title}" (${DEPENDENCY_TYPES[old.dependencyType]?.label || 'FS'})` : 'нет';
        const newDepStr = newDep ? `"${newDep.title}" (${depTypeLabel})` : 'нет';
        changes.push(`Зависимость: ${oldDepStr} → ${newDepStr}`);
      }
      if (changes.length > 0) {
        auditMessage = `Изменение задачи "${task.title}": ${changes.join('; ')}`;
        this.addAudit('Изменение задачи', auditMessage, 'task', task.id);
      }
      tasks = this._data.tasks.map(t => t.id === task.id ? task : t);
    } else {
      if (!task.createdAt) {
        task.createdAt = new Date().toISOString();
      }
      tasks = [...this._data.tasks, task];
      this.addAudit('Создание задачи', task.title, 'task', task.id);
      if (task.assigneeId && task.assigneeId !== this._currentUser?.id) {
        this.addNotification(task.assigneeId, `Вам назначена задача "${task.title}"`, { targetType: 'task', targetId: task.id });
      }
    }

    this._data = { ...this._data, tasks };

    if (task.parentTaskId) {
      this._recalcSummaryHoursChain(task.parentTaskId);
    }
    if (task.isSummary) {
      this._recalcSummaryHoursChain(task.id);
    }

    this._notify();
    this._archiveOldTasks(3);
  }

  deleteTask(id) {
    const task = this._data.tasks.find(t => t.id === id);
    if (task) {
      this.addAudit('Удаление задачи', task.title);
    }

    const parentId = task?.parentTaskId;

    this._data.tasks = this._data.tasks.map(t => {
      if (t.parentTaskId === id) {
        return { ...t, parentTaskId: null };
      }
      return t;
    });

    this._data = { ...this._data, tasks: this._data.tasks.filter(t => t.id !== id) };

    if (parentId) {
      this._recalcSummaryHoursChain(parentId);
    }

    this._notify();
  }

  upsertProject(project) {
    const idx = this._data.projects.findIndex(p => p.id === project.id);
    let projects;
    let auditMessage = '';
    if (idx >= 0) {
      const oldProject = this._data.projects[idx];
      const changes = [];
      if (oldProject.name !== project.name) changes.push(`Название: "${oldProject.name}" → "${project.name}"`);
      if (oldProject.code !== project.code) changes.push(`Код: "${oldProject.code}" → "${project.code}"`);
      if (oldProject.budget !== project.budget) changes.push(`Бюджет: ${oldProject.budget ?? '-'} → ${project.budget ?? '-'}`);
      if (oldProject.status !== project.status) {
        changes.push(`Статус: ${PROJECT_STATUSES[oldProject.status]} → ${PROJECT_STATUSES[project.status]}`);
        if ((project.status === 'closed' || project.status === 'cancelled') && oldProject.status !== project.status) {
          project.archived = true;
          project.archivedAt = TODAY;
          this._data.tasks = this._data.tasks.map(t => {
            if (t.projectId === project.id) {
              const updated = { ...t, archived: true, archivedAt: TODAY };
              if (t.creatorId) {
                this.addNotification(t.creatorId, `Задача "${t.title}" проекта ${project.code} архивирована (проект закрыт)`, { targetType: 'task', targetId: t.id });
              }
              return updated;
            }
            return t;
          });
          this.addNotification(project.managerId || 'system', `Проект "${project.name}" архивирован`, { targetType: 'project', targetId: project.id });
        }
      }
      if (changes.length > 0) {
        auditMessage = `Изменение проекта "${project.name}": ${changes.join('; ')}`;
        this.addAudit('Изменение проекта', auditMessage, 'project', project.id);
      }
      projects = this._data.projects.map(p => p.id === project.id ? project : p);
    } else {
      projects = [...this._data.projects, project];
      this.addAudit('Создание проекта', project.name, 'project', project.id);
    }
    this._data = { ...this._data, projects };
    this._notify();
    this._archiveOldTasks(3);
  }

  deleteProject(id) {
    const project = this._data.projects.find(p => p.id === id);
    if (project) {
      this.addAudit('Удаление проекта', project.name);
    }
    this._data = {
      ...this._data,
      projects: this._data.projects.filter(p => p.id !== id),
      tasks: this._data.tasks.filter(t => t.projectId !== id)
    };
    this._notify();
  }

  upsertVacation(vac) {
    const idx = this._data.vacations.findIndex(v => v.id === vac.id);
    let vacations;
    if (idx >= 0) {
      const old = this._data.vacations[idx];
      this.addAudit('Изменение отпуска', `${vac.empId} ${fmtDMY(vac.start)}-${fmtDMY(vac.end)}`);
      vacations = this._data.vacations.map(v => v.id === vac.id ? vac : v);
    } else {
      vacations = [...this._data.vacations, vac];
      this.addAudit('Создание отпуска', `${vac.empId} ${fmtDMY(vac.start)}-${fmtDMY(vac.end)}`);
    }
    this._data = { ...this._data, vacations };
    this._notify();
    if (vac.delegation.enabled && vac.status === 'approved' && vac.start <= TODAY) {
      this.applyDelegation(vac.id);
    }
  }

  deleteVacation(id) {
    const vac = this._data.vacations.find(v => v.id === id);
    if (vac) {
      this.addAudit('Удаление отпуска', `${vac.empId} ${fmtDMY(vac.start)}-${fmtDMY(vac.end)}`);
      if (vac.delegation.enabled) {
        this.revertDelegation(id);
      }
    }
    this._data = { ...this._data, vacations: this._data.vacations.filter(v => v.id !== id) };
    this._notify();
  }

  applyDelegation(vacationId) {
    const vac = this._data.vacations.find(v => v.id === vacationId);
    if (!vac || !vac.delegation.enabled || vac.status !== 'approved') return;
    const start = vac.start;
    const end = vac.end;
    const fromId = vac.empId;
    const toId = vac.delegation.subId;
    const statuses = vac.delegation.statuses.length ? vac.delegation.statuses : ['new', 'inwork', 'review'];
    this._data.tasks = this._data.tasks.map(t => {
      if (t.archived) return t;
      if (t.assigneeId !== fromId) return t;
      if (!statuses.includes(t.status)) return t;
      if (t.deadline && t.deadline < start) return t;
      const updated = { ...t, assigneeId: toId };
      updated.history = [...updated.history, {
        ts: Date.now(),
        who: 'system',
        text: `Задача переназначена с ${this.empName(fromId)} на ${this.empName(toId)} на период отпуска с ${fmtDMY(start)} по ${fmtDMY(end)}`
      }];
      return updated;
    });
    this._notify();
    this.addNotification(toId, `Вам переданы задачи ${this.empName(fromId)} на время отпуска`, { targetType: 'vacation', targetId: vacationId });
    this.addNotification(fromId, `Ваши задачи переданы ${this.empName(toId)} на период отпуска`, { targetType: 'vacation', targetId: vacationId });
  }

  revertDelegation(vacationId) {
    const vac = this._data.vacations.find(v => v.id === vacationId);
    if (!vac || !vac.delegation.enabled) return;
    const fromId = vac.empId;
    const toId = vac.delegation.subId;
    this._data.tasks = this._data.tasks.map(t => {
      if (t.archived) return t;
      if (t.assigneeId !== toId) return t;
      const hasDelegation = t.history.some(h => h.text.includes(`переназначена с ${this.empName(fromId)} на ${this.empName(toId)}`));
      if (!hasDelegation) return t;
      const updated = { ...t, assigneeId: fromId };
      updated.history = [...updated.history, {
        ts: Date.now(),
        who: 'system',
        text: `Задача возвращена ${this.empName(fromId)} по окончании отпуска`
      }];
      return updated;
    });
    this._notify();
    this.addNotification(fromId, `Задачи возвращены вам по окончании отпуска`, { targetType: 'vacation', targetId: vacationId });
  }

  upsertEmployee(emp) {
    const idx = this._data.employees.findIndex(e => e.id === emp.id);
    let employees;
    if (idx >= 0) {
      const old = this._data.employees[idx];
      if (JSON.stringify(old.departments) !== JSON.stringify(emp.departments)) {
        this.addAudit('Изменение подразделений', `${emp.last} ${emp.first}: ${old.departments.map(d => d.deptId).join(',')} → ${emp.departments.map(d => d.deptId).join(',')}`);
      }
      if (JSON.stringify(old.roles) !== JSON.stringify(emp.roles)) {
        this.addAudit('Изменение ролей', `${emp.last} ${emp.first}: ${old.roles.join(', ')} → ${emp.roles.join(', ')}`);
      }
      employees = this._data.employees.map(e => e.id === emp.id ? emp : e);
    } else {
      employees = [...this._data.employees, emp];
      this.addAudit('Создание сотрудника', `${emp.last} ${emp.first}`);
    }
    this._data = { ...this._data, employees };
    this._notify();
  }

  upsertDepartment(dept) {
    const idx = this._data.departments.findIndex(d => d.id === dept.id);
    let departments;
    if (idx >= 0) {
      departments = this._data.departments.map(d => d.id === dept.id ? dept : d);
    } else {
      departments = [...this._data.departments, dept];
      this.addAudit('Создание отдела', dept.name);
    }
    this._data = { ...this._data, departments };
    this._notify();
  }

  upsertKb(kb) {
    const idx = this._data.kbs.findIndex(k => k.id === kb.id);
    let kbs;
    if (idx >= 0) {
      kbs = this._data.kbs.map(k => k.id === kb.id ? kb : k);
    } else {
      kbs = [...this._data.kbs, kb];
      this.addAudit('Создание КБ', kb.name);
    }
    this._data = { ...this._data, kbs };
    this._notify();
  }

  addAudit(action, details, targetType = null, targetId = null) {
    let detailsStr = details;
    if (typeof details === 'object') {
      detailsStr = JSON.stringify(details);
    }
    this._data = {
      ...this._data,
      audit: [
        {
          id: uid(),
          ts: Date.now(),
          userId: this._currentUser?.id || "system",
          action,
          details: detailsStr,
          targetType,
          targetId,
        },
        ...this._data.audit
      ]
    };
    this._notify();
  }

  addNotification(userId, text, target = null) {
    this._data = {
      ...this._data,
      notifications: [
        { id: uid(), userId, text, ts: Date.now(), read: false, targetType: target?.targetType || null, targetId: target?.targetId || null },
        ...this._data.notifications
      ]
    };
    this._notify();
  }

  markNotificationRead(id) {
    this._data = {
      ...this._data,
      notifications: this._data.notifications.map(n => n.id === id ? { ...n, read: true } : n)
    };
    this._notify();
  }

  markAllNotificationsRead(userId) {
    this._data = {
      ...this._data,
      notifications: this._data.notifications.map(n => n.userId === userId ? { ...n, read: true } : n)
    };
    this._notify();
  }

  addHoursRequest(req) {
    this._data = { ...this._data, hoursRequests: [req, ...this._data.hoursRequests] };
    this._notify();
  }

  upsertRoleDelegation(rd) {
    const idx = this._data.roleDelegations.findIndex(r => r.id === rd.id);
    let roleDelegations;
    if (idx >= 0) {
      roleDelegations = this._data.roleDelegations.map(r => r.id === rd.id ? rd : r);
    } else {
      roleDelegations = [...this._data.roleDelegations, rd];
      this.addAudit('Создание делегирования ролей', `${rd.fromId} → ${rd.toId}: ${rd.roles.join(', ')}`);
    }
    this._data = { ...this._data, roleDelegations };
    this._notify();
  }

  empName(id) {
    const e = this._data.employees.find(x => x.id === id);
    return e ? `${e.last} ${e.first}` : '-';
  }
}

src/services/mockData.js

import { TODAY, iso, addDays, addMonths, uid } from '../utils/date';
import { TASK_STATUSES, VACATION_TYPES, PROJECT_STATUSES } from '../utils/constants';

export function buildMockData() {
  const D = (off) => iso(addDays(new Date(), off));
  const now = Date.now();
  const makeDate = (daysOffset) => {
    const d = new Date();
    d.setDate(d.getDate() + daysOffset);
    return iso(d);
  };

  const settings = { archiveMonths: 6 };

  const kbs = [
    { id: "kb_la", name: "КБ «ЛА»", full: "Конструкторское бюро летательных аппаратов" },
    { id: "kb_ad", name: "КБ «АД»", full: "Конструкторское бюро авиационных двигателей" },
  ];

  const departments = [
    { id: "d_aero", name: "Отдел аэродинамики", kbId: "kb_la" },
    { id: "d_strla", name: "Отдел прочности", kbId: "kb_la" },
    { id: "d_comp", name: "Отдел компоновки и весовых балансов", kbId: "kb_la" },
    { id: "d_gas", name: "Отдел газодинамики", kbId: "kb_ad" },
    { id: "d_stren", name: "Отдел прочности двигателей", kbId: "kb_ad" },
    { id: "d_sau", name: "Отдел систем автоматического управления", kbId: "kb_ad" },
    { id: "d_av1", name: "Отдел бортового радиоэлектронного оборудования (ОБРЭО)", kbId: "kb_la" },
    { id: "d_otk", name: "ОКК ИЦ", kbId: null },
    { id: "d_hr", name: "Отдел управления персоналом", kbId: null },
    { id: "d_management", name: "Группа управления и развития", kbId: null },
  ];

  const employees = [
    { id: "sergey.adminov", last: "Админов", first: "Сергей", email: "sergey.adminov", pass: "Admin2026!", position: "Администратор системы", departments: [{ deptId: "d_otk", primary: true }], roles: ["admin"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "101", tab: "1001", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "aleksey.gendirov", last: "Гендиров", first: "Алексей", email: "aleksey.gendirov", pass: "Director2026!", position: "Генеральный директор", departments: [], roles: ["director"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "102", tab: "1002", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "erik.ekonomistov", last: "Экономистов", first: "Эрик", email: "erik.ekonomistov", pass: "Econ2026!", position: "Главный экономист", departments: [{ deptId: "d_management", primary: true }], roles: ["economist"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "103", tab: "1003", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "ivan.konstruktorov", last: "Конструкторов", first: "Иван", email: "ivan.konstruktorov", pass: "KbLa2026!", position: "Главный конструктор КБ «ЛА»", departments: [], roles: ["kb_chief", "executor"], kbIds: ["kb_la"], headDeptIds: [], phone: "+7 900 000-00-00", extension: "104", tab: "1004", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "mikhail.dvigatelev", last: "Двигателев", first: "Михаил", email: "mikhail.dvigatelev", pass: "KbAd2026!", position: "Главный конструктор КБ «АД»", departments: [], roles: ["kb_chief", "executor"], kbIds: ["kb_ad"], headDeptIds: [], phone: "+7 900 000-00-00", extension: "105", tab: "1005", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "olga.personalova", last: "Персоналова", first: "Ольга", email: "olga.personalova", pass: "Hr2026!", position: "Руководитель отдела управления персоналом", departments: [{ deptId: "d_hr", primary: true }], roles: ["hr", "head", "executor"], kbIds: [], headDeptIds: ["d_hr"], phone: "+7 900 000-00-00", extension: "106", tab: "1006", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "mikhail.otdelov", last: "Отделов", first: "Михаил", email: "mikhail.otdelov", pass: "Head2026!", position: "Начальник отдела аэродинамики", departments: [{ deptId: "d_aero", primary: true }, { deptId: "d_comp", primary: false }], roles: ["head", "executor", "project_lead"], kbIds: [], headDeptIds: ["d_aero", "d_comp"], phone: "+7 900 000-00-00", extension: "107", tab: "1007", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "kirill.proektov", last: "Проектов", first: "Кирилл", email: "kirill.proektov", pass: "Pm2026!", position: "Инженер", departments: [{ deptId: "d_aero", primary: true }], roles: ["project_lead", "executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "116", tab: "1016", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "nikolay.managerov", last: "Менеджеров", first: "Николай", email: "nikolay.managerov", pass: "Pm2026!", position: "Менеджер проектов", departments: [{ deptId: "d_av1", primary: true }], roles: ["project_manager", "executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "117", tab: "1017", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_otk_head", last: "Отков", first: "Олег", email: "otk.head", pass: "Head2026!", position: "Руководитель отдела контроля качества", departments: [{ deptId: "d_otk", primary: true }], roles: ["head", "executor"], kbIds: [], headDeptIds: ["d_otk"], phone: "+7 900 000-00-00", extension: "201", tab: "2001", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_otk_spec", last: "Специалистов", first: "Сергей", email: "otk.spec", pass: "Exec2026!", position: "Специалист по контролю качества", departments: [{ deptId: "d_otk", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "202", tab: "2002", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "isaev", last: "Исаев", first: "Роман", email: "isaev", pass: "Exec2026!", position: "Инженер-аэродинамик", departments: [{ deptId: "d_aero", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "118", tab: "1018", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_fedorov", last: "Фёдоров", first: "Игорь", email: "fedorov", pass: "Head2026!", position: "Начальник отдела аэродинамики", departments: [{ deptId: "d_aero", primary: true }], roles: ["head", "executor", "project_lead"], kbIds: [], headDeptIds: ["d_aero", "d_comp"], phone: "+7 900 000-00-00", extension: "107", tab: "1007", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_gromov", last: "Громов", first: "Сергей", email: "gromov", pass: "Head2026!", position: "Начальник отдела прочности", departments: [{ deptId: "d_strla", primary: true }], roles: ["head", "executor", "project_lead"], kbIds: [], headDeptIds: ["d_strla"], phone: "+7 900 000-00-00", extension: "108", tab: "1008", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_ilina", last: "Ильина", first: "Анна", email: "ilina", pass: "Exec2026!", position: "Ведущий инженер-компоновщик", departments: [{ deptId: "d_comp", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "109", tab: "1009", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_krylov", last: "Крылов", first: "Виктор", email: "krylov", pass: "Head2026!", position: "Начальник отдела газодинамики", departments: [{ deptId: "d_gas", primary: true }], roles: ["head", "executor", "project_lead"], kbIds: [], headDeptIds: ["d_gas"], phone: "+7 900 000-00-00", extension: "110", tab: "1010", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_medvedev", last: "Медведев", first: "Павел", email: "medvedev", pass: "Head2026!", position: "Начальник отдела прочности двигателей", departments: [{ deptId: "d_stren", primary: true }], roles: ["head", "executor", "project_lead"], kbIds: [], headDeptIds: ["d_stren"], phone: "+7 900 000-00-00", extension: "111", tab: "1011", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_orlova", last: "Орлова", first: "Елена", email: "orlova", pass: "Head2026!", position: "Начальник отдела САУ", departments: [{ deptId: "d_sau", primary: true }], roles: ["head", "executor"], kbIds: [], headDeptIds: ["d_sau"], phone: "+7 900 000-00-00", extension: "112", tab: "1012", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_morozov", last: "Морозов", first: "Константин", email: "morozov", pass: "Pm2026!", position: "Ведущий инженер", departments: [{ deptId: "d_aero", primary: true }], roles: ["project_lead", "executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "116", tab: "1016", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_zaitsev", last: "Зайцев", first: "Алексей", email: "zaitsev", pass: "Exec2026!", position: "Инженер по прочности", departments: [{ deptId: "d_strla", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "119", tab: "1019", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_frolova", last: "Фролова", first: "Дарья", email: "frolova", pass: "Exec2026!", position: "Инженер-расчётчик", departments: [{ deptId: "d_strla", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "120", tab: "1020", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_tolka", last: "Толкачёва", first: "Ирина", email: "tolkacheva", pass: "Exec2026!", position: "Инженер-конструктор", departments: [{ deptId: "d_comp", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "121", tab: "1021", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_gusev", last: "Гусев", first: "Максим", email: "gusev", pass: "Exec2026!", position: "Инженер по весам", departments: [{ deptId: "d_comp", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "122", tab: "1022", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_tihonov", last: "Тихонов", first: "Егор", email: "tihonov", pass: "Exec2026!", position: "Инженер-газодинамик", departments: [{ deptId: "d_gas", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "123", tab: "1023", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_melnik", last: "Мельник", first: "Светлана", email: "melnik", pass: "Exec2026!", position: "Инженер по ресурсу", departments: [{ deptId: "d_stren", primary: true }, { deptId: "d_gas", primary: false }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "124", tab: "1024", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_koval", last: "Ковальчук", first: "Пётр", email: "kovalchuk", pass: "Exec2026!", position: "Инженер-программист САУ", departments: [{ deptId: "d_sau", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "125", tab: "1025", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_somova", last: "Сомова", first: "Екатерина", email: "somova", pass: "Exec2026!", position: "Инженер-конструктор", departments: [{ deptId: "d_comp", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "126", tab: "1026", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
    { id: "e_anokhin", last: "Анохин", first: "Сергей", email: "anokhin", pass: "Exec2026!", position: "Инженер по ОБРЭО", departments: [{ deptId: "d_av1", primary: true }], roles: ["executor"], kbIds: [], headDeptIds: [], phone: "+7 900 000-00-00", extension: "127", tab: "1027", notif: { deadlineEmail: true, overdueDigest: false, commentSub: true }, failed: 0, lockUntil: 0, fired: false, passwordHistory: [], photo: null },
  ];

  const projects = [
    {
      id: "p_lm24",
      code: "ЛМ-24",
      name: "Лёгкий многоцелевой самолёт ЛМ-24",
      desc: "ОКР по созданию лёгкого многоцелевого самолёта.",
      kbId: "kb_la",
      managerId: "e_morozov",
      start: D(-25),
      end: D(50),
      status: "active",
      budget: 300,
      color: "#0ea5e9",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "Минобороны РФ",
      aircraftType: "Су-57",
      projectType: "Модификация",
      stage: "Рабочая документация",
      priority: "AOG",
      comments: [],
      history: [{ ts: Date.now() - 86400000*10, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_cert",
      code: "СЕРТ-24",
      name: "Сертификация самолёта ЛМ-24",
      desc: "Комплекс сертификационных работ.",
      kbId: "kb_la",
      managerId: "e_fedorov",
      start: D(-10),
      end: D(45),
      status: "active",
      budget: 90,
      color: "#8b5cf6",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "Росавиация",
      aircraftType: "Су-57",
      projectType: "Модификация",
      stage: "Испытания",
      priority: "CRIT",
      comments: [],
      history: [{ ts: Date.now() - 86400000*9, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_heli",
      code: "В-112",
      name: "Модернизация вертолёта В-112",
      desc: "Модернизация планера и систем.",
      kbId: "kb_la",
      managerId: "e_gromov",
      start: D(-30),
      end: D(35),
      status: "active",
      budget: 120,
      color: "#f43f5e",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "ВКС РФ",
      aircraftType: "Ка-52",
      projectType: "Ремонт",
      stage: "Изготовление",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*8, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_rd900",
      code: "РД-900",
      name: "Турбовинтовой двигатель РД-900",
      desc: "Перспективный ТВД.",
      kbId: "kb_ad",
      managerId: "e_krylov",
      start: D(-20),
      end: D(60),
      status: "active",
      budget: 200,
      color: "#f59e0b",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "ОАК",
      aircraftType: "Ил-76",
      projectType: "Модификация",
      stage: "Эскизный проект",
      priority: "CRIT",
      comments: [],
      history: [{ ts: Date.now() - 86400000*7, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_apu",
      code: "ВСУ-14",
      name: "Вспомогательная силовая установка ВСУ-14",
      desc: "ВСУ для ЛМ-24.",
      kbId: "kb_ad",
      managerId: "e_medvedev",
      start: D(-12),
      end: D(30),
      status: "active",
      budget: 70,
      color: "#10b981",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "Минобороны РФ",
      aircraftType: "Су-57",
      projectType: "Ремонт",
      stage: "Рабочая документация",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*6, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_obr",
      code: "ОБРЭО-01",
      name: "Модернизация бортового оборудования",
      desc: "Замена аналоговых систем на цифровые.",
      kbId: null,
      managerId: "nikolay.managerov",
      start: D(-5),
      end: D(20),
      status: "active",
      budget: 150,
      color: "#f97316",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "nikolay.managerov",
      customer: "Ростех",
      aircraftType: "МиГ-35",
      projectType: "Модификация",
      stage: "Эскизный проект",
      priority: "AOG",
      comments: [],
      history: [{ ts: Date.now() - 86400000*5, who: "nikolay.managerov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_event",
      code: "АДМ-1",
      name: "Внутренние мероприятия предприятия",
      desc: "Административный проект: организационные работы и мероприятия.",
      kbId: null,
      managerId: "",
      start: D(-5),
      end: null,
      status: "active",
      budget: null,
      color: "#14b8a6",
      ptype: "admin",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "ООО «ВДИ»",
      aircraftType: "Другой",
      projectType: "Модификация",
      stage: "Сдача",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*4, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_old",
      code: "ИТ-15",
      name: "Модернизация локальной сети предприятия",
      desc: "Проект завершён более полугода назад - подлежит архивации.",
      kbId: null,
      managerId: "e_morozov",
      start: D(-300),
      end: D(-230),
      status: "closed",
      budget: 120,
      color: "#94a3b8",
      ptype: "prod",
      archived: true,
      archivedAt: D(-215),
      closedAt: D(-215),
      creatorId: "aleksey.gendirov",
      customer: "ООО «ВДИ»",
      aircraftType: "Другой",
      projectType: "Ремонт",
      stage: "Сдача",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*300, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_long",
      code: "АДМ-0",
      name: "Многолетняя программа внутренних мероприятий",
      desc: "Долгосрочный административный проект - исключение из архивации.",
      kbId: null,
      managerId: "olga.personalova",
      start: D(-400),
      end: null,
      status: "closed",
      budget: null,
      color: "#f59e0b",
      ptype: "admin",
      longterm: true,
      archived: false,
      archivedAt: null,
      closedAt: D(-300),
      creatorId: "aleksey.gendirov",
      customer: "ООО «ВДИ»",
      aircraftType: "Другой",
      projectType: "Модификация",
      stage: "Сдача",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*400, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_aero",
      code: "АЭРО-24",
      name: "Аэродинамические исследования ЛМ-24",
      desc: "Исследования аэродинамических характеристик самолёта.",
      kbId: "kb_la",
      managerId: "mikhail.otdelov",
      start: D(-10),
      end: D(30),
      status: "active",
      budget: 80,
      color: "#f97316",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "Минобороны РФ",
      aircraftType: "Су-57",
      projectType: "Модификация",
      stage: "Испытания",
      priority: "CRIT",
      comments: [],
      history: [{ ts: Date.now() - 86400000*3, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_proch",
      code: "ПРОЧ-24",
      name: "Прочностные испытания планера",
      desc: "Статические и усталостные испытания.",
      kbId: "kb_la",
      managerId: "e_gromov",
      start: D(-5),
      end: D(20),
      status: "active",
      budget: 100,
      color: "#8b5cf6",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "ВКС РФ",
      aircraftType: "Су-57",
      projectType: "Модификация",
      stage: "Изготовление",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*2, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_sau",
      code: "САУ-24",
      name: "Система управления двигателем РД-900",
      desc: "Разработка цифровой системы управления.",
      kbId: "kb_ad",
      managerId: "e_orlova",
      start: D(-2),
      end: D(25),
      status: "active",
      budget: 120,
      color: "#14b8a6",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "ОАК",
      aircraftType: "Ил-76",
      projectType: "Модификация",
      stage: "Эскизный проект",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*1, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_obr_sw",
      code: "ОБРЭО-ПО",
      name: "Разработка ПО для ОБРЭО",
      desc: "Программное обеспечение для бортового оборудования.",
      kbId: "kb_la",
      managerId: "nikolay.managerov",
      start: D(0),
      end: D(30),
      status: "active",
      budget: 90,
      color: "#0ea5e9",
      ptype: "prod",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "nikolay.managerov",
      customer: "Ростех",
      aircraftType: "МиГ-35",
      projectType: "Модификация",
      stage: "Рабочая документация",
      priority: "CRIT",
      comments: [],
      history: [{ ts: Date.now(), who: "nikolay.managerov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_portal",
      code: "ПОРТ-24",
      name: "Внутренний портал сотрудника",
      desc: "Административный проект по созданию портала.",
      kbId: null,
      managerId: "olga.personalova",
      start: D(-20),
      end: D(40),
      status: "active",
      budget: null,
      color: "#f59e0b",
      ptype: "admin",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "ООО «ВДИ»",
      aircraftType: "Другой",
      projectType: "Модификация",
      stage: "Эскизный проект",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*2, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
    {
      id: "p_bp",
      code: "БП-24",
      name: "Оптимизация бизнес-процессов",
      desc: "Анализ и оптимизация процессов.",
      kbId: null,
      managerId: "erik.ekonomistov",
      start: D(-15),
      end: D(15),
      status: "active",
      budget: null,
      color: "#f43f5e",
      ptype: "admin",
      archived: false,
      archivedAt: null,
      closedAt: null,
      creatorId: "aleksey.gendirov",
      customer: "ООО «ВДИ»",
      aircraftType: "Другой",
      projectType: "Модификация",
      stage: "Рабочая документация",
      priority: "NORM",
      comments: [],
      history: [{ ts: Date.now() - 86400000*1, who: "aleksey.gendirov", text: "Проект создан" }],
      files: [],
    },
  ];

  // Вспомогательная функция для создания задач с одним исполнителем (assigneeId)
  const T = (id, title, projectId, assigneeId, planned, s, dl, status, priority, desc, extra = {}) => {
    const history = extra.history || [{ ts: now - 86400000 * 6, who: extra.creatorId || "aleksey.gendirov", text: "Задача создана" }];
    const creatorId = extra.creatorId || (history.length > 0 ? history[0].who : "aleksey.gendirov");
    const startDate = makeDate(s);
    const deadlineDate = dl !== null ? makeDate(dl) : null;
    const createdAtDate = new Date(startDate);
    createdAtDate.setDate(createdAtDate.getDate() + Math.floor(Math.random() * 11) - 5);
    const createdAtStr = createdAtDate.toISOString();
    return {
      id, title, desc: desc || "", projectId,
      assigneeId: assigneeId || null,  // один исполнитель
      plannedHours: planned,
      start: startDate,
      deadline: deadlineDate,
      status,
      priority,
      logs: extra.logs || [],
      comments: extra.comments || [],
      history,
      creatorId,
      createdAt: extra.createdAt || createdAtStr,
      delegatedFrom: extra.delegatedFrom || null,
      archived: extra.archived || false,
      archivedAt: extra.archivedAt || null,
      closedAt: extra.closedAt || null,
      isSummary: extra.isSummary || false,
      parentTaskId: extra.parentTaskId || null,
      files: extra.files || [],
      dependencyId: extra.dependencyId || null,
      dependencyType: extra.dependencyType || 'FS',
    };
  };

  const tasks = [
    T("t01", "Расчёт подъёмной силы крыла", "p_lm24", "isaev", 24, -12, 6, "inwork", "high", "Расчёт и оформление отчёта.", {
      logs: [ { id: uid(), userId: "isaev", date: makeDate(-6), hours: 6, note: "Проверка методики" }, { id: uid(), userId: "isaev", date: makeDate(-2), hours: 5, note: "Расчётная сетка" } ],
      comments: [ { id: "c1", parentId: null, authorId: "e_morozov", ts: now - 3600000 * 20, text: "@Исаев Роман - подключите, пожалуйста, отдел прочности к пятнице." }, { id: "c2", parentId: "c1", authorId: "isaev", ts: now - 3600000 * 18, text: "Принято, сегодня подготовлю исходные данные." } ],
      creatorId: "e_morozov"
    }),
    T("t02", "3D-модель фюзеляжа", "p_lm24", "e_tolka", 40, -15, 12, "inwork", "mid", "Силовой набор и обводы.", {
      logs: [ { id: uid(), userId: "e_tolka", date: makeDate(-5), hours: 8, note: "Шпангоуты" } ],
      creatorId: "e_morozov"
    }),
    T("t03", "Нагрузки на элероны", "p_lm24", "e_zaitsev", 16, -10, -2, "new", "crit", "Эпюры нагрузок для навесок.", { creatorId: "e_morozov" }),
    T("t04", "Отчёт по прочности фюзеляжа", "p_lm24", "e_frolova", 32, -8, 18, "review", "high", "Статика и усталость.", {
      logs: [ { id: uid(), userId: "e_frolova", date: makeDate(-3), hours: 12, note: "МКЭ-модель" } ],
      creatorId: "e_morozov"
    }),
    T("t05", "Весовая сводка компоновки", "p_lm24", "e_gusev", 20, -5, 9, "inwork", "mid", "", {
      logs: [ { id: uid(), userId: "e_gusev", date: makeDate(-1), hours: 4, note: "Сведение таблиц" } ],
      creatorId: "e_morozov"
    }),
    T("t06", "Программа лётных испытаний", "p_cert", "e_fedorov", 16, -2, 25, "new", "mid", "Совместно с лётной службой.", { creatorId: "e_fedorov" }),
    T("t07", "Согласование плана статиспытаний", "p_cert", "e_anokhin", 12, -4, 4, "inwork", "high", "", {
      logs: [ { id: uid(), userId: "e_anokhin", date: makeDate(-2), hours: 3, note: "Замечания" } ],
      creatorId: "e_fedorov"
    }),
    T("t08", "Чертежи лопастей несущего винта", "p_heli", "e_somova", 36, -18, 20, "inwork", "high", "Переназначено на период отпуска Сомовой.", {
      delegatedFrom: "e_somova",
      logs: [ { id: uid(), userId: "e_somova", date: makeDate(-7), hours: 10, note: "Комлевая часть" } ],
      creatorId: "e_gromov"
    }),
    T("t09", "Вибрационный расчёт главного редуктора", "p_heli", "isaev", 18, -6, 14, "new", "mid", "", { creatorId: "e_gromov" }),
    T("t10", "Термогазодинамический расчёт компрессора", "p_rd900", "e_tihonov", 48, -14, 28, "inwork", "crit", "Режимы взлёт/крейсер.", {
      logs: [ { id: uid(), userId: "e_tihonov", date: makeDate(-4), hours: 12, note: "Характеристики ступеней" } ],
      creatorId: "e_krylov"
    }),
    T("t11", "Прочность камеры сгорания", "p_rd900", "e_medvedev", 30, -9, 22, "inwork", "mid", "", {
      logs: [ { id: uid(), userId: "e_medvedev", date: makeDate(-3), hours: 6, note: "Теплонапряжённость" } ],
      creatorId: "e_krylov"
    }),
    T("t12", "ТЗ на САУ-900", "p_rd900", "e_orlova", 20, -7, 10, "review", "mid", "", {
      logs: [ { id: uid(), userId: "e_orlova", date: makeDate(-2), hours: 8, note: "Разделы 3–5" } ],
      creatorId: "e_krylov"
    }),
    T("t13", "Компрессор ВСУ-14", "p_apu", "e_tihonov", 22, -4, 16, "new", "mid", "", { creatorId: "e_medvedev" }),
    T("t14", "Испытания стартер-генератора ВСУ", "p_apu", "e_gusev", 14, -12, -4, "inwork", "high", "Стенд №3, протокол.", {
      logs: [ { id: uid(), userId: "e_gusev", date: makeDate(-6), hours: 6, note: "Прогон на стенде" } ],
      creatorId: "e_medvedev"
    }),
    T("t15", "Разработка ТЗ на новое радиооборудование", "p_obr", "e_anokhin", 30, -3, 15, "inwork", "high", "Требования к дальности и помехозащищённости.", { creatorId: "nikolay.managerov" }),
    T("t16", "Тестирование прототипа приемника", "p_obr", "e_anokhin", 20, 2, 18, "new", "mid", "", { creatorId: "nikolay.managerov" }),
    T("t17", "Интеграция с бортовой шиной", "p_obr", "e_anokhin", 24, 5, 25, "new", "mid", "", { creatorId: "nikolay.managerov" }),
    T("t18", "Подготовка зала ко Дню промышленности", "p_event", "olga.personalova", null, 0, 6, "new", "mid", "", { creatorId: "olga.personalova" }),
    T("t19", "Заказать сувенирную продукцию", "p_event", "olga.personalova", 6, 1, 10, "new", "low", "", { creatorId: "olga.personalova" }),
    T("t_a1", "Монтаж оборудования точек доступа", "p_old", "e_anokhin", 30, -290, -240, "closed", "mid", "Завершено в прошлом отчётном периоде.", {
      logs: [ { id: uid(), userId: "e_anokhin", date: makeDate(-250), hours: 28, note: "Монтаж и пусконаладка" } ],
      closedAt: D(-215),
      comments: [ { id: "ca1", parentId: null, authorId: "e_morozov", ts: now - 86400000 * 220, text: "Прошу зафиксировать итоговую схему размещения точек." }, { id: "ca2", parentId: "ca1", authorId: "e_anokhin", ts: now - 86400000 * 218, text: "Схема приложена к отчёту, всё смонтировано." } ],
      creatorId: "e_morozov"
    }),
    T("t_a2", "Аудит сетевых кабелей", "p_old", "e_morozov", 18, -280, -235, "closed", "low", "", {
      logs: [ { id: uid(), userId: "e_morozov", date: makeDate(-240), hours: 16, note: "Аудит завершён" } ],
      closedAt: D(-220),
      creatorId: "e_morozov"
    }),
    T("t_a3", "Подготовка регламента мероприятий", "p_long", "olga.personalova", 10, -320, -305, "closed", "low", "Задача долгосрочного административного проекта - не архивируется.", {
      logs: [ { id: uid(), userId: "olga.personalova", date: makeDate(-310), hours: 9, note: "Регламент готов" } ],
      closedAt: D(-300),
      creatorId: "olga.personalova"
    }),
    T("t20", "Аудит качества сборки", "p_lm24", "sergey.adminov", 12, -8, 5, "new", "high", "Проверка соответствия технологии."),
    T("t21", "Утверждение стратегии развития", "p_bp", "aleksey.gendirov", 8, -5, 10, "new", "high", "Подготовка и утверждение стратегии."),
    T("t22", "Анализ бюджетов проектов", "p_bp", "erik.ekonomistov", 16, -3, 12, "inwork", "mid", "Сравнение плановых и фактических затрат.", {
      logs: [ { id: uid(), userId: "erik.ekonomistov", date: makeDate(-2), hours: 8, note: "Сбор данных" } ]
    }),
    T("t23", "Руководство проектированием крыла", "p_lm24", "ivan.konstruktorov", 20, -10, 15, "inwork", "crit", "Общее руководство конструкторской группой."),
    T("t24", "Расчёт газодинамики двигателя", "p_rd900", "mikhail.dvigatelev", 30, -12, 20, "new", "high", "Расчёт параметров рабочего процесса."),
    T("t25", "Координация аэродинамических расчётов", "p_aero", "mikhail.otdelov", 18, -5, 10, "inwork", "mid", "Сведение результатов.", {
      logs: [ { id: uid(), userId: "mikhail.otdelov", date: makeDate(-2), hours: 6, note: "Совещание" } ]
    }),
    T("t26", "Планирование испытаний", "p_cert", "kirill.proektov", 14, -4, 8, "new", "mid", "Разработка программы испытаний."),
    T("t27", "Управление проектом ОБРЭО", "p_obr", "nikolay.managerov", 24, -3, 15, "inwork", "high", "Координация работ по проекту.", {
      logs: [ { id: uid(), userId: "nikolay.managerov", date: makeDate(-1), hours: 6, note: "План-график" } ]
    }),
    T("t28", "Контроль качества сборки", "p_lm24", "e_otk_head", 16, -6, 4, "new", "high", "Входной контроль комплектующих."),
    T("t29", "Проверка документации", "p_lm24", "e_otk_spec", 12, -4, 2, "new", "mid", "Проверка конструкторской документации."),
    T("t31", "Термогазодинамика РД-900", "p_rd900", "e_krylov", 26, -10, 18, "inwork", "crit", "Термодинамические расчёты.", {
      logs: [ { id: uid(), userId: "e_krylov", date: makeDate(-3), hours: 12, note: "Моделирование" } ]
    }),
    T("t32", "Планирование прочностных испытаний", "p_proch", "e_gromov", 14, -2, 6, "new", "mid", "План испытаний планера."),
    T("t33", "Компоновка отсеков", "p_lm24", "e_ilina", 20, -8, 10, "inwork", "mid", "Размещение оборудования.", {
      logs: [ { id: uid(), userId: "e_ilina", date: makeDate(-1), hours: 4, note: "Эскизы" } ]
    }),
    T("t34", "Программирование САУ", "p_sau", "e_koval", 40, -6, 20, "new", "high", "Разработка ПО для управления двигателем."),
    T("t35", "Расчёт ресурса лопаток", "p_rd900", "e_melnik", 24, -9, 14, "inwork", "mid", "Усталостный расчёт.", {
      logs: [ { id: uid(), userId: "e_melnik", date: makeDate(-4), hours: 10, note: "Нагрузки" } ]
    }),
  ];

  const vacations = [
    { id: "v1", empId: "e_somova", start: D(-2), end: D(5), type: "annual", comment: "Отдых, Сочи", status: "approved", delegation: { enabled: true, subId: "e_anokhin", statuses: ["inwork", "review"], state: "applied" } },
    { id: "v2", empId: "e_tihonov", start: D(3), end: D(14), type: "annual", comment: "Плановый отпуск", status: "pending", delegation: { enabled: true, subId: "e_melnik", statuses: ["inwork"], state: null } },
    { id: "v3", empId: "e_gusev", start: D(-20), end: D(-8), type: "sick", comment: "Больничный лист", status: "approved", delegation: { enabled: false, subId: null, statuses: [], state: null } },
    { id: "v4", empId: "isaev", start: D(10), end: D(17), type: "annual", comment: "Отпуск", status: "pending", delegation: { enabled: false, subId: null, statuses: [], state: null } },
    { id: "v5", empId: "e_anokhin", start: D(-15), end: D(-3), type: "annual", comment: "Уже был", status: "approved", delegation: { enabled: false, subId: null, statuses: [], state: null } },
  ];

  const hoursRequests = [
    { id: "hr1", kind: "task", targetId: "t04", oldH: 32, newH: 48, reason: "Добавился расчёт усталостных трещин по требованию ОТК.", reqId: "e_morozov", status: "pending", ts: now - 3600000 * 5 },
    { id: "hr2", kind: "project", targetId: "p_lm24", oldH: 180, newH: 210, reason: "Расширение scope: добавлены работы по сертификации.", reqId: "e_morozov", status: "pending", ts: now - 3600000 * 20 },
  ];

  const regRequests = [
    { id: "rg1", first: "Олег", last: "Новиков", email: "novikov", pass: "Exec2026!", status: "pending", ts: now - 3600000 * 26 },
  ];

  const notifications = [
    { id: uid(), userId: "aleksey.gendirov", text: "Запрос на изменение плановых часов по задаче «Отчёт по прочности фюзеляжа» ожидает решения.", ts: now - 3600000 * 5, read: false, targetType: 'hours', targetId: 'hr1' },
    { id: uid(), userId: "e_fedorov", text: "Тихонов Е. подал заявку на отпуск с делегированием задач - требуется утверждение.", ts: now - 3600000 * 8, read: false, targetType: 'vacation', targetId: 'v2' },
    { id: uid(), userId: "e_anokhin", text: "Вам переданы задачи Сомовой Е. на период отпуска.", ts: now - 3600000 * 30, read: false, targetType: 'task', targetId: 't08' },
    { id: uid(), userId: "nikolay.managerov", text: "Новая заявка на регистрацию: Новиков Олег.", ts: now - 3600000 * 26, read: false, targetType: 'registration', targetId: 'rg1' },
  ];

  const audit = [
    { id: uid(), ts: now - 86400000 * 2, userId: "e_morozov", action: "Запрос изменения часов", details: "t04: 32 → 48 ч" },
    { id: uid(), ts: now - 86400000 * 3, userId: "e_morozov", action: "Утверждено делегирование отпуска", details: "Сомова Е. → Анохин С." },
    { id: uid(), ts: now - 86400000 * 6, userId: "nikolay.managerov", action: "Создан проект", details: "ОБРЭО-01" },
  ];

  return {
    settings,
    kbs,
    departments,
    employees,
    projects,
    tasks,
    vacations,
    hoursRequests,
    roleDelegations: [],
    regRequests,
    notifications,
    audit,
  };
}

src/hooks/

  • Folder: hooks/

src/hooks/index.js

export { useStore } from './useStore';
export { useAuth } from './useAuth';
export { useDataHelpers } from './useDataHelpers';
export { useTaskFilters } from './useTaskFilters';

src/hooks/useAuth.js

import { useState, useEffect } from 'react';
import { useStore } from './useStore';

export const useAuth = () => {
  const { store } = useStore();
  const [user, setUser] = useState(store.getCurrentUser() || null);

  useEffect(() => {
    const checkUser = () => {
      const currentUser = store.getCurrentUser() || null;
      if (currentUser !== user) {
        setUser(currentUser);
      }
    };
    const unsubscribe = store.subscribe(checkUser);
    return unsubscribe;
  }, [store, user]);

  const roles = user ? user.roles : [];
  const hasRole = (role) => roles.includes(role);
  const hasAnyRole = (...rs) => rs.some(r => roles.includes(r));
  return { user, roles, hasRole, hasAnyRole };
};

src/hooks/useDataHelpers.js

import { useMemo } from 'react';

export const useDataHelpers = (data) => {
  const empName = (id) => {
    if (!data || !data.employees) return '-';
    const e = data.employees.find(x => x.id === id);
    return e ? `${e.last} ${e.first}` : '-';
  };

  const primaryDept = (emp) => {
    if (!emp || !data || !data.departments) return null;
    const p = emp.departments?.find(x => x.primary) || emp.departments?.[0];
    return p ? data.departments.find(d => d.id === p.deptId) : null;
  };

  const getTaskSpent = (task) => {
    if (!task || !task.logs || !Array.isArray(task.logs)) return 0;
    return task.logs.reduce((s, l) => s + (l.hours || 0), 0);
  };

  const getProjectStats = (projectId) => {
    if (!data || !data.tasks) return { plan: 0, fact: 0, count: 0 };
    const tasks = data.tasks.filter(t => t.projectId === projectId && !t.archived);
    const plan = tasks.reduce((s, t) => s + (t.plannedHours || 0), 0);
    const fact = tasks.reduce((s, t) => s + getTaskSpent(t), 0);
    return { plan, fact, count: tasks.length };
  };

  const getEmployeeLoad = (empId) => {
    if (!data || !data.tasks) return { plan: 0, cnt: 0 };
    const active = data.tasks.filter(t => !t.archived && t.assigneeId === empId && !['closed','cancelled'].includes(t.status));
    return { plan: active.reduce((s, t) => s + (t.plannedHours || 0), 0), cnt: active.length };
  };

  const vacOverlap = (empId, from, to) => {
    if (!data || !data.vacations || !from || !to) return null;
    return data.vacations.find(v => v.empId === empId && v.status === 'approved' && v.start <= to && v.end >= from) || null;
  };

  return { empName, primaryDept, getTaskSpent, getProjectStats, getEmployeeLoad, vacOverlap };
};

src/hooks/useModals.js

import { useState } from 'react';

export function useModals({ store, data, user }) {
  const [modal, setModal] = useState(null);

  const openTask = (taskId = null, initialTab = 'form', parentTaskId = null, initialProjectId = null) =>
    setModal({ type: 'task', taskId, initialTab, parentTaskId, initialProjectId });

  const openProject = (projectId = null) =>
    setModal({ type: 'project', projectId });

  const openHoursReq = (kind, targetId) =>
    setModal({ type: 'hours', kind, targetId });

  const openRoles = (empId) =>
    setModal({ type: 'roles', empId });

  const openDepts = (empId) =>
    setModal({ type: 'depts', empId });

  const openVacation = (vacationId = null, forEmpId = null) =>
    setModal({ type: 'vacation', vacationId, forEmpId });

  const openDelegation = () =>
    setModal({ type: 'delegation' });

  const openVacNow = () =>
    setModal({ type: 'vacnow' });

  const closeModal = () => setModal(null);

  return {
    modal,
    openTask,
    openProject,
    openHoursReq,
    openRoles,
    openDepts,
    openVacation,
    openDelegation,
    openVacNow,
    closeModal,
  };
}

src/hooks/useStore.js

export { useStore } from '../context/StoreContext';

src/hooks/useTaskFilters.js

import { useMemo } from 'react';

export const useTaskFilters = (data, filters) => {
  const { projectId, assigneeId, priority, deptId, search } = filters;
  return useMemo(() => {
    let list = data.tasks.filter(t => !t.archived);
    if (projectId && projectId !== 'all') list = list.filter(t => t.projectId === projectId);
    if (assigneeId && assigneeId !== 'all') list = list.filter(t => t.assigneeId === assigneeId);
    if (priority && priority !== 'all') list = list.filter(t => t.priority === priority);
    if (deptId && deptId !== 'all') {
      list = list.filter(t => {
        const emp = t.assigneeId ? data.employees.find(e => e.id === t.assigneeId) : null;
        return emp && emp.departments.some(d => d.deptId === deptId);
      });
    }
    if (search?.trim()) {
      const s = search.trim().toLowerCase();
      list = list.filter(t => {
        const p = data.projects.find(x => x.id === t.projectId);
        return t.title.toLowerCase().includes(s) || (p && (p.name.toLowerCase().includes(s) || p.code.toLowerCase().includes(s)));
      });
    }
    return list;
  }, [data, projectId, assigneeId, priority, deptId, search]);
};

src/css/

  • Folder: css/

src/css/styles.css

@import url('https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-400-normal.css');
@import url('https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-600-normal.css');
@import url('https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.css');
@import url('https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-800-normal.css');

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --bg: #eef2f7;
  --panel: #fff;
  --line: #e2e8f0;
  --txt: #0f172a;
  --mut: #64748b;
  --acc: #2563eb;
  --acc2: #0ea5e9;
  --side: #0c1526;
  --side2: #16233c;
  --sh: 0 1px 2px rgba(15,23,42,.06), 0 4px 16px rgba(15,23,42,.06);
}

html, body, #root {
  height: 100%;
}

body {
  font-family: 'Inter', system-ui, sans-serif;
  background: var(--bg);
  color: var(--txt);
  font-size: 16px;
}

button {
  font-family: inherit;
  cursor: pointer;
}

input, select, textarea {
  font-family: inherit;
  font-size: 16px;
  color: var(--txt);
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-thumb {
  background: #cbd5e1;
  border-radius: 8px;
  border: 2px solid var(--bg);
}

@keyframes fadeUp {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}
@keyframes popIn {
  from { opacity: 0; transform: scale(.97); }
  to { opacity: 1; transform: scale(1); }
}
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-8px); }
  40% { transform: translateX(8px); }
  60% { transform: translateX(-5px); }
  80% { transform: translateX(5px); }
}

/* --- login --- */
.login-wrap {
  min-height: 100vh;
  display: grid;
  grid-template-columns: 1.1fr 1fr;
}
.login-hero {
  background: radial-gradient(1200px 600px at 10% -10%, #1e3a8a 0%, transparent 55%),
              radial-gradient(900px 500px at 90% 110%, #0e7490 0%, transparent 55%),
              #0c1526;
  color: #e2e8f0;
  padding: 48px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 16px;
}
.login-hero h2 {
  font-size: 34px;
  font-weight: 800;
  color: #fff;
  letter-spacing: -.5px;
}
.login-hero p {
  font-size: 16px;
  color: #94a3b8;
  max-width: 480px;
  line-height: 1.6;
}
.hero-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 9px;
  color: #cbd5e1;
  font-size: 15px;
}
.hero-list li {
  padding-left: 26px;
  position: relative;
}
.hero-list li:before {
  content: "✓";
  position: absolute;
  left: 0;
  color: #38bdf8;
  font-weight: 700;
}
.hero-stack {
  margin-top: 12px;
  display: inline-block;
  padding: 6px 12px;
  border: 1px solid #334155;
  border-radius: 999px;
  color: #7dd3fc;
  font-size: 14px;
  letter-spacing: .4px;
}
.login-panel {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px;
  background: var(--bg);
  overflow: auto;
}
.login-card {
  width: 470px;
  background: #fff;
  border-radius: 16px;
  box-shadow: var(--sh);
  padding: 28px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  animation: popIn .25s ease;
}
.login-card.shake {
  animation: shake .45s ease;
}
.login-card h3 {
  font-size: 24px;
  font-weight: 800;
}
.login-sub {
  color: var(--mut);
  margin-bottom: 8px;
  font-size: 15px;
}
.login-err {
  background: #fef2f2;
  color: #b91c1c;
  border: 1px solid #fecaca;
  border-radius: 8px;
  padding: 8px 12px;
  font-size: 15px;
}
.cookie-note {
  font-size: 13px;
  color: var(--mut);
  text-align: center;
  padding-top: 4px;
}
.demo-title {
  font-size: 13px;
  color: var(--mut);
  margin-top: 12px;
  text-transform: uppercase;
  letter-spacing: .5px;
}
.demo-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 7px;
}
.demo-chip {
  display: flex;
  flex-direction: column;
  gap: 2px;
  text-align: left;
  background: #f8fafc;
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 7px 10px;
  transition: .15s;
}
.demo-chip:hover {
  border-color: var(--acc2);
  background: #f0f9ff;
  transform: translateY(-1px);
}
.demo-login {
  font-weight: 700;
  font-size: 15px;
}
.demo-role {
  font-size: 12px;
  color: var(--mut);
}
.email-inp {
  display: flex;
  align-items: center;
  gap: 6px;
}
.email-inp .inp {
  flex: 1;
}
.email-dom {
  color: var(--mut);
  font-size: 15px;
  white-space: nowrap;
}
.pass-toggle-btn {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  color: var(--mut);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  padding: 0;
}
.pass-toggle-btn:hover {
  color: var(--txt);
}

/* --- main layout --- */
.shell {
  display: grid;
  grid-template-columns: 256px 1fr;
  height: 100vh;
  overflow: hidden;
}
.sidebar {
  background: linear-gradient(180deg, var(--side) 0%, #0e1b30 100%);
  color: #cbd5e1;
  display: flex;
  flex-direction: column;
  padding: 16px 12px;
}
.logo {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 4px 6px 14px;
  border-bottom: 1px solid #1e2c47;
  margin-bottom: 12px;
}
.logo-mark {
  width: 38px;
  height: 38px;
  border-radius: 10px;
  background: linear-gradient(135deg, #0ea5e9, #2563eb);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 800;
  font-size: 16px;
  box-shadow: 0 4px 12px rgba(14,165,233,.35);
  flex: none;
}
.logo-name {
  font-weight: 800;
  letter-spacing: 1.5px;
  color: #fff;
  font-size: 17px;
}
.logo-sub {
  font-size: 12px;
  color: #64748b;
}
.nav {
  display: flex;
  flex-direction: column;
  gap: 3px;
  flex: 1;
}
.nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border-radius: 10px;
  border: none;
  background: transparent;
  color: #94a3b8;
  font-size: 15px;
  font-weight: 600;
  transition: .15s;
  text-align: left;
  width: 100%;
}
.nav-item:hover {
  background: #16233c;
  color: #e2e8f0;
}
.nav-item.on {
  background: linear-gradient(90deg, #1d4ed8, #2563eb);
  color: #fff;
  box-shadow: 0 4px 14px rgba(37,99,235,.35);
}
.nav-lbl {
  flex: 1;
}
.side-foot {
  display: flex;
  flex-direction: column;
  gap: 9px;
}
.env-badge {
  display: flex;
  gap: 6px;
  align-items: center;
  font-size: 12px;
  color: #64748b;
  padding: 0 6px;
}
.user-card {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--side2);
  border: 1px solid #223250;
  border-radius: 12px;
  padding: 9px;
  margin-bottom: 16px;
}
.user-card-clickable {
  cursor: pointer;
}
.user-name {
  font-size: 14px;
  font-weight: 700;
  color: #e2e8f0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 120px;
}
.user-roles {
  font-size: 12px;
  color: #38bdf8;
  font-weight: 600;
}
.icon-btn.dark {
  margin-left: auto;
  color: #94a3b8;
  background: transparent;
}
.icon-btn.dark:hover {
  color: #f87171;
  background: #16233c;
}

.main {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 24px;
  background: #fff;
  border-bottom: 1px solid var(--line);
}
.page-title {
  font-size: 22px;
  font-weight: 800;
  letter-spacing: -.3px;
}
.page-sub {
  font-size: 14px;
  color: var(--mut);
  margin-top: 2px;
}
.top-tools {
  display: flex;
  gap: 10px;
  align-items: center;
}
.bell-wrap {
  position: relative;
}
.icon-btn {
  border: none;
  background: transparent;
  color: var(--mut);
  border-radius: 8px;
  padding: 7px;
  display: inline-flex;
  align-items: center;
  transition: .15s;
  position: relative;
}
.icon-btn:hover {
  background: #f1f5f9;
  color: var(--txt);
}
.icon-btn.danger:hover {
  background: #fef2f2;
  color: #dc2626;
}
.icon-btn.bell.has {
  color: #2563eb;
}
.bell-count {
  position: absolute;
  top: 2px;
  right: 1px;
  background: #ef4444;
  color: #fff;
  font-size: 11px;
  font-weight: 800;
  border-radius: 99px;
  padding: 0 4px;
  min-width: 14px;
  text-align: center;
}
.notif-pop {
  position: absolute;
  right: 0;
  top: 44px;
  width: 380px;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: 0 20px 50px rgba(2,8,23,.25);
  z-index: 60;
  animation: popIn .15s ease;
  overflow: hidden;
}
.notif-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 14px;
  border-bottom: 1px solid var(--line);
  font-weight: 800;
  font-size: 16px;
}
.notif-list {
  max-height: 340px;
  overflow: auto;
}
.notif-item {
  padding: 10px 14px;
  border-bottom: 1px solid #f1f5f9;
  font-size: 15px;
  cursor: pointer;
}
.notif-item.new {
  background: #eff6ff;
}
.notif-empty {
  padding: 12px;
}
.notif-note {
  padding: 8px 14px;
  border-top: 1px solid var(--line);
  font-size: 13px;
}
.content {
  flex: 1;
  overflow: auto;
  padding: 20px 24px;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* --- common components --- */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  border-radius: 10px;
  border: 1px solid transparent;
  padding: 10px 18px;
  font-size: 15px;
  font-weight: 700;
  transition: .15s;
  white-space: nowrap;
}
.btn.primary {
  background: linear-gradient(135deg, #2563eb, #1d4ed8);
  color: #fff;
  box-shadow: 0 4px 12px rgba(37,99,235,.3);
}
.btn.primary:hover {
  transform: translateY(-1px);
}
.btn.primary:disabled {
  opacity: .5;
  transform: none;
}
.btn.ghost {
  background: #fff;
  border-color: var(--line);
  color: var(--txt);
}
.btn.ghost:hover {
  border-color: var(--acc2);
  color: var(--acc);
}
.btn.ghost.sm, .btn.primary.sm, .btn.danger.sm {
  padding: 7px 12px;
  font-size: 14px;
}
.btn.danger {
  background: #fef2f2;
  color: #dc2626;
  border-color: #fecaca;
}
.btn.big {
  padding: 12px;
  font-size: 17px;
  justify-content: center;
  margin-top: 6px;
}
.inp {
  width: 100%;
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 10px 14px;
  outline: none;
  background: #fff;
  transition: .15s;
  font-size: 16px;
}
.inp:focus {
  border-color: var(--acc2);
  box-shadow: 0 0 0 3px rgba(14,165,233,.15);
}
.inp:disabled {
  background: #f8fafc;
  color: #64748b;
}
.inp.sel {
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, #64748b 50%),
                    linear-gradient(135deg, #64748b 50%, transparent 50%);
  background-position: calc(100% - 16px) 15px, calc(100% - 11px) 15px;
  background-size: 5px 5px;
  background-repeat: no-repeat;
  padding-right: 30px;
}
.inp.sel.sm {
  padding: 8px 26px 8px 12px;
  font-size: 14px;
  width: auto;
}

/* --- единый тулбар (строка управления) --- */
.toolbar {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 6px;
  margin-bottom: 14px;
}

.toolbar .filter-search {
  width: 150px;
  flex-shrink: 0;
}

.toolbar .filter-select.filter-select-dept {
  width: 150px;
  flex-shrink: 0;
}

.toolbar .btn-group {
  display: flex;
  gap: 4px;
  flex-shrink: 0;
}

.toolbar .dept-pick {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 13px;
  cursor: pointer;
  flex-shrink: 0;
  margin-left: auto; /* прижимает к правому краю */
}

.toolbar .btn.primary {
  flex-shrink: 0;
}

/* --- старые стили (поисковая строка и пр.) --- */
.search-box {
  display: flex;
  align-items: center;
  gap: 8px;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 0 10px;
  color: var(--mut);
}
.search-box input {
  border: none;
  background: transparent;
  outline: none;
  padding: 8px 0;
  width: 170px;
  font-size: 15px;
}
.link {
  border: none;
  background: none;
  color: var(--acc);
  font-size: 14px;
  font-weight: 600;
  text-decoration: underline;
  padding: 0;
}
.red-link {
  color: #dc2626;
}
.lbl {
  font-size: 13px;
  font-weight: 700;
  color: var(--mut);
  margin-top: 6px;
  text-transform: uppercase;
  letter-spacing: .4px;
}
.mut { color: var(--mut); }
.sm { font-size: 14px; }
.red { color: #dc2626; }

.avatar {
  width: 32px;
  height: 32px;
  border-radius: 999px;
  background: linear-gradient(135deg, #1e3a8a, #0ea5e9);
  color: #fff;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 700;
  flex: none;
}
.avatar.sm {
  width: 36px;
  height: 36px;
  font-size: 14px;
}
.avatar.xs {
  width: 20px;
  height: 20px;
  font-size: 10px;
  flex-shrink: 0;
}
.pdot {
  width: 8px;
  height: 8px;
  border-radius: 99px;
  display: inline-block;
  flex: none;
}
.kcount {
  background: #e2e8f0;
  color: #475569;
  border-radius: 99px;
  font-size: 13px;
  font-weight: 700;
  padding: 1px 8px;
  margin-left: auto;
}
.spacer { flex: 1; }

.tabs {
  display: flex;
  gap: 6px;
  margin-bottom: 14px;
  flex-wrap: wrap;
}
.tab {
  border: 1px solid var(--line);
  background: #fff;
  border-radius: 10px;
  padding: 8px 14px;
  font-size: 15px;
  font-weight: 700;
  color: var(--mut);
  transition: .15s;
}
.tab.on {
  background: #0f172a;
  color: #fff;
  border-color: #0f172a;
}
.tabs.sm .tab {
  padding: 6px 12px;
  font-size: 14px;
}
.seg {
  display: flex;
  background: #e2e8f0;
  border-radius: 10px;
  padding: 3px;
  gap: 2px;
}
.seg-btn {
  border: none;
  background: transparent;
  border-radius: 8px;
  padding: 5px 12px;
  font-size: 14px;
  font-weight: 700;
  color: var(--mut);
}
.seg-btn.on {
  background: #fff;
  color: var(--txt);
  box-shadow: 0 1px 4px rgba(15,23,42,.12);
}
.seg.sm .seg-btn {
  padding: 4px 9px;
  font-size: 13px;
}

.tbl {
  width: 100%;
  border-collapse: collapse;
  font-size: 15px;
}
.tbl th {
  text-align: left;
  color: var(--mut);
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: .4px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--line);
}
.tbl td {
  padding: 9px 10px;
  border-bottom: 1px solid #f1f5f9;
  vertical-align: middle;
  font-size: 14px;
}
.st-chip {
  font-size: 13px;
  font-weight: 800;
  border-radius: 6px;
  padding: 3px 8px;
  background: #f1f5f9;
  color: #475569;
  white-space: nowrap;
}
.st-chip.approved, .st-chip.active {
  background: #ecfdf5;
  color: #059669;
}
.st-chip.rejected, .st-chip.revoked {
  background: #fef2f2;
  color: #dc2626;
}
.st-chip.pending {
  background: #fffbeb;
  color: #b45309;
}
.warn-box {
  display: flex;
  gap: 9px;
  align-items: center;
  background: #fffbeb;
  border: 1px solid #fde68a;
  color: #92400e;
  border-radius: 10px;
  padding: 10px 12px;
  font-size: 15px;
  margin-bottom: 10px;
}
.info-box {
  background: #eff6ff;
  border: 1px solid #bfdbfe;
  color: #1e40af;
  border-radius: 10px;
  padding: 10px 12px;
  font-size: 14px;
  margin-bottom: 10px;
}
.budget-hint {
  margin-top: 12px;
  font-size: 14px;
  color: var(--mut);
  background: #f8fafc;
  border-radius: 8px;
  padding: 8px 12px;
}
.over-note {
  font-size: 13px;
  color: #dc2626;
  margin-top: 6px;
  font-weight: 600;
}
.adm-badge {
  font-size: 12px;
  font-weight: 800;
  color: #0f766e;
  background: #ccfbf1;
  border-radius: 6px;
  padding: 2.5px 7px;
  margin-left: 6px;
}
.pj-kb.adm {
  background: #ccfbf1;
  color: #0f766e;
}
.dept-chip {
  display: inline-block;
  font-size: 13px;
  font-weight: 700;
  background: #f1f5f9;
  border-radius: 7px;
  padding: 4px 9px;
  margin: 0 6px 6px 0;
}
.dept-chip.prim {
  background: #dbeafe;
  color: #1d4ed8;
}
.prim-btn {
  background: #eff6ff;
  border-color: #bfdbfe;
  color: #1d4ed8;
}
/* --- archive --- */
.arch-tools {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}
.arch-date-inp {
  width: 160px;
}
.rep-panel {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 14px;
  margin-bottom: 14px;
}
.rep-panel-title {
  font-size: 17px;
  font-weight: 800;
  margin-bottom: 10px;
}
.pj-code {
  display: inline-block;
  font-size: 13px;
  font-weight: 700;
  border-radius: 6px;
  padding: 3px 8px;
}
.modal-lg {
  max-width: 720px;
}

/* --- kanban --- */
.kanban {
  display: grid;
  grid-template-columns: repeat(var(--columns, 4), minmax(240px, 1fr));
  gap: 14px;
  min-height: 68vh;
  animation: fadeUp .3s ease;
}
.kanban.k5 {
  grid-template-columns: repeat(var(--columns, 5), minmax(215px, 1fr));
}
.kcol {
  background: #e9eef5;
  border: 1px solid var(--line);
  border-radius: 14px;
  display: flex;
  flex-direction: column;
  transition: .15s;
}
.kcol.over {
  border-color: var(--acc2);
  background: #e0f2fe;
  box-shadow: inset 0 0 0 2px rgba(14,165,233,.35);
}
.kcol-head {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  font-weight: 800;
  padding: 11px 13px;
  color: #334155;
}
.kdot {
  width: 9px;
  height: 9px;
  border-radius: 99px;
  flex: none;
}
.kcol-body {
  display: flex;
  flex-direction: column;
  gap: 9px;
  padding: 0 9px 11px;
  overflow: auto;
  flex: 1;
}
.kempty {
  text-align: center;
  color: #94a3b8;
  font-size: 14px;
  border: 1.5px dashed #cbd5e1;
  border-radius: 10px;
  padding: 20px 0;
}
.kcard {
  position: relative;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 11px 11px 11px 15px;
  box-shadow: 0 1px 3px rgba(15,23,42,.05);
  cursor: pointer;
  transition: .15s;
  overflow: hidden;
}
.kcard:hover {
  transform: translateY(-2px);
  box-shadow: var(--sh);
}
.kcard.dim {
  opacity: .55;
}
.kcard-prio {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 4px;
}
.kcard-title {
  font-weight: 700;
  font-size: 15px;
  line-height: 1.35;
  margin-bottom: 6px;
}
.kcard-proj {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  font-weight: 700;
  color: #475569;
  margin-bottom: 7px;
}
.prio-chip {
  margin-left: auto;
  font-size: 12px;
  font-weight: 800;
}
.kcard-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
}
.kassignee {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: #334155;
  font-weight: 600;
  overflow: visible;
  white-space: nowrap;
}
.kassignee .avatar.xs {
  flex-shrink: 0;
}
.khours {
  display: inline-flex;
  gap: 4px;
  align-items: center;
  font-size: 13px;
  color: var(--mut);
  font-weight: 700;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/*
 * Метка непрочитанных сообщений на карточке задачи/проекта.
 * Родитель (.kcard, .pj-card.relative) уже position: relative -
 * поэтому здесь только absolute без установки контекста.
 * pointer-events: none - клик по бейджу должен проваливаться
 * на карточку (открыть её), а не поглощаться меткой.
 */
.kcard-unread {
  position: absolute;
  right: 8px;
  bottom: 8px;
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  background: #ef4444;
  color: #fff;
  font-size: 11px;
  font-weight: 700;
  line-height: 1.4;
  border-radius: 99px;
  pointer-events: none;
}

.vac-note, .del-note {
  display: flex;
  gap: 5px;
  align-items: center;
  font-size: 12px;
  color: #0369a1;
  background: #e0f2fe;
  border-radius: 6px;
  padding: 3px 7px;
  margin-top: 6px;
  font-weight: 600;
}
.del-note {
  color: #7c3aed;
  background: #f5f3ff;
}
.kcard-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 8px;
}
.kdl {
  font-size: 12px;
  font-weight: 700;
  color: #475569;
  background: #f1f5f9;
  border-radius: 6px;
  padding: 2px 7px;
  white-space: nowrap;
}
.kdl.late {
  background: #fef2f2;
  color: #dc2626;
}
.kdl.soon {
  background: #fffbeb;
  color: #b45309;
}
.mini-progress {
  flex: 1;
  height: 5px;
  background: #e2e8f0;
  border-radius: 99px;
  overflow: hidden;
}
.mini-progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #0ea5e9, #2563eb);
  border-radius: 99px;
}

/* --- gantt --- */
.gantt {
  min-width: 100%;
  width: var(--gantt-width, auto);
}

.gantt-track {
  position: relative;
  height: 46px;
}

.gbar {
  position: absolute;
  top: 11px;
  height: 24px;
  border-radius: 7px;
  overflow: hidden;
  cursor: pointer;
  background: var(--bar-bg, #64748b33);
  border: 1px solid var(--bar-bg, #64748b33);
  left: var(--bar-left, 0);
  width: var(--bar-width, 0);
  opacity: var(--bar-opacity, 1);
}

.gbar-fill {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  border-radius: 6px;
  width: var(--fill-width, 0%);
  background: var(--fill-color, #64748b);
  opacity: 0.9;
  transition: width 0.15s;
}

.gbar-vac {
  position: relative;
  margin-left: auto;
  margin-right: 6px;
  font-size: 14px;
}
.gantt-panel {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: var(--sh);
  overflow: hidden;
  animation: fadeUp .3s ease;
}
.gantt-scroll {
  overflow: auto;
  max-height: calc(100vh - 220px);
}
.gantt-top {
  display: flex;
  position: sticky;
  top: 0;
  z-index: 5;
  background: #fff;
  border-bottom: 1px solid var(--line);
}
.gantt-corner {
  width: 240px;
  flex: none;
  padding: 14px;
  font-size: 14px;
  font-weight: 800;
  color: var(--mut);
  border-right: 1px solid var(--line);
  display: flex;
  align-items: flex-end;
}
.gantt-months {
  display: flex;
  height: 24px;
}
.gantt-month {
  font-size: 13px;
  font-weight: 800;
  color: #334155;
  padding: 4px 0 2px 8px;
  border-left: 1px solid var(--line);
  white-space: nowrap;
  overflow: hidden;
}
.gantt-days {
  display: flex;
  height: 26px;
}
.gantt-track {
  position: relative;
  height: 46px;
}
.gday {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  color: #64748b;
  border-left: 1px solid #eef2f7;
  font-variant-numeric: tabular-nums;
}
.gday.wk {
  background: #f8fafc;
  color: #b6c2d4;
}
.gday.td {
  background: #2563eb;
  color: #fff;
  font-weight: 800;
  border-radius: 6px;
}
.gantt-body {
  position: relative;
}
.gantt-grid {
  position: absolute;
  top: 0;
  bottom: 0;
  display: flex;
  pointer-events: none;
}
.gcell {
  border-left: 1px solid #f1f5f9;
  height: 100%;
}
.gcell.wk {
  background: #f8fafc88;
}
.gtoday {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: #ef4444;
  opacity: .8;
}
.gantt-group {
  display: flex;
  align-items: center;
  background: #f1f5f9;
  border-top: 1px solid var(--line);
}
.gantt-group-name {
  width: 240px;
  flex: none;
  font-size: 14px;
  font-weight: 800;
  padding: 7px 14px;
  display: flex;
  gap: 8px;
  align-items: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  border-right: 1px solid var(--line);
}
.gantt-row {
  display: flex;
  border-top: 1px solid #f1f5f9;
}
.gantt-row:hover {
  background: #f8fafc;
}
.gantt-label {
  width: 240px;
  flex: none;
  padding: 5px 14px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  border-right: 1px solid var(--line);
  cursor: pointer;
  min-height: 46px;
}
.gtitle {
  font-size: 14px;
  font-weight: 700;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.gtitle.dim {
  text-decoration: line-through;
  opacity: .6;
}
.gsub {
  font-size: 13px;
  color: var(--mut);
}
.gantt-track {
  position: relative;
  height: 46px;
}
.gbar {
  position: absolute;
  top: 11px;
  height: 24px;
  border-radius: 7px;
  overflow: hidden;
  display: flex;
  align-items: center;
}
.gbar:hover {
  filter: brightness(1.05);
}
.gbar-fill {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  border-radius: 6px;
  opacity: 0.9;
}
.gbar-vac {
  position: relative;
  margin-left: auto;
  margin-right: 6px;
  font-size: 14px;
}
.gantt-legend {
  display: flex;
  gap: 20px;
  padding: 9px 16px;
  border-top: 1px solid var(--line);
  font-size: 14px;
  color: var(--mut);
  align-items: center;
  flex-wrap: wrap;
}
.lg-dot {
  width: 9px;
  height: 9px;
  border-radius: 3px;
  display: inline-block;
  margin-right: 5px;
}

/* --- calendar --- */
.cal-panel {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: var(--sh);
  padding: 16px;
  animation: fadeUp .3s ease;
  width: 100%;
  box-sizing: border-box;
  height: auto;
}

.cal-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
  flex-wrap: wrap;
  flex-shrink: 0;
}
.cal-right {
  display: flex;
  gap: 10px;
  align-items: center;
}
.cal-nav {
  display: flex;
  gap: 8px;
  align-items: center;
}
.cal-title {
  font-size: 20px;
  font-weight: 800;
  min-width: 120px;
  text-align: center;
}
.cal-note {
  font-size: 13px;
  color: var(--mut);
  margin-bottom: 10px;
}
.cal-grid-head {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 6px;
  margin-bottom: 6px;
}
.cal-wd {
  font-size: 13px;
  font-weight: 800;
  color: var(--mut);
  text-transform: uppercase;
  letter-spacing: .5px;
  padding: 0 6px;
  text-align: center;
}
.cal-wd.wk {
  color: #f59e0b;
}

.cal-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  grid-auto-rows: auto;
  gap: 6px;
}

.outwrap {
  opacity: .4;
}

.cal-cell {
  background: #fbfcfe;
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 6px;
  overflow: visible;
  transition: .15s;
  height: auto;
  display: flex;
  flex-direction: column;
  min-height: 80px;
}
.cal-cell:hover {
  border-color: #cbd5e1;
}
.cal-cell.today {
  border-color: var(--acc);
  box-shadow: inset 0 0 0 1.5px var(--acc);
}
.cal-cell.today .cal-daynum {
  background: #2563eb;
  color: #fff;
  border-radius: 6px;
  padding: 0 5px;
  width: fit-content;
}
.cal-daynum {
  font-size: 14px;
  font-weight: 700;
  color: #475569;
  display: inline-block;
  margin-bottom: 4px;
  flex-shrink: 0;
}

.cal-chips {
  display: flex;
  flex-direction: column;
  gap: 3px;
  overflow: visible;
  flex: 1;
}
.cal-chip {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  padding: 4px 6px 3px 6px;
  border: 1px solid;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
  width: 100%;
  box-sizing: border-box;
  flex-shrink: 0;
  overflow: hidden;
}
.cal-chip:hover {
  background: #f0f9ff;
}
.cal-task-title {
  display: flex;
  align-items: center;
  gap: 5px;
  font-weight: 600;
  font-size: 13px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  width: 100%;
}
/* .cal-executor {
  font-size: 11px;
  color: var(--mut);
  font-weight: 400;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  width: 100%;
} */
.cal-executors {
  display: flex;
  gap: 4px;
  flex-wrap: wrap;
  margin-top: 2px;
}

.cal-week {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 6px;
}
.cal-week .cal-cell {
  min-height: 80px;
}
.cal-week.one {
  grid-template-columns: 1fr;
}

/* --- projects --- */
.sec-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
  margin-bottom: 14px;
  flex-wrap: wrap;
}
.sec-note {
  font-size: 14px;
  color: var(--mut);
  max-width: 760px;
}
.sec-actions {
  display: flex;
  gap: 8px;
}
.pj-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
  gap: 14px;
  animation: fadeUp .3s ease;
}
.pj-card {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: var(--sh);
  padding: 15px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  transition: .15s;
}
.pj-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 8px 24px rgba(15,23,42,.1);
}
.pj-top {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}
.pj-code {
  font-weight: 800;
  font-size: 14px;
  border-radius: 7px;
  padding: 3px 9px;
}
.pj-kb {
  font-size: 13px;
  font-weight: 700;
  color: #475569;
  background: #f1f5f9;
  border-radius: 6px;
  padding: 3px 8px;
}
.pj-st {
  margin-left: auto;
  font-size: 13px;
  font-weight: 800;
  border-radius: 6px;
  padding: 3px 8px;
}
.pj-st.active {
  background: #ecfdf5;
  color: #059669;
}
.pj-st.closed {
  background: #f1f5f9;
  color: #64748b;
}
.pj-name {
  font-weight: 800;
  font-size: 17px;
  line-height: 1.3;
}
.pj-row {
  font-size: 14px;
}
.pj-budget {
  background: #f8fafc;
  border-radius: 10px;
  padding: 9px;
}
.pj-budget-row {
  display: flex;
  justify-content: space-between;
  font-size: 13px;
  gap: 6px;
  flex-wrap: wrap;
  margin-bottom: 7px;
}
.pj-progress {
  height: 7px;
  background: #e2e8f0;
  border-radius: 99px;
  overflow: hidden;
}
.pj-progress-fill {
  height: 100%;
  border-radius: 99px;
}
.pj-progress-fill.over {
  background: #ef4444;
}
.pj-foot {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.pj-avatars {
  display: flex;
}
.pj-avatars .avatar.xs {
  margin-right: -4px;
  border: 2px solid #fff;
}
.pj-actions {
  display: flex;
  gap: 6px;
  align-items: center;
  flex-wrap: wrap;
}

/* --- staff --- */
.staff {
  display: flex;
  flex-direction: column;
  gap: 18px;
  animation: fadeUp .3s ease;
}
.st-section {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: var(--sh);
  overflow: hidden;
}
.st-sec-head {
  padding: 13px 18px;
  background: linear-gradient(90deg, #f8fafc, #fff);
  border-bottom: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.st-sec-title {
  font-weight: 800;
  font-size: 17px;
}
.st-sec-sub {
  font-size: 14px;
  color: var(--mut);
}
.st-dept {
  border-top: 1px solid #f1f5f9;
}
.st-dept-head {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 9px 18px;
  background: #fbfcfe;
  border-bottom: 1px solid #f1f5f9;
  font-size: 15px;
}
.st-dept-name {
  font-weight: 800;
}
.st-row {
  display: flex;
  gap: 12px;
  align-items: center;
  padding: 9px 18px;
  border-top: 1px solid #f6f8fb;
}
.st-row:hover {
  background: #f8fafc;
}
.st-name {
  width: 260px;
  flex: none;
}
.st-fio {
  font-weight: 700;
  font-size: 15px;
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}
.vac-badge {
  font-size: 12px;
  font-weight: 800;
  color: #047857;
  background: #ecfdf5;
  border-radius: 6px;
  padding: 2px 7px;
}
.vac-badge.fired {
  background: #fef2f2;
  color: #dc2626;
}
.st-pos {
  font-size: 13px;
  color: var(--mut);
}
.st-roles {
  display: flex;
  gap: 5px;
  flex-wrap: wrap;
  flex: 1;
}
.role-chip {
  font-size: 12px;
  font-weight: 800;
  border-radius: 6px;
  padding: 2.5px 7px;
  letter-spacing: .3px;
}
.role-chip.blue {
  background: #e0f2fe;
  color: #0369a1;
}
.role-chip.indigo {
  background: #eff6ff;
  color: #1d4ed8;
}
.st-load {
  width: 170px;
  flex: none;
  display: flex;
  gap: 8px;
  align-items: center;
}
.st-load-bar {
  flex: 1;
  height: 7px;
  background: #e2e8f0;
  border-radius: 99px;
  overflow: hidden;
}
.st-load-fill {
  height: 100%;
  background: linear-gradient(90deg, #0ea5e9, #2563eb);
  border-radius: 99px;
}
.st-load-fill.over {
  background: linear-gradient(90deg, #f59e0b, #ef4444);
}
.st-load-txt {
  font-size: 13px;
  font-weight: 700;
  color: var(--mut);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.st-load-txt.over {
  color: #dc2626;
}
.st-nums {
  width: 70px;
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.st-nums b {
  font-size: 16px;
}
.st-nums span {
  font-size: 12px;
  color: var(--mut);
}

/* --- reports --- */
.rep {
  display: flex;
  flex-direction: column;
  gap: 14px;
  animation: fadeUp .3s ease;
}
.rep-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 12px;
}
.rep-card {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: var(--sh);
  padding: 15px;
}
.rep-num {
  font-size: 27px;
  font-weight: 800;
  letter-spacing: -.5px;
}
.rep-lbl {
  font-size: 14px;
  color: var(--mut);
  margin-top: 2px;
}
.rep-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.rep-item {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  align-items: center;
  flex-wrap: wrap;
}
.rep-panel {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: var(--sh);
  padding: 16px;
}
.rep-panel-title {
  font-weight: 800;
  font-size: 16px;
  margin-bottom: 12px;
  display: flex;
  align-items: center;
  gap: 10px;
}
.rep-btns {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

/* --- cabinet --- */
.cab {
  animation: fadeUp .3s ease;
}
.cab-grid {
  display: grid;
  grid-template-columns: 1.2fr 1fr;
  gap: 14px;
  align-items: start;
}
.stack-col {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.cab-stat {
  margin-bottom: 12px;
}
.cab-stat-head {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 14px;
  font-weight: 800;
  margin-bottom: 6px;
}
.cab-task {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 15px;
  padding: 7px 10px;
  border: 1px solid var(--line);
  border-radius: 9px;
  margin-bottom: 5px;
  cursor: pointer;
}
.cab-task:hover {
  border-color: var(--acc2);
  background: #f0f9ff;
}
.cab-proj {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 15px;
  padding: 5px 0;
}
.util-bars {
  display: flex;
  gap: 4px;
  align-items: flex-end;
  height: 80px;
  margin: 12px 0 6px;
}
.util-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  font-size: 11px;
  color: var(--mut);
}
.util-bar {
  width: 14px;
  background: linear-gradient(180deg, #0ea5e9, #2563eb);
  border-radius: 4px 4px 0 0;
}
.dept-picks {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 180px;
  overflow: auto;
}
.dept-pick {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 14px;
  cursor: pointer;
}
.dept-pick input {
  accent-color: #2563eb;
}
.depts-readonly {
  grid-column: 2;
}
.toggle-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 14px;
  padding: 10px 0;
  border-bottom: 1px solid #f1f5f9;
  font-size: 15px;
}
.toggle {
  width: 40px;
  height: 22px;
  background: #cbd5e1;
  border-radius: 99px;
  position: relative;
  cursor: pointer;
  transition: .2s;
  flex: none;
}
.toggle.on {
  background: #2563eb;
}
.toggle-knob {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 18px;
  height: 18px;
  background: #fff;
  border-radius: 99px;
  transition: .2s;
}
.toggle.on .toggle-knob {
  left: 20px;
}

/* --- modals --- */
.roles-list {
  display: flex;
  flex-direction: column;
  gap: 7px;
  margin-top: 10px;
}
.roles-item {
  display: flex;
  gap: 10px;
  align-items: center;
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 8px 12px;
  font-size: 15px;
  cursor: pointer;
  transition: .15s;
}
.roles-item:hover {
  border-color: var(--acc2);
}
.roles-item input {
  width: 16px;
  height: 16px;
  accent-color: #2563eb;
}
.sub-picks {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  padding: 8px 12px 4px 34px;
  font-size: 14px;
}

/* --- chat --- */
.chat {
  display: flex;
  flex-direction: column;
  gap: 9px;
}
.cm {
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 9px 12px;
  background: #fff;
}
.cm.reply {
  margin-left: 26px;
  background: #fbfcfe;
}
.cm-head {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 14px;
  margin-bottom: 4px;
  flex-wrap: wrap;
}
.cm-author {
  font-weight: 800;
  font-size: 14px;
}
.cm-text {
  font-size: 15px;
  line-height: 1.6;
}
.mention {
  color: #2563eb;
  font-weight: 700;
}
.cm-actions {
  display: flex;
  gap: 12px;
  margin-top: 5px;
}
.cm-actions .link {
  font-size: 14px;
}
.cm-edit {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.cm-edit textarea {
  width: 100%;
  font-size: 15px;
}
.cm-foot {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
  margin-top: 2px;
}
.cm-input-wrap {
  position: relative;
}
.mention-pop {
  background: #ffffff;
  border: 1px solid var(--line, #e2e8f0);
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  padding: 4px 0;
  min-width: 200px;
  max-width: 300px;
  max-height: 200px;
  overflow-y: auto;
}
.mention-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  cursor: pointer;
  font-size: 13px;
}

.mention-item:hover {
  background: #f1f5f9;
}
.reply-banner {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  align-items: center;
  font-size: 14px;
  background: #eff6ff;
  border: 1px solid #bfdbfe;
  color: #1e40af;
  border-radius: 8px;
  padding: 6px 10px;
}

.overlay {
  position: fixed;
  inset: 0;
  background: rgba(12,21,38,.55);
  backdrop-filter: blur(3px);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 50;
  padding: 20px;
}
.modal {
  width: 100%;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 24px 60px rgba(2,8,23,.35);
  animation: popIn .2s ease;
  max-height: 92vh;
  display: flex;
  flex-direction: column;
}
.modal-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px 20px;
  border-bottom: 1px solid var(--line);
}
.modal-head h3 {
  font-size: 19px;
  font-weight: 800;
}
.modal-body {
  padding: 16px 20px;
  overflow: auto;
}
.modal-foot {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-top: 16px;
  padding-top: 13px;
  border-top: 1px solid var(--line);
}
.form-grid {
  display: grid;
  grid-template-columns: 190px 1fr;
  gap: 8px 14px;
  align-items: center;
}
.form-grid .lbl {
  margin-top: 0;
  text-align: right;
  font-size: 14px;
}
.form-grid .inp, .form-grid .duo, .form-grid textarea.inp {
  grid-column: 2;
  font-size: 16px;
}
.form-grid > div {
  grid-column: 2;
}
.form-grid > select {
  grid-column: 2;
  font-size: 16px;
}
.duo {
  display: flex;
  gap: 10px;
  align-items: center;
  flex-wrap: wrap;
}
.duo-note {
  font-size: 14px;
  color: var(--mut);
}
.tm-block {
  margin-top: 16px;
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 13px;
  background: #fbfcfe;
}
.tm-progress {
  height: 8px;
  background: #e2e8f0;
  border-radius: 99px;
  overflow: hidden;
  margin-bottom: 11px;
}
.tm-progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #10b981, #0ea5e9);
  border-radius: 99px;
  transition: width .3s ease;
}
.tm-logs {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 11px;
  max-height: 180px;
  overflow: auto;
}
.tm-log {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 14px;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 10px;
}
.tm-log-name {
  font-weight: 700;
  max-width: 150px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tm-log-note {
  color: var(--mut);
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tm-log-h {
  font-variant-numeric: tabular-nums;
  color: #0369a1;
}
.tm-add {
  display: flex;
  gap: 8px;
}
.tm-add .inp {
  font-size: 15px;
}
.ro-note {
  max-width: 280px;
  text-align: right;
}
.empty-note {
  text-align: center;
  color: var(--mut);
  padding: 60px 0;
  background: #fff;
  border: 1.5px dashed var(--line);
  border-radius: 14px;
  font-size: 16px;
}

/* --- профиль: аватар --- */
.profile-avatar {
  width: 120px;
  height: 120px;
  border-radius: 50%;
  background: linear-gradient(135deg, #1e3a8a, #0ea5e9);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  flex-shrink: 0;
}
.profile-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.profile-avatar .avatar-placeholder {
  color: #fff;
  font-size: 44px;
  font-weight: 700;
}
.photo-block {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
}
.photo-actions {
  display: flex;
  gap: 8px;
}

/* ========== ДОБАВЛЕНО: СТИЛИ ДЛЯ ГАЛОЧЕК ПАРОЛЯ ========== */
.pass-checks {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  font-size: 14px;
  color: var(--mut);
  margin-top: 4px;
  grid-column: 2;
}
.pass-checks span {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
.pass-checks .ok {
  color: #10b981;
  font-weight: 700;
}
/* --- Gantt: фильтры --- */
.gantt-filters {
  display: flex;
  gap: 10px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--line);
  background: #f8fafc;
  align-items: center;
  flex-wrap: wrap;
}
.gantt-filters .inp.sm {
  min-width: 150px;
  font-size: 15px;
}

/* --- Gantt: базовый план --- */
.gbar-baseline {
  position: absolute;
  top: 11px;
  height: 24px;
  border-radius: 7px;
  z-index: 1;
}

/* --- Gantt: зависимости (SVG стрелки) --- */
.gantt-dependencies {
  overflow: visible;
}
.dependency-arrow path {
  transition: stroke 0.15s ease, fill 0.15s ease;
}
.dependency-arrow:hover path {
  stroke: #2563eb;
  fill: #2563eb;
}

/* --- Popover для редактирования задачи --- */
.task-edit-popover {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 8px 32px rgba(15, 23, 42, 0.15);
  min-width: 280px;
  max-width: 360px;
  z-index: 1001;
  animation: popIn 0.2s ease;
}
.popover-overlay {
  position: fixed;
  z-index: 1000;
}
.popover-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  background: #f8fafc;
  border-radius: 12px 12px 0 0;
}
.popover-title {
  font-size: 15px;
  font-weight: 700;
  color: var(--txt);
}
.popover-body {
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.popover-footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 10px 14px;
  border-top: 1px solid var(--line);
  background: #f8fafc;
  border-radius: 0 0 12px 12px;
}
.form-group {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.form-label {
  font-size: 13px;
  font-weight: 600;
  color: var(--mut);
  text-transform: uppercase;
  letter-spacing: 0.4px;
}
.form-static {
  font-size: 15px;
  color: var(--txt);
  padding: 6px 0;
}

/* --- Иконка замка для админа --- */
.icon-btn.xs {
  padding: 2px;
  border-radius: 4px;
}
.icon-btn.xs svg {
  width: 12px;
  height: 12px;
}
.tasks-table-wrap {
  overflow-x: auto;
}
.tasks-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
  background: #fff;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.tasks-table th {
  background: #f8fafc;
  text-align: left;
  padding: 10px 12px;
  border-bottom: 1px solid #e2e8f0;
  font-weight: 600;
  color: #475569;
}
.tasks-table td {
  padding: 10px 12px;
  border-bottom: 1px solid #f1f5f9;
  vertical-align: middle;
}
.tasks-table tbody tr:hover {
  background: #f8fafc;
}
.task-status-badge {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 500;
}
.task-status-badge.new { background: #e2e8f0; color: #475569; }
.task-status-badge.in_progress { background: #dbeafe; color: #2563eb; }
.task-status-badge.review { background: #fef3c7; color: #d97706; }
.task-status-badge.closed { background: #d1fae5; color: #059669; }
.task-status-badge.cancelled { background: #fee2e2; color: #dc2626; }
.task-priority-badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 500;
}
.task-priority-badge.critical { background: #fecaca; color: #b91c1c; }
.task-priority-badge.high { background: #fca5a5; color: #b91c1c; }
.task-priority-badge.medium { background: #fde68a; color: #92400e; }
.task-priority-badge.low { background: #e5e7eb; color: #4b5563; }
.late { color: #dc2626; }
.assigned-executor {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  background: #f1f5f9;
  border-radius: 16px;
  padding: 2px 8px 2px 4px;
  font-size: 14px;
  font-weight: 500;
  border: 1px solid var(--line);
}
.assigned-executor button {
  background: none;
  border: none;
  color: #94a3b8;
  font-size: 16px;
  cursor: pointer;
  padding: 0 2px;
  line-height: 1;
}
.assigned-executor button:hover {
  color: #dc2626;
}
.cal-chip-aog { border-color: #dc2626; }
.cal-chip-crit { border-color: #f59e0b; }
.cal-chip-norm { border-color: #10b981; }

.cal-dot-aog { background: #dc2626; }
.cal-dot-crit { background: #f59e0b; }
.cal-dot-norm { background: #10b981; }

/* Кастомный тултип для аватара - работает для всех аватаров */
.avatar-tooltip {
  position: fixed;
  background: #1e293b;
  color: #fff;
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 12px;
  line-height: 1.4;
  white-space: pre-wrap;
  pointer-events: none;
  z-index: 10000;
  max-width: 300px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  font-family: system-ui, -apple-system, sans-serif;
  transition: opacity 0.15s;
}
.file-input-hidden {
  display: none;
}
/* ===== ProjectModal: поля без сетки, единое выравнивание ===== */
.project-info-fields {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 0;
  width: 100%;
}

.field-row {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 12px;
  width: 100%;
}

.field-label {
  min-width: 100px;
  text-align: left;
  font-weight: 700;
  color: var(--mut);
  text-transform: uppercase;
  letter-spacing: .4px;
  font-size: 13px;
  flex-shrink: 0;
}

.field-row .inp,
.field-row .inp.sel {
  flex: 1;
  min-width: 0;
}

/* Парные строки - в одну линию */
.pj-pair-row {
  display: flex;
  flex-wrap: nowrap;
  gap: 16px;
  align-items: center;
  padding: 0;
  margin: 0;
  width: 100%;
}

.pj-pair-item {
  flex: 1 1 0;
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.pj-pair-label {
  min-width: 100px;
  text-align: left;
  font-weight: 700;
  color: var(--mut);
  text-transform: uppercase;
  letter-spacing: .4px;
  font-size: 13px;
  flex-shrink: 0;
}

.pj-pair-input {
  flex: 1;
  min-width: 0;
}

src/context/

  • Folder: context/

src/context/StoreContext.jsx

import  { createContext, useContext, useState, useEffect } from 'react';
import DataStore from '../services/DataStore';

const StoreContext = createContext(null);
export const useStore = () => {
  const ctx = useContext(StoreContext);
  if (!ctx) throw new Error('useStore must be used within StoreProvider');
  return ctx;
};

export const StoreProvider = ({ children }) => {
  const [store] = useState(() => new DataStore());
  const [data, setData] = useState(store.data);

  useEffect(() => {
    const unsub = store.subscribe(newData => setData(newData));
    return unsub;
  }, [store]);

  const login = (email, password) => store.login(email, password);
  const logout = () => store.logout();

  return (
    <StoreContext.Provider value={{ store, data, login, logout }}>
      {children}
    </StoreContext.Provider>
  );
};

src/context/ToastContext.jsx

import React, { createContext, useContext, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';

const ToastContext = createContext(null);

export const useToast = () => {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error('useToast must be used within ToastProvider');
  return ctx;
};

export const ToastProvider = ({ children }) => {
  const [toasts, setToasts] = useState([]);

  const showToast = useCallback((message, type = 'info', duration = 3000) => {
    const id = Date.now() + Math.random();
    setToasts(prev => [...prev, { id, message, type, duration }]);
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id));
    }, duration);
  }, []);

  const removeToast = useCallback((id) => {
    setToasts(prev => prev.filter(t => t.id !== id));
  }, []);

  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      <ToastContainer toasts={toasts} removeToast={removeToast} />
    </ToastContext.Provider>
  );
};

const ToastContainer = ({ toasts, removeToast }) => {
  if (!toasts.length) return null;
  return createPortal(
    <div style={{
      position: 'fixed',
      bottom: '20px',
      right: '20px',
      zIndex: 9999,
      display: 'flex',
      flexDirection: 'column',
      gap: '8px',
      maxWidth: '400px',
      width: '100%',
    }}>
      {toasts.map(toast => {
        let bgColor = '#3b82f6';
        let textColor = '#ffffff';
        if (toast.type === 'success') bgColor = '#10b981';
        else if (toast.type === 'error') bgColor = '#ef4444';
        else if (toast.type === 'warning') bgColor = '#f59e0b';
        else if (toast.type === 'info') bgColor = '#3b82f6';
        return (
          <div
            key={toast.id}
            style={{
              background: bgColor,
              color: textColor,
              padding: '12px 16px',
              borderRadius: '8px',
              boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
              fontSize: '14px',
              fontWeight: 500,
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center',
              animation: 'fadeUp 0.2s ease',
            }}
          >
            <span>{toast.message}</span>
            <button
              onClick={() => removeToast(toast.id)}
              style={{
                background: 'transparent',
                border: 'none',
                color: 'white',
                cursor: 'pointer',
                fontSize: '18px',
                lineHeight: '1',
                padding: '0 4px',
              }}
            >
              ×
            </button>
          </div>
        );
      })}
    </div>,
    document.body
  );
};

src/components/

  • Folder: components/

src/components/Archive.jsx

import React, { useState } from 'react';
import { PROJECT_TYPES, TASK_STATUSES } from '../utils/constants';
import { fmtDMY, TODAY } from '../utils/date';
import { canRestore } from '../utils/permissions';
import { Ic, ICONS } from './Icons';
import { useDataHelpers } from '../hooks';

export default function Archive({ db, ur, openTask, openProject, restoreTask, restoreProject }) {
  const { getTaskSpent, empName } = useDataHelpers(db);
  const [fFrom, setFFrom] = useState('');
  const [fTo, setFTo] = useState('');
  const [fProj, setFProj] = useState('all');
  const [fExec, setFExec] = useState('all');
  const [fDept, setFDept] = useState('all');

  const archProjects = db.projects.filter(p => p.archived || p.status === 'closed' || p.status === 'cancelled');
  const archTasks = db.tasks.filter(t => t.archived || t.status === 'closed' || t.status === 'cancelled');

  const fit = (archivedAt) => (!fFrom || archivedAt >= fFrom) && (!fTo || archivedAt <= fTo);
  const projList = archProjects.filter(p => fit(p.archivedAt || TODAY));
  const taskList = archTasks.filter(t => {
    if (!fit(t.archivedAt || TODAY)) return false;
    if (fProj !== 'all' && t.projectId !== fProj) return false;
    if (fExec !== 'all' && t.assigneeId !== fExec) return false;
    if (fDept !== 'all') {
      const assignee = t.assigneeId ? db.employees.find(x => x.id === t.assigneeId) : null;
      if (!assignee || !assignee.departments.some(d => d.deptId === fDept)) return false;
    }
    return true;
  });

  const execs = [...new Set(archTasks.map(t => t.assigneeId).filter(Boolean))].map(id => db.employees.find(e => e.id === id)).filter(Boolean);
  const projOptions = [...new Set(archTasks.map(t => t.projectId))].map(id => db.projects.find(p => p.id === id)).filter(Boolean);

  return (
    <div>
      <div className="sec-head">
        <div className="sec-note">Архив закрытых задач и проектов. Только чтение.</div>
      </div>

      <div className="toolbar">
        <input className="inp arch-date-inp" type="date" value={fFrom} onChange={e => setFFrom(e.target.value)} />
        <input className="inp arch-date-inp" type="date" value={fTo} onChange={e => setFTo(e.target.value)} />
        <select className="inp sel sm" value={fProj} onChange={e => setFProj(e.target.value)}>
          <option value="all">Все проекты</option>{projOptions.map(p => <option key={p.id} value={p.id}>{p.code}</option>)}
        </select>
        <select className="inp sel sm" value={fExec} onChange={e => setFExec(e.target.value)}>
          <option value="all">Все исполнители</option>{execs.map(e => <option key={e.id} value={e.id}>{e.last}</option>)}
        </select>
        <select className="inp sel sm" value={fDept} onChange={e => setFDept(e.target.value)}>
          <option value="all">Все подразделения</option>{db.departments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
        </select>
      </div>

      <div className="rep-panel">
        <div className="rep-panel-title">Архивные проекты ({projList.length})</div>
        <table className="tbl">
          <thead><tr><th>Код</th><th>Название</th><th>Тип</th><th>В архиве с</th><th></th></tr></thead>
          <tbody>
            {projList.map(p => (
              <tr key={p.id}>
                <td><span className="pj-code" style={{ background: p.color + '22', color: p.color }}>{p.code}</span></td>
                <td><b>{p.name}</b></td>
                <td>{PROJECT_TYPES[p.ptype || 'prod']}</td>
                <td>{fmtDMY(p.archivedAt)}</td>
                <td>
                  <button className="btn ghost sm" onClick={() => openProject(p.id)}>Открыть</button>
                  {canRestore(ur) && p.archived && (
                    <button className="btn ghost sm" onClick={() => restoreProject(p.id)}>
                      <Ic d={ICONS.restore} size={12} /> Восстановить
                    </button>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="rep-panel">
        <div className="rep-panel-title">Архивные задачи ({taskList.length})</div>
        <table className="tbl">
          <thead><tr><th>Задача</th><th>Проект</th><th>Исполнитель</th><th>В архиве с</th><th></th></tr></thead>
          <tbody>
            {taskList.map(t => (
              <tr key={t.id}>
                <td><b>{t.title}</b></td>
                <td>{db.projects.find(x => x.id === t.projectId)?.code}</td>
                <td>{t.assigneeId ? empName(t.assigneeId) : '-'}</td>
                <td>{fmtDMY(t.archivedAt)}</td>
                <td>
                  <button className="btn ghost sm" onClick={() => openTask(t.id)}>Открыть</button>
                  {canRestore(ur) && t.archived && (
                    <button className="btn ghost sm" onClick={() => restoreTask(t.id)}>
                      <Ic d={ICONS.restore} size={12} /> Восстановить
                    </button>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

src/components/Avatar.jsx

import React, { useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { initials } from '../utils/date';

export default function Avatar({ employee, size = 'sm', className = '' }) {
  if (!employee) return null;

  const [tooltip, setTooltip] = useState({ visible: false, text: '', x: 0, y: 0 });
  const ref = useRef(null);

  const tooltipText = `${employee.last} ${employee.first}` +
    (employee.extension ? `\nВн. телефон: ${employee.extension}` : '');

  const handleMouseEnter = () => {
    if (ref.current) {
      const rect = ref.current.getBoundingClientRect();
      setTooltip({
        visible: true,
        text: tooltipText,
        x: rect.left + rect.width / 2,
        y: rect.top - 10,
      });
    }
  };

  const handleMouseLeave = () => {
    setTooltip(prev => ({ ...prev, visible: false }));
  };

  const baseClass = 'avatar';
  const sizeClass = size ? ` ${size}` : '';
  const classes = `${baseClass}${sizeClass}${className ? ' ' + className : ''}`;

  const avatarContent = employee.photo ? (
    <img
      src={employee.photo}
      alt={`${employee.first} ${employee.last}`}
      className={`${classes} avatar-img`}
    />
  ) : (
    <div className={`${classes} avatar-initials`}>
      {initials(employee.first, employee.last) || '?'}
    </div>
  );

  return (
    <>
      <span
        ref={ref}
        className="avatar-wrapper"
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        style={{ display: 'inline-block', position: 'relative' }}
      >
        {avatarContent}
      </span>

      {tooltip.visible &&
        createPortal(
          <div
            className="avatar-tooltip"
            style={{
              left: tooltip.x,
              top: tooltip.y,
              transform: 'translateX(-50%) translateY(-100%)',
            }}
          >
            {tooltip.text}
          </div>,
          document.body
        )
      }
    </>
  );
}

src/components/Cabinet.jsx

import React, { useState, useMemo, useRef } from 'react';
import { DOMAIN, TASK_STATUSES, TASK_STATUS_ORDER, VACATION_TYPES } from '../utils/constants';
import { TODAY, fmtDMY, fmtD, iso, addDays, initials, isTaskActive } from '../utils/date';
import { useDataHelpers } from '../hooks';
import { useToast } from '../context/ToastContext';
import { Ic, ICONS } from './Icons';
import { getPrimaryDeptName } from '../utils/helpers';
import { Modal } from './Modal';

function downloadCSV(name, rows) {
  const csv = '\uFEFF' + rows.map(r => r.map(c => `"${String(c ?? '').replace(/"/g, '""')}"`).join(';')).join('\r\n');
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = name.endsWith('.csv') ? name : name + '.csv';
  a.click();
  setTimeout(() => URL.revokeObjectURL(a.href), 500);
}

const PasswordChangeModal = ({ user, store, onClose }) => {
  const { showToast } = useToast();
  const [oldPass, setOldPass] = useState('');
  const [newPass, setNewPass] = useState('');
  const [confirmPass, setConfirmPass] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = () => {
    setError('');
    if (user.pass !== oldPass) {
      setError('Неверный старый пароль');
      return;
    }
    if (newPass.length < 8 || !/[A-ZА-ЯЁ]/.test(newPass) || !/[a-zа-яё]/.test(newPass) || !/\d/.test(newPass) || !/[^A-Za-zА-Яа-яЁё0-9]/.test(newPass)) {
      setError('Пароль должен содержать минимум 8 символов, заглавные и строчные буквы, цифру и спецсимвол');
      return;
    }
    if (newPass !== confirmPass) {
      setError('Пароли не совпадают');
      return;
    }
    const history = user.passwordHistory || [];
    if (history.some(p => p === newPass)) {
      setError('Этот пароль уже использовался ранее. Выберите другой.');
      return;
    }
    const updated = {
      ...user,
      pass: newPass,
      passwordHistory: [...history.slice(-4), newPass],
    };
    store.upsertEmployee(updated);
    showToast('Пароль успешно изменён', 'success');
    onClose();
  };

  return (
    <Modal title="Смена пароля" onClose={onClose} width={460}>
      <div className="form-grid">
        <label className="lbl">Старый пароль *</label>
        <input className="inp" type="password" value={oldPass} onChange={e => setOldPass(e.target.value)} />
        <label className="lbl">Новый пароль *</label>
        <input className="inp" type="password" value={newPass} onChange={e => setNewPass(e.target.value)} />
        <label className="lbl">Подтверждение *</label>
        <input className="inp" type="password" value={confirmPass} onChange={e => setConfirmPass(e.target.value)} />
      </div>
      {error && <div className="login-err" style={{ marginTop: 8 }}>{error}</div>}
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" onClick={handleSubmit}>Сменить пароль</button>
      </div>
    </Modal>
  );
};

export default function Cabinet({ store, data, user, openTask, openVacation, openDelegation }) {
  const { showToast } = useToast();
  const { empName, getEmployeeLoad } = useDataHelpers(data);
  const [tab, setTab] = useState('overview');

  const [expFrom, setExpFrom] = useState('');
  const [expTo, setExpTo] = useState('');

  const [editMode, setEditMode] = useState(false);
  const [phone, setPhone] = useState(user.phone || '');
  const [extension, setExtension] = useState(user.extension || '');
  const [showPasswordModal, setShowPasswordModal] = useState(false);

  const myTasks = data.tasks.filter(t => isTaskActive(t) && t.assigneeId === user.id && !user.fired);
  const myProjects = [...new Set(myTasks.map(t => t.projectId))].map(id => data.projects.find(p => p.id === id)).filter(Boolean);
  const myVacs = data.vacations.filter(v => v.empId === user.id);
  const myDeleg = data.roleDelegations.filter(r => r.fromId === user.id || r.toId === user.id);

  const range = 20;
  const days = [];
  for (let i = range - 1; i >= 0; i--) days.push(iso(addDays(new Date(), -i)));

  const taskTableData = useMemo(() => {
    return myTasks.map(t => {
      const logMap = {};
      t.logs.forEach(l => {
        logMap[l.date] = (logMap[l.date] || 0) + l.hours;
      });
      return { task: t, logMap };
    });
  }, [myTasks]);

  const exportMyReport = () => {
    if (!expFrom || !expTo) {
      showToast('Выберите даты начала и окончания периода', 'warning');
      return;
    }
    if (expFrom > expTo) {
      showToast('Дата начала не может быть позже даты окончания', 'error');
      return;
    }

    const allLogs = [];
    myTasks.forEach(t => {
      const project = data.projects.find(p => p.id === t.projectId);
      t.logs.forEach(l => {
        if (l.date >= expFrom && l.date <= expTo) {
          allLogs.push({
            date: l.date,
            hours: l.hours,
            task: t.title,
            project: project?.code || '-'
          });
        }
      });
    });

    if (!allLogs.length) {
      showToast('За выбранный период нет учтенных часов', 'warning');
      return;
    }

    const rows = [['Проект', 'Задача', 'Дата', 'Часы']];
    allLogs.forEach(l => rows.push([l.project, l.task, fmtDMY(l.date), l.hours]));
    
    downloadCSV(`отчет_${user.last}_${expFrom}_${expTo}`, rows);
    showToast('Отчёт выгружен!', 'success');
  };

  const tabs = [
    ['overview', 'Сводка'],
    ['vacations', 'Мои отпуска'],
    ['delegation', 'Делегирование ролей'],
    ['profile', 'Профиль и уведомления']
  ];

  const handleSaveProfile = () => {
    if (!extension.trim()) {
      showToast('Внутренний номер телефона не может быть пустым', 'error');
      return;
    }
    const updated = {
      ...user,
      phone: phone.trim(),
      extension: extension.trim(),
    };
    store.upsertEmployee(updated);
    setEditMode(false);
    showToast('Данные обновлены', 'success');
  };

  const fileInputRef = useRef(null);
  const handlePhotoUpload = (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      const photoData = ev.target.result;
      const updated = { ...user, photo: photoData };
      store.upsertEmployee(updated);
      showToast('Фото загружено', 'success');
    };
    reader.readAsDataURL(file);
  };
  const handlePhotoDelete = () => {
    if (user.photo && window.confirm('Удалить фото?')) {
      const updated = { ...user, photo: null };
      store.upsertEmployee(updated);
      showToast('Фото удалено', 'info');
    }
  };

  return (
    <div className="cab">
      <div className="tabs">
        {tabs.map(([id, label]) => (
          <button key={id} className={`tab${tab === id ? ' on' : ''}`} onClick={() => setTab(id)}>{label}</button>
        ))}
      </div>

      {tab === 'overview' && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
          <div className="rep-panel">
            <div className="rep-panel-title">Затраченные часы по задачам (последние 20 дней)</div>
            <div className="toolbar" style={{ marginBottom: 12, display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
              <span className="mut sm">Экспорт:</span>
              <input className="inp" type="date" style={{ width: 150 }} value={expFrom} onChange={e => setExpFrom(e.target.value)} />
              <span className="mut sm">-</span>
              <input className="inp" type="date" style={{ width: 150 }} value={expTo} onChange={e => setExpTo(e.target.value)} />
              <button className="btn primary sm" onClick={exportMyReport}>
                <Ic d={ICONS.download} size={13} /> Выгрузить в Excel (CSV)
              </button>
            </div>

            <div style={{ width: '100%', overflowX: 'auto' }}>
              <table className="tbl" style={{ width: '100%', minWidth: '700px', borderCollapse: 'collapse' }}>
                <thead>
                  <tr>
                    <th style={{ minWidth: '200px', textAlign: 'left', borderBottom: '1px solid var(--line)' }}>Задача</th>
                    {days.map(d => (
                      <th key={d} style={{ textAlign: 'center', minWidth: '60px', borderBottom: '1px solid var(--line)', fontSize: '11px', color: 'var(--mut)' }}>
                        {fmtD(d)}
                      </th>
                    ))}
                  </tr>
                </thead>
                <tbody>
                  {taskTableData.map(({ task, logMap }) => (
                    <tr key={task.id} style={{ cursor: 'pointer' }} onClick={() => openTask(task.id)}>
                      <td style={{ textAlign: 'left', fontWeight: 600, borderBottom: '1px solid #f1f5f9' }}>
                        {task.title}
                        <span className="mut sm" style={{ marginLeft: 8, fontWeight: 400 }}>
                          ({data.projects.find(p => p.id === task.projectId)?.code || '-'})
                        </span>
                      </td>
                      {days.map(d => (
                        <td key={d} style={{ textAlign: 'center', borderBottom: '1px solid #f1f5f9' }}>
                          {logMap[d] ? <b>{logMap[d]} ч</b> : ''}
                        </td>
                      ))}
                    </tr>
                  ))}
                  {taskTableData.length === 0 && (
                    <tr><td colSpan={days.length + 1} className="mut" style={{ textAlign: 'center', padding: 20 }}>Нет учтённых часов</td></tr>
                  )}
                </tbody>
              </table>
            </div>
          </div>

          <div className="rep-panel">
            <div className="rep-panel-title">Мои задачи по статусам</div>
            {TASK_STATUS_ORDER.map(st => {
              const list = myTasks.filter(t => t.status === st);
              if (!list.length) return null;
              return (
                <div key={st} className="cab-stat">
                  <div className="cab-stat-head">
                    <span className="kdot" style={{ background: TASK_STATUSES[st].color }} />
                    {TASK_STATUSES[st].label}
                    <span className="kcount">{list.length}</span>
                  </div>
                  {list.map(t => (
                    <div key={t.id} className="cab-task" onClick={() => openTask(t.id)}>
                      <span className="pdot" style={{ background: data.projects.find(p => p.id === t.projectId)?.color }} />
                      {t.title}
                      <span className="mut sm"> · {t.deadline ? `до ${fmtD(t.deadline)}` : 'без срока исполнения'} · {t.plannedHours ?? '-'} ч</span>
                    </div>
                  ))}
                </div>
              );
            })}
            {myTasks.length === 0 && <div className="mut">Задач пока нет</div>}
          </div>

          <div className="rep-panel">
            <div className="rep-panel-title">Мои проекты</div>
            {myProjects.map(p => (
              <div key={p.id} className="cab-proj">
                <span className="pdot" style={{ background: p.color }} />
                {p.code} - {p.name}
                {p.ptype === 'admin' && <span className="adm-badge" style={{ marginLeft: 8 }}>адм</span>}
              </div>
            ))}
            {myProjects.length === 0 && <div className="mut">Нет участия в проектах</div>}
          </div>
        </div>
      )}

      {tab === 'vacations' && (
        <div className="rep-panel">
          <div className="rep-panel-title">
            Мои отпуска
            <button className="btn primary sm" style={{ marginLeft: 'auto' }} onClick={() => openVacation(null)}>
              <Ic d={ICONS.plus} size={13} /> Добавить отпуск
            </button>
          </div>
          <table className="tbl">
            <thead><tr><th>Начало</th><th>Окончание</th><th>Тип</th><th>Комментарий</th><th>Делегирование</th><th>Статус</th><th></th></tr></thead>
            <tbody>
              {myVacs.map(v => {
                const started = v.start <= TODAY;
                return (
                  <tr key={v.id}>
                    <td>{fmtDMY(v.start)}</td><td>{fmtDMY(v.end)}</td>
                    <td>{VACATION_TYPES[v.type]}</td>
                    <td>{v.comment || '-'}</td>
                    <td>{v.delegation.enabled ? `→ ${empName(v.delegation.subId)}` : '-'}</td>
                    <td><span className={`st-chip ${v.status}`}>
                      {{ pending: 'На утверждении', approved: 'Утверждён', rejected: 'Отклонён' }[v.status]}
                    </span></td>
                    <td>
                      {!started && v.status !== 'rejected' ? (
                        <>
                          <button className="icon-btn" onClick={() => openVacation(v.id)}><Ic d={ICONS.edit} size={14} /></button>
                          <button className="icon-btn danger" onClick={() => { store.deleteVacation(v.id); store.addAudit('Удаление отпуска', fmtDMY(v.start)); }}><Ic d={ICONS.trash} size={14} /></button>
                        </>
                      ) : <span className="mut sm">{started ? 'изменение через HR/администратора' : ''}</span>}
                    </td>
                  </tr>
                );
              })}
              {myVacs.length === 0 && <tr><td colSpan="7" className="mut">Отпусков нет</td></tr>}
            </tbody>
          </table>
        </div>
      )}

      {tab === 'delegation' && (
        <div className="rep-panel">
          <div className="rep-panel-title">
            Временная передача ролей
            <button className="btn primary sm" style={{ marginLeft: 'auto' }} onClick={openDelegation}>
              <Ic d={ICONS.plus} size={13} /> Делегировать роль
            </button>
          </div>
          <p className="mut sm">Роли «Суперадминистратор» и «Генеральный директор» делегируются только через суперадминистратора. Получатель должен подтвердить принятие.</p>
          <table className="tbl">
            <thead><tr><th>От кого</th><th>Кому</th><th>Роли</th><th>Период</th><th>Статус</th><th></th></tr></thead>
            <tbody>
              {myDeleg.map(r => (
                <tr key={r.id}>
                  <td>{empName(r.fromId)}</td><td>{empName(r.toId)}</td>
                  <td>{r.roles.join(', ')}</td>
                  <td>{fmtDMY(r.start)} - {r.end ? fmtDMY(r.end) : 'до отмены'}</td>
                  <td><span className={`st-chip ${r.status}`}>
                    {{ pending: 'Ожидает принятия', active: 'Активно', rejected: 'Отклонено', revoked: 'Отозвано', expired: 'Истекло' }[r.status]}
                  </span></td>
                  <td>{r.status === 'active' && r.fromId === user.id && <button className="btn ghost sm" onClick={() => { /* revoke */ }}>Отозвать</button>}</td>
                </tr>
              ))}
              {myDeleg.length === 0 && <tr><td colSpan="6" className="mut">Делегирований нет</td></tr>}
            </tbody>
          </table>
        </div>
      )}

      {tab === 'profile' && (
        <>
          <div className="cab-grid">
            <div className="rep-panel">
              <div className="rep-panel-title">Личные данные</div>
              <div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
                <div style={{ flex: '0 0 140px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
                  {user.photo ? (
                    <img src={user.photo} alt="Аватар" style={{ width: 120, height: 120, borderRadius: '50%', objectFit: 'cover', border: '1px solid var(--line)' }} />
                  ) : (
                    <div style={{ width: 120, height: 120, borderRadius: '50%', background: '#e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>Нет фото</div>
                  )}
                  <input type="file" accept="image/*" ref={fileInputRef} style={{ display: 'none' }} onChange={handlePhotoUpload} />
                  <button className="btn primary sm" onClick={() => fileInputRef.current?.click()}>Загрузить фото</button>
                  {user.photo && <button className="btn ghost sm" onClick={handlePhotoDelete}>Удалить фото</button>}
                </div>

                <div style={{ flex: 1, minWidth: 300 }}>
                  <div className="form-grid">
                    <label className="lbl">Фамилия</label>
                    <input className="inp" disabled value={user.last} />

                    <label className="lbl">Имя</label>
                    <input className="inp" disabled value={user.first} />

                    <label className="lbl">Должность</label>
                    <input className="inp" disabled value={user.position || 'Сотрудник'} />

                    <label className="lbl">E-mail</label>
                    <input className="inp" disabled value={user.email + '@' + DOMAIN} />

                    <label className="lbl">Мобильный телефон</label>
                    <input
                      className="inp"
                      disabled={!editMode}
                      value={phone}
                      onChange={e => setPhone(e.target.value)}
                    />

                    <label className="lbl">Внутренний номер телефона *</label>
                    <input
                      className="inp"
                      disabled={!editMode}
                      value={extension}
                      onChange={e => setExtension(e.target.value)}
                      onBlur={() => {
                        if (!extension.trim()) {
                          showToast('Внутренний номер телефона обязателен', 'error');
                        }
                      }}
                    />

                    <label className="lbl">Табельный №</label>
                    <input className="inp" disabled value={user.tab || ''} />

                    <label className="lbl">Подразделения</label>
                    <div className="depts-readonly">
                      {user.departments.map(d => {
                        const dd = data.departments.find(x => x.id === d.deptId);
                        if (!dd) return null;
                        const isPrimary = d.primary;
                        let positionDisplay = '';
                        if (!isPrimary && d.position) {
                          positionDisplay = ` (${d.position})`;
                        }
                        return (
                          <span key={d.deptId} className={`dept-chip${isPrimary ? ' prim' : ''}`}>
                            {dd.name}
                            {isPrimary ? ' · основное' : ' · (совм)'}
                            {positionDisplay}
                          </span>
                        );
                      })}
                      {user.departments.length === 0 && <span className="mut sm">не назначены</span>}
                    </div>
                  </div>

                  <div style={{ marginTop: 16, display: 'flex', gap: 10, justifyContent: 'flex-end', alignItems: 'center' }}>
                    <button className="btn danger" onClick={() => setShowPasswordModal(true)}>Изменить пароль</button>
                    {editMode ? (
                      <>
                        <button className="btn ghost" onClick={() => {
                          setEditMode(false);
                          setPhone(user.phone || '');
                          setExtension(user.extension || '');
                        }}>Отмена</button>
                        <button className="btn primary" onClick={handleSaveProfile}>Сохранить</button>
                      </>
                    ) : (
                      <button className="btn primary" onClick={() => setEditMode(true)}>Изменить данные</button>
                    )}
                  </div>
                </div>
              </div>
            </div>

            <div className="rep-panel">
              <div className="rep-panel-title">Уведомления</div>
              {[
                ['deadlineEmail', 'E-mail о сроках - за 3 дня до срока исполнения задачи'],
                ['overdueDigest', 'Контроль просрочек - ежедневная сводка'],
                ['commentSub', 'Подписка на обсуждение задач, где я исполнитель или ответственный']
              ].map(([k, label]) => (
                <label key={k} className="toggle-row">
                  <span>{label}</span>
                  <span className={`toggle${user.notif[k] ? ' on' : ''}`} onClick={() => {
                    const updated = { ...user, notif: { ...user.notif, [k]: !user.notif[k] } };
                    store.upsertEmployee(updated);
                  }}><span className="toggle-knob" /></span>
                </label>
              ))}
              <p className="mut sm">Критичные уведомления (восстановление пароля, утверждения) отключить нельзя.</p>

              <div className="rep-panel-title" style={{ marginTop: 16 }}>История делегирований</div>
              {data.roleDelegations.filter(r => r.fromId === user.id).map(r => (
                <div key={r.id} className="mut sm">→ {empName(r.toId)}: {r.roles.join(', ')} ({fmtDMY(r.start)} - {r.end ? fmtDMY(r.end) : 'до отмены'})</div>
              ))}
              {data.roleDelegations.filter(r => r.fromId === user.id).length === 0 && <div className="mut sm">Передач ролей не было</div>}
            </div>
          </div>

          {showPasswordModal && (
            <PasswordChangeModal
              user={user}
              store={store}
              onClose={() => setShowPasswordModal(false)}
            />
          )}
        </>
      )}
    </div>
  );
}

src/components/Calendar.jsx

import { useState, useMemo } from 'react';
import { iso, addDays, fmtDMY, isTaskActive } from '../utils/date';
import { taskVisible, computeScope, hasRole } from '../utils/permissions';
import { Ic, ICONS } from './Icons';
import Avatar from './Avatar';

const DayCell = ({ d, big, byDay, db, openTask }) => {
  const dayIso = iso(d);
  const tasks = byDay[dayIso] || [];

  return (
    <div className={`cal-cell${dayIso === iso(new Date()) ? ' today' : ''}`}>
      <div className="cal-daynum">{d.getDate()}</div>
      <div className="cal-chips">
        {tasks.slice(0, big ? 12 : 3).map(t => {
          const p = db.projects.find(x => x.id === t.projectId);
          const assignee = t.assigneeId ? db.employees.find(e => e.id === t.assigneeId) : null;

          return (
            <div
              key={t.id}
              className="cal-chip"
              style={{ borderColor: p?.color }}
              onClick={() => openTask(t.id)}
              title={`${t.title} (${p?.code || 'без проекта'})`}
            >
              <div className="cal-task-title">
                <span className="pdot" style={{ background: p?.color }} />
                <span>{t.title}</span>
              </div>
              <div className="cal-executors">
                {assignee && <Avatar employee={assignee} size="xs" />}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default function Calendar({ db, ur, openTask }) {
  const scope = useMemo(() => computeScope(ur, db), [ur, db]);
  const [mode, setMode] = useState('month');
  const [anchor, setAnchor] = useState(new Date());
  const [showOnlyMy, setShowOnlyMy] = useState(false);

  const canSeeAll = hasRole(ur, "admin", "director", "economist", "kb_chief", "head", "project_lead", "project_manager");

  let allTasks = db.tasks.filter(t => isTaskActive(t) && taskVisible(ur, scope, t, db) && t.deadline && !['closed','cancelled'].includes(t.status));

  if (showOnlyMy) {
    allTasks = allTasks.filter(t => t.assigneeId === ur.id);
  }

  const byDay = useMemo(() => {
    const m = {};
    allTasks.forEach(t => { (m[t.deadline] = m[t.deadline] || []).push(t); });
    return m;
  }, [allTasks]);

  const shift = (dir) => {
    if (mode === 'month') setAnchor(new Date(anchor.getFullYear(), anchor.getMonth() + dir, 1));
    else if (mode === 'week') setAnchor(addDays(anchor, dir * 7));
    else setAnchor(addDays(anchor, dir));
  };
  const title = mode === 'month' ? `${['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'][anchor.getMonth()]} ${anchor.getFullYear()}` :
    mode === 'week' ? `Неделя ${fmtDMY(iso(addDays(anchor, -((anchor.getDay()+6)%7))))} - ${fmtDMY(iso(addDays(anchor, 6-((anchor.getDay()+6)%7))))}` : fmtDMY(iso(anchor));

  let body = null;
  if (mode === 'month') {
    const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
    const offset = (first.getDay() + 6) % 7;
    const cells = []; for (let i=0; i<42; i++) cells.push(addDays(first, i-offset));
    body = (<>
      <div className="cal-grid-head">{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(w => <div key={w} className="cal-wd">{w}</div>)}</div>
      <div className="cal-grid">{cells.map(d => <div key={iso(d)} className={d.getMonth()===anchor.getMonth()?'':'outwrap'}><DayCell d={d} big={false} byDay={byDay} db={db} openTask={openTask} /></div>)}</div>
    </>);
  } else if (mode === 'week') {
    const mon = addDays(anchor, -((anchor.getDay()+6)%7));
    body = <div className="cal-week">{[0,1,2,3,4,5,6].map(i => <DayCell key={i} d={addDays(mon, i)} big byDay={byDay} db={db} openTask={openTask} />)}</div>;
  } else {
    body = <div className="cal-week one"><DayCell d={anchor} big byDay={byDay} db={db} openTask={openTask} /></div>;
  }

  return (
    <div className="cal-panel">
      <div className="cal-head">
        <div className="cal-nav">
          <button className="icon-btn" onClick={() => shift(-1)}><Ic d={ICONS.left} size={16} /></button>
          <div className="cal-title">{title}</div>
          <button className="icon-btn" onClick={() => shift(1)}><Ic d={ICONS.right} size={16} /></button>
        </div>
        <div className="cal-right">
          <button className="btn ghost sm" onClick={() => setAnchor(new Date())}>Сегодня</button>
          <div className="seg">{['day','week','month'].map(m => <button key={m} className={`seg-btn${mode===m?' on':''}`} onClick={() => setMode(m)}>{['День','Неделя','Месяц'][['day','week','month'].indexOf(m)]}</button>)}</div>
          {canSeeAll && (
            <label className="dept-pick" style={{ marginLeft: 8 }}>
              <input type="checkbox" checked={showOnlyMy} onChange={(e) => setShowOnlyMy(e.target.checked)} />
              <span style={{ fontSize: 13 }}>Мои задачи</span>
            </label>
          )}
        </div>
      </div>
      <div className="cal-note">Только задачи со сроком выполнения.</div>
      {body}
    </div>
  );
}

src/components/CreateEmployeeModal.jsx

import React, { useState } from 'react';
import { Modal } from './Modal';
import { uid } from '../utils/date';
import { ROLES } from '../utils/constants';

export default function CreateEmployeeModal({ db, setDb, onClose, toast, audit }) {
  const [form, setForm] = useState({
    last: '',
    first: '',
    email: '',
    pass: '',
    position: 'Сотрудник',
    departments: [],
    roles: ['executor'],
    phone: '',
    extension: '',
    tab: String(1000 + Math.floor(Math.random() * 8999)),
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
  };

  const handleSubmit = () => {
    if (!form.last.trim() || !form.first.trim() || !form.email.trim() || !form.pass.trim()) {
      toast('Заполните все обязательные поля', 'err');
      return;
    }
    if (db.employees.some(e => e.email === form.email)) {
      toast('Сотрудник с таким email уже существует', 'err');
      return;
    }
    // Проверка пароля
    if (form.pass.length < 8) {
      toast('Пароль должен быть не менее 8 символов', 'err');
      return;
    }
    const newEmp = {
      id: 'e_' + uid(),
      last: form.last.trim(),
      first: form.first.trim(),
      email: form.email.trim(),
      pass: form.pass,
      position: form.position || 'Сотрудник',
      departments: form.departments,
      roles: form.roles,
      kbIds: [],
      headDeptIds: [],
      phone: form.phone || '',
      extension: form.extension || '',
      tab: form.tab || String(1000 + Math.floor(Math.random() * 8999)),
      notif: { deadlineEmail: true, overdueDigest: false, commentSub: true },
      failed: 0,
      lockUntil: 0,
      fired: false,
      photo: null,
      passwordHistory: []
    };
    setDb(prev => ({
      ...prev,
      employees: [...prev.employees, newEmp]
    }));
    audit('Создание сотрудника', `${newEmp.last} ${newEmp.first}`);
    toast('Сотрудник создан');
    onClose();
  };

  return (
    <Modal title="Добавить сотрудника" onClose={onClose} width={560}>
      <div className="form-grid">
        <label className="lbl">Фамилия *</label>
        <input className="inp" name="last" value={form.last} onChange={handleChange} />
        <label className="lbl">Имя *</label>
        <input className="inp" name="first" value={form.first} onChange={handleChange} />
        <label className="lbl">E-mail *</label>
        <input className="inp" name="email" value={form.email} onChange={handleChange} />
        <label className="lbl">Пароль *</label>
        <input className="inp" type="password" name="pass" value={form.pass} onChange={handleChange} />
        <label className="lbl">Должность</label>
        <input className="inp" name="position" value={form.position} onChange={handleChange} />
        <label className="lbl">Телефон</label>
        <input className="inp" name="phone" value={form.phone} onChange={handleChange} />
        <label className="lbl">Внутренний номер</label>
        <input className="inp" name="extension" value={form.extension} onChange={handleChange} />
        <label className="lbl">Табельный №</label>
        <input className="inp" name="tab" value={form.tab} onChange={handleChange} />
        <label className="lbl">Роли (по умолчанию исполнитель)</label>
        <div className="sub-picks">
          {Object.keys(ROLES).map(r => (
            <label key={r} className="dept-pick">
              <input
                type="checkbox"
                checked={form.roles.includes(r)}
                onChange={() => {
                  setForm(prev => ({
                    ...prev,
                    roles: prev.roles.includes(r) ? prev.roles.filter(x => x !== r) : [...prev.roles, r]
                  }));
                }}
              />
              {ROLES[r].label}
            </label>
          ))}
        </div>
      </div>
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" onClick={handleSubmit}>Создать</button>
      </div>
    </Modal>
  );
}

src/components/Discussion.jsx

// src/components/Discussion.jsx
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { uid, fmtDT, initials } from '../utils/date';
import { has } from '../utils/permissions';
import { Ic, ICONS } from './Icons';
import { COMMENT_EDIT_WINDOW } from '../utils/constants';
import Avatar from './Avatar';

/**
 * Рендерит текст с подсветкой @упоминаний
 */
function renderMentionText(text) {
  return text.split("@").map((part, i) => {
    if (i === 0) return <span key={i}>{part}</span>;
    const tokens = part.split(/(\s+)/);
    let mention = tokens[0];
    let restStart = 1;
    if (tokens.length > 2 && /^[А-ЯA-ZЁ]/.test(tokens[2])) {
      mention += " " + tokens[2];
      restStart = 3;
    }
    return (
      <span key={i}>
        <span className="mention">@{mention}</span>
        {tokens.slice(restStart).join("")}
      </span>
    );
  });
}

/**
 * Извлекает ID сотрудников, упомянутых через @фамилия
 */
export function extractMentions(text, employees) {
  const found = [];
  text.split("@").slice(1).forEach((part) => {
    const token = part.trim().split(/[\s,.!?:;]/)[0].toLowerCase();
    if (!token) return;
    const emp = employees.find((e) => e.last.toLowerCase() === token);
    if (emp && !found.includes(emp.id)) found.push(emp.id);
  });
  return found;
}

/**
 * Универсальный компонент обсуждения (дерево комментариев с ответами, упоминаниями, редактированием)
 */
export default function Discussion({
  comments,
  currentUser,
  candidates = [],
  onUpdateComments,
  onCommentAdded,
  readOnly = false,
  canComment = true,
  toast,
  employees = [],
}) {
  const [text, setText] = useState("");
  const [replyTo, setReplyTo] = useState(null);
  const [editingId, setEditingId] = useState(null);
  const [editText, setEditText] = useState("");
  const [mentionQ, setMentionQ] = useState(null);
  const [mentionPopup, setMentionPopup] = useState({ visible: false, x: 0, y: 0 });
  const textareaRef = useRef(null);
  const cursorPosRef = useRef(null);

  const allEmployees = employees;

  // Фильтрация кандидатов для упоминаний
  const filteredCandidates = useMemo(() => {
    if (mentionQ === null) return [];
    return candidates.filter((e) =>
      (`${e.last} ${e.first}`).toLowerCase().includes(mentionQ.toLowerCase())
    );
  }, [candidates, mentionQ]);

  // При изменении mentionQ вычисляем позицию для портала
  useEffect(() => {
    if (mentionQ !== null && textareaRef.current) {
      const rect = textareaRef.current.getBoundingClientRect();
      setMentionPopup({
        visible: true,
        x: rect.left,
        y: rect.bottom + 4,
      });
    } else {
      setMentionPopup(prev => ({ ...prev, visible: false }));
    }
  }, [mentionQ]);

  // Восстановление курсора после обновления text
  useEffect(() => {
    if (cursorPosRef.current !== null && textareaRef.current) {
      textareaRef.current.setSelectionRange(cursorPosRef.current, cursorPosRef.current);
      cursorPosRef.current = null;
    }
  }, [text]);

  // Поиск автора по ID
  const getAuthor = (id) => allEmployees.find((e) => e.id === id);

  // Обработка ввода текста (включая поиск @)
  const onType = (val) => {
    setText(val);
    const lastAt = val.lastIndexOf("@");
    if (lastAt >= 0) {
      const suffix = val.slice(lastAt + 1);
      if (!/\s/.test(suffix) && suffix.length <= 30) {
        setMentionQ(suffix);
      } else {
        setMentionQ(null);
      }
    } else {
      setMentionQ(null);
    }
  };

  // Выбор упоминания из всплывающего списка
  const pickMention = (emp) => {
    const lastAt = text.lastIndexOf("@");
    if (lastAt === -1) return;

    const prefix = text.slice(0, lastAt + 1);
    const suffix = text.slice(lastAt + 1);
    const insert = `${emp.last} ${emp.first}, `;
    const newText = prefix + insert + suffix;

    // Позиция курсора – сразу после вставленного имени (включая запятую и пробел)
    const cursorPos = prefix.length + insert.length;
    cursorPosRef.current = cursorPos;

    setText(newText);
    setMentionQ(null);

    // Возвращаем фокус в текстовое поле
    if (textareaRef.current) {
      textareaRef.current.focus();
    }
  };

  // Отправка комментария
  const send = () => {
    if (readOnly || !canComment) return;
    if (!text.trim()) return;

    const newComment = {
      id: uid(),
      parentId: replyTo,
      authorId: currentUser.id,
      ts: Date.now(),
      text: text.trim(),
    };

    const updatedComments = [...comments, newComment];
    onUpdateComments(updatedComments);
    if (onCommentAdded) onCommentAdded(newComment);

    setText("");
    setReplyTo(null);
    setMentionQ(null);
  };

  // Проверка права на удаление
  const canDelete = (c) => {
    const hasReplies = comments.some((x) => x.parentId === c.id);
    if (has(currentUser, "admin", "director")) return true;
    return c.authorId === currentUser.id && !hasReplies;
  };

  // Проверка права на редактирование (в течение окна)
  const canEdit = (c) =>
    c.authorId === currentUser.id &&
    Date.now() - c.ts < COMMENT_EDIT_WINDOW;

  // Удаление комментария и всех ответов
  const del = (c) => {
    if (!window.confirm("Удалить комментарий и все ответы?")) return;

    const subtree = new Set([c.id]);
    let changed = true;
    while (changed) {
      changed = false;
      comments.forEach((x) => {
        if (x.parentId && subtree.has(x.parentId) && !subtree.has(x.id)) {
          subtree.add(x.id);
          changed = true;
        }
      });
    }

    const updatedComments = comments.filter((x) => !subtree.has(x.id));
    onUpdateComments(updatedComments);
    if (toast) toast("Комментарий удалён");
  };

  // Сохранение отредактированного комментария
  const saveEdit = (c) => {
    if (!editText.trim()) return;
    const updatedComments = comments.map((x) =>
      x.id === c.id ? { ...x, text: editText.trim() } : x
    );
    onUpdateComments(updatedComments);
    setEditingId(null);
    setEditText("");
  };

  // Рекурсивное построение дерева комментариев
  const renderTree = (parentId, depth) => {
    const children = comments
      .filter((c) => (c.parentId || null) === parentId)
      .sort((a, b) => a.ts - b.ts);

    return children.map((c) => {
      const author = getAuthor(c.authorId);
      return (
        <div key={c.id}>
          <div className={"cm" + (depth > 0 ? " reply" : "")}>
            <div className="cm-head">
              <Avatar employee={author} size="xs" />
              <span className="cm-author">
                {author ? `${author.last} ${author.first}` : "-"}
              </span>
              <span className="mut sm">{fmtDT(c.ts)}</span>
              {!readOnly &&
                editingId !== c.id &&
                Date.now() - c.ts < COMMENT_EDIT_WINDOW &&
                c.authorId === currentUser.id && (
                  <span className="mut sm">· можно редактировать</span>
                )}
            </div>

            {editingId === c.id ? (
              <div className="cm-edit">
                <textarea
                  className="inp"
                  rows="2"
                  value={editText}
                  onChange={(e) => setEditText(e.target.value)}
                />
                <div className="cm-actions">
                  <button className="btn primary sm" onClick={() => saveEdit(c)}>
                    Сохранить
                  </button>
                  <button
                    className="btn ghost sm"
                    onClick={() => setEditingId(null)}
                  >
                    Отмена
                  </button>
                </div>
              </div>
            ) : (
              <div className="cm-text">{renderMentionText(c.text)}</div>
            )}

            {!readOnly && canComment && (
              <div className="cm-actions">
                <button className="link" onClick={() => setReplyTo(c.id)}>
                  Ответить
                </button>
                {canEdit(c) && editingId !== c.id && (
                  <button
                    className="link"
                    onClick={() => {
                      setEditingId(c.id);
                      setEditText(c.text);
                    }}
                  >
                    Редактировать
                  </button>
                )}
                {canDelete(c) && (
                  <button className="link red-link" onClick={() => del(c)}>
                    Удалить
                  </button>
                )}
              </div>
            )}
          </div>
          {renderTree(c.id, depth + 1)}
        </div>
      );
    });
  };

  return (
    <div className="chat">
      {renderTree(null, 0)}

      {comments.length === 0 && (
        <div className="mut sm">Обсуждений пока нет - начните диалог.</div>
      )}

      {!readOnly && canComment ? (
        <>
          {replyTo && (
            <div className="reply-banner">
              Ответ на комментарий{" "}
              {(() => {
                const parent = comments.find((x) => x.id === replyTo);
                const author = parent ? getAuthor(parent.authorId) : null;
                return author ? `${author.last} ${author.first}` : "";
              })()}
              <button className="link" onClick={() => setReplyTo(null)}>
                отменить
              </button>
            </div>
          )}

          <div className="cm-input-wrap">
            {/* Список упоминаний рендерится через портал с динамическим позиционированием */}
            {mentionPopup.visible && filteredCandidates.length > 0 &&
              createPortal(
                <div
                  className="mention-pop"
                  style={{
                    position: 'fixed',
                    left: mentionPopup.x,
                    top: mentionPopup.y,
                    zIndex: 10001,
                  }}
                >
                  {filteredCandidates.slice(0, 6).map((e) => (
                    <div
                      key={e.id}
                      className="mention-item"
                      onClick={() => pickMention(e)}
                    >
                      <span className="avatar xs">
                        {initials(e.first, e.last)}
                      </span>
                      {e.last} {e.first}
                    </div>
                  ))}
                </div>,
                document.body
              )
            }

            <textarea
              ref={textareaRef}
              className="inp"
              rows="2"
              placeholder="Комментарий… Введите @ для упоминания участника"
              value={text}
              onChange={(e) => onType(e.target.value)}
              disabled={readOnly || !canComment}
            />
          </div>

          <div className="cm-foot">
            <span className="mut sm">
              Участники получат уведомление; упомянутые - отдельно.
            </span>
            <button className="btn primary sm" onClick={send}>
              <Ic d={ICONS.chat} size={13} /> Отправить
            </button>
          </div>
        </>
      ) : readOnly ? (
        <div className="info-box">
          Обсуждение сохранено. Добавление комментариев к архивным объектам запрещено.
        </div>
      ) : (
        !canComment && (
          <div className="info-box">
            У вас нет прав для комментирования этого объекта.
          </div>
        )
      )}
    </div>
  );
}

src/components/EditEmployeeModal.jsx

import React, { useState } from 'react';
import { Modal } from './Modal';
import { hasRole } from '../utils/permissions';

export default function EditEmployeeModal({ db, setDb, employeeId, onClose, toast, audit, ur }) {
  const emp = db.employees.find(e => e.id === employeeId);
  if (!emp) return null;

  const [form, setForm] = useState({
    last: emp.last || '',
    first: emp.first || '',
    email: emp.email || '',
    position: emp.position || 'Сотрудник',   // <-- основная должность
    phone: emp.phone || '',
    extension: emp.extension || '',
    tab: emp.tab || '',
  });
  const [newPass, setNewPass] = useState('');

  const isAdmin = hasRole(ur, 'admin');

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
  };

  const handleSave = () => {
    if (!form.last.trim() || !form.first.trim() || !form.email.trim()) {
      toast('Фамилия, имя и email обязательны', 'err');
      return;
    }
    const updatedEmp = {
      ...emp,
      last: form.last.trim(),
      first: form.first.trim(),
      email: form.email.trim(),
      position: form.position.trim() || 'Сотрудник',   // <-- сохраняем основную должность
      phone: form.phone || '',
      extension: form.extension || '',
      tab: form.tab || '',
    };
    if (isAdmin && newPass.trim()) {
      if (newPass.length < 8) {
        toast('Пароль должен быть не менее 8 символов', 'err');
        return;
      }
      updatedEmp.pass = newPass.trim();
      const history = emp.passwordHistory || [];
      if (history.some(p => p === newPass.trim())) {
        toast('Этот пароль уже использовался', 'err');
        return;
      }
      updatedEmp.passwordHistory = [...history.slice(-4), newPass.trim()];
    }
    setDb(prev => ({
      ...prev,
      employees: prev.employees.map(e => e.id === employeeId ? updatedEmp : e)
    }));
    audit('Редактирование сотрудника', `${updatedEmp.last} ${updatedEmp.first}`);
    toast('Данные сотрудника обновлены');
    onClose();
  };

  return (
    <Modal title={`Редактирование сотрудника - ${emp.last} ${emp.first}`} onClose={onClose} width={560}>
      <div className="form-grid">
        <label className="lbl">Фамилия *</label>
        <input className="inp" name="last" value={form.last} onChange={handleChange} />
        <label className="lbl">Имя *</label>
        <input className="inp" name="first" value={form.first} onChange={handleChange} />
        <label className="lbl">E-mail *</label>
        <input className="inp" name="email" value={form.email} onChange={handleChange} />
        <label className="lbl">Должность (основная)</label>   {/* <-- подпись */}
        <input className="inp" name="position" value={form.position} onChange={handleChange} />
        <label className="lbl">Телефон</label>
        <input className="inp" name="phone" value={form.phone} onChange={handleChange} />
        <label className="lbl">Внутренний номер</label>
        <input className="inp" name="extension" value={form.extension} onChange={handleChange} />
        <label className="lbl">Табельный №</label>
        <input className="inp" name="tab" value={form.tab} onChange={handleChange} />
        {isAdmin && (
          <>
            <label className="lbl">Новый пароль</label>
            <input className="inp" type="password" value={newPass} onChange={(e) => setNewPass(e.target.value)} />
          </>
        )}
      </div>
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" onClick={handleSave}>Сохранить</button>
      </div>
    </Modal>
  );
}

src/components/ErrorBoundary.jsx

import React from 'react';

export default class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  componentDidCatch(error, errorInfo) {
    console.error('Error caught by ErrorBoundary:', error, errorInfo);
  }
  render() {
    if (this.state.hasError) {
      return (
        <div style={{ padding: '40px', fontFamily: 'sans-serif', textAlign: 'center' }}>
          <h2>Что-то пошло не так</h2>
          <p style={{ color: '#666' }}>Попробуйте перезагрузить страницу или сообщите разработчику.</p>
          <button onClick={() => window.location.reload()} style={{ padding: '8px 16px', marginTop: '12px', cursor: 'pointer' }}>
            Перезагрузить
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

src/components/FormField.jsx

import { useId } from 'react';

export const FormField = ({ label, type = 'text', value, onChange, disabled, options, ...props }) => {
  const id = `field-${useId()}`;
  return (
    <>
      <label className="lbl" htmlFor={id}>{label}</label>
      {type === 'select' ? (
        <select className="inp sel" id={id} value={value} onChange={onChange} disabled={disabled}>
          {options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
        </select>
      ) : type === 'textarea' ? (
        <textarea className="inp" id={id} rows={props.rows || 2} value={value} onChange={onChange} disabled={disabled} />
      ) : type === 'date' ? (
        <input className="inp" type="date" id={id} value={value} onChange={onChange} disabled={disabled} />
      ) : (
        <input className="inp" type={type} id={id} value={value} onChange={onChange} disabled={disabled} />
      )}
    </>
  );
};

src/components/Gantt.jsx

import React, { useState, useMemo } from 'react';
import { TODAY, iso, addDays, parseISO, fmtD, fmtDMY, isTaskActive } from '../utils/date';
import { TASK_STATUSES, DEPENDENCY_TYPES, PRIORITIES } from '../utils/constants';
import { useDataHelpers } from '../hooks';
import { computeScope, taskVisible } from '../utils/permissions';
import { Ic, ICONS } from './Icons';
import Avatar from './Avatar';
import { getProjectColor } from '../utils/projectHelpers';

export default function Gantt({ db, ur, openTask, openProject }) {
  const { empName, getTaskSpent, vacOverlap } = useDataHelpers(db);
  const scope = useMemo(() => computeScope(ur, db), [ur, db]);
  const tasks = db.tasks.filter(t => isTaskActive(t) && taskVisible(ur, scope, t, db) && t.start && t.deadline);

  const [mode, setMode] = useState('month');
  const [anchor, setAnchor] = useState(() => {
    const d = new Date();
    return iso(new Date(d.getFullYear(), d.getMonth(), 1));
  });

  const DW = 34;

  const getDaysInRange = (anchorDate, mode) => {
    const start = parseISO(anchorDate);
    const days = [];
    if (mode === 'month') {
      const year = start.getFullYear();
      const month = start.getMonth();
      const daysInMonth = new Date(year, month + 1, 0).getDate();
      for (let i = 0; i < daysInMonth; i++) {
        days.push(iso(new Date(year, month, i + 1)));
      }
    } else if (mode === 'quarter') {
      for (let i = 0; i < 90; i++) {
        days.push(iso(addDays(start, i)));
      }
    } else {
      for (let i = 0; i < 365; i++) {
        days.push(iso(addDays(start, i)));
      }
    }
    return days;
  };

  const shift = (dir) => {
    let newAnchor;
    if (mode === 'month') {
      const d = parseISO(anchor);
      d.setMonth(d.getMonth() + dir);
      newAnchor = iso(d);
    } else if (mode === 'quarter') {
      const d = parseISO(anchor);
      d.setMonth(d.getMonth() + dir * 3);
      newAnchor = iso(d);
    } else {
      const d = parseISO(anchor);
      d.setFullYear(d.getFullYear() + dir);
      newAnchor = iso(d);
    }
    setAnchor(newAnchor);
  };

  const ganttData = useMemo(() => {
    if (!tasks.length) return null;
    const days = getDaysInRange(anchor, mode);
    const months = [];
    days.forEach((day, i) => {
      const d = parseISO(day);
      const lbl = `${['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'][d.getMonth()]} ${d.getFullYear()}`;
      if (!months.length || months[months.length-1].label !== lbl) months.push({ label: lbl, from: i, to: i });
      else months[months.length-1].to = i;
    });
    const viewStart = days[0];
    const viewEnd = days[days.length - 1];
    const groups = [];
    const seen = new Set();
    tasks.forEach(t => {
      if (!seen.has(t.projectId)) {
        seen.add(t.projectId);
        const project = db.projects.find(p => p.id === t.projectId);
        if (project && (scope.all || scope.projIds.has(project.id))) {
          groups.push({ project, items: [] });
        }
      }
    });
    groups.forEach(g => {
      const items = tasks
        .filter(t => t.projectId === g.project.id)
        .map(t => {
          let sIdx = days.indexOf(t.start);
          let eIdx = days.indexOf(t.deadline);
          if (sIdx === -1 && eIdx === -1) {
            if (t.start < viewStart && t.deadline > viewEnd) {
              sIdx = 0; eIdx = days.length - 1;
            } else {
              return null;
            }
          }
          if (sIdx === -1 && t.start < viewStart) sIdx = 0;
          if (eIdx === -1 && t.deadline > viewEnd) eIdx = days.length - 1;
          if (sIdx === -1 || eIdx === -1) return null;
          if (sIdx > eIdx) return null;
          return { ...t, sIdx, eIdx };
        })
        .filter(Boolean)
        .sort((a,b) => (a.start < b.start ? -1 : 1));
      g.items = items;
    });
    return { days, months, groups: groups.filter(g => g.items.length > 0) };
  }, [tasks, db.projects, anchor, mode, scope]);

  if (!ganttData || ganttData.groups.length === 0) {
    return (
      <div className="gantt-panel">
        <div className="cal-head" style={{ padding: '12px 16px', borderBottom: '1px solid var(--line)' }}>
          <div className="cal-nav">
            <button className="icon-btn" onClick={() => shift(-1)}><Ic d={ICONS.left} size={16} /></button>
            <div className="cal-title" style={{ minWidth: '120px', fontSize: '15px', fontWeight: 700 }}>{fmtDMY(anchor)}</div>
            <button className="icon-btn" onClick={() => shift(1)}><Ic d={ICONS.right} size={16} /></button>
          </div>
          <div className="cal-right">
            <div className="seg">
              {[['month','Месяц'], ['quarter','Квартал'], ['year','Год']].map(([m,l]) => (
                <button key={m} className={`seg-btn${mode === m ? ' on' : ''}`} onClick={() => setMode(m)}>{l}</button>
              ))}
            </div>
          </div>
        </div>
        <div className="empty-note" style={{ padding: '60px 0' }}>Нет доступных задач в выбранном периоде</div>
      </div>
    );
  }

  const { days, months, groups } = ganttData;
  const todayIdx = days.indexOf(TODAY);
  const width = days.length * DW;
  const totalWidth = width + 240;

  const getDepCoords = (t, depTask, depItem) => {
    if (!depTask || !depItem) return null;
    const tLeft = t.sIdx * DW + DW/2;
    const tRight = t.eIdx * DW + DW/2;
    const depLeft = depItem.sIdx * DW + DW/2;
    const depRight = depItem.eIdx * DW + DW/2;
    switch (t.dependencyType) {
      case 'SS': return { fromX: depLeft, toX: tLeft, fromY: -25, toY: -10 };
      case 'FF': return { fromX: depRight, toX: tRight, fromY: -25, toY: -10 };
      case 'SF': return { fromX: depLeft, toX: tRight, fromY: -25, toY: -10 };
      default: return { fromX: depRight, toX: tLeft, fromY: -25, toY: -10 };
    }
  };

  return (
    <div className="gantt-panel">
      <div className="cal-head" style={{ padding: '12px 16px', borderBottom: '1px solid var(--line)' }}>
        <div className="cal-nav">
          <button className="icon-btn" onClick={() => shift(-1)}><Ic d={ICONS.left} size={16} /></button>
          <div className="cal-title" style={{ minWidth: '120px', fontSize: '15px', fontWeight: 700 }}>{fmtDMY(anchor)}</div>
          <button className="icon-btn" onClick={() => shift(1)}><Ic d={ICONS.right} size={16} /></button>
        </div>
        <div className="cal-right">
          <div className="seg">
            {[['month','Месяц'], ['quarter','Квартал'], ['year','Год']].map(([m,l]) => (
              <button key={m} className={`seg-btn${mode === m ? ' on' : ''}`} onClick={() => setMode(m)}>{l}</button>
            ))}
          </div>
        </div>
      </div>

      <div className="gantt-scroll">
        <div className="gantt" style={{ '--gantt-width': totalWidth + 'px' }}>
          <div className="gantt-top">
            <div className="gantt-corner">Проект / задача</div>
            <div className="gantt-axis" style={{ width }}>
              <div className="gantt-months" style={{ display: 'flex', flexWrap: 'nowrap', width }}>
                {months.map((m, i) => (
                  <div key={i} className="gantt-month" style={{ width: (m.to - m.from + 1) * DW, flex: 'none' }}>
                    {m.label}
                  </div>
                ))}
              </div>
              <div className="gantt-days" style={{ display: 'flex', flexWrap: 'nowrap', width }}>
                {days.map(d => {
                  const dt = parseISO(d);
                  const wk = dt.getDay();
                  return (
                    <div
                      key={d}
                      className={`gday${(wk === 0 || wk === 6) ? ' wk' : ''}${d === TODAY ? ' td' : ''}`}
                      style={{ width: DW, flex: 'none', whiteSpace: 'nowrap', boxSizing: 'border-box' }}
                    >
                      {dt.getDate()}
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
          <div className="gantt-body">
            <div className="gantt-grid" style={{ width, left: 240 }}>
              {days.map(d => <div key={d} className={`gcell${([0,6].includes(parseISO(d).getDay()) ? ' wk' : '')}`} style={{ width: DW, flex: 'none' }} />)}
              {todayIdx >= 0 && <div className="gtoday" style={{ left: todayIdx * DW + DW/2 }} />}
            </div>
            {groups.map(g => {
              const taskDeps = g.items.reduce((acc, t) => {
                if (t.dependencyId) acc[t.id] = db.tasks.find(dt => dt.id === t.dependencyId);
                return acc;
              }, {});
              const projectColor = getProjectColor(g.project);

              return (
                <div key={g.project.id}>
                  <div className="gantt-group">
                    <div 
                      className="gantt-group-name" 
                      style={{ cursor: 'pointer', textDecoration: 'underline' }}
                      onClick={() => openProject && openProject(g.project.id)}
                      title="Открыть проект"
                    >
                      <span className="pdot" style={{ background: projectColor }} />{g.project.code} · {g.project.name}
                    </div>
                    <div style={{ width }} />
                  </div>
                  {g.items.map(t => {
                    const assignee = t.assigneeId ? db.employees.find(e => e.id === t.assigneeId) : null;
                    const left = t.sIdx * DW + 2;
                    const w = Math.max((t.eIdx - t.sIdx + 1) * DW - 4, DW - 8);
                    const sp = getTaskSpent(t);
                    const pct = Math.min(100, (sp / Math.max(1, t.plannedHours || 0)) * 100);
                    const fillWidth = pct > 0 ? Math.max(pct, 2) : 0;
                    const vac = assignee ? vacOverlap(assignee.id, t.start, t.deadline) : null;
                    const tip = `${t.title}: ${fmtD(t.start)} - ${fmtD(t.deadline)}, план ${t.plannedHours ?? '-'} ч${vac ? `. Исполнитель в отпуске ${fmtDMY(vac.start)}–${fmtDMY(vac.end)}` : ''}`;
                    const depTask = taskDeps[t.id];
                    let depLine = null;
                    if (depTask) {
                      const depItem = g.items.find(it => it.id === depTask.id);
                      if (depItem) {
                        const coords = getDepCoords(t, depTask, depItem);
                        if (coords) {
                          depLine = (
                            <svg
                              style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', overflow: 'visible' }}
                            >
                              <path
                                d={`M ${coords.fromX} ${coords.fromY} L ${coords.toX} ${coords.fromY} L ${coords.toX} ${coords.toY}`}
                                fill="none"
                                stroke="#94a3b8"
                                strokeWidth="2"
                                strokeDasharray="4 2"
                                markerEnd="url(#arrowhead)"
                              />
                              <text
                                x={(coords.fromX + coords.toX) / 2}
                                y={coords.fromY - 5}
                                fontSize="10"
                                fill="#64748b"
                                textAnchor="middle"
                              >
                                {t.dependencyType || 'FS'}
                              </text>
                            </svg>
                          );
                        }
                      }
                    }

                    const priorityColor = PRIORITIES[t.priority]?.color || '#64748b';
                    const bgColor = priorityColor + '33';

                    return (
                      <div key={t.id} className="gantt-row" style={{ position: 'relative' }}>
                        {depLine}
                        <div className="gantt-label" onClick={() => openTask(t.id)}>
                          <span className={`gtitle${t.status === 'cancelled' ? ' dim' : ''}`}>{t.title}</span>
                          <span className="gsub">
                            {assignee && <Avatar employee={assignee} size="xs" />} · {t.plannedHours ?? '-'} ч · {TASK_STATUSES[t.status].label}
                          </span>
                        </div>
                        <div className="gantt-track">
                          <div
                            className="gbar"
                            style={{
                              '--bar-left': left + 'px',
                              '--bar-width': w + 'px',
                              '--bar-bg': bgColor,
                              '--bar-opacity': t.status === 'cancelled' ? 0.45 : 1,
                              '--fill-width': fillWidth + '%',
                              '--fill-color': t.status === 'closed' ? '#10b981' : priorityColor
                            }}
                            onClick={() => openTask(t.id)}
                            title={tip}
                          >
                            <div className="gbar-fill" />
                            {vac && <span className="gbar-vac">🏖</span>}
                          </div>
                        </div>
                      </div>
                    );
                  })}
                </div>
              );
            })}
          </div>
        </div>
      </div>
      <div className="gantt-legend" style={{ padding: '8px 16px', borderTop: '1px solid var(--line)' }}>
        <span><span className="lg-dot" style={{ background: '#ef4444' }} /> сегодня</span>
        <span><span className="lg-dot" style={{ background: '#e2e8f0' }} /> выходные</span>
        <span>🏖 - исполнитель в отпуске</span>
        <span>Заполнение полосы - факт / план</span>
      </div>
    </div>
  );
}

src/components/Icons.jsx

import React from 'react';

export const Ic = ({ d, size = 18, w = 1.8 }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={w} strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>
);

export const ICONS = {
  kanban: "M4 4h4v16H4zM10 4h4v10h-4zM16 4h4v7h-4z",
  gantt: "M3 6h8M9 12h10M5 18h7M3 4v16",
  cal: "M4 6h16v14H4zM4 10h16M8 4v4M16 4v4",
  folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
  users: "M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM2.5 20a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 6.6M17.5 14a6.5 6.5 0 0 1 4 6",
  chart: "M4 20V10M10 20V4M16 20v-8M22 20H2",
  clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
  plus: "M12 5v14M5 12h14",
  out: "M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3M16 8l4 4-4 4M20 12H9",
  x: "M6 6l12 12M18 6L6 18",
  left: "M15 6l-6 6 6 6",
  right: "M9 6l6 6-6 6",
  search: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3",
  trash: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6",
  edit: "M4 20l1-4L16.5 4.5a2.1 2.1 0 0 1 3 3L8 19zM13 6l5 5",
  shield: "M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z",
  download: "M12 4v11M7 11l5 5 5-5M4 20h16",
  bell: "M18 9a6 6 0 1 0-12 0c0 5-2 6-2 6h16s-2-1-2-6M10 19a2 2 0 0 0 4 0",
  beach: "M12 3v9M12 12l7 3M12 12L5 15M4 20c2-1.5 4-1.5 6 0 2-1.5 4-1.5 6 0",
  user: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0",
  inbox: "M3 13l3-8h12l3 8v6H3zM3 13h5l1.5 2.5h5L16 13h5",
  book: "M4 5a2 2 0 0 1 2-2h14v18H6a2 2 0 0 0-2 2zM8 3v18",
  swap: "M7 8h13M17 5l3 3-3 3M17 16H4M7 13l-3 3 3 3",
  chat: "M21 12a8 8 0 0 1-8 8H4l2.5-2.5A8 8 0 1 1 21 12z",
  archive: "M3 4h18v4H3zM5 8v12h14V8M10 12h4",
  restore: "M3 12a9 9 0 1 0 3-6.7M3 4v5h5",
  eye: "M1 12s3-7 11-7 11 7 11 7-3 7-11 7-11-7-11-7zM12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z",
  filter: "M4 6h16M6 12h14M8 18h12",
  close: "M6 6l12 12M18 6L6 18",
  lock: "M7 11V7a5 5 0 0 1 10 0v4h1v10H6V11h1zm2 0h6V7a3 3 0 0 0-6 0v4z",
  file: "M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM13 2v7h7",
  list: "M3 6h18M3 12h18M3 18h18",
  tasks: "M3 6h12M3 12h16M3 18h14M7 6l2 2 4-4M7 12l2 2 4-4M7 18l2 2 4-4",
  external: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3",
};

src/components/Journal.jsx

import React, { useState, useMemo } from 'react';
import { fmtDT, fmtDMY } from '../utils/date';
import { useDataHelpers } from '../hooks';
import { Ic, ICONS } from './Icons';

// Безопасная проверка даты
const safeDate = (ts) => {
  const d = new Date(ts);
  return isNaN(d.getTime()) ? null : d;
};

// Правильный парсинг строки "дд.мм.гггг" в объект Date
const parseDayKey = (dayKey) => {
  const parts = dayKey.split('.');
  if (parts.length !== 3) return null;
  const [day, month, year] = parts.map(Number);
  if (!day || !month || !year) return null;
  return new Date(year, month - 1, day);
};

const ACTIONS = [
  { value: 'all', label: 'Все действия' },
  { value: 'Создание задачи', label: 'Создание задачи' },
  { value: 'Изменение задачи', label: 'Изменение задачи' },
  { value: 'Удаление задачи', label: 'Удаление задачи' },
  { value: 'Изменение статуса задачи', label: 'Изменение статуса' },
  { value: 'Создание проекта', label: 'Создание проекта' },
  { value: 'Изменение проекта', label: 'Изменение проекта' },
  { value: 'Удаление проекта', label: 'Удаление проекта' },
  { value: 'Архивация проекта', label: 'Архивация проекта' },
  { value: 'Создание сотрудника', label: 'Создание сотрудника' },
  { value: 'Изменение ролей', label: 'Изменение ролей' },
  { value: 'Изменение подразделений', label: 'Изменение подразделений' },
  { value: 'Увольнение сотрудника', label: 'Увольнение сотрудника' },
  { value: 'Восстановление сотрудника', label: 'Восстановление сотрудника' },
  { value: 'Создание отпуска', label: 'Создание отпуска' },
  { value: 'Изменение отпуска', label: 'Изменение отпуска' },
  { value: 'Удаление отпуска', label: 'Удаление отпуска' },
  { value: 'Запрос изменения часов', label: 'Запрос изменения часов' },
  { value: 'Утверждение запроса часов', label: 'Утверждение запроса часов' },
  { value: 'Отклонение запроса часов', label: 'Отклонение запроса часов' },
  { value: 'Создание делегирования ролей', label: 'Делегирование ролей' },
  { value: 'Принятие делегирования', label: 'Принятие делегирования' },
  { value: 'Отклонение делегирования', label: 'Отклонение делегирования' },
  { value: 'Автоматическая архивация задач', label: 'Автоматическая архивация' },
  { value: 'Утверждение отпуска', label: 'Утверждение отпуска' },
  { value: 'Отклонение отпуска', label: 'Отклонение отпуска' },
  { value: 'Одобрение регистрации', label: 'Одобрение регистрации' },
  { value: 'Отклонение регистрации', label: 'Отклонение регистрации' },
];

export default function Journal({ db }) {
  const { empName } = useDataHelpers(db);
  const [filters, setFilters] = useState({
    dateFrom: '',
    dateTo: '',
    userId: 'all',
    action: 'all',
    search: '',
  });
  const [page, setPage] = useState(1);
  const pageSize = 20;

  const allEntries = useMemo(() => {
    return [...(db.audit || [])].sort((a, b) => b.ts - a.ts);
  }, [db.audit]);

  const userOptions = useMemo(() => {
    const userIds = new Set(allEntries.map(e => e.userId).filter(id => id !== 'system'));
    return [...userIds].map(id => ({ id, name: empName(id) || id }));
  }, [allEntries, empName]);

  const filteredEntries = useMemo(() => {
    return allEntries.filter(entry => {
      const dateObj = safeDate(entry.ts);
      if (!dateObj) return false; // пропускаем записи с некорректной датой
      
      const entryDate = fmtDMY(entry.ts);
      if (filters.dateFrom && entryDate < filters.dateFrom) return false;
      if (filters.dateTo && entryDate > filters.dateTo) return false;
      if (filters.userId !== 'all' && entry.userId !== filters.userId) return false;
      if (filters.action !== 'all' && entry.action !== filters.action) return false;
      if (filters.search.trim()) {
        const q = filters.search.trim().toLowerCase();
        const match = (entry.action || '').toLowerCase().includes(q) ||
                      (entry.details || '').toLowerCase().includes(q);
        if (!match) return false;
      }
      return true;
    });
  }, [allEntries, filters]);

  // Группировка по датам (день/месяц) - используем parseDayKey
  const groupedEntries = useMemo(() => {
    const groups = {};
    filteredEntries.forEach(entry => {
      const d = safeDate(entry.ts);
      if (!d) return; // пропускаем
      
      const dayKey = fmtDMY(entry.ts);
      const monthKey = `${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
      
      if (!groups[monthKey]) groups[monthKey] = {};
      if (!groups[monthKey][dayKey]) groups[monthKey][dayKey] = [];
      groups[monthKey][dayKey].push(entry);
    });
    
    // Сортировка месяцев (от новых к старым)
    const sortedMonths = Object.keys(groups).sort((a, b) => {
      const [m1, y1] = a.split('.').map(Number);
      const [m2, y2] = b.split('.').map(Number);
      return y2 - y1 || m2 - m1;
    });
    
    const result = [];
    sortedMonths.forEach(month => {
      const days = Object.keys(groups[month]).sort((a, b) => {
        const [d1, m1, y1] = a.split('.').map(Number);
        const [d2, m2, y2] = b.split('.').map(Number);
        return y2 - y1 || m2 - m1 || d2 - d1;
      });
      days.forEach(day => {
        result.push({ type: 'day', date: day, entries: groups[month][day] });
      });
    });
    return result;
  }, [filteredEntries]);

  // Плоский список для пагинации (без дубликатов)
  const flatEntries = useMemo(() => {
    const result = [];
    let lastMonth = null;
    
    groupedEntries.forEach(group => {
      const monthDate = parseDayKey(group.date);
      const monthLabel = monthDate && !isNaN(monthDate.getTime()) 
        ? monthDate.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' }) 
        : 'Неизвестный месяц';
      
      group.entries.forEach((entry, idx) => {
        const isFirstInDay = idx === 0;
        const dayObj = safeDate(entry.ts);
        const dayLabel = isFirstInDay && dayObj 
          ? dayObj.toLocaleDateString('ru-RU', { 
              weekday: 'long', 
              day: 'numeric' 
            }) 
          : null;
        
        result.push({
          ...entry,
          _monthLabel: lastMonth !== monthLabel ? monthLabel : null,
          _dayLabel: dayLabel,
        });
        
        if (lastMonth !== monthLabel) {
          lastMonth = monthLabel;
        }
      });
    });
    
    return result;
  }, [groupedEntries]);

  const paginatedFlat = useMemo(() => {
    return flatEntries.slice((page - 1) * pageSize, page * pageSize);
  }, [flatEntries, page]);

  const totalEntries = filteredEntries.length;
  const totalPages = Math.ceil(totalEntries / pageSize);

  const handleFilterChange = (key, value) => {
    setFilters(prev => ({ ...prev, [key]: value }));
    setPage(1);
  };

  const renderDetails = (entry) => {
    if (!entry.details) return null;
    try {
      const obj = typeof entry.details === 'string' ? JSON.parse(entry.details) : entry.details;
      if (typeof obj === 'object') {
        return (
          <div style={{ fontSize: '12px', color: 'var(--mut)', marginTop: '2px' }}>
            {Object.entries(obj).map(([key, val]) => (
              <div key={key}><b>{key}:</b> {String(val)}</div>
            ))}
          </div>
        );
      }
      return <span className="mut sm">{String(entry.details)}</span>;
    } catch {
      return <span className="mut sm">{String(entry.details)}</span>;
    }
  };

  const exportToCSV = () => {
    const headers = ['Дата', 'Время', 'Пользователь', 'Действие', 'Детали'];
    const rows = filteredEntries.map(e => {
      const d = safeDate(e.ts);
      const date = d ? fmtDMY(e.ts) : 'неизвестно';
      const time = d ? d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : '-';
      const user = e.userId === 'system' ? 'Система' : empName(e.userId) || e.userId;
      const details = typeof e.details === 'string' ? e.details.replace(/"/g, '""') : '';
      return [date, time, user, e.action, `"${details}"`].join(';');
    });
    const csv = [headers.join(';'), ...rows].join('\n');
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = `journal_${filters.dateFrom || 'start'}_${filters.dateTo || 'end'}.csv`;
    link.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div className="rep">
      <div className="rep-panel" style={{ padding: '16px' }}>
        <div className="rep-panel-title">Фильтры журнала</div>
        <div className="toolbar" style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
          <label className="lbl" style={{ margin: 0 }}>С даты:</label>
          <input
            className="inp"
            type="date"
            value={filters.dateFrom}
            onChange={e => handleFilterChange('dateFrom', e.target.value)}
            style={{ width: '150px' }}
          />
          <span>-</span>
          <input
            className="inp"
            type="date"
            value={filters.dateTo}
            onChange={e => handleFilterChange('dateTo', e.target.value)}
            style={{ width: '150px' }}
          />

          <label className="lbl" style={{ margin: 0 }}>Пользователь:</label>
          <select
            className="inp sel"
            value={filters.userId}
            onChange={e => handleFilterChange('userId', e.target.value)}
            style={{ width: '180px' }}
          >
            <option value="all">Все</option>
            {userOptions.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
          </select>

          <label className="lbl" style={{ margin: 0 }}>Действие:</label>
          <select
            className="inp sel"
            value={filters.action}
            onChange={e => handleFilterChange('action', e.target.value)}
            style={{ width: '200px' }}
          >
            {ACTIONS.map(a => <option key={a.value} value={a.value}>{a.label}</option>)}
          </select>

          <div className="search-box" style={{ flex: 1, minWidth: '200px' }}>
            <Ic d={ICONS.search} size={15} />
            <input
              placeholder="Поиск по действию или деталям..."
              value={filters.search}
              onChange={e => handleFilterChange('search', e.target.value)}
            />
          </div>
        </div>
      </div>

      <div className="rep-panel" style={{ padding: '16px' }}>
        <div className="rep-panel-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <span>
            Всего записей: {filteredEntries.length}
            <span className="mut sm" style={{ marginLeft: '12px', fontWeight: 'normal' }}>
              (показано {paginatedFlat.length})
            </span>
          </span>
          <button className="btn primary sm" onClick={exportToCSV}>
            Выгрузить CSV
          </button>
        </div>
        
        <div style={{ marginTop: '12px' }}>
          {paginatedFlat.length === 0 ? (
            <div className="mut" style={{ textAlign: 'center', padding: '40px 20px' }}>
              <div style={{ fontSize: '48px', marginBottom: '12px', opacity: 0.3 }}>📋</div>
              <div style={{ fontSize: '15px', fontWeight: 600, marginBottom: '4px' }}>Записей не найдено</div>
              <div className="mut sm">Измените параметры фильтра или выберите другой период</div>
            </div>
          ) : (
            <table className="tbl" style={{ fontSize: '13px' }}>
              <thead>
                <tr>
                  <th style={{ width: '160px', color: 'var(--mut)', textTransform: 'uppercase', fontSize: '11px', letterSpacing: '0.5px' }}>
                    Дата и время
                  </th>
                  <th style={{ width: '180px', color: 'var(--mut)', textTransform: 'uppercase', fontSize: '11px', letterSpacing: '0.5px' }}>
                    Пользователь
                  </th>
                  <th style={{ color: 'var(--mut)', textTransform: 'uppercase', fontSize: '11px', letterSpacing: '0.5px' }}>
                    Действие
                  </th>
                  <th style={{ color: 'var(--mut)', textTransform: 'uppercase', fontSize: '11px', letterSpacing: '0.5px' }}>
                    Детали
                  </th>
                </tr>
              </thead>
              <tbody>
                {paginatedFlat.map((entry, index) => {
                  const showMonth = entry._monthLabel !== null;
                  const showDay = entry._dayLabel !== null;
                  
                  return (
                    <React.Fragment key={entry.id}>
                      {showMonth && (
                        <tr>
                          <td colSpan="4" style={{ 
                            background: 'linear-gradient(90deg, #f8fafc, transparent)', 
                            padding: '12px 10px 8px',
                            borderTop: index === 0 ? 'none' : '2px solid var(--line)',
                          }}>
                            <div style={{ 
                              fontSize: '12px', 
                              fontWeight: 800, 
                              color: '#1e293b',
                              textTransform: 'capitalize',
                              letterSpacing: '0.3px',
                            }}>
                              {entry._monthLabel}
                            </div>
                          </td>
                        </tr>
                      )}
                      {showDay && (
                        <tr>
                          <td colSpan="4" style={{ 
                            padding: '8px 10px 6px',
                            borderTop: '1px dashed #e2e8f0',
                          }}>
                            <div style={{ 
                              fontSize: '11px', 
                              fontWeight: 700, 
                              color: 'var(--acc)',
                              textTransform: 'capitalize',
                              letterSpacing: '0.3px',
                            }}>
                              {entry._dayLabel}
                            </div>
                          </td>
                        </tr>
                      )}
                      <tr style={{ 
                        background: showDay ? '#fafbff' : 'transparent',
                        transition: 'background 0.15s',
                      }}
                      onMouseEnter={(e) => e.currentTarget.style.background = showDay ? '#f0f7ff' : '#f8fafc'}
                      onMouseLeave={(e) => e.currentTarget.style.background = showDay ? '#fafbff' : 'transparent'}
                      >
                        <td style={{ 
                          whiteSpace: 'nowrap', 
                          fontSize: '12px',
                          color: 'var(--mut)',
                          fontFamily: 'monospace',
                        }}>
                          {safeDate(entry.ts) ? fmtDT(entry.ts) : '-'}
                        </td>
                        <td>
                          <div style={{ 
                            display: 'flex', 
                            alignItems: 'center', 
                            gap: '8px',
                            fontWeight: 600,
                            fontSize: '13px',
                          }}>
                            <div className="avatar xs" style={{ 
                              background: entry.userId === 'system' 
                                ? 'linear-gradient(135deg, #64748b, #475569)' 
                                : 'linear-gradient(135deg, #1e3a8a, #0ea5e9)',
                              width: '24px',
                              height: '24px',
                              fontSize: '9px',
                            }}>
                              {entry.userId === 'system' ? 'S' : (empName(entry.userId) || entry.userId).charAt(0).toUpperCase()}
                            </div>
                            {entry.userId === 'system' ? 'Система' : empName(entry.userId) || entry.userId}
                          </div>
                        </td>
                        <td>
                          <div style={{ 
                            fontWeight: 600, 
                            fontSize: '13px',
                            color: '#1e293b',
                          }}>
                            {entry.action}
                          </div>
                        </td>
                        <td style={{ maxWidth: '400px' }}>
                          {renderDetails(entry)}
                        </td>
                      </tr>
                    </React.Fragment>
                  );
                })}
              </tbody>
            </table>
          )}
        </div>

        {totalPages > 1 && (
          <div style={{ 
            display: 'flex', 
            justifyContent: 'center', 
            alignItems: 'center',
            gap: '12px', 
            marginTop: '20px',
            paddingTop: '16px',
            borderTop: '1px solid var(--line)',
          }}>
            <button
              className="btn ghost sm"
              disabled={page === 1}
              onClick={() => setPage(p => Math.max(1, p - 1))}
              style={{ 
                minWidth: '36px',
                height: '36px',
                padding: '0',
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
              }}
            >
              ←
            </button>
            <span style={{ 
              fontSize: '13px', 
              color: 'var(--mut)',
              fontWeight: 500,
            }}>
              Страница <b style={{ color: 'var(--txt)' }}>{page}</b> из <b style={{ color: 'var(--txt)' }}>{totalPages}</b>
            </span>
            <button
              className="btn ghost sm"
              disabled={page === totalPages}
              onClick={() => setPage(p => Math.min(totalPages, p + 1))}
              style={{ 
                minWidth: '36px',
                height: '36px',
                padding: '0',
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
              }}
            >
              →
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

src/components/Kanban.jsx

import React, { useState } from 'react';
import { Ic, ICONS } from './Icons';

export default function Kanban({
  items,
  statusOrder,
  statusMap,
  renderCard,
  onDrop,
  onNew,
  searchQuery,
  onSearchChange,
  showOnlyMy,
  onToggleMy,
  extraFilters,
  emptyMessage = 'Нет элементов',
  columns, // новый пропс
}) {
  const [dragOverCol, setDragOverCol] = useState(null);

  // Определяем классы и стили
  const kanbanClasses = ['kanban'];
  const style = {};

  if (columns) {
    // Если задано явное количество колонок – устанавливаем CSS-переменную
    style['--columns'] = columns;
    // Не добавляем класс k5, так как переменная перекроет его
  } else {
    // Иначе – старое поведение: если статусов 5, добавляем класс k5
    if (statusOrder.length === 5) {
      kanbanClasses.push('k5');
    }
    // Если статусов не 5, то используем стандартный стиль (4 колонки)
    // Без переменной --columns будет использовано значение по умолчанию из CSS
  }

  return (
    <div>
      {/* Панель инструментов (без изменений) */}
      <div className="toolbar">
        {onSearchChange && (
          <div className="search-box">
            <Ic d={ICONS.search} size={15} />
            <input
              placeholder="Поиск…"
              value={searchQuery}
              onChange={(e) => onSearchChange(e.target.value)}
            />
          </div>
        )}
        {extraFilters}
        {onToggleMy && (
          <label className="dept-pick" style={{ marginLeft: 8 }}>
            <input
              type="checkbox"
              checked={showOnlyMy}
              onChange={(e) => onToggleMy(e.target.checked)}
            />
            <span style={{ fontSize: 13 }}>Только мои</span>
          </label>
        )}
        {onNew && (
          <button className="btn primary" onClick={onNew}>
            <Ic d={ICONS.plus} size={15} /> Создать
          </button>
        )}
      </div>

      <div className={kanbanClasses.join(' ')} style={style}>
        {statusOrder.map((st) => {
          const list = items.filter((item) => item.status === st);
          return (
            <div
              key={st}
              className={`kcol${dragOverCol === st ? ' over' : ''}`}
              onDragOver={(e) => {
                e.preventDefault();
                setDragOverCol(st);
              }}
              onDragLeave={() => setDragOverCol(null)}
              onDrop={(e) => {
                e.preventDefault();
                setDragOverCol(null);
                const id = e.dataTransfer.getData('text/plain');
                if (id) {
                  onDrop(id, st);
                }
              }}
            >
              <div className="kcol-head">
                <span className="kdot" style={{ background: statusMap[st].color }} />
                {statusMap[st].label}
                <span className="kcount">{list.length}</span>
              </div>
              <div className="kcol-body">
                {list.length === 0 ? (
                  <div className="kempty">{emptyMessage}</div>
                ) : (
                  list.map((item) => (
                    <div
                      key={item.id}
                      className="kcard"
                      draggable
                      onDragStart={(e) => e.dataTransfer.setData('text/plain', item.id)}
                    >
                      {renderCard(item)}
                    </div>
                  ))
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

src/components/LoginScreen.jsx

import React, { useState, useRef } from 'react';
import { DOMAIN } from '../utils/constants';
import { uid } from '../utils/date';
import { Ic, ICONS } from './Icons';

function passIssues(p) {
  return [
    { ok: p.length >= 8, t: "Минимум 8 символов" },
    { ok: /[A-ZА-ЯЁ]/.test(p), t: "Заглавная буква" },
    { ok: /[a-zа-яё]/.test(p), t: "Строчная буква" },
    { ok: /\d/.test(p), t: "Цифра" },
    { ok: /[^A-Za-zА-Яа-яЁё0-9]/.test(p), t: "Специальный символ" },
  ];
}

export default function LoginScreen({ db, setDb, onLogin, toast }) {
  const [mode, setMode] = useState("login");
  const [lg, setLg] = useState("");
  const [pw, setPw] = useState("");
  const [showPass, setShowPass] = useState(false);
  const [passTimer, setPassTimer] = useState(null);
  const passTimerRef = useRef(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);
  const [shake, setShake] = useState(false);
  const [reg, setReg] = useState({ first: "", last: "", email: "", pass: "", pass2: "" });
  const [forgot, setForgot] = useState("");

  const [showPassword, setShowPassword] = useState(false);
  const passwordTimerRef = useRef(null);

  const [showRegPass, setShowRegPass] = useState(false);
  const regPassTimerRef = useRef(null);

  const fail = (m) => { setErr(m); setShake(true); setTimeout(() => setShake(false), 450); };
  const doLogin = (loginVal, passVal) => {
    setBusy(true);
    setErr(null);
    setTimeout(() => {
      const r = onLogin(loginVal, passVal);
      if (r) fail(r);
      setBusy(false);
    }, 30);
  };

  const togglePasswordVisibility = () => {
    if (showPassword) {
      setShowPassword(false);
      if (passwordTimerRef.current) {
        clearTimeout(passwordTimerRef.current);
        passwordTimerRef.current = null;
      }
    } else {
      setShowPassword(true);
      if (passwordTimerRef.current) clearTimeout(passwordTimerRef.current);
      passwordTimerRef.current = setTimeout(() => {
        setShowPassword(false);
        passwordTimerRef.current = null;
      }, 10000);
    }
  };

  const toggleRegPassVisibility = () => {
    if (showRegPass) {
      setShowRegPass(false);
      if (regPassTimerRef.current) {
        clearTimeout(regPassTimerRef.current);
        regPassTimerRef.current = null;
      }
    } else {
      setShowRegPass(true);
      if (regPassTimerRef.current) clearTimeout(regPassTimerRef.current);
      regPassTimerRef.current = setTimeout(() => {
        setShowRegPass(false);
        regPassTimerRef.current = null;
      }, 10000);
    }
  };

  const submit = (e) => {
    if (e && e.preventDefault) e.preventDefault();
    try {
      if (mode === "login") { doLogin(lg, pw); return; }
      if (mode === "register") {
        if (!reg.first.trim() || !reg.last.trim() || !reg.email.trim()) return fail("Заполните все обязательные поля");
        if (db.employees.some((x) => x.email.toLowerCase() === reg.email.trim().toLowerCase()) || db.regRequests.some((x) => x.email.toLowerCase() === reg.email.trim().toLowerCase())) return fail("Такой e-mail уже зарегистрирован");
        if (passIssues(reg.pass).some((i) => !i.ok)) return fail("Пароль не соответствует требованиям безопасности");
        if (reg.pass !== reg.pass2) return fail("Пароли не совпадают");

        const newEmployee = {
          id: "e_" + uid(),
          last: reg.last.trim(),
          first: reg.first.trim(),
          email: reg.email.trim().toLowerCase(),
          pass: reg.pass,
          position: "Сотрудник",
          departments: [],
          roles: ["executor"],
          kbIds: [],
          headDeptIds: [],
          phone: "",
          extension: "",
          tab: String(1000 + Math.floor(Math.random() * 8999)),
          notif: { deadlineEmail: true, overdueDigest: false, commentSub: true },
          failed: 0,
          lockUntil: 0,
          fired: false,
          photo: null
        };
        setDb((s) => ({
          ...s,
          employees: [...s.employees, newEmployee],
          notifications: [
            { id: uid(), userId: "sergey.adminov", text: `Новая регистрация: ${newEmployee.last} ${newEmployee.first}`, ts: Date.now(), read: false, targetType: null, targetId: null },
            ...s.notifications
          ]
        }));
        toast("Регистрация успешна! Выполняется вход...");
        const loginOk = onLogin(reg.email.trim().toLowerCase(), reg.pass);
        if (!loginOk) {
          fail("Ошибка автоматического входа после регистрации.");
        }
        setReg({ first: "", last: "", email: "", pass: "", pass2: "" });
        setMode("login");
        return;
      }
      if (mode === "forgot") {
        if (!forgot.trim()) return fail("Укажите e-mail");
        toast("Ссылка для восстановления пароля отправлена на " + forgot.trim() + "@" + DOMAIN + " (действует 1 час). Заглушка.");
        setMode("login");
      }
    } catch (ex) {
      fail("Внутренняя ошибка: " + (ex && ex.message ? ex.message : ex));
    }
  };
  const issues = passIssues(reg.pass);

  const demos = [
    { l: "sergey.adminov", p: "Admin2026!", t: "Суперадминистратор" },
    { l: "aleksey.gendirov", p: "Director2026!", t: "Генеральный директор" },
    { l: "erik.ekonomistov", p: "Econ2026!", t: "Главный экономист" },
    { l: "ivan.konstruktorov", p: "KbLa2026!", t: "Гл. конструктор КБ «ЛА»" },
    { l: "olga.personalova", p: "Hr2026!", t: "HR-менеджер" },
    { l: "mikhail.otdelov", p: "Head2026!", t: "Руководитель отделов" },
    { l: "nikolay.managerov", p: "Pm2026!", t: "Менеджер проектов" },
    { l: "kirill.proektov", p: "Pm2026!", t: "Ответственный по проекту" },
    { l: "isaev", p: "Exec2026!", t: "Исполнитель" }
  ];

  return (
    <div className="login-wrap">
      <div className="login-hero">
        <div className="logo lg"><div className="logo-mark">АП</div><div><div className="logo-name">АвиаГоризонт</div><div className="logo-sub">планирование и учёт времени</div></div></div>
        <h2>Единая среда планирования КБ</h2>
        <p>Канбан, диаграмма Ганта и календарь. Производственные проекты двух типов, административные проекты, задачи с подзадачами бесконечной вложенности, отпуска с делегированием, HR-администрирование и журнал аудита.</p>
        <ul className="hero-list">
          <li>9 ролей, включая HR-менеджера; временное делегирование полномочий</li>
          <li>Архив закрытых задач и проектов при попадании в завершенные или отмененные</li>
          <li>Комментарии с ветками ответов и @упоминаниями участников в задачах и проектах</li>
        </ul>
        <div className="hero-stack">React · Vite · Node.js · PostgreSQL · Ubuntu LTS · ООП/KISS/DRY</div>
      </div>
      <div className="login-panel">
        <form className={"login-card" + (shake ? " shake" : "")} onSubmit={submit}>
          {mode !== "register" && (
            <>
              <h3>{mode === "forgot" ? "Восстановление пароля" : "Вход в систему"}</h3>
              <div className="login-sub">{mode === "forgot" ? "Ссылка будет отправлена на зарегистрированный e-mail" : "Логин - e-mail без домена " + "@" + DOMAIN}</div>
              {mode === "forgot" ? (
                <>
                  <label className="lbl">E-mail</label>
                  <div className="email-inp"><input className="inp" value={forgot} onChange={(e) => { setForgot(e.target.value); setErr(null); }} placeholder="ivanov" autoFocus /><span className="email-dom">{"@" + DOMAIN}</span></div>
                </>
              ) : (
                <>
                  <label className="lbl">Логин (e-mail)</label>
                  <div className="email-inp"><input className="inp" value={lg} onChange={(e) => { setLg(e.target.value); setErr(null); }} placeholder="ivanov" autoFocus /><span className="email-dom">{"@" + DOMAIN}</span></div>
                  <label className="lbl">Пароль</label>
                  <div style={{ position: 'relative' }}>
                    <input
                      className="inp"
                      type={showPassword ? "text" : "password"}
                      value={pw}
                      onChange={(e) => { setPw(e.target.value); setErr(null); }}
                      placeholder="с учётом регистра"
                    />
                    <button
                      type="button"
                      onClick={togglePasswordVisibility}
                      className="pass-toggle-btn"
                    >
                      <Ic d={ICONS.eye} size={18} />
                    </button>
                  </div>
                </>
              )}
            </>
          )}

          {mode === "register" && (
            <>
              <h3>Регистрация сотрудника</h3>
              <div className="login-sub">После регистрации вы автоматически войдёте с ролью «Исполнитель». Суперадминистратор получит уведомление.</div>
              <div className="reg-row">
                <div><label className="lbl">Имя *</label><input className="inp" value={reg.first} onChange={(e) => setReg({ ...reg, first: e.target.value })} /></div>
                <div><label className="lbl">Фамилия *</label><input className="inp" value={reg.last} onChange={(e) => setReg({ ...reg, last: e.target.value })} /></div>
              </div>
              <label className="lbl">E-mail *</label>
              <div className="email-inp"><input className="inp" value={reg.email} onChange={(e) => setReg({ ...reg, email: e.target.value })} placeholder="ivanov" /><span className="email-dom">{"@" + DOMAIN}</span></div>
              <label className="lbl">Пароль *</label>
              <div style={{ position: 'relative' }}>
                <input
                  className="inp"
                  type={showRegPass ? "text" : "password"}
                  value={reg.pass}
                  onChange={(e) => setReg({ ...reg, pass: e.target.value })}
                />
                <button
                  type="button"
                  onClick={toggleRegPassVisibility}
                  className="pass-toggle-btn"
                >
                  <Ic d={ICONS.eye} size={18} />
                </button>
              </div>
              <div className="pass-checks">{issues.map((i) => <span key={i.t} className={i.ok ? "ok" : ""}>✓ {i.t}</span>)}</div>
              <label className="lbl">Подтверждение пароля *</label>
              <input className="inp" type="password" value={reg.pass2} onChange={(e) => setReg({ ...reg, pass2: e.target.value })} />
            </>
          )}

          {err && <div className="login-err">{err}</div>}
          <button className="btn primary big" type="submit" disabled={busy}>{busy ? "Выполняется вход…" : mode === "login" ? "Войти" : mode === "register" ? "Зарегистрироваться" : "Отправить ссылку"}</button>

          {mode === "login" && (
            <>
              <div className="login-links">
                <button type="button" className="link" onClick={() => { setMode("forgot"); setErr(null); }}>Забыли пароль?</button>
                <button type="button" className="link" onClick={() => { setMode("register"); setErr(null); }}>Регистрация</button>
              </div>
              <div className="cookie-note">Сессия хранится в cookie 30 дней (HttpOnly, Secure, SameSite=Lax - на стороне сервера).</div>
              <div className="demo-title">Демо-доступы - клик сразу выполняет вход</div>
              <div className="demo-grid">
                {demos.map((d) => (
                  <button type="button" key={d.l} className="demo-chip" onClick={() => { setLg(d.l); setPw(d.p); setErr(null); doLogin(d.l, d.p); }}>
                    <span className="demo-login">{d.l}</span><span className="demo-role">{d.t}</span>
                  </button>
                ))}
              </div>
            </>
          )}

          {mode !== "login" && <div className="login-links"><button type="button" className="link" onClick={() => { setMode("login"); setErr(null); }}>← Назад ко входу</button></div>}
        </form>
      </div>
    </div>
  );
}

src/components/MainLayout.jsx

import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useAuth, useStore } from '../hooks';
import { hasRole, canExport, computeScope } from '../utils/permissions';
import { ICONS, Ic } from './Icons';
import { initials } from '../utils/date';
import NotifPanel from './NotifPanel';
import { useModals } from '../hooks/useModals';
import ModalRenderer from './ModalRenderer';
import * as Views from './views';
import Calendar from './Calendar';
import Gantt from './Gantt';

export default function MainLayout({ store, data, user }) {
  const { logout } = useStore();
  const [view, setView] = useState('kanban');
  const [notifOpen, setNotifOpen] = useState(false);
  const notifRef = useRef(null);

  const {
    modal,
    openTask,
    openProject,
    openHoursReq,
    openRoles,
    openDepts,
    openVacation,
    openDelegation,
    openVacNow,
    closeModal,
  } = useModals({ store, data, user });

  const scope = useMemo(() => computeScope(user, data), [user, data]);

  const navItems = [
    { id: 'kanban', label: 'Задачи', icon: ICONS.tasks },
    { id: 'gantt', label: 'Диаграмма Ганта', icon: ICONS.gantt },
    { id: 'calendar', label: 'Календарь', icon: ICONS.cal },
    { id: 'projects', label: 'Проекты', icon: ICONS.folder },
    { id: 'staff', label: 'Персонал', icon: ICONS.users },
    ...(canExport(user) || hasRole(user, 'kb_chief', 'head', 'project_lead', 'hr')
      ? [{ id: 'reports', label: 'Отчёты', icon: ICONS.chart }]
      : []),
    { id: 'archive', label: 'Архив', icon: ICONS.archive },
    { id: 'requests', label: 'Запросы и заявки', icon: ICONS.inbox },
    ...(hasRole(user, 'admin', 'director') ? [{ id: 'journal', label: 'Журнал аудита', icon: ICONS.book }] : []),
  ];

  const myNotifs = data.notifications.filter((n) => n.userId === user.id);
  const unread = myNotifs.filter((n) => !n.read).length;

  const handleNotificationNavigate = (notification) => {
    const { targetType, targetId } = notification;
    if (!targetType || !targetId) return;
    store.markNotificationRead(notification.id);
    switch (targetType) {
      case 'task':
        openTask(targetId, 'chat');
        break;
      case 'project':
        openProject(targetId);
        break;
      case 'hours':
        setView('requests');
        break;
      case 'vacation':
        openVacation(targetId);
        break;
      case 'delegation':
        setView('requests');
        break;
      case 'registration':
        setView('requests');
        break;
      default:
        break;
    }
    setNotifOpen(false);
  };

  useEffect(() => {
    const handleClickOutside = (e) => {
      if (notifOpen && notifRef.current && !notifRef.current.contains(e.target)) {
        setNotifOpen(false);
      }
    };
    const handleEsc = (e) => {
      if (e.key === 'Escape' && notifOpen) setNotifOpen(false);
    };
    document.addEventListener('mousedown', handleClickOutside);
    document.addEventListener('keydown', handleEsc);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
      document.removeEventListener('keydown', handleEsc);
    };
  }, [notifOpen]);

  const renderView = () => {
    const commonProps = { db: data, ur: user, openTask, openProject, store };
    switch (view) {
      case 'kanban':
        return <Views.KanbanView {...commonProps} />;
      case 'gantt':
        return <Gantt {...commonProps} />;
      case 'calendar':
        return <Calendar {...commonProps} />;
      case 'projects':
        return <Views.ProjectsView {...commonProps} openHoursReq={openHoursReq} />;
      case 'cabinet':
        return (
          <Views.CabinetView
            store={store}
            data={data}
            user={user}
            openTask={openTask}
            openVacation={openVacation}
            openDelegation={openDelegation}
          />
        );
      case 'staff':
        return (
          <Views.StaffView
            db={data}
            ur={user}
            setDb={(fn) => { store._data = fn(store._data); store._notify(); }}
            openRoles={openRoles}
            openDepts={openDepts}
            openVacation={openVacation}
          />
        );
      case 'reports':
        return <Views.ReportsView db={data} ur={user} />;
      case 'archive':
        return (
          <Views.ArchiveView
            db={data}
            ur={user}
            openTask={openTask}
            openProject={openProject}
            setArchiveMonths={(m) => { store._data.settings.archiveMonths = m; store._notify(); }}
            store={store}
          />
        );
      case 'requests':
        return (
          <Views.RequestsView
            db={data}
            setDb={(fn) => { store._data = fn(store._data); store._notify(); }}
            ur={user}
            addAudit={store.addAudit.bind(store)}
            notify={store.addNotification.bind(store)}
          />
        );
      case 'journal':
        return <Views.JournalView db={data} ur={user} />;
      default:
        return null;
    }
  };

  return (
    <div className="shell">
      <aside className="sidebar">
        <div className="logo">
          <div className="logo-mark">АП</div>
          <div>
            <div className="logo-name">АвиаГоризонт</div>
            <div className="logo-sub">планирование и учёт времени</div>
          </div>
        </div>
        <div
          className="user-card"
          onClick={() => setView('cabinet')}
          style={{ cursor: 'pointer', marginBottom: '16px' }}
        >
          <div className="avatar">
            {user.photo ? (
              <img
                src={user.photo}
                alt="Аватар"
                style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
              />
            ) : (
              initials(user.first, user.last)
            )}
          </div>
          <div className="user-meta">
            <div className="user-name">{user.last} {user.first}</div>
            <div className="user-roles">{user.roles.join(' · ')}</div>
          </div>
          <button
            className="icon-btn dark"
            onClick={(e) => {
              e.stopPropagation();
              logout();
            }}
            title="Выйти"
          >
            <Ic d={ICONS.out} size={16} />
          </button>
        </div>
        <nav className="nav">
          {navItems.map((n) => (
            <button
              key={n.id}
              className={`nav-item${view === n.id ? ' on' : ''}`}
              onClick={() => setView(n.id)}
            >
              <Ic d={n.icon} /> <span className="nav-lbl">{n.label}</span>
            </button>
          ))}
        </nav>
        <div className="side-foot">
          <div className="env-badge">
            <Ic d={ICONS.shield} size={14} /> Демо · заглушка Java/PostgreSQL
          </div>
        </div>
      </aside>

      <main className="main">
        <header className="topbar">
          <div>
            <h1 className="page-title">
              {navItems.find((n) => n.id === view)?.label || 'Личный кабинет'}
            </h1>
            <div className="page-sub">
              Вторник, 4 августа 2026 · вы вошли как {user.last} {user.first}
            </div>
          </div>
          <div className="top-tools">
            <button className="btn ghost" onClick={openVacNow}>
              <Ic d={ICONS.beach} size={15} /> Сотрудники в отпусках
            </button>
            <div className="bell-wrap" ref={notifRef}>
              <button
                className={`icon-btn bell${unread ? ' has' : ''}`}
                onClick={() => setNotifOpen((v) => !v)}
              >
                <Ic d={ICONS.bell} size={17} />
                {unread > 0 && <span className="bell-count">{unread}</span>}
              </button>
              {notifOpen && (
                <NotifPanel
                  list={myNotifs}
                  setDb={(fn) => { store._data = fn(store._data); store._notify(); }}
                  onNavigate={handleNotificationNavigate}
                  onClose={() => setNotifOpen(false)}
                />
              )}
            </div>
          </div>
        </header>

        <div className="content">{renderView()}</div>
      </main>

      {modal && (
        <ModalRenderer
          modal={modal}
          onClose={closeModal}
          db={data}
          ur={user}
          store={store}
          openTask={openTask}
          openProject={openProject}
          openHoursReq={openHoursReq}
          openRoles={openRoles}
          openDepts={openDepts}
          openVacation={openVacation}
          openDelegation={openDelegation}
        />
      )}
    </div>
  );
}

src/components/Modal.jsx

import React, { useEffect } from 'react';
import { Ic, ICONS } from './Icons';

export const Modal = ({ title, onClose, children, width = 640 }) => {
  useEffect(() => {
    const handler = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [onClose]);

  return (
    <div className="overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="modal" style={{ maxWidth: width }}>
        <div className="modal-head">
          <h3>{title}</h3>
          <button className="icon-btn" onClick={onClose}><Ic d={ICONS.x} size={16} /></button>
        </div>
        <div className="modal-body">{children}</div>
      </div>
    </div>
  );
};

src/components/ModalRenderer.jsx

// src/components/ModalRenderer.jsx
import React from 'react';
import {
  TaskModal,
  ProjectModal,
  HoursRequestModal,
  RolesModal,
  DeptsModal,
  VacationModal,
  DelegationModal,
  VacNowModal,
} from './Modals';
import { TASK_STATUSES, PROJECT_STATUSES, ROLES } from '../utils/constants';
import { fmtDMY } from '../utils/date';
import { hasRole } from '../utils/permissions';
import { useDataHelpers } from '../hooks';

export default function ModalRenderer({
  modal,
  onClose,
  db,
  ur,
  store,
  openTask,
  openProject,
  openHoursReq,
  openRoles,
  openDepts,
  openVacation,
  openDelegation,
}) {
  const { empName } = useDataHelpers(db);

  if (!modal) return null;

  switch (modal.type) {
    case 'task':
      return (
        <TaskModal
          db={db}
          ur={ur}
          taskId={modal.taskId}
          initialTab={modal.initialTab || 'form'}
          parentTaskId={modal.parentTaskId}
          initialProjectId={modal.initialProjectId} // <--- ДОБАВЛЕНО
          onClose={onClose}
          onSave={(task, isNew) => {
            const old = db.tasks.find(x => x.id === task.id);
            if (old && hasRole(ur, 'admin')) {
              const changes = {};
              if (old.plannedHours !== task.plannedHours) changes.plannedHours = `${old.plannedHours ?? '-'} → ${task.plannedHours ?? '-'}`;
              if (old.status !== task.status) changes.status = `${TASK_STATUSES[old.status].label} → ${TASK_STATUSES[task.status].label}`;
              if (JSON.stringify(old.assigneeIds || []) !== JSON.stringify(task.assigneeIds || []))
                changes.assignees = `${(old.assigneeIds || []).map(id => empName(id)).join(', ')} → ${(task.assigneeIds || []).map(id => empName(id)).join(', ')}`;
              if (old.deadline !== task.deadline) changes.deadline = `${old.deadline ? fmtDMY(old.deadline) : '-'} → ${task.deadline ? fmtDMY(task.deadline) : '-'}`;
              if (Object.keys(changes).length) {
                store.addAudit('Административное изменение задачи (прямое)', changes, 'task', task.id);
              }
            }
            store.upsertTask(task);
            if (isNew) {
              (task.assigneeIds || []).forEach(id => {
                if (id !== ur.id) {
                  store.addNotification(id, `Вам назначена задача "${task.title}" (проект ${db.projects.find(p => p.id === task.projectId)?.code || '-'}).`, { targetType: 'task', targetId: task.id });
                }
              });
            }
            onClose();
          }}
          onDelete={(id) => {
            const task = db.tasks.find(t => t.id === id);
            store.deleteTask(id);
            store.addAudit('Удаление задачи', { title: task?.title }, 'task', id);
            onClose();
          }}
          onHoursReq={openHoursReq}
          patchTask={store.upsertTask.bind(store)}
          notify={(userId, text, target) => store.addNotification(userId, text, target)}
          store={store}
          spent={(task) => task.logs.reduce((s, l) => s + l.hours, 0)}
          planSum={(projectId) => db.tasks.filter(t => t.projectId === projectId).reduce((s, t) => s + (t.plannedHours || 0), 0)}
          openTask={openTask}
        />
      );

    case 'project':
      return (
        <ProjectModal
          db={db}
          ur={ur}
          projectId={modal.projectId}
          onClose={onClose}
          onSave={(p, isNew) => {
            const old = db.projects.find(x => x.id === p.id);
            if (old && hasRole(ur, 'admin')) {
              const changes = {};
              if (old.budget !== p.budget) changes.budget = `${old.budget ?? '-'} → ${p.budget ?? '-'}`;
              if (old.name !== p.name) changes.name = `${old.name} → ${p.name}`;
              if (old.managerId !== p.managerId) changes.manager = `${empName(old.managerId)} → ${empName(p.managerId)}`;
              if (old.status !== p.status) changes.status = `${PROJECT_STATUSES[old.status]} → ${PROJECT_STATUSES[p.status]}`;
              if (Object.keys(changes).length) {
                store.addAudit('Административное изменение проекта (прямое)', changes, 'project', p.id);
              }
            }
            store.upsertProject(p);
            store.addAudit(isNew ? 'Создание проекта' : 'Изменение проекта', { name: p.name, code: p.code, budget: p.budget }, 'project', p.id);
            onClose();
          }}
          onDelete={(p) => {
            store.deleteProject(p.id);
            store.addAudit('Удаление проекта', { name: p.name }, 'project', p.id);
            onClose();
          }}
          store={store}
          openTask={openTask}
        />
      );

    case 'hours':
      return (
        <HoursRequestModal
          db={db}
          ur={ur}
          kind={modal.kind}
          targetId={modal.targetId}
          onClose={onClose}
          onSubmit={(r) => {
            store.addHoursRequest(r);
            const target = modal.kind === 'task' ? db.tasks.find(t => t.id === modal.targetId) : db.projects.find(p => p.id === modal.targetId);
            const targetTitle = target ? (modal.kind === 'task' ? target.title : target.name) : '';
            
            const directors = db.employees.filter(e => e.roles.includes('director') && !e.fired);
            directors.forEach(d => {
              store.addNotification(
                d.id,
                `Запрос на изменение часов по ${modal.kind === 'task' ? 'задаче' : 'проекту'} "${targetTitle}" от ${ur.last} ${ur.first}. Обоснование: ${r.reason}.`,
                { targetType: 'hours', targetId: r.id }
              );
            });
            
            store.addNotification(
              ur.id,
              `Ваш запрос на изменение часов по ${modal.kind === 'task' ? 'задаче' : 'проекту'} "${targetTitle}" отправлен на рассмотрение ГД.`,
              { targetType: 'hours', targetId: r.id }
            );
            
            store.addAudit('Запрос изменения часов', { target: targetTitle, oldH: r.oldH, newH: r.newH, reason: r.reason }, 'hoursRequest', r.id);
            onClose();
          }}
        />
      );

    case 'roles':
      return (
        <RolesModal
          db={db}
          setDb={(fn) => { store._data = fn(store._data); store._notify(); }}
          empId={modal.empId}
          onClose={onClose}
          audit={store.addAudit.bind(store)}
        />
      );

    case 'depts':
      return (
        <DeptsModal
          db={db}
          setDb={(fn) => { store._data = fn(store._data); store._notify(); }}
          empId={modal.empId}
          onClose={onClose}
          audit={store.addAudit.bind(store)}
        />
      );

    case 'vacation':
      return (
        <VacationModal
          db={db}
          ur={ur}
          vacationId={modal.vacationId}
          forEmpId={modal.forEmpId || null}
          onClose={onClose}
          onSave={(v, isNew) => {
            store.upsertVacation(v);
            store.addAudit(isNew ? 'Создание отпуска' : 'Изменение отпуска', { employee: empName(v.empId), period: `${fmtDMY(v.start)}-${fmtDMY(v.end)}` }, 'vacation', v.id);
            onClose();
          }}
        />
      );

    case 'delegation':
      return (
        <DelegationModal
          db={db}
          ur={ur}
          onClose={onClose}
          onSubmit={(rd) => {
            store.upsertRoleDelegation(rd);
            store.addNotification(rd.toId, `Вам предложено временное принятие ролей: ${rd.roles.map(r => ROLES[r].label).join(', ')}.`, { targetType: 'delegation', targetId: rd.id });
            store.addAudit('Создание делегирования ролей', { from: empName(rd.fromId), to: empName(rd.toId), roles: rd.roles.join(', ') }, 'delegation', rd.id);
            onClose();
          }}
        />
      );

    case 'vacnow':
      return (
        <VacNowModal
          db={db}
          onClose={onClose}
        />
      );

    default:
      return null;
  }
}

src/components/NotifPanel.jsx

import React from 'react';
import { fmtDT } from '../utils/date';
import { Ic } from './Icons';

export default function NotifPanel({ list, setDb, onNavigate, onClose }) {
  const markAllRead = () => {
    setDb((s) => ({ ...s, notifications: s.notifications.map((n) => ({ ...n, read: true })) }));
    if (onClose) onClose();
  };

  const handleClick = (item) => {
    if (onNavigate) onNavigate(item);
    if (onClose) onClose();
  };

  return (
    <div className="notif-pop">
      <div className="notif-head">
        <span>Уведомления</span>
        <button className="link" onClick={markAllRead}>прочитать все</button>
      </div>
      <div className="notif-list">
        {list.slice(0, 12).map(n => (
          <div 
            key={n.id} 
            className={`notif-item${n.read ? '' : ' new'}`} 
            onClick={() => handleClick(n)}
          >
            <div>{n.text}</div>
            <div className="mut sm">{fmtDT(n.ts)}</div>
          </div>
        ))}
        {list.length === 0 && <div className="mut sm notif-empty">Нет уведомлений</div>}
      </div>
      <p className="mut sm notif-note">E-mail-дубли отправляются по настройкам профиля.</p>
    </div>
  );
}

src/components/ProjectProgress.jsx

// components/ProjectProgress.jsx
import React from 'react';
import { getProjectColor } from '../utils/projectHelpers';

export default function ProjectProgress({ project, plan, fact }) {
  const budget = project?.budget;
  if (budget == null) return null; // для админ-проектов без бюджета ничего не показываем

  const usePct = Math.round((fact / Math.max(1, budget)) * 100);
  const overPlan = plan > budget;
  const color = getProjectColor(project);

  return (
    <div className="pj-budget">
      <div className="pj-budget-row">
        <span>Бюджет: <b>{budget} ч</b></span>
        <span>План: <b className={overPlan ? 'red' : ''}>{plan} ч</b></span>
        <span>Факт: <b>{fact} ч</b></span>
        <span>Использовано: <b>{usePct}%</b></span>
      </div>
      <div className="pj-progress">
        <div
          className={`pj-progress-fill${usePct > 100 ? ' over' : ''}`}
          style={{ width: Math.min(100, usePct) + '%', background: color }}
        />
      </div>
    </div>
  );
}

src/components/Projects.jsx

import React from 'react';
import { PROJECT_STATUSES, PROJECT_TYPES, PROJECT_PRIORITIES } from '../utils/constants';
import { fmtDMY, initials, isTaskActive } from '../utils/date';
import { Ic, ICONS } from './Icons';
import { hasRole } from '../utils/permissions';
import { getProjectColor } from '../utils/projectHelpers';
import ProjectProgress from './ProjectProgress';

export default function Projects({ db, ur, openProject, openHoursReq, closeProject, cancelProject, projects }) {
  const canCloseProject = (project) => {
    const creatorId = project.creatorId || (project.history?.find(h => h.who !== 'system')?.who);
    return hasRole(ur, 'admin') || hasRole(ur, 'director') || (creatorId && creatorId === ur.id);
  };

  return (
    <div>
      <div className="pj-grid">
        {projects.map(p => {
          const tasks = db.tasks.filter(t => t.projectId === p.id && isTaskActive(t));
          const plan = tasks.reduce((s, t) => s + (t.plannedHours || 0), 0);
          const fact = tasks.reduce((s, t) => s + t.logs.reduce((lsum, l) => lsum + l.hours, 0), 0);
          const usePct = p.budget ? Math.round((fact / Math.max(1, p.budget)) * 100) : 0;
          const overPlan = p.budget != null && plan > p.budget;
          const projectColor = getProjectColor(p);

          return (
            <div 
              key={p.id} 
              className="pj-card" 
              onClick={() => openProject(p.id)}
              style={{ cursor: 'pointer' }}
            >
              <div className="pj-top">
                <span className="pj-code" style={{ background: projectColor + '22', color: projectColor }}>
                  {p.code}
                </span>
                <span className={`pj-st ${p.status}`}>{PROJECT_STATUSES[p.status]}</span>
                <span style={{ fontSize: '11px', color: '#64748b' }}>{PROJECT_TYPES[p.ptype || 'prod']}</span>
                <span className="pj-priority" style={{ color: PROJECT_PRIORITIES[p.priority]?.color || '#64748b', fontWeight: 600 }}>
                  {p.priority || 'NORM'}
                </span>
              </div>
              <div className="pj-name">{p.name}</div>
              <div className="pj-row">
                <span className="mut">Сроки:</span> {fmtDMY(p.start)} - {p.end ? fmtDMY(p.end) : 'не задан'}
              </div>
              <ProjectProgress project={p} plan={plan} fact={fact} />
              <div className="pj-foot">
                <div className="pj-avatars">
                  {tasks.slice(0, 6).map(t => {
                    const a = t.assigneeId ? db.employees.find(e => e.id === t.assigneeId) : null;
                    return a && (
                      <span
                        key={t.id}
                        className="avatar xs"
                        title={`${a.last} ${a.first}`}
                      >
                        {initials(a.first, a.last)}
                      </span>
                    );
                  })}
                </div>
                <div className="pj-actions" onClick={(e) => e.stopPropagation()}>
                  {((hasRole(ur, 'project_lead') && p.managerId === ur.id) || hasRole(ur, 'admin', 'director', 'economist', 'kb_chief')) && p.status === 'active' && p.ptype !== 'admin' && (
                    <button className="btn ghost sm" onClick={() => openHoursReq('project', p.id)}>
                      <Ic d={ICONS.clock} size={13} /> Запросить изменение часов
                    </button>
                  )}
                  {hasRole(ur, 'admin', 'director', 'economist', 'kb_chief') && (
                    <button className="icon-btn" title="Редактировать" onClick={() => openProject(p.id)}>
                      <Ic d={ICONS.edit} size={15} />
                    </button>
                  )}
                  {canCloseProject(p) && p.status !== 'closed' && p.status !== 'cancelled' && (
                    <button className="icon-btn danger" title="Закрыть/Отменить проект" onClick={() => {
                      const action = window.confirm(`Закрыть проект "${p.name}"? Все задачи проекта будут переведены в статус "Закрыта".`) 
                        ? 'close' 
                        : (window.confirm(`Отменить проект "${p.name}"? Все задачи проекта будут переведены в статус "Отменена".`) ? 'cancel' : null);
                      if (action === 'close') closeProject(p);
                      else if (action === 'cancel') cancelProject(p);
                    }}>
                      <Ic d={ICONS.x} size={15} />
                    </button>
                  )}
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

src/components/Reports.jsx

import React, { useState, useMemo, useEffect } from 'react';
import { TASK_STATUSES, PRIORITIES, PROJECT_STATUSES } from '../utils/constants';
import { TODAY, fmtDMY, fmtDT } from '../utils/date';
import { hasRole, computeScope, taskVisible } from '../utils/permissions';
import { useToast } from '../context/ToastContext';
import { Ic, ICONS } from './Icons';
import { useDataHelpers } from '../hooks';
import { getPrimaryDeptName } from '../utils/helpers';

const REPORT_TYPES = [
  { value: 'tasks', label: 'Задачи' },
  { value: 'projects', label: 'Проекты' },
  { value: 'employees', label: 'Сотрудники' },
  { value: 'worklog', label: 'Трудозатраты' },
];

export default function Reports({ db, ur }) {
  const { showToast } = useToast();
  const { empName, getTaskSpent, getProjectStats } = useDataHelpers(db);
  const scope = useMemo(() => computeScope(ur, db), [ur, db]);

  const safeDb = useMemo(() => {
    if (!db) return { projects: [], tasks: [], employees: [], departments: [], kbs: [], vacations: [] };
    return db;
  }, [db]);

  const [filters, setFilters] = useState({
    type: 'tasks',
    dateFrom: '',
    dateTo: '',
    deadlineFrom: '',
    deadlineTo: '',
    projectId: 'all',
    assigneeId: 'all',
    status: 'all',
    priority: 'all',
    customer: '',
  });

  const [savedFilters, setSavedFilters] = useState(() => {
    try {
      const data = localStorage.getItem('savedReportFilters');
      return data ? JSON.parse(data) : [];
    } catch {
      return [];
    }
  });

  const [filterName, setFilterName] = useState('');
  const [results, setResults] = useState([]);

  const allProjects = (safeDb.projects || []);
  const visibleProjects = useMemo(() => {
    if (scope.all) return allProjects;
    return allProjects.filter(p => scope.projIds.has(p.id));
  }, [allProjects, scope]);

  const allEmployees = (safeDb.employees || []);
  const visibleEmployees = useMemo(() => {
    if (scope.all) return allEmployees;
    return allEmployees.filter(e => scope.empIds.has(e.id));
  }, [allEmployees, scope]);

  const applyFilters = () => {
    const type = filters.type;
    const tasksList = safeDb.tasks || [];
    const projectsList = safeDb.projects || [];
    const employeesList = safeDb.employees || [];

    if (type === 'tasks') {
      let tasks = tasksList;
      if (filters.dateFrom || filters.dateTo) {
        tasks = tasks.filter(t => {
          if (!t.createdAt) return false;
          if (filters.dateFrom && t.createdAt < filters.dateFrom) return false;
          if (filters.dateTo && t.createdAt > filters.dateTo) return false;
          return true;
        });
      }
      if (filters.deadlineFrom || filters.deadlineTo) {
        tasks = tasks.filter(t => {
          if (!t.deadline) return false;
          if (filters.deadlineFrom && t.deadline < filters.deadlineFrom) return false;
          if (filters.deadlineTo && t.deadline > filters.deadlineTo) return false;
          return true;
        });
      }
      if (filters.projectId !== 'all') tasks = tasks.filter(t => t.projectId === filters.projectId);
      if (filters.assigneeId !== 'all') tasks = tasks.filter(t => t.assigneeId === filters.assigneeId);
      if (filters.status !== 'all') tasks = tasks.filter(t => t.status === filters.status);
      if (filters.priority !== 'all') tasks = tasks.filter(t => t.priority === filters.priority);
      if (filters.customer) {
        tasks = tasks.filter(t => {
          const project = projectsList.find(p => p.id === t.projectId);
          return project && project.customer?.toLowerCase().includes(filters.customer.toLowerCase());
        });
      }
      tasks = tasks.filter(t => taskVisible(ur, scope, t, safeDb));
      setResults(tasks);
    } else if (type === 'projects') {
      let projects = projectsList;
      if (filters.dateFrom) projects = projects.filter(p => p.start >= filters.dateFrom);
      if (filters.dateTo) projects = projects.filter(p => p.start <= filters.dateTo);
      if (filters.deadlineFrom) projects = projects.filter(p => p.end && p.end >= filters.deadlineFrom);
      if (filters.deadlineTo) projects = projects.filter(p => p.end && p.end <= filters.deadlineTo);
      if (filters.projectId !== 'all') projects = projects.filter(p => p.id === filters.projectId);
      if (filters.customer) projects = projects.filter(p => p.customer?.toLowerCase().includes(filters.customer.toLowerCase()));
      if (!scope.all) projects = projects.filter(p => scope.projIds.has(p.id));
      setResults(projects);
    } else if (type === 'employees') {
      let employees = employeesList;
      if (filters.projectId !== 'all') {
        const taskIds = tasksList.filter(t => t.projectId === filters.projectId).map(t => t.id);
        employees = employees.filter(e =>
          tasksList.some(t => t.assigneeId === e.id && taskIds.includes(t.id))
        );
      }
      if (filters.assigneeId !== 'all') employees = employees.filter(e => e.id === filters.assigneeId);
      if (filters.customer) {
        employees = employees.filter(e => {
          const userTasks = tasksList.filter(t => t.assigneeId === e.id);
          if (userTasks.length === 0) return false;
          return userTasks.some(t => {
            const project = projectsList.find(p => p.id === t.projectId);
            return project && project.customer?.toLowerCase().includes(filters.customer.toLowerCase());
          });
        });
      }
      setResults(employees);
    } else if (type === 'worklog') {
      let logs = [];
      tasksList.forEach(t => {
        (t.logs || []).forEach(l => {
          logs.push({
            ...l,
            taskTitle: t.title,
            projectId: t.projectId,
            assigneeId: t.assigneeId,
          });
        });
      });
      if (filters.dateFrom) logs = logs.filter(l => l.date >= filters.dateFrom);
      if (filters.dateTo) logs = logs.filter(l => l.date <= filters.dateTo);
      if (filters.projectId !== 'all') logs = logs.filter(l => l.projectId === filters.projectId);
      if (filters.assigneeId !== 'all') logs = logs.filter(l => l.userId === filters.assigneeId);
      if (filters.customer) {
        logs = logs.filter(l => {
          const project = projectsList.find(p => p.id === l.projectId);
          return project && project.customer?.toLowerCase().includes(filters.customer.toLowerCase());
        });
      }
      if (!scope.all) {
        const visibleTaskIds = tasksList
          .filter(t => taskVisible(ur, scope, t, safeDb))
          .map(t => t.id);
        logs = logs.filter(l => {
          const task = tasksList.find(t => t.id === l.taskId);
          return task && visibleTaskIds.includes(task.id);
        });
      }
      setResults(logs);
    }
  };

  useEffect(() => {
    applyFilters();
  }, [filters, safeDb, ur, scope]);

  const handleFilterChange = (key, value) => {
    setFilters(prev => ({ ...prev, [key]: value }));
  };

  const resetFilters = () => {
    setFilters({
      type: filters.type,
      dateFrom: '',
      dateTo: '',
      deadlineFrom: '',
      deadlineTo: '',
      projectId: 'all',
      assigneeId: 'all',
      status: 'all',
      priority: 'all',
      customer: '',
    });
  };

  const saveFilter = () => {
    if (!filterName.trim()) {
      showToast('Введите название фильтра', 'warning');
      return;
    }
    const newFilter = {
      id: Date.now(),
      name: filterName.trim(),
      filters: { ...filters },
    };
    const updated = [...savedFilters, newFilter];
    setSavedFilters(updated);
    localStorage.setItem('savedReportFilters', JSON.stringify(updated));
    setFilterName('');
    showToast('Фильтр сохранён', 'success');
  };

  const loadFilter = (filter) => {
    setFilters(filter.filters);
    setFilterName(filter.name);
  };

  const deleteFilter = (id) => {
    const updated = savedFilters.filter(f => f.id !== id);
    setSavedFilters(updated);
    localStorage.setItem('savedReportFilters', JSON.stringify(updated));
  };

  const downloadXLSX = () => {
    if (results.length === 0) {
      showToast('Нет данных для выгрузки', 'warning');
      return;
    }
    showToast('Выгрузка XLSX пока не реализована', 'info');
  };

  const renderResults = () => {
    const type = filters.type;
    if (results.length === 0) {
      return <div className="empty-note" style={{ padding: '20px 0' }}>Нет данных, соответствующих фильтрам</div>;
    }

    if (type === 'tasks') {
      return (
        <div style={{ width: '100%', overflowX: 'auto' }}>
          <table className="tbl" style={{ minWidth: '800px', fontSize: '13px' }}>
            <thead>
              <tr>
                <th>#</th>
                <th>Задача</th>
                <th>Проект</th>
                <th>Исполнитель</th>
                <th>Статус</th>
                <th>Приоритет</th>
                <th>План (ч)</th>
                <th>Факт (ч)</th>
                <th>Срок исполнения</th>
              </tr>
            </thead>
            <tbody>
              {results.map((t, idx) => {
                const project = (safeDb.projects || []).find(p => p.id === t.projectId);
                const statusDef = TASK_STATUSES[t.status] || { label: t.status || 'Неизвестно', color: '#64748b' };
                const priorityDef = PRIORITIES[t.priority] || { label: t.priority || 'Неизвестно', color: '#64748b' };
                return (
                  <tr key={t.id}>
                    <td>{idx + 1}</td>
                    <td><b>{t.title}</b></td>
                    <td>{project?.code || '-'}</td>
                    <td>{t.assigneeId ? empName(t.assigneeId) : '-'}</td>
                    <td><span className="st-chip" style={{ background: statusDef.color + '22', color: statusDef.color }}>{statusDef.label}</span></td>
                    <td><span style={{ color: priorityDef.color }}>{priorityDef.label}</span></td>
                    <td>{t.plannedHours ?? '-'}</td>
                    <td>{getTaskSpent(t)}</td>
                    <td>{t.deadline ? fmtDMY(t.deadline) : '-'}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      );
    } else if (type === 'projects') {
      return (
        <div style={{ width: '100%', overflowX: 'auto' }}>
          <table className="tbl" style={{ minWidth: '600px', fontSize: '13px' }}>
            <thead>
              <tr>
                <th>#</th>
                <th>Код</th>
                <th>Проект</th>
                <th>Статус</th>
                <th>Заказчик</th>
                <th>Бюджет (ч)</th>
                <th>План (ч)</th>
                <th>Факт (ч)</th>
                <th>Ответственный</th>
              </tr>
            </thead>
            <tbody>
              {results.map((p, idx) => {
                const stats = getProjectStats(p.id);
                return (
                  <tr key={p.id}>
                    <td>{idx + 1}</td>
                    <td><b>{p.code}</b></td>
                    <td>{p.name}</td>
                    <td><span className={`st-chip ${p.status === 'active' ? 'active' : ''}`}>{PROJECT_STATUSES[p.status] || p.status}</span></td>
                    <td>{p.customer || '-'}</td>
                    <td>{p.budget ?? '-'}</td>
                    <td>{stats.plan}</td>
                    <td>{stats.fact}</td>
                    <td>{empName(p.managerId)}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      );
    } else if (type === 'employees') {
      return (
        <div style={{ width: '100%', overflowX: 'auto' }}>
          <table className="tbl" style={{ minWidth: '600px', fontSize: '13px' }}>
            <thead>
              <tr>
                <th>#</th>
                <th>Сотрудник</th>
                <th>Отдел (основной)</th>
                <th>План (ч)</th>
                <th>Факт (ч)</th>
                <th>Кол-во задач</th>
              </tr>
            </thead>
            <tbody>
              {results.map((e, idx) => {
                const deptName = getPrimaryDeptName(e, safeDb);
                const tasks = (safeDb.tasks || []).filter(t => t.assigneeId === e.id);
                const plan = tasks.reduce((s, t) => s + (t.plannedHours || 0), 0);
                const fact = tasks.reduce((s, t) => s + getTaskSpent(t), 0);
                return (
                  <tr key={e.id}>
                    <td>{idx + 1}</td>
                    <td><b>{e.last} {e.first}</b></td>
                    <td>{deptName}</td>
                    <td>{plan}</td>
                    <td>{fact}</td>
                    <td>{tasks.length}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      );
    } else if (type === 'worklog') {
      return (
        <div style={{ width: '100%', overflowX: 'auto' }}>
          <table className="tbl" style={{ minWidth: '700px', fontSize: '13px' }}>
            <thead>
              <tr>
                <th>#</th>
                <th>Дата</th>
                <th>Сотрудник</th>
                <th>Задача</th>
                <th>Проект</th>
                <th>Часы</th>
                <th>Комментарий</th>
              </tr>
            </thead>
            <tbody>
              {results.map((l, idx) => {
                const project = (safeDb.projects || []).find(p => p.id === l.projectId);
                const user = (safeDb.employees || []).find(e => e.id === l.userId);
                return (
                  <tr key={idx}>
                    <td>{idx + 1}</td>
                    <td>{fmtDMY(l.date)}</td>
                    <td>{user ? `${user.last} ${user.first}` : '-'}</td>
                    <td>{l.taskTitle}</td>
                    <td>{project?.code || '-'}</td>
                    <td><b>{l.hours}</b></td>
                    <td>{l.note || '-'}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      );
    }
  };

  const type = filters.type;
  const showDateRange = type !== 'employees';
  const showDeadlineRange = type === 'tasks' || type === 'projects';
  const showStatusPriority = type === 'tasks';

  return (
    <div className="rep">
      <div className="rep-panel" style={{ padding: '16px' }}>
        <div className="rep-panel-title">Фильтры отчёта</div>

        <div className="toolbar" style={{ marginBottom: '8px' }}>
          <label className="lbl" style={{ margin: 0 }}>Тип отчёта:</label>
          <div className="seg">
            {REPORT_TYPES.map(typeOption => (
              <button
                key={typeOption.value}
                className={`seg-btn${filters.type === typeOption.value ? ' on' : ''}`}
                onClick={() => handleFilterChange('type', typeOption.value)}
              >
                {typeOption.label}
              </button>
            ))}
          </div>
        </div>

        <div className="toolbar" style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
          {showDateRange && (
            <>
              <label className="lbl" style={{ margin: 0 }}>
                {type === 'worklog' ? 'Дата записи:' : type === 'projects' ? 'Начало:' : 'Создан с:'}
              </label>
              <input
                className="inp"
                type="date"
                value={filters.dateFrom}
                onChange={e => handleFilterChange('dateFrom', e.target.value)}
                style={{ width: '150px' }}
              />
              <span>-</span>
              <input
                className="inp"
                type="date"
                value={filters.dateTo}
                onChange={e => handleFilterChange('dateTo', e.target.value)}
                style={{ width: '150px' }}
              />
            </>
          )}

          {showDeadlineRange && (
            <>
              <label className="lbl" style={{ margin: 0 }}>
                {type === 'projects' ? 'Окончание:' : 'Срок исполнения:'}
              </label>
              <input
                className="inp"
                type="date"
                value={filters.deadlineFrom}
                onChange={e => handleFilterChange('deadlineFrom', e.target.value)}
                style={{ width: '150px' }}
              />
              <span>-</span>
              <input
                className="inp"
                type="date"
                value={filters.deadlineTo}
                onChange={e => handleFilterChange('deadlineTo', e.target.value)}
                style={{ width: '150px' }}
              />
            </>
          )}

          <label className="lbl" style={{ margin: 0 }}>Проект:</label>
          <select
            className="inp sel"
            value={filters.projectId}
            onChange={e => handleFilterChange('projectId', e.target.value)}
            style={{ width: '180px' }}
          >
            <option value="all">Все проекты</option>
            {visibleProjects.map(p => <option key={p.id} value={p.id}>{p.code} - {p.name}</option>)}
          </select>

          <label className="lbl" style={{ margin: 0 }}>Исполнитель:</label>
          <select
            className="inp sel"
            value={filters.assigneeId}
            onChange={e => handleFilterChange('assigneeId', e.target.value)}
            style={{ width: '180px' }}
          >
            <option value="all">Все</option>
            {visibleEmployees.map(e => <option key={e.id} value={e.id}>{e.last} {e.first}</option>)}
          </select>

          <label className="lbl" style={{ margin: 0 }}>Заказчик:</label>
          <input
            className="inp"
            type="text"
            value={filters.customer}
            onChange={e => handleFilterChange('customer', e.target.value)}
            placeholder="поиск по названию"
            style={{ width: '200px' }}
          />

          {showStatusPriority && (
            <>
              <label className="lbl" style={{ margin: 0 }}>Статус задачи:</label>
              <select
                className="inp sel"
                value={filters.status}
                onChange={e => handleFilterChange('status', e.target.value)}
                style={{ width: '140px' }}
              >
                <option value="all">Все</option>
                {Object.keys(TASK_STATUSES).map(st => <option key={st} value={st}>{TASK_STATUSES[st].label}</option>)}
              </select>

              <label className="lbl" style={{ margin: 0 }}>Приоритет:</label>
              <select
                className="inp sel"
                value={filters.priority}
                onChange={e => handleFilterChange('priority', e.target.value)}
                style={{ width: '140px' }}
              >
                <option value="all">Все</option>
                {Object.keys(PRIORITIES).map(pr => <option key={pr} value={pr}>{PRIORITIES[pr].label}</option>)}
              </select>
            </>
          )}
        </div>

        <div style={{ marginTop: '12px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
          <button className="btn primary" onClick={applyFilters}>Применить</button>
          <button className="btn ghost" onClick={resetFilters}>Сбросить фильтры</button>
          <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
            <input
              className="inp"
              type="text"
              value={filterName}
              onChange={e => setFilterName(e.target.value)}
              placeholder="Название шаблона"
              style={{ width: '180px' }}
            />
            <button className="btn ghost" onClick={saveFilter}>
              <Ic d={ICONS.plus} size={13} /> Сохранить фильтр
            </button>
          </div>
        </div>
      </div>

      {savedFilters.length > 0 && (
        <div className="rep-panel" style={{ padding: '16px' }}>
          <div className="rep-panel-title">Сохранённые шаблоны</div>
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
            {savedFilters.map(f => {
              const criteria = [];
              const typeLabel = REPORT_TYPES.find(t => t.value === f.filters.type)?.label || 'Задачи';
              criteria.push(`Тип: ${typeLabel}`);
              if (f.filters.dateFrom || f.filters.dateTo) {
                const from = f.filters.dateFrom ? fmtDMY(f.filters.dateFrom) : '';
                const to = f.filters.dateTo ? fmtDMY(f.filters.dateTo) : '';
                criteria.push(`Период: ${from} - ${to}`);
              }
              if (f.filters.deadlineFrom || f.filters.deadlineTo) {
                const from = f.filters.deadlineFrom ? fmtDMY(f.filters.deadlineFrom) : '';
                const to = f.filters.deadlineTo ? fmtDMY(f.filters.deadlineTo) : '';
                criteria.push(`Срок исполнения: ${from} - ${to}`);
              }
              if (f.filters.projectId !== 'all') {
                const proj = (safeDb.projects || []).find(p => p.id === f.filters.projectId);
                criteria.push(`Проект: ${proj?.code || '-'}`);
              }
              if (f.filters.assigneeId !== 'all') {
                const emp = (safeDb.employees || []).find(e => e.id === f.filters.assigneeId);
                criteria.push(`Исполнитель: ${emp ? emp.last : '-'}`);
              }
              if (f.filters.status !== 'all') criteria.push(`Статус: ${TASK_STATUSES[f.filters.status]?.label || f.filters.status}`);
              if (f.filters.priority !== 'all') criteria.push(`Приоритет: ${PRIORITIES[f.filters.priority]?.label || f.filters.priority}`);
              if (f.filters.customer) criteria.push(`Заказчик: ${f.filters.customer}`);
              const displayText = criteria.length ? criteria.join(' · ') : 'Все';

              return (
                <div key={f.id} className="pj-card" style={{ minWidth: '200px', maxWidth: '280px', cursor: 'pointer', padding: '12px', position: 'relative' }}>
                  <div style={{ fontWeight: '700', fontSize: '14px', marginBottom: '6px' }}>{f.name}</div>
                  <div style={{ fontSize: '12px', color: 'var(--mut)', lineHeight: '1.4' }}>{displayText}</div>
                  <button
                    className="icon-btn"
                    style={{ position: 'absolute', top: '6px', right: '6px' }}
                    onClick={(e) => { e.stopPropagation(); deleteFilter(f.id); }}
                    title="Удалить шаблон"
                  >
                    <Ic d={ICONS.x} size={14} />
                  </button>
                  <div style={{ marginTop: '8px' }}>
                    <button className="btn ghost sm" onClick={() => loadFilter(f)}>Загрузить</button>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      )}

      <div className="rep-panel" style={{ padding: '16px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
          <div className="rep-panel-title" style={{ marginBottom: 0 }}>Результаты ({results.length})</div>
          <button className="btn primary sm" onClick={downloadXLSX} disabled={results.length === 0}>
            <Ic d={ICONS.download} size={13} /> Выгрузить XLSX
          </button>
        </div>
        {renderResults()}
      </div>
    </div>
  );
}

src/components/Requests.jsx

import React, { useState } from "react";
import { TASK_STATUSES, VACATION_TYPES, ROLES } from "../utils/constants";
import { fmtDMY, fmtDT } from "../utils/date";
import { hasRole, canApproveVacation } from "../utils/permissions";
import { Ic, ICONS } from "./Icons";
import { useDataHelpers } from "../hooks";

export default function Requests({ db, setDb, ur, initialTab = 'hours', addAudit, notify }) {
  const { empName } = useDataHelpers(db);
  const [tab, setTab] = useState(initialTab);

  const tabs = [];
  if (hasRole(ur, 'director', 'admin')) tabs.push(['hours', 'Изменение часов']);
  tabs.push(['vac', 'Делегирование отпусков']);
  tabs.push(['rd', 'Передача ролей']);
  if (hasRole(ur, 'admin')) tabs.push(['reg', 'Заявки на регистрацию']);

  const decideHours = (r, ok) => {
    const targetTitle = ok 
      ? (r.kind === "task" 
          ? db.tasks.find(t => t.id === r.targetId)?.title 
          : db.projects.find(p => p.id === r.targetId)?.name)
      : (r.kind === "task" 
          ? db.tasks.find(t => t.id === r.targetId)?.title 
          : db.projects.find(p => p.id === r.targetId)?.name);
    
    setDb((s) => {
      const st = { ...s, hoursRequests: s.hoursRequests.map((x) => (x.id === r.id ? { ...x, status: ok ? "approved" : "rejected" } : x)) };
      if (ok) {
        if (r.kind === "task") st.tasks = st.tasks.map((t) => (t.id === r.targetId ? { ...t, plannedHours: r.newH } : t));
        else st.projects = st.projects.map((p) => (p.id === r.targetId ? { ...p, budget: r.newH } : p));
      }
      return st;
    });
    
    setTimeout(() => {
      if (ok) {
        addAudit('Утверждение запроса часов', { 
          task: targetTitle, 
          previousHours: r.oldH, 
          newHours: r.newH,
          reason: r.reason          // <-- добавлено поле reason
        }, 'hoursRequest', r.id);
      } else {
        addAudit('Отклонение запроса часов', { 
          task: targetTitle,
          requestedHours: r.newH,
          reason: r.reason
        }, 'hoursRequest', r.id);
      }
    }, 0);

    if (notify) {
      const statusText = ok ? 'утверждён' : 'отклонён';
      notify(
        r.reqId,
        `Ваш запрос на изменение часов по ${r.kind === 'task' ? 'задаче' : 'проекту'} "${targetTitle}" ${statusText}.`,
        { targetType: 'hours', targetId: r.id }
      );
    }
  };

  const decideVac = (v, ok) => {
    const employeeName = empName(v.empId);
    const period = `${fmtDMY(v.start)}-${fmtDMY(v.end)}`;
    
    setDb((s) => {
      const updated = { ...s, vacations: s.vacations.map((x) => (x.id === v.id ? { ...x, status: ok ? "approved" : "rejected" } : x)) };
      return updated;
    });
    
    setTimeout(() => {
      if (ok) {
        addAudit('Утверждение отпуска', { 
          employee: employeeName, 
          period,
          type: VACATION_TYPES[v.type]?.label || v.type
        }, 'vacation', v.id);
      } else {
        addAudit('Отклонение отпуска', { 
          employee: employeeName, 
          period,
          type: VACATION_TYPES[v.type]?.label || v.type
        }, 'vacation', v.id);
      }
    }, 0);
  };

  const decideRD = (r, ok) => {
    const fromName = empName(r.fromId);
    const toName = empName(r.toId);
    const rolesStr = r.roles.join(', ');
    
    setDb((s) => {
      const updated = { ...s, roleDelegations: s.roleDelegations.map((x) => (x.id === r.id ? { ...x, status: ok ? "active" : "rejected" } : x)) };
      return updated;
    });
    
    setTimeout(() => {
      if (ok) {
        addAudit('Принятие делегирования', { 
          from: fromName, 
          to: toName, 
          roles: rolesStr,
          start: fmtDMY(r.start),
          end: fmtDMY(r.end)
        }, 'delegation', r.id);
      } else {
        addAudit('Отклонение делегирования', { 
          from: fromName, 
          to: toName, 
          roles: rolesStr 
        }, 'delegation', r.id);
      }
    }, 0);
  };

  const decideReg = (r, ok) => {
    const empNameStr = `${r.last} ${r.first}`;
    
    if (ok) {
      setDb((s) => {
        const existing = s.employees.find(e => e.email === r.email);
        if (existing) {
          const updated = { ...existing, roles: ['executor'] };
          return { 
            ...s, 
            employees: s.employees.map(e => e.id === updated.id ? updated : e),
            regRequests: s.regRequests.map(x => x.id === r.id ? { ...x, status: "approved" } : x)
          };
        } else {
          const newEmp = { 
            id: "e_" + Math.random().toString(36).slice(2,6),
            last: r.last,
            first: r.first,
            email: r.email,
            pass: r.pass,
            position: "Сотрудник",
            departments: [],
            roles: ["executor"],
            kbIds: [],
            headDeptIds: [],
            phone: "",
            tab: String(1000 + Math.floor(Math.random() * 8999)),
            notif: { deadlineEmail: true, overdueDigest: false, commentSub: true },
            failed: 0,
            lockUntil: 0
          };
          return { 
            ...s, 
            employees: [...s.employees, newEmp],
            regRequests: s.regRequests.map(x => x.id === r.id ? { ...x, status: "approved" } : x)
          };
        }
      });
      
      setTimeout(() => {
        addAudit('Одобрение регистрации', { 
          email: r.email, 
          employee: empNameStr,
          position: r.position || 'Сотрудник'
        }, 'registration', r.id);
      }, 0);
    } else {
      setDb((s) => {
        return { ...s, regRequests: s.regRequests.map((x) => (x.id === r.id ? { ...x, status: "rejected" } : x)) };
      });
      
      setTimeout(() => {
        addAudit('Отклонение регистрации', { 
          email: r.email, 
          employee: empNameStr,
          reason: r.rejectionReason || 'Не указана'
        }, 'registration', r.id);
      }, 0);
    }
  };

  return (
    <div>
      <div className="tabs">{tabs.map(([id, l]) => <button key={id} className={"tab" + (tab === id ? " on" : "")} onClick={() => setTab(id)}>{l}</button>)}</div>

      {tab === "hours" && hasRole(ur, "director", "admin") && (
        <div className="rep-panel">
          <div className="rep-panel-title">Запросы на изменение плановых часов</div>
          <table className="tbl">
            <thead>
              <tr>
                <th>Объект</th>
                <th>Текущее</th>
                <th>Предлагаемое</th>
                <th>Обоснование</th>
                <th>Запросил</th>
                <th>Решение</th>
              </tr>
            </thead>
            <tbody>
              {db.hoursRequests.map(r => (
                <tr key={r.id}>
                  <td><b>{r.kind === "task" ? db.tasks.find(t => t.id === r.targetId)?.title : db.projects.find(p => p.id === r.targetId)?.name}</b></td>
                  <td>{r.oldH} ч</td>
                  <td><b>{r.newH} ч</b></td>
                  <td className="mut sm">{r.reason}</td>
                  <td>{empName(r.reqId)}</td>
                  <td>
                    {r.status === "pending" ? (
                      <>
                        <button className="btn primary sm" onClick={() => decideHours(r, true)}>Подтвердить</button>
                        <button className="btn danger sm" onClick={() => decideHours(r, false)}>Отклонить</button>
                      </>
                    ) : (
                      <span className={"st-chip " + (r.status === "approved" ? "approved" : "rejected")}>{r.status}</span>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {tab === "vac" && (
        <div className="rep-panel">
          <div className="rep-panel-title">Отпуска с делегированием - на утверждение</div>
          <table className="tbl">
            <thead><tr><th>Сотрудник</th><th>Период</th><th>Замещающий</th><th>Решение</th></tr></thead>
            <tbody>
              {db.vacations.filter(v => v.status === "pending" && canApproveVacation(ur, v, db)).map(v => (
                <tr key={v.id}>
                  <td><b>{empName(v.empId)}</b></td>
                  <td>{fmtDMY(v.start)} - {fmtDMY(v.end)}</td>
                  <td>{v.delegation.enabled ? empName(v.delegation.subId) : '-'}</td>
                  <td><button className="btn primary sm" onClick={() => decideVac(v, true)}>Утвердить</button> <button className="btn danger sm" onClick={() => decideVac(v, false)}>Отклонить</button></td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {tab === "rd" && (
        <div className="rep-panel">
          <div className="rep-panel-title">Передача ролей</div>
          <table className="tbl">
            <thead><tr><th>От</th><th>Кому</th><th>Роли</th><th>Статус / действие</th></tr></thead>
            <tbody>
              {db.roleDelegations.map(r => (
                <tr key={r.id}>
                  <td>{empName(r.fromId)}</td><td>{empName(r.toId)}</td>
                  <td>{r.roles.map(x => ROLES[x].label).join(", ")}</td>
                  <td>{r.status === "pending" && r.toId === ur.id ? (<><button className="btn primary sm" onClick={() => decideRD(r, true)}>Принять</button> <button className="btn danger sm" onClick={() => decideRD(r, false)}>Отклонить</button></>) : <span className={"st-chip " + r.status}>{r.status}</span>}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {tab === "reg" && hasRole(ur, "admin") && (
        <div className="rep-panel">
          <div className="rep-panel-title">Заявки на регистрацию</div>
          <table className="tbl">
            <thead><tr><th>ФИО</th><th>E-mail</th><th>Решение</th></tr></thead>
            <tbody>
              {db.regRequests.map(r => (
                <tr key={r.id}>
                  <td><b>{r.last} {r.first}</b></td><td>{r.email}@aviahorizont.ru</td>
                  <td>{r.status === "pending" ? (<><button className="btn primary sm" onClick={() => decideReg(r, true)}>Одобрить</button> <button className="btn danger sm" onClick={() => decideReg(r, false)}>Отклонить</button></>) : <span className={"st-chip " + (r.status === "approved" ? "approved" : "rejected")}>{r.status}</span>}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

src/components/Staff.jsx

// Staff.jsx
import React, { useState, useMemo, useCallback } from "react";
import { ROLES, VACATION_TYPES } from "../utils/constants";
import { TODAY, fmtDMY, uid } from "../utils/date";
import {
  canEditDepartments,
  canEditRoles,
  canManageAllVacations,
  hasRole,
  canFireEmployee,
} from "../utils/permissions";
import { Ic, ICONS } from "./Icons";
import { useDataHelpers } from "../hooks";
import EditEmployeeModal from "./EditEmployeeModal";
import CreateEmployeeModal from "./CreateEmployeeModal";
import Avatar from "./Avatar";
import { getPrimaryDeptName } from "../utils/helpers";

// ---- Строка сотрудника ----
const EmployeeRow = React.memo(({
  employee,
  isFired,
  db,
  ur,
  openDepts,
  openRoles,
  setDb,
  canFire,
  getEmployeeLoad,
  openEditEmployee,
}) => {
  const l = getEmployeeLoad(employee.id);
  const norm = 160;
  const pct = Math.min(100, Math.round((l.plan / norm) * 100));
  const vacNow = db.vacations.find(v => v.empId === employee.id && v.status === 'approved' && v.start <= TODAY && TODAY <= v.end);

  const handleFireToggle = useCallback(() => {
    const updated = { ...employee, fired: !employee.fired };
    setDb((s) => ({
      ...s,
      employees: s.employees.map(emp => emp.id === employee.id ? updated : emp),
      audit: [{ id: uid(), ts: Date.now(), userId: ur.id, action: employee.fired ? 'Восстановление сотрудника' : 'Увольнение сотрудника', details: `${employee.last} ${employee.first}` }, ...s.audit]
    }));
  }, [employee, setDb, ur]);

  const mainDept = getPrimaryDeptName(employee, db);
  const displayPosition = employee.position || 'Сотрудник';

  return (
    <div className="st-row">
      <Avatar employee={employee} size="sm" />
      <div className="st-name">
        <div className="st-fio">
          {employee.last} {employee.first}
          {isFired && <span className="vac-badge fired">Уволен</span>}
          {vacNow && <span className="vac-badge">в отпуске до {fmtDMY(vacNow.end)}</span>}
        </div>
        <div className="st-pos">
          {displayPosition}
        </div>
      </div>
      <div className="st-roles">
        {employee.roles.map(r => (
          <span key={r} className="role-chip" style={{ background: ROLES[r].color + '1e', color: ROLES[r].color }}>{ROLES[r].short}</span>
        ))}
      </div>
      {!isFired && (
        <div className="st-load">
          <div className="st-load-bar"><div className={`st-load-fill${l.plan > norm ? ' over' : ''}`} style={{ width: pct + '%' }} /></div>
          <span className={`st-load-txt${l.plan > norm ? ' over' : ''}`}>{l.plan} ч · {Math.round((l.plan / norm) * 100)}%</span>
        </div>
      )}
      <div className="st-nums"><b>{isFired ? '-' : l.cnt}</b><span>{isFired ? 'задач' : 'задач'}</span></div>
      {canEditDepartments(ur) && !isFired && <button className="btn ghost sm" title="Подразделения" onClick={() => openDepts(employee.id)}><Ic d={ICONS.users} size={13} /> Отделы</button>}
      {canEditRoles(ur) && !isFired && <button className="btn ghost sm" onClick={() => openRoles(employee.id)}><Ic d={ICONS.shield} size={13} /> Роли</button>}
      {canFire && (
        <button className={`btn ghost sm${isFired ? '' : ' danger'}`} onClick={handleFireToggle}>
          <Ic d={isFired ? ICONS.restore : ICONS.x} size={13} /> {isFired ? 'Восстановить' : 'Уволить'}
        </button>
      )}
      {canEditDepartments(ur) && (
        <button className="icon-btn" title="Редактировать сотрудника" onClick={() => openEditEmployee(employee.id)}>
          <Ic d={ICONS.edit} size={15} />
        </button>
      )}
    </div>
  );
});

// ---- Основной компонент Staff ----
export default function Staff({ db, setDb, ur, openRoles, openDepts, openVacation }) {
  const { getEmployeeLoad, empName } = useDataHelpers(db);
  const [showFired, setShowFired] = useState(false);
  const [editEmployeeId, setEditEmployeeId] = useState(null);
  const [showCreateModal, setShowCreateModal] = useState(false);

  const activeEmployees = useMemo(() => db.employees.filter(e => !e.fired), [db.employees]);
  const firedEmployees = useMemo(() => db.employees.filter(e => e.fired), [db.employees]);

  const noDeptEmployees = useMemo(() => {
    return activeEmployees
      .filter(e => e.departments.length === 0 && !e.roles.includes('kb_chief'))
      .sort((a, b) => {
        if (a.roles.includes('director') && !b.roles.includes('director')) return -1;
        if (!a.roles.includes('director') && b.roles.includes('director')) return 1;
        return a.last.localeCompare(b.last);
      });
  }, [activeEmployees]);

  const deptMap = useMemo(() => {
    const map = new Map();
    db.departments.forEach(d => {
      const members = activeEmployees.filter(e => e.departments.some(x => x.deptId === d.id));
      if (members.length) map.set(d.id, members);
    });
    return map;
  }, [db.departments, activeEmployees]);

  const kbSections = useMemo(() => {
    return db.kbs.map(k => {
      const deptsInKb = db.departments.filter(d => d.kbId === k.id);
      const members = activeEmployees.filter(e => e.departments.some(d => deptsInKb.some(x => x.id === d.deptId)));
      const chiefs = activeEmployees.filter(e => e.roles.includes('kb_chief') && (e.kbIds || []).includes(k.id));
      if (members.length === 0 && deptsInKb.length === 0 && chiefs.length === 0) return null;
      return { kb: k, deptsInKb, members, chiefs };
    }).filter(Boolean);
  }, [db.kbs, db.departments, activeEmployees]);

  const deptsWithoutKb = useMemo(() => db.departments.filter(d => d.kbId === null), [db.departments]);
  const allVacs = useMemo(() => [...db.vacations].sort((a,b) => (a.start < b.start ? 1 : -1)), [db.vacations]);
  const canFire = canFireEmployee(ur);

  const openEditEmployee = useCallback((id) => setEditEmployeeId(id), []);
  const closeEditEmployee = useCallback(() => setEditEmployeeId(null), []);

  const renderDepartment = useCallback((deptId) => {
    const members = deptMap.get(deptId) || [];
    if (!members.length) return null;
    const dept = db.departments.find(d => d.id === deptId);
    if (!dept) return null;
    const headNames = db.employees.filter(e => (e.headDeptIds || []).includes(deptId) && !e.fired).map(e => `${e.last} ${e.first[0]}.`).join(', ');
    return (
      <div className="st-dept" key={deptId}>
        <div className="st-dept-head">
          <span className="st-dept-name">{dept.name}</span>
          <span className="mut">руководитель: {headNames || '-'}</span>
          <span className="kcount">{members.length}</span>
        </div>
        {members.map(e => (
          <EmployeeRow
            key={e.id}
            employee={e}
            isFired={false}
            db={db}
            ur={ur}
            openDepts={openDepts}
            openRoles={openRoles}
            setDb={setDb}
            canFire={canFire}
            getEmployeeLoad={getEmployeeLoad}
            openEditEmployee={openEditEmployee}
          />
        ))}
      </div>
    );
  }, [deptMap, db, ur, openDepts, openRoles, setDb, canFire, getEmployeeLoad, openEditEmployee]);

  return (
    <div className="staff">
      <div className="sec-head">
        {canEditRoles(ur) && (
          <div className="sec-actions">
            <button className="btn ghost sm" onClick={() => {
              const name = window.prompt('Название нового КБ:');
              if (name) setDb((s) => ({ ...s, kbs: [...s.kbs, { id: 'kb_' + Math.random().toString(36).slice(2,6), name, full: name }] }));
            }}><Ic d={ICONS.plus} size={13} /> КБ</button>
            <button className="btn ghost sm" onClick={() => {
              const name = window.prompt('Название нового отдела:');
              if (name) setDb((s) => ({ ...s, departments: [...s.departments, { id: 'd_' + Math.random().toString(36).slice(2,6), name, kbId: null }] }));
            }}><Ic d={ICONS.plus} size={13} /> Отдел</button>
            {canEditDepartments(ur) && (
              <button className="btn primary sm" onClick={() => setShowCreateModal(true)}>
                <Ic d={ICONS.plus} size={13} /> Добавить сотрудника
              </button>
            )}
          </div>
        )}
      </div>

      {showCreateModal && (
        <CreateEmployeeModal
          db={db}
          setDb={setDb}
          onClose={() => setShowCreateModal(false)}
          toast={(msg, type) => alert(msg)}
          audit={(action, details) => setDb(prev => ({ ...prev, audit: [{ id: uid(), ts: Date.now(), userId: ur.id, action, details }, ...prev.audit] }))}
        />
      )}

      {editEmployeeId && (
        <EditEmployeeModal
          db={db}
          setDb={setDb}
          employeeId={editEmployeeId}
          onClose={closeEditEmployee}
          toast={(msg, type) => alert(msg)}
          audit={(action, details) => setDb(prev => ({ ...prev, audit: [{ id: uid(), ts: Date.now(), userId: ur.id, action, details }, ...prev.audit] }))}
          ur={ur}
        />
      )}

      {noDeptEmployees.length > 0 && (
        <div className="st-section">
          <div className="st-sec-head">
            <div className="st-sec-title">Руководство</div>
            <div className="st-sec-sub">{noDeptEmployees.length} чел.</div>
          </div>
          {noDeptEmployees.map(e => (
            <EmployeeRow
              key={e.id}
              employee={e}
              isFired={false}
              db={db}
              ur={ur}
              openDepts={openDepts}
              openRoles={openRoles}
              setDb={setDb}
              canFire={canFire}
              getEmployeeLoad={getEmployeeLoad}
              openEditEmployee={openEditEmployee}
            />
          ))}
        </div>
      )}

      {kbSections.map(({ kb, deptsInKb, members, chiefs }) => (
        <div className="st-section" key={kb.id}>
          <div className="st-sec-head">
            <div className="st-sec-title">{kb.name}</div>
            <div className="st-sec-sub">{kb.full} · главный конструктор: {chiefs.map(e => `${e.last} ${e.first}`).join(', ') || '-'}</div>
          </div>
          {chiefs.length > 0 && (
            <div className="st-dept" style={{ borderTop: '1px solid var(--line)' }}>
              <div className="st-dept-head">
                <span className="st-dept-name">Главные конструкторы</span>
                <span className="kcount">{chiefs.length}</span>
              </div>
              {chiefs.map(e => (
                <EmployeeRow
                  key={e.id}
                  employee={e}
                  isFired={false}
                  db={db}
                  ur={ur}
                  openDepts={openDepts}
                  openRoles={openRoles}
                  setDb={setDb}
                  canFire={canFire}
                  getEmployeeLoad={getEmployeeLoad}
                  openEditEmployee={openEditEmployee}
                />
              ))}
            </div>
          )}
          {deptsInKb.map(d => renderDepartment(d.id))}
        </div>
      ))}

      {deptsWithoutKb.length > 0 && (
        <div className="st-section">
          <div className="st-sec-head">
            <div className="st-sec-title">Отделы и сотрудники вне КБ</div>
            <div className="st-sec-sub">Подразделения прямого подчинения</div>
          </div>
          {deptsWithoutKb.map(d => renderDepartment(d.id))}
        </div>
      )}

      {canManageAllVacations(ur) && (
        <div className="st-section">
          <div className="st-sec-head">
            <div className="st-sec-title">Все отпуска</div>
            <button className="btn primary sm" onClick={() => openVacation(null, null)}><Ic d={ICONS.plus} size={13} /> Отпуск сотруднику</button>
          </div>
          <div style={{ padding: 14 }}>
            <table className="tbl">
              <thead><tr><th>Сотрудник</th><th>Отдел</th><th>Период</th><th>Тип</th><th>Делегирование</th><th>Статус</th><th></th></tr></thead>
              <tbody>
                {allVacs.map(v => {
                  const e = db.employees.find(x => x.id === v.empId);
                  if (e && e.fired) return null;
                  return (
                    <tr key={v.id}>
                      <td><b>{empName(v.empId)}</b></td>
                      <td>{getPrimaryDeptName(e, db)}</td>
                      <td>{fmtDMY(v.start)} - {fmtDMY(v.end)}</td>
                      <td>{VACATION_TYPES[v.type]}</td>
                      <td>{v.delegation.enabled ? `→ ${empName(v.delegation.subId)}` : '-'}</td>
                      <td><span className={`st-chip ${v.status}`}>
                        {{ pending: 'На утверждении', approved: 'Утверждён', rejected: 'Отклонён' }[v.status]}
                      </span></td>
                      <td>
                        <button className="icon-btn" onClick={() => openVacation(v.id, null)}><Ic d={ICONS.edit} size={14} /></button>
                        <button className="icon-btn danger" onClick={() => { setDb((s) => ({ ...s, vacations: s.vacations.filter(x => x.id !== v.id) })); }}><Ic d={ICONS.trash} size={14} /></button>
                      </td>
                    </tr>
                  );
                })}
                {allVacs.length === 0 && <tr><td colSpan="7" className="mut">Отпусков нет</td></tr>}
              </tbody>
            </table>
          </div>
        </div>
      )}

      {firedEmployees.length > 0 && canFire && (
        <div className="st-section">
          <div className="st-sec-head">
            <div className="st-sec-title">Архив сотрудников (уволенные)</div>
            <button className="btn ghost sm" onClick={() => setShowFired(!showFired)}>
              {showFired ? 'Скрыть' : 'Показать'}
            </button>
          </div>
          {showFired && (
            <div className="st-empty-box">
              {firedEmployees.map(e => (
                <EmployeeRow
                  key={e.id}
                  employee={e}
                  isFired={true}
                  db={db}
                  ur={ur}
                  openDepts={openDepts}
                  openRoles={openRoles}
                  setDb={setDb}
                  canFire={canFire}
                  getEmployeeLoad={getEmployeeLoad}
                  openEditEmployee={openEditEmployee}
                />
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

src/components/Table.jsx

import React from 'react';

export const Table = ({ columns, data, renderRow, emptyMessage = 'Нет данных' }) => (
  <table className="tbl">
    <thead><tr>{columns.map((col, i) => <th key={i}>{col}</th>)}</tr></thead>
    <tbody>
      {data.length ? data.map(renderRow) : <tr><td colSpan={columns.length} className="mut">{emptyMessage}</td></tr>}
    </tbody>
  </table>
);

src/components/views/

  • Folder: components/

src/components/views/ArchiveView.jsx

import React from 'react';
import Archive from '../Archive';
import { TODAY } from '../../utils/date';

export default function ArchiveView({ db, ur, openTask, openProject, setArchiveMonths, store }) {
  const restoreTask = (id) => {
    const t = db.tasks.find(x => x.id === id);
    store.upsertTask({ ...t, archived: false, archivedAt: null, closedAt: null, status: 'new' });
  };
  const restoreProject = (id) => {
    const p = db.projects.find(x => x.id === id);
    store.upsertProject({ ...p, archived: false, archivedAt: null, closedAt: null, status: 'active' });
    db.tasks.filter(t => t.projectId === id).forEach(t => {
      store.upsertTask({ ...t, archived: false, archivedAt: null, closedAt: null, status: 'new' });
    });
  };
  return (
    <Archive
      db={db}
      ur={ur}
      openTask={openTask}
      openProject={openProject}
      setArchiveMonths={setArchiveMonths}
      restoreTask={restoreTask}
      restoreProject={restoreProject}
    />
  );
}

src/components/views/CabinetView.jsx

import React from 'react';
import Cabinet from '../Cabinet';

export default function CabinetView({ store, data, user, openTask, openVacation, openDelegation }) {
  return (
    <Cabinet
      store={store}
      data={data}
      user={user}
      openTask={openTask}
      openVacation={openVacation}
      openDelegation={openDelegation}
    />
  );
}

src/components/views/index.js

export { default as KanbanView } from './TasksView';
export { default as ProjectsView } from './ProjectsView';
export { default as CabinetView } from './CabinetView';
export { default as StaffView } from './StaffView';
export { default as ReportsView } from './ReportsView';
export { default as ArchiveView } from './ArchiveView';
export { default as RequestsView } from './RequestsView';
export { default as JournalView } from './JournalView';

src/components/views/JournalView.jsx

import React from 'react';
import Journal from '../Journal';

export default function JournalView({ db, ur }) {
  return <Journal db={db} />;
}

src/components/views/ProjectsView.jsx

// src/components/views/ProjectsView.jsx
import React, { useState, useMemo } from 'react';
import Kanban from '../Kanban';
import Projects from '../Projects';
import { PROJECT_STATUSES, PROJECT_TYPES, PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITIES } from '../../utils/constants';
import { TODAY } from '../../utils/date';
import { computeScope, hasRole } from '../../utils/permissions';
import { Ic, ICONS } from '../Icons';
import Avatar from '../Avatar';
import { getProjectColor } from '../../utils/projectHelpers';
import ProjectProgress from '../ProjectProgress';

export default function ProjectsView({ db, ur, openProject, openHoursReq, store }) {
  const scope = useMemo(() => computeScope(ur, db), [ur, db]);
  const [viewMode, setViewMode] = useState('kanban');
  const [showOnlyMyProjects, setShowOnlyMyProjects] = useState(false);
  const canSeeAll = hasRole(ur, 'admin', 'director', 'economist', 'kb_chief', 'head', 'project_lead', 'project_manager');

  const [searchQuery, setSearchQuery] = useState('');
  const [filterStatus, setFilterStatus] = useState('all');
  const [filterType, setFilterType] = useState('all');
  const [filterPriority, setFilterPriority] = useState('all');
  const [filterParticipant, setFilterParticipant] = useState('all');
  const [filterDept, setFilterDept] = useState('all');

  const baseProjects = useMemo(() => {
    let list = scope.all
      ? db.projects.filter(p => !p.archived || p.status === 'closed' || p.status === 'cancelled')
      : db.projects.filter(p => (!p.archived || p.status === 'closed' || p.status === 'cancelled') && scope.projIds.has(p.id));
    if (showOnlyMyProjects) {
      const myTasks = db.tasks.filter(t => t.assigneeId === ur.id && !t.archived);
      const myProjectIds = new Set(myTasks.map(t => t.projectId));
      list = list.filter(p => myProjectIds.has(p.id));
    }
    return list;
  }, [db, scope, showOnlyMyProjects, ur.id]);

  const participantOptions = useMemo(() => {
    const activeProjectIds = new Set(db.projects.filter(p => !p.archived || p.status === 'closed' || p.status === 'cancelled').map(p => p.id));
    const involvedIds = new Set();
    db.tasks.forEach(t => {
      if (activeProjectIds.has(t.projectId) && !t.archived && t.assigneeId) {
        involvedIds.add(t.assigneeId);
      }
    });
    return db.employees.filter(e => involvedIds.has(e.id));
  }, [db]);

  const deptOptions = useMemo(() => {
    const deptIds = new Set();
    participantOptions.forEach(e => e.departments.forEach(d => deptIds.add(d.deptId)));
    return db.departments.filter(d => deptIds.has(d.id));
  }, [participantOptions, db.departments]);

  const filteredProjects = useMemo(() => {
    let list = baseProjects;

    if (searchQuery.trim()) {
      const q = searchQuery.trim().toLowerCase();
      list = list.filter(p =>
        p.name.toLowerCase().includes(q) ||
        (p.code && p.code.toLowerCase().includes(q))
      );
    }

    if (filterStatus !== 'all') {
      list = list.filter(p => p.status === filterStatus);
    }

    if (filterType !== 'all') {
      list = list.filter(p => (p.ptype || 'prod') === filterType);
    }

    if (filterPriority !== 'all') {
      list = list.filter(p => p.priority === filterPriority);
    }

    if (filterParticipant !== 'all') {
      const projectIdsWithParticipant = new Set();
      db.tasks.forEach(t => {
        if (!t.archived && t.assigneeId === filterParticipant) {
          projectIdsWithParticipant.add(t.projectId);
        }
      });
      list = list.filter(p => projectIdsWithParticipant.has(p.id));
    }

    if (filterDept !== 'all') {
      const projectIdsWithDept = new Set();
      db.tasks.forEach(t => {
        if (!t.archived && t.assigneeId) {
          const assignee = db.employees.find(e => e.id === t.assigneeId);
          if (assignee && assignee.departments.some(d => d.deptId === filterDept)) {
            projectIdsWithDept.add(t.projectId);
          }
        }
      });
      list = list.filter(p => projectIdsWithDept.has(p.id));
    }

    return list;
  }, [baseProjects, searchQuery, filterStatus, filterType, filterPriority, filterParticipant, filterDept, db]);

  const renderProjectCard = (project) => {
    const tasks = db.tasks.filter(t => t.projectId === project.id && !t.archived);
    const plan = tasks.reduce((s, t) => s + (t.plannedHours || 0), 0);
    const fact = tasks.reduce((s, t) => s + t.logs.reduce((lsum, l) => lsum + l.hours, 0), 0);
    const uniqueAssignees = [...new Set(tasks.map(t => t.assigneeId).filter(Boolean))];
    const canClose = () => {
      const creatorId = project.creatorId || project.history?.find(h => h.who !== 'system')?.who;
      return hasRole(ur, 'admin') || hasRole(ur, 'director') || (creatorId && creatorId === ur.id);
    };
    const projectColor = getProjectColor(project);
    return (
      <div onClick={() => openProject(project.id)}>
        <div className="kcard-title">{project.name}</div>
        <div className="kcard-proj">
          <span className="pdot" style={{ background: projectColor }} />
          {project.code}
        </div>
        <div className="kcard-meta">
          <span className="mut sm">{PROJECT_STATUSES[project.ptype || 'prod']}</span>
          <span className="mut sm" style={{ marginLeft: 8, color: PROJECT_PRIORITIES[project.priority]?.color || '#64748b' }}>
            {project.priority || 'NORM'}
          </span>
          <ProjectProgress project={project} plan={plan} fact={fact} />
        </div>
        <div className="kcard-foot">
          <div className="pj-avatars" style={{ flex: 1 }}>
            {uniqueAssignees.slice(0,4).map(id => {
              const a = db.employees.find(e => e.id === id);
              return a ? <Avatar key={id} employee={a} size="xs" /> : null;
            })}
            {uniqueAssignees.length > 4 && <span className="mut sm">+{uniqueAssignees.length-4}</span>}
          </div>
          <div className="pj-actions" onClick={(e) => e.stopPropagation()}>
            {canClose() && project.status !== 'closed' && project.status !== 'cancelled' && (
              <button className="icon-btn danger" title="Закрыть/Отменить проект" onClick={() => {
                const action = window.confirm(`Закрыть проект "${project.name}"?`) ? 'close' : window.confirm(`Отменить проект "${project.name}"?`) ? 'cancel' : null;
                if (action === 'close') store.upsertProject({ ...project, status: 'closed', closedAt: TODAY });
                else if (action === 'cancel') store.upsertProject({ ...project, status: 'cancelled' });
              }}>
                <Ic d={ICONS.x} size={15} />
              </button>
            )}
          </div>
        </div>
      </div>
    );
  };

  const handleMoveProject = (id, newStatus) => {
    const project = db.projects.find(p => p.id === id);
    if (project && project.status !== newStatus) {
      store.upsertProject({ ...project, status: newStatus });
    }
  };

  const closeProject = (p) => store.upsertProject({ ...p, status: 'closed', closedAt: TODAY });
  const cancelProject = (p) => store.upsertProject({ ...p, status: 'cancelled' });

  const canCreateProject = hasRole(ur, 'admin', 'director', 'kb_chief', 'project_manager');

  return (
    <>
      <div className="toolbar">
        <div className="btn-group">
          <button className={`btn ghost sm ${viewMode === 'list' ? 'active' : ''}`} onClick={() => setViewMode('list')}>
            <Ic d={ICONS.list} size={15} /> Список
          </button>
          <button className={`btn ghost sm ${viewMode === 'kanban' ? 'active' : ''}`} onClick={() => setViewMode('kanban')}>
            <Ic d={ICONS.kanban} size={15} /> Канбан
          </button>
        </div>

        <input
          className="inp sm filter-search"
          placeholder="Поиск..."
          value={searchQuery}
          onChange={e => setSearchQuery(e.target.value)}
        />

        <select className="inp sel sm filter-select" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
          <option value="all">Статус</option>
          {Object.entries(PROJECT_STATUSES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
        </select>

        <select className="inp sel sm filter-select" value={filterType} onChange={e => setFilterType(e.target.value)}>
          <option value="all">Тип</option>
          {Object.entries(PROJECT_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
        </select>

        <select className="inp sel sm filter-select" value={filterPriority} onChange={e => setFilterPriority(e.target.value)}>
          <option value="all">Приоритет</option>
          {Object.entries(PROJECT_PRIORITIES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
        </select>

        <select className="inp sel sm filter-select" value={filterParticipant} onChange={e => setFilterParticipant(e.target.value)}>
          <option value="all">Участник</option>
          {participantOptions.map(emp => <option key={emp.id} value={emp.id}>{emp.last} {emp.first}</option>)}
        </select>

        <select className="inp sel sm filter-select filter-select-dept" value={filterDept} onChange={e => setFilterDept(e.target.value)}>
          <option value="all">Отдел</option>
          {deptOptions.map(dept => <option key={dept.id} value={dept.id}>{dept.name}</option>)}
        </select>

        {canSeeAll && (
          <label className="dept-pick" style={{ marginLeft: 'auto' }}>
            <input type="checkbox" checked={showOnlyMyProjects} onChange={e => setShowOnlyMyProjects(e.target.checked)} />
            <span>Проекты с моими задачами</span>
          </label>
        )}

        {canCreateProject && (
          <button className="btn primary" onClick={() => openProject(null)}>
            <Ic d={ICONS.plus} size={15} /> Проект
          </button>
        )}
      </div>

      {viewMode === 'kanban' ? (
        <Kanban
          items={filteredProjects}
          statusOrder={PROJECT_STATUS_ORDER}
          statusMap={PROJECT_STATUS_CONFIG}
          renderCard={renderProjectCard}
          onDrop={handleMoveProject}
          columns={4}
        />
      ) : (
        <Projects
          db={db}
          ur={ur}
          openProject={openProject}
          openHoursReq={openHoursReq}
          closeProject={closeProject}
          cancelProject={cancelProject}
          projects={filteredProjects}
        />
      )}
    </>
  );
}

src/components/views/ReportsView.jsx

import React from 'react';
import Reports from '../Reports';

export default function ReportsView({ db, ur }) {
  return <Reports db={db} ur={ur} />;
}

src/components/views/RequestsView.jsx

import React from 'react';
import Requests from '../Requests';

export default function RequestsView({ db, setDb, ur, addAudit }) {
  return <Requests db={db} setDb={setDb} ur={ur} initialTab="hours" addAudit={addAudit} />;
}

src/components/views/StaffView.jsx

import React from 'react';
import Staff from '../Staff';

export default function StaffView({ db, ur, setDb, openRoles, openDepts, openVacation }) {
  return (
    <Staff
      db={db}
      ur={ur}
      setDb={setDb}
      openRoles={openRoles}
      openDepts={openDepts}
      openVacation={openVacation}
    />
  );
}

src/components/views/TasksList.jsx

// src/components/TasksList.jsx
import React from 'react';
import { TASK_STATUSES, PRIORITIES } from '../../utils/constants';
import { fmtDMY, TODAY } from '../../utils/date';
import Avatar from '../Avatar';

export default function TasksList({ tasks, db, openTask }) {
  if (!tasks.length) {
    return <div className="empty-note" style={{ padding: '40px 0' }}>Нет задач, соответствующих фильтрам</div>;
  }

  return (
    <div className="pj-grid">
      {tasks.map(task => {
        const project = db.projects.find(p => p.id === task.projectId);
        const assignee = task.assigneeId ? db.employees.find(e => e.id === task.assigneeId) : null;
        const factHours = task.logs.reduce((sum, log) => sum + log.hours, 0);
        const overdue = task.deadline && !['closed','cancelled'].includes(task.status) && task.deadline < TODAY;
        const status = TASK_STATUSES[task.status]?.label || task.status;
        const priority = PRIORITIES[task.priority]?.label || task.priority;
        const priorityColor = PRIORITIES[task.priority]?.color || '#e2e8f0';

        return (
          <div 
            key={task.id} 
            className="pj-card" 
            onClick={() => openTask(task.id)}
            style={{ cursor: 'pointer' }}
          >
            <div className="pj-top">
              <span className="pj-code" style={{ background: project?.color + '22', color: project?.color || '#64748b' }}>
                {project?.code || 'Без проекта'}
              </span>
              <span className={`pj-st ${task.status}`}>{status}</span>
              <span style={{ fontSize: '11px', color: '#64748b' }}>{priority}</span>
            </div>
            <div className="pj-name">{task.title}</div>
            <div className="pj-row">
              <span className="mut">Срок исполнения: </span> 
              {task.deadline ? (
                <span className={overdue ? 'red' : ''}>{fmtDMY(task.deadline)}</span>
              ) : 'не задан'}
            </div>
            <div className="pj-row">
              <span className="mut">Ответственный: </span>
              <span>
                {assignee ? `${assignee.last} ${assignee.first}` : 'не назначен'}
              </span>
            </div>
            <div className="pj-budget" style={{ marginTop: 8 }}>
              <div className="pj-budget-row">
                <span>Часы: <b>{factHours}</b> / <b>{task.plannedHours ?? '-'}</b></span>
              </div>
              {task.plannedHours > 0 && (
                <div className="pj-progress">
                  <div 
                    className="pj-progress-fill" 
                    style={{ width: Math.min(100, (factHours / task.plannedHours) * 100) + '%', background: project?.color || '#3b82f6' }} 
                  />
                </div>
              )}
            </div>
            <div className="pj-foot">
              {overdue && <span style={{ color: '#dc2626', fontSize: 12, fontWeight: 600 }}>Просрочено</span>}
            </div>
          </div>
        );
      })}
    </div>
  );
}

src/components/views/TasksView.jsx

// src/components/views/TasksView.jsx
import React, { useState, useMemo } from 'react';
import Kanban from '../Kanban';
import TasksList from './TasksList';
import { TASK_STATUSES, TASK_STATUS_ORDER, PRIORITIES } from '../../utils/constants';
import { fmtDMY, daysDiff, TODAY, isTaskActive } from '../../utils/date';
import { taskVisible, computeScope, hasRole, canChangeTaskStatus, canCreateTask } from '../../utils/permissions';
import { useToast } from '../../context/ToastContext';
import { ICONS, Ic } from '../Icons';
import { useDataHelpers } from '../../hooks';
import Avatar from '../Avatar';
import { getProjectColor } from '../../utils/projectHelpers';

export default function TasksView({ db, ur, openTask, store }) {
  const { showToast } = useToast();
  const { getTaskSpent } = useDataHelpers(db);
  const scope = useMemo(() => computeScope(ur, db), [ur, db]);
  const canSeeAll = hasRole(ur, 'admin', 'director', 'economist', 'kb_chief', 'head', 'project_lead', 'project_manager');

  const [viewMode, setViewMode] = useState('kanban');
  const [fProj, setFProj] = useState('all');
  const [fExec, setFExec] = useState('all');
  const [fPrio, setFPrio] = useState('all');
  const [fDept, setFDept] = useState('all');
  const [q, setQ] = useState('');
  const [showOnlyMy, setShowOnlyMy] = useState(false);

  const filteredTasks = useMemo(() => {
    let list = db.tasks.filter(t => isTaskActive(t) && taskVisible(ur, scope, t, db));
    if (fProj !== 'all') list = list.filter(t => t.projectId === fProj);
    if (fExec !== 'all') list = list.filter(t => t.assigneeId === fExec); // <-- один исполнитель
    if (fPrio !== 'all') list = list.filter(t => t.priority === fPrio);
    if (fDept !== 'all') {
      list = list.filter(t => {
        const assignee = t.assigneeId ? db.employees.find(e => e.id === t.assigneeId) : null;
        return assignee && assignee.departments.some(d => d.deptId === fDept);
      });
    }
    if (q.trim()) {
      const s = q.trim().toLowerCase();
      list = list.filter(t =>
        t.title.toLowerCase().includes(s) ||
        (db.projects.find(p => p.id === t.projectId)?.name || '').toLowerCase().includes(s)
      );
    }
    if (showOnlyMy) list = list.filter(t => t.assigneeId === ur.id); // <-- один исполнитель
    return list;
  }, [db, ur, scope, fProj, fExec, fPrio, fDept, q, showOnlyMy]);

  const projOptions = useMemo(() => {
    const ids = new Set(filteredTasks.map(t => t.projectId).filter(Boolean));
    return db.projects.filter(p => ids.has(p.id) && !p.archived);
  }, [filteredTasks, db.projects]);

  const execOptions = useMemo(() => {
    const ids = new Set(filteredTasks.map(t => t.assigneeId).filter(Boolean));
    return db.employees.filter(e => ids.has(e.id));
  }, [filteredTasks, db.employees]);

  const isOnlyExecutor = ur.roles.length === 1 && ur.roles[0] === 'executor';

  const renderTaskCard = (task) => {
    const p = db.projects.find(x => x.id === task.projectId);
    const assignee = task.assigneeId ? db.employees.find(e => e.id === task.assigneeId) : null;
    const sp = getTaskSpent(task);
    const overdue = task.deadline && !['closed','cancelled'].includes(task.status) && task.deadline < TODAY;
    const soon = task.deadline && !overdue && !['closed','cancelled'].includes(task.status) && daysDiff(TODAY, task.deadline) <= 3;
    const priority = PRIORITIES[task.priority] || { label: task.priority || 'Нет', color: '#64748b' };
    return (
      <div onClick={() => openTask(task.id)}>
        <div className="kcard-prio" style={{ background: priority.color }} />
        <div className="kcard-title">{task.title}</div>
        <div className="kcard-proj">
          <span className="pdot" style={{ background: getProjectColor(p) }} />
          {p?.code}
        </div>
        <div className="kcard-meta">
          {assignee && (
            <span className="kassignee">
              <Avatar employee={assignee} size="xs" />
            </span>
          )}
          <span className="khours"><Ic d={ICONS.clock} size={13} /> {sp}/{task.plannedHours ?? '-'} ч</span>
          <span className="prio-chip" style={{ color: priority.color, fontWeight: 700, fontSize: '12px' }}>
            {priority.label}
          </span>
        </div>
        <div className="kcard-foot">
          <span className={'kdl' + (overdue ? ' late' : soon ? ' soon' : '')}>
            {task.deadline ? (overdue ? `просрочено ${-daysDiff(TODAY, task.deadline)} дн` : `до ${fmtDMY(task.deadline)}`) : 'без дедлайна'}
          </span>
        </div>
      </div>
    );
  };

  const handleMoveTask = (taskId, newStatus) => {
    const task = db.tasks.find(t => t.id === taskId);
    if (!task) return;
    if (!canChangeTaskStatus(ur, task, newStatus, db)) {
      showToast('У вас нет прав на изменение статуса этой задачи.', 'error');
      return;
    }
    const isClosing = (newStatus === 'closed' || newStatus === 'cancelled') && task.status !== newStatus;
    const updatedTask = {
      ...task,
      status: newStatus,
      closedAt: isClosing ? TODAY : task.closedAt,
      archived: isClosing ? true : task.archived,
      archivedAt: isClosing ? TODAY : task.archivedAt,
      history: [...task.history, { ts: Date.now(), who: ur.id, text: `Статус → ${TASK_STATUSES[newStatus].label}` }]
    };
    store.upsertTask(updatedTask);
  };

  const canCreate = canCreateTask(ur);

  return (
    <>
      <div className="toolbar">
        <div className="btn-group">
          <button className={`btn ghost sm ${viewMode === 'list' ? 'active' : ''}`} onClick={() => setViewMode('list')}>
            <Ic d={ICONS.list} size={15} /> Список
          </button>
          <button className={`btn ghost sm ${viewMode === 'kanban' ? 'active' : ''}`} onClick={() => setViewMode('kanban')}>
            <Ic d={ICONS.kanban} size={15} /> Канбан
          </button>
        </div>

        <input
          className="inp sm filter-search"
          placeholder="Поиск..."
          value={q}
          onChange={e => setQ(e.target.value)}
        />

        <select className="inp sel sm filter-select" value={fProj} onChange={e => setFProj(e.target.value)}>
          <option value="all">Проект</option>
          {projOptions.map(p => <option key={p.id} value={p.id}>{p.code}</option>)}
        </select>

        {!isOnlyExecutor && (
          <select className="inp sel sm filter-select" value={fExec} onChange={e => setFExec(e.target.value)}>
            <option value="all">Исполнитель</option>
            {execOptions.map(e => <option key={e.id} value={e.id}>{e.last}</option>)}
          </select>
        )}

        <select className="inp sel sm filter-select" value={fPrio} onChange={e => setFPrio(e.target.value)}>
          <option value="all">Приоритет</option>
          {Object.entries(PRIORITIES).map(([k,v]) => <option key={k} value={k}>{v.label}</option>)}
        </select>

        {!isOnlyExecutor && (
          <select className="inp sel sm filter-select" value={fDept} onChange={e => setFDept(e.target.value)}>
            <option value="all">Отдел</option>
            {db.departments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
          </select>
        )}

        {canSeeAll && (
          <label className="dept-pick" style={{ marginLeft: 'auto' }}>
            <input type="checkbox" checked={showOnlyMy} onChange={e => setShowOnlyMy(e.target.checked)} />
            <span>Мои задачи</span>
          </label>
        )}

        {canCreate && (
          <button className="btn primary" onClick={() => openTask(null)}>
            <Ic d={ICONS.plus} size={15} /> Создать
          </button>
        )}
      </div>

      {viewMode === 'kanban' ? (
        <Kanban
          items={filteredTasks}
          statusOrder={TASK_STATUS_ORDER}
          statusMap={TASK_STATUSES}
          renderCard={renderTaskCard}
          onDrop={handleMoveTask}
        />
      ) : (
        <TasksList tasks={filteredTasks} db={db} openTask={openTask} />
      )}
    </>
  );
}

src/components/Modals/

  • Folder: components/

src/components/Modals/DelegationModal.jsx

import React, { useState } from 'react';
import { Modal } from '../Modal';
import { ROLES } from '../../utils/constants';
import { TODAY, iso, addDays, uid } from '../../utils/date';

export const DelegationModal = ({ db, ur, onClose, onSubmit }) => {
  const [toId, setToId] = useState("");
  const [roles, setRoles] = useState([]);
  const [start, setStart] = useState(TODAY);
  const [end, setEnd] = useState(iso(addDays(new Date(), 14)));
  const [openEnd, setOpenEnd] = useState(false);
  const [reason, setReason] = useState("");
  const allowed = ur.roles.filter((r) => !["admin", "director", "executor"].includes(r));
  return (
    <Modal title="Временная передача ролей" onClose={onClose} width={520}>
      <div className="form-grid">
        <label className="lbl">Сотрудник-получатель *</label>
        <select className="inp sel" value={toId} onChange={(e) => setToId(e.target.value)}>
          <option value="">- выберите -</option>
          {db.employees.filter((e) => e.id !== ur.id).map((e) => <option key={e.id} value={e.id}>{e.last} {e.first}</option>)}
        </select>
        <label className="lbl">Передаваемые роли *</label>
        <div className="sub-picks">
          {allowed.length ? allowed.map((r) => <label key={r} className="dept-pick"><input type="checkbox" checked={roles.includes(r)} onChange={() => setRoles((s) => s.includes(r) ? s.filter((x) => x !== r) : [...s, r])} />{ROLES[r].label}</label>) : <span className="mut sm">Нет ролей, доступных для передачи</span>}
        </div>
        <label className="lbl">Дата начала *</label><input className="inp" type="date" value={start} onChange={(e) => setStart(e.target.value)} />
        <label className="lbl">Дата окончания</label>
        <div className="duo"><input className="inp" type="date" disabled={openEnd} value={end} onChange={(e) => setEnd(e.target.value)} /><label className="dept-pick"><input type="checkbox" checked={openEnd} onChange={(e) => setOpenEnd(e.target.checked)} /> до отмены</label></div>
        <label className="lbl">Обоснование *</label><textarea className="inp" rows="2" value={reason} onChange={(e) => setReason(e.target.value)} />
      </div>
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" disabled={!toId || !roles.length || !reason.trim()} onClick={() => onSubmit({ id: uid(), fromId: ur.id, toId, roles, start, end: openEnd ? null : end, reason: reason.trim(), status: "pending" })}>Отправить запрос</button>
      </div>
    </Modal>
  );
};

src/components/Modals/DeptsModal.jsx

import React, { useState } from 'react';
import { Modal } from '../Modal';

export const DeptsModal = ({ db, setDb, empId, onClose, toast, audit }) => {
  const emp = db.employees.find((e) => e.id === empId);
  const [sel, setSel] = useState(emp.departments.map(d => ({ ...d })));

  const toggle = (deptId) => {
    setSel((s) => {
      if (s.some((x) => x.deptId === deptId)) {
        const next = s.filter((x) => x.deptId !== deptId);
        // если после удаления не осталось primary, делаем первый primary
        if (next.length && !next.some((x) => x.primary)) {
          next[0].primary = true;
        }
        return next;
      }
      // добавляем новый отдел как неосновной (primary: false)
      return [...s, { deptId, primary: s.length === 0, position: '' }];
    });
  };

  const setPrimary = (deptId) => {
    setSel((s) => s.map((x) => ({ ...x, primary: x.deptId === deptId })));
  };

  const setPosition = (deptId, pos) => {
    setSel((s) => s.map((x) => (x.deptId === deptId ? { ...x, position: pos } : x)));
  };

  const save = () => {
    if (!sel.length) return toast("Выберите хотя бы одно подразделение", "err");
    if (!sel.some((x) => x.primary)) return toast("Укажите основное подразделение", "err");
    // Обновляем сотрудника
    const before = emp.departments.map((x) => `${x.deptId}:${x.position || ''}`).join(',');
    const after = sel.map((x) => `${x.deptId}:${x.position || ''}`).join(',');
    setDb((s) => ({
      ...s,
      employees: s.employees.map((e) =>
        e.id === empId
          ? { ...e, departments: sel.map(({ deptId, primary, position }) => ({ deptId, primary, position: position?.trim() || '' })) }
          : e
      ),
    }));
    audit("Изменение подразделений сотрудника", `${emp.last} ${emp.first}: [${before}] → [${after}]`);
    toast("Подразделения обновлены");
    onClose();
  };

  return (
    <Modal title={`Подразделения - ${emp.last} ${emp.first}`} onClose={onClose} width={560}>
      <p className="mut sm">
        Сотрудник может числиться в нескольких отделах. Отметьте основное подразделение. 
        <strong> Для дополнительных (совмещаемых) отделов вы можете указать отдельную должность.</strong>
      </p>
      <div className="roles-list">
        {db.departments.map((d) => {
          const cur = sel.find((x) => x.deptId === d.id);
          const kb = db.kbs.find((k) => k.id === d.kbId);
          const isPrimary = cur && cur.primary;
          const isExtra = cur && !cur.primary;
          return (
            <div key={d.id} style={{ marginBottom: 8 }}>
              <label className="roles-item" style={{ border: 'none', padding: 0 }}>
                <input type="checkbox" checked={!!cur} onChange={() => toggle(d.id)} />
                <span style={{ flex: 1 }}>
                  {d.name} <span className="mut sm">{kb ? `· ${kb.name}` : "· вне КБ"}</span>
                </span>
                {cur && (
                  <button className={"btn ghost sm" + (isPrimary ? " prim-btn" : "")} onClick={(e) => { e.preventDefault(); setPrimary(d.id); }}>
                    {isPrimary ? "основное ✓" : "сделать основным"}
                  </button>
                )}
              </label>
              {/* Поле для дополнительной должности показываем только для НЕосновных отделов */}
              {isExtra && (
                <div style={{ marginLeft: 28, marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
                  <label className="lbl" style={{ margin: 0, fontSize: 12 }}>Должность в этом отделе (дополнительная):</label>
                  <input
                    className="inp"
                    style={{ flex: 1, padding: '6px 10px', fontSize: 14 }}
                    value={cur.position || ''}
                    onChange={(e) => setPosition(d.id, e.target.value)}
                    placeholder="Например: Ведущий инженер (совмещение)"
                  />
                </div>
              )}
              {/* Для основного отдела не показываем поле - должность редактируется в карточке сотрудника */}
            </div>
          );
        })}
      </div>
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" onClick={save}>Сохранить</button>
      </div>
    </Modal>
  );
};

src/components/Modals/HoursRequestModal.jsx

import React, { useState } from 'react';
import { Modal } from '../Modal';
import { uid } from '../../utils/date';

export const HoursRequestModal = ({ db, ur, kind, targetId, onClose, onSubmit }) => {
  const target = kind === "task" ? db.tasks.find((t) => t.id === targetId) : db.projects.find((p) => p.id === targetId);
  const cur = kind === "task" ? target?.plannedHours : target?.budget;
  const [newH, setNewH] = useState(cur);
  const [reason, setReason] = useState("");
  return (
    <Modal title={`Запрос изменения часов - ${kind === "task" ? "задача" : "бюджет проекта"}`} onClose={onClose} width={480}>
      <p className="mut sm">{kind === "task" ? target?.title : target?.name}. Запрос будет направлен генеральному директору.</p>
      <div className="form-grid">
        <label className="lbl">Текущее значение</label><input className="inp" disabled value={(cur ?? "-") + " ч"} />
        <label className="lbl">Новое значение *</label><input className="inp" type="number" min="1" step="0.5" value={newH} onChange={(e) => setNewH(e.target.value)} />
        <label className="lbl">Обоснование *</label><textarea className="inp" rows="3" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Почему требуется изменение…" />
      </div>
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" disabled={!reason.trim() || !newH} onClick={() => onSubmit({ id: uid(), kind, targetId, oldH: cur, newH: +newH, reason: reason.trim(), reqId: ur.id, status: "pending", ts: Date.now() })}>Отправить запрос</button>
      </div>
    </Modal>
  );
};

src/components/Modals/index.jsx

export { TaskModal } from './TaskModal';
export { ProjectModal } from './ProjectModal';
export { HoursRequestModal } from './HoursRequestModal';
export { RolesModal } from './RolesModal';
export { DeptsModal } from './DeptsModal';
export { VacationModal } from './VacationModal';
export { DelegationModal } from './DelegationModal';
export { VacNowModal } from './VacNowModal';

src/components/Modals/ProjectModal.jsx

// src/components/modals/ProjectModal.jsx
import React, { useState, useMemo, useEffect } from 'react';
import { Modal } from '../Modal';
import Discussion from '../Discussion';
import { useDataHelpers } from '../../hooks';
import {
  PROJECT_STATUSES, PROJECT_TYPES, TASK_STATUSES, PROJECT_PRIORITIES,
  ADMIN_PROJECT_PRIORITIES,
} from '../../utils/constants';
import {
  TODAY, iso, addDays, uid, fmtDT, fmtDMY,
} from '../../utils/date';
import {
  hasRole, computeScope, canEditProjectFields, canChangeProjectStatus, canCreateProject,
} from '../../utils/permissions';
import { Ic, ICONS } from '../Icons';
import { getProjectColor } from '../../utils/projectHelpers';

const AIRCRAFT_TYPES = [
  'Су-57', 'МиГ-35', 'Ту-160', 'Ил-76', 'Ка-52', 'Другой'
];
const PROJECT_TYPE_OPTIONS = ['Ремонт', 'Модификация', 'КС', 'ИКУ'];

const ADMIN_PRIORITY_MAP = {
  AOG: 'high',
  CRIT: 'mid',
  NORM: 'low',
};

export const ProjectModal = ({ db, ur, projectId, onClose, onSave, onDelete, toast, openTask, store }) => {
  const existing = projectId ? db.projects.find((p) => p.id === projectId) : null;
  const { empName, getTaskSpent } = useDataHelpers(db);
  const scope = computeScope(ur, db);
  const isExec = !scope.all && !hasRole(ur, 'director', 'economist', 'kb_chief', 'head', 'project_lead');

  const getInitialPriority = (proj) => {
    if (!proj) return 'NORM';
    if (proj.ptype === 'admin' && ADMIN_PRIORITY_MAP[proj.priority]) {
      return ADMIN_PRIORITY_MAP[proj.priority];
    }
    return proj.priority || 'NORM';
  };

  const [f, setF] = useState(existing ? { ...existing, priority: getInitialPriority(existing) } : {
    id: "p_" + uid(),
    code: "",
    name: "",
    desc: "",
    kbId: "",
    managerId: "",
    start: TODAY,
    end: iso(addDays(new Date(), 30)),
    status: "active",
    budget: 100,
    color: PROJECT_PRIORITIES['NORM'].color,
    ptype: "prod",
    longterm: false,
    archived: false,
    archivedAt: null,
    closedAt: null,
    creatorId: ur.id,
    customer: "",
    aircraftType: "",
    projectType: "",
    priority: "NORM",
    comments: existing?.comments || [],
    history: existing?.history || [{ ts: Date.now(), who: ur.id, text: "Проект создан" }],
    files: existing?.files || [],
  });

  const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
  const isAdminType = f.ptype === "admin";

  useEffect(() => {
    if (isAdminType) {
      if (!ADMIN_PRIORITY_MAP[f.priority] && !Object.keys(ADMIN_PROJECT_PRIORITIES).includes(f.priority)) {
        setF(prev => ({ ...prev, priority: 'high' }));
      }
    } else {
      if (!PROJECT_PRIORITIES[f.priority]) {
        setF(prev => ({ ...prev, priority: 'NORM' }));
      }
    }
  }, [isAdminType, f.priority]);

  const isNew = !existing;
  const canEditFields = isNew ? canCreateProject(ur) : canEditProjectFields(ur, f);
  const canChangeStatus = isNew ? canCreateProject(ur) : canChangeProjectStatus(ur, f, f.status);

  const [tab, setTab] = useState('info');
  const [taskSortField, setTaskSortField] = useState('created');
  const [taskSortDir, setTaskSortDir] = useState('desc');

  const handlePriorityChange = (val) => {
    setF(prev => ({ ...prev, priority: val, color: getProjectColor({ ...prev, priority: val }) }));
  };

  const statusOptions = [
    { value: 'active', label: 'Активный' },
    { value: 'inactive', label: 'Неактивный' },
    { value: 'closed', label: 'Закрыт' },
    { value: 'cancelled', label: 'Отменён' }
  ];
  const creationStatusOptions = [
    { value: 'active', label: 'Активный' },
    { value: 'inactive', label: 'Неактивный' }
  ];

  const save = () => {
    if (!f.name.trim()) return toast("Укажите название проекта", "err");
    if (!f.code.trim()) return toast("Укажите код проекта", "err");
    if (!f.start) return toast("Укажите дату начала", "err");
    if (!f.customer?.trim()) return toast("Укажите заказчика", "err");
    if (!isAdminType) {
      if (!f.aircraftType) return toast("Выберите тип ВС", "err");
      if (!f.projectType) return toast("Выберите категорию", "err");
      if (!f.managerId) return toast("Для производственного проекта ответственный обязателен", "err");
      if (!f.end) return toast("Для производственного проекта дата окончания обязательна", "err");
      if (!f.kbId) return toast("Для производственного проекта необходимо выбрать подразделение (КБ)", "err");
    }
    const budgetValue = f.budget !== '' && f.budget != null ? +f.budget : null;
    if (!isAdminType && (!f.budget || +f.budget <= 0)) {
      return toast("Для производственного проекта бюджет обязателен и должен быть больше 0", "err");
    }
    if (!f.priority) return toast("Выберите приоритет проекта", "err");

    const finalColor = getProjectColor(f);
    onSave({ 
      ...f, 
      color: finalColor, 
      kbId: f.kbId || null, 
      budget: budgetValue,
      managerId: isAdminType ? "" : f.managerId, 
      end: isAdminType ? null : f.end, 
      longterm: false 
    }, !existing);
  };

  const taskList = useMemo(() => {
    if (!existing) return [];
    let list = db.tasks.filter(t => t.projectId === projectId);
    if (!existing.archived) {
      list = list.filter(t => !t.archived);
    }
    if (isExec) {
      list = list.filter(t => t.assigneeId === ur.id);
    }

    const sortFn = (a, b) => {
      let valA, valB;
      switch (taskSortField) {
        case 'title':
          valA = a.title.toLowerCase();
          valB = b.title.toLowerCase();
          break;
        case 'assignees':
          const assigneeA = a.assigneeId ? db.employees.find(e => e.id === a.assigneeId) : null;
          const assigneeB = b.assigneeId ? db.employees.find(e => e.id === b.assigneeId) : null;
          valA = assigneeA ? assigneeA.last.toLowerCase() : '';
          valB = assigneeB ? assigneeB.last.toLowerCase() : '';
          break;
        case 'status':
          valA = a.status;
          valB = b.status;
          break;
        case 'planned':
          valA = a.plannedHours ?? -1;
          valB = b.plannedHours ?? -1;
          break;
        case 'fact':
          valA = a.logs.reduce((s, l) => s + l.hours, 0);
          valB = b.logs.reduce((s, l) => s + l.hours, 0);
          break;
        case 'remaining':
          valA = (a.plannedHours || 0) - a.logs.reduce((s, l) => s + l.hours, 0);
          valB = (b.plannedHours || 0) - b.logs.reduce((s, l) => s + l.hours, 0);
          break;
        case 'creator':
          const getCreator = (task) => {
            const id = task.history?.find(h => h.who !== 'system')?.who || task.history?.[0]?.who;
            return id ? db.employees.find(e => e.id === id)?.last || '' : '';
          };
          valA = getCreator(a).toLowerCase();
          valB = getCreator(b).toLowerCase();
          break;
        case 'deadline':
          valA = a.deadline || '';
          valB = b.deadline || '';
          break;
        case 'created':
        default:
          valA = a.history?.[0]?.ts || 0;
          valB = b.history?.[0]?.ts || 0;
          break;
      }
      if (valA < valB) return taskSortDir === 'asc' ? -1 : 1;
      if (valA > valB) return taskSortDir === 'asc' ? 1 : -1;
      return 0;
    };
    list.sort(sortFn);
    return list;
  }, [db, projectId, ur.id, isExec, existing, taskSortField, taskSortDir]);

  const handleSort = (field) => {
    if (taskSortField === field) {
      setTaskSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
    } else {
      setTaskSortField(field);
      setTaskSortDir('asc');
    }
  };

  const canComment = hasRole(ur, 'admin', 'director', 'kb_chief', 'project_lead', 'project_manager');
  const isProjectManager = existing && existing.managerId === ur.id;
  const canCommentFinal = canComment || isProjectManager;

  const canUploadFiles = hasRole(ur, 'admin', 'director', 'project_manager') || (existing && existing.managerId === ur.id);

  const candidates = useMemo(() => {
    const ids = new Set(
      db.tasks
        .filter((t) => t.projectId === f.id)
        .map((t) => t.assigneeId)
        .filter(Boolean)
    );
    if (f.managerId) ids.add(f.managerId);
    return [...ids]
      .map((id) => db.employees.find((e) => e.id === id))
      .filter(Boolean);
  }, [db, f.id, f.managerId]);

  const handleUpdateComments = (newComments) => {
    setF(prev => ({ ...prev, comments: newComments }));
    if (existing && store) {
      const updatedProject = { ...f, comments: newComments };
      store.upsertProject(updatedProject);
    }
  };

  const handleCommentAdded = () => {};

  const handleFileUpload = (e) => {
    const file = e.target.files[0];
    if (!file) return;
    if (file.size > 10 * 1024 * 1024) {
      toast("Файл слишком большой (максимум 10 МБ)", "err");
      e.target.value = '';
      return;
    }
    const reader = new FileReader();
    reader.onload = (ev) => {
      const fileData = ev.target.result;
      const newFile = {
        id: uid(),
        name: file.name,
        size: file.size,
        url: fileData,
        uploadedBy: ur.id,
        uploadedAt: new Date().toISOString(),
      };
      const updatedFiles = [...(f.files || []), newFile];
      setF(prev => ({ ...prev, files: updatedFiles }));
      if (existing && store) {
        const updatedProject = { ...f, files: updatedFiles };
        store.upsertProject(updatedProject);
      }
      toast("Файл загружен");
      e.target.value = '';
    };
    reader.readAsDataURL(file);
  };

  const handleFileDelete = (fileId) => {
    if (!window.confirm("Удалить файл?")) return;
    const updatedFiles = (f.files || []).filter(file => file.id !== fileId);
    setF(prev => ({ ...prev, files: updatedFiles }));
    if (existing && store) {
      const updatedProject = { ...f, files: updatedFiles };
      store.upsertProject(updatedProject);
    }
    toast("Файл удалён");
  };

  const priorityOptions = isAdminType ? ADMIN_PROJECT_PRIORITIES : PROJECT_PRIORITIES;

  return (
    <Modal title={existing ? (canEditFields ? "Проект (Редактирование)" : "Проект (Просмотр)") : "Новый проект"} onClose={onClose} width={760}>
      {existing && existing.archived && <div className="info-box">Этот проект находится в архиве. Редактирование недоступно.</div>}

      <div className="tabs sm">
        <button className={`tab${tab === 'info' ? ' on' : ''}`} onClick={() => setTab('info')}>Информация</button>
        <button className={`tab${tab === 'tasks' ? ' on' : ''}`} onClick={() => setTab('tasks')}>Задачи ({taskList.length})</button>
        <button className={`tab${tab === 'discussion' ? ' on' : ''}`} onClick={() => setTab('discussion')}>Обсуждение ({f.comments?.length || 0})</button>
        <button className={`tab${tab === 'files' ? ' on' : ''}`} onClick={() => setTab('files')}>Файлы ({f.files?.length || 0})</button>
      </div>

      {tab === 'info' && (
        <div className="project-info-fields">
          <div className="field-row">
            <label className="field-label">Название *</label>
            <input className="inp" disabled={!canEditFields} value={f.name} onChange={(e) => set("name", e.target.value)} />
          </div>

          <div className="field-row">
            <label className="field-label">Описание</label>
            <textarea className="inp" rows="2" disabled={!canEditFields} value={f.desc} onChange={(e) => set("desc", e.target.value)} />
          </div>

          <div className="field-row">
            <label className="field-label">Заказчик *</label>
            <input className="inp" disabled={!canEditFields} value={f.customer} onChange={(e) => set("customer", e.target.value)} placeholder="Наименование заказчика" />
          </div>

          <div className="pj-pair-row">
            <div className="pj-pair-item">
              <label className="pj-pair-label">Тип проекта *</label>
              <select className="inp sel pj-pair-input" disabled={!canEditFields} value={f.ptype} onChange={(e) => set("ptype", e.target.value)}>
                {Object.entries(PROJECT_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
              </select>
            </div>
            <div className="pj-pair-item">
              <label className="pj-pair-label">Код *</label>
              <input className="inp pj-pair-input" disabled={!canEditFields} value={f.code} onChange={(e) => set("code", e.target.value)} />
            </div>
          </div>

          {!isAdminType && (
            <div className="pj-pair-row">
              <div className="pj-pair-item">
                <label className="pj-pair-label">Тип ВС *</label>
                <select className="inp sel pj-pair-input" disabled={!canEditFields} value={f.aircraftType} onChange={(e) => set("aircraftType", e.target.value)}>
                  <option value="">- выберите -</option>
                  {AIRCRAFT_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
                </select>
              </div>
              <div className="pj-pair-item">
                <label className="pj-pair-label">Категория *</label>
                <select className="inp sel pj-pair-input" disabled={!canEditFields} value={f.projectType} onChange={(e) => set("projectType", e.target.value)}>
                  <option value="">- выберите -</option>
                  {PROJECT_TYPE_OPTIONS.map(t => <option key={t} value={t}>{t}</option>)}
                </select>
              </div>
            </div>
          )}

          <div className="pj-pair-row">
            <div className="pj-pair-item">
              <label className="pj-pair-label">Приоритет</label>
              <select className="inp sel pj-pair-input" disabled={!canEditFields} value={f.priority} onChange={(e) => handlePriorityChange(e.target.value)}>
                {Object.entries(priorityOptions).map(([k, v]) => (
                  <option key={k} value={k}>{v.label}</option>
                ))}
              </select>
            </div>
            <div className="pj-pair-item">
              <label className="pj-pair-label">Подразделение</label>
              <select className="inp sel pj-pair-input" disabled={!canEditFields} value={f.kbId || ""} onChange={(e) => set("kbId", e.target.value)}>
                {isAdminType && <option value="">Общеорганизационный</option>}
                {db.kbs.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
              </select>
            </div>
          </div>

          <div className="pj-pair-row">
            <div className="pj-pair-item">
              <label className="pj-pair-label">Дата начала *</label>
              <input className="inp pj-pair-input" type="date" disabled={!canEditFields} value={f.start} onChange={(e) => set("start", e.target.value)} />
            </div>
            <div className="pj-pair-item">
              <label className="pj-pair-label">Дата окончания {!isAdminType && "*"}</label>
              <input className="inp pj-pair-input" type="date" disabled={!canEditFields || isAdminType} value={f.end || ""} onChange={(e) => set("end", e.target.value)} />
            </div>
          </div>

          <div className="pj-pair-row">
            <div className="pj-pair-item">
              <label className="pj-pair-label">Бюджет, ч {!isAdminType && "*"}</label>
              <input 
                className="inp pj-pair-input" 
                type="number" 
                min="0" 
                step="0.5"
                disabled={!canEditFields} 
                value={f.budget ?? ""} 
                placeholder={isAdminType ? "опционально" : ""}
                onChange={(e) => set("budget", e.target.value === "" ? null : +e.target.value)} 
              />
            </div>
            <div className="pj-pair-item">
              <label className="pj-pair-label">Статус</label>
              <select className="inp sel pj-pair-input" disabled={!canChangeStatus} value={f.status || 'active'} onChange={(e) => set("status", e.target.value)}>
                {(existing ? statusOptions : creationStatusOptions).map((opt) => (
                  <option key={opt.value} value={opt.value}>{opt.label}</option>
                ))}
              </select>
            </div>
          </div>

          {!isAdminType && (
            <div className="field-row">
              <label className="field-label">Ответственный *</label>
              <select className="inp sel" disabled={!canEditFields} value={f.managerId || ""} onChange={(e) => set("managerId", e.target.value)}>
                <option value="">- выберите -</option>
                {db.employees.map((e) => <option key={e.id} value={e.id}>{e.last} {e.first}</option>)}
              </select>
            </div>
          )}
        </div>
      )}

      {tab === 'tasks' && existing && (
        <div className="tm-block">
          <div className="tm-block-header">
            <div className="rep-panel-title">Задачи проекта ({taskList.length})</div>
            <button className="btn primary sm" onClick={() => openTask(null, 'form', null, existing.id)} disabled={existing.archived}>
              <Ic d={ICONS.plus} size={14} /> Создать задачу
            </button>
          </div>
          <div className="table-wrap">
            <table className="tbl tasks-table">
              <thead>
                <tr>
                  <th className="sortable" onClick={() => handleSort('title')}>
                    Задача {taskSortField === 'title' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                  <th className="sortable" onClick={() => handleSort('assignees')}>
                    Исполнитель {taskSortField === 'assignees' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                  <th className="sortable" onClick={() => handleSort('status')}>
                    Статус {taskSortField === 'status' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                  <th className="sortable" onClick={() => handleSort('planned')}>
                    План (ч) {taskSortField === 'planned' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                  <th className="sortable" onClick={() => handleSort('fact')}>
                    Факт (ч) {taskSortField === 'fact' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                  <th className="sortable" onClick={() => handleSort('remaining')}>
                    Остаток {taskSortField === 'remaining' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                  <th className="sortable" onClick={() => handleSort('creator')}>
                    Создал {taskSortField === 'creator' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                  <th className="sortable" onClick={() => handleSort('deadline')}>
                    Дедлайн {taskSortField === 'deadline' && (taskSortDir === 'asc' ? '↑' : '↓')}
                  </th>
                </tr>
              </thead>
              <tbody>
                {taskList.map(t => {
                  const assignee = t.assigneeId ? db.employees.find(e => e.id === t.assigneeId) : null;
                  const creatorId = t.history?.length > 0 ? t.history.find(h => h.who !== 'system')?.who || t.history[0]?.who : null;
                  const creator = creatorId ? db.employees.find(e => e.id === creatorId) : null;
                  const spent = getTaskSpent(t);
                  const remaining = (t.plannedHours || 0) - spent;
                  return (
                    <tr key={t.id} className="clickable-row" onClick={() => { onClose(); openTask(t.id); }}>
                      <td><b>{t.title}</b></td>
                      <td>{assignee ? `${assignee.last} ${assignee.first}` : '-'}</td>
                      <td><span className="st-chip" style={{ background: TASK_STATUSES[t.status].color + '22', color: TASK_STATUSES[t.status].color }}>{TASK_STATUSES[t.status].label}</span></td>
                      <td>{t.plannedHours ?? '-'}</td>
                      <td>{spent}</td>
                      <td className={remaining < 0 ? 'text-danger' : ''}>{t.plannedHours ? remaining : '-'}</td>
                      <td className="mut sm">{creator ? `${creator.last} ${creator.first}` : 'Система'}</td>
                      <td className="mut sm">{t.deadline ? fmtDMY(t.deadline) : '-'}</td>
                    </tr>
                  );
                })}
                {taskList.length === 0 && <tr><td colSpan="8" className="mut text-center">Нет задач</td></tr>}
              </tbody>
            </table>
          </div>
          <div className="project-meta">
            Проект создан: {existing && existing.history?.length > 0 ? (() => {
              const creatorId = existing.history.find(h => h.who !== 'system')?.who || existing.history[0]?.who;
              const creator = creatorId ? db.employees.find(e => e.id === creatorId) : null;
              return `${creator ? creator.last + ' ' + creator.first : '-'}, ${fmtDT(existing.history[0].ts)}`;
            })() : '-'}
          </div>
        </div>
      )}

      {tab === 'discussion' && (
        <Discussion
          comments={f.comments || []}
          currentUser={ur}
          candidates={candidates}
          onUpdateComments={handleUpdateComments}
          onCommentAdded={handleCommentAdded}
          readOnly={existing?.archived}
          canComment={canCommentFinal}
          toast={toast}
          employees={db.employees}
        />
      )}

      {tab === 'files' && (
        <div className="tm-block">
          <div className="rep-panel-title">Файлы проекта</div>
          {!existing?.archived && canUploadFiles && (
            <div className="toolbar">
              <input type="file" id="file-upload-input" className="file-input-hidden" onChange={handleFileUpload} />
              <label htmlFor="file-upload-input" className="btn primary sm">
                <Ic d={ICONS.file} size={14} /> Выбрать файл
              </label>
            </div>
          )}
          {(!f.files || f.files.length === 0) && (
            <div className="mut sm">Файлы не загружены</div>
          )}
          {f.files && f.files.length > 0 && (
            <div className="files-list">
              {f.files.map(file => {
                const uploader = db.employees.find(e => e.id === file.uploadedBy);
                const fileSize = file.size < 1024 ? file.size + ' Б' : file.size < 1048576 ? (file.size / 1024).toFixed(1) + ' КБ' : (file.size / 1048576).toFixed(1) + ' МБ';
                return (
                  <div key={file.id} className="file-item">
                    <Ic d={ICONS.file} size={24} />
                    <div className="file-info">
                      <div className="file-name">{file.name}</div>
                      <div className="file-meta">{fileSize} · загрузил {uploader ? `${uploader.last} ${uploader.first}` : '-'} {file.uploadedAt ? fmtDMY(file.uploadedAt) : ''}</div>
                    </div>
                    <a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer" className="btn ghost sm">Скачать</a>
                    {!existing?.archived && canUploadFiles && (
                      <button className="icon-btn danger" onClick={() => handleFileDelete(file.id)} title="Удалить файл">
                        <Ic d={ICONS.trash} size={14} />
                      </button>
                    )}
                  </div>
                );
              })}
            </div>
          )}
        </div>
      )}

      <div className="modal-foot">
        {existing && hasRole(ur, 'admin') && !existing.archived && (
          <button className="btn danger" onClick={() => onDelete(existing)}>
            <Ic d={ICONS.trash} size={14} /> Удалить
          </button>
        )}
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Закрыть</button>
        {(isNew || canEditFields || canChangeStatus) && !existing?.archived && (
          <button className="btn primary" onClick={save}>{isNew ? "Создать проект" : "Сохранить изменения"}</button>
        )}
        {existing && !canEditFields && !canChangeStatus && <span className="mut sm">Режим только для чтения</span>}
      </div>
    </Modal>
  );
};

src/components/Modals/RolesModal.jsx

import React, { useState } from 'react';
import { Modal } from '../Modal';
import { ROLES } from '../../utils/constants';

export const RolesModal = ({ db, setDb, empId, onClose, toast, audit }) => {
  const emp = db.employees.find((e) => e.id === empId);
  if (!emp) return null;

  const [roles, setRoles] = useState(emp.roles || []);
  const [kbIds, setKbIds] = useState(emp.kbIds || []);
  const [headDeptIds, setHeadDeptIds] = useState(emp.headDeptIds || []);

  const toggle = (r) => setRoles((s) => (s.includes(r) ? s.filter((x) => x !== r) : [...s, r]));

  const save = () => {
    // Если роль kb_chief активна, очищаем отделы (главные конструкторы не имеют отделов)
    let updatedDepartments = emp.departments;
    if (roles.includes("kb_chief")) {
      updatedDepartments = [];
    }

    setDb((s) => ({
      ...s,
      employees: s.employees.map((e) =>
        e.id === empId
          ? {
              ...e,
              roles,
              kbIds: roles.includes("kb_chief") ? kbIds : [],
              headDeptIds: roles.includes("head") ? headDeptIds : [],
              departments: updatedDepartments,
            }
          : e
      ),
    }));

    audit(
      "Назначение ролей",
      `${emp.last} ${emp.first}: ${roles.map((r) => ROLES[r]?.short || r).join(", ")}`
    );
    toast("Роли сохранены");
    onClose();
  };

  return (
    <Modal title={`Роли - ${emp.last} ${emp.first}`} onClose={onClose} width={520}>
      <p className="mut sm">
        Сотрудник может иметь несколько ролей. Для «Главного конструктора» укажите КБ, для «Руководителя отдела» - перечень отделов.
      </p>
      <div className="roles-list">
        {Object.entries(ROLES).map(([k, v]) => (
          <div key={k}>
            <label className="roles-item">
              <input type="checkbox" checked={roles.includes(k)} onChange={() => toggle(k)} />
              <span className="role-chip" style={{ background: v.color + "1e", color: v.color }}>
                {v.short}
              </span>
              {v.label}
            </label>

            {k === "kb_chief" && roles.includes("kb_chief") && (
              <div className="sub-picks">
                {db.kbs.map((kb) => (
                  <label key={kb.id} className="dept-pick">
                    <input
                      type="checkbox"
                      checked={kbIds.includes(kb.id)}
                      onChange={() =>
                        setKbIds((s) =>
                          s.includes(kb.id) ? s.filter((x) => x !== kb.id) : [...s, kb.id]
                        )
                      }
                    />
                    {kb.name}
                  </label>
                ))}
              </div>
            )}

            {k === "head" && roles.includes("head") && (
              <div className="sub-picks">
                {db.departments.map((d) => (
                  <label key={d.id} className="dept-pick">
                    <input
                      type="checkbox"
                      checked={headDeptIds.includes(d.id)}
                      onChange={() =>
                        setHeadDeptIds((s) =>
                          s.includes(d.id) ? s.filter((x) => x !== d.id) : [...s, d.id]
                        )
                      }
                    />
                    {d.name}
                    {d.kbId ? ` (${db.kbs.find((x) => x.id === d.kbId)?.name})` : ""}
                  </label>
                ))}
              </div>
            )}
          </div>
        ))}
      </div>
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" onClick={save}>Сохранить роли</button>
      </div>
    </Modal>
  );
};

src/components/Modals/TaskModal.jsx

// src/components/modals/TaskModal.jsx
import React, { useState, useMemo } from 'react';
import { Modal } from '../Modal';
import Discussion, { extractMentions } from '../Discussion';
import { useDataHelpers } from '../../hooks';
import { useToast } from '../../context/ToastContext';
import {
  TASK_STATUSES, TASK_STATUS_ORDER, PRIORITIES, DEPENDENCY_TYPES,
} from '../../utils/constants';
import {
  TODAY, fmtDMY, fmtDT, iso, addDays, addMonths, addYears, uid, fmtD, parseISO,
} from '../../utils/date';
import {
  canCreateTask, canEditTaskFields, hasRole, has,
  assigneeOptions, computeScope, canChangeTaskStatus,
} from '../../utils/permissions';
import { Ic, ICONS } from '../Icons';
import Avatar from '../Avatar';

function generateRepeatDates(startDate, deadline, repeatConfig, endDate, maxCount = 100) {
  const { type, interval, days, endType, endValue } = repeatConfig;
  const result = [];
  let currentStart = new Date(startDate);
  let currentDeadline = deadline ? new Date(deadline) : null;
  let count = 0;

  if (type === 'none') {
    return [{ start: iso(currentStart), deadline: deadline ? iso(currentDeadline) : null }];
  }

  const endDateObj = endType === 'date' ? new Date(endValue) : null;
  const maxCountLimit = endType === 'count' ? parseInt(endValue, 10) : null;

  while (count < maxCount) {
    result.push({
      start: iso(currentStart),
      deadline: currentDeadline ? iso(currentDeadline) : null
    });
    count++;

    let shouldStop = false;
    if (endType === 'date' && endDateObj && currentStart >= endDateObj) {
      shouldStop = true;
    }
    if (endType === 'count' && count >= maxCountLimit) {
      shouldStop = true;
    }
    if (shouldStop) break;
    if (count >= maxCount) break;

    let nextStart = new Date(currentStart);
    let nextDeadline = currentDeadline ? new Date(currentDeadline) : null;

    switch (type) {
      case 'daily':
        nextStart = addDays(currentStart, interval || 1);
        if (nextDeadline) nextDeadline = addDays(currentDeadline, interval || 1);
        break;
      case 'weekly_days': {
        const dayNumbers = days.map(d => parseInt(d, 10));
        const currentDay = currentStart.getDay() || 7;
        let found = false;
        for (let i = 1; i <= 7; i++) {
          const nextDay = new Date(currentStart);
          nextDay.setDate(currentStart.getDate() + i);
          const dayOfWeek = nextDay.getDay() || 7;
          if (dayNumbers.includes(dayOfWeek)) {
            nextStart = nextDay;
            if (nextDeadline) {
              nextDeadline = addDays(nextDeadline, i);
            }
            found = true;
            break;
          }
        }
        if (!found) {
          const nextWeek = addDays(currentStart, 7);
          nextStart = nextWeek;
          if (nextDeadline) nextDeadline = addDays(currentDeadline, 7);
        }
        break;
      }
      case 'workdays': {
        let next = addDays(currentStart, 1);
        while (next.getDay() === 0 || next.getDay() === 6) {
          next = addDays(next, 1);
        }
        nextStart = next;
        if (nextDeadline) {
          let nd = addDays(currentDeadline, 1);
          while (nd.getDay() === 0 || nd.getDay() === 6) {
            nd = addDays(nd, 1);
          }
          nextDeadline = nd;
        }
        break;
      }
      case 'monthly':
        nextStart = addMonths(currentStart, interval || 1);
        if (nextDeadline) nextDeadline = addMonths(currentDeadline, interval || 1);
        break;
      case 'yearly':
        nextStart = addYears(currentStart, interval || 1);
        if (nextDeadline) nextDeadline = addYears(currentDeadline, interval || 1);
        break;
      case 'custom':
        nextStart = addDays(currentStart, interval || 1);
        if (nextDeadline) nextDeadline = addDays(currentDeadline, interval || 1);
        break;
      default:
        break;
    }

    if (nextStart <= currentStart) break;
    currentStart = nextStart;
    currentDeadline = nextDeadline;
  }

  return result;
}

export const TaskModal = ({ 
  db, ur, taskId, initialTab = 'form', parentTaskId, initialProjectId,
  onClose, onSave, onDelete, onHoursReq, patchTask, notify, store,
  openTask,
  spent, planSum, 
}) => {
  const { showToast } = useToast();
  const { empName, getTaskSpent, vacOverlap, primaryDept } = useDataHelpers(db);
  const existing = taskId ? db.tasks.find((t) => t.id === taskId) : null;
  const isNew = !existing;
  const isSubtask = !isNew ? !!existing.parentTaskId : !!parentTaskId;
  
  const readOnly = !!(existing && existing.archived);
  
  const canEditFields = !readOnly && (existing ? canEditTaskFields(ur, existing, db) : canCreateTask(ur));
  const canChangeStatus = !readOnly && existing && canChangeTaskStatus(ur, existing, null, db);
  const isAssignee = existing && (existing.assigneeIds || []).includes(ur.id);
  const canEditPlannedHours = hasRole(ur, 'admin') && !isAssignee;
  const isAuthor = existing && existing.creatorId === ur.id;
  const isReview = existing && existing.status === 'review';
  
  const scope = computeScope(ur, db) || { all: false, empIds: new Set(), projIds: new Set() };
  const projs = (scope.all ? db.projects : db.projects.filter((p) => scope.projIds.has(p.id))).filter((p) => p.status === "active" && !p.archived);
  const asOpts = assigneeOptions(ur, db);
  
  const initialForm = existing ? { ...existing } : {
    id: "t_" + uid(),
    title: "",
    desc: "",
    projectId: initialProjectId || "",
    assigneeId: null,                       // <-- один исполнитель
    priority: "mid",
    plannedHours: 8,
    start: TODAY,
    deadline: iso(addDays(new Date(), 14)),
    status: "new",
    logs: [],
    comments: [],
    history: [],
    delegatedFrom: null,
    archived: false,
    archivedAt: null,
    closedAt: null,
    creatorId: ur.id,
    dependencyId: null,
    dependencyType: 'FS',
    repeatType: 'none',
    repeatInterval: 1,
    repeatDays: [],
    repeatEndType: 'date',
    repeatEndValue: '',
    files: [],
    isSummary: false,
    parentTaskId: null,
  };

  if (parentTaskId && isNew) {
    const parent = db.tasks.find(t => t.id === parentTaskId);
    if (parent) {
      initialForm.parentTaskId = parentTaskId;
      initialForm.isSummary = false;
      initialForm.projectId = parent.projectId || '';
    }
  }

  const [f, setF] = useState(initialForm);
  const [logH, setLogH] = useState("");
  const [logNote, setLogNote] = useState("");
  const [logDate, setLogDate] = useState(TODAY);
  const [tab, setTab] = useState(initialTab);
  const [confirmVac, setConfirmVac] = useState(null);
  const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
  
  const proj = db.projects.find((p) => p.id === f.projectId);
  const isAdminProj = proj && proj.ptype === "admin";
  const sp = f.logs.reduce((s, l) => s + l.hours, 0);
  const remainProj = proj && proj.budget != null && !proj.archived ? proj.budget - (planSum(proj.id) - (existing ? (existing.plannedHours || 0) : 0)) : null;
  const vacWarn = !readOnly && f.assigneeId && f.deadline ? vacOverlap(f.assigneeId, f.start || f.deadline, f.deadline) : null;
  const isExec = existing && (existing.assigneeIds || []).includes(ur.id);
  
  const canLog = !readOnly && (existing ? isExec : f.assigneeId === ur.id);

  const statusOptions = useMemo(() => {
    if (isExec && !canChangeTaskStatus(ur, f, 'closed', db) && !canChangeTaskStatus(ur, f, 'cancelled', db)) {
      return TASK_STATUS_ORDER.filter(s => ['new', 'inwork', 'review'].includes(s));
    }
    return TASK_STATUS_ORDER;
  }, [isExec, f, ur, db]);

  const localPatchTask = (updatedTask) => {
    setF(prev => ({ ...prev, ...updatedTask }));
  };

  const ensureExecutorRole = (empId) => {
    const emp = db.employees.find(e => e.id === empId);
    if (emp && !emp.roles.includes('executor')) {
      const updated = { ...emp, roles: [...emp.roles, 'executor'] };
      store.upsertEmployee(updated);
    }
  };

  const candidates = useMemo(() => {
    const ids = new Set(
      db.tasks
        .filter((t) => t.projectId === f.projectId)
        .map((t) => t.assigneeIds || [])
        .flat()
    );
    const pj = db.projects.find((p) => p.id === f.projectId);
    if (pj && pj.managerId) ids.add(pj.managerId);
    return [...ids]
      .map((id) => db.employees.find((e) => e.id === id))
      .filter(Boolean);
  }, [db, f.projectId]);

  const handleUpdateComments = (newComments) => {
    const updatedTask = { ...f, comments: newComments };
    localPatchTask(updatedTask);
    if (existing) {
      patchTask(updatedTask);
    }
  };

  const handleCommentAdded = (comment) => {
    const mentioned = extractMentions(comment.text, db.employees);
    const pj = db.projects.find((p) => p.id === f.projectId);
    const subs = new Set(mentioned);
    if (f.assigneeId) subs.add(f.assigneeId);
    if (pj && pj.managerId) subs.add(pj.managerId);
    if (comment.parentId) {
      const parent = f.comments.find((x) => x.id === comment.parentId);
      if (parent) subs.add(parent.authorId);
    }
    subs.delete(ur.id);
    subs.forEach((uidX) =>
      notify(
        uidX,
        `${ur.last} ${ur.first}: новый комментарий к задаче «${f.title}»${
          mentioned.includes(uidX) ? " (вас упомянули)" : ""
        }.`
      )
    );
  };

  const handleFileUpload = (e) => {
    const file = e.target.files[0];
    if (!file) return;
    if (file.size > 10 * 1024 * 1024) {
      showToast('Файл слишком большой (максимум 10 МБ)', 'error');
      e.target.value = '';
      return;
    }
    const reader = new FileReader();
    reader.onload = (ev) => {
      const fileData = ev.target.result;
      const newFile = {
        id: uid(),
        name: file.name,
        size: file.size,
        url: fileData,
        uploadedBy: ur.id,
        uploadedAt: new Date().toISOString(),
      };
      const updatedFiles = [...(f.files || []), newFile];
      setF(prev => ({ ...prev, files: updatedFiles }));
      if (existing && patchTask) {
        const updatedTask = { ...f, files: updatedFiles };
        patchTask(updatedTask);
      }
      showToast('Файл загружен', 'success');
      e.target.value = '';
    };
    reader.readAsDataURL(file);
  };

  const handleFileDelete = (fileId) => {
    if (!window.confirm('Удалить файл?')) return;
    const updatedFiles = (f.files || []).filter(file => file.id !== fileId);
    setF(prev => ({ ...prev, files: updatedFiles }));
    if (existing && patchTask) {
      const updatedTask = { ...f, files: updatedFiles };
      patchTask(updatedTask);
    }
    showToast('Файл удалён', 'info');
  };

  const hasSubtasks = useMemo(() => {
    return db.tasks.some(t => t.parentTaskId === f.id);
  }, [db.tasks, f.id]);

  const doSave = (newStatus) => {
    const history = [...(f.history || [])];
    const statusToSave = newStatus || f.status;
    if (existing && existing.status !== statusToSave) {
      history.push({ ts: Date.now(), who: ur.id, text: `Статус: ${TASK_STATUSES[existing.status].label} → ${TASK_STATUSES[statusToSave].label}` });
    }
    
    let finalStart = f.start;
    let finalDeadline = f.deadline;
    
    if (f.dependencyId && f.dependencyType) {
      const depTask = db.tasks.find(t => t.id === f.dependencyId);
      if (depTask) {
        switch (f.dependencyType) {
          case 'SS':
            finalStart = depTask.start;
            if (f.deadline && depTask.start) {
              const diffDays = Math.round((new Date(f.deadline) - new Date(f.start || TODAY)) / (1000 * 60 * 60 * 24));
              finalDeadline = iso(addDays(parseISO(depTask.start), diffDays));
            }
            break;
          case 'FF':
            if (depTask.deadline) {
              finalDeadline = depTask.deadline;
              if (f.start && f.deadline) {
                const duration = Math.round((new Date(f.deadline) - new Date(f.start)) / (1000 * 60 * 60 * 24));
                finalStart = iso(addDays(parseISO(depTask.deadline), -duration));
              }
            }
            break;
          case 'SF':
            if (depTask.start) {
              finalDeadline = depTask.start;
              if (f.start && f.deadline) {
                const duration = Math.round((new Date(f.deadline) - new Date(f.start)) / (1000 * 60 * 60 * 24));
                finalStart = iso(addDays(parseISO(depTask.start), -duration));
              }
            }
            break;
          case 'FS':
          default:
            if (depTask.deadline) {
              finalStart = depTask.deadline;
              if (f.deadline) {
                const diffDays = Math.round((new Date(f.deadline) - new Date(f.start || TODAY)) / (1000 * 60 * 60 * 24));
                finalDeadline = iso(addDays(parseISO(depTask.deadline), diffDays));
              }
            }
            break;
        }
      }
    }
    
    const newClosed = statusToSave === "closed" && (!existing || existing.status !== "closed");
    let taskToSave = {
      ...f,
      start: finalStart,
      deadline: finalDeadline,
      status: statusToSave,
      plannedHours: f.plannedHours === "" || f.plannedHours == null ? null : +f.plannedHours,
      closedAt: newClosed ? TODAY : (existing ? existing.closedAt : null),
      history,
      creatorId: existing ? existing.creatorId : ur.id,
      files: f.files || [],
    };

    if (hasSubtasks) {
      taskToSave.isSummary = true;
    }

    delete taskToSave.repeatType;
    delete taskToSave.repeatInterval;
    delete taskToSave.repeatDays;
    delete taskToSave.repeatEndType;
    delete taskToSave.repeatEndValue;

    try {
      if (!existing && f.repeatType !== 'none') {
        const repeatConfig = {
          type: f.repeatType,
          interval: parseInt(f.repeatInterval, 10) || 1,
          days: f.repeatDays.map(Number),
          endType: f.repeatEndType,
          endValue: f.repeatEndValue,
        };
        const startDate = f.start;
        const deadline = f.deadline;
        const dates = generateRepeatDates(startDate, deadline, repeatConfig, f.repeatEndValue, 50);
        dates.forEach((d, index) => {
          const taskCopy = {
            ...taskToSave,
            id: "t_" + uid(),
            start: d.start,
            deadline: d.deadline,
          };
          if (index === 0) {
            onSave({ ...taskCopy, id: taskToSave.id }, true);
          } else {
            onSave(taskCopy, true);
          }
        });
        onClose();
      } else {
        onSave(taskToSave, !existing);
      }
    } catch (error) {
      showToast(error.message || 'Ошибка сохранения задачи', 'error');
    }
  };

  const save = () => {
    if (!f.title.trim()) {
      showToast('Укажите название задачи', 'error');
      return;
    }
    if (!f.projectId) {
      showToast('Задача обязательно назначается в рамках проекта', 'error');
      return;
    }
    if (!f.assigneeId) {
      showToast('Выберите ответственного исполнителя', 'error');
      return;
    }
    if (!isAdminProj) {
      if (!f.plannedHours || +f.plannedHours <= 0) {
        showToast('Для производственного проекта плановые часы обязательны', 'error');
        return;
      }
      if (!f.deadline) {
        showToast('Для производственного проекта срок исполнения обязателен', 'error');
        return;
      }
    }

    // Проверка права на создание задачи (для новых и подзадач)
    if (!existing && !canCreateTask(ur)) {
      showToast('У вас нет прав на создание задач. Только ГК, ГД, Админ и Менеджер проектов.', 'error');
      return;
    }
    if (parentTaskId && !canCreateTask(ur)) {
      showToast('У вас нет прав на создание подзадач.', 'error');
      return;
    }

    const projForBudget = db.projects.find(p => p.id === f.projectId);
    if (projForBudget && projForBudget.budget != null && !projForBudget.archived && !isAdminProj) {
      const currentPlanSum = planSum(projForBudget.id);
      const newTotal = currentPlanSum + (+f.plannedHours || 0);
      if (newTotal > projForBudget.budget) {
        showToast(
          `Превышение бюджета проекта! Бюджет: ${projForBudget.budget} ч, текущая сумма задач: ${currentPlanSum} ч, запрошено: ${f.plannedHours || 0} ч. Уменьшите плановые часы.`,
          'error'
        );
        return;
      }
    }

    if (f.repeatType !== 'none') {
      if (f.repeatType === 'weekly_days' && (!f.repeatDays || f.repeatDays.length === 0)) {
        showToast('Выберите хотя бы один день недели', 'error');
        return;
      }
      if (f.repeatEndType === 'date' && !f.repeatEndValue) {
        showToast('Укажите дату окончания повторения', 'error');
        return;
      }
      if (f.repeatEndType === 'count' && (!f.repeatEndValue || parseInt(f.repeatEndValue, 10) <= 0)) {
        showToast('Укажите количество повторений', 'error');
        return;
      }
      if (f.repeatEndType === 'date' && f.repeatEndValue && f.repeatEndValue <= f.start) {
        showToast('Дата окончания должна быть позже даты начала', 'error');
        return;
      }
    }

    if (f.assigneeId) {
      ensureExecutorRole(f.assigneeId);
    }
    if (vacWarn) { setConfirmVac(vacWarn); return; }
    doSave(f.status);
  };

  const addLog = () => {
    const h = parseFloat(String(logH).replace(",", "."));
    if (!h || h <= 0) {
      showToast('Введите корректное количество часов', 'error');
      return;
    }
    if (f.plannedHours && sp + h > f.plannedHours) {
      showToast(`Нельзя внести больше плановых: доступно ещё ${Math.max(0, f.plannedHours - sp)} ч`, 'error');
      return;
    }
    const newLogs = [...f.logs, { id: uid(), userId: ur.id, date: logDate, hours: h, note: logNote.trim() }];
    setF((s) => ({ ...s, logs: newLogs }));
    setLogH("");
    setLogNote("");
    setLogDate(TODAY);
    showToast('Часы учтены', 'success');
  };

  const handleAccept = () => {
    if (!isAuthor || !isReview) return;
    doSave('closed');
  };
  const handleRework = () => {
    if (!isAuthor || !isReview) return;
    doSave('inwork');
  };

  const subtasks = useMemo(() => {
    return db.tasks.filter(t => t.parentTaskId === f.id);
  }, [db, f.id]);

  const parentTask = useMemo(() => {
    if (!f.parentTaskId) return null;
    return db.tasks.find(t => t.id === f.parentTaskId);
  }, [db, f.parentTaskId]);

  return (
    <Modal title={(readOnly ? "Архивная задача - только чтение" : existing ? "Карточка задачи" : "Новая задача")} onClose={onClose} width={800}>
      {readOnly && <div className="info-box">Задача в архиве с {fmtDMY(existing.archivedAt)}. Редактирование, изменение статусов и комментирование запрещены.</div>}
      <div className="tabs sm">
        {[
          ["form", "Данные"],
          ["time", `Учёт времени (${sp}/${f.plannedHours ?? "-"})`],
          ...((f.isSummary || hasSubtasks) ? [["subtasks", `Подзадачи (${subtasks.length})`]] : []),
          ...(existing ? [["chat", `Обсуждение (${f.comments.length})`], ["files", `Файлы (${f.files?.length || 0})`], ["hist", "История"]] : [])
        ].map(([id, l]) => 
          <button key={id} className={"tab" + (tab === id ? " on" : "")} onClick={() => setTab(id)}>{l}</button>
        )}
      </div>

      {tab === "form" && (
        <>
          {vacWarn && <div className="warn-box"><Ic d={ICONS.beach} size={15} /> Ответственный исполнитель находится в отпуске с {fmtDMY(vacWarn.start)} по {fmtDMY(vacWarn.end)}. Даты пересекаются с периодом задачи.</div>}
          {remainProj !== null && remainProj - (+f.plannedHours || 0) < 0 && <div className="warn-box">Внимание: задача превысит остаток бюджета проекта ({remainProj} ч). Потребуется утверждение ГД.</div>}
          {isAdminProj && !readOnly && <div className="info-box">Административный проект: срок исполнения и плановые часы задачи - по желанию.</div>}

          <div className="project-info-fields">
            {/* Название */}
            <div className="field-row">
              <label className="field-label">Название *</label>
              <input className="inp" disabled={!canEditFields} value={f.title} onChange={(e) => set("title", e.target.value)} />
            </div>

            {/* Суммарная задача */}
            <div className="field-row">
              <label className="field-label">Суммарная задача</label>
              <div className="duo" style={{ flex: 1 }}>
                <input
                  type="checkbox"
                  disabled={!canEditFields || hasSubtasks}
                  checked={hasSubtasks || f.isSummary}
                  onChange={(e) => set("isSummary", e.target.checked)}
                />
                <span className="mut sm">Отметьте, если эта задача содержит подзадачи</span>
              </div>
            </div>

            {/* Родительская задача */}
            {parentTask && (
              <div className="field-row">
                <label className="field-label">Родительская задача</label>
                <div className="duo" style={{ flex: 1 }}>
                  <input className="inp" disabled value={parentTask.title} />
                  <button
                    className="btn ghost sm"
                    onClick={() => {
                      onClose();
                      setTimeout(() => openTask(parentTask.id), 50);
                    }}
                  >
                    <Ic d={ICONS.external} size={14} /> Перейти
                  </button>
                </div>
              </div>
            )}

            {/* Описание */}
            <div className="field-row">
              <label className="field-label">Описание</label>
              <textarea className="inp" rows="2" disabled={!canEditFields} value={f.desc} onChange={(e) => set("desc", e.target.value)} />
            </div>

            {/* Проект - заблокирован, если передан initialProjectId */}
            <div className="field-row">
              <label className="field-label">Проект * <span className="mut">(активные)</span></label>
              {readOnly ? (
                <input className="inp" disabled value={proj ? `${proj.code} - ${proj.name}` : ""} />
              ) : initialProjectId ? (
                <input className="inp" disabled value={proj ? `${proj.code} - ${proj.name}` : ""} />
              ) : (
                <select className="inp sel" disabled={!canEditFields || isSubtask} value={f.projectId} onChange={(e) => set("projectId", e.target.value)} style={{ flex: 1 }}>
                  <option value="">- выберите проект -</option>
                  {projs.map((p) => <option key={p.id} value={p.id}>{p.code} - {p.name}{p.ptype === "admin" ? " (административный)" : ""}</option>)}
                </select>
              )}
            </div>

            {/* Приоритет */}
            <div className="field-row">
              <label className="field-label">Приоритет *</label>
              <select className="inp sel" disabled={!canEditFields} value={f.priority} onChange={(e) => set("priority", e.target.value)} style={{ flex: 1 }}>
                {Object.entries(PRIORITIES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
              </select>
            </div>

            {/* Исполнитель (ответственный) - один select */}
            <div className="field-row">
              <label className="field-label">Ответственный *</label>
              {readOnly ? (
                <input className="inp" disabled value={empName(f.assigneeId)} />
              ) : (
                <select
                  className="inp sel"
                  disabled={!canEditFields}
                  value={f.assigneeId || ''}
                  onChange={(e) => set("assigneeId", e.target.value || null)}
                  style={{ flex: 1 }}
                >
                  <option value="">- выберите -</option>
                  {asOpts.map(e => (
                    <option key={e.id} value={e.id}>{e.last} {e.first}</option>
                  ))}
                </select>
              )}
            </div>

            {/* Парная строка: Начало работы и Срок исполнения */}
            <div className="pj-pair-row">
              <div className="pj-pair-item">
                <label className="pj-pair-label">Начало работы</label>
                <input className="inp pj-pair-input" type="date" disabled={!canEditFields} value={f.start} onChange={(e) => set("start", e.target.value)} />
              </div>
              <div className="pj-pair-item">
                <label className="pj-pair-label">Срок исполнения {!isAdminProj && "*"}</label>
                <input className="inp pj-pair-input" type="date" disabled={!canEditFields} value={f.deadline || ""} onChange={(e) => set("deadline", e.target.value)} />
              </div>
            </div>

            {/* Парная строка: Плановые часы и Статус */}
            <div className="pj-pair-row">
              <div className="pj-pair-item">
                <label className="pj-pair-label">Плановые часы {!isAdminProj && "*"}</label>
                <div style={{ display: 'flex', gap: '8px', flex: 1, flexWrap: 'wrap', alignItems: 'center' }}>
                  {f.isSummary ? (
                    <input className="inp" disabled value={f.plannedHours ?? 0} style={{ flex: 1, minWidth: '80px' }} />
                  ) : (
                    <input 
                      className="inp" 
                      type="number" 
                      min="0.5" 
                      step="0.5" 
                      disabled={!canEditPlannedHours} 
                      value={f.plannedHours ?? ""} 
                      onChange={(e) => set("plannedHours", e.target.value)} 
                      style={{ flex: 1, minWidth: '80px' }}
                    />
                  )}
                  {!readOnly && existing && !canEditPlannedHours && !f.isSummary && (
                    <button 
                      className="btn ghost sm" 
                      type="button" 
                      onClick={() => onHoursReq("task", existing.id)}
                      style={{ whiteSpace: 'nowrap' }}
                    >
                      <Ic d={ICONS.clock} size={13} /> Запросить изменение
                    </button>
                  )}
                  {!existing && isAdminProj && (
                    <span className="duo-note">опционально</span>
                  )}
                  {f.isSummary && (
                    <span className="duo-note">(сумма подзадач)</span>
                  )}
                </div>
              </div>
              <div className="pj-pair-item">
                <label className="pj-pair-label">Статус *</label>
                <select className="inp sel pj-pair-input" disabled={!canChangeStatus && !isAuthor && !isExec} value={f.status} onChange={(e) => set("status", e.target.value)}>
                  {statusOptions.map((s) => <option key={s} value={s}>{TASK_STATUSES[s].label}</option>)}
                </select>
              </div>
            </div>

            {isExec && !canEditFields && !readOnly && (
              <div className="info-box">Исполнитель может переводить задачу в «В работе» и «На проверке»; закрытие и отмена - у ответственного/руководителя.</div>
            )}

            {/* Зависимости - поля одно под другим */}
            <div className="field-row">
              <label className="field-label">Зависит от задачи</label>
              <select
                className="inp sel"
                disabled={!canEditFields}
                value={f.dependencyId || ''}
                onChange={(e) => set("dependencyId", e.target.value || null)}
              >
                <option value="">- нет зависимости -</option>
                {db.tasks
                  .filter(t => t.id !== f.id && t.projectId === f.projectId && t.status !== 'closed' && t.status !== 'cancelled')
                  .map(t => (
                    <option key={t.id} value={t.id}>{t.title}</option>
                  ))
                }
              </select>
            </div>

            <div className="field-row">
              <label className="field-label">Тип зависимости</label>
              <select
                className="inp sel"
                disabled={!canEditFields || !f.dependencyId}
                value={f.dependencyType || 'FS'}
                onChange={(e) => set("dependencyType", e.target.value)}
              >
                {Object.entries(DEPENDENCY_TYPES).map(([key, val]) => (
                  <option key={key} value={key}>{val.label} - {val.desc}</option>
                ))}
              </select>
            </div>

            {remainProj !== null && <div className="budget-hint">Остаток бюджета проекта «{proj.code}»: <b>{remainProj} ч</b> из {proj.budget} ч</div>}
            
            {isAuthor && isReview && !readOnly && (
              <div style={{ marginTop: 16, display: 'flex', gap: 10 }}>
                <button className="btn primary" onClick={handleAccept}><Ic d={ICONS.check} size={15} /> Принять (закрыть)</button>
                <button className="btn ghost" onClick={handleRework}><Ic d={ICONS.refresh} size={15} /> Отправить на доработку</button>
              </div>
            )}
          </div>

          {/* Блок Повторение (только для новых задач) */}
          {!existing && !readOnly && (
            <div className="tm-block" style={{ marginTop: '12px' }}>
              <div className="rep-panel-title">Повторение</div>
              <div className="project-info-fields" style={{ gap: '8px' }}>
                <div className="field-row">
                  <label className="field-label">Тип повторения</label>
                  <select className="inp sel" value={f.repeatType} onChange={(e) => set("repeatType", e.target.value)} style={{ flex: 1 }}>
                    <option value="none">Нет</option>
                    <option value="daily">Ежедневно</option>
                    <option value="weekly_days">Еженедельно по дням</option>
                    <option value="workdays">Каждый рабочий день</option>
                    <option value="monthly">Ежемесячно</option>
                    <option value="yearly">Ежегодно</option>
                    <option value="custom">Произвольно (через N дней)</option>
                  </select>
                </div>

                {f.repeatType === 'custom' && (
                  <div className="field-row">
                    <label className="field-label">Интервал (дней)</label>
                    <input className="inp" type="number" min="1" value={f.repeatInterval} onChange={(e) => set("repeatInterval", e.target.value)} style={{ flex: 1 }} />
                  </div>
                )}
                {f.repeatType === 'weekly_days' && (
                  <div className="field-row">
                    <label className="field-label">Дни недели</label>
                    <div className="duo" style={{ flex: 1, flexWrap: 'wrap', gap: '6px' }}>
                      {['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map((day, idx) => {
                        const dayNum = idx + 1;
                        const checked = f.repeatDays.includes(String(dayNum));
                        return (
                          <label key={dayNum} className="dept-pick" style={{ margin: 0 }}>
                            <input
                              type="checkbox"
                              checked={checked}
                              onChange={() => {
                                const newDays = checked
                                  ? f.repeatDays.filter(d => d !== String(dayNum))
                                  : [...f.repeatDays, String(dayNum)];
                                set("repeatDays", newDays);
                              }}
                            />
                            {day}
                          </label>
                        );
                      })}
                    </div>
                  </div>
                )}
                {f.repeatType !== 'none' && (
                  <div className="field-row">
                    <label className="field-label">Окончание</label>
                    <div className="duo" style={{ flex: 1, display: 'flex', gap: '8px' }}>
                      <select className="inp sel" value={f.repeatEndType} onChange={(e) => set("repeatEndType", e.target.value)} style={{ width: '120px' }}>
                        <option value="date">По дате</option>
                        <option value="count">По количеству</option>
                      </select>
                      {f.repeatEndType === 'date' ? (
                        <input className="inp" type="date" value={f.repeatEndValue} onChange={(e) => set("repeatEndValue", e.target.value)} style={{ flex: 1 }} />
                      ) : (
                        <input className="inp" type="number" min="1" value={f.repeatEndValue} onChange={(e) => set("repeatEndValue", e.target.value)} placeholder="кол-во" style={{ width: '100px' }} />
                      )}
                      <span className="mut sm" style={{ alignSelf: 'center' }}>всего {f.repeatEndType === 'count' ? 'задач' : ''}</span>
                    </div>
                  </div>
                )}
              </div>
            </div>
          )}
        </>
      )}

      {tab === "time" && (
        <div className="tm-block" style={{ marginTop: 0 }}>
          <div className="tm-progress"><div className="tm-progress-fill" style={{ width: Math.min(100, (sp / Math.max(1, f.plannedHours || 0)) * 100) + "%" }} /></div>
          {f.logs.length > 0 && (
            <div className="tm-logs">
              {f.logs.map((l) => (
                <div key={l.id} className="tm-log">
                  <span className="tm-log-name">{empName(l.userId)}</span>
                  <span className="mut">{fmtD(l.date)}</span>
                  <span className="tm-log-note">{l.note}</span>
                  <b className="tm-log-h">{l.hours} ч</b>
                </div>
              ))}
            </div>
          )}
          {canLog ? (
            <>
              <div className="tm-add">
                <input
                  className="inp"
                  type="date"
                  value={logDate}
                  onChange={(e) => setLogDate(e.target.value)}
                  max={TODAY}
                  style={{ width: '150px' }}
                />
                <input
                  className="inp"
                  style={{ width: 90 }}
                  type="number"
                  min="0.5"
                  step="0.5"
                  placeholder="часы"
                  value={logH}
                  onChange={(e) => setLogH(e.target.value)}
                />
                <input
                  className="inp"
                  placeholder="комментарий"
                  value={logNote}
                  onChange={(e) => setLogNote(e.target.value)}
                />
                <button className="btn ghost" onClick={addLog}>
                  <Ic d={ICONS.clock} size={14} /> Внести часы
                </button>
              </div>
              <div className="mut sm" style={{ marginTop: 8 }}>
                {f.plannedHours ? `Часы не могут превышать плановые: доступно ещё ${Math.max(0, f.plannedHours - sp)} ч.` : "Плановые часы не заданы - ограничение не применяется."}
                <br />
                <span style={{ fontSize: '13px', color: 'var(--mut)' }}>Выберите дату за прошлые дни или сегодня (будущие даты недоступны).</span>
              </div>
            </>
          ) : <div className="mut sm">{readOnly ? "Учёт часов для архивных задач недоступен." : "Часы вносит только исполнитель задачи."}</div>}
        </div>
      )}

      {tab === "subtasks" && (f.isSummary || hasSubtasks) && (
        <div className="tm-block" style={{ marginTop: 0 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
            <div className="rep-panel-title">Подзадачи</div>
            <button
              className="btn primary sm"
              onClick={() => {
                onClose();
                setTimeout(() => openTask(null, 'form', f.id), 50);
              }}
              disabled={f.archived}
            >
              <Ic d={ICONS.plus} size={14} /> Создать подзадачу
            </button>
          </div>
          {subtasks.length === 0 ? (
            <div className="mut sm">Нет подзадач</div>
          ) : (
            <div style={{ width: '100%', overflowX: 'auto' }}>
              <table className="tbl" style={{ minWidth: 500, fontSize: 13 }}>
                <thead>
                  <tr>
                    <th>Название</th>
                    <th>Статус</th>
                    <th>Исполнители</th>
                    <th></th>
                  </tr>
                </thead>
                <tbody>
                  {subtasks.map(sub => (
                    <tr key={sub.id}>
                      <td><b>{sub.title}</b></td>
                      <td><span className="st-chip" style={{ background: TASK_STATUSES[sub.status]?.color + '22', color: TASK_STATUSES[sub.status]?.color }}>{TASK_STATUSES[sub.status]?.label}</span></td>
                      <td>{sub.assigneeId ? empName(sub.assigneeId) : '-'}</td>
                      <td>
                        <button 
                          className="btn ghost sm" 
                          onClick={() => {
                            onClose();
                            setTimeout(() => {
                              if (typeof openTask === 'function') {
                                openTask(sub.id);
                              } else {
                                console.error('openTask не функция');
                              }
                            }, 50);
                          }}
                        >
                          Открыть
                        </button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          )}
        </div>
      )}

      {tab === "chat" && existing && (
        <Discussion
          comments={f.comments || []}
          currentUser={ur}
          candidates={candidates}
          onUpdateComments={handleUpdateComments}
          onCommentAdded={handleCommentAdded}
          readOnly={readOnly}
          canComment={!readOnly}
          toast={showToast}
          employees={db.employees}
        />
      )}

      {tab === "files" && (
        <div className="tm-block" style={{ marginTop: 0 }}>
          <div className="rep-panel-title">Файлы задачи</div>
          {!readOnly && (canEditFields || f.assigneeId === ur.id || f.creatorId === ur.id) && (
            <div className="toolbar">
              <input
                type="file"
                id="task-file-upload-input"
                className="file-input-hidden"
                onChange={handleFileUpload}
                style={{ display: 'none' }}
              />
              <label htmlFor="task-file-upload-input" className="btn primary sm">
                <Ic d={ICONS.file} size={14} /> Выбрать файл
              </label>
            </div>
          )}
          {(!f.files || f.files.length === 0) && (
            <div className="mut sm">Файлы не загружены</div>
          )}
          {f.files && f.files.length > 0 && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
              {f.files.map(file => {
                const uploader = db.employees.find(e => e.id === file.uploadedBy);
                const fileSize = file.size < 1024
                  ? file.size + ' Б'
                  : file.size < 1048576
                    ? (file.size / 1024).toFixed(1) + ' КБ'
                    : (file.size / 1048576).toFixed(1) + ' МБ';
                return (
                  <div
                    key={file.id}
                    style={{
                      display: 'flex',
                      alignItems: 'center',
                      gap: '10px',
                      background: '#f8fafc',
                      padding: '8px 12px',
                      borderRadius: '8px',
                      border: '1px solid var(--line)'
                    }}
                  >
                    <Ic d={ICONS.file} size={24} />
                    <div style={{ flex: 1 }}>
                      <div style={{ fontWeight: 600 }}>{file.name}</div>
                      <div className="mut sm">
                        {fileSize} · загрузил {uploader ? `${uploader.last} ${uploader.first}` : '-'}{' '}
                        {file.uploadedAt ? fmtDMY(file.uploadedAt) : ''}
                      </div>
                    </div>
                    <a
                      href={file.url}
                      download={file.name}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="btn ghost sm"
                    >
                      Скачать
                    </a>
                    {!readOnly && (canEditFields || f.assigneeId === ur.id || f.creatorId === ur.id) && (
                      <button
                        className="icon-btn danger"
                        onClick={() => handleFileDelete(file.id)}
                        title="Удалить файл"
                      >
                        <Ic d={ICONS.trash} size={14} />
                      </button>
                    )}
                  </div>
                );
              })}
            </div>
          )}
        </div>
      )}

      {tab === "hist" && existing && (
        <div className="tm-logs" style={{ maxHeight: 260 }}>
          {[...f.history].reverse().map((h, i) => (
            <div key={i} className="tm-log"><span className="tm-log-name">{h.who === "system" ? "Система" : empName(h.who)}</span><span className="mut sm">{fmtDT(h.ts)}</span><span className="tm-log-note">{h.text}</span></div>
          ))}
        </div>
      )}

      <div className="modal-foot">
        <div className="spacer" />
        {!readOnly && (canEditFields || isExec) ? (
          <>
            <button className="btn ghost" onClick={onClose}>Отмена</button>
            <button className="btn primary" onClick={save}>{existing ? "Сохранить" : "Создать задачу"}</button>
          </>
        ) : (
          <button className="btn ghost" onClick={onClose}>Закрыть</button>
        )}
        {existing && canEditFields && !readOnly && (
          <button className="btn danger" onClick={() => onDelete(existing.id)}>
            <Ic d={ICONS.trash} size={14} /> Удалить
          </button>
        )}
      </div>

      {confirmVac && (
        <Modal title="Конфликт с отпуском исполнителя" onClose={() => setConfirmVac(null)} width={460}>
          <p>Ответственный исполнитель находится в отпуске с {fmtDMY(confirmVac.start)} по {fmtDMY(confirmVac.end)}. Вы уверены, что хотите назначить задачу?</p>
          <div className="modal-foot">
            <div className="spacer" />
            <button className="btn ghost" onClick={() => setConfirmVac(null)}>Отмена</button>
            <button className="btn primary" onClick={() => { setConfirmVac(null); doSave(f.status); }}>Назначить</button>
          </div>
        </Modal>
      )}
    </Modal>
  );
};

src/components/Modals/VacationModal.jsx

// VacationModal.jsx
import React, { useState } from 'react';
import { Modal } from '../Modal';
import { useDataHelpers } from '../../hooks';
import { VACATION_TYPES, TASK_STATUSES } from '../../utils/constants';
import { TODAY, iso, addDays, uid, fmtDMY } from '../../utils/date';
import { canManageAllVacations } from '../../utils/permissions';
import { getPrimaryDeptName } from '../../utils/helpers'; // <-- добавлен импорт

export const VacationModal = ({ db, ur, vacationId, forEmpId, onClose, onSave }) => {
  const existing = vacationId ? db.vacations.find((v) => v.id === vacationId) : null;
  const canPick = canManageAllVacations(ur);
  const { empName, primaryDept } = useDataHelpers(db);
  const [f, setF] = useState(existing ? { ...existing, delegation: { ...existing.delegation } } : {
    id: "v_" + uid(), empId: forEmpId || ur.id, start: TODAY, end: iso(addDays(new Date(), 7)), type: "annual", comment: "",
    status: canManageAllVacations(ur) && forEmpId ? "approved" : "pending",
    delegation: { enabled: false, subId: "", statuses: [], state: null },
  });
  const [error, setError] = useState('');
  const set = (k, v) => setF((s) => ({ ...s, [k]: v }));

  const save = () => {
    setError('');
    if (!f.start || !f.end || f.end < f.start) {
      setError('Даты указаны некорректно');
      return;
    }
    if (f.delegation.enabled && !f.delegation.subId) {
      setError('Для делегирования необходимо выбрать замещающего сотрудника');
      return;
    }
    onSave({ ...f }, !existing);
  };

  // Для выбора сотрудника (если есть право)
  const renderEmployeeSelect = () => {
    if (!canPick) return null;
    return (
      <>
        <label className="lbl">Сотрудник *</label>
        <select className="inp sel" value={f.empId} onChange={(e) => set("empId", e.target.value)}>
          {db.employees.map((e) => (
            <option key={e.id} value={e.id}>
              {empName(e.id)} - {getPrimaryDeptName(e, db)}
            </option>
          ))}
        </select>
      </>
    );
  };

  return (
    <Modal title={existing ? "Отпуск" : "Новый отпуск"} onClose={onClose} width={560}>
      {error && <div className="login-err">{error}</div>}
      <div className="form-grid">
        {renderEmployeeSelect()}
        <label className="lbl">Дата начала *</label><input className="inp" type="date" value={f.start} onChange={(e) => set("start", e.target.value)} />
        <label className="lbl">Дата окончания *</label><input className="inp" type="date" value={f.end} onChange={(e) => set("end", e.target.value)} />
        <label className="lbl">Тип отпуска *</label>
        <select className="inp sel" value={f.type} onChange={(e) => set("type", e.target.value)}>
          {Object.entries(VACATION_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
        </select>
        <label className="lbl">Комментарий</label><input className="inp" value={f.comment} onChange={(e) => set("comment", e.target.value)} />
        {canManageAllVacations(ur) && (
          <>
            <label className="lbl">Статус</label>
            <select className="inp sel" value={f.status} onChange={(e) => set("status", e.target.value)}>
              <option value="pending">На утверждении</option>
              <option value="approved">Утверждён</option>
              <option value="rejected">Отклонён</option>
            </select>
          </>
        )}
      </div>
      <div className="tm-block">
        <label className="roles-item" style={{ border: "none", padding: 0 }}>
          <input type="checkbox" checked={f.delegation.enabled} onChange={(e) => {
            set("delegation", { ...f.delegation, enabled: e.target.checked });
            if (!e.target.checked) set("delegation", { ...f.delegation, enabled: false, subId: "" });
          }} />
          <b>Делегировать задачи на время отпуска</b>
        </label>
        {f.delegation.enabled && (
          <>
            <label className="lbl">Замещающий сотрудник *</label>
            <select
              className="inp sel"
              value={f.delegation.subId}
              onChange={(e) => set("delegation", { ...f.delegation, subId: e.target.value })}
              style={{ borderColor: f.delegation.enabled && !f.delegation.subId ? '#dc2626' : '' }}
            >
              <option value="">- выберите -</option>
              {db.employees.filter((e) => e.id !== f.empId).map((e) => (
                <option key={e.id} value={e.id}>{empName(e.id)} - {getPrimaryDeptName(e, db)}</option>
              ))}
            </select>
            {f.delegation.enabled && !f.delegation.subId && (
              <div className="error-message show" style={{ gridColumn: '2' }}>Выберите замещающего сотрудника</div>
            )}
            <label className="lbl">Какие задачи делегировать</label>
            <div className="sub-picks">
              {["new", "inwork", "review"].map((st) => (
                <label key={st} className="dept-pick">
                  <input type="checkbox" checked={f.delegation.statuses.includes(st)} onChange={() => set("delegation", { ...f.delegation, statuses: f.delegation.statuses.includes(st) ? f.delegation.statuses.filter((x) => x !== st) : [...f.delegation.statuses, st] })} />
                  {TASK_STATUSES[st].label}
                </label>
              ))}
            </div>
            <p className="mut sm">Делегирование утверждает руководитель до начала отпуска. Задачи вернутся автоматически после окончания отпуска. Задачи, где сотрудник - ответственный по проекту, передаются только через делегирование ролей.</p>
          </>
        )}
      </div>
      <div className="modal-foot">
        <div className="spacer" />
        <button className="btn ghost" onClick={onClose}>Отмена</button>
        <button className="btn primary" onClick={save}>Сохранить</button>
      </div>
    </Modal>
  );
};

src/components/Modals/VacNowModal.jsx

import React, { useState } from 'react';
import { Modal } from '../Modal';
import { useDataHelpers } from '../../hooks';
import { VACATION_TYPES } from '../../utils/constants';
import { TODAY, fmtDMY } from '../../utils/date';

export const VacNowModal = ({ db, onClose, toast }) => {
  const [fDept, setFDept] = useState("all");
  const [sort, setSort] = useState("start");
  const [, setTick] = useState(0);
  const { primaryDept, empName } = useDataHelpers(db);

  const rows = db.vacations
    .filter((v) => v.status === "approved" && v.start <= TODAY && TODAY <= v.end)
    .map((v) => {
      const e = db.employees.find((x) => x.id === v.empId);
      return { v, e, dept: primaryDept(e) };
    })
    .filter((r) => fDept === "all" || (r.dept && r.dept.id === fDept))
    .sort((a, b) => (sort === "start" ? (a.v.start < b.v.start ? -1 : 1) : (a.v.end < b.v.end ? -1 : 1)));

  return (
    <Modal title="Сотрудники в отпусках (сейчас)" onClose={onClose} width={880}>
      <div className="toolbar">
        <select className="inp sel sm" value={fDept} onChange={(e) => setFDept(e.target.value)}>
          <option value="all">Все подразделения</option>
          {db.departments.map((d) => (
            <option key={d.id} value={d.id}>{d.name}</option>
          ))}
        </select>
        <div className="seg sm">
          <button className={"seg-btn" + (sort === "start" ? " on" : "")} onClick={() => setSort("start")}>
            по началу
          </button>
          <button className={"seg-btn" + (sort === "end" ? " on" : "")} onClick={() => setSort("end")}>
            по окончанию
          </button>
        </div>
        <div className="spacer" />
        <button className="btn ghost sm" onClick={() => { setTick((x) => x + 1); toast("Список обновлён"); }}>
          ⟳ Обновить
        </button>
      </div>
      <table className="tbl">
        <thead>
          <tr>
            <th>ФИО</th>
            <th>Подразделение (основное)</th>
            <th>Начало</th>
            <th>Окончание</th>
            <th>Тип</th>
            <th>Делегирование</th>
            <th>Комментарий</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r) => {
            const delegationText = r.v.delegation.enabled
              ? `→ ${empName(r.v.delegation.subId)}`
              : '-';
            return (
              <tr key={r.v.id}>
                <td><b>{r.e ? `${r.e.last} ${r.e.first}` : "-"}</b></td>
                <td>{r.dept?.name || "-"}</td>
                <td>{fmtDMY(r.v.start)}</td>
                <td>{fmtDMY(r.v.end)}</td>
                <td>{VACATION_TYPES[r.v.type]}</td>
                <td>{delegationText}</td>
                <td className="mut">{r.v.comment || "-"}</td>
              </tr>
            );
          })}
          {rows.length === 0 && (
            <tr>
              <td colSpan="7" className="mut">Сейчас никто не находится в отпуске</td>
            </tr>
          )}
        </tbody>
      </table>
    </Modal>
  );
};

src/assets/

  • Folder: assets/