From 7f1e36874af7f217a6d92f3acd535ba28d729092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 5 Oct 2026 12:33:14 +0500 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D1=83?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D1=83=20=D0=A2?= =?UTF-8?q?=D0=9C=D0=A6=20=D0=BF=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D1=83=20cree9=5Ftmctransfe?= =?UTF-8?q?rurl=20=D0=B2=20Dataverse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владельцы внешней системы указали другую колонку строки заявки (cr498_cree9_tmctransferurl) вместо прежней cr498_portalrequesturl. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/webparts/requestPortal/shared/config.ts | 12 ++++++++---- .../shared/dataverse/demandLine.service.ts | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 3d92e51..6bda410 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -132,11 +132,15 @@ export const DATAVERSE_API_VERSION = 'v9.2'; */ export const DATAVERSE_DEMANDLINE_ENTITY_SET = 'cree9_demandlines'; /** - * Колонка-URL, куда пишем ссылку на заявку портала (создана вручную в Dataverse). - * NB: префикс cr498_ (издатель среды org95ca17d6.crm4 по умолчанию), а НЕ cree9_ - * как у остальных колонок таблицы — так сгенерировалось при создании колонки. + * Колонка, куда пишем ссылку на заявку портала (создана вручную в Dataverse, + * отображаемое имя «cree9_tmctransferurl», однострочный текст). Указана владельцами + * внешней системы; прежняя URL-колонка cr498_portalrequesturl больше не используется. + * NB: логическое имя с префиксом cr498_ (издатель среды org95ca17d6.crm4 по + * умолчанию) — так сгенерировалось при создании колонки. + * Ссылка на заявку ~200+ символов (кириллица в пути страницы кодируется), поэтому + * макс. длина колонки должна быть не меньше 500 — иначе PATCH отклонится. */ -export const DATAVERSE_DEMANDLINE_LINK_FIELD = 'cr498_portalrequesturl'; +export const DATAVERSE_DEMANDLINE_LINK_FIELD = 'cr498_cree9_tmctransferurl'; /** * Колонка «ID позиции ЛЗК» на «Строке заявки» — GUID строки cree9_lzkposition * ХРАНИТСЯ ТЕКСТОМ (не lookup-связью), поэтому её нельзя $expand'нуть — только diff --git a/src/webparts/requestPortal/shared/dataverse/demandLine.service.ts b/src/webparts/requestPortal/shared/dataverse/demandLine.service.ts index 6c0718a..df85313 100644 --- a/src/webparts/requestPortal/shared/dataverse/demandLine.service.ts +++ b/src/webparts/requestPortal/shared/dataverse/demandLine.service.ts @@ -13,7 +13,7 @@ import { * * positionId — GUID первичного ключа строки (приходит в URL как positionId, см. * shared/routing). Адресуем строку напрямую: (). PATCH пишет - * URL в колонку cree9_portalrequesturl. + * URL в колонку DATAVERSE_DEMANDLINE_LINK_FIELD (см. shared/config). * * Заголовок If-Match: * ОБЯЗАТЕЛЕН: без него PATCH в Dataverse — это upsert (нет * строки с таким id → СОЗДАЁТ новую). Нам нужно строго обновление существующей From 9400f8546e8d58d9502fb1a8d6074e67e044462a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 5 Oct 2026 12:39:48 +0500 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=20source=20=D0=B2=D0=BE=20=D0=B2=D0=BD=D0=B5?= =?UTF-8?q?=D1=88=D0=BD=D0=B5=D0=B9=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B5?= =?UTF-8?q?=20=D0=A2=D0=9C=D0=A6=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Внешняя система (напр. КПС) передаёт source — пишем его в новую колонку SourceSystem рядом с SourcePositionId, чтобы по заявке было видно, откуда она пришла и куда отвечать. Запоминание источника вынесено в отдельный эффект: positionId/source/склад сохраняются и по ссылке без nomenclatureId. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/RequestPortal.tsx | 3 +- .../features/submit/SubmitFeature.tsx | 10 +++++- .../forms/TmcTransfer/TmcTransferForm.tsx | 33 +++++++++++++------ .../requestPortal/forms/TmcTransfer/load.ts | 2 ++ .../requestPortal/forms/TmcTransfer/model.ts | 7 ++++ .../forms/TmcTransfer/provisioning.json | 1 + src/webparts/requestPortal/models/types.ts | 2 ++ .../requestPortal/shared/routing.test.ts | 4 +++ src/webparts/requestPortal/shared/routing.ts | 10 +++++- 9 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 786fcad..dd579e7 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -480,7 +480,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context sourceId: route.sourceId, positionId: route.positionId, nomenclatureId: route.nomenclatureId, - storeName: route.storeName + storeName: route.storeName, + sourceSystem: route.sourceSystem } : undefined } diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index b2abeb3..4e63079 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -20,7 +20,14 @@ export interface ISubmitFeatureProps { /** Открыть сразу форму, минуя плитки — с предзаполнением (производная заявка, * sourceId задан) или без него (пустая новая форма конкретного типа). * positionId/nomenclatureId — параметры внешней ссылки (только форма ТМЦ). */ - initialForm?: { formKey: string; sourceId?: number; positionId?: string; nomenclatureId?: string; storeName?: string }; + initialForm?: { + formKey: string; + sourceId?: number; + positionId?: string; + nomenclatureId?: string; + storeName?: string; + sourceSystem?: string; + }; /** Открыть сразу форму на РЕДАКТИРОВАНИЕ существующей заявки (черновик/возвращена). */ editForm?: { formKey: string; itemId: number }; /** Пользователь выбрал плитку — сообщаем наверх, чтобы адрес страницы стал @@ -145,6 +152,7 @@ const SubmitFeature: React.FC = ({ positionId={external?.positionId} nomenclatureId={external?.nomenclatureId} storeName={external?.storeName} + sourceSystem={external?.sourceSystem} /> diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 6331bb0..32a664c 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -91,7 +91,8 @@ const TmcTransferForm: React.FC = ({ onCodeLoaded, positionId, nomenclatureId, - storeName + storeName, + sourceSystem }) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); @@ -144,18 +145,30 @@ const TmcTransferForm: React.FC = ({ }; }, [editId, context]); - // Открытие по внешней ссылке (positionId + nomenclatureId в URL): запоминаем - // строку-источник и предзаполняем таблицу остатками номенклатуры по складам. - // Только для новой заявки — при редактировании позиции уже сохранены. + // Открытие по внешней ссылке: запоминаем, откуда пришла заявка — система + // (source), строка-источник (positionId) и склад. Строка нужна при отправке для + // обратной записи ссылки в Dataverse, система — чтобы знать, откуда заявка, + // склад — справочно (см. model.ts). Не зависит от nomenclatureId: ссылка может + // прийти и без товара. Только для новой заявки — у сохранённой всё уже в SP. + useEffect(() => { + if (editId || (!positionId && !storeName && !sourceSystem)) { + return; + } + setData((prev) => ({ + ...prev, + SourcePositionId: positionId || '', + SourceStoreName: storeName || '', + SourceSystem: sourceSystem || '' + })); + }, [editId, positionId, storeName, sourceSystem]); + + // Предзаполнение таблицы остатками номенклатуры по складам (nomenclatureId из + // внешней ссылки). nomenclatureId отдельно в шапке не храним — он едет в каждой + // позиции ItemsJson. Только для новой заявки — при редактировании позиции уже сохранены. useEffect(() => { if (editId || !nomenclatureId) { return; } - // Строку-источник и склад фиксируем сразу, независимо от ответа справочника: - // строка нужна при отправке для обратной записи ссылки в Dataverse, склад — - // только справочно для пользователя (см. SourceStoreName в model.ts). - // nomenclatureId отдельно в шапке не храним — он едет в каждой позиции ItemsJson. - setData((prev) => ({ ...prev, SourcePositionId: positionId || '', SourceStoreName: storeName || '' })); let alive = true; getNomenclature(context, nomenclatureId, storeName) .then((rows) => { @@ -175,7 +188,7 @@ const TmcTransferForm: React.FC = ({ return () => { alive = false; }; - }, [editId, nomenclatureId, positionId, storeName, context, setSubmitError]); + }, [editId, nomenclatureId, storeName, context, setSubmitError]); // Проект-получатель («Куда») по той же внешней ссылке: строка-источник в // Dataverse хранит ID позиции ЛЗК, у которой есть родительская запись LZK diff --git a/src/webparts/requestPortal/forms/TmcTransfer/load.ts b/src/webparts/requestPortal/forms/TmcTransfer/load.ts index 1e3b99e..47969d4 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/load.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/load.ts @@ -53,6 +53,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P 'ContactPhone', 'SourcePositionId', 'SourceStoreName', + 'SourceSystem', 'ItemsJson', 'RequestStatus', 'RequestUniqueCode' @@ -80,6 +81,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P ContactPhone: (item.ContactPhone as string) || '', SourcePositionId: (item.SourcePositionId as string) || '', SourceStoreName: (item.SourceStoreName as string) || '', + SourceSystem: (item.SourceSystem as string) || '', Items: parseItems(item.ItemsJson) }; diff --git a/src/webparts/requestPortal/forms/TmcTransfer/model.ts b/src/webparts/requestPortal/forms/TmcTransfer/model.ts index 6b0bcad..fbb283a 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/model.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/model.ts @@ -68,6 +68,12 @@ export interface IFormData { * именно списываются ТМЦ. '' — ссылка без фильтра по складу или заявка заведена вручную. */ SourceStoreName: string; + /** + * Система, из которой открыли форму по внешней ссылке — параметр `source` в URL + * (напр. 'kps'). Вместе с SourcePositionId однозначно указывает, откуда пришла + * заявка и куда отвечать. '' — заявка заведена вручную или ссылка без source. + */ + SourceSystem: string; // Табличная часть — позиции ТМЦ (сериализуется в ItemsJson). У строк из // справочника есть nomenclatureId (см. ILineItem), у дописанных вручную — нет. @@ -115,5 +121,6 @@ export const EMPTY_DATA: IFormData = { ContactPhone: '', SourcePositionId: '', SourceStoreName: '', + SourceSystem: '', Items: [] }; diff --git a/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json b/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json index baa4565..7307c8e 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json +++ b/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json @@ -25,6 +25,7 @@ { "internalName": "SourcePositionId", "title": "Id строки-источника (Dataverse)", "type": "Text" }, { "internalName": "SourceStoreName", "title": "Склад-источник (внешняя ссылка)", "type": "Text" }, + { "internalName": "SourceSystem", "title": "Система-источник (внешняя ссылка)", "type": "Text" }, { "internalName": "ItemsJson", "title": "Позиции ТМЦ (JSON)", "type": "Note" } ] diff --git a/src/webparts/requestPortal/models/types.ts b/src/webparts/requestPortal/models/types.ts index d155b7e..5bb89d4 100644 --- a/src/webparts/requestPortal/models/types.ts +++ b/src/webparts/requestPortal/models/types.ts @@ -49,6 +49,8 @@ export interface IRequestFormProps { nomenclatureId?: string; /** Склад-фильтр для справочника номенклатуры (см. positionId). */ storeName?: string; + /** Система-источник внешней ссылки (параметр `source`, напр. 'kps'; см. positionId). */ + sourceSystem?: string; /** * Id СВОЕЙ заявки, открытой на редактирование (черновик / возвращённая / * отклонённая). В отличие от sourceId это не предзаполнение новой заявки, а diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 5f0ec8a..f7535d9 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -129,6 +129,10 @@ describe('parseRoute', () => { }); }); + it('внешняя ссылка ТМЦ: source прокидывается в create как sourceSystem', () => { + expect(parseRoute('?rptype=wh-tmc-transfer&positionId=abc&source=kps').sourceSystem).toBe('kps'); + }); + it('positionId/nomenclatureId без rptype игнорируются — обычная подача', () => { // Внешние параметры имеют смысл только при открытии формы (rptype); сами по // себе не должны влиять на маршрут. diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index e8a2371..b45e0c7 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -86,6 +86,13 @@ export interface IRoute { * этому складу. Приходит по той же внешней ссылке; пусто — все склады. */ storeName?: string; + /** + * Система, из которой открыли форму по внешней ссылке (параметр `source`, + * напр. 'kps'). Сохраняется в заявке, чтобы знать, откуда она пришла и куда + * отвечать. Не путать с sourceId (rpsource) — тот про предзаполнение из + * другой заявки портала. + */ + sourceSystem?: string; /** Подвкладка «Моих заявок»; задана только при nav='own'. */ ownTab?: OwnTab; /** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */ @@ -378,6 +385,7 @@ export function parseRoute(search: string = window.location.search): IRoute { const positionId = params.get('positionId') ?? undefined; const nomenclatureId = params.get('nomenclatureId') ?? undefined; const storeName = params.get('storeName') ?? undefined; + const sourceSystem = params.get('source') ?? undefined; // Подвкладка и фильтр — состояние раздела «Мои заявки», поэтому вне его не // читаются вовсе (иначе чужой rptab из ссылки «протёк» бы в другой раздел). @@ -432,7 +440,7 @@ export function parseRoute(search: string = window.location.search): IRoute { // rpmode=approve без валидного rpid) должна откатываться на обычную подачу, // а не тихо превращаться в открытие пустой формы другого режима. if (formKey && mode !== 'approve' && mode !== 'edit') { - return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName }; + return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName, sourceSystem }; } return { mode: 'submit', nav, ownTab, ownStatus, allTypeId, page, query }; } From f8ab2a1d71a25d648f31131c90da9df23635e987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 5 Oct 2026 12:41:18 +0500 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=D0=B4=D0=B0=D1=82=D1=8B=20=C2=AB?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=C2=BB=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=B5=D0=BD=D1=8C=20=D0=BD=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharePoint хранит поле «только дата» как полночь по поясу сайта (UTC+5) в UTC: «05.10» приходит из REST как 2026-10-04T19:00:00Z. Формы брали первые 10 символов и показывали 04.10, а каждое пересохранение (полное право, исполнитель, повторная отправка) записывало дату ещё на день раньше. Чтение всех дат идёт через spDateOnlyToIso (округление до ближайшей полуночи UTC), «сегодня» — через todayIsoDate вместо toISOString, который до 05:00 давал вчерашнее число. Уже сдвинутые записи в SP не исправляются. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../requestPortal/forms/BusinessTrip/load.ts | 4 +- .../forms/Clearance/ClearanceForm.tsx | 3 +- .../requestPortal/forms/Clearance/load.ts | 3 +- .../forms/ConcreteRequest/load.ts | 3 +- .../forms/Dismissal/createClearance.ts | 3 +- .../requestPortal/forms/Dismissal/load.ts | 3 +- .../forms/EmployeeTransfer/load.ts | 3 +- .../forms/RecruitingHire/load.ts | 3 +- .../forms/RecruitingSelection/load.ts | 3 +- .../requestPortal/forms/Referral/load.ts | 3 +- .../ServiceRequest/ServiceRequestForm.tsx | 3 +- .../forms/ServiceRequest/load.ts | 3 +- .../forms/SkudExplanation/load.ts | 4 +- .../SkudExplanation/skud/holidays.service.ts | 3 +- .../forms/TmcTransfer/TmcTransferForm.tsx | 3 +- .../requestPortal/forms/TmcTransfer/load.ts | 3 +- .../requestPortal/forms/UnpaidLeave/load.ts | 5 +- .../requestPortal/forms/Vacation/load.ts | 3 +- .../forms/WorkplaceSetup/load.ts | 3 +- .../requestPortal/shared/dateUtils.test.ts | 51 +++++++++++++++++++ .../requestPortal/shared/dateUtils.ts | 24 +++++++++ 21 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dateUtils.test.ts diff --git a/src/webparts/requestPortal/forms/BusinessTrip/load.ts b/src/webparts/requestPortal/forms/BusinessTrip/load.ts index 426950b..a0c1d76 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/load.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { parseTasks } from './tripData'; import { parseRoute } from './route'; @@ -42,8 +43,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string): const data: IFormData = { ...EMPTY_DATA, - // Колонка «только дата» — время в ответе нулевое, берём дату как есть. - SubmitDate: typeof item.SubmitDate === 'string' ? item.SubmitDate.slice(0, 10) : '', + SubmitDate: spDateOnlyToIso(item.SubmitDate), Employee: employee?.EMail || '', Position: (item.Position as string) || '', Organization: (item.Organization as string) || '', diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 0b22ae0..d339b50 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -30,6 +30,7 @@ import { import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { todayIsoDate } from '../../shared/dateUtils'; import { approvalListTitle, approvalListTitleForListId, @@ -109,7 +110,7 @@ const ClearanceForm: React.FC = ({ if (editId) { return; } - const today = new Date().toISOString().slice(0, 10); + const today = todayIsoDate(); setData((prev) => ({ ...prev, TerminationDate: prev.TerminationDate || today })); }, [editId]); diff --git a/src/webparts/requestPortal/forms/Clearance/load.ts b/src/webparts/requestPortal/forms/Clearance/load.ts index 51d283c..6ae3597 100644 --- a/src/webparts/requestPortal/forms/Clearance/load.ts +++ b/src/webparts/requestPortal/forms/Clearance/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; /** Число или '' (SharePoint отдаёт null для незаполненного Number). */ @@ -60,7 +61,7 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro Employee: employee?.EMail || '', Position: (item.Position as string) || '', Department: (item.Department as string) || '', - TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + TerminationDate: spDateOnlyToIso(termination), ParkingCard: !!item.ParkingCard, AccessCard: !!item.AccessCard, // Хранится в WriteOffTrainingAmount, не в одноимённом TrainingAmount — см. diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/load.ts b/src/webparts/requestPortal/forms/ConcreteRequest/load.ts index e76f527..8ed2cdb 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/load.ts +++ b/src/webparts/requestPortal/forms/ConcreteRequest/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, IConcreteItem, EMPTY_DATA } from './model'; export interface ILoadedRequest { @@ -53,7 +54,7 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom const data: IFormData = { ...EMPTY_DATA, - RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + RequestDate: spDateOnlyToIso(requestDate), SiteChiefEmail: (item.SiteChiefEmail as string) || '', SiteChiefName: (item.SiteChiefName as string) || '', ForemanEmail: (item.ForemanEmail as string) || '', diff --git a/src/webparts/requestPortal/forms/Dismissal/createClearance.ts b/src/webparts/requestPortal/forms/Dismissal/createClearance.ts index 421655a..a155070 100644 --- a/src/webparts/requestPortal/forms/Dismissal/createClearance.ts +++ b/src/webparts/requestPortal/forms/Dismissal/createClearance.ts @@ -16,6 +16,7 @@ import { dataListTitleForListId } from '../../shared/listTitles'; import { requestLinkUrl } from '../../shared/routing'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID as CLEARANCE_FORM_ID, EMPTY_DATA as CLEARANCE_EMPTY, IFormData as IClearanceData } from '../Clearance/model'; import { clearanceApprovalService, saveClearanceRequest } from '../Clearance/submit'; import { FORM_ID, primaryPosition, zipOrganization } from './model'; @@ -136,7 +137,7 @@ export async function ensureClearanceForDismissal( Position: position, Department: department, // Последний рабочий день заявления = дата расторжения договора в обходном листе. - TerminationDate: ((item.LastWorkingDay as string) || '').slice(0, 10), + TerminationDate: spDateOnlyToIso(item.LastWorkingDay), Organization: organizationText }; diff --git a/src/webparts/requestPortal/forms/Dismissal/load.ts b/src/webparts/requestPortal/forms/Dismissal/load.ts index b066ea1..a3ca682 100644 --- a/src/webparts/requestPortal/forms/Dismissal/load.ts +++ b/src/webparts/requestPortal/forms/Dismissal/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA, zipOrganization } from './model'; export interface ILoadedRequest { @@ -47,7 +48,7 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro const data: IFormData = { ...EMPTY_DATA, Employee: employee?.EMail || '', - LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + LastWorkingDay: spDateOnlyToIso(lastDay), Phone: (item.Phone as string) || '', Comment: (item.Comment as string) || '', Organization: zipOrganization(item.Organization, item.Department, item.Position), diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/load.ts b/src/webparts/requestPortal/forms/EmployeeTransfer/load.ts index c897b9d..39564f2 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/load.ts +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; export interface ILoadedRequest { @@ -47,7 +48,7 @@ export async function loadEmployeeTransferRequest(itemId: number, listId?: strin Employee: employee?.EMail || '', Position: (item.Position as string) || '', Organization: (item.Organization as string) || '', - TransferDate: transferDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + TransferDate: spDateOnlyToIso(transferDate), NewPosition: (item.NewPosition as string) || '', NewDepartment: (item.NewDepartment as string) || '', NewDepartmentId: (item.NewDepartmentId as string) || '', diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index f645ab3..954351f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -1,6 +1,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ICompany } from '../../shared/dictionary'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model'; import { APPROVER_CHANGES_FIELD } from './changeTracking'; @@ -76,7 +77,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise< if (value === null || value === undefined) { writable[key] = ''; } else if (key === 'HireHireDate' && typeof value === 'string') { - writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date + writable[key] = spDateOnlyToIso(value); } else { writable[key] = String(value); } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts index 27a2848..ee0e9da 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -1,6 +1,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ICompany } from '../../shared/dictionary'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model'; import { APPROVER_CHANGES_FIELD } from './changeTracking'; @@ -103,7 +104,7 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro if (value === null || value === undefined) { writable[key] = ''; } else if (DATE_FIELDS.indexOf(key) !== -1 && typeof value === 'string') { - writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date + writable[key] = spDateOnlyToIso(value); } else { writable[key] = String(value); } diff --git a/src/webparts/requestPortal/forms/Referral/load.ts b/src/webparts/requestPortal/forms/Referral/load.ts index 48a49e0..f67d797 100644 --- a/src/webparts/requestPortal/forms/Referral/load.ts +++ b/src/webparts/requestPortal/forms/Referral/load.ts @@ -1,6 +1,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ICompany } from '../../shared/dictionary'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; /** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */ @@ -72,7 +73,7 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom const data: IFormData = { ...EMPTY_DATA, - SubmitDate: submitDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + SubmitDate: spDateOnlyToIso(submitDate), Referrer: referrer?.EMail || '', ReferrerPosition: (item.ReferrerPosition as string) || '', ReferrerDepartment: (item.ReferrerDepartment as string) || '', diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 0c2354f..7cd9029 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -35,6 +35,7 @@ import { import { effectiveUserEmail } from '../../shared/impersonation'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { todayIsoDate } from '../../shared/dateUtils'; import { IRequestFormProps } from '../../models/types'; import { approvalListTitle, @@ -82,7 +83,7 @@ const ServiceRequestForm: React.FC = ({ return; } const user = context.pageContext.user; - const today = new Date().toISOString().slice(0, 10); + const today = todayIsoDate(); const requesterEmail = effectiveUserEmail(context); setData((prev) => ({ ...prev, diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts index de097e2..b2959df 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/load.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { parseGroupedJson } from './cost'; import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow } from './model'; @@ -57,7 +58,7 @@ function parseRows(raw: unknown): T[] { /** ISO-дата из SharePoint → YYYY-MM-DD для input type=date. */ function toDateInput(raw: unknown): string { - return typeof raw === 'string' ? raw.slice(0, 10) : ''; + return spDateOnlyToIso(raw); } /** diff --git a/src/webparts/requestPortal/forms/SkudExplanation/load.ts b/src/webparts/requestPortal/forms/SkudExplanation/load.ts index 5491db0..92d8736 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/load.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { parseMissedDays } from './missedDaysJson'; import { toDateKey } from './skud/skudDays'; @@ -52,8 +53,7 @@ export async function loadSkudExplanationRequest(itemId: number, listId?: string const data: IFormData = { ...EMPTY_DATA, - // Колонка «только дата» — время в ответе нулевое, берём дату как есть (как в Referral/load.ts). - SubmitDate: ((item.SubmitDate as string) || '').slice(0, 10), + SubmitDate: spDateOnlyToIso(item.SubmitDate), Employee: employee?.EMail || '', EmployeeFio: (item.SkudEmployeeFio as string) || employee?.Title || '', Position: (item.Position as string) || '', diff --git a/src/webparts/requestPortal/forms/SkudExplanation/skud/holidays.service.ts b/src/webparts/requestPortal/forms/SkudExplanation/skud/holidays.service.ts index c965d1e..f3aba16 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/skud/holidays.service.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/skud/holidays.service.ts @@ -1,6 +1,7 @@ import { SPHttpClient } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { HOLIDAYS_LIST_TITLE, HOLIDAYS_SITE_URL } from '../../../shared/config'; +import { spDateOnlyToIso } from '../../../shared/dateUtils'; interface IHolidayItem { Title?: string; @@ -25,7 +26,7 @@ async function fetchHolidays(context: WebPartContext): Promise(); (body.value || []).forEach((item) => { - if (item.holidayDate) map.set(item.holidayDate.slice(0, 10), stripParens(item.Title || '')); + if (item.holidayDate) map.set(spDateOnlyToIso(item.holidayDate), stripParens(item.Title || '')); }); return map; } diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 32a664c..071562f 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -50,6 +50,7 @@ import { requestLinkUrl } from '../../shared/routing'; import { writeDemandLinePortalLink } from '../../shared/dataverse/demandLine.service'; import { resolveLzkProjectId } from '../../shared/dataverse/lzkPosition.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { todayIsoDate } from '../../shared/dateUtils'; import { IRequestFormProps } from '../../models/types'; import { approvalListTitle, @@ -120,7 +121,7 @@ const TmcTransferForm: React.FC = ({ return; } const user = context.pageContext.user; - const today = new Date().toISOString().slice(0, 10); + const today = todayIsoDate(); const requesterEmail = effectiveUserEmail(context); setData((prev) => ({ ...prev, diff --git a/src/webparts/requestPortal/forms/TmcTransfer/load.ts b/src/webparts/requestPortal/forms/TmcTransfer/load.ts index 47969d4..05c59a8 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/load.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/load.ts @@ -1,6 +1,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ILineItem } from '../../shared/ui'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; export interface ILoadedRequest { @@ -70,7 +71,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P Department: (item.Department as string) || '', DepartmentId: (item.DepartmentId as string) || '', Subject: (item.Subject as string) || '', - RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + RequestDate: spDateOnlyToIso(requestDate), SourceProject: (item.SourceProject as string) || '', SourceProjectId: (item.SourceProjectId as string) || '', SourceProjectManager: (item.SourceProjectManager as string) || '', diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/load.ts b/src/webparts/requestPortal/forms/UnpaidLeave/load.ts index 25ace41..4d2867b 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/load.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; export interface ILoadedRequest { @@ -51,8 +52,8 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P ...EMPTY_DATA, Employee: employee?.EMail || '', Position: (item.Position as string) || '', - StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date - EndDate: end.slice(0, 10), + StartDate: spDateOnlyToIso(start), + EndDate: spDateOnlyToIso(end), Reason: (item.Reason as string) || '', Substitute: substitute?.EMail || '', Organization: (item.Organization as string) || '', diff --git a/src/webparts/requestPortal/forms/Vacation/load.ts b/src/webparts/requestPortal/forms/Vacation/load.ts index 5bd8218..34d5c16 100644 --- a/src/webparts/requestPortal/forms/Vacation/load.ts +++ b/src/webparts/requestPortal/forms/Vacation/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { FORM_ID, IFormData, EMPTY_DATA } from './model'; export interface ILoadedRequest { @@ -50,7 +51,7 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom ...EMPTY_DATA, Employee: employee?.EMail || '', Position: (item.Position as string) || '', - StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + StartDate: spDateOnlyToIso(start), DaysCount: days === null || days === undefined ? '' : String(days), Substitute: substitute?.EMail || '', Organization: (item.Organization as string) || '', diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts index b04d12a..3170777 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts @@ -1,5 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; import { ICompany } from '../../shared/dictionary'; import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model'; @@ -52,7 +53,7 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro if (value === null || value === undefined) { writable[key] = ''; } else if (key === 'StartDate' && typeof value === 'string') { - writable[key] = value.slice(0, 10); + writable[key] = spDateOnlyToIso(value); } else { writable[key] = String(value); } diff --git a/src/webparts/requestPortal/shared/dateUtils.test.ts b/src/webparts/requestPortal/shared/dateUtils.test.ts new file mode 100644 index 0000000..8711669 --- /dev/null +++ b/src/webparts/requestPortal/shared/dateUtils.test.ts @@ -0,0 +1,51 @@ +import { spDateOnlyToIso } from './dateUtils'; + +// SharePoint хранит поле «только дата» как полночь по поясу САЙТА, переведённую +// в UTC, — поэтому «5 октября» приходит из REST как предыдущий день по UTC. +describe('spDateOnlyToIso', () => { + it('сайт в UTC+5 (Астана с 2024): 04T19:00Z → 5-е', () => { + expect(spDateOnlyToIso('2026-10-04T19:00:00Z')).toBe('2026-10-05'); + }); + + it('сайт в UTC+6 (старый пояс Астаны): 04T18:00Z → 5-е', () => { + expect(spDateOnlyToIso('2026-10-04T18:00:00Z')).toBe('2026-10-05'); + }); + + it('сайт в UTC: 05T00:00Z → 5-е', () => { + expect(spDateOnlyToIso('2026-10-05T00:00:00Z')).toBe('2026-10-05'); + }); + + it('переход через месяц и год', () => { + expect(spDateOnlyToIso('2026-09-30T19:00:00Z')).toBe('2026-10-01'); + expect(spDateOnlyToIso('2026-12-31T19:00:00Z')).toBe('2027-01-01'); + }); + + it('високосный год: 28 февраля → 29-е', () => { + expect(spDateOnlyToIso('2028-02-28T19:00:00Z')).toBe('2028-02-29'); + }); + + it('строку без времени возвращает как есть', () => { + expect(spDateOnlyToIso('2026-10-05')).toBe('2026-10-05'); + }); + + it('пустое/отсутствующее значение → пустая строка', () => { + expect(spDateOnlyToIso('')).toBe(''); + expect(spDateOnlyToIso(null)).toBe(''); + expect(spDateOnlyToIso(undefined)).toBe(''); + }); + + it('повторное сохранение не сдвигает дату (запись → чтение → запись)', () => { + // Имитация: SP вернул 04T19:00Z, мы прочитали, сохранили обратно «как есть», + // SP снова хранит 04T19:00Z — дата обязана остаться 5-м, а не уехать на 4-е. + const firstRead = spDateOnlyToIso('2026-10-04T19:00:00Z'); + expect(firstRead).toBe('2026-10-05'); + const secondRead = spDateOnlyToIso(`${addDays(firstRead, -1)}T19:00:00Z`); + expect(secondRead).toBe(firstRead); + }); +}); + +function addDays(isoDate: string, days: number): string { + const d = new Date(`${isoDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} diff --git a/src/webparts/requestPortal/shared/dateUtils.ts b/src/webparts/requestPortal/shared/dateUtils.ts index 88a4d1b..1094531 100644 --- a/src/webparts/requestPortal/shared/dateUtils.ts +++ b/src/webparts/requestPortal/shared/dateUtils.ts @@ -25,6 +25,30 @@ export function isoDatePlusMonths(months: number): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } +/** Сегодня по МЕСТНОМУ времени в формате YYYY-MM-DD (toISOString дал бы дату по UTC — до 05:00 вчерашнюю). */ +export function todayIsoDate(): string { + return isoDatePlusDays(0); +} + +const HALF_DAY_MS = 12 * 60 * 60 * 1000; + +/** + * Значение поля SharePoint «только дата» → YYYY-MM-DD для input type=date. + * + * REST отдаёт такие поля в UTC: «5 октября» на сайте с поясом UTC+5 приходит + * как «2026-10-04T19:00:00Z», и `slice(0, 10)` давал 4-е число (а каждое + * пересохранение сдвигало дату ещё на день назад). Округляем до ближайшей + * полуночи UTC (+12 ч) — так верно для любого пояса сайта в пределах ±11 ч и + * не зависит от пояса браузера. + */ +export function spDateOnlyToIso(value: unknown): string { + if (typeof value !== 'string' || value === '') return ''; + if (value.indexOf('T') < 0) return value.slice(0, 10); + const ms = Date.parse(value); + if (isNaN(ms)) return value.slice(0, 10); + return new Date(ms + HALF_DAY_MS).toISOString().slice(0, 10); +} + /** YYYY-MM-DD → дд.мм.гггг, для текста ошибок валидации. Невалидную строку возвращает как есть. */ export function formatIsoDate(iso: string): string { const [y, m, d] = iso.split('-');