From 2d9431dc9dfe0234b8ba3930f75ca926dc5b1bc3 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 7 Oct 2026 09:34:58 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?src/assets/components=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/components/Archive.jsx | 175 +++++++ src/assets/components/Avatar.jsx | 101 ++++ src/assets/components/Cabinet.jsx | 584 ++++++++++++++++++++++++ src/assets/components/Calendar.jsx | 213 +++++++++ src/assets/components/ConfirmDialog.jsx | 59 +++ 5 files changed, 1132 insertions(+) create mode 100644 src/assets/components/Archive.jsx create mode 100644 src/assets/components/Avatar.jsx create mode 100644 src/assets/components/Cabinet.jsx create mode 100644 src/assets/components/Calendar.jsx create mode 100644 src/assets/components/ConfirmDialog.jsx diff --git a/src/assets/components/Archive.jsx b/src/assets/components/Archive.jsx new file mode 100644 index 0000000..408a935 --- /dev/null +++ b/src/assets/components/Archive.jsx @@ -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 ( + + ); + } + return Проект в архиве - сначала восстановите его; + }; + + return ( +
+
+
Архив закрытых задач и проектов. Только чтение.
+
+ +
+ setFilter('dateFrom', e.target.value)} + /> + setFilter('dateTo', e.target.value)} + /> + setFilter('assigneeId', v)} + options={assigneeSelectOptions} + /> + setOldPass(e.target.value)} /> + + setNewPass(e.target.value)} /> + + setConfirmPass(e.target.value)} /> +
+ {error &&
{error}
} +
+
+ + +
+ + ); +}; + +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 ( +
+
+ {tabs.map(([id, label]) => ( + + ))} +
+ + {tab === 'overview' && ( +
+
+
Затраченные часы по задачам (последние 20 дней)
+
+ Экспорт: + setExpFrom(e.target.value)} /> + - + setExpTo(e.target.value)} /> + +
+ +
+ + + + + {days.map(d => ( + + ))} + + + + {taskTableData.map(({ task, logMap }) => ( + openTask(task.id)}> + + {days.map(d => ( + + ))} + + ))} + {taskTableData.length === 0 && ( + + )} + +
Задача + {fmtD(d)} +
+ {task.title} + + ({projects.find(p => p.id === task.projectId)?.code || '-'}) + + + {logMap[d] ? {logMap[d]} ч : ''} +
Нет учтённых часов
+
+
+ +
+
Мои задачи по статусам
+ {TASK_STATUS_ORDER.map(st => { + const list = myTasks.filter(t => t.status === st); + if (!list.length) return null; + return ( +
+
+ + {TASK_STATUSES[st].label} + {list.length} +
+ {list.map(t => ( +
openTask(t.id)}> + p.id === t.projectId)?.color }} /> + {t.title} + + {' · '} + {t.deadline ? `до ${fmtD(t.deadline)}` : 'без срока исполнения'} + {' · '} + {t.plannedHours ?? '-'} ч + +
+ ))} +
+ ); + })} + {myTasks.length === 0 &&
Задач пока нет
} +
+ +
+
Мои проекты
+ {myProjects.map(p => ( +
+ + {p.code} - {p.name} + {p.ptype === 'admin' && адм} +
+ ))} + {myProjects.length === 0 &&
Нет участия в проектах
} +
+
+ )} + + {tab === 'vacations' && ( +
+
+ Мои отпуска + +
+ + + + + + + + + {myVacs.map(v => { + const started = v.start <= TODAY; + return ( + + + + + + + + + ); + })} + {myVacs.length === 0 && } + +
НачалоОкончаниеТипКомментарийСтатус
{fmtDMY(v.start)}{fmtDMY(v.end)}{VACATION_TYPES[v.type]}{v.comment || '-'} + + {{ pending: 'На утверждении', approved: 'Утверждён', rejected: 'Отклонён' }[v.status]} + + + {!started && v.status !== 'rejected' ? ( + <> + + + + ) : ( + {started ? 'изменение через HR/администратора' : ''} + )} +
Отпусков нет
+
+ )} + + {tab === 'delegation' && ( +
+
+ Временная передача ролей + +
+

+ Роли «Суперадминистратор» и «Генеральный директор» делегируются только через + суперадминистратора. Получатель должен подтвердить принятие. Здесь же — замещения + на период отсутствия (отпуск, командировка, больничный), создаваемые вручную: + с отпуском они больше не связаны. +

+ + + + + + + + + {myDeleg.map(r => { + const canManage = canManageSubstitution(user, r); + return ( + + + + + + + + + ); + })} + {myDeleg.length === 0 && } + +
От когоКомуРолиПериодСтатус
{empName(r.fromId)}{empName(r.toId)}{r.roles.join(', ')}{fmtDMY(r.start)} - {r.end ? fmtDMY(r.end) : 'до отмены'} + + {{ + pending: 'Ожидает принятия', + active: 'Активно', + rejected: 'Отклонено', + revoked: 'Отозвано', + cancelled: 'Отменено', + expired: 'Истекло', + }[r.status] || r.status} + + + {r.status === 'active' && canManage && ( + + )} +
Делегирований нет
+
+ )} + + {tab === 'calendar' && canEditCalendar && ( + + )} + + {tab === 'profile' && ( + <> +
+
+
Личные данные
+
+
+ {user.photo + ? Аватар + :
Нет фото
} + + + {user.photo && ( + + )} +
+ +
+
+ + + + + + + + + + + + + + setExtension(e.target.value)} + onBlur={() => { + if (!extension.trim()) showToast('Внутренний номер телефона обязателен', 'error'); + }} /> + + + + + +
+ {positions.map((p, i) => ( + + {formatPositionLabel(p)} + + {' · '}{p.kind === 'primary' ? 'основное' : 'совм'} + + + ))} + {positions.length === 0 && не назначены} +
+
+ +
+ + {editMode ? ( + <> + + + + ) : ( + + )} +
+
+
+
+ +
+
Уведомления
+ {[ + ['deadlineEmail', 'E-mail о сроках - за 3 дня до срока исполнения задачи'], + ['overdueDigest', 'Контроль просрочек - ежедневная сводка'], + ['commentSub', 'Подписка на обсуждение задач, где я исполнитель или ответственный'], + ].map(([k, label]) => ( + + ))} +

+ Критичные уведомления (восстановление пароля, утверждения) отключить нельзя. +

+ +
История делегирований
+ {delegations.filter(r => r.fromId === user.id).map(r => ( +
+ → {empName(r.toId)}: {r.roles.join(', ')} ( + {fmtDMY(r.start)} - {r.end ? fmtDMY(r.end) : 'до отмены'}) +
+ ))} + {delegations.filter(r => r.fromId === user.id).length === 0 && ( +
Передач ролей не было
+ )} +
+
+ + {showPasswordModal && ( + setShowPasswordModal(false)} + /> + )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/assets/components/Calendar.jsx b/src/assets/components/Calendar.jsx new file mode 100644 index 0000000..e62cb00 --- /dev/null +++ b/src/assets/components/Calendar.jsx @@ -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 ( +
+
{d.getDate()}
+
+ {tasks.slice(0, big ? 12 : 3).map(t => renderChip(t))} +
+
+ ); +}; + +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 ( + + {({ anchorProps }) => ( +
openTask(task.id)} + title={`${task.title} (${p?.code || 'без проекта'})`} + > +
+ + {task.title} +
+
+ {assignee && } +
+
+ )} +
+ ); + }; + + 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 = ( + <> +
+ {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(w => ( +
{w}
+ ))} +
+
+ {cells.map(d => ( +
+ +
+ ))} +
+ + ); + } else if (mode === 'week') { + const mon = addDays(anchor, -((anchor.getDay() + 6) % 7)); + body = ( +
+ {[0, 1, 2, 3, 4, 5, 6].map(i => ( + + ))} +
+ ); + } else { + body = ( +
+ +
+ ); + } + + return ( +
+
+
+ +
{title}
+ +
+
+ +
+ {['day', 'week', 'month'].map(m => ( + + ))} +
+ {canSeeAll && ( + + )} +
+
+
Только задачи со сроком выполнения.
+ {body} +
+ ); +} + +export default memo(Calendar); \ No newline at end of file diff --git a/src/assets/components/ConfirmDialog.jsx b/src/assets/components/ConfirmDialog.jsx new file mode 100644 index 0000000..b745fa2 --- /dev/null +++ b/src/assets/components/ConfirmDialog.jsx @@ -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 ( + +
+ + +
+ } + > + {state.message && ( +

+ {state.message} +

+ )} + {isPrompt && ( + setValue(e.target.value)} + placeholder={state.placeholder || ''} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + /> + )} +
+ ); +}; \ No newline at end of file