feat(table): визуально отличать редактируемые ячейки от расчётных и закрытых (AURORA-1890)
- utils/cellEditability.js: isCellEditable/isComputedColumn — чистые функции, isComputedColumn повторяет часть v3._apply_form_cell/_apply_form3_cell (0023_apply_cell_empty_string.sql), чтобы админ не видел вычисляемые колонки как редактируемые. tableColumns.jsx использует isCellEditable вместо инлайновой проверки. - useRealtimeData.js: возвращает stageColumnDeadlines (closes_at по колонке текущего этапа) рядом с columnsCurStage. - Cell.jsx: редактируемая ячейка — белый фон, рамка 1px #7FA7C9 (2px при наведении), курсор text; нередактируемая ячейка строки INPUT — фон #F4F5F7, текст #5F6B7A; занятая коллегой — пунктирная оранжевая рамка 2px #F0A030 без изменения фона. Строки ITEM/GROUP/ROOT не затронуты. Сохранён порядок стилей из AURORA-1894 (INVALID_STYLES последним) и спред fontWeight для отрицательных чисел из AURORA-1892. - TableHead.jsx: метка срока ввода «✎ до DD.MM HH:mm» в заголовке колонки текущего этапа, только для исполнителя (utils/deadlineFormat.js). - SettingPanel/CellLegend.jsx: иконка "?" с легендой (можно вводить / расчёт или закрыто) рядом с плашкой контроля ошибок, без новой панели и без новых цветов в palettes columnColors.js. node --test: cellEditability.test.js, deadlineFormat.test.js — 18/18. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d518961365
commit
df6ec795a3
@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
import React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useCellLock } from '../../contexts/RealtimeContext';
|
import { useCellLock } from '../../contexts/RealtimeContext';
|
||||||
import { formatNumber } from '../../utils/numberFormat';
|
import { formatNumber } from '../../utils/numberFormat';
|
||||||
|
|
||||||
@ -17,11 +17,12 @@ const BASE_CELL_STYLES = {
|
|||||||
padding: '8px',
|
padding: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AURORA-1890: занятая другим пользователем ячейка не должна читаться как
|
||||||
|
// «только чтение» (старый серый фон #f5f5f5 путался с нередактируемой
|
||||||
|
// ячейкой) — фон не меняем, отличаем пунктирной оранжевой рамкой и значком 🔒.
|
||||||
const LOCKED_STYLES = {
|
const LOCKED_STYLES = {
|
||||||
border: '2px solid #e0e0e0',
|
border: '2px dashed #F0A030',
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
cursor: 'not-allowed',
|
cursor: 'not-allowed',
|
||||||
opacity: 0.85,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const INVALID_STYLES = {
|
const INVALID_STYLES = {
|
||||||
@ -35,11 +36,25 @@ const INVALID_STYLES = {
|
|||||||
const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
|
const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
|
||||||
const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
|
const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
|
||||||
|
|
||||||
|
// AURORA-1890: редактируемая ячейка строки INPUT — белый фон и заметная
|
||||||
|
// синяя рамка (вместо прежней почти незаметной полупрозрачной 2px #d8e4ea94).
|
||||||
|
// Толщина рамки растёт при наведении (1px → 2px, см. isHovered в CellComponent), курсор — text.
|
||||||
const EDITABLE_STYLES = {
|
const EDITABLE_STYLES = {
|
||||||
cursor: 'pointer',
|
cursor: 'text',
|
||||||
border: '2px solid #d8e4ea94',
|
backgroundColor: '#FFFFFF',
|
||||||
|
};
|
||||||
|
const EDITABLE_BORDER_COLOR = '#7FA7C9';
|
||||||
|
|
||||||
|
// AURORA-1890: нередактируемая ячейка строки INPUT (вычисляемая или вне
|
||||||
|
// текущего этапа) — приглушённый фон и текст, чтобы визуально отличаться от
|
||||||
|
// редактируемой белой ячейки без отдельной палитры цветов.
|
||||||
|
const NOT_EDITABLE_INPUT_STYLES = {
|
||||||
|
cursor: 'default',
|
||||||
|
backgroundColor: '#F4F5F7',
|
||||||
|
color: '#5F6B7A',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Строки ITEM/GROUP/ROOT: вне скоупа AURORA-1890 — сохраняем прежнее поведение.
|
||||||
const NOT_EDITABLE_STYLES = {
|
const NOT_EDITABLE_STYLES = {
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
};
|
};
|
||||||
@ -135,9 +150,18 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
|
|||||||
const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 1 }) => {
|
const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 1 }) => {
|
||||||
const cellKey = `${row.id}_${column.id}`;
|
const cellKey = `${row.id}_${column.id}`;
|
||||||
const isLocked = useCellLock(cellKey);
|
const isLocked = useCellLock(cellKey);
|
||||||
|
// AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px).
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
|
||||||
|
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown';
|
const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown';
|
||||||
const nameIndent = column.id === 'data.header.name' || column.id === 'data.name' ? 8 + row.depth * 16 : 8;
|
const nameIndent = column.id === 'data.header.name' || column.id === 'data.name' ? 8 + row.depth * 16 : 8;
|
||||||
|
// AURORA-1890: строки ITEM/GROUP/ROOT не получают серую "нередактируемую"
|
||||||
|
// заливку — она зарезервирована под строку ввода (та же проверка, что в
|
||||||
|
// tableColumns.jsx getColorCell).
|
||||||
|
const rowType = row?.original?.row_type || row?.row_type;
|
||||||
|
const isInputRow = rowType === 'INPUT';
|
||||||
|
|
||||||
const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => {
|
const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => {
|
||||||
const value = cell.getValue();
|
const value = cell.getValue();
|
||||||
@ -165,12 +189,17 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
|||||||
return createHighlightedContent(rawValue, displayValue, searchQueries);
|
return createHighlightedContent(rawValue, displayValue, searchQueries);
|
||||||
}, [rawValue, displayValue, globalFilter, columnFilter]);
|
}, [rawValue, displayValue, globalFilter, columnFilter]);
|
||||||
|
|
||||||
|
// AURORA-1890: занятая коллегой ячейка больше не красится в серый —
|
||||||
|
// обычный brightness-based цвет текста, отличие только в рамке/бейдже.
|
||||||
const textColor = useMemo(() => {
|
const textColor = useMemo(() => {
|
||||||
if (isLocked) return '#999999';
|
|
||||||
const brightness = getColorBrightness(backgroundColor);
|
const brightness = getColorBrightness(backgroundColor);
|
||||||
if (isNegative) return brightness < 128 ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
|
if (isNegative) return brightness < 128 ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
|
||||||
return brightness < 128 ? '#ffffff' : '#000000';
|
return brightness < 128 ? '#ffffff' : '#000000';
|
||||||
}, [backgroundColor, isLocked, isNegative]);
|
}, [backgroundColor, isNegative]);
|
||||||
|
|
||||||
|
// AURORA-1890: нередактируемый стиль зависит от типа строки (INPUT получает
|
||||||
|
// приглушённую заливку, ITEM/GROUP/ROOT — прежнее поведение без изменений).
|
||||||
|
const notEditableStyles = isInputRow ? NOT_EDITABLE_INPUT_STYLES : NOT_EDITABLE_STYLES;
|
||||||
|
|
||||||
const cellStyles = useMemo(
|
const cellStyles = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -178,14 +207,14 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
|||||||
paddingLeft: nameIndent,
|
paddingLeft: nameIndent,
|
||||||
justifyContent: isNumeric ? 'flex-end' : 'flex-start',
|
justifyContent: isNumeric ? 'flex-end' : 'flex-start',
|
||||||
textAlign: isNumeric ? 'right' : 'left',
|
textAlign: isNumeric ? 'right' : 'left',
|
||||||
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
|
backgroundColor,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
...(isNegative ? { fontWeight: 600 } : {}),
|
...(isNegative ? { fontWeight: 600 } : {}),
|
||||||
...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES),
|
...(isEditable ? { ...EDITABLE_STYLES, border: `${isHovered ? 2 : 1}px solid ${EDITABLE_BORDER_COLOR}` } : notEditableStyles),
|
||||||
...(isLocked ? LOCKED_STYLES : {}),
|
...(isLocked ? LOCKED_STYLES : {}),
|
||||||
...(isInvalid ? INVALID_STYLES : {}),
|
...(isInvalid ? INVALID_STYLES : {}),
|
||||||
}),
|
}),
|
||||||
[backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent, isNegative],
|
[backgroundColor, isInvalid, isLocked, textColor, isEditable, notEditableStyles, isNumeric, nameIndent, isNegative, isHovered],
|
||||||
);
|
);
|
||||||
|
|
||||||
const lockBadge = useMemo(() => {
|
const lockBadge = useMemo(() => {
|
||||||
@ -201,7 +230,14 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||||
<div type='button' className='cell' style={cellStyles} onClick={handleClick}>
|
<div
|
||||||
|
type='button'
|
||||||
|
className='cell'
|
||||||
|
style={cellStyles}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
|
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
|
||||||
{lockBadge}
|
{lockBadge}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -82,6 +82,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
columnsCurStage,
|
columnsCurStage,
|
||||||
|
stageColumnDeadlines,
|
||||||
isLoading: isTableLoading,
|
isLoading: isTableLoading,
|
||||||
editingCells: dataEditingCells,
|
editingCells: dataEditingCells,
|
||||||
} = useRealtimeData(formId, sheetName, direction, formType, year);
|
} = useRealtimeData(formId, sheetName, direction, formType, year);
|
||||||
@ -844,6 +845,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
onColumnSelect={handleColumnSelect}
|
onColumnSelect={handleColumnSelect}
|
||||||
onChangeWidth={handleSetColumnWidth}
|
onChangeWidth={handleSetColumnWidth}
|
||||||
isLoadingData={isLoadingData}
|
isLoadingData={isLoadingData}
|
||||||
|
stageColumnDeadlines={stageColumnDeadlines}
|
||||||
|
userRoleId={userRoleId}
|
||||||
/>,
|
/>,
|
||||||
headerPortalRef.current,
|
headerPortalRef.current,
|
||||||
)}
|
)}
|
||||||
|
|||||||
55
web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx
Normal file
55
web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||||
|
import { IconButton, Stack, Tooltip } from '@mui/material';
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
// AURORA-1890: легенда рядом с плашкой "Контроль ошибок" — те же цвета,
|
||||||
|
// что в Cell.jsx (EDITABLE_STYLES / NOT_EDITABLE_INPUT_STYLES), без новых
|
||||||
|
// палитр колонок (columnColors.js не меняется).
|
||||||
|
const controlSx = {
|
||||||
|
minHeight: '2.5rem',
|
||||||
|
flex: '0 0 2.5rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleBaseSx = {
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '0.875rem',
|
||||||
|
height: '0.875rem',
|
||||||
|
borderRadius: '0.1875rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const editableSampleSx = {
|
||||||
|
...sampleBaseSx,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
border: '1px solid #7FA7C9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const notEditableSampleSx = {
|
||||||
|
...sampleBaseSx,
|
||||||
|
backgroundColor: '#F4F5F7',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.12)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const legendRowSx = { alignItems: 'center', gap: 0.75 };
|
||||||
|
|
||||||
|
const CellLegend = () => (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<Stack direction='column' sx={{ gap: 0.75, py: 0.25 }}>
|
||||||
|
<Stack direction='row' sx={legendRowSx}>
|
||||||
|
<span style={editableSampleSx} />
|
||||||
|
<span>можно вводить</span>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction='row' sx={legendRowSx}>
|
||||||
|
<span style={notEditableSampleSx} />
|
||||||
|
<span>расчёт или закрыто на этапе</span>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
}>
|
||||||
|
<IconButton variant='outlined' sx={controlSx} aria-label='Пояснение цветов ячеек'>
|
||||||
|
<HelpOutlineIcon sx={{ fontSize: '1.15rem' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default memo(CellLegend);
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
VisibleExpenseItems,
|
VisibleExpenseItems,
|
||||||
} from '../../common/icons/icons';
|
} from '../../common/icons/icons';
|
||||||
import { collectLeafColumns } from '../utils/columnUtils';
|
import { collectLeafColumns } from '../utils/columnUtils';
|
||||||
|
import CellLegend from './CellLegend';
|
||||||
import { GroupByObject } from './GroupByObject/GroupByObject';
|
import { GroupByObject } from './GroupByObject/GroupByObject';
|
||||||
import { Panel } from './SettingPanel.style';
|
import { Panel } from './SettingPanel.style';
|
||||||
import ValidationErrorsAlert from './ValidationErrorsAlert';
|
import ValidationErrorsAlert from './ValidationErrorsAlert';
|
||||||
@ -321,7 +322,10 @@ const SettingPanel = ({
|
|||||||
/>
|
/>
|
||||||
</GroupByObject>
|
</GroupByObject>
|
||||||
</Stack>
|
</Stack>
|
||||||
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
|
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
|
||||||
|
<CellLegend />
|
||||||
|
</Stack>
|
||||||
</Panel>
|
</Panel>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,21 @@ import React, { useMemo } from 'react';
|
|||||||
import ColumnResizer from './ColumnResizer';
|
import ColumnResizer from './ColumnResizer';
|
||||||
import { formatHeaderYear } from '../utils/headerYearUtils';
|
import { formatHeaderYear } from '../utils/headerYearUtils';
|
||||||
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
|
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
|
||||||
|
import { formatDeadline } from '../utils/deadlineFormat';
|
||||||
|
import { ROLES_NAME_ID } from '../../../constants/constants';
|
||||||
|
|
||||||
|
// AURORA-1890: срок ввода в заголовке колонки показывается только
|
||||||
|
// исполнителю (у админа колонка открыта всегда объединением всех фаз,
|
||||||
|
// метка ему ничего не даёт — см. тикет, п.5).
|
||||||
|
const DEADLINE_LABEL_STYLES = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.5625rem',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginTop: '2px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
const getColumnPinnedStyles = (column) => {
|
const getColumnPinnedStyles = (column) => {
|
||||||
const pinningPosition = column.getIsPinned();
|
const pinningPosition = column.getIsPinned();
|
||||||
@ -102,7 +117,7 @@ const getColorBrightness = (hexColor) => {
|
|||||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear }) => {
|
const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear, stageColumnDeadlines, userRoleId }) => {
|
||||||
const column = header.column;
|
const column = header.column;
|
||||||
const headerSize = header.getSize();
|
const headerSize = header.getSize();
|
||||||
const isPinned = Boolean(pinningPosition);
|
const isPinned = Boolean(pinningPosition);
|
||||||
@ -120,6 +135,15 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
|
|||||||
return brightness < 128 ? '#ffffff' : '#000000';
|
return brightness < 128 ? '#ffffff' : '#000000';
|
||||||
}, [backgroundColor]);
|
}, [backgroundColor]);
|
||||||
|
|
||||||
|
// AURORA-1890: срок ввода колонки на текущем этапе исполнителя.
|
||||||
|
const deadlineLabel = useMemo(() => {
|
||||||
|
if (userRoleId === ROLES_NAME_ID.admin) return null;
|
||||||
|
const closesAt = stageColumnDeadlines?.get?.(column.id);
|
||||||
|
if (!closesAt) return null;
|
||||||
|
const formatted = formatDeadline(closesAt);
|
||||||
|
return formatted ? `✎ ${formatted}` : null;
|
||||||
|
}, [stageColumnDeadlines, column.id, userRoleId]);
|
||||||
|
|
||||||
const handleChangeWidth = (deltaWidth) => {
|
const handleChangeWidth = (deltaWidth) => {
|
||||||
const size = header.getSize();
|
const size = header.getSize();
|
||||||
onChangeWidth(header, size + deltaWidth);
|
onChangeWidth(header, size + deltaWidth);
|
||||||
@ -173,6 +197,7 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
}}>
|
}}>
|
||||||
{header.isPlaceholder ? null : formatHeaderYear(column.columnDef.header, budgetYear)}
|
{header.isPlaceholder ? null : formatHeaderYear(column.columnDef.header, budgetYear)}
|
||||||
|
{deadlineLabel && <span style={DEADLINE_LABEL_STYLES}>{deadlineLabel}</span>}
|
||||||
</div>
|
</div>
|
||||||
<ColumnResizer reportDragDeltaX={handleChangeWidth} />
|
<ColumnResizer reportDragDeltaX={handleChangeWidth} />
|
||||||
</div>
|
</div>
|
||||||
@ -317,7 +342,16 @@ const FilterCell = ({ column }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData, budgetYear }) => {
|
const TableHeadComponent = ({
|
||||||
|
table,
|
||||||
|
selectedColumnId,
|
||||||
|
onColumnSelect,
|
||||||
|
onChangeWidth,
|
||||||
|
isLoadingData,
|
||||||
|
budgetYear,
|
||||||
|
stageColumnDeadlines,
|
||||||
|
userRoleId,
|
||||||
|
}) => {
|
||||||
const handleHeaderClick = (header) => {
|
const handleHeaderClick = (header) => {
|
||||||
if (!header.subHeaders?.length && header.column?.id) {
|
if (!header.subHeaders?.length && header.column?.id) {
|
||||||
onColumnSelect?.(header.column.id);
|
onColumnSelect?.(header.column.id);
|
||||||
@ -361,6 +395,8 @@ const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeW
|
|||||||
pinningPosition={pinningPosition}
|
pinningPosition={pinningPosition}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onChangeWidth={onChangeWidth}
|
onChangeWidth={onChangeWidth}
|
||||||
|
stageColumnDeadlines={stageColumnDeadlines}
|
||||||
|
userRoleId={userRoleId}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
|
|||||||
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [columnsCurStage, setColumnsCurStage] = useState([]);
|
const [columnsCurStage, setColumnsCurStage] = useState([]);
|
||||||
|
// AURORA-1890: срок ввода (closes_at) для колонок текущего этапа —
|
||||||
|
// чтобы показать его в заголовке колонки. Ключи совпадают с columnsCurStage
|
||||||
|
// ("data.<column>"), closes_at отбрасывался раньше (useRealtimeData.js:48-51).
|
||||||
|
const [stageColumnDeadlines, setStageColumnDeadlines] = useState(new Map());
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [editingCells, setEditingCells] = useState({});
|
const [editingCells, setEditingCells] = useState({});
|
||||||
|
|
||||||
@ -47,8 +51,10 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
|
|
||||||
const data = res.result.filter((d) => d.depth > -1);
|
const data = res.result.filter((d) => d.depth > -1);
|
||||||
const curStageColumns = res.result.filter((d) => d.depth === -1)[0];
|
const curStageColumns = res.result.filter((d) => d.depth === -1)[0];
|
||||||
const stageColumns = (curStageColumns?.data?.editable || []).map((column) => `data.${column.column}`);
|
const editableColumns = curStageColumns?.data?.editable || [];
|
||||||
|
const stageColumns = editableColumns.map((column) => `data.${column.column}`);
|
||||||
setColumnsCurStage(stageColumns);
|
setColumnsCurStage(stageColumns);
|
||||||
|
setStageColumnDeadlines(new Map(editableColumns.map((column) => [`data.${column.column}`, column.closes_at])));
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
setData([]);
|
setData([]);
|
||||||
@ -233,6 +239,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
columnsCurStage,
|
columnsCurStage,
|
||||||
|
stageColumnDeadlines,
|
||||||
isLoading,
|
isLoading,
|
||||||
editingCells,
|
editingCells,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { getDenominationDivisor } from '../common/useDenomination';
|
|||||||
import { ROLES_NAME_ID } from '../../constants/constants';
|
import { ROLES_NAME_ID } from '../../constants/constants';
|
||||||
import { blueColumn } from './constants/columnColors';
|
import { blueColumn } from './constants/columnColors';
|
||||||
import { sectionCodeColor } from './constants/columnConfig';
|
import { sectionCodeColor } from './constants/columnConfig';
|
||||||
|
import { isCellEditable } from './utils/cellEditability';
|
||||||
|
|
||||||
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
||||||
|
|
||||||
@ -128,13 +129,18 @@ export const getTableColumns = ({
|
|||||||
const cellPropsCache = new WeakMap();
|
const cellPropsCache = new WeakMap();
|
||||||
const editPropsCache = new WeakMap();
|
const editPropsCache = new WeakMap();
|
||||||
|
|
||||||
const isEditable = (row, column) => {
|
// AURORA-1890: stageColumns ожидается как Set (сигнатура isCellEditable),
|
||||||
if (row.original?.row_type !== 'INPUT') return false;
|
// columnsCurStage из useRealtimeData — массив; строим Set один раз на
|
||||||
if (userRoleId === ROLES_NAME_ID.admin) return true;
|
// пересборку колонок, а не на каждую ячейку.
|
||||||
const keyCol = column.id;
|
const stageColumns = new Set(columnsCurStage);
|
||||||
if (columnsCurStage.includes(keyCol)) return true;
|
|
||||||
return false;
|
const isEditable = (row, column) =>
|
||||||
};
|
isCellEditable({
|
||||||
|
rowType: row.original?.row_type,
|
||||||
|
columnId: column.id,
|
||||||
|
isAdmin: userRoleId === ROLES_NAME_ID.admin,
|
||||||
|
stageColumns,
|
||||||
|
});
|
||||||
|
|
||||||
const getCachedCellProps = (row, column, table) => {
|
const getCachedCellProps = (row, column, table) => {
|
||||||
const key = `${row.id}_${column.id}`;
|
const key = `${row.id}_${column.id}`;
|
||||||
|
|||||||
107
web/src/components/RealtimeTable/utils/cellEditability.js
Normal file
107
web/src/components/RealtimeTable/utils/cellEditability.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// AURORA-1890: редактируемость ячейки — чистая функция, вынесенная из
|
||||||
|
// tableColumns.jsx. Используется tableColumns.jsx (isEditable внутри
|
||||||
|
// getTableColumns) и будет использоваться AURORA-1891 — сигнатуру без
|
||||||
|
// необходимости не менять.
|
||||||
|
//
|
||||||
|
// isComputedColumn повторяет часть серверной проверки из
|
||||||
|
// v3._apply_form_cell и v3._apply_form3_cell
|
||||||
|
// (api/alembic/versions/sql/0023_apply_cell_empty_string.sql; функция
|
||||||
|
// переопределяется CREATE OR REPLACE в более новых миграциях, но список
|
||||||
|
// computed_field/normative_field исключений не менялся): какие колонки
|
||||||
|
// сервер отвергает при попытке правки через _apply_form_cell/_apply_form3_cell,
|
||||||
|
// те же колонки фронт не должен показывать как редактируемые — даже
|
||||||
|
// администратору, у которого isEditable иначе был бы всегда true на
|
||||||
|
// строках INPUT.
|
||||||
|
//
|
||||||
|
// Это фронтовый список, зашитый в коде (а не синхронизированный с БД
|
||||||
|
// автоматически) — умышленно, см. AURORA-1890. Расхождения чинятся отдельным
|
||||||
|
// PR, а не здесь.
|
||||||
|
|
||||||
|
// 0023:416-420 — IF v_field IN (...) THEN RAISE EXCEPTION 'computed_field: %'
|
||||||
|
// применяется к полям scope q1..q4, плюс FORM_3 (_apply_form3_cell, 0023:515)
|
||||||
|
// называет те же по смыслу поля иначе (quarter_actual вместо actual_quarter,
|
||||||
|
// total_corr — отдельное суммарное поле без аналога в FORM_1/2/4).
|
||||||
|
const QUARTER_COMPUTED_FIELDS = new Set([
|
||||||
|
'corrected_plan',
|
||||||
|
'new_plan',
|
||||||
|
'actual_quarter',
|
||||||
|
'booking',
|
||||||
|
'rem_booking',
|
||||||
|
'rem_actual',
|
||||||
|
'residual_after_booking',
|
||||||
|
'residual_after_actual',
|
||||||
|
'economy',
|
||||||
|
'total',
|
||||||
|
// FORM_3 / PROJECT CURRENT_EXPENSES (_apply_form3_cell, 0023:515-517)
|
||||||
|
'quarter_actual',
|
||||||
|
'total_corr',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 0023:200-219 — header.* редактируется через v3.budget_line, остальные
|
||||||
|
// поля header.* (year/section/item_id/num_group/vsp_address) — computed_field.
|
||||||
|
const HEADER_EDITABLE_FIELDS = new Set(['name', 'internal_order', 'justification', 'vsp_id']);
|
||||||
|
|
||||||
|
// 0023:154-155 — атрибуты ВСП на сателлитах AHR_RENT/AHR_UTILITY/AHR_SECURITY
|
||||||
|
// правятся через справочник ВСП, не через эту ячейку.
|
||||||
|
const VSP_ATTRIBUTE_FIELDS = new Set(['address', 'object_type', 'rented_area']);
|
||||||
|
|
||||||
|
// 0023:115-127 — лист лимитов AHR_LIMIT: amount_qN вычисляется из
|
||||||
|
// qty × limit × period_factor, limit_with_vat/limit_without_vat — нормативный
|
||||||
|
// справочник (правится админом отдельно, не через ячейку формы).
|
||||||
|
const LIMIT_SHEET_COMPUTED_FIELDS = new Set(['amount_q1', 'amount_q2', 'amount_q3', 'amount_q4', 'limit_with_vat', 'limit_without_vat']);
|
||||||
|
|
||||||
|
const QUARTER_SCOPE_PATTERN = /^q[1-4]$/;
|
||||||
|
const FACT_QUARTER_SCOPE_PATTERN = /^fact_q[1-4]$/;
|
||||||
|
|
||||||
|
const DATA_PREFIX = 'data.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Повторяет серверный список computed_field/normative_field исключений
|
||||||
|
* v3._apply_form_cell / v3._apply_form3_cell для набора колонок, который
|
||||||
|
* знает фронт (FORM_1/2/4, PROJECT, сателлиты AHR_LIMIT/RENT/UTILITY/SECURITY,
|
||||||
|
* OTCH9F). columnId — это React Table column.id в формате accessorKey, т.е.
|
||||||
|
* всегда с префиксом "data." для полей строки (например "data.q1.booking").
|
||||||
|
*/
|
||||||
|
export const isComputedColumn = (columnId) => {
|
||||||
|
if (typeof columnId !== 'string' || !columnId.startsWith(DATA_PREFIX)) return false;
|
||||||
|
|
||||||
|
const parts = columnId.slice(DATA_PREFIX.length).split('.');
|
||||||
|
const [scope, field] = parts;
|
||||||
|
|
||||||
|
// 0023:221-223 (FORM_1/2/4), 0023:515 (FORM_3) — approved.*/totals.* всегда вычисляемые.
|
||||||
|
if (scope === 'approved' || scope === 'totals') return true;
|
||||||
|
|
||||||
|
// 0023:200-219 — header.* кроме разрешённого списка.
|
||||||
|
if (scope === 'header') return !HEADER_EDITABLE_FIELDS.has(field);
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
// *.year — plan.year, seq_dfip.year, seq_ssp.year, reserve.year и т.п.
|
||||||
|
if (field === 'year') return true;
|
||||||
|
|
||||||
|
// qN.<field> из списка вычисляемых полей квартала.
|
||||||
|
if (QUARTER_SCOPE_PATTERN.test(scope) && QUARTER_COMPUTED_FIELDS.has(field)) return true;
|
||||||
|
|
||||||
|
// fact_qN.total — сателлиты AHR_RENT/AHR_UTILITY/AHR_SECURITY.
|
||||||
|
if (FACT_QUARTER_SCOPE_PATTERN.test(scope) && field === 'total') return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
if (VSP_ATTRIBUTE_FIELDS.has(scope)) return true;
|
||||||
|
if (LIMIT_SHEET_COMPUTED_FIELDS.has(scope)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isCellEditable({ rowType, columnId, isAdmin, stageColumns })
|
||||||
|
*
|
||||||
|
* rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId))
|
||||||
|
*
|
||||||
|
* stageColumns — Set с ключами вида "data.<column>" (columnsCurStage из
|
||||||
|
* useRealtimeData, отражающий окно текущего этапа пользователя).
|
||||||
|
*/
|
||||||
|
export const isCellEditable = ({ rowType, columnId, isAdmin, stageColumns }) =>
|
||||||
|
rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId));
|
||||||
146
web/src/components/RealtimeTable/utils/cellEditability.test.js
Normal file
146
web/src/components/RealtimeTable/utils/cellEditability.test.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { isCellEditable, isComputedColumn } from './cellEditability.js';
|
||||||
|
|
||||||
|
// Контрольные примеры из тикета AURORA-1890.
|
||||||
|
|
||||||
|
test('INPUT row with a column open on the user stage is editable', () => {
|
||||||
|
const editable = isCellEditable({
|
||||||
|
rowType: 'INPUT',
|
||||||
|
columnId: 'data.plan.q1',
|
||||||
|
isAdmin: false,
|
||||||
|
stageColumns: new Set(['data.plan.q1']),
|
||||||
|
});
|
||||||
|
assert.equal(editable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ITEM row is never editable, even for a stage column', () => {
|
||||||
|
const editable = isCellEditable({
|
||||||
|
rowType: 'ITEM',
|
||||||
|
columnId: 'data.plan.q1',
|
||||||
|
isAdmin: false,
|
||||||
|
stageColumns: new Set(['data.plan.q1']),
|
||||||
|
});
|
||||||
|
assert.equal(editable, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin cannot edit computed columns on an INPUT row', () => {
|
||||||
|
const computedColumnIds = ['data.q1.corrected_plan', 'data.approved.q1', 'data.totals.fact_year', 'data.header.vsp_address'];
|
||||||
|
|
||||||
|
for (const columnId of computedColumnIds) {
|
||||||
|
const editable = isCellEditable({
|
||||||
|
rowType: 'INPUT',
|
||||||
|
columnId,
|
||||||
|
isAdmin: true,
|
||||||
|
stageColumns: new Set(),
|
||||||
|
});
|
||||||
|
assert.equal(editable, false, `${columnId} must not be editable even for admin`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can edit a regular (non-computed) INPUT cell outside their own stage', () => {
|
||||||
|
const editable = isCellEditable({
|
||||||
|
rowType: 'INPUT',
|
||||||
|
columnId: 'data.plan.q1',
|
||||||
|
isAdmin: true,
|
||||||
|
stageColumns: new Set(),
|
||||||
|
});
|
||||||
|
assert.equal(editable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executor cannot edit a column outside their current stage', () => {
|
||||||
|
const editable = isCellEditable({
|
||||||
|
rowType: 'INPUT',
|
||||||
|
columnId: 'data.plan.q1',
|
||||||
|
isAdmin: false,
|
||||||
|
stageColumns: new Set(['data.plan.q2']),
|
||||||
|
});
|
||||||
|
assert.equal(editable, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// isComputedColumn — повторяет v3._apply_form_cell / v3._apply_form3_cell
|
||||||
|
// (api/alembic/versions/sql/0023_apply_cell_empty_string.sql).
|
||||||
|
|
||||||
|
test('approved.* and totals.* are always computed (0023:221-223, 0023:515)', () => {
|
||||||
|
assert.equal(isComputedColumn('data.approved.q1'), true);
|
||||||
|
assert.equal(isComputedColumn('data.approved.year'), true);
|
||||||
|
assert.equal(isComputedColumn('data.totals.fact_year'), true);
|
||||||
|
assert.equal(isComputedColumn('data.totals.go_balance_only'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('header.* is computed except name/internal_order/justification/vsp_id (0023:200-219)', () => {
|
||||||
|
assert.equal(isComputedColumn('data.header.name'), false);
|
||||||
|
assert.equal(isComputedColumn('data.header.internal_order'), false);
|
||||||
|
assert.equal(isComputedColumn('data.header.justification'), false);
|
||||||
|
assert.equal(isComputedColumn('data.header.vsp_id'), false);
|
||||||
|
|
||||||
|
assert.equal(isComputedColumn('data.header.vsp_address'), true);
|
||||||
|
assert.equal(isComputedColumn('data.header.section'), true);
|
||||||
|
assert.equal(isComputedColumn('data.header.item_id'), true);
|
||||||
|
assert.equal(isComputedColumn('data.header.num_group'), true);
|
||||||
|
assert.equal(isComputedColumn('data.header.year'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('qN computed fields match the SQL exception list (0023:416-420)', () => {
|
||||||
|
const computedQuarterFields = [
|
||||||
|
'corrected_plan',
|
||||||
|
'new_plan',
|
||||||
|
'actual_quarter',
|
||||||
|
'booking',
|
||||||
|
'rem_booking',
|
||||||
|
'rem_actual',
|
||||||
|
'residual_after_booking',
|
||||||
|
'residual_after_actual',
|
||||||
|
'economy',
|
||||||
|
'total',
|
||||||
|
];
|
||||||
|
for (const field of computedQuarterFields) {
|
||||||
|
assert.equal(isComputedColumn(`data.q1.${field}`), true, `data.q1.${field} must be computed`);
|
||||||
|
assert.equal(isComputedColumn(`data.q4.${field}`), true, `data.q4.${field} must be computed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// обычные ненулевые поля квартала остаются редактируемыми
|
||||||
|
assert.equal(isComputedColumn('data.q1.adj_current'), false);
|
||||||
|
assert.equal(isComputedColumn('data.q1.pay_amount'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FORM_3 (PROJECT/CURRENT_EXPENSES) uses different names for the same computed fields (_apply_form3_cell, 0023:515)', () => {
|
||||||
|
assert.equal(isComputedColumn('data.q1.quarter_actual'), true);
|
||||||
|
assert.equal(isComputedColumn('data.q2.total_corr'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('*.year columns are computed regardless of scope (plan.year, seq_dfip.year, seq_ssp.year, reserve.year)', () => {
|
||||||
|
assert.equal(isComputedColumn('data.plan.year'), true);
|
||||||
|
assert.equal(isComputedColumn('data.seq_dfip.year'), true);
|
||||||
|
assert.equal(isComputedColumn('data.seq_ssp.year'), true);
|
||||||
|
assert.equal(isComputedColumn('data.reserve.year'), true);
|
||||||
|
assert.equal(isComputedColumn('data.sequestration.year'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('satellite fact_qN.total is computed, other fact_qN fields are editable (0023:163-164)', () => {
|
||||||
|
assert.equal(isComputedColumn('data.fact_q1.total'), true);
|
||||||
|
assert.equal(isComputedColumn('data.fact_q1.jan'), false);
|
||||||
|
assert.equal(isComputedColumn('data.fact_q2.total'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VSP attributes on AHR_RENT/UTILITY/SECURITY satellites are computed (0023:154-155)', () => {
|
||||||
|
assert.equal(isComputedColumn('data.address'), true);
|
||||||
|
assert.equal(isComputedColumn('data.object_type'), true);
|
||||||
|
assert.equal(isComputedColumn('data.rented_area'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AHR_LIMIT sheet: amount_qN and limit_with/without_vat are computed (0023:120-124)', () => {
|
||||||
|
assert.equal(isComputedColumn('data.amount_q1'), true);
|
||||||
|
assert.equal(isComputedColumn('data.amount_q4'), true);
|
||||||
|
assert.equal(isComputedColumn('data.limit_with_vat'), true);
|
||||||
|
assert.equal(isComputedColumn('data.limit_without_vat'), true);
|
||||||
|
|
||||||
|
// qty_qN остаётся редактируемым полем листа лимитов
|
||||||
|
assert.equal(isComputedColumn('data.qty_q1'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain non-"data." column ids (row number, grouping headers) are never computed', () => {
|
||||||
|
assert.equal(isComputedColumn('sort_order'), false);
|
||||||
|
assert.equal(isComputedColumn(undefined), false);
|
||||||
|
assert.equal(isComputedColumn(null), false);
|
||||||
|
});
|
||||||
17
web/src/components/RealtimeTable/utils/deadlineFormat.js
Normal file
17
web/src/components/RealtimeTable/utils/deadlineFormat.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// AURORA-1890: срок ввода в заголовке колонки — closes_at показывается в
|
||||||
|
// локальном времени браузера пользователя (ISO-строка/Date уже несёт
|
||||||
|
// смещение часового пояса, Date сам пересчитывает его в локальное при
|
||||||
|
// обращении к getDate/getHours и т.п.), без года: «до 31.10 18:00».
|
||||||
|
export const formatDeadline = (closesAt) => {
|
||||||
|
if (!closesAt) return null;
|
||||||
|
|
||||||
|
const date = closesAt instanceof Date ? closesAt : new Date(closesAt);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `до ${day}.${month} ${hours}:${minutes}`;
|
||||||
|
};
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { formatDeadline } from './deadlineFormat.js';
|
||||||
|
|
||||||
|
test('ISO closes_at is formatted as "до DD.MM HH:mm" in local time', () => {
|
||||||
|
const closesAt = new Date(2026, 9, 31, 18, 0, 0); // 31 октября, 18:00, локальное время
|
||||||
|
assert.equal(formatDeadline(closesAt.toISOString()), 'до 31.10 18:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-digit day/month/hour/minute are zero-padded', () => {
|
||||||
|
const closesAt = new Date(2026, 0, 5, 9, 3, 0); // 5 января, 09:03
|
||||||
|
assert.equal(formatDeadline(closesAt.toISOString()), 'до 05.01 09:03');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Date instance is accepted directly, not only an ISO string', () => {
|
||||||
|
const closesAt = new Date(2026, 11, 1, 0, 0, 0);
|
||||||
|
assert.equal(formatDeadline(closesAt), 'до 01.12 00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falsy or invalid input returns null instead of throwing', () => {
|
||||||
|
assert.equal(formatDeadline(null), null);
|
||||||
|
assert.equal(formatDeadline(undefined), null);
|
||||||
|
assert.equal(formatDeadline(''), null);
|
||||||
|
assert.equal(formatDeadline('not-a-date'), null);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user