Загрузить файлы в «src/assets/components»
This commit is contained in:
1 parent
a98861119d
commit
2d9431dc9d
5 files changed
+1132
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user