From df6ec795a3bf83b3839c39a73cc5cbf9c2dea3cd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 1 Nov 2026 13:00:40 +0400 Subject: [PATCH] =?UTF-8?q?feat(table):=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BB=D0=B8=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D1=83=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8B=D1=85=20(AURORA-1890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 60 +++++-- .../RealtimeTable/RealtimeTable.jsx | 3 + .../RealtimeTable/SettingPanel/CellLegend.jsx | 55 +++++++ .../SettingPanel/SettingPanel.jsx | 6 +- .../RealtimeTable/TableHead/TableHead.jsx | 40 ++++- .../RealtimeTable/hooks/useRealtimeData.js | 9 +- .../components/RealtimeTable/tableColumns.jsx | 20 ++- .../RealtimeTable/utils/cellEditability.js | 107 +++++++++++++ .../utils/cellEditability.test.js | 146 ++++++++++++++++++ .../RealtimeTable/utils/deadlineFormat.js | 17 ++ .../utils/deadlineFormat.test.js | 25 +++ 11 files changed, 465 insertions(+), 23 deletions(-) create mode 100644 web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx create mode 100644 web/src/components/RealtimeTable/utils/cellEditability.js create mode 100644 web/src/components/RealtimeTable/utils/cellEditability.test.js create mode 100644 web/src/components/RealtimeTable/utils/deadlineFormat.js create mode 100644 web/src/components/RealtimeTable/utils/deadlineFormat.test.js diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index bada91a..9fbbdbb 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -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: -
+
{highlightedContent} {lockBadge}
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 7d0f231..a35aec1 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -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, )} diff --git a/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx b/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx new file mode 100644 index 0000000..8bcc26c --- /dev/null +++ b/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx @@ -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 = () => ( + + + + можно вводить + + + + расчёт или закрыто на этапе + + + }> + + + + +); + +export default memo(CellLegend); diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 3acceb7..6b4dba4 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -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 = ({ /> - + + + + ); diff --git a/web/src/components/RealtimeTable/TableHead/TableHead.jsx b/web/src/components/RealtimeTable/TableHead/TableHead.jsx index fa6d1d7..39dce95 100644 --- a/web/src/components/RealtimeTable/TableHead/TableHead.jsx +++ b/web/src/components/RealtimeTable/TableHead/TableHead.jsx @@ -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 && {deadlineLabel}}
@@ -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} /> )), )} diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js index df45906..2b4973b 100644 --- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js +++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js @@ -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."), 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, }; diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index cf557e0..baee1c2 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -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}`; diff --git a/web/src/components/RealtimeTable/utils/cellEditability.js b/web/src/components/RealtimeTable/utils/cellEditability.js new file mode 100644 index 0000000..36efce4 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellEditability.js @@ -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. из списка вычисляемых полей квартала. + 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." (columnsCurStage из + * useRealtimeData, отражающий окно текущего этапа пользователя). + */ +export const isCellEditable = ({ rowType, columnId, isAdmin, stageColumns }) => + rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId)); diff --git a/web/src/components/RealtimeTable/utils/cellEditability.test.js b/web/src/components/RealtimeTable/utils/cellEditability.test.js new file mode 100644 index 0000000..618a710 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellEditability.test.js @@ -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); +}); diff --git a/web/src/components/RealtimeTable/utils/deadlineFormat.js b/web/src/components/RealtimeTable/utils/deadlineFormat.js new file mode 100644 index 0000000..e539bef --- /dev/null +++ b/web/src/components/RealtimeTable/utils/deadlineFormat.js @@ -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}`; +}; diff --git a/web/src/components/RealtimeTable/utils/deadlineFormat.test.js b/web/src/components/RealtimeTable/utils/deadlineFormat.test.js new file mode 100644 index 0000000..6ef1e27 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/deadlineFormat.test.js @@ -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); +});