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:
Andrey Epifancev 2026-11-01 13:00:40 +04:00
parent d518961365
commit df6ec795a3
11 changed files with 465 additions and 23 deletions

View File

@ -1,4 +1,4 @@
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useState } from 'react';
import { useCellLock } from '../../contexts/RealtimeContext';
import { formatNumber } from '../../utils/numberFormat';
@ -17,11 +17,12 @@ const BASE_CELL_STYLES = {
padding: '8px',
};
// AURORA-1890: занятая другим пользователем ячейка не должна читаться как
// «только чтение» (старый серый фон #f5f5f5 путался с нередактируемой
// ячейкой) — фон не меняем, отличаем пунктирной оранжевой рамкой и значком 🔒.
const LOCKED_STYLES = {
border: '2px solid #e0e0e0',
backgroundColor: '#f5f5f5',
border: '2px dashed #F0A030',
cursor: 'not-allowed',
opacity: 0.85,
};
const INVALID_STYLES = {
@ -35,11 +36,25 @@ const INVALID_STYLES = {
const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
// AURORA-1890: редактируемая ячейка строки INPUT — белый фон и заметная
// синяя рамка (вместо прежней почти незаметной полупрозрачной 2px #d8e4ea94).
// Толщина рамки растёт при наведении (1px → 2px, см. isHovered в CellComponent), курсор — text.
const EDITABLE_STYLES = {
cursor: 'pointer',
border: '2px solid #d8e4ea94',
cursor: 'text',
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 = {
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 cellKey = `${row.id}_${column.id}`;
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 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 value = cell.getValue();
@ -165,12 +189,17 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
return createHighlightedContent(rawValue, displayValue, searchQueries);
}, [rawValue, displayValue, globalFilter, columnFilter]);
// AURORA-1890: занятая коллегой ячейка больше не красится в серый —
// обычный brightness-based цвет текста, отличие только в рамке/бейдже.
const textColor = useMemo(() => {
if (isLocked) return '#999999';
const brightness = getColorBrightness(backgroundColor);
if (isNegative) return brightness < 128 ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
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(
() => ({
@ -178,14 +207,14 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
paddingLeft: nameIndent,
justifyContent: isNumeric ? 'flex-end' : 'flex-start',
textAlign: isNumeric ? 'right' : 'left',
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
backgroundColor,
color: textColor,
...(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 : {}),
...(isInvalid ? INVALID_STYLES : {}),
}),
[backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent, isNegative],
[backgroundColor, isInvalid, isLocked, textColor, isEditable, notEditableStyles, isNumeric, nameIndent, isNegative, isHovered],
);
const lockBadge = useMemo(() => {
@ -201,7 +230,14 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
return (
// 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>
{lockBadge}
</div>

View File

@ -82,6 +82,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const {
data,
columnsCurStage,
stageColumnDeadlines,
isLoading: isTableLoading,
editingCells: dataEditingCells,
} = useRealtimeData(formId, sheetName, direction, formType, year);
@ -844,6 +845,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
stageColumnDeadlines={stageColumnDeadlines}
userRoleId={userRoleId}
/>,
headerPortalRef.current,
)}

View 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);

View File

@ -22,6 +22,7 @@ import {
VisibleExpenseItems,
} from '../../common/icons/icons';
import { collectLeafColumns } from '../utils/columnUtils';
import CellLegend from './CellLegend';
import { GroupByObject } from './GroupByObject/GroupByObject';
import { Panel } from './SettingPanel.style';
import ValidationErrorsAlert from './ValidationErrorsAlert';
@ -321,7 +322,10 @@ const SettingPanel = ({
/>
</GroupByObject>
</Stack>
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
<CellLegend />
</Stack>
</Panel>
</>
);

View File

@ -2,6 +2,21 @@ import React, { useMemo } from 'react';
import ColumnResizer from './ColumnResizer';
import { formatHeaderYear } from '../utils/headerYearUtils';
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 pinningPosition = column.getIsPinned();
@ -102,7 +117,7 @@ const getColorBrightness = (hexColor) => {
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 headerSize = header.getSize();
const isPinned = Boolean(pinningPosition);
@ -120,6 +135,15 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
return brightness < 128 ? '#ffffff' : '#000000';
}, [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 size = header.getSize();
onChangeWidth(header, size + deltaWidth);
@ -173,6 +197,7 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
boxSizing: 'border-box',
}}>
{header.isPlaceholder ? null : formatHeaderYear(column.columnDef.header, budgetYear)}
{deadlineLabel && <span style={DEADLINE_LABEL_STYLES}>{deadlineLabel}</span>}
</div>
<ColumnResizer reportDragDeltaX={handleChangeWidth} />
</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) => {
if (!header.subHeaders?.length && header.column?.id) {
onColumnSelect?.(header.column.id);
@ -361,6 +395,8 @@ const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeW
pinningPosition={pinningPosition}
onClick={handleHeaderClick}
onChangeWidth={onChangeWidth}
stageColumnDeadlines={stageColumnDeadlines}
userRoleId={userRoleId}
/>
)),
)}

View File

@ -9,6 +9,10 @@ import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const [data, setData] = 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 [editingCells, setEditingCells] = useState({});
@ -47,8 +51,10 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const data = res.result.filter((d) => d.depth > -1);
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);
setStageColumnDeadlines(new Map(editableColumns.map((column) => [`data.${column.column}`, column.closes_at])));
if (!data || data.length === 0) {
setData([]);
@ -233,6 +239,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
return {
data,
columnsCurStage,
stageColumnDeadlines,
isLoading,
editingCells,
};

View File

@ -7,6 +7,7 @@ import { getDenominationDivisor } from '../common/useDenomination';
import { ROLES_NAME_ID } from '../../constants/constants';
import { blueColumn } from './constants/columnColors';
import { sectionCodeColor } from './constants/columnConfig';
import { isCellEditable } from './utils/cellEditability';
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
@ -128,13 +129,18 @@ export const getTableColumns = ({
const cellPropsCache = new WeakMap();
const editPropsCache = new WeakMap();
const isEditable = (row, column) => {
if (row.original?.row_type !== 'INPUT') return false;
if (userRoleId === ROLES_NAME_ID.admin) return true;
const keyCol = column.id;
if (columnsCurStage.includes(keyCol)) return true;
return false;
};
// AURORA-1890: stageColumns ожидается как Set (сигнатура isCellEditable),
// columnsCurStage из useRealtimeData — массив; строим Set один раз на
// пересборку колонок, а не на каждую ячейку.
const stageColumns = new Set(columnsCurStage);
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 key = `${row.id}_${column.id}`;

View 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));

View 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);
});

View 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}`;
};

View File

@ -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);
});