AURORA-1882: явная метка «На удаление» у задач в списке и на странице задачи #314
11
web/src/components/common/DeletionMarkChip.jsx
Normal file
11
web/src/components/common/DeletionMarkChip.jsx
Normal 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>
|
||||||
|
);
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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' }}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
22
web/src/utils/formatDeletionHint.js
Normal file
22
web/src/utils/formatDeletionHint.js
Normal 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(' — ');
|
||||||
|
};
|
||||||
35
web/src/utils/formatDeletionHint.test.js
Normal file
35
web/src/utils/formatDeletionHint.test.js
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user