AURORA-1882: явная метка «На удаление» у задач в списке и на странице задачи #314

Merged
aep merged 2 commits from feat/313-aurora-1882-deletion-mark-chip into test 2026-11-11 18:11:12 +03:00
6 changed files with 86 additions and 2 deletions

View File

@ -0,0 +1,11 @@
import { Tooltip } from '@mui/material';
import { formatDeletionHint } from '../../utils/formatDeletionHint';
import { Chip } from '../styles/StyledChip';
const chipSx = { background: '#fde8e8', color: '#b42318', whiteSpace: 'nowrap', width: 'max-content', cursor: 'default' };
export const DeletionMarkChip = ({ task, usersById, sx = {} }) => (
<Tooltip title={formatDeletionHint(task, usersById)} arrow>
<Chip sx={[chipSx, ...(Array.isArray(sx) ? sx : [sx])]}>На удаление</Chip>
</Tooltip>
);

View File

@ -9,6 +9,7 @@ import { TasksApi } from '../../api/tasks';
import { FORM_TYPE_TRANSLATE } from '../../constants/constants'; import { FORM_TYPE_TRANSLATE } from '../../constants/constants';
import { ExportButton } from '../common/Buttons/ButtonsActions'; import { ExportButton } from '../common/Buttons/ButtonsActions';
import { Chip } from '../styles/StyledChip'; import { Chip } from '../styles/StyledChip';
import { DeletionMarkChip } from './DeletionMarkChip';
import { LastOpenedBadge } from './LastOpenedBadge'; import { LastOpenedBadge } from './LastOpenedBadge';
import { IconWithContent } from './IconWithContent'; import { IconWithContent } from './IconWithContent';
import Modal from './Modal/Modal'; import Modal from './Modal/Modal';
@ -62,6 +63,7 @@ export const TaskCardComponent = ({
isAdmin = false, isAdmin = false,
onChanged, onChanged,
currentUserId = null, currentUserId = null,
usersById = {},
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -213,7 +215,7 @@ export const TaskCardComponent = ({
<CardHeader> <CardHeader>
<TaskId>ID: {id}</TaskId> <TaskId>ID: {id}</TaskId>
<Stack direction='row' gap={0.75} alignItems='center'> <Stack direction='row' gap={0.75} alignItems='center'>
{taskForm.to_delete && <Chip sx={{ background: '#fde8e8', color: '#b42318' }}>На удаление</Chip>} {taskForm.to_delete && <DeletionMarkChip task={taskForm} usersById={usersById} />}
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>} {isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
</Stack> </Stack>
</CardHeader> </CardHeader>

View File

@ -4,9 +4,11 @@ import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ProjectsApi } from '../../api/projects'; // Добавьте импорт API для проектов import { ProjectsApi } from '../../api/projects'; // Добавьте импорт API для проектов
import { TasksApi } from '../../api/tasks'; import { TasksApi } from '../../api/tasks';
import { UsersApi } from '../../api/users';
import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal'; import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal';
import { DangerOutlinedButton, PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons'; import { DangerOutlinedButton, PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions'; import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
import { DeletionMarkChip } from '../../components/common/DeletionMarkChip';
import { IconWithContent } from '../../components/common/IconWithContent'; import { IconWithContent } from '../../components/common/IconWithContent';
import { HeaderPortal } from '../../components/common/HeaderPortal'; import { HeaderPortal } from '../../components/common/HeaderPortal';
import SearchComponent from '../../components/common/SearchComponent'; import SearchComponent from '../../components/common/SearchComponent';
@ -49,6 +51,15 @@ export default function TaskPage() {
const [addYearModalOpen, setAddYearModalOpen] = useState(false); const [addYearModalOpen, setAddYearModalOpen] = useState(false);
const [isAddingYear, setIsAddingYear] = useState(false); const [isAddingYear, setIsAddingYear] = useState(false);
const [dataReloadKey, setDataReloadKey] = useState(0); const [dataReloadKey, setDataReloadKey] = useState(0);
const [usersById, setUsersById] = useState({});
const isMarkedForDeletion = Boolean(task?.to_delete);
useEffect(() => {
if (!isMarkedForDeletion) return;
UsersApi.getAll()
.then((data) => setUsersById(Object.fromEntries((data.result || []).map((item) => [item.id, item]))))
.catch(() => toast.error('Ошибка загрузки пользователей'));
}, [isMarkedForDeletion]);
useEffect(() => { useEffect(() => {
const getData = async () => { const getData = async () => {
@ -162,6 +173,7 @@ export default function TaskPage() {
<span>Таблиц: {tables.length}</span> <span>Таблиц: {tables.length}</span>
</IconWithContent> </IconWithContent>
</Chip> </Chip>
{isMarkedForDeletion && <DeletionMarkChip task={task} usersById={usersById} />}
</Box> </Box>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', gap: 2, justifyContent: 'space-between' }}>

View File

@ -5,6 +5,7 @@ import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { TasksApi } from '../../api/tasks'; import { TasksApi } from '../../api/tasks';
import { ExportButton, ExportExitButton, ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions'; import { ExportButton, ExportExitButton, ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
import { DeletionMarkChip } from '../../components/common/DeletionMarkChip';
import FormBadge from '../../components/common/FormBadge'; import FormBadge from '../../components/common/FormBadge';
import { LastOpenedBadge } from '../../components/common/LastOpenedBadge'; import { LastOpenedBadge } from '../../components/common/LastOpenedBadge';
import Modal from '../../components/common/Modal/Modal'; import Modal from '../../components/common/Modal/Modal';
@ -84,6 +85,7 @@ export default function TasksTable({
sx={{ color: 'inherit', fontWeight: 500, fontSize: '1rem' }}> sx={{ color: 'inherit', fontWeight: 500, fontSize: '1rem' }}>
{cell.getValue()} {cell.getValue()}
</Link> </Link>
{row.original.to_delete && <DeletionMarkChip task={row.original} usersById={usersById} sx={{ mb: 0 }} />}
{String(row.original.id) === lastOpenedId && ( {String(row.original.id) === lastOpenedId && (
<span ref={lastOpenedRef}> <span ref={lastOpenedRef}>
<LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge> <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>
@ -106,7 +108,7 @@ export default function TasksTable({
<FormBadge label={creatorName} muted={row.original.to_delete} /> <FormBadge label={creatorName} muted={row.original.to_delete} />
{creator?.is_active === false && ( {creator?.is_active === false && (
<Typography component='span' variant='caption' sx={{ color: 'text.disabled', whiteSpace: 'nowrap' }}> <Typography component='span' variant='caption' sx={{ color: 'text.disabled', whiteSpace: 'nowrap' }}>
Отправлен на удаление Пользователь удалён
</Typography> </Typography>
)} )}
</Box> </Box>

View File

@ -0,0 +1,22 @@
const pad = (n) => String(n).padStart(2, '0');
const formatDateTime = (value) => {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '';
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
/** Текст подсказки для чипа «На удаление»: когда и кем задача отправлена на удаление. */
export const formatDeletionHint = (task, usersById) => {
const parts = ['Отправлена на удаление'];
const when = formatDateTime(task?.deleted_at);
if (when) parts[0] += ` ${when}`;
const userId = task?.deleted_by;
if (userId !== null && userId !== undefined && userId !== '') {
const user = usersById?.[userId];
const name = user?.full_name?.trim() || user?.username || `Пользователь #${userId}`;
parts.push(name);
}
return parts.join(' — ');
};

View File

@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatDeletionHint } from './formatDeletionHint.js';
const at = new Date(2026, 10, 5, 9, 7).toISOString();
const users = { 7: { id: 7, full_name: ' Иванов Иван ', username: 'ivanov' } };
test('дата и ФИО', () => {
assert.equal(formatDeletionHint({ deleted_at: at, deleted_by: 7 }, users), 'Отправлена на удаление 05.11.2026 09:07 — Иванов Иван');
});
test('без deleted_by', () => {
assert.equal(formatDeletionHint({ deleted_at: at, deleted_by: null }, users), 'Отправлена на удаление 05.11.2026 09:07');
});
test('без deleted_at', () => {
assert.equal(formatDeletionHint({ deleted_by: 7 }, users), 'Отправлена на удаление — Иванов Иван');
});
test('без даты и пользователя', () => {
assert.equal(formatDeletionHint({}, users), 'Отправлена на удаление');
assert.equal(formatDeletionHint(null, null), 'Отправлена на удаление');
});
test('deleted_by нет в справочнике', () => {
assert.equal(formatDeletionHint({ deleted_at: at, deleted_by: 99 }, users), 'Отправлена на удаление 05.11.2026 09:07 — Пользователь #99');
assert.equal(formatDeletionHint({ deleted_by: 99 }, undefined), 'Отправлена на удаление — Пользователь #99');
});
test('нет full_name — username; невалидная дата игнорируется', () => {
assert.equal(
formatDeletionHint({ deleted_at: 'мусор', deleted_by: 8 }, { 8: { username: 'petrov' } }),
'Отправлена на удаление — petrov',
);
});