diff --git a/docs/forms.md b/docs/forms.md index 9d3151b..9b924d7 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -48,6 +48,7 @@ | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ | | [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ | +| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | | [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ | diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index af0e41f..a8a8645 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -17,6 +17,14 @@ export const REQUEST_TYPES: IRequestType[] = [ // Отдел кадров { id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' }, { id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' }, + // Пока только тестировщикам: тип заявки и маршрут ещё не заведены в админке approval-route. + { + id: 'hr-skud-explanation', + categoryId: 'hr', + title: 'Объяснительная по СКУД', + icon: 'user-check', + comingSoon: true + }, { id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document', comingSoon: true }, { id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard', comingSoon: true }, { diff --git a/src/webparts/requestPortal/forms/SkudExplanation/README.md b/src/webparts/requestPortal/forms/SkudExplanation/README.md new file mode 100644 index 0000000..bb1d83b --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/README.md @@ -0,0 +1,61 @@ +# Объяснительная об отсутствии отметок в СКУД (hr-skud-explanation) + +**Назначение:** объяснительная сотрудника за дни, когда в СКУД нет отметки прихода/ухода +(одна запись) или нет отметок вовсе. +**Категория:** Отдел кадров (плитка «Объяснительная по СКУД») +**Списки SP:** «Объяснительная по СКУД — данные» / «Объяснительная по СКУД — согласование» +**RequestTypeId (внешний API маршрутов):** _плейсхолдер_ — получит числовой id при заведении +типа в админке approval-route с `pageUrl` «hr-skud-explanation». + +## Маршрут +- Фазы: согласование (напр. непосредственный руководитель) + исполнение (напр. кадры) — + обе фазы грузятся отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')` + в `useSkudRoute` (паттерн Referral). Без запроса 'Execution' шаг исполнения пропадает. +- Планируемый маршрут резолвится по e-mail сотрудника; согласующих заявитель может + править (новая / черновик / возврат), исполнителей — нет (только чтение). +- Нет согласующих вовсе — `initialSubmitStatus` сразу ставит «Выполнение». + +## Особое поведение +- **Только «на себя».** Проходы берутся из `GET api.tnsu.kz/Hikvision/Events/CurrentUser` — + API определяет сотрудника по токену, за другого их не получить. Поле «Сотрудник» не + редактируется. 404 от API = учётная запись не связана с 1С → вместо календаря сообщение. +- **Календарь «Мой СКУД»** (перенесён из portal-main-page, те же цвета и правила): + зелёный ≥ 8 ч, жёлтый < 8 ч (рабочее время = последний проход − первый − 1 ч обеда), + красный — 1 запись, штриховка — прошедший будний день без проходов, индиго — праздник + (SP-список «Производственный Календарь» на корневом сайте, `HOLIDAYS_SITE_URL`). +- **Какие дни выбираются** (`toMissedKind`): прошедшие < 8 ч (часто пропущен приход/уход), + с 1 записью и без отметок; сегодня — только если нет ни одной отметки (одна запись утром — + норма, сотрудник ещё не ушёл). ≥ 8 ч, выходные, праздники — нет. +- **Лимит — текущий и прошлый месяц** (`SELECTABLE_MONTHS_BACK = 1`): дальше календарь не + листается, а при отправке черновика с устаревшими днями валидация просит их убрать. +- **Защита от повторов** (`submittedDays.ts` / `.service.ts`): день, уже вошедший в свою + объяснительную со статусом На согласовании / Согласовано / Выполнение / Закрыта / + Возвращена на доработку, в календаре помечен ✓ и не выбирается (подсказка — номер и + статус той заявки). «Отклонено» и «Черновик» не блокируют. Ищем по всем партициям, + фильтр `EmployeeId` + `Created ≥ начала доступного периода`; открытая заявка себя не + блокирует. Перед отправкой список перечитывается (вторая вкладка). Ошибка чтения + форму не блокирует — календарь без пометок. Индекс на `Employee` провижинер не ставит; + понадобится, только если партиция перерастёт 5000 элементов. +- **Дни сохраняются снимком** (`SkudMissedDaysJson`: дата, вид, первая/последняя запись), + т.к. согласующий чужой СКУД открыть не может. В режиме чтения календаря нет — только + список дней. `SkudMissedDates` — та же информация читаемой строкой для представлений SP. +- **Ф.И.О. полностью** — из `Employee/ByEmail` (`SkudEmployeeFio`), а не из профиля SP + (там «Имя Фамилия»): в тексте записки нужно «Фамилия И. О.». +- **Текст записки собирается автоматически** (`noteText.ts`) из дат, причины и + подтверждения; показывается только в просмотре (документ для печати) — в форме подачи + предпросмотра нет (убран по просьбе Тимура). +- **Печать** — только документ (бланк: шапка «Директору {организация}», должность, Ф.И.О., + заголовок, текст, дата подачи, подпись), без данных формы и маршрута. +- **Дата подачи** (`SubmitDate`, общий столбец сайта «только дата») — не редактируется, + ставится сегодняшней у новой заявки и при открытии черновика/доработки; у отправленной + остаётся прежней. Печатается внизу бланка (для старых записей без неё — `Created`). +- **Заголовок элемента** (`Title`): `Объяснительная по СКУД — {ФИО} — дд.мм.гггг`. + +## Секции формы +EmployeeSection (сотрудник · должность · организация) → DaysSection (календарь + выбранные +дни) → ExplanationSection (причина · «другое» · подтверждение). + +## Точки входа +- rptype: `hr-skud-explanation` +- Режим согласующего (просмотр): есть (`SkudExplanationView`) +- «Создать на основе»: нет diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.module.scss b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.module.scss @@ -0,0 +1,20 @@ +@import '../../../../styles/design-tokens'; + +.back { + display: inline-flex; + align-items: center; + gap: $sp-2; + margin-bottom: $sp-4; + padding: 0; + border: 0; + background: transparent; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: color .18s; + + &:hover { + color: $gold-600; + } +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx new file mode 100644 index 0000000..92d9731 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx @@ -0,0 +1,309 @@ +import * as React from 'react'; +import { useState, useMemo, useEffect } from 'react'; +import styles from './SkudExplanationForm.module.scss'; +import { + Alert, + FormCard, + InitiatorLine, + FormModeProvider, + Icon, + RequestNumber, + SubmitActions, + getDraftDeleteHandler, + useRequestSubmit +} from '../../shared/ui'; +import { + ApprovalMatrix, + ApprovalHistory, + StatusBadge, + notifyActiveStep, + useRequestTypeId, + REQUEST_STATUS, + isEditableStatus, + initialSubmitStatus, + IApproverToWrite, + unassignedStepMessage, + resolveWriteListId +} from '../../shared/approval'; +import { IRequestFormProps } from '../../models/types'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { useStableCallback } from '../../shared/useStableCallback'; +import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model'; +import { validateSkudExplanation } from './validation'; +import { saveSkudExplanationRequest, skudExplanationApprovalService } from './submit'; +import { loadSkudExplanationRequest } from './load'; +import { submittedConflictMessage } from './submittedDays'; +import { getSubmittedDays } from './submittedDays.service'; +import { useSubmittedDays } from './useSubmittedDays'; +import { useSkudRoute } from './useSkudRoute'; +import { todayDateKey } from './skud/skudDays'; +import { EmployeeSection } from './sections/EmployeeSection'; +import { DaysSection } from './sections/DaysSection'; +import { ExplanationSection } from './sections/ExplanationSection'; + +const SkudExplanationForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId, + onCodeLoaded +}) => { + const [data, setData] = useState(EMPTY_DATA); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); + const submit = useRequestSubmit(); + // Стабильная обёртка: ссылка onCodeLoaded у родителя меняется на каждом + // рендере, а в зависимостях эффекта загрузки она перезапрашивала бы заявку. + const notifyCode = useStableCallback(onCodeLoaded); + const { setError: setSubmitError } = submit; + + const readOnly = !isEditableStatus(requestStatus); + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + // Отправленную заявку уже не правят — пустой e-mail не делает запрос. + const submittedDays = useSubmittedDays(readOnly ? '' : data.Employee, requestId); + + const approval = useMemo(() => skudExplanationApprovalService(editListId), [editListId]); + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; + + // Открыта своя заявка на редактирование (черновик/возврат). + useEffect(() => { + if (!editId) return; + let alive = true; + loadSkudExplanationRequest(editId, editListId) + .then((loaded) => { + if (!alive) return; + // Черновик/доработку подают заново — дата подачи становится сегодняшней; + // у отправленной заявки остаётся та, с которой её подали. + const submitDate = isEditableStatus(loaded.status) ? todayDateKey() : loaded.data.SubmitDate; + setData({ ...loaded.data, SubmitDate: submitDate }); + setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); + if (loaded.code) notifyCode(loaded.code); + }) + .catch((e) => { + if (alive) setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + }); + return () => { + alive = false; + }; + }, [editId, editListId, notifyCode, setSubmitError]); + + // Новая заявка — всегда на себя: ФИО полностью, должность и организации из + // Employee/ByEmail (ФИО профиля SharePoint — «Имя Фамилия», для записки не годится). + useEffect(() => { + if (editId) return; + let alive = true; + const user = context.pageContext.user; + setData((prev) => ({ + ...prev, + SubmitDate: todayDateKey(), + Employee: user.email, + EmployeeFio: prev.EmployeeFio || user.displayName + })); + getEmployeeOrganizations(context, user.email) + .then((records) => { + if (!alive) return; + setData((prev) => ({ + ...prev, + EmployeeFio: records[0]?.fio || prev.EmployeeFio, + Position: prev.Position || records[0]?.position || '', + Organization: formatEmployeeOrganizations(records) + })); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [editId, context]); + + // Маршрут по сотруднику; у открытой заявки — из уже записанных строк, чтобы не затереть правки. + const { approvers: matrix, executors } = useSkudRoute({ + context, + approval, + requestId: editId, + employeeEmail: data.Employee, + requestTypeId, + editable: !readOnly + }); + + const validate = (): string | undefined => { + const formError = validateSkudExplanation(data); + if (formError) return formError; + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; + + // На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи. + const validateSubmit = (): string | undefined => { + const formError = validate(); + if (formError) return formError; + const repeatError = submittedConflictMessage(data.MissedDays, submittedDays); + if (repeatError) return repeatError; + if (matrix.error) { + return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + const unassignedError = unassignedStepMessage( + matrix.rows, + (r) => + `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` + ); + if (unassignedError) return unassignedError; + return unassignedStepMessage( + executors.rows, + (r) => + `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + }; + + const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => + rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order, + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason, + autoApprove: r.autoApprove + })); + + const save = (status: string): (() => Promise) => async (): Promise => { + // validate() уже гарантировал requestTypeId, но TS не видит этого через замыкание. + const rtId = requestTypeId; + if (!rtId) throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + // Пометки в календаре — на момент открытия формы; за это время объяснительную + // на те же дни могли подать из другой вкладки. Перечитываем прямо перед записью. + if (status !== REQUEST_STATUS.draft) { + const repeatError = submittedConflictMessage(data.MissedDays, await getSubmittedDays(data.Employee, requestId)); + if (repeatError) throw new Error(repeatError); + } + // Текущую партицию спрашиваем прямо перед записью: этот вызов запускает архивацию по порогу. + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); + const { id, code } = await saveSkudExplanationRequest( + requestId, + rtId, + data, + toApprovers(matrix.rows), + toApprovers(executors.rows), + status, + writeListId + ); + setRequestId(id); + setRequestStatus(status); + if (code) setRequestCode(code); + if (onSaved) onSaved(); + // Отправка (на согласование или, без согласующих, сразу на исполнение) — уведомляем активный шаг. + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { + await notifyActiveStep(context, approval, id, { + requestTypeId: rtId, + dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), + approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID) + }); + } + }; + + const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); + // Согласующих в маршруте может не оказаться — тогда initialSubmitStatus сразу даёт «Выполнение». + const handleSubmit = (): Promise => + submit.run('submit', save(initialSubmitStatus(matrix.rows.length, executors.rows.length)), validateSubmit); + + const handleDelete = getDraftDeleteHandler({ + formKey: FORM_ID, + requestId, + requestStatus, + listId: editListId, + code: requestCode, + submit, + onDeleted: onCancel + }); + + const sectionProps = { data, set, context }; + + return ( +
+ + + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {submit.error && {submit.error}} + + + + + + + + approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение (после согласования)" + roleLabel="Исполнитель" + emptyText="Исполнители не заданы по маршруту." + /> + )} + + {editId && ( +
+ +
+ )} + + +
+
+ ); +}; + +export default SkudExplanationForm; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.module.scss b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.module.scss new file mode 100644 index 0000000..82bbc32 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.module.scss @@ -0,0 +1,29 @@ +@import '../../../../styles/design-tokens'; + +.state { + padding: $sp-8; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} + +.documentBlock { + margin-top: $sp-6; +} + +.documentTitle { + margin-bottom: $sp-3; + font-family: $font-serif; + font-size: $fs-section-title; + font-weight: 600; + color: $navy-900; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: $sp-3; + margin-bottom: $sp-4; +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.tsx new file mode 100644 index 0000000..6c9c87a --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationView.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './SkudExplanationView.module.scss'; +import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + ApprovalHistory, + notifyActiveStep, + useRequestTypeId +} from '../../shared/approval'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { useStableCallback } from '../../shared/useStableCallback'; +import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model'; +import { loadSkudExplanationRequest } from './load'; +import { skudExplanationApprovalService } from './submit'; +import { useSkudRoute } from './useSkudRoute'; +import { EmployeeSection } from './sections/EmployeeSection'; +import { DaysSection } from './sections/DaysSection'; +import { ExplanationSection } from './sections/ExplanationSection'; +import { NoteDocument } from './components/NoteDocument'; + +interface ISkudExplanationViewProps { + context: WebPartContext; + itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; + /** Партиция, где лежит эта заявка — см. IApprovalViewProps.listId. */ + listId?: string; + onCodeLoaded?: (code: string) => void; +} + +// В режиме чтения поля не редактируются — обновление пустышка. +const NOOP_SET: SetField = () => undefined; + +/** + * Объяснительная для согласующего: данные заявки (только чтение) + документ + * записки. На печать идёт только документ (rp-print-root) — как бумажный бланк. + */ +const SkudExplanationView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + const notifyCode = useStableCallback(onCodeLoaded); + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [created, setCreated] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [printOrgIndex, setPrintOrgIndex] = useState(0); + + const approval = useMemo(() => skudExplanationApprovalService(listId), [listId]); + const { id: requestTypeId } = useRequestTypeId(context, formKey); + const dataListRef = listId || dataListTitle(FORM_ID); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); + + // decisionSeq: после решения строки маршрута меняются — перечитываем обе фазы. + const [decisionSeq, setDecisionSeq] = useState(0); + const { approvers, executors } = useSkudRoute({ + context, + approval, + requestId: itemId, + employeeEmail: data.Employee, + requestTypeId, + editable: false, + reloadSeq: decisionSeq + }); + + useEffect(() => { + let alive = true; + setLoading(true); + loadSkudExplanationRequest(itemId, listId) + .then((loaded) => { + if (!alive) return; + setData(loaded.data); + setStatus(loaded.status); + setCode(loaded.code); + setCreated(loaded.created); + notifyCode(loaded.code); + setError(null); + setLoading(false); + }) + .catch((e) => { + if (!alive) return; + setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`); + setLoading(false); + }); + return () => { + alive = false; + }; + }, [itemId, listId, notifyCode]); + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const notify = (): void => { + // Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем. + if (!requestTypeId) return; + notifyActiveStep(context, approval, itemId, { + requestTypeId, + dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID), + approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID) + }).catch(() => undefined); + }; + + const sectionProps = { data, set: NOOP_SET, context, printOrgIndex }; + + return ( +
+
+ +
+ + + } + headerRight={status ? : undefined} + > + + + + + + + {/* Не в CollapseSection: свёрнутая секция убрала бы rp-print-root из DOM, и печатать было бы нечего. */} +
+
Объяснительная записка
+
+ + {/* Created — запасной вариант для заявок, сохранённых до появления «Даты подачи». */} + +
+
+
+ + approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )} + +
+ +
+ +
+ approval.searchUsers(q)} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default SkudExplanationView; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/approval/provisioning.json b/src/webparts/requestPortal/forms/SkudExplanation/approval/provisioning.json new file mode 100644 index 0000000..352d1bc --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/approval/provisioning.json @@ -0,0 +1,17 @@ +{ + "dataListTitle": "Объяснительная по СКУД — согласование", + "columns": [ + { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, + { "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, + { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Объяснительная по СКУД — данные", "lookupShowField": "Title" }, + { "internalName": "Date", "title": "Дата", "type": "DateTime" }, + { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, + { "internalName": "StepOrder", "title": "Порядок", "type": "Number" }, + { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }, + { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }, + { "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" }, + { "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" }, + { "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" }, + { "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } + ] +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/NoteDocument.module.scss b/src/webparts/requestPortal/forms/SkudExplanation/components/NoteDocument.module.scss new file mode 100644 index 0000000..dd7b7aa --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/NoteDocument.module.scss @@ -0,0 +1,71 @@ +@import '../../../../../styles/design-tokens'; + +// Документ имитирует бумажный бланк: шрифт с засечками, выравнивание по ширине, +// красная строка — как в образце объяснительной отдела кадров. +$doc-font: 'Times New Roman', Times, serif; +$paragraph-indent: 2.5em; +$sign-line-width: 120px; + +.document { + width: 100%; + padding: $sp-4; + border: 1px solid $gray-200; + border-radius: $r-md; + background: $white; + color: $gray-900; + font-family: $doc-font; + font-size: 15px; + line-height: 1.5; + + @media (min-width: 640px) { + padding: $sp-8; + } +} + +.addressee { + margin-left: auto; + margin-bottom: $sp-8; + max-width: 320px; + text-align: right; + font-weight: 600; +} + +.author { + margin-top: $sp-1; +} + +.title { + margin-bottom: $sp-6; + text-align: center; + font-weight: 700; + text-transform: uppercase; +} + +.paragraph { + margin: 0 0 $sp-2; + text-align: justify; + text-indent: $paragraph-indent; + overflow-wrap: anywhere; +} + +.footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-end; + gap: $sp-4; + margin-top: $sp-8; + font-weight: 600; +} + +.signature { + display: inline-flex; + align-items: flex-end; + gap: $sp-2; +} + +.signLine { + display: inline-block; + width: $sign-line-width; + border-bottom: 1px solid $gray-900; +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/NoteDocument.tsx b/src/webparts/requestPortal/forms/SkudExplanation/components/NoteDocument.tsx new file mode 100644 index 0000000..86f8a5f --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/NoteDocument.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import styles from './NoteDocument.module.scss'; +import { IFormData } from '../model'; +import { buildNoteParagraphs, formatShortDate, shortFio } from '../noteText'; + +interface INoteDocumentProps { + data: IFormData; + /** Дата записки, YYYY-MM-DD (дата подачи; у новой заявки — сегодня). */ + noteDate: string; + /** Какая из организаций сотрудника идёт в шапку (см. PrintSplitButton). */ + orgIndex?: number; +} + +/** «Компания — должность» → компания; организаций несколько — берём выбранную для печати. */ +function companyOf(organization: string, index: number): string { + const line = organization.split('\n')[index] || ''; + return line.split(' — ')[0].trim(); +} + +/** + * Объяснительная записка в виде бумажного документа (как бланк отдела кадров): + * шапка справа, заголовок, текст, дата и подпись. Предпросмотр в форме и + * печатная форма в просмотре — один и тот же компонент. + */ +export const NoteDocument: React.FC = ({ data, noteDate, orgIndex = 0 }) => { + const company = companyOf(data.Organization, orgIndex); + const name = shortFio(data.EmployeeFio); + + return ( +
+
+ {company &&
Директору {company}
} + {data.Position &&
{data.Position}
} + {name &&
{name}
} +
+ +
Объяснительная записка
+ + {buildNoteParagraphs(data).map((paragraph) => ( +

+ {paragraph} +

+ ))} + +
+ {formatShortDate(noteDate)} г. + + + {name} + +
+
+ ); +}; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/SelectedDaysList.module.scss b/src/webparts/requestPortal/forms/SkudExplanation/components/SelectedDaysList.module.scss new file mode 100644 index 0000000..e394269 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/SelectedDaysList.module.scss @@ -0,0 +1,57 @@ +@import '../../../../../styles/design-tokens'; + +.list { + display: flex; + flex-wrap: wrap; + gap: $sp-2; + margin: 0; + padding: 0; + list-style: none; +} + +.chip { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: $sp-2; + max-width: 100%; + padding: $sp-1 $sp-2 $sp-1 $sp-3; + border: 1px solid $navy-100; + border-radius: $r-full; + background: $navy-50; + font-size: $fs-sm; +} + +.date { + font-weight: 600; + color: $navy-900; +} + +.kind { + font-size: $fs-label; + color: $red-500; +} + +.remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: $gray-500; + cursor: pointer; + + &:hover { + background: $navy-100; + color: $navy-900; + } +} + +.empty { + font-size: $fs-sm; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/SelectedDaysList.tsx b/src/webparts/requestPortal/forms/SkudExplanation/components/SelectedDaysList.tsx new file mode 100644 index 0000000..fa8d554 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/SelectedDaysList.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Icon } from '../../../shared/ui'; +import styles from './SelectedDaysList.module.scss'; +import { IMissedDay } from '../model'; +import { formatLongDate, formatMissedDetails } from '../noteText'; + +interface ISelectedDaysListProps { + days: IMissedDay[]; + /** Не передан — список только для чтения (просмотр, отправленная заявка). */ + onRemove?: (date: string) => void; +} + +/** Выбранные в объяснительную дни — «чипсы» с тем, что было в СКУД на момент подачи. */ +export const SelectedDaysList: React.FC = ({ days, onRemove }) => { + if (days.length === 0) { + return
Дни не выбраны — отметьте их в календаре.
; + } + + return ( +
    + {days.map((day) => ( +
  • + {formatLongDate(day.date)} + {formatMissedDetails(day)} + {onRemove && ( + + )} +
  • + ))} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/SkudCalendar.module.scss b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudCalendar.module.scss new file mode 100644 index 0000000..295d032 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudCalendar.module.scss @@ -0,0 +1,293 @@ +@import '../../../../../styles/design-tokens'; + +// Цвета статусов — те же, что в «Мой СКУД» (portal-main-page), чтобы сотрудник +// видел одну и ту же картину в обоих местах. +$full-bg: #ECFDF5; +$full-border: #A7F3D0; +$full-text: $green-600; + +$partial-bg: #FFFBEB; +$partial-border: #FDE68A; +$partial-text: #B45309; + +$single-bg: $red-50; +$single-border: #FECACA; +$single-text: $red-500; + +$holiday-bg: #EEF2FF; +$holiday-border: #C7D2FE; +$holiday-text: #4338CA; + +$weekend-bg: #EDEFF2; + +// Шире 7 колонок по ~64px ячейки перестают быть квадратными и читаются хуже — +// на широком экране календарь не растягивается на всю форму. +$calendar-max-width: 480px; + +.calendar { + width: 100%; + max-width: $calendar-max-width; +} + +.nav { + display: flex; + align-items: center; + justify-content: center; + gap: $sp-3; + margin-bottom: $sp-1; +} + +.navButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $white; + color: $navy-700; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: $gold-500; + } + + &:disabled { + color: $gray-300; + cursor: default; + } +} + +.month { + min-width: 150px; + text-align: center; + font-size: $fs-body; + font-weight: 600; + color: $navy-900; +} + +.summary { + margin-bottom: $sp-2; + text-align: center; + font-size: $fs-label; + color: $gray-500; +} + +.state { + padding: $sp-6 0; + text-align: center; + font-size: $fs-sm; + color: $gray-500; +} + +.weekHead, +.grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: $sp-1; + + @media (min-width: 480px) { + gap: 6px; + } +} + +.weekHead { + margin-bottom: $sp-1; +} + +.weekDay { + text-align: center; + font-size: $fs-caption; + font-weight: 600; + color: $gray-400; + text-transform: uppercase; +} + +.empty { + aspect-ratio: 1 / 1; +} + +.cell { + aspect-ratio: 1 / 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 0; + padding: 0; + border: 1px solid transparent; + border-radius: $r-sm; + background: $gray-50; + color: $gray-400; + font-family: inherit; + cursor: default; + transition: border-color .18s, box-shadow .18s; +} + +.dayNum { + font-size: $fs-label; + font-weight: 500; + + @media (min-width: 480px) { + font-size: $fs-sm; + } +} + +.cellTime { + font-size: 9px; + font-weight: 600; + + @media (min-width: 480px) { + font-size: 10px; + } +} + +.full { + background: $full-bg; + border-color: $full-border; + color: $full-text; +} + +.partial { + background: $partial-bg; + border-color: $partial-border; + color: $partial-text; +} + +.single { + background: $single-bg; + border-color: $single-border; + color: $single-text; +} + +.holiday { + background: $holiday-bg; + border-color: $holiday-border; + color: $holiday-text; +} + +.weekend { + background: $weekend-bg; + + .dayNum { + color: $gray-500; + } +} + +// Прошедший будний день без проходов — диагональная штриховка «нет данных». +.absent { + background: repeating-linear-gradient(45deg, $white, $white 4px, $gray-200 4px, $gray-200 8px); + border-color: $gray-200; + + .dayNum { + color: $gray-500; + } +} + +.selectable { + cursor: pointer; + + &:hover { + border-color: $navy-700; + } +} + +// День уже в поданной объяснительной: синяя рамка + галочка поверх цвета СКУД, +// чтобы было видно и «что было», и «уже объяснено». +.cell { + position: relative; +} + +.submitted { + border-color: $blue-600; + opacity: .75; +} + +.submittedMark { + position: absolute; + top: 2px; + right: 4px; + font-size: 10px; + font-weight: 700; + line-height: 1; + color: $blue-600; +} + +.selected { + border-color: $navy-800; + box-shadow: inset 0 0 0 2px $navy-800; + + .dayNum { + font-weight: 700; + } +} + +.today { + box-shadow: inset 0 0 0 2px $gold-500; +} + +.legendWrap { + margin-top: $sp-3; + text-align: center; +} + +.legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: $sp-2 $sp-3; + font-size: $fs-caption; + color: $gray-500; +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 3px; +} + +.dotFull { + background: $full-border; + border: 1px solid $full-text; +} + +.dotPartial { + background: $partial-border; + border: 1px solid $partial-text; +} + +.dotSingle { + background: $single-border; + border: 1px solid $single-text; +} + +.dotHoliday { + background: $holiday-border; + border: 1px solid $holiday-text; +} + +.dotSubmitted { + position: relative; + background: $blue-50; + border: 1px solid $blue-600; +} + +.dotAbsent { + background: repeating-linear-gradient(45deg, $white, $white 2px, $gray-300 2px, $gray-300 4px); + border: 1px solid $gray-200; +} + +.hint { + margin-top: $sp-2; + font-size: $fs-caption; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/SkudCalendar.tsx b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudCalendar.tsx new file mode 100644 index 0000000..615388b --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudCalendar.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { Alert, Icon } from '../../../shared/ui'; +import styles from './SkudCalendar.module.scss'; +import { SkudLegend } from './SkudLegend'; +import { SkudDayCell } from './SkudDayCell'; +import { useSkudMonth } from '../skud/useSkudMonth'; +import { buildMonthCells, minSelectableDateKey, monthWorkedHours, todayDateKey } from '../skud/skudDays'; +import { IMissedDay } from '../model'; +import { ISubmittedDay } from '../submittedDays'; + +const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + +interface ISkudCalendarProps { + context: WebPartContext; + /** Даты (YYYY-MM-DD), уже выбранные в объяснительную. */ + selectedDates: string[]; + /** Дни, уже закрытые поданными объяснительными (см. useSubmittedDays). */ + submittedDays: Map; + onToggle: (day: IMissedDay) => void; +} + +function monthTitle(year: number, month: number): string { + const text = new Date(year, month, 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' }); + const clean = text.replace(' г.', ''); + return clean.charAt(0).toUpperCase() + clean.slice(1); +} + +/** Календарь «Мой СКУД» с выбором дней (какие кликабельны — см. toMissedKind); листается на SELECTABLE_MONTHS_BACK назад. */ +export const SkudCalendar: React.FC = ({ context, selectedDates, submittedDays, onToggle }) => { + const skud = useSkudMonth(context); + const cells = buildMonthCells(skud.year, skud.month, skud.byDay); + const bounds = { todayKey: todayDateKey(), minKey: minSelectableDateKey() }; + + if (skud.notLinked) { + return ( + + Данные СКУД недоступны: ваша учётная запись не связана с системой 1С. Обратитесь в отдел кадров. + + ); + } + + return ( +
+
+ + {monthTitle(skud.year, skud.month)} + +
+ + {!skud.loading && !skud.error && ( +
+ Дней: {skud.byDay.size} · ≈ {monthWorkedHours(skud.byDay)} ч за месяц +
+ )} + + {skud.loading &&
Загрузка отметок СКУД…
} + {!skud.loading && skud.error && Не удалось загрузить отметки СКУД: {skud.error}} + + {!skud.loading && !skud.error && ( + <> +
+ {WEEKDAYS.map((w) => ( + + {w} + + ))} +
+
+ {cells.map((cell, index) => + cell ? ( + = 0} + onToggle={onToggle} + /> + ) : ( + + ) + )} +
+ + + )} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/SkudDayCell.tsx b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudDayCell.tsx new file mode 100644 index 0000000..6a36488 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudDayCell.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import styles from './SkudCalendar.module.scss'; +import { CellKind, IDayInfo, cellKind, isWeekendColumn, toMissedKind } from '../skud/skudDays'; +import { IMissedDay } from '../model'; +import { ISubmittedDay } from '../submittedDays'; + +/** Границы выбора: сегодня и самая ранняя доступная дата (см. minSelectableDateKey). */ +export interface IDayBounds { + todayKey: string; + minKey: string; +} + +interface ISkudDayCellProps { + info: IDayInfo; + /** Позиция в сетке — по ней определяется выходной (Сб/Вс). */ + cellIndex: number; + bounds: IDayBounds; + /** Название праздника, если день — праздничный выходной. */ + holidayName?: string; + /** День уже закрыт поданной объяснительной — выбрать нельзя. */ + submitted?: ISubmittedDay; + selected: boolean; + onToggle: (day: IMissedDay) => void; +} + +const KIND_CLASS: Record = { + full: styles.full, + partial: styles.partial, + single: styles.single, + absent: styles.absent, + holiday: styles.holiday, + weekend: styles.weekend, + empty: '' +}; + +function hint(info: IDayInfo, kind: CellKind, holidayName: string | undefined): string { + if (kind === 'holiday') return holidayName || 'Праздник'; + if (info.events.length === 1) return `Одна запись: ${info.first}`; + if (info.events.length > 1) return `${info.first} → ${info.last}`; + return kind === 'absent' ? 'Нет отметок' : ''; +} + +/** Ячейка дня: цвет по отметкам СКУД; «проблемный» день можно выбрать в объяснительную. */ +export const SkudDayCell: React.FC = ({ + info, + cellIndex, + bounds, + holidayName, + submitted, + selected, + onToggle +}) => { + const kind = cellKind(info, isWeekendColumn(cellIndex), !!holidayName, bounds.todayKey); + const missedKind = submitted ? undefined : toMissedKind(kind, info.dateKey, bounds.todayKey, bounds.minKey); + const className = [ + styles.cell, + KIND_CLASS[kind], + missedKind ? styles.selectable : '', + submitted ? styles.submitted : '', + selected ? styles.selected : '', + info.dateKey === bounds.todayKey ? styles.today : '' + ] + .filter(Boolean) + .join(' '); + const title = submitted ? `Уже подана: №${submitted.code} (${submitted.status})` : hint(info, kind, holidayName); + + const handleClick = (): void => { + if (!missedKind) return; + // Снимок того, что было в СКУД: согласующий увидит это вместо календаря. + const hasExit = info.events.length > 1; + onToggle({ date: info.dateKey, kind: missedKind, first: info.first, last: hasExit ? info.last : '' }); + }; + + return ( + + ); +}; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/components/SkudLegend.tsx b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudLegend.tsx new file mode 100644 index 0000000..f798042 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/components/SkudLegend.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import styles from './SkudCalendar.module.scss'; + +const ITEMS: { className: string; label: string }[] = [ + { className: styles.dotFull, label: '≥ 8 ч' }, + { className: styles.dotPartial, label: '< 8 ч' }, + { className: styles.dotSingle, label: '1 запись' }, + { className: styles.dotHoliday, label: 'праздник' }, + { className: styles.dotAbsent, label: 'отсутствие' }, + { className: styles.dotSubmitted, label: '✓ уже подана' } +]; + +/** Легенда цветов календаря СКУД + подсказка, какие дни выбираются. */ +export const SkudLegend: React.FC = () => ( +
+
+ {ITEMS.map((item) => ( + + + {item.label} + + ))} +
+
+ Выбрать можно дни < 8 ч, с одной записью или без отметок (сегодня — если отметок нет) за текущий и прошлый + месяц. +
+
+); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/load.ts b/src/webparts/requestPortal/forms/SkudExplanation/load.ts new file mode 100644 index 0000000..5491db0 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/load.ts @@ -0,0 +1,73 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { dataListTitle } from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA } from './model'; +import { parseMissedDays } from './missedDaysJson'; +import { toDateKey } from './skud/skudDays'; + +export interface ILoadedRequest { + data: IFormData; + status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; + /** Дата создания записи, YYYY-MM-DD — дата объяснительной в печатной форме. */ + created: string; +} + +// Created приходит в UTC: срез строки дал бы вчерашнюю дату для записей, +// созданных рано утром по местному времени. +function localDateKey(value: unknown): string { + if (typeof value !== 'string' || !value) return ''; + const d = new Date(value); + return toDateKey(d.getFullYear(), d.getMonth(), d.getDate()); +} + +/** + * Прочитать объяснительную по id. listId — партиция, где лежит именно эта + * заявка (см. UnpaidLeave/load.ts): задан — читаем по url списка, иначе из + * текущей партиции. + */ +export async function loadSkudExplanationRequest(itemId: number, listId?: string): Promise { + const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const item = (await list.items + .getById(itemId) + .select( + 'Id', + 'Created', + 'SubmitDate', + 'SkudEmployeeFio', + 'Position', + 'Organization', + 'SkudMissedDaysJson', + 'SkudReason', + 'SkudReasonDetails', + 'SkudPresenceConfirmed', + 'RequestStatus', + 'RequestUniqueCode', + 'Employee/Title', + 'Employee/EMail' + ) + .expand('Employee')()) as Record; + + const employee = item.Employee as { Title?: string; EMail?: string } | undefined; + + const data: IFormData = { + ...EMPTY_DATA, + // Колонка «только дата» — время в ответе нулевое, берём дату как есть (как в Referral/load.ts). + SubmitDate: ((item.SubmitDate as string) || '').slice(0, 10), + Employee: employee?.EMail || '', + EmployeeFio: (item.SkudEmployeeFio as string) || employee?.Title || '', + Position: (item.Position as string) || '', + Organization: (item.Organization as string) || '', + MissedDays: parseMissedDays(item.SkudMissedDaysJson), + Reason: (item.SkudReason as string) || '', + ReasonDetails: (item.SkudReasonDetails as string) || '', + PresenceConfirmed: item.SkudPresenceConfirmed === true + }; + + return { + data, + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '', + created: localDateKey(item.Created) + }; +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/missedDaysJson.ts b/src/webparts/requestPortal/forms/SkudExplanation/missedDaysJson.ts new file mode 100644 index 0000000..97910ad --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/missedDaysJson.ts @@ -0,0 +1,16 @@ +import { IMissedDay } from './model'; + +/** + * Разбор колонки SkudMissedDaysJson. Битый/пустой JSON не должен ронять ни + * открытие заявки, ни проверку повторов — считаем, что дней нет. + */ +export function parseMissedDays(json: unknown): IMissedDay[] { + if (typeof json !== 'string' || !json) return []; + try { + const parsed = JSON.parse(json) as unknown; + return Array.isArray(parsed) ? (parsed as IMissedDay[]) : []; + } catch (e) { + console.warn('[Объяснительная по СКУД] Не удалось разобрать SkudMissedDaysJson', e); + return []; + } +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/model.ts b/src/webparts/requestPortal/forms/SkudExplanation/model.ts new file mode 100644 index 0000000..de58267 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/model.ts @@ -0,0 +1,101 @@ +// import type — чтобы тесты noteText не тянули SPFx-рантайм через модель. +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import type { RequestTypeId } from '../../models/types'; +import type { MissedKind } from './skud/skudDays'; + +/** + * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков + * SharePoint выводятся из него, см. shared/listTitles.ts. + */ +export const FORM_ID: RequestTypeId = 'hr-skud-explanation'; + +/** Полное название — заголовок формы, просмотра и элемента списка (плитка короче, см. data/requests.ts). */ +export const FORM_TITLE = 'Объяснительная об отсутствии отметок в СКУД'; + +/** День, по которому пишется объяснительная, — снимок СКУД на момент подачи. */ +export interface IMissedDay { + /** YYYY-MM-DD */ + date: string; + kind: MissedKind; + /** HH:MM первой записи; '' — отметок не было вовсе. */ + first: string; + /** HH:MM последней записи; '' — записей меньше двух. */ + last: string; +} + +/** + * Данные формы. Заявка только «на себя»: проходы СКУД API отдаёт по токену + * текущего пользователя, за другого сотрудника их не получить. Дни хранятся + * снимком (IMissedDay), потому что согласующий чужой СКУД открыть не может. + */ +export interface IFormData { + /** + * Дата подачи, YYYY-MM-DD — ставится сегодняшней автоматически, пока заявку + * можно править (новая / черновик / доработка), и печатается в бланке. + */ + SubmitDate: string; + /** E-mail сотрудника (People) — всегда текущий пользователь. */ + Employee: string; + /** Ф.И.О. полностью «Фамилия Имя Отчество» из Employee/ByEmail — для текста записки. */ + EmployeeFio: string; + Position: string; + /** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */ + Organization: string; + /** Выбранные дни, по возрастанию даты. */ + MissedDays: IMissedDay[]; + /** Значение из SKUD_REASONS. */ + Reason: string; + /** Своя формулировка причины — только при Reason = OTHER_REASON. */ + ReasonDetails: string; + /** Сотрудник подтверждает, что был на рабочем месте. */ + PresenceConfirmed: boolean; +} + +export type SetField = (key: K, value: IFormData[K]) => void; + +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; + /** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */ + printOrgIndex?: number; +} + +export const EMPTY_DATA: IFormData = { + SubmitDate: '', + Employee: '', + EmployeeFio: '', + Position: '', + Organization: '', + MissedDays: [], + Reason: '', + ReasonDetails: '', + PresenceConfirmed: false +}; + +export const OTHER_REASON = 'Другое'; + +export interface ISkudReason { + /** Значение Choice-колонки SkudReason. */ + value: string; + /** Продолжение фразы «…произошло по причине ___» в тексте записки. */ + phrase: string; +} + +/** Варианты причины. Меняя value — менять и choices колонки SkudReason в provisioning.json. */ +export const SKUD_REASONS: ISkudReason[] = [ + { value: 'Невнимательность', phrase: 'невнимательности' }, + { value: 'Забыл(а) пропуск', phrase: 'того, что я забыл(а) пропуск' }, + { value: 'Неисправность турникета или считывателя', phrase: 'неисправности турникета или считывателя' }, + { value: OTHER_REASON, phrase: '' } +]; + +/** Подписи дня в списке выбранных и в колонке SkudMissedDates. */ +export const MISSED_KIND_LABEL: Record = { + partial: '< 8 ч', + single: '1 запись', + absent: 'нет отметок' +}; + +export const PRESENCE_CONFIRMATION_LABEL = + 'Подтверждаю, что находился(ась) на рабочем месте и исполнял(а) трудовые обязанности в течение рабочего дня'; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/noteText.test.ts b/src/webparts/requestPortal/forms/SkudExplanation/noteText.test.ts new file mode 100644 index 0000000..24113fd --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/noteText.test.ts @@ -0,0 +1,54 @@ +import { EMPTY_DATA, IFormData, OTHER_REASON } from './model'; +import { buildNoteParagraphs, formatMissedDaysSummary, joinWithAnd, shortFio } from './noteText'; + +const DATA: IFormData = { + ...EMPTY_DATA, + Employee: 'gorodetskiy.timur@tnsu.kz', + EmployeeFio: 'Городецкий Тимур Александрович', + MissedDays: [ + { date: '2026-07-21', kind: 'single', first: '08:35', last: '' }, + { date: '2026-07-22', kind: 'absent', first: '', last: '' }, + { date: '2026-07-23', kind: 'partial', first: '08:49', last: '13:10' } + ], + Reason: 'Невнимательность', + PresenceConfirmed: true +}; + +describe('noteText', () => { + it('shortFio: фамилия + инициалы', () => { + expect(shortFio('Городецкий Тимур Александрович')).toBe('Городецкий Т. А.'); + expect(shortFio('Городецкий')).toBe('Городецкий'); + expect(shortFio(' ')).toBe(''); + }); + + it('joinWithAnd', () => { + expect(joinWithAnd(['a'])).toBe('a'); + expect(joinWithAnd(['a', 'b'])).toBe('a и b'); + expect(joinWithAnd(['a', 'b', 'c'])).toBe('a, b и c'); + }); + + it('текст записки как в бланке отдела кадров', () => { + expect(buildNoteParagraphs(DATA)).toEqual([ + 'Я, Городецкий Т. А., по факту отсутствия отметок в системе СКУД 21 июля 2026 г., 22 июля 2026 г. и 23 июля 2026 г. сообщаю следующее:', + 'Отсутствие отметки в системе СКУД в указанные даты произошло по причине невнимательности. Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.', + 'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.' + ]); + }); + + it('своя причина и одна дата', () => { + const [, second] = buildNoteParagraphs({ + ...DATA, + MissedDays: [DATA.MissedDays[0]], + Reason: OTHER_REASON, + ReasonDetails: 'был на объекте', + PresenceConfirmed: false + }); + expect(second).toBe('Отсутствие отметки в системе СКУД в указанную дату произошло по следующей причине: был на объекте'); + }); + + it('сводка дней для колонки SP', () => { + expect(formatMissedDaysSummary(DATA.MissedDays)).toBe( + '21.07.2026 (1 запись 08:35); 22.07.2026 (нет отметок); 23.07.2026 (< 8 ч 08:49–13:10)' + ); + }); +}); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/noteText.ts b/src/webparts/requestPortal/forms/SkudExplanation/noteText.ts new file mode 100644 index 0000000..41dc964 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/noteText.ts @@ -0,0 +1,89 @@ +import { IFormData, IMissedDay, MISSED_KIND_LABEL, OTHER_REASON, SKUD_REASONS } from './model'; + +/** + * Текст объяснительной записки из данных формы — один источник и для + * предпросмотра в форме, и для печатного документа в просмотре. Без React — + * покрыт тестами (noteText.test.ts). + */ + +const MONTHS_GENITIVE = [ + 'января', + 'февраля', + 'марта', + 'апреля', + 'мая', + 'июня', + 'июля', + 'августа', + 'сентября', + 'октября', + 'ноября', + 'декабря' +]; + +/** Заглушка на месте незаполненной части текста в предпросмотре. */ +const BLANK = '____'; + +/** YYYY-MM-DD → «21 июля 2026 г.» */ +export function formatLongDate(iso: string): string { + const [y, m, d] = iso.split('-').map(Number); + if (!y || !m || !d) return iso; + return `${d} ${MONTHS_GENITIVE[m - 1]} ${y} г.`; +} + +/** YYYY-MM-DD → «21.07.2026» */ +export function formatShortDate(iso: string): string { + const [y, m, d] = iso.split('-'); + return y && m && d ? `${d}.${m}.${y}` : iso; +} + +/** ['a'] → 'a'; ['a','b'] → 'a и b'; ['a','b','c'] → 'a, b и c'. */ +export function joinWithAnd(parts: string[]): string { + if (parts.length <= 1) return parts.join(''); + return `${parts.slice(0, -1).join(', ')} и ${parts[parts.length - 1]}`; +} + +/** «Городецкий Тимур Александрович» → «Городецкий Т. А.» */ +export function shortFio(fio: string): string { + const [surname, ...rest] = fio.trim().split(/\s+/); + if (!surname) return ''; + const initials = rest.map((name) => `${name.charAt(0).toUpperCase()}.`).join(' '); + return initials ? `${surname} ${initials}` : surname; +} + +/** Что было в СКУД в этот день: «1 запись 08:35», «< 8 ч 08:49–13:10», «нет отметок». */ +export function formatMissedDetails(day: IMissedDay): string { + const times = [day.first, day.last].filter(Boolean).join('–'); + return times ? `${MISSED_KIND_LABEL[day.kind]} ${times}` : MISSED_KIND_LABEL[day.kind]; +} + +/** Выбранные дни одной строкой — для колонки SkudMissedDates (читается в представлениях SP). */ +export function formatMissedDaysSummary(days: IMissedDay[]): string { + return days.map((d) => `${formatShortDate(d.date)} (${formatMissedDetails(d)})`).join('; '); +} + +function reasonSentence(data: IFormData, dateWord: string): string { + if (data.Reason === OTHER_REASON) { + const details = data.ReasonDetails.trim() || BLANK; + return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по следующей причине: ${details}`; + } + const reason = SKUD_REASONS.find((r) => r.value === data.Reason); + return `Отсутствие отметки в системе СКУД в ${dateWord} произошло по причине ${reason ? reason.phrase : BLANK}.`; +} + +/** Абзацы тела записки (без шапки и подписи). */ +export function buildNoteParagraphs(data: IFormData): string[] { + const dates = data.MissedDays.length > 0 ? joinWithAnd(data.MissedDays.map((d) => formatLongDate(d.date))) : BLANK; + const dateWord = data.MissedDays.length > 1 ? 'указанные даты' : 'указанную дату'; + const name = shortFio(data.EmployeeFio) || BLANK; + + const second = data.PresenceConfirmed + ? `${reasonSentence(data, dateWord)} Факт моего нахождения на рабочем месте и исполнения трудовых обязанностей в течение рабочего дня подтверждаю.` + : reasonSentence(data, dateWord); + + return [ + `Я, ${name}, по факту отсутствия отметок в системе СКУД ${dates} сообщаю следующее:`, + second, + 'В дальнейшем обязуюсь соблюдать установленный порядок фиксации рабочего времени и не допускать подобных случаев.' + ]; +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/provisioning.json b/src/webparts/requestPortal/forms/SkudExplanation/provisioning.json new file mode 100644 index 0000000..eef721c --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/provisioning.json @@ -0,0 +1,18 @@ +{ + "dataListTitle": "Объяснительная по СКУД — данные", + "columns": [ + { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + + { "internalName": "SubmitDate", "title": "Дата подачи", "type": "DateTime", "dateOnly": true }, + { "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, + { "internalName": "SkudEmployeeFio", "title": "Ф.И.О. сотрудника", "type": "Text" }, + { "internalName": "Position", "title": "Должность", "type": "Text" }, + { "internalName": "Organization", "title": "Организация", "type": "Note" }, + { "internalName": "SkudMissedDates", "title": "Дни без отметок", "type": "Note" }, + { "internalName": "SkudMissedDaysJson", "title": "Дни без отметок (JSON)", "type": "Note" }, + { "internalName": "SkudReason", "title": "Причина", "type": "Choice", "choices": ["Невнимательность", "Забыл(а) пропуск", "Неисправность турникета или считывателя", "Другое"] }, + { "internalName": "SkudReasonDetails", "title": "Причина (подробно)", "type": "Note" }, + { "internalName": "SkudPresenceConfirmed", "title": "Присутствие подтверждено", "type": "Boolean" } + ] +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/sections/DaysSection.module.scss b/src/webparts/requestPortal/forms/SkudExplanation/sections/DaysSection.module.scss new file mode 100644 index 0000000..fd9d594 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/sections/DaysSection.module.scss @@ -0,0 +1,21 @@ +@import '../../../../../styles/design-tokens'; + +// Тело CollapseSection — сетка в 2 колонки под FormField; календарь и список +// дней не поля, им нужна вся ширина. +.fullRow { + grid-column: 1 / -1; + min-width: 0; +} + +.calendar { + display: flex; + justify-content: center; + margin-bottom: $sp-4; +} + +.label { + margin-bottom: $sp-2; + font-size: $fs-label; + font-weight: 500; + color: $gray-700; +} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/sections/DaysSection.tsx b/src/webparts/requestPortal/forms/SkudExplanation/sections/DaysSection.tsx new file mode 100644 index 0000000..014bd43 --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/sections/DaysSection.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { CollapseSection, useFormReadOnly } from '../../../shared/ui'; +import { IMissedDay, ISectionProps } from '../model'; +import { SkudCalendar } from '../components/SkudCalendar'; +import { SelectedDaysList } from '../components/SelectedDaysList'; +import { ISubmittedDay } from '../submittedDays'; +import styles from './DaysSection.module.scss'; + +/** Клик по уже выбранному дню снимает выбор, по новому — добавляет; порядок — по дате. */ +function toggleDay(days: IMissedDay[], day: IMissedDay): IMissedDay[] { + const exists = days.some((d) => d.date === day.date); + if (exists) return days.filter((d) => d.date !== day.date); + return [...days, day].sort((a, b) => a.date.localeCompare(b.date)); +} + +interface IDaysSectionProps extends ISectionProps { + /** Дни, уже закрытые поданными объяснительными; нужны только форме подачи. */ + submittedDays?: Map; +} + +const NO_SUBMITTED_DAYS = new Map(); + +/** + * Дни без отметок. В редактируемой заявке — календарь СКУД с выбором; в + * режиме чтения календарь не показываем: он строится по СКУД ТЕКУЩЕГО + * пользователя, а согласующему нужен сохранённый снимок дней заявителя. + */ +export const DaysSection: React.FC = ({ data, set, context, submittedDays }) => { + const readOnly = useFormReadOnly(); + const selectedDates = data.MissedDays.map((d) => d.date); + const removeDay = (date: string): void => set('MissedDays', data.MissedDays.filter((d) => d.date !== date)); + + return ( + +
+ {!readOnly && ( +
+ set('MissedDays', toggleDay(data.MissedDays, day))} + /> +
+ )} +
Выбрано дней: {data.MissedDays.length}
+ +
+
+ ); +}; diff --git a/src/webparts/requestPortal/forms/SkudExplanation/sections/EmployeeSection.tsx b/src/webparts/requestPortal/forms/SkudExplanation/sections/EmployeeSection.tsx new file mode 100644 index 0000000..942629b --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/sections/EmployeeSection.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; +import { ISectionProps } from '../model'; + +/** + * Сотрудник — всегда текущий пользователь (СКУД отдаёт только свои проходы), + * поэтому поле «Сотрудник» не редактируется даже в новой заявке. Дата подачи + * тоже не редактируется — её ставит форма. + */ +export const EmployeeSection: React.FC = ({ data, set, context, printOrgIndex }) => ( + + + set('Employee', v)} readOnly /> + + {/* Дата подачи — всегда сегодняшняя (ставит форма), руками не меняется. */} + + set('SubmitDate', v)} readOnly /> + + + set('Position', v)} /> + + + +); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/sections/ExplanationSection.tsx b/src/webparts/requestPortal/forms/SkudExplanation/sections/ExplanationSection.tsx new file mode 100644 index 0000000..39a5f5e --- /dev/null +++ b/src/webparts/requestPortal/forms/SkudExplanation/sections/ExplanationSection.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { Checkbox, CollapseSection, FormField, Select, TextArea } from '../../../shared/ui'; +import { ISectionProps, OTHER_REASON, PRESENCE_CONFIRMATION_LABEL, SKUD_REASONS } from '../model'; + +const REASON_OPTIONS = SKUD_REASONS.map((r) => ({ value: r.value, label: r.value })); + +/** + * Причина отсутствия отметки + подтверждение присутствия на рабочем месте. + * Поля кладём прямо в CollapseSection: её тело уже сетка полей, лишний + * FormGrid внутри сжал бы всё в половину ширины. + */ +export const ExplanationSection: React.FC = ({ data, set }) => ( + + +