10 Commits

Author SHA1 Message Date
gorbenko.pavel 63c674ed99 Merge branch 'claude/heuristic-jones-cb2a26' into service-request
Tests / frontend-test (pull_request) Successful in 1m18s
2026-09-01 17:25:21 +05:00
gorbenko.pavel da5c3807e8 fix(print): убрать полосы прокрутки из печати таблиц
На печати заявки под строками сметы и объёмов работ выходила серая полоса
прокрутки со стрелками (видно в PDF): контейнеры с overflow-x: auto
печатаются вместе со своим скроллбаром, а уехавшее вбок на бумаге просто
теряется — прокрутить лист нельзя.

В @media print отпускаем ширину: overflow: visible и min-width: 0, тогда
таблица укладывается в лист целиком. Помимо новых блоков «Заявки на
услуги» это же исправлено у общей ItemsTable (печать СЗ на перемещение
ТМЦ) и у матрицы согласования — там ровно тот же контейнер.

Блоки @media print стоят В КОНЦЕ файлов намеренно: медиазапрос не
повышает специфичность, и базовое `.row { min-width: 640px }`, объявленное
ниже, перебивало бы правило печати (поймано на скомпилированном CSS).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:19:30 +05:00
gorbenko.pavel c5a443b5d1 Включить react-hooks/exhaustive-deps и разобрать устаревшие замыкания
Правило было не задано вовсе: плагин eslint-plugin-react-hooks подключает
профиль @microsoft/eslint-config-spfx/lib/profiles/react, но правил из него
не включает. Теперь в .eslintrc.js явно заданы rules-of-hooks (error) и
exhaustive-deps (warn); сборка проходит с нулём предупреждений.

Что реально чинилось, а не затыкалось:

- shared/useStableCallback.ts — обёртка с неизменной ссылкой над колбэком-пропом
  (аналог будущего useEffectEvent). Ссылка стабильна, внутри всегда актуальная
  версия колбэка, поэтому её можно честно класть в зависимости.
- onCodeLoaded во всех forms/*/*View.tsx и *Form.tsx идёт в эффект загрузки
  через эту обёртку: просто дописать проп в зависимости нельзя — заявка
  перезапрашивалась бы на каждый рендер родителя.
- useRequestSubmit: run стабилизирован (useCallback + savingRef вместо чтения
  состояния), возвращаемый объект мемоизирован. Формы берут из него стабильный
  setError и указывают в зависимостях его, а не submit целиком.
- shared/usePersistedQuery.ts (useRoutedQuery) — было устаревшее замыкание:
  эффект навсегда запоминал первую версию onQueryChange, а та замкнута на route
  на момент монтирования, то есть набранный запрос мог уехать в маршрут чужого
  раздела. Теперь зовётся актуальная версия.
- propertyPane/DebugUserField.tsx — то же самое с onResolved.
- Формы: в эффект "новая заявка по умолчанию на себя" дописан context
  (единственный экземпляр на жизнь веб-части, лишних перезапусков не даёт),
  в ReferralForm ещё и мемоизированный approval.
- RequestsTableView: dataKey в зависимости (он и так выводится из view/
  statusFilter/debouncedQuery, которые там уже есть).

Осознанные отступления оставлены с объяснением ПОЧЕМУ: useMatrixEditor (момент
перечитывания матрицы задаёт вызывающий через reloadKey), companiesFieldValue
в RecruitingHire/RecruitingSelection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:11:54 +05:00
gorbenko.pavel 6d92d7f9c1 fix(approval): колонки SLA в манифестах согласования + читаемый текст ошибки
Маршрут «Заявки на услуги» падал с 400 «Поле или свойство SLAhours не
существует»: общий ApprovalListService читает и пишет SLAhours/
AskOverdueReason у ЛЮБОЙ формы, а в манифесте согласования их не было.
Манифест копировался с TmcTransfer, где их тоже нет — там на тенанте
колонки почему-то есть, поэтому не проявлялось, но следующая партиция
списка создалась бы уже сломанной. Добавил обеим формам.

Текст ошибки при этом выводился как «Поле…»:
useMatrixEditor и useRequestTypeId клали в state сырой err.message, а
PnPjs кладёт туда тело ответа SharePoint целиком, где кириллица —
\u-escape-последовательности. Пропустил оба через friendlyErrorMessage,
который для этого и написан (он уже используется формами).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:06:06 +05:00
gorbenko.pavel 1e265875e2 fix(ServiceRequest): падение просмотра заявки — хук после раннего return
useMemo с attachmentUrls стоял ПОСЛЕ `if (loading) return` — на первом
рендере (загрузка) он не вызывался, на втором вызывался, порядок хуков
менялся, и React ронял компонент с #310 «rendered more hooks than during
the previous render». Карточка заявки на услуги не открывалась вообще.
Перенёс расчёт выше ранних выходов.

Заодно включил `react-hooks/rules-of-hooks` (error) — плагин давно в
зависимостях и в коде есть eslint-disable для exhaustive-deps, то есть
правила подразумевались, но в .eslintrc их забыли объявить. По остальному
коду правило даёт 0 нарушений, так что этот баг был единственным.
`exhaustive-deps` пока оставил выключенным: на текущем коде это 16
предупреждений, их разбор — отдельная задача, а не довесок к фиксу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:49:44 +05:00
gorbenko.pavel a3d3983d78 fix(ServiceRequest): убрать лишний FormGrid внутри CollapseSection
CollapseSection.body у @tnsu/ui-kit уже сам является 2-колоночным грид-
контейнером (._body{grid-template-columns:1fr 1fr}) — FormGrid внутри
неё создавал ВЛОЖЕННЫЙ грид, который занимал только ОДНУ из двух колонок
родителя (как единственный grid-item без явного grid-column), а его
собственные две колонки делили уже урезанную пополам ширину. Из-за этого
«Объём работ», «Итого объёмы работ» и «Общие замечания» рендерились на
половине ширины карточки, хотя место было.

FormField кладём прямо в CollapseSection, без обёртки FormGrid — так уже
сделано во всех остальных формах (см. Clearance/RequestSection.tsx).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 16:37:58 +05:00
gorbenko.pavel 97fb71036b fix(ServiceRequest): строка сметы/объёмов работ не влезала в узкий Drawer
Форма открывается в боковом Drawer, а не на всю страницу — его ширина не
связана с viewport, поэтому брейкпоинты CollapseSection/FormGrid (следят
за шириной окна) не спасают. Фикс-колонки строки (ед.изм/кол-во/цена/
сумма) при этом не сжимались (grid-колонки по умолчанию не сжимаются
своего содержимого) и вылезали за рамку карточки.

Обернул строки в горизонтально скроллящийся контейнер (тот же приём, что
раньше был у плоской таблицы — .tableWrap в AnnexesSection) вместо попытки
сжать поля до нечитаемости.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 16:28:21 +05:00
gorbenko.pavel 0b9bb80b1f refactor(ServiceRequest): итог групп — прямо в JSON, без отдельной колонки
Итог сметы и объёмов работ теперь кладём прямо в JSON-колонку рядом с
группами ({ groups, total } — см. serializeGroups/parseGroupedJson в
cost.ts), а не отдельной числовой колонкой SharePoint. Форма total из
хранилища не доверяет, всегда считает заново; в JSON он нужен только тем,
кто читает колонку напрямую (отчёты, sp-query), не поднимая форму.

Убраны EstimateTotal/WorkVolumesTotal из провижининг-манифеста — они ещё
не были провижинены на тенанте, миграция не нужна.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:50:56 +05:00
gorbenko.pavel 6e2ad78e6e feat(ServiceRequest): детализация объёмов работ под «Объём работ»
Под текстовым описанием объёма работ добавлена таблица групп/позиций с
ценой и суммой — в том же формате, что и смета (группа → позиции → сумма
по группе → общий итог). Дополняет текст итоговыми цифрами, не заменяет
его.

Редактор таблицы «группы → позиции» вынесен в отдельный переиспользуемый
компонент CostGroupsEditor — CostsSection и новый блок в ScopeSection
используют один и тот же код вместо двух копий.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:43:56 +05:00
gorbenko.pavel 8fcba75f0d feat(ServiceRequest): прикрепление файла к строке «Приложения»
Вернул возможность прикладывать файл прямо к строке «Приложения» (fileName
на IAnnexRow, хранится как встроенное вложение элемента списка) и убрал
общий блок «Вложения» — он был избыточен: файлы теперь привязаны к своим
строкам, отдельного места под них не нужно.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:28:47 +05:00
48 changed files with 1049 additions and 399 deletions
+33
View File
@@ -11,7 +11,24 @@ module.exports = {
'ecmaVersion': 2018, 'ecmaVersion': 2018,
'sourceType': 'module' 'sourceType': 'module'
}, },
plugins: ['react-hooks'],
rules: { rules: {
// RATIONALE: Хук, вызванный не на каждом рендере (например, useMemo
// ПОСЛЕ раннего `return` для loading/error), ломает порядок
// хуков и валит компонент в рантайме с React #310 «rendered
// more hooks than during the previous render» — ошибка,
// которую видно только при открытии конкретного экрана.
// Правило ловит это на сборке. eslint-plugin-react-hooks
// давно в зависимостях, а в коде есть eslint-disable для
// exhaustive-deps — то есть правила подразумевались, но
// включить их в конфиг забыли (поймали на ServiceRequestView).
'react-hooks/rules-of-hooks': 2,
// Зависимости эффектов — ВЫКЛЮЧЕНО намеренно. Правило полезное, но на
// текущем коде даёт 16 предупреждений (в основном намеренно опущенные
// onCodeLoaded/submit во всех формах). Шум такого объёма в каждой сборке
// приучает не читать варнинги вовсе — включать имеет смысл вместе с
// разбором этих мест, отдельной задачей, а не заодно с фиксом падения.
'react-hooks/exhaustive-deps': 0,
// Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin // Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin
'@rushstack/no-new-null': 1, '@rushstack/no-new-null': 1,
// Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin // Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin
@@ -290,6 +307,22 @@ module.exports = {
// Rationale to disable: !!{} // Rationale to disable: !!{}
'no-extra-boolean-cast': 0, 'no-extra-boolean-cast': 0,
// ==================================================================== // ====================================================================
// eslint-plugin-react-hooks (плагин подключён профилем
// @microsoft/eslint-config-spfx/lib/profiles/react, но правил он не задаёт)
// ====================================================================
// RATIONALE: Хуки нельзя звать условно/в циклах — иначе React
// перепутает состояние между рендерами.
'react-hooks/rules-of-hooks': 2,
// RATIONALE: Неполный массив зависимостей useEffect/useCallback/
// useMemo — это устаревшее замыкание: эффект продолжает
// видеть старые пропсы и state. Держим предупреждением:
// там, где отступление осознанное (например, эффект
// должен сработать только на смену id, а не на каждое
// изменение колбэка), рядом стоит
// // eslint-disable-next-line react-hooks/exhaustive-deps
// с объяснением причины.
'react-hooks/exhaustive-deps': 1,
// ====================================================================
// @microsoft/eslint-plugin-spfx // @microsoft/eslint-plugin-spfx
// ==================================================================== // ====================================================================
'@microsoft/spfx/no-require-ensure': 2, '@microsoft/spfx/no-require-ensure': 2,
@@ -477,7 +477,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
unsubscribe(); unsubscribe();
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [context, types, view, statusFilter, debouncedQuery, page]); }, [context, types, view, statusFilter, debouncedQuery, page, dataKey]);
// Все ветки отдают уже нарезанную страницу — просто показываем как есть. // Все ветки отдают уже нарезанную страницу — просто показываем как есть.
const q = debouncedQuery.trim(); const q = debouncedQuery.trim();
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveClearanceRequest, clearanceApprovalService } from './submit'; import { saveClearanceRequest, clearanceApprovalService } from './submit';
import { loadClearanceRequest } from './load'; import { loadClearanceRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
const ClearanceForm: React.FC<IRequestFormProps> = ({ const ClearanceForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -53,6 +54,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -78,19 +87,18 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId]);
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
// обходной лист оформляют в день увольнения). При открытии на редактирование // обходной лист оформляют в день увольнения). При открытии на редактирование
@@ -31,6 +31,7 @@ import {
} from './submit'; } from './submit';
import { canEditHrRecordsFields, canEditParkingField } from './approverRights'; import { canEditHrRecordsFields, canEditParkingField } from './approverRights';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IClearanceViewProps { interface IClearanceViewProps {
context: WebPartContext; context: WebPartContext;
@@ -49,6 +50,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
// отдел подбора — парковочная карта (см. approverRights.ts). // отдел подбора — парковочная карта (см. approverRights.ts).
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -115,7 +121,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
setLockedWriteOffs( setLockedWriteOffs(
@@ -132,7 +138,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата // isDelegator намеренно не даёт прав: делегировавший не решает за делегата
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizat
import { saveDismissalRequest, dismissalApprovalService } from './submit'; import { saveDismissalRequest, dismissalApprovalService } from './submit';
import { loadDismissalRequest } from './load'; import { loadDismissalRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
const DismissalForm: React.FC<IRequestFormProps> = ({ const DismissalForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -53,6 +54,14 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -82,19 +91,18 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId]);
/** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */ /** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */
const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] => const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] =>
@@ -124,8 +132,9 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // context — единственный экземпляр WebPartContext на всё время жизни
}, [editId]); // веб-части, так что перезапуска эффекта эта зависимость не добавляет.
}, [editId, context]);
// Выбор другого сотрудника подтягивает его таблицу организаций. // Выбор другого сотрудника подтягивает его таблицу организаций.
const handleEmployeeChange = (login: string): void => { const handleEmployeeChange = (login: string): void => {
@@ -29,6 +29,7 @@ import { dismissalApprovalService, updateAssetsAcceptor } from './submit';
import { loadClearanceRequest } from '../Clearance/load'; import { loadClearanceRequest } from '../Clearance/load';
import { ensureClearanceForDismissal } from './createClearance'; import { ensureClearanceForDismissal } from './createClearance';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
/** /**
* Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком * Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком
@@ -56,6 +57,11 @@ interface IDismissalViewProps {
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через // Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -94,7 +100,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
} }
@@ -108,7 +114,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
// Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала // Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала
// должны отработать/подтвердиться все связанные с увольнением отметки (карты, // должны отработать/подтвердиться все связанные с увольнением отметки (карты,
@@ -60,6 +60,7 @@ import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection'; import { AgencySection } from './sections/AgencySection';
import { useStableCallback } from '../../shared/useStableCallback';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -83,6 +84,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
@@ -101,19 +110,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
setExistingAttachments(atts); setExistingAttachments(atts);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [editId, editListId]); }, [editId, editListId, notifyCode, setSubmitError]);
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего // Маршрут согласования (планируемый) резолвится по email инициатора — текущего
// пользователя (кандидата ещё нет в системе). У заявки, открытой на // пользователя (кандидата ещё нет в системе). У заявки, открытой на
@@ -33,6 +33,7 @@ import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection'; import { AgencySection } from './sections/AgencySection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IRecruitingHireViewProps { interface IRecruitingHireViewProps {
context: WebPartContext; context: WebPartContext;
@@ -56,6 +57,11 @@ const NOOP_SET: SetField = () => undefined;
// Переиспользует те же секции, что и форма подачи; редактирование выключено // Переиспользует те же секции, что и форма подачи; редактирование выключено
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -116,7 +122,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -131,7 +137,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -52,6 +52,7 @@ import { DepartmentSection } from './sections/DepartmentSection';
import { VacancySection } from './sections/VacancySection'; import { VacancySection } from './sections/VacancySection';
import { HealthTripsSection } from './sections/HealthTripsSection'; import { HealthTripsSection } from './sections/HealthTripsSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { useStableCallback } from '../../shared/useStableCallback';
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -69,6 +70,14 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -86,20 +95,19 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
setExistingAttachments(atts); setExistingAttachments(atts);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId]);
// Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение // Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение
// «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail // «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail
@@ -132,8 +140,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // context — единственный экземпляр WebPartContext на всё время жизни
}, [editId]); // веб-части, так что перезапуска эффекта эта зависимость не добавляет.
}, [editId, context]);
// Выбор другого ответственного подтягивает его подразделение и организацию. // Выбор другого ответственного подтягивает его подразделение и организацию.
const handleResponsibleChange = (login: string): void => { const handleResponsibleChange = (login: string): void => {
@@ -29,6 +29,7 @@ import { DepartmentSection } from './sections/DepartmentSection';
import { VacancySection } from './sections/VacancySection'; import { VacancySection } from './sections/VacancySection';
import { HealthTripsSection } from './sections/HealthTripsSection'; import { HealthTripsSection } from './sections/HealthTripsSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IRecruitingSelectionViewProps { interface IRecruitingSelectionViewProps {
context: WebPartContext; context: WebPartContext;
@@ -47,6 +48,11 @@ const NOOP_SET: SetField = () => undefined;
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>. // секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -80,7 +86,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -95,7 +101,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -43,6 +43,7 @@ import { CandidateSection } from './sections/CandidateSection';
import { VacancySection } from './sections/VacancySection'; import { VacancySection } from './sections/VacancySection';
import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { CandidateInfoSection } from './sections/CandidateInfoSection';
import { AssessmentSection } from './sections/AssessmentSection'; import { AssessmentSection } from './sections/AssessmentSection';
import { useStableCallback } from '../../shared/useStableCallback';
const ReferralForm: React.FC<IRequestFormProps> = ({ const ReferralForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -58,6 +59,14 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -83,19 +92,18 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId]);
// Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение — // Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение —
// из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и // из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и
@@ -136,8 +144,10 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // context — единственный экземпляр WebPartContext на всё время жизни
}, [editId]); // веб-части, approval мемоизирован по editListId: перезапуска эффекта эти
// зависимости не добавляют.
}, [editId, context, approval]);
// Выбор другого рекомендателя подтягивает его подразделение из профиля, // Выбор другого рекомендателя подтягивает его подразделение из профиля,
// должность и организации — из Employee/ByEmail. Оба поля (Position/ // должность и организации — из Employee/ByEmail. Оба поля (Position/
@@ -29,6 +29,7 @@ import { CandidateSection } from './sections/CandidateSection';
import { VacancySection } from './sections/VacancySection'; import { VacancySection } from './sections/VacancySection';
import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { CandidateInfoSection } from './sections/CandidateInfoSection';
import { AssessmentSection } from './sections/AssessmentSection'; import { AssessmentSection } from './sections/AssessmentSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IReferralViewProps { interface IReferralViewProps {
context: WebPartContext; context: WebPartContext;
@@ -48,6 +49,11 @@ const NOOP_SET: SetField = () => undefined;
// те же секции, что и форма подачи; редактирование выключено через // те же секции, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -101,7 +107,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
} }
@@ -115,7 +121,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -11,9 +11,11 @@
## Особое поведение ## Особое поведение
- Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`). - Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`).
- Табличная часть «Приложения» (`Annexes`) — просто опись документов заявки (описание + номер приложения), без реальных файлов; для файлов есть общий блок «Вложения». - Табличная часть «Приложения» (`Annexes`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке.
- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. `EstimateTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно. - Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать.
- Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `EstimateJson`) — тот же приём, что в `TmcTransfer` (`ItemsJson`). - Под «Объём работ» (текстовое поле) — детализация по позициям в ТОМ ЖЕ формате, что и смета: группы/позиции/цена/сумма (`WorkVolumes`, см. `CostGroupsEditor.tsx` — общий редактор, переиспользуется и в CostsSection, и здесь). Дополняет текст итоговыми цифрами, не заменяет его.
- `Costs`/`WorkVolumes` сериализуются в JSON-колонки (`EstimateJson`/`WorkVolumesJson`) ОБЪЕКТОМ `{ groups, total }` — готовый итог кладётся прямо в JSON рядом с группами (см. `serializeGroups`/`parseGroupedJson` в `cost.ts`), а не отдельной числовой колонкой: он и так пересчитывается из groups при каждом сохранении, отдельная колонка была бы лишней схемой ради того же значения. Сама форма читает только `groups` — total из хранилища не доверяет, всегда считает заново (`costTotal`); `total` в JSON нужен тем, кто читает колонку напрямую (отчёты, sp-query), не поднимая форму.
- `Annexes` сериализуется в `AnnexesJson` просто массивом (без обёртки total — там считать нечего) — тот же приём, что в `TmcTransfer` (`ItemsJson`).
- Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется. - Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется.
- Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей. - Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей.
@@ -6,10 +6,6 @@ import {
FormCard, FormCard,
InitiatorLine, InitiatorLine,
FormModeProvider, FormModeProvider,
FileDropzone,
AttachmentList,
CollapseSection,
FormField,
Icon, Icon,
RequestNumber, RequestNumber,
SubmitActions, SubmitActions,
@@ -64,7 +60,10 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
onCodeLoaded onCodeLoaded
}) => { }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); // Файлы, выбранные для строк «Приложения», ещё не загруженные на сервер (нужен
// id элемента — см. save()). Ключ — fileName (= выбранное имя строки), не индекс
// строки: индекс «плывёт» при удалении строк, а имя однозначно и переживает это.
const [annexFiles, setAnnexFiles] = useState<Record<string, File>>({});
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
@@ -206,13 +205,53 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
order: r.order order: r.order
})); }));
const handleRemoveAttachment = (fileName: string): void => { // Ссылки на скачивание для уже загруженных файлов строк «Приложения».
if (requestId === undefined) { const attachmentUrls = useMemo(() => {
const map: Record<string, string> = {};
existingAttachments.forEach((a) => {
map[a.fileName] = a.url;
});
return map;
}, [existingAttachments]);
// Выбрали файл для строки приложения — сохраняем в памяти до save() (нужен id
// элемента, см. saveServiceRequest). Если у строки уже был загруженный файл —
// сразу открепляем старый, иначе на элементе повиснет лишнее вложение.
const handlePickAnnexFile = (rowIndex: number, file: File): void => {
const prevFileName = data.Annexes[rowIndex]?.fileName;
if (prevFileName && requestId !== undefined && existingAttachments.some((a) => a.fileName === prevFileName)) {
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, prevFileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== prevFileName)))
.catch(() => undefined);
}
setData((prev) => ({
...prev,
Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: file.name } : row))
}));
setAnnexFiles((prev) => ({ ...prev, [file.name]: file }));
};
// Открепить файл строки: если он уже на сервере — удаляем вложение сразу,
// иначе просто забываем ещё не загруженный выбор.
const handleRemoveAnnexFile = (rowIndex: number): void => {
const fileName = data.Annexes[rowIndex]?.fileName;
setData((prev) => ({
...prev,
Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: '' } : row))
}));
if (!fileName) {
return; return;
} }
setAnnexFiles((prev) => {
const next = { ...prev };
delete next[fileName];
return next;
});
if (requestId !== undefined && existingAttachments.some((a) => a.fileName === fileName)) {
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName) deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined); .catch(() => undefined);
}
}; };
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => { const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
@@ -230,9 +269,16 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
if (code) { if (code) {
setRequestCode(code); setRequestCode(code);
} }
if (files.length > 0) { // Файлы строк «Приложения» — только те, чья строка ещё существует (строку
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // могли удалить, не открепляя файл явно — см. AnnexesSection.removeRow) и
setFiles([]); // чьё имя всё ещё совпадает (замену уже отправляет handlePickAnnexFile).
const annexFileNames = new Set(data.Annexes.map((r) => r.fileName).filter(Boolean));
const pendingAnnexFiles = Object.entries(annexFiles)
.filter(([fileName]) => annexFileNames.has(fileName))
.map(([, file]) => file);
if (pendingAnnexFiles.length > 0) {
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, pendingAnnexFiles);
setAnnexFiles({});
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
} }
if (onSaved) { if (onSaved) {
@@ -264,7 +310,15 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate); const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const sectionProps = { data, set, context, onRequesterChange: handleRequesterChange }; const sectionProps = {
data,
set,
context,
onRequesterChange: handleRequesterChange,
attachmentUrls,
onPickAnnexFile: handlePickAnnexFile,
onRemoveAnnexFile: handleRemoveAnnexFile
};
return ( return (
<div> <div>
@@ -307,19 +361,6 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
<RemarksSection {...sectionProps} /> <RemarksSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
{!readOnly && (
<CollapseSection title="Вложения" icon="upload">
{existingAttachments.length > 0 && (
<FormField label="Прикреплённые файлы" full>
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
</FormField>
)}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
)}
<ApprovalMatrix <ApprovalMatrix
editor={matrix} editor={matrix}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
@@ -8,9 +8,6 @@ import {
FormCard, FormCard,
InitiatorLine, InitiatorLine,
FormModeProvider, FormModeProvider,
CollapseSection,
FormField,
AttachmentList,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
Icon Icon
@@ -110,6 +107,18 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
}; };
}, [itemId, listId]); }, [itemId, listId]);
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
// ломает порядок хуков (React #310 — «rendered more hooks than during the
// previous render»), потому что на первом рендере (loading) его бы не было.
const attachmentUrls = useMemo(() => {
const map: Record<string, string> = {};
attachments.forEach((a) => {
map[a.fileName] = a.url;
});
return map;
}, [attachments]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
} }
@@ -121,7 +130,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
); );
} }
const sectionProps = { data, set, context, printOrgIndex }; const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
const notify = (): void => { const notify = (): void => {
if (!requestTypeId) return; if (!requestTypeId) return;
@@ -167,14 +176,6 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
<CostsSection {...sectionProps} /> <CostsSection {...sectionProps} />
<RemarksSection {...sectionProps} /> <RemarksSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
<div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
</div>
</FormCard> </FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
@@ -9,6 +9,8 @@
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" }, { "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }, { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }, { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } { "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
] ]
} }
@@ -31,6 +31,43 @@ export function hasAnyCostItem(groups: ICostGroup[]): boolean {
return groups.some((group) => group.items.some((row) => row.description.trim().length > 0)); return groups.some((group) => group.items.some((row) => row.description.trim().length > 0));
} }
/**
* Форма JSON-колонки для групп (EstimateJson / WorkVolumesJson): группы +
* готовый итог. Итог кладём РЯДОМ с группами в тот же JSON, а не отдельной
* числовой колонкой SharePoint — он и так пересчитывается из groups при
* каждом сохранении (см. serializeGroups), отдельная колонка была бы просто
* ещё одним местом, которое нужно провижинить и держать в курсе схемы.
*/
export interface IGroupedJson {
groups: ICostGroup[];
total: number;
}
/** Собрать JSON-колонку: группы как есть + пересчитанный итог. */
export function serializeGroups(groups: ICostGroup[]): IGroupedJson {
return { groups, total: costTotal(groups) };
}
/**
* Разобрать JSON-колонку групп. Итог из хранилища НЕ используется формой —
* она всегда считает его заново из groups (см. costTotal); в разобранном
* объекте total пригождается только тем, кто читает эту колонку напрямую
* (отчёты, sp-query), не поднимая форму.
*/
export function parseGroupedJson(raw: unknown): ICostGroup[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as ICostGroup[]; // старый формат (просто массив), до появления total
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as IGroupedJson).groups)) {
return (parsed as IGroupedJson).groups;
}
return [];
} catch {
return [];
}
}
/** /**
* Сумма для показа: «1 234 567,89». Без символа валюты — валюта подписана в * Сумма для показа: «1 234 567,89». Без символа валюты — валюта подписана в
* заголовке колонки (тенге), как в бумажной форме. * заголовке колонки (тенге), как в бумажной форме.
@@ -1,6 +1,7 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, IAnnexRow, ICostGroup } from './model'; import { parseGroupedJson } from './cost';
import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow } from './model';
export interface ILoadedRequest { export interface ILoadedRequest {
data: IFormData; data: IFormData;
@@ -34,6 +35,7 @@ const SELECT_FIELDS = [
'EstimatedFinishDate', 'EstimatedFinishDate',
'GeneralRemarks', 'GeneralRemarks',
'AnnexesJson', 'AnnexesJson',
'WorkVolumesJson',
'EstimateJson', 'EstimateJson',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode' 'RequestUniqueCode'
@@ -89,12 +91,15 @@ export async function loadServiceRequest(itemId: number, listId?: string): Promi
ContractKind: (item.ContractKind as string) || '', ContractKind: (item.ContractKind as string) || '',
ContractAction: (item.ContractAction as string) || '', ContractAction: (item.ContractAction as string) || '',
ScopeOfWork: (item.ScopeOfWork as string) || '', ScopeOfWork: (item.ScopeOfWork as string) || '',
WorkVolumes: parseGroupedJson(item.WorkVolumesJson),
SuggestedSuppliers: (item.SuggestedSuppliers as string) || '', SuggestedSuppliers: (item.SuggestedSuppliers as string) || '',
EstimatedStartDate: toDateInput(item.EstimatedStartDate), EstimatedStartDate: toDateInput(item.EstimatedStartDate),
EstimatedFinishDate: toDateInput(item.EstimatedFinishDate), EstimatedFinishDate: toDateInput(item.EstimatedFinishDate),
GeneralRemarks: (item.GeneralRemarks as string) || '', GeneralRemarks: (item.GeneralRemarks as string) || '',
Annexes: parseRows<IAnnexRow>(item.AnnexesJson), // fileName — через spread с дефолтом: заявки, сохранённые до появления
Costs: parseRows<ICostGroup>(item.EstimateJson) // прикреплённых файлов у строки приложения, не имеют его в JSON вовсе.
Annexes: parseRows<IAnnexRow>(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })),
Costs: parseGroupedJson(item.EstimateJson)
}; };
return { return {
@@ -9,15 +9,22 @@ import { RequestTypeId } from '../../models/types';
export const FORM_ID: RequestTypeId = 'wh-service-request'; export const FORM_ID: RequestTypeId = 'wh-service-request';
/** /**
* Строка таблицы приложений: документ, упомянутый в заявке (чертежи, ТЗ, * Строка таблицы приложений: документ, который заявитель прикладывает к
* ведомости объёмов) — только опись, без реальных файлов (в бумажной форме * заявке (чертежи, ТЗ, ведомости объёмов, перечень материалов…).
* это тоже просто перечень с номером, без вложений).
*/ */
export interface IAnnexRow { export interface IAnnexRow {
/** Описание документа. */ /** Описание документа. */
description: string; description: string;
/** Номер приложения / шифр проекта (напр. «ASL-1812-2025-5-КЖ»). */ /** Номер приложения / шифр проекта (напр. «ASL-1812-2025-5-КЖ»). */
annexNo: string; annexNo: string;
/**
* Имя реально прикреплённого файла — привязывает строку к одному из вложений
* элемента списка (см. shared/attachments). '' — файл ещё не выбран.
* Отдельного списка/библиотеки под это не заводим: вложения — встроенная
* возможность любого списка SharePoint, форма использует её напрямую, без
* отдельного общего блока «Вложения» — каждый файл привязан к своей строке.
*/
fileName: string;
} }
/** /**
@@ -48,7 +55,7 @@ export interface ICostGroup {
items: ICostRow[]; items: ICostRow[];
} }
export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '' }; export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', fileName: '' };
/** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */ /** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */
export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' }; export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' };
/** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */ /** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */
@@ -117,6 +124,13 @@ export interface IFormData {
// --- Описание требований --- // --- Описание требований ---
/** Объём работ — основное содержание заявки, длинный текст. */ /** Объём работ — основное содержание заявки, длинный текст. */
ScopeOfWork: string; ScopeOfWork: string;
/**
* Детализация объёмов работ по разделам (группам) — тот же формат
* группа/позиция/цена/сумма, что и у сметы (см. CostGroupsEditor,
* сериализуется в WorkVolumesJson). Дополняет текстовое описание
* ScopeOfWork итоговыми цифрами по каждой позиции, а не заменяет его.
*/
WorkVolumes: ICostGroup[];
/** Предлагаемые поставщики / требования к субподрядчику. */ /** Предлагаемые поставщики / требования к субподрядчику. */
SuggestedSuppliers: string; SuggestedSuppliers: string;
EstimatedStartDate: string; EstimatedStartDate: string;
@@ -147,6 +161,25 @@ export interface ISectionProps {
* поля отдельный обработчик. Не передан (режим чтения) — правка через set. * поля отдельный обработчик. Не передан (режим чтения) — правка через set.
*/ */
onRequesterChange?: (login: string) => void; onRequesterChange?: (login: string) => void;
/**
* Только для AnnexesSection: ссылки на скачивание уже прикреплённых файлов
* (fileName → ServerRelativeUrl). Не передан — ссылки не показываются
* (напр. новая заявка до первого сохранения, там вложений ещё нет).
*/
attachmentUrls?: Record<string, string>;
/**
* Только для AnnexesSection: выбрать файл для строки приложения — форма
* держит его в памяти (см. ServiceRequestForm.annexFiles) и реально
* загружает при сохранении (нужен id элемента). Не передан — прикрепление
* недоступно (режим просмотра).
*/
onPickAnnexFile?: (rowIndex: number, file: File) => void;
/**
* Только для AnnexesSection: открепить файл строки. Если он уже загружен на
* сервер — форма сразу удаляет вложение элемента, иначе просто забывает
* выбор (ещё не сохранённый). Не передан — открепление недоступно.
*/
onRemoveAnnexFile?: (rowIndex: number) => void;
} }
/** Ревизия по умолчанию — как в бумажной форме («Рев:00»). */ /** Ревизия по умолчанию — как в бумажной форме («Рев:00»). */
@@ -171,13 +204,14 @@ export const EMPTY_DATA: IFormData = {
ContractKind: '', ContractKind: '',
ContractAction: '', ContractAction: '',
ScopeOfWork: '', ScopeOfWork: '',
// Новая заявка сразу показывает одну пустую группу (не «пустое место с
// кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения
// всё равно перезапишет (см. load.ts). Тот же приём — у Costs ниже.
WorkVolumes: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }],
SuggestedSuppliers: '', SuggestedSuppliers: '',
EstimatedStartDate: '', EstimatedStartDate: '',
EstimatedFinishDate: '', EstimatedFinishDate: '',
GeneralRemarks: '', GeneralRemarks: '',
Annexes: [], Annexes: [],
// Новая заявка сразу показывает одну пустую группу сметы (не «пустое место с
// кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения
// всё равно перезапишет (см. load.ts).
Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }] Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }]
}; };
@@ -23,13 +23,13 @@
{ "internalName": "ContractAction", "title": "Действие с договором", "type": "Choice", "choices": ["Новый договор", "Доп. соглашение", "Возобновление договора", "Пролонгация договора", "Расторжение договора"] }, { "internalName": "ContractAction", "title": "Действие с договором", "type": "Choice", "choices": ["Новый договор", "Доп. соглашение", "Возобновление договора", "Пролонгация договора", "Расторжение договора"] },
{ "internalName": "ScopeOfWork", "title": "Объём работ", "type": "Note" }, { "internalName": "ScopeOfWork", "title": "Объём работ", "type": "Note" },
{ "internalName": "WorkVolumesJson", "title": "Объёмы работ по позициям (JSON, с итогом)", "type": "Note" },
{ "internalName": "SuggestedSuppliers", "title": "Предлагаемые поставщики", "type": "Note" }, { "internalName": "SuggestedSuppliers", "title": "Предлагаемые поставщики", "type": "Note" },
{ "internalName": "EstimatedStartDate", "title": "Предполагаемая дата начала", "type": "DateTime", "dateOnly": true }, { "internalName": "EstimatedStartDate", "title": "Предполагаемая дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EstimatedFinishDate", "title": "Предполагаемая дата окончания", "type": "DateTime", "dateOnly": true }, { "internalName": "EstimatedFinishDate", "title": "Предполагаемая дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "GeneralRemarks", "title": "Общие замечания", "type": "Note" }, { "internalName": "GeneralRemarks", "title": "Общие замечания", "type": "Note" },
{ "internalName": "AnnexesJson", "title": "Приложения (JSON)", "type": "Note" }, { "internalName": "AnnexesJson", "title": "Приложения (JSON)", "type": "Note" },
{ "internalName": "EstimateJson", "title": "Сметная стоимость (JSON)", "type": "Note" }, { "internalName": "EstimateJson", "title": "Сметная стоимость (JSON, с итогом)", "type": "Note" }
{ "internalName": "EstimateTotal", "title": "Итого без НДС", "type": "Number", "decimals": 2 }
] ]
} }
@@ -10,6 +10,7 @@
overflow-x: auto; overflow-x: auto;
} }
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -45,6 +46,10 @@
} }
.annexNo { .annexNo {
width: 180px;
}
.file {
width: 220px; width: 220px;
} }
@@ -53,6 +58,83 @@
text-align: center; text-align: center;
} }
// Скрытый нативный <input type=file> — открывается кликом по кнопке .attach.
.fileInput {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.attach {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 9px;
border: 1px dashed $gray-300;
border-radius: $r-sm;
background: transparent;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-caption;
cursor: pointer;
transition: .18s;
&:hover {
border-color: $gold-400;
color: $gold-600;
}
}
.fileName {
display: inline-flex;
align-items: center;
gap: $sp-2;
max-width: 100%;
a,
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: $font-sans;
font-size: $fs-sm;
}
a {
color: $navy-700;
&:hover {
color: $gold-600;
}
}
}
.removeFile {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 4px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.noFile {
color: $gray-400;
}
.remove { .remove {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -83,3 +165,10 @@
gap: $sp-2; gap: $sp-2;
margin-top: $sp-3; margin-top: $sp-3;
} }
// См. CostGroupsEditor: на бумаге прокрутки нет, уехавшее вбок теряется.
@media print {
.tableWrap {
overflow: visible;
}
}
@@ -3,13 +3,73 @@ import styles from './AnnexesSection.module.scss';
import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
import { EMPTY_ANNEX_ROW, IAnnexRow, ISectionProps } from '../model'; import { EMPTY_ANNEX_ROW, IAnnexRow, ISectionProps } from '../model';
/** Файл строки: ссылка на скачивание, если уже загружен, иначе — прикрепить/открепить. */
const AnnexFileCell: React.FC<{
row: IAnnexRow;
readOnly: boolean;
url?: string;
onPick?: (file: File) => void;
onRemove?: () => void;
}> = ({ row, readOnly, url, onPick, onRemove }) => {
const inputRef = React.useRef<HTMLInputElement>(null);
if (!row.fileName) {
if (readOnly || !onPick) {
return <span className={styles.noFile}>—</span>;
}
return (
<>
<button type="button" className={styles.attach} onClick={() => inputRef.current?.click()}>
<Icon name="upload" size="sm" />
Прикрепить
</button>
<input
ref={inputRef}
type="file"
className={styles.fileInput}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
</>
);
}
return (
<span className={styles.fileName}>
{url ? (
<a href={url} target="_blank" rel="noopener noreferrer" title={row.fileName}>
{row.fileName}
</a>
) : (
<span title={row.fileName}>{row.fileName}</span>
)}
{!readOnly && onRemove && (
<button type="button" className={styles.removeFile} title="Открепить файл" onClick={onRemove}>
<Icon name="close" size="sm" />
</button>
)}
</span>
);
};
/** /**
* Приложения к заявке — опись документов (проектные чертежи, ТЗ, ведомости * Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень
* объёмов, перечень материалов…), без реальных файлов: соответствует * материалов…) — описание документа, номер приложения и сам файл. Файл
* колонкам «Описание документа» / «Приложение №» бумажной формы, остальные * физически хранится как вложение элемента списка (та же встроенная
* её колонки (кол-во страниц, подтверждение) в электронной версии не нужны. * возможность SharePoint, что раньше использовал общий блок «Вложения» —
* его убрали, каждый файл теперь привязан к своей строке напрямую, отдельная
* библиотека под это не нужна).
*/ */
export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => { export const AnnexesSection: React.FC<ISectionProps> = ({
data,
set,
attachmentUrls,
onPickAnnexFile,
onRemoveAnnexFile
}) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
const setRow = (index: number, patch: Partial<IAnnexRow>): void => { const setRow = (index: number, patch: Partial<IAnnexRow>): void => {
@@ -20,7 +80,15 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
}; };
const addRow = (): void => set('Annexes', [...data.Annexes, { ...EMPTY_ANNEX_ROW }]); const addRow = (): void => set('Annexes', [...data.Annexes, { ...EMPTY_ANNEX_ROW }]);
const removeRow = (index: number): void => set('Annexes', data.Annexes.filter((_, i) => i !== index)); const removeRow = (index: number): void => {
// Строка с файлом (уже загруженным или ещё нет) — сначала открепляем через
// родителя (см. ServiceRequestForm.handleRemoveAnnexFile), иначе загруженное
// вложение останется висеть на элементе без ссылающейся на него строки.
if (data.Annexes[index]?.fileName) {
onRemoveAnnexFile?.(index);
}
set('Annexes', data.Annexes.filter((_, i) => i !== index));
};
return ( return (
<CollapseSection title="Приложения" icon="document"> <CollapseSection title="Приложения" icon="document">
@@ -32,13 +100,14 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
<th className={styles.num}>#</th> <th className={styles.num}>#</th>
<th>Описание документа</th> <th>Описание документа</th>
<th className={styles.annexNo}>Приложение №</th> <th className={styles.annexNo}>Приложение №</th>
<th className={styles.file}>Файл</th>
{!readOnly && <th className={styles.act} aria-label="Действия" />} {!readOnly && <th className={styles.act} aria-label="Действия" />}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.Annexes.length === 0 ? ( {data.Annexes.length === 0 ? (
<tr> <tr>
<td colSpan={readOnly ? 3 : 4} className={styles.empty}> <td colSpan={readOnly ? 4 : 5} className={styles.empty}>
Приложения не добавлены Приложения не добавлены
</td> </td>
</tr> </tr>
@@ -49,6 +118,9 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
<td className={styles.num}>{i + 1}</td> <td className={styles.num}>{i + 1}</td>
<td>{row.description}</td> <td>{row.description}</td>
<td className={styles.annexNo}>{row.annexNo}</td> <td className={styles.annexNo}>{row.annexNo}</td>
<td className={styles.file}>
<AnnexFileCell row={row} readOnly url={attachmentUrls?.[row.fileName]} />
</td>
</tr> </tr>
) : ( ) : (
<tr key={i}> <tr key={i}>
@@ -69,6 +141,15 @@ export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
borderless borderless
/> />
</td> </td>
<td className={styles.file}>
<AnnexFileCell
row={row}
readOnly={false}
url={attachmentUrls?.[row.fileName]}
onPick={onPickAnnexFile ? (file) => onPickAnnexFile(i, file) : undefined}
onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined}
/>
</td>
<td className={styles.act}> <td className={styles.act}>
<button type="button" className={styles.remove} title="Удалить строку" onClick={() => removeRow(i)}> <button type="button" className={styles.remove} title="Удалить строку" onClick={() => removeRow(i)}>
<Icon name="trash" size="sm" /> <Icon name="trash" size="sm" />
@@ -54,12 +54,27 @@
} }
} }
// Строка = наименование / ед.изм / кол-во / цена / сумма (+ кнопка удаления). // Строка целиком в фикс-колонках (ед.изм/кол-во/цена/сумма) физически не
// влезает в узкую панель — форма открывается в Drawer сбоку, а не на всю
// страницу, и его ширина не связана с шириной окна браузера (в отличие от
// брейкпоинтов CollapseSection/FormGrid, которые следят за viewport, а не за
// шириной своего контейнера). Вместо того чтобы сжимать поля до
// нечитаемости, даём строке её естественную минимальную ширину и скроллим
// горизонтально контейнером — тот же приём, что раньше был у плоской таблицы
// (см. AnnexesSection .tableWrap), а не «резиновая» вёрстка, которая сама себя
// сжать до бесконечности не умеет (grid-колонки по умолчанию не сжимаются
// уже своего содержимого).
.rowsWrap {
width: 100%;
overflow-x: auto;
}
.row { .row {
display: grid; display: grid;
grid-template-columns: 1fr 96px 110px 140px 140px 32px; grid-template-columns: minmax(160px, 1fr) 84px 96px 128px 128px 30px;
gap: $sp-2; gap: $sp-2;
align-items: center; align-items: center;
min-width: 640px;
& + & { & + & {
margin-top: $sp-2; margin-top: $sp-2;
@@ -140,3 +155,21 @@
font-weight: 700; font-weight: 700;
color: $navy-700; color: $navy-700;
} }
// ---- Печать ----
// На бумаге прокрутки нет: «уехавшее вбок» просто потерялось бы, а сам
// контейнер печатается вместе с полосой прокрутки (видно на печати заявки).
// Отпускаем ширину — колонки строки суммарно уже A4, так что она укладывается
// в лист целиком.
//
// Блок ДОЛЖЕН стоять в конце файла: @media print не повышает специфичность, и
// базовое `.row { min-width }` выше перебило бы это правило, стой оно раньше.
@media print {
.rowsWrap {
overflow: visible;
}
.row {
min-width: 0;
}
}
@@ -0,0 +1,195 @@
import * as React from 'react';
import styles from './CostGroupsEditor.module.scss';
import { Button, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost';
import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow } from '../model';
interface IGroupProps {
group: ICostGroup;
index: number;
readOnly: boolean;
onChangeName: (name: string) => void;
onChangeItem: (itemIndex: number, patch: Partial<ICostRow>) => void;
onAddItem: () => void;
onRemoveItem: (itemIndex: number) => void;
onRemoveGroup: () => void;
canRemoveGroup: boolean;
}
/** Один раздел (группа): название + таблица позиций внутри неё. */
const CostGroup: React.FC<IGroupProps> = ({
group,
index,
readOnly,
onChangeName,
onChangeItem,
onAddItem,
onRemoveItem,
onRemoveGroup,
canRemoveGroup
}) => (
<div className={styles.group}>
<div className={styles.groupHead}>
<span className={styles.groupLabel}>Группа {index + 1}</span>
<TextInput
value={group.name}
onChange={onChangeName}
placeholder="Наименование группы"
readOnly={readOnly}
/>
{!readOnly && canRemoveGroup && (
<button type="button" className={styles.removeGroup} title="Удалить группу" onClick={onRemoveGroup}>
<Icon name="trash" size="sm" />
</button>
)}
</div>
<div className={styles.rowsWrap}>
<div className={styles.row} role="row">
<span className={styles.colHead}>Наименование позиции</span>
<span className={styles.colHead}>Ед. изм.</span>
<span className={styles.colHead}>Количество</span>
<span className={styles.colHead}>Цена</span>
<span className={styles.colHead}>Сумма</span>
{!readOnly && <span className={styles.colHead} aria-hidden />}
</div>
{group.items.length === 0 ? (
<div className={styles.empty}>Позиции не добавлены</div>
) : (
group.items.map((item, i) =>
readOnly ? (
<div className={styles.row} key={i} role="row">
<span className={styles.cellStatic}>{item.description || '—'}</span>
<span className={styles.cellStatic}>{item.unit || '—'}</span>
<span className={styles.cellStatic}>{item.qty || '—'}</span>
<span className={styles.cellStatic}>{item.rate || '—'}</span>
<span className={styles.cellAmount}>{formatAmount(rowAmount(item))}</span>
</div>
) : (
<div className={styles.row} key={i} role="row">
<TextInput
value={item.description}
onChange={(v) => onChangeItem(i, { description: v })}
placeholder="Наименование"
ariaLabel="Наименование позиции"
/>
<TextInput
value={item.unit}
onChange={(v) => onChangeItem(i, { unit: v })}
placeholder="шт."
ariaLabel="Единица измерения"
/>
<TextInput
value={item.qty}
onChange={(v) => onChangeItem(i, { qty: v })}
placeholder="0"
ariaLabel="Количество"
/>
<TextInput
value={item.rate}
onChange={(v) => onChangeItem(i, { rate: v })}
placeholder="0"
ariaLabel="Цена за единицу"
/>
<span className={styles.cellAmount}>{item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'}</span>
<button type="button" className={styles.removeItem} title="Удалить позицию" onClick={() => onRemoveItem(i)}>
<Icon name="trash" size="sm" />
</button>
</div>
)
)
)}
</div>
<div className={styles.groupFoot}>
{!readOnly && (
<Button variant="secondary" size="sm" onClick={onAddItem}>
<Icon name="plus" size="sm" />
Добавить позицию
</Button>
)}
<span className={styles.groupTotal}>Сумма по группе: {formatAmount(groupAmount(group))}</span>
</div>
</div>
);
export interface ICostGroupsEditorProps {
groups: ICostGroup[];
onChange: (groups: ICostGroup[]) => void;
/** Подпись итоговой строки. По умолчанию — «Итого без НДС». */
totalLabel?: string;
/** Текст при пустом списке групп в режиме просмотра. */
emptyReadOnlyText?: string;
}
/**
* Редактор таблицы «группы → позиции с ценой и количеством»: используется и
* для сметной стоимости (CostsSection), и для детализации объёмов работ
* (ScopeSection) — один и тот же формат, разные поля модели формы. Сумма по
* позиции, по группе и общий итог — производные (qty × цена), считаются на
* лету и НЕ хранятся построчно: правка количества/цены не может их
* рассинхронизировать (см. cost.ts).
*/
export const CostGroupsEditor: React.FC<ICostGroupsEditorProps> = ({
groups,
onChange,
totalLabel = 'Итого без НДС',
emptyReadOnlyText = 'Не заполнено'
}) => {
const readOnly = useFormReadOnly();
const setGroup = (index: number, patch: Partial<ICostGroup>): void => {
onChange(groups.map((group, i) => (i === index ? { ...group, ...patch } : group)));
};
const setItem = (groupIndex: number, itemIndex: number, patch: Partial<ICostRow>): void => {
setGroup(groupIndex, {
items: groups[groupIndex].items.map((row, i) => (i === itemIndex ? { ...row, ...patch } : row))
});
};
const addItem = (groupIndex: number): void =>
setGroup(groupIndex, { items: [...groups[groupIndex].items, { ...EMPTY_COST_ROW }] });
const removeItem = (groupIndex: number, itemIndex: number): void =>
setGroup(groupIndex, { items: groups[groupIndex].items.filter((_, i) => i !== itemIndex) });
const addGroup = (): void => onChange([...groups, { ...EMPTY_COST_GROUP, items: [{ ...EMPTY_COST_ROW }] }]);
const removeGroup = (groupIndex: number): void => onChange(groups.filter((_, i) => i !== groupIndex));
return (
<div className={styles.full}>
{groups.map((group, i) => (
<CostGroup
key={i}
group={group}
index={i}
readOnly={readOnly}
onChangeName={(name) => setGroup(i, { name })}
onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)}
onAddItem={() => addItem(i)}
onRemoveItem={(itemIndex) => removeItem(i, itemIndex)}
onRemoveGroup={() => removeGroup(i)}
canRemoveGroup={groups.length > 1}
/>
))}
{groups.length === 0 && readOnly && <div className={styles.empty}>{emptyReadOnlyText}</div>}
{!readOnly && (
<Button variant="secondary" size="sm" onClick={addGroup}>
<Icon name="plus" size="sm" />
Добавить группу
</Button>
)}
{groups.length > 0 && (
<div className={styles.total}>
{totalLabel}: {formatAmount(costTotal(groups))}
</div>
)}
</div>
);
};
@@ -1,184 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import styles from './CostsSection.module.scss'; import { CollapseSection } from '../../../shared/ui';
import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; import { CostGroupsEditor } from './CostGroupsEditor';
import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost'; import { ISectionProps } from '../model';
import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow, ISectionProps } from '../model';
interface IGroupProps { /** Сметная стоимость услуг-работ по разделам (группам) — см. CostGroupsEditor. */
group: ICostGroup; export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => (
index: number;
readOnly: boolean;
onChangeName: (name: string) => void;
onChangeItem: (itemIndex: number, patch: Partial<ICostRow>) => void;
onAddItem: () => void;
onRemoveItem: (itemIndex: number) => void;
onRemoveGroup: () => void;
canRemoveGroup: boolean;
}
/** Один раздел сметы: название группы + таблица позиций внутри неё. */
const CostGroup: React.FC<IGroupProps> = ({
group,
index,
readOnly,
onChangeName,
onChangeItem,
onAddItem,
onRemoveItem,
onRemoveGroup,
canRemoveGroup
}) => (
<div className={styles.group}>
<div className={styles.groupHead}>
<span className={styles.groupLabel}>Группа {index + 1}</span>
<TextInput
value={group.name}
onChange={onChangeName}
placeholder="Наименование группы"
readOnly={readOnly}
/>
{!readOnly && canRemoveGroup && (
<button type="button" className={styles.removeGroup} title="Удалить группу" onClick={onRemoveGroup}>
<Icon name="trash" size="sm" />
</button>
)}
</div>
<div className={styles.row} role="row">
<span className={styles.colHead}>Наименование позиции</span>
<span className={styles.colHead}>Ед. изм.</span>
<span className={styles.colHead}>Количество</span>
<span className={styles.colHead}>Цена (смета)</span>
<span className={styles.colHead}>Сумма (смета)</span>
{!readOnly && <span className={styles.colHead} aria-hidden />}
</div>
{group.items.length === 0 ? (
<div className={styles.empty}>Позиции не добавлены</div>
) : (
group.items.map((item, i) =>
readOnly ? (
<div className={styles.row} key={i} role="row">
<span className={styles.cellStatic}>{item.description || '—'}</span>
<span className={styles.cellStatic}>{item.unit || '—'}</span>
<span className={styles.cellStatic}>{item.qty || '—'}</span>
<span className={styles.cellStatic}>{item.rate || '—'}</span>
<span className={styles.cellAmount}>{formatAmount(rowAmount(item))}</span>
</div>
) : (
<div className={styles.row} key={i} role="row">
<TextInput
value={item.description}
onChange={(v) => onChangeItem(i, { description: v })}
placeholder="Наименование"
ariaLabel="Наименование позиции"
/>
<TextInput
value={item.unit}
onChange={(v) => onChangeItem(i, { unit: v })}
placeholder="шт."
ariaLabel="Единица измерения"
/>
<TextInput
value={item.qty}
onChange={(v) => onChangeItem(i, { qty: v })}
placeholder="0"
ariaLabel="Количество"
/>
<TextInput
value={item.rate}
onChange={(v) => onChangeItem(i, { rate: v })}
placeholder="0"
ariaLabel="Цена за единицу"
/>
<span className={styles.cellAmount}>{item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'}</span>
<button type="button" className={styles.removeItem} title="Удалить позицию" onClick={() => onRemoveItem(i)}>
<Icon name="trash" size="sm" />
</button>
</div>
)
)
)}
<div className={styles.groupFoot}>
{!readOnly && (
<Button variant="secondary" size="sm" onClick={onAddItem}>
<Icon name="plus" size="sm" />
Добавить позицию
</Button>
)}
<span className={styles.groupTotal}>Сумма по группе: {formatAmount(groupAmount(group))}</span>
</div>
</div>
);
/**
* Сметная стоимость услуг-работ по разделам (группам). Сумма по позиции,
* по группе и общий итог — все производные (qty × цена), считаются на лету
* и НЕ хранятся построчно (см. cost.ts): правка количества/цены не может
* рассинхронизировать сумму. EstimateTotal в SharePoint дублируется отдельной
* числовой колонкой при сохранении (см. submit.ts) — только для отчётов,
* форма её сама не читает.
*/
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => {
const readOnly = useFormReadOnly();
const setGroup = (index: number, patch: Partial<ICostGroup>): void => {
set(
'Costs',
data.Costs.map((group, i) => (i === index ? { ...group, ...patch } : group))
);
};
const setItem = (groupIndex: number, itemIndex: number, patch: Partial<ICostRow>): void => {
setGroup(groupIndex, {
items: data.Costs[groupIndex].items.map((row, i) => (i === itemIndex ? { ...row, ...patch } : row))
});
};
const addItem = (groupIndex: number): void =>
setGroup(groupIndex, { items: [...data.Costs[groupIndex].items, { ...EMPTY_COST_ROW }] });
const removeItem = (groupIndex: number, itemIndex: number): void =>
setGroup(groupIndex, { items: data.Costs[groupIndex].items.filter((_, i) => i !== itemIndex) });
const addGroup = (): void => set('Costs', [...data.Costs, { ...EMPTY_COST_GROUP, items: [{ ...EMPTY_COST_ROW }] }]);
const removeGroup = (groupIndex: number): void => set('Costs', data.Costs.filter((_, i) => i !== groupIndex));
// Новая заявка стартует без единой группы — «Добавить группу» появляется
// сразу, первую группу создаёт пользователь так же, как и все остальные.
return (
<CollapseSection title="Сметная стоимость услуг-работ" icon="calculator"> <CollapseSection title="Сметная стоимость услуг-работ" icon="calculator">
<div className={styles.full}> <CostGroupsEditor
{data.Costs.map((group, i) => ( groups={data.Costs}
<CostGroup onChange={(groups) => set('Costs', groups)}
key={i} emptyReadOnlyText="Смета не заполнена"
group={group}
index={i}
readOnly={readOnly}
onChangeName={(name) => setGroup(i, { name })}
onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)}
onAddItem={() => addItem(i)}
onRemoveItem={(itemIndex) => removeItem(i, itemIndex)}
onRemoveGroup={() => removeGroup(i)}
canRemoveGroup={data.Costs.length > 1}
/> />
))}
{data.Costs.length === 0 && readOnly && <div className={styles.empty}>Смета не заполнена</div>}
{!readOnly && (
<Button variant="secondary" size="sm" onClick={addGroup}>
<Icon name="plus" size="sm" />
Добавить группу
</Button>
)}
{data.Costs.length > 0 && (
<div className={styles.total}>Итого без НДС: {formatAmount(costTotal(data.Costs))}</div>
)}
</div>
</CollapseSection> </CollapseSection>
); );
};
@@ -1,11 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormGrid, FormField, TextArea } from '../../../shared/ui'; import { CollapseSection, FormField, TextArea } from '../../../shared/ui';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
/** Общие замечания — отдельный блок бумажной формы, не относится к объёму работ. */ /** Общие замечания — отдельный блок бумажной формы, не относится к объёму работ. */
export const RemarksSection: React.FC<ISectionProps> = ({ data, set }) => ( export const RemarksSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Общие замечания" icon="info"> <CollapseSection title="Общие замечания" icon="info">
<FormGrid>
<FormField label="Общие замечания" htmlFor="GeneralRemarks" full> <FormField label="Общие замечания" htmlFor="GeneralRemarks" full>
<TextArea <TextArea
id="GeneralRemarks" id="GeneralRemarks"
@@ -15,6 +14,5 @@ export const RemarksSection: React.FC<ISectionProps> = ({ data, set }) => (
placeholder="Особые условия, ссылки на нормативы, порядок приёмки скрытых работ…" placeholder="Особые условия, ссылки на нормативы, порядок приёмки скрытых работ…"
/> />
</FormField> </FormField>
</FormGrid>
</CollapseSection> </CollapseSection>
); );
@@ -8,3 +8,19 @@
font-size: $fs-caption; font-size: $fs-caption;
color: $gray-500; color: $gray-500;
} }
// Блок детализации объёмов работ (CostGroupsEditor) — своя подпись сверху
// (не через FormField: там подпись сцеплена с htmlFor одного контрола, а
// здесь под ней целый редактор групп, не один инпут).
.volumesBlock {
grid-column: 1 / -1;
}
.volumesLabel {
display: block;
margin-bottom: $sp-2;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 500;
color: $gray-700;
}
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import styles from './ScopeSection.module.scss'; import styles from './ScopeSection.module.scss';
import { CollapseSection, FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, TextArea } from '../../../shared/ui';
import { CostGroupsEditor } from './CostGroupsEditor';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
const MS_PER_DAY = 24 * 60 * 60 * 1000; const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -23,7 +24,6 @@ export const ScopeSection: React.FC<ISectionProps> = ({ data, set }) => {
return ( return (
<CollapseSection title="Описание требований" icon="helmet"> <CollapseSection title="Описание требований" icon="helmet">
<FormGrid>
<FormField label="Объём работ" htmlFor="ScopeOfWork" required full> <FormField label="Объём работ" htmlFor="ScopeOfWork" required full>
<TextArea <TextArea
id="ScopeOfWork" id="ScopeOfWork"
@@ -34,6 +34,16 @@ export const ScopeSection: React.FC<ISectionProps> = ({ data, set }) => {
/> />
</FormField> </FormField>
<div className={styles.volumesBlock}>
<span className={styles.volumesLabel}>Итого объёмы работ</span>
<CostGroupsEditor
groups={data.WorkVolumes}
onChange={(groups) => set('WorkVolumes', groups)}
totalLabel="Итого"
emptyReadOnlyText="Объёмы работ не заполнены"
/>
</div>
<FormField label="Предлагаемые поставщики" htmlFor="SuggestedSuppliers" full> <FormField label="Предлагаемые поставщики" htmlFor="SuggestedSuppliers" full>
<TextArea <TextArea
id="SuggestedSuppliers" id="SuggestedSuppliers"
@@ -63,7 +73,6 @@ export const ScopeSection: React.FC<ISectionProps> = ({ data, set }) => {
</FormField> </FormField>
{days !== undefined && <div className={styles.duration}>Продолжительность: {days} кал. дн.</div>} {days !== undefined && <div className={styles.duration}>Продолжительность: {days} кал. дн.</div>}
</FormGrid>
</CollapseSection> </CollapseSection>
); );
}; };
@@ -1,6 +1,6 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { costTotal } from './cost'; import { serializeGroups } from './cost';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
/** /**
@@ -21,24 +21,21 @@ function buildTitle(data: IFormData): string {
/** /**
* Поля элемента списка данных из модели формы. Табличные части уходят в * Поля элемента списка данных из модели формы. Табличные части уходят в
* JSON-колонки, итог сметы — в отдельную числовую колонку. * JSON-колонки; у групп (Costs/WorkVolumes) в тот же JSON кладётся и готовый
* итог — см. serializeGroups в cost.ts.
*/ */
function buildItem(data: IFormData, status: string): Record<string, unknown> { function buildItem(data: IFormData, status: string): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status }; const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => { (Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
if (key === 'Annexes' || key === 'Costs') return; // ниже, отдельными колонками if (key === 'Annexes' || key === 'Costs' || key === 'WorkVolumes') return; // ниже, отдельными колонками
const value = data[key]; const value = data[key];
item[key] = value === '' ? null : value; item[key] = value === '' ? null : value;
}); });
item.AnnexesJson = data.Annexes.length > 0 ? JSON.stringify(data.Annexes) : null; item.AnnexesJson = data.Annexes.length > 0 ? JSON.stringify(data.Annexes) : null;
item.EstimateJson = data.Costs.length > 0 ? JSON.stringify(data.Costs) : null; item.WorkVolumesJson = data.WorkVolumes.length > 0 ? JSON.stringify(serializeGroups(data.WorkVolumes)) : null;
// Итог сметы дублируем числовой колонкой намеренно: в самой форме он считается item.EstimateJson = data.Costs.length > 0 ? JSON.stringify(serializeGroups(data.Costs)) : null;
// на лету (см. cost.ts), но представлениям SharePoint и отчётам нужна сумма
// без разбора JSON — иначе любой отчёт по заявкам пришлось бы писать кодом.
const total = costTotal(data.Costs);
item.EstimateTotal = total > 0 ? total : null;
return item; return item;
} }
@@ -51,6 +51,7 @@ import { getApplicantProfile } from './services/profile.service';
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config'; import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection'; import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback';
/** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */ /** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */
function buildPlannerNotes(data: IFormData, code: string): string { function buildPlannerNotes(data: IFormData, code: string): string {
@@ -86,6 +87,14 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -102,20 +111,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
setExistingAttachments(atts); setExistingAttachments(atts);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId]);
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки. // Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком. // Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
@@ -140,8 +148,9 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // context — единственный экземпляр WebPartContext на всё время жизни
}, [editId]); // веб-части, так что перезапуска эффекта эта зависимость не добавляет.
}, [editId, context]);
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
@@ -41,6 +41,7 @@ import { PlannerTicketService } from './services/plannerTicket.service';
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config'; import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection'; import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface ITicket1CViewProps { interface ITicket1CViewProps {
context: WebPartContext; context: WebPartContext;
@@ -83,6 +84,11 @@ function buildPlannerNotes(
// Заявка в отдел 1С только для просмотра (режим согласующего). // Заявка в отдел 1С только для просмотра (режим согласующего).
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -118,7 +124,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -133,7 +139,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита // Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
// от повторного создания карточки, если решение применяется из нескольких // от повторного создания карточки, если решение применяется из нескольких
@@ -50,6 +50,7 @@ import { saveTmcTransferRequest, tmcTransferApprovalService } from './submit';
import { loadTmcTransferRequest } from './load'; import { loadTmcTransferRequest } from './load';
import { HeaderSection } from './sections/HeaderSection'; import { HeaderSection } from './sections/HeaderSection';
import { ItemsSection } from './sections/ItemsSection'; import { ItemsSection } from './sections/ItemsSection';
import { useStableCallback } from '../../shared/useStableCallback';
const TmcTransferForm: React.FC<IRequestFormProps> = ({ const TmcTransferForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -67,6 +68,14 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
@@ -135,19 +144,19 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
setExistingAttachments(atts); setExistingAttachments(atts);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [editId, editListId]); }, [editId, editListId, notifyCode, setSubmitError]);
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
@@ -40,6 +40,7 @@ import { loadTmcTransferRequest } from './load';
import { tmcTransferApprovalService } from './submit'; import { tmcTransferApprovalService } from './submit';
import { HeaderSection } from './sections/HeaderSection'; import { HeaderSection } from './sections/HeaderSection';
import { ItemsSection } from './sections/ItemsSection'; import { ItemsSection } from './sections/ItemsSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface ITmcTransferViewProps { interface ITmcTransferViewProps {
context: WebPartContext; context: WebPartContext;
@@ -54,6 +55,11 @@ interface ITmcTransferViewProps {
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего). // Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -107,7 +113,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -122,7 +128,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -9,6 +9,8 @@
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" }, { "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }, { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }, { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } { "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
] ]
} }
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit'; import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
import { loadUnpaidLeaveRequest } from './load'; import { loadUnpaidLeaveRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -53,6 +54,14 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -78,19 +87,18 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId]);
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя, // Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
// его должность и организации (Employee/ByEmail — см. shared/dictionary/ // его должность и организации (Employee/ByEmail — см. shared/dictionary/
@@ -116,8 +124,9 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // context — единственный экземпляр WebPartContext на всё время жизни
}, [editId]); // веб-части, так что перезапуска эффекта эта зависимость не добавляет.
}, [editId, context]);
// Выбор другого сотрудника подтягивает его должность и организации. // Выбор другого сотрудника подтягивает его должность и организации.
const handleEmployeeChange = (login: string): void => { const handleEmployeeChange = (login: string): void => {
@@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadUnpaidLeaveRequest } from './load'; import { loadUnpaidLeaveRequest } from './load';
import { unpaidLeaveApprovalService } from './submit'; import { unpaidLeaveApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IUnpaidLeaveViewProps { interface IUnpaidLeaveViewProps {
context: WebPartContext; context: WebPartContext;
@@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined;
// ту же секцию, что и форма подачи; редактирование выключено через // ту же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -77,7 +83,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
} }
@@ -91,7 +97,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod
import { saveVacationRequest, vacationApprovalService } from './submit'; import { saveVacationRequest, vacationApprovalService } from './submit';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
const VacationForm: React.FC<IRequestFormProps> = ({ const VacationForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -53,6 +54,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
@@ -78,19 +87,18 @@ const VacationForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId]);
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя, // Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
// его должность и организации (Employee/ByEmail — см. shared/dictionary/ // его должность и организации (Employee/ByEmail — см. shared/dictionary/
@@ -116,8 +124,9 @@ const VacationForm: React.FC<IRequestFormProps> = ({
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // context — единственный экземпляр WebPartContext на всё время жизни
}, [editId]); // веб-части, так что перезапуска эффекта эта зависимость не добавляет.
}, [editId, context]);
// Выбор другого сотрудника подтягивает его должность и организации (как в // Выбор другого сотрудника подтягивает его должность и организации (как в
// исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle). // исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle).
@@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadVacationRequest } from './load'; import { loadVacationRequest } from './load';
import { vacationApprovalService } from './submit'; import { vacationApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IVacationViewProps { interface IVacationViewProps {
context: WebPartContext; context: WebPartContext;
@@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined;
// же секцию, что и форма подачи; редактирование выключено через // же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -77,7 +83,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
} }
@@ -91,7 +97,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -51,6 +51,7 @@ import { loadPrefillFromHire } from './prefill';
import { loadWorkplaceRequest } from './load'; import { loadWorkplaceRequest } from './load';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
import { useStableCallback } from '../../shared/useStableCallback';
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -71,6 +72,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined); const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined); const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit(); const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
// каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
// saving/error), а setError стабилен — его и берём.
const { setError: setSubmitError } = submit;
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение. // Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
const readOnly = !isEditableStatus(requestStatus); const readOnly = !isEditableStatus(requestStatus);
@@ -91,19 +100,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined); setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code); if (loaded.code) notifyCode(loaded.code);
setExistingAttachments(atts); setExistingAttachments(atts);
} }
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [editId, editListId]); }, [editId, editListId, notifyCode, setSubmitError]);
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource). // Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
useEffect(() => { useEffect(() => {
@@ -122,13 +131,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
}) })
.catch((e) => { .catch((e) => {
if (alive) { if (alive) {
submit.setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`); setSubmitError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
} }
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [sourceId]); }, [sourceId, setSubmitError]);
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
@@ -156,8 +165,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // context — единственный экземпляр WebPartContext на всё время жизни
}, [editId, userEmail]); // веб-части, так что перезапуска эффекта эта зависимость не добавляет.
}, [editId, userEmail, context]);
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
@@ -40,6 +40,7 @@ import { loadWorkplaceRequest } from './load';
import { workplaceApprovalService, updateReviewFields } from './submit'; import { workplaceApprovalService, updateReviewFields } from './submit';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
import { useStableCallback } from '../../shared/useStableCallback';
interface IWorkplaceSetupViewProps { interface IWorkplaceSetupViewProps {
context: WebPartContext; context: WebPartContext;
@@ -58,6 +59,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель) // Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает). // на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -122,7 +128,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -137,7 +143,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования // Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
// (по маршруту — непосредственного руководителя) и это текущий пользователь. // (по маршруту — непосредственного руководителя) и это текущий пользователь.
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { resolveDebugUser } from '../shared/impersonation'; import { resolveDebugUser } from '../shared/impersonation';
import { useStableCallback } from '../shared/useStableCallback';
export interface IDebugUserFieldProps { export interface IDebugUserFieldProps {
label: string; label: string;
@@ -22,6 +23,10 @@ const DEBOUNCE_MS = 400;
* чтобы не гадать, кого именно подставили. * чтобы не гадать, кого именно подставили.
*/ */
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => { export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
// onResolved приходит из панели свойств и пересоздаётся на каждом её рендере:
// в зависимостях эффекта он перезапускал бы поиск, без него — эффект звал бы
// навсегда запомненную первую версию колбэка. Стабильная обёртка снимает оба.
const notifyResolved = useStableCallback(onResolved);
const [query, setQuery] = React.useState(initialQuery || ''); const [query, setQuery] = React.useState(initialQuery || '');
const [status, setStatus] = React.useState<Status>('idle'); const [status, setStatus] = React.useState<Status>('idle');
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined); const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
@@ -32,7 +37,7 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
if (!text) { if (!text) {
setStatus('idle'); setStatus('idle');
setFound(undefined); setFound(undefined);
onResolved('', '', ''); notifyResolved('', '', '');
return; return;
} }
@@ -49,25 +54,25 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
if (cancelled) return; if (cancelled) return;
if (isEmail) { if (isEmail) {
setFound(hit); setFound(hit);
onResolved(text, text, hit ? hit.displayName : ''); notifyResolved(text, text, hit ? hit.displayName : '');
} else if (hit) { } else if (hit) {
setStatus('found'); setStatus('found');
setFound(hit); setFound(hit);
onResolved(text, hit.email, hit.displayName); notifyResolved(text, hit.email, hit.displayName);
} else { } else {
setStatus('notfound'); setStatus('notfound');
setFound(undefined); setFound(undefined);
onResolved(text, '', ''); notifyResolved(text, '', '');
} }
}) })
.catch(() => { .catch(() => {
if (cancelled) return; if (cancelled) return;
if (isEmail) { if (isEmail) {
onResolved(text, text, ''); notifyResolved(text, text, '');
} else { } else {
setStatus('notfound'); setStatus('notfound');
setFound(undefined); setFound(undefined);
onResolved(text, '', ''); notifyResolved(text, '', '');
} }
}); });
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -76,8 +81,7 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
cancelled = true; cancelled = true;
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [query, context, notifyResolved]);
}, [query]);
return ( return (
<div> <div>
@@ -128,6 +128,7 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.matrix { .matrix {
width: 100%; width: 100%;
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
@@ -355,3 +356,16 @@
padding-top: $sp-2 !important; padding-top: $sp-2 !important;
} }
} }
// ---- Печать ----
// Прокрутка на бумаге недоступна: неуместившееся вбок просто не попало бы на
// лист. Обе оси отпускаем — overflow-y здесь стоит только ради анимации
// перестановки строк (см. .matrixScroll), а в печати её нет.
//
// В конце файла намеренно: @media print не повышает специфичность, и любое
// базовое правило ниже перебило бы это (см. CostGroupsEditor.module.scss).
@media print {
.matrixScroll {
overflow: visible;
}
}
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { friendlyErrorMessage } from '../diagnostics';
import { IMatrixRow } from './models/IMatrixRow'; import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser'; import { ISiteUser } from './models/ISiteUser';
@@ -175,13 +176,22 @@ export function useMatrixEditor(
}) })
.catch((err: Error) => { .catch((err: Error) => {
if (!active) return; if (!active) return;
setError(err.message); // Через friendlyErrorMessage, а не err.message: PnPjs кладёт в message
// тело ответа SharePoint целиком, и кириллица в нём остаётся
// \u-escape-последовательностями — пользователь видел «Поле или
// свойство…» набором «Поле».
setError(friendlyErrorMessage(err));
setLoading(false); setLoading(false);
}); });
return () => { return () => {
active = false; active = false;
}; };
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready/retrySeq. // load в зависимостях СОЗНАТЕЛЬНО нет: когда матрицу перечитывать —
// решает вызывающий через reloadKey (у него это, например, «выбранный
// сотрудник + id заявки»), а сам load пересоздаётся и от тех данных,
// которые перезагрузки не требуют. С load в массиве маршрут дёргался бы
// на каждое такое изменение. Устаревшего замыкания при этом нет: любой
// повторный запуск (reloadKey/ready/retrySeq) берёт свежий load.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey, ready, retrySeq]); }, [reloadKey, ready, retrySeq]);
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../diagnostics';
import { getRequestTypeIdByPageUrl } from './approvalRoute.service'; import { getRequestTypeIdByPageUrl } from './approvalRoute.service';
export interface IRequestTypeIdState { export interface IRequestTypeIdState {
@@ -33,7 +34,9 @@ export function useRequestTypeId(context: WebPartContext, pageUrl: string): IReq
}) })
.catch((e: Error) => { .catch((e: Error) => {
if (alive) { if (alive) {
setError(e.message); // См. useMatrixEditor: сырой message от PnPjs/SharePoint показывает
// кириллицу \u-escape-последовательностями.
setError(friendlyErrorMessage(e));
setLoading(false); setLoading(false);
} }
}); });
@@ -5,6 +5,7 @@
overflow-x: auto; overflow-x: auto;
} }
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -164,3 +165,11 @@
display: flex; display: flex;
gap: $sp-2; gap: $sp-2;
} }
// На печати прокрутки не существует: то, что не поместилось по ширине, просто
// пропало бы с листа, а контейнер вышел бы с полосой прокрутки.
@media print {
.tableWrap {
overflow: visible;
}
}
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
// Общая машина сохранения заявки для ВСЕХ форм портала. // Общая машина сохранения заявки для ВСЕХ форм портала.
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка / // Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
@@ -15,6 +15,7 @@ export interface IRequestSubmit {
/** /**
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined. * Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика). * Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
* Ссылка на функцию неизменна — её можно класть в зависимости хуков.
*/ */
run: ( run: (
kind: SubmitKind, kind: SubmitKind,
@@ -29,13 +30,17 @@ export function useRequestSubmit(): IRequestSubmit {
const [kind, setKind] = useState<SubmitKind | undefined>(undefined); const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined); const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
// Тот же признак «идёт сохранение», но читаемый синхронно. Нужен, чтобы run
// не зависела от состояния и сохраняла неизменную ссылку: формы кладут её
// (и весь submit) в зависимости эффектов.
const savingRef = useRef<boolean>(false);
const run = async ( const run = useCallback(async (
k: SubmitKind, k: SubmitKind,
save: () => Promise<void>, save: () => Promise<void>,
validate?: () => string | undefined validate?: () => string | undefined
): Promise<void> => { ): Promise<void> => {
if (saving) { if (savingRef.current) {
return; return;
} }
setKind(k); setKind(k);
@@ -45,6 +50,7 @@ export function useRequestSubmit(): IRequestSubmit {
return; return;
} }
setError(undefined); setError(undefined);
savingRef.current = true;
setSaving(true); setSaving(true);
try { try {
await save(); await save();
@@ -52,9 +58,20 @@ export function useRequestSubmit(): IRequestSubmit {
} catch (e) { } catch (e) {
setError(`Ошибка сохранения: ${(e as Error).message}`); setError(`Ошибка сохранения: ${(e as Error).message}`);
} finally { } finally {
// require-atomic-updates ловит присваивание после await, но здесь это не
// гонка: savingRef — не производное состояние, а флаг «занято» с одним
// владельцем, и второй вызов run до этой строки просто не проходит.
// eslint-disable-next-line require-atomic-updates
savingRef.current = false;
setSaving(false); setSaving(false);
} }
}; }, []);
return { saving, kind, savedKind, error, run, setError }; // Объект пересоздаётся только при РЕАЛЬНОЙ смене состояния операции, а не на
// каждом рендере формы: run и setError стабильны, поэтому эффекты, которым
// нужен только setError, не перезапускаются вовсе.
return useMemo(
() => ({ saving, kind, savedKind, error, run, setError }),
[saving, kind, savedKind, error, run]
);
} }
@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useStableCallback } from './useStableCallback';
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск // Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов // срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
@@ -57,15 +58,17 @@ export function useRoutedQuery(
const [input, setInput] = React.useState<string>(routeQuery); const [input, setInput] = React.useState<string>(routeQuery);
const debounced = useDebouncedQuery(input); const debounced = useDebouncedQuery(input);
const pushed = React.useRef<string>(routeQuery); const pushed = React.useRef<string>(routeQuery);
// onQueryChange родитель пересоздаёт на каждом рендере: в зависимостях он
// гонял бы эффект вхолостую, а без него эффект навсегда запомнил бы ПЕРВУЮ
// версию колбэка (а она замкнута на раздел, открытый при монтировании, — и
// запрос уезжал бы не в тот маршрут). Стабильная обёртка снимает и то и другое.
const pushQuery = useStableCallback(onQueryChange);
React.useEffect(() => { React.useEffect(() => {
if (debounced === pushed.current) return; if (debounced === pushed.current) return;
pushed.current = debounced; pushed.current = debounced;
onQueryChange(debounced); pushQuery(debounced);
// onQueryChange пересоздаётся на каждый рендер родителя — в зависимостях }, [debounced, pushQuery]);
// он гонял бы эффект вхолостую; важен только сам факт смены debounced.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounced]);
React.useEffect(() => { React.useEffect(() => {
if (routeQuery === pushed.current) return; if (routeQuery === pushed.current) return;
@@ -0,0 +1,33 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
/**
* Обёртка вокруг колбэка-пропа с НЕИЗМЕННОЙ ссылкой.
*
* Зачем: колбэк, приходящий сверху пропом (`onCodeLoaded`, `onSaved` и т.п.),
* родитель почти всегда пересоздаёт на каждом рендере. Положить такой колбэк
* в зависимости useEffect нельзя — эффект начнёт перезапускаться на ровном
* месте (для форм портала это означает повторную загрузку заявки с сервера).
* Выкинуть его из зависимостей тоже нельзя — эффект зафиксирует ПЕРВУЮ версию
* колбэка и после смены пропа будет звать устаревшее замыкание.
*
* Хук снимает обе проблемы: наружу отдаётся одна и та же функция на всю жизнь
* компонента, а внутри она всегда вызывает АКТУАЛЬНУЮ версию колбэка. Такую
* обёртку можно честно указывать в зависимостях (см. react-hooks/exhaustive-deps).
*
* Аналог `useEffectEvent` из будущих версий React; ограничен колбэками без
* возвращаемого значения — именно такие у нас в пропах.
*
* ВАЖНО: вызывать её во время РЕНДЕРА нельзя — только из эффектов и
* обработчиков событий, иначе можно прочитать колбэк от прошлого рендера.
*/
export function useStableCallback<TArgs extends unknown[]>(
callback: ((...args: TArgs) => void) | undefined
): (...args: TArgs) => void {
const ref = useRef(callback);
// Обновляем ссылку до того, как отработают обычные (passive) эффекты, —
// так вызов из useEffect гарантированно попадает в свежий колбэк.
useLayoutEffect(() => {
ref.current = callback;
});
return useCallback((...args: TArgs) => ref.current?.(...args), []);
}