Загрузить файлы в «src/assets/components»

This commit is contained in:
admin committed 2026-10-07 09:34:58 +00:00
1 parent a98861119d
commit 2d9431dc9d
5 files changed
+1132

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
// src/components/Archive.jsx
import React, { useMemo } from 'react';
import { PROJECT_TYPES } from '../utils/constants';
import { fmtDMY, TODAY } from '../utils/date';
import { canRestore, canRestoreTask } from '../utils/permissions';
import { isArchived } from '../utils/entityState';
import { Ic, ICONS } from './Icons';
import { useDataHelpers, useFilters } from '../hooks';
import { Select } from './Select';
import { optionsFromList } from '../utils/selectOptions';
const INITIAL_FILTERS = Object.freeze({
dateFrom: '',
dateTo: '',
projectId: 'all',
assigneeId: 'all',
deptId: 'all',
});
export default function Archive({ db, ur, openTask, openProject, restoreTask, restoreProject }) {
const { getTaskSpent, empName } = useDataHelpers(db);
const { filters, setFilter } = useFilters(INITIAL_FILTERS);
const { dateFrom, dateTo, projectId, assigneeId, deptId } = filters;
const archProjects = db.projects.filter(isArchived);
const archTasks = db.tasks.filter(isArchived);
const fit = (archivedAt) => (!dateFrom || archivedAt >= dateFrom) && (!dateTo || archivedAt <= dateTo);
const projList = archProjects.filter(p => fit(p.archivedAt || TODAY));
const taskList = archTasks.filter(t => {
if (!fit(t.archivedAt || TODAY)) return false;
if (projectId !== 'all' && t.projectId !== projectId) return false;
if (assigneeId !== 'all' && t.assigneeId !== assigneeId) return false;
if (deptId !== 'all') {
const assignee = t.assigneeId ? db.employees.find(x => x.id === t.assigneeId) : null;
if (!assignee || !assignee.departments.some(d => d.deptId === deptId)) 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);
const projectSelectOptions = useMemo(
() => optionsFromList(projOptions, 'Все проекты', p => ({ value: p.id, label: p.code })),
[projOptions],
);
const assigneeSelectOptions = useMemo(
() => optionsFromList(execs, 'Все исполнители', e => ({ value: e.id, label: e.last })),
[execs],
);
const deptSelectOptions = useMemo(
() => optionsFromList(db.departments, 'Все подразделения', d => ({ value: d.id, label: d.name })),
[db.departments],
);
const renderRestoreCell = (task) => {
if (!canRestore(ur) || !isArchived(task)) return null;
if (canRestoreTask(ur, task, db)) {
return (
<button className="btn ghost sm" onClick={() => restoreTask(task.id)}>
<Ic d={ICONS.restore} size={12} /> Восстановить
</button>
);
}
return <span className="mut sm ml-2">Проект в архиве - сначала восстановите его</span>;
};
return (
<div>
<div className="sec-head">
<div className="sec-note">Архив закрытых задач и проектов. Только чтение.</div>
</div>
<div className="toolbar">
<input
className="inp arch-date-inp"
type="date"
value={dateFrom}
onChange={e => setFilter('dateFrom', e.target.value)}
/>
<input
className="inp arch-date-inp"
type="date"
value={dateTo}
onChange={e => setFilter('dateTo', e.target.value)}
/>
<Select
className="sm"
value={projectId}
onChange={v => setFilter('projectId', v)}
options={projectSelectOptions}
/>
<Select
className="sm"
value={assigneeId}
onChange={v => setFilter('assigneeId', v)}
options={assigneeSelectOptions}
/>
<Select
className="sm"
value={deptId}
onChange={v => setFilter('deptId', v)}
options={deptSelectOptions}
/>
</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) && isArchived(p) && (
<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>
{renderRestoreCell(t)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
// src/components/Avatar.jsx
import React, { memo, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { initials } from '../utils/date';
import { DOMAIN } from '../utils/constants';
/**
* Аватар сотрудника с тултипом при наведении.
*
* memo-компонент: рендерится в списках и канбане десятками, при
* неизменном сотруднике не должен перерисовываться при изменении
* чужих данных. Пропсы - employee (ссылка на объект), size, className;
* memo сравнивает их стандартным shallow-equal.
*
* ВАЖНО: хуки вызываются до возможного return null. Раньше стоял
* `if (!employee) return null;` перед useState/useRef - это нарушение
* Rules of Hooks: если employee менялся с null на не-null (или наоборот)
* на смонтированном компоненте, порядок хуков в двух рендерах различался,
* и React падал с «Rendered fewer hooks than expected».
*
* Тултип показывает ФИО, служебный e-mail и (если есть) внутренний
* телефон. Состав полей формируется здесь, а не приходит пропсом:
* он одинаков во всех местах, где рендерится аватар, и не должен
* зависеть от того, что вызывающий код счёл нужным передать.
*/
const Avatar = memo(function Avatar({ employee, size = 'sm', className = '' }) {
const [tooltip, setTooltip] = useState({ visible: false, text: '', x: 0, y: 0 });
const ref = useRef(null);
if (!employee) return null;
// В employee.email хранится короткий логин без домена (так же, как
// в моках и в поле ввода при логине). Тултип показывает полный
// адрес - тот же формат, что в профиле кабинета.
const tooltipText = [
`${employee.last} ${employee.first}`,
employee.email ? `E-mail: ${employee.email}@${DOMAIN}` : null,
employee.extension ? `Вн. телефон: ${employee.extension}` : null,
].filter(Boolean).join('\n');
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 classes = `avatar${size ? ' ' + size : ''}${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}
>
{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
)
}
</>
);
});
export default Avatar;
+584
View File
@@ -0,0 +1,584 @@
// src/components/Cabinet.jsx
import { useState, useMemo, useRef } from 'react';
import {
DOMAIN, TASK_STATUSES, TASK_STATUS_ORDER, VACATION_TYPES,
DIALOGS, TOASTS, FILE_LIMITS, FILE_MESSAGES,
} from '../utils/constants';
import { TODAY, fmtDMY, fmtD, iso, addDays } from '../utils/date';
import { isTaskActive } from '../utils/entityState';
import { useDataHelpers } from '../hooks';
import { useToast } from '../context/ToastContext';
import { useConfirm } from '../context/ConfirmContext';
import {
canManageProductionCalendar,
canManageSubstitution,
canViewRoleDelegation,
} from '../utils/permissions';
import { downloadCsv } from '../utils/csvExport';
import {
buildEmployeePositions,
formatPositionLabel,
} from '../utils/helpers';
import { Ic, ICONS } from './Icons';
import { Modal } from './Modal';
import ProductionCalendarView from './views/ProductionCalendarView';
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;
}
store.upsertEmployee({
...user,
pass: newPass,
passwordHistory: [...history.slice(-4), newPass],
});
showToast('Пароль успешно изменён', 'success');
onClose();
};
return (
<Modal title="Смена пароля" onClose={onClose} width={460}>
<div className="form-grid">
<label className="lbl">Старый пароль<span className="required-star">*</span></label>
<input className="inp" type="password" value={oldPass} onChange={e => setOldPass(e.target.value)} />
<label className="lbl">Новый пароль<span className="required-star">*</span></label>
<input className="inp" type="password" value={newPass} onChange={e => setNewPass(e.target.value)} />
<label className="lbl">Подтверждение<span className="required-star">*</span></label>
<input className="inp" type="password" value={confirmPass} onChange={e => setConfirmPass(e.target.value)} />
</div>
{error && <div className="login-err mt-2">{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 { confirm } = useConfirm();
const { empName } = useDataHelpers(data);
const [tab, setTab] = useState('overview');
const [expFrom, setExpFrom] = useState('');
const [expTo, setExpTo] = useState('');
const [editMode, setEditMode] = useState(false);
const [extension, setExtension] = useState(user.extension || '');
const [showPasswordModal, setShowPasswordModal] = useState(false);
const positions = useMemo(
() => buildEmployeePositions(user, data),
[user, data],
);
const tasks = data.tasks;
const projects = data.projects;
const vacations = data.vacations;
const delegations = data.roleDelegations;
const myTasks = useMemo(
() => tasks.filter(t => isTaskActive(t) && t.assigneeId === user.id && !user.fired),
[tasks, user.id, user.fired],
);
const myProjects = useMemo(() => {
const ids = [...new Set(myTasks.map(t => t.projectId))];
return ids.map(id => projects.find(p => p.id === id)).filter(Boolean);
}, [myTasks, projects]);
const myVacs = useMemo(
() => vacations.filter(v => v.empId === user.id),
[vacations, user.id],
);
// Видимость записи — через доменный предикат (admin/director, fromId,
// toId). Дубль условия fromId/toId === user.id удалён.
const myDeleg = useMemo(
() => delegations.filter(r => canViewRoleDelegation(user, r)),
[delegations, user],
);
const days = useMemo(() => {
const out = [];
for (let i = 19; i >= 0; i--) out.push(iso(addDays(new Date(), -i)));
return out;
}, []);
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 = 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 canEditCalendar = canManageProductionCalendar(user);
const tabs = [
['overview', 'Сводка'],
['vacations', 'Мои отпуска'],
['delegation', 'Делегирование и замещение'],
['profile', 'Профиль и уведомления'],
...(canEditCalendar ? [['calendar', 'Правка календаря']] : []),
];
const handleSaveProfile = () => {
if (!extension.trim()) {
showToast('Внутренний номер телефона не может быть пустым', 'error');
return;
}
store.upsertEmployee({
...user,
extension: extension.trim(),
});
setEditMode(false);
showToast('Данные обновлены', 'success');
};
const fileInputRef = useRef(null);
const handlePhotoUpload = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast(FILE_MESSAGES.notImage, 'error');
e.target.value = '';
return;
}
if (file.size > FILE_LIMITS.profilePhoto) {
showToast(FILE_MESSAGES.profilePhotoTooLarge, 'error');
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
store.upsertEmployee({ ...user, photo: ev.target.result });
showToast('Фото загружено', 'success');
};
reader.readAsDataURL(file);
};
const handlePhotoDelete = async () => {
if (!user.photo) return;
const ok = await confirm(DIALOGS.deleteProfilePhoto);
if (!ok) return;
store.upsertEmployee({ ...user, photo: null });
showToast(TOASTS.photoDeleted, 'info');
};
const handleRevokeDelegation = (rd) => {
try {
store.revokeRoleDelegation(rd.id);
showToast('Замещение отозвано', 'success');
} catch (err) {
showToast(err.message || 'Не удалось отозвать замещение', 'error');
}
};
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 className="flex flex-col gap-4">
<div className="rep-panel">
<div className="rep-panel-title">Затраченные часы по задачам (последние 20 дней)</div>
<div className="toolbar flex items-center flex-wrap gap-2 mb-3">
<span className="mut sm">Экспорт:</span>
<input className="inp w-150" type="date" value={expFrom} onChange={e => setExpFrom(e.target.value)} />
<span className="mut sm">-</span>
<input className="inp w-150" type="date" 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 className="w-full overflow-x-auto">
<table className="tbl w-full cab-timesheet-table">
<thead>
<tr>
<th className="text-left border-b cab-timesheet-task-col">Задача</th>
{days.map(d => (
<th key={d} className="text-center border-b text-xs text-mut cab-timesheet-day-col">
{fmtD(d)}
</th>
))}
</tr>
</thead>
<tbody>
{taskTableData.map(({ task, logMap }) => (
<tr key={task.id} className="cursor-pointer" onClick={() => openTask(task.id)}>
<td className="text-left font-semibold border-b">
{task.title}
<span className="mut sm font-normal ml-2">
({projects.find(p => p.id === task.projectId)?.code || '-'})
</span>
</td>
{days.map(d => (
<td key={d} className="text-center border-b">
{logMap[d] ? <b>{logMap[d]} ч</b> : ''}
</td>
))}
</tr>
))}
{taskTableData.length === 0 && (
<tr><td colSpan={days.length + 1} className="mut text-center p-5">Нет учтённых часов</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: 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 ml-2">адм</span>}
</div>
))}
{myProjects.length === 0 && <div className="mut">Нет участия в проектах</div>}
</div>
</div>
)}
{tab === 'vacations' && (
<div className="rep-panel">
<div className="rep-panel-title flex items-center">
Мои отпуска
<button className="btn primary sm ml-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>
</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>
<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="6" className="mut">Отпусков нет</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'delegation' && (
<div className="rep-panel">
<div className="rep-panel-title flex items-center">
Временная передача ролей
<button className="btn primary sm ml-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 => {
const canManage = canManageSubstitution(user, r);
return (
<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: 'Отозвано',
cancelled: 'Отменено',
expired: 'Истекло',
}[r.status] || r.status}
</span>
</td>
<td>
{r.status === 'active' && canManage && (
<button
className="btn ghost sm"
onClick={() => handleRevokeDelegation(r)}
>
Отозвать
</button>
)}
</td>
</tr>
);
})}
{myDeleg.length === 0 && <tr><td colSpan="6" className="mut">Делегирований нет</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'calendar' && canEditCalendar && (
<ProductionCalendarView store={store} />
)}
{tab === 'profile' && (
<>
<div className="cab-grid">
<div className="rep-panel">
<div className="rep-panel-title">Личные данные</div>
<div className="flex gap-4 flex-wrap">
<div className="cab-photo-col">
{user.photo
? <img src={user.photo} alt="Аватар" className="cab-profile-photo" />
: <div className="cab-profile-photo-empty">Нет фото</div>}
<input type="file" accept="image/*" ref={fileInputRef}
className="file-input-hidden" onChange={handlePhotoUpload} />
<button className="btn primary sm" onClick={() => fileInputRef.current?.click()}>
Загрузить фото
</button>
{user.photo && (
<button className="btn ghost sm" onClick={handlePhotoDelete}>Удалить фото</button>
)}
</div>
<div className="cab-profile-fields">
<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">
Внутренний номер телефона<span className="required-star">*</span>
</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">
{positions.map((p, i) => (
<span key={i} className={`dept-chip${p.kind === 'primary' ? ' prim' : ''}`}>
{formatPositionLabel(p)}
<span className="mut sm">
{' · '}{p.kind === 'primary' ? 'основное' : 'совм'}
</span>
</span>
))}
{positions.length === 0 && <span className="mut sm">не назначены</span>}
</div>
</div>
<div className="mt-4 flex gap-3 justify-end items-center">
<button className="btn danger" onClick={() => setShowPasswordModal(true)}>
Изменить пароль
</button>
{editMode ? (
<>
<button className="btn ghost" onClick={() => {
setEditMode(false);
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={() => {
store.upsertEmployee({
...user,
notif: { ...user.notif, [k]: !user.notif[k] },
});
}}><span className="toggle-knob" /></span>
</label>
))}
<p className="mut sm">
Критичные уведомления (восстановление пароля, утверждения) отключить нельзя.
</p>
<div className="rep-panel-title mt-4">История делегирований</div>
{delegations.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>
))}
{delegations.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>
);
}
+213
View File
@@ -0,0 +1,213 @@
// src/components/Calendar.jsx
import { useState, useMemo, memo } from 'react';
import { useToast } from '../context/ToastContext';
import { computeScope, canSeeAllContent, taskVisible } from '../utils/permissions';
import { isTaskActive } from '../utils/entityState';
import { changeTaskStatus, deleteTask } from '../utils/taskActions';
import { buildTaskMenu } from './menus';
import FloatingMenu from './FloatingMenu';
import EmployeeCardLink from './EmployeeCardLink';
import { Ic, ICONS } from './Icons';
import { ToggleSwitch } from './ToggleSwitch';
import { fmtDMY, iso, addDays } from '../utils/date';
import { useScheduleDb } from '../hooks/useDb';
const MONTH_NAMES = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
];
const DayCell = ({ d, big, byDay, renderChip }) => {
const dayIso = iso(d);
const tasks = byDay[dayIso] || [];
const todayIso = iso(new Date());
return (
<div className={`cal-cell${dayIso === todayIso ? ' today' : ''}`}>
<div className="cal-daynum">{d.getDate()}</div>
<div className="cal-chips">
{tasks.slice(0, big ? 12 : 3).map(t => renderChip(t))}
</div>
</div>
);
};
function Calendar({ ur, openTask, store, openCopyTask, openTemplateFromTask }) {
const db = useScheduleDb();
const { tasks, projects, employees } = db;
const { showToast } = useToast();
const scope = useMemo(() => computeScope(ur, db), [ur, db]);
const [mode, setMode] = useState('month');
const [anchor, setAnchor] = useState(new Date());
const [showOnlyMy, setShowOnlyMy] = useState(false);
// Единая точка правила «видит весь контент» - та же, что в TasksView
// и ProjectsView. Раньше список ролей был продублирован здесь строкой,
// и при изменении набора ролей приходилось править два файла.
const canSeeAll = canSeeAllContent(ur);
const allTasks = useMemo(() => {
let list = tasks.filter(t =>
isTaskActive(t) &&
taskVisible(ur, scope, t, db) &&
t.deadline &&
!['closed', 'cancelled'].includes(t.status)
);
if (showOnlyMy) list = list.filter(t => t.assigneeId === ur.id);
return list;
}, [tasks, ur, scope, db, showOnlyMy]);
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'
? `${MONTH_NAMES[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));
const handleMoveTask = (taskId, newStatus) => {
if (changeTaskStatus({
task: tasks.find(t => t.id === taskId),
newStatus, user: ur, db, store,
}) === false) {
showToast('У вас нет прав на изменение статуса этой задачи.', 'error');
}
};
const handleDeleteTask = (task) => {
deleteTask({ task, store });
showToast(`Задача «${task.title}» удалена`, 'success');
};
const renderTaskChip = (task) => {
const p = projects.find(x => x.id === task.projectId);
const assignee = task.assigneeId ? employees.find(e => e.id === task.assigneeId) : null;
const menuItems = buildTaskMenu({
task, user: ur, db, openTask,
onMove: handleMoveTask,
onDelete: handleDeleteTask,
onCopy: openCopyTask,
onMakeTemplate: openTemplateFromTask,
});
return (
<FloatingMenu key={task.id} items={menuItems}>
{({ anchorProps }) => (
<div
{...anchorProps}
className="cal-chip"
style={{ borderColor: p?.color }}
onClick={() => openTask(task.id)}
title={`${task.title} (${p?.code || 'без проекта'})`}
>
<div className="cal-task-title">
<span className="pdot" style={{ background: p?.color }} />
<span>{task.title}</span>
</div>
<div className="cal-executors">
{assignee && <EmployeeCardLink employee={assignee} size="xs" />}
</div>
</div>
)}
</FloatingMenu>
);
};
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} renderChip={renderTaskChip} />
</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} renderChip={renderTaskChip} />
))}
</div>
);
} else {
body = (
<div className="cal-week one">
<DayCell d={anchor} big byDay={byDay} renderChip={renderTaskChip} />
</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 && (
<ToggleSwitch
checked={showOnlyMy}
onChange={setShowOnlyMy}
label="Мои задачи"
/>
)}
</div>
</div>
<div className="cal-note">Только задачи со сроком выполнения.</div>
{body}
</div>
);
}
export default memo(Calendar);
+59
View File
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { useNestedModalEscape } from './Templates/useNestedModalEscape';
export const ConfirmDialog = ({ state, onCancel, onSubmit }) => {
const [value, setValue] = useState(state.defaultValue || '');
const isPrompt = state.mode === 'prompt';
const canSubmit = !isPrompt || value.trim().length > 0;
// Перехватываем Escape до модалки, лежащей под нами. Иначе нажатие
// закрыло бы и наш диалог, и родительский (TaskModal, ProjectModal и т.п.).
useNestedModalEscape(onCancel);
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit(isPrompt ? value.trim() : true);
};
const title = state.title || (isPrompt ? 'Введите значение' : 'Подтвердите действие');
return (
<Modal
title={title}
onClose={onCancel}
width={440}
footer={
<div className="modal-foot">
<div className="spacer" />
<button className="btn ghost" onClick={onCancel}>
{state.cancelLabel || 'Отмена'}
</button>
<button
className={state.danger ? 'btn danger' : 'btn primary'}
onClick={handleSubmit}
disabled={!canSubmit}
>
{state.confirmLabel || (isPrompt ? 'Создать' : 'OK')}
</button>
</div>
}
>
{state.message && (
<p style={{ fontSize: 15, lineHeight: 1.55, marginBottom: isPrompt ? 14 : 0 }}>
{state.message}
</p>
)}
{isPrompt && (
<input
className="inp"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={state.placeholder || ''}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
/>
)}
</Modal>
);
};