From bfb3eb7b75518926f4c54625ab23ed8e8fd0f9ce 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: Fri, 16 Oct 2026 09:48:06 +0500 Subject: [PATCH] wip: add report form page (not wired yet) Ported stroy-bot-report form saved before tests are written. Not reachable from the app until save/mode tests cover the delete-and- recreate algorithm. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../stroyBot/pages/report/BackLink.tsx | 18 + .../stroyBot/pages/report/PickerField.tsx | 23 ++ .../stroyBot/pages/report/ReportActions.tsx | 38 +++ .../stroyBot/pages/report/ReportForm.tsx | 134 ++++++++ .../stroyBot/pages/report/ReportHeader.tsx | 27 ++ .../pages/report/ReportMainFields.tsx | 83 +++++ .../stroyBot/pages/report/ReportMessages.tsx | 29 ++ .../pages/report/ReportPage.module.scss | 301 +++++++++++++++++ .../stroyBot/pages/report/ReportPage.tsx | 14 + .../pages/report/dataverseReportRepository.ts | 286 ++++++++++++++++ src/webparts/stroyBot/pages/report/goBack.ts | 15 + .../stroyBot/pages/report/photoFiles.ts | 22 ++ .../pages/report/picker/Picker.module.scss | 242 +++++++++++++ .../pages/report/picker/PickerBreadcrumbs.tsx | 35 ++ .../pages/report/picker/PickerCreateForm.tsx | 81 +++++ .../report/picker/PickerCreateSection.tsx | 42 +++ .../pages/report/picker/PickerDrillList.tsx | 55 +++ .../pages/report/picker/PickerGroupFilter.tsx | 49 +++ .../pages/report/picker/PickerListItem.tsx | 26 ++ .../pages/report/picker/PickerSheet.tsx | 108 ++++++ .../pages/report/picker/pickerRules.ts | 73 ++++ .../pages/report/picker/pickerTypes.ts | 47 +++ .../pages/report/picker/usePickerCreate.ts | 74 ++++ .../pages/report/reportCollections.ts | 113 +++++++ .../stroyBot/pages/report/reportConstants.ts | 22 ++ .../pages/report/reportFormUpdates.ts | 92 +++++ .../stroyBot/pages/report/reportLoader.ts | 160 +++++++++ .../stroyBot/pages/report/reportPayload.ts | 53 +++ .../stroyBot/pages/report/reportPicker.ts | 44 +++ .../stroyBot/pages/report/reportRules.ts | 157 +++++++++ .../stroyBot/pages/report/reportSave.ts | 34 ++ .../pages/report/reportSectionHandlers.ts | 60 ++++ .../stroyBot/pages/report/reportServices.ts | 23 ++ .../stroyBot/pages/report/reportTypes.ts | 215 ++++++++++++ .../stroyBot/pages/report/reportValidation.ts | 37 ++ .../pages/report/sections/CommentSection.tsx | 25 ++ .../report/sections/CountHoursFields.tsx | 49 +++ .../pages/report/sections/EditableComment.tsx | 54 +++ .../report/sections/NestedResourceGroup.tsx | 60 ++++ .../pages/report/sections/PhotoSection.tsx | 80 +++++ .../pages/report/sections/PlanFact.tsx | 23 ++ .../pages/report/sections/ResourceCard.tsx | 54 +++ .../pages/report/sections/ResourceSection.tsx | 65 ++++ .../pages/report/sections/SectionTitle.tsx | 16 + .../pages/report/sections/WorkCard.tsx | 82 +++++ .../pages/report/sections/WorkSection.tsx | 43 +++ .../pages/report/sections/WorkValueInput.tsx | 50 +++ .../report/sharePointReportRepository.ts | 318 ++++++++++++++++++ src/webparts/stroyBot/pages/report/texts.ts | 133 ++++++++ .../stroyBot/pages/report/usePickerModal.ts | 151 +++++++++ .../stroyBot/pages/report/useReportForm.ts | 193 +++++++++++ 51 files changed, 4228 insertions(+) create mode 100644 src/webparts/stroyBot/pages/report/BackLink.tsx create mode 100644 src/webparts/stroyBot/pages/report/PickerField.tsx create mode 100644 src/webparts/stroyBot/pages/report/ReportActions.tsx create mode 100644 src/webparts/stroyBot/pages/report/ReportForm.tsx create mode 100644 src/webparts/stroyBot/pages/report/ReportHeader.tsx create mode 100644 src/webparts/stroyBot/pages/report/ReportMainFields.tsx create mode 100644 src/webparts/stroyBot/pages/report/ReportMessages.tsx create mode 100644 src/webparts/stroyBot/pages/report/ReportPage.module.scss create mode 100644 src/webparts/stroyBot/pages/report/ReportPage.tsx create mode 100644 src/webparts/stroyBot/pages/report/dataverseReportRepository.ts create mode 100644 src/webparts/stroyBot/pages/report/goBack.ts create mode 100644 src/webparts/stroyBot/pages/report/photoFiles.ts create mode 100644 src/webparts/stroyBot/pages/report/picker/Picker.module.scss create mode 100644 src/webparts/stroyBot/pages/report/picker/PickerBreadcrumbs.tsx create mode 100644 src/webparts/stroyBot/pages/report/picker/PickerCreateForm.tsx create mode 100644 src/webparts/stroyBot/pages/report/picker/PickerCreateSection.tsx create mode 100644 src/webparts/stroyBot/pages/report/picker/PickerDrillList.tsx create mode 100644 src/webparts/stroyBot/pages/report/picker/PickerGroupFilter.tsx create mode 100644 src/webparts/stroyBot/pages/report/picker/PickerListItem.tsx create mode 100644 src/webparts/stroyBot/pages/report/picker/PickerSheet.tsx create mode 100644 src/webparts/stroyBot/pages/report/picker/pickerRules.ts create mode 100644 src/webparts/stroyBot/pages/report/picker/pickerTypes.ts create mode 100644 src/webparts/stroyBot/pages/report/picker/usePickerCreate.ts create mode 100644 src/webparts/stroyBot/pages/report/reportCollections.ts create mode 100644 src/webparts/stroyBot/pages/report/reportConstants.ts create mode 100644 src/webparts/stroyBot/pages/report/reportFormUpdates.ts create mode 100644 src/webparts/stroyBot/pages/report/reportLoader.ts create mode 100644 src/webparts/stroyBot/pages/report/reportPayload.ts create mode 100644 src/webparts/stroyBot/pages/report/reportPicker.ts create mode 100644 src/webparts/stroyBot/pages/report/reportRules.ts create mode 100644 src/webparts/stroyBot/pages/report/reportSave.ts create mode 100644 src/webparts/stroyBot/pages/report/reportSectionHandlers.ts create mode 100644 src/webparts/stroyBot/pages/report/reportServices.ts create mode 100644 src/webparts/stroyBot/pages/report/reportTypes.ts create mode 100644 src/webparts/stroyBot/pages/report/reportValidation.ts create mode 100644 src/webparts/stroyBot/pages/report/sections/CommentSection.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/CountHoursFields.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/EditableComment.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/NestedResourceGroup.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/PhotoSection.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/PlanFact.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/ResourceCard.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/ResourceSection.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/SectionTitle.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/WorkCard.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/WorkSection.tsx create mode 100644 src/webparts/stroyBot/pages/report/sections/WorkValueInput.tsx create mode 100644 src/webparts/stroyBot/pages/report/sharePointReportRepository.ts create mode 100644 src/webparts/stroyBot/pages/report/texts.ts create mode 100644 src/webparts/stroyBot/pages/report/usePickerModal.ts create mode 100644 src/webparts/stroyBot/pages/report/useReportForm.ts diff --git a/src/webparts/stroyBot/pages/report/BackLink.tsx b/src/webparts/stroyBot/pages/report/BackLink.tsx new file mode 100644 index 0000000..e643b1c --- /dev/null +++ b/src/webparts/stroyBot/pages/report/BackLink.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { goBack } from './goBack'; +import { REPORT_TEXTS } from './texts'; +import styles from './ReportPage.module.scss'; + +/** «← Вернуться назад» — назад по истории браузера, как в старой форме. */ +export const BackLink: React.FC = () => ( + { + event.preventDefault(); + goBack(); + }} + > + {REPORT_TEXTS.back} + +); diff --git a/src/webparts/stroyBot/pages/report/PickerField.tsx b/src/webparts/stroyBot/pages/report/PickerField.tsx new file mode 100644 index 0000000..23fafb3 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/PickerField.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import styles from './ReportPage.module.scss'; + +interface Props { + id: string; + value: string; + placeholder: string; + readOnly: boolean; + onOpen: () => void; +} + +/** Поле-кнопка: показывает выбранное значение и открывает пикер. */ +export const PickerField: React.FC = ({ id, value, placeholder, readOnly, onOpen }) => ( + +); diff --git a/src/webparts/stroyBot/pages/report/ReportActions.tsx b/src/webparts/stroyBot/pages/report/ReportActions.tsx new file mode 100644 index 0000000..433fe20 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/ReportActions.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { Button } from '@tnsu/ui-kit'; +import { BackLink } from './BackLink'; +import { REPORT_TEXTS } from './texts'; +import styles from './ReportPage.module.scss'; + +const TEXTS = REPORT_TEXTS.actions; + +/** Какие кнопки показать — решают правила (reportRules), компонент только рисует. */ +export interface IReportActionsView { + showSave: boolean; + showBackAfterSave: boolean; + showApprove: boolean; +} + +interface Props { + view: IReportActionsView; + isSaving: boolean; + onSave: () => void; + onApprove: () => void; +} + +/** Низ формы: «Сохранить отчет» → после сохранения «← Вернуться назад»; в режиме утверждения — «Утвердить отчет». */ +export const ReportActions: React.FC = ({ view, isSaving, onSave, onApprove }) => ( +
+ {view.showSave && ( + + )} + {view.showBackAfterSave && } + {view.showApprove && ( + + )} +
+); diff --git a/src/webparts/stroyBot/pages/report/ReportForm.tsx b/src/webparts/stroyBot/pages/report/ReportForm.tsx new file mode 100644 index 0000000..a2d6ec4 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/ReportForm.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Spinner } from '@tnsu/ui-kit'; +import { useAppServices } from '../../app/AppServicesContext'; +import { EmptyState } from '../../shared/components/EmptyState'; +import { IReportRouteParams } from './reportTypes'; +import { createReportServices } from './reportServices'; +import { canShowBackAfterSave, canShowSaveButton, getStatusBadge } from './reportRules'; +import { useReportForm } from './useReportForm'; +import { usePickerModal } from './usePickerModal'; +import { createSectionHandlers } from './reportSectionHandlers'; +import { BackLink } from './BackLink'; +import { ReportHeader } from './ReportHeader'; +import { ReportMessages } from './ReportMessages'; +import { ReportMainFields } from './ReportMainFields'; +import { ReportActions } from './ReportActions'; +import { WorkSection } from './sections/WorkSection'; +import { ResourceSection } from './sections/ResourceSection'; +import { CommentSection } from './sections/CommentSection'; +import { PhotoSection } from './sections/PhotoSection'; +import { PickerSheet } from './picker/PickerSheet'; +import { REPORT_TEXTS } from './texts'; +import styles from './ReportPage.module.scss'; + +interface Props { + params: IReportRouteParams; +} + +/** + * Страница SharePoint — это форма: Enter в поле ввода отправил бы её. Глушим Enter везде, + * кроме многострочных полей, и само событие submit — как в старом web part. + */ +const preventEnterSubmit = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && (event.target as HTMLElement).tagName !== 'TEXTAREA') { + event.preventDefault(); + } +}; +const preventSubmit = (event: React.FormEvent): void => event.preventDefault(); + +/** Форма отчёта: собирает секции; состояние — в useReportForm, пикер — в usePickerModal. */ +export const ReportForm: React.FC = ({ params }) => { + const appServices = useAppServices(); + const services = React.useMemo(() => createReportServices(appServices), [appServices]); + const report = useReportForm(services, params); + const picker = usePickerModal({ services, report, projectId: params.projectId }); + const handlers = createSectionHandlers(report, picker); + const { form, mode, isReadOnly } = report; + + if (report.isLoading) { + return ; + } + if (mode === 'noaccess') { + return ; + } + + const isSaved = !!report.successMessage; + const runAsync = (action: () => Promise) => (): void => { + action().catch(() => undefined); + }; + + return ( +
+ + + + + + + handlers.addResource({ kind: 'profession' })} + onChange={(itemId, patch) => handlers.changeResource({ kind: 'profession' }, itemId, patch)} + onRemove={(itemId) => handlers.removeResource({ kind: 'profession' }, itemId)} + /> + handlers.addResource({ kind: 'technic' })} + onChange={(itemId, patch) => handlers.changeResource({ kind: 'technic' }, itemId, patch)} + onRemove={(itemId) => handlers.removeResource({ kind: 'technic' }, itemId)} + /> + + + + {/* Внизу формы ошибки валидации и успех повторяются — как в старой форме (ошибка запроса — только вверху). */} + + + + {picker.isOpen && picker.options && ( + + )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/report/ReportHeader.tsx b/src/webparts/stroyBot/pages/report/ReportHeader.tsx new file mode 100644 index 0000000..1b853bf --- /dev/null +++ b/src/webparts/stroyBot/pages/report/ReportHeader.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Badge } from '@tnsu/ui-kit'; +import { ReportStatusBadge } from './reportRules'; +import { REPORT_TEXTS } from './texts'; +import styles from './ReportPage.module.scss'; + +const TEXTS = REPORT_TEXTS.header; + +interface Props { + reportNumber: number; + projectName: string; + status: ReportStatusBadge | undefined; +} + +/** «Отчет №N» / «Новый отчет», «Проект: …» и бейдж статуса. */ +export const ReportHeader: React.FC = ({ reportNumber, projectName, status }) => ( +
+

{reportNumber > 0 ? TEXTS.reportNumber(reportNumber) : TEXTS.newReport}

+
+ + {TEXTS.project} {projectName} + + {status === 'approved' && {TEXTS.approved}} + {status === 'pending' && {TEXTS.pending}} +
+
+); diff --git a/src/webparts/stroyBot/pages/report/ReportMainFields.tsx b/src/webparts/stroyBot/pages/report/ReportMainFields.tsx new file mode 100644 index 0000000..5379b1b --- /dev/null +++ b/src/webparts/stroyBot/pages/report/ReportMainFields.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { FormField, Select, TextInput } from '@tnsu/ui-kit'; +import { IShift } from './reportTypes'; +import { PickerField } from './PickerField'; +import { REPORT_TEXTS } from './texts'; +import styles from './ReportPage.module.scss'; + +const TEXTS = REPORT_TEXTS.fields; + +const FIELD_IDS = { + reportDate: 'report-date', + shift: 'report-shift', + object: 'report-object', + company: 'report-company' +} as const; + +/** Значения шапки отчёта — срез состояния формы. */ +export interface IMainFieldValues { + reportDate: string; + shiftId: number | undefined; + objectPath: string; + companyName: string; +} + +interface Props { + values: IMainFieldValues; + shifts: IShift[]; + readOnly: boolean; + onDateChange: (value: string) => void; + onShiftChange: (value: string) => void; + onOpenObject: () => void; + onOpenCompany: () => void; +} + +/** Дата отчета, Смена, Объект, Компания. */ +export const ReportMainFields: React.FC = (props) => { + const { values, shifts, readOnly } = props; + const shiftOptions = shifts.map((shift) => ({ value: String(shift.Id), label: shift.Title })); + + return ( +
+ + + + + + + ) + )} + + )} + {errorMessage &&
{errorMessage}
} +
+ + +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/report/picker/PickerCreateSection.tsx b/src/webparts/stroyBot/pages/report/picker/PickerCreateSection.tsx new file mode 100644 index 0000000..0fb2f16 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/PickerCreateSection.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { Button } from '@tnsu/ui-kit'; +import { IDrillLevel, IPickerCreateData, IPickerItem } from './pickerTypes'; +import { createButtonLabel, currentParentId } from './pickerRules'; +import { usePickerCreate } from './usePickerCreate'; +import { PickerCreateForm } from './PickerCreateForm'; +import styles from './Picker.module.scss'; + +interface Props { + items: IPickerItem[]; + search: string; + drillDown: boolean; + drillPath: IDrillLevel[]; + requiresUnit: boolean; + onCreateNew: (data: IPickerCreateData) => Promise; + onSelect: (item: IPickerItem) => void; +} + +/** Низ пикера: кнопка «+ Создать новую позицию» / «+ Добавить в «…»» или форма создания. */ +export const PickerCreateSection: React.FC = (props) => { + const { items, search, drillDown, drillPath, requiresUnit } = props; + const create = usePickerCreate(props.onCreateNew, props.onSelect); + const parentId = drillDown ? currentParentId(drillPath) : undefined; + const currentLevel = drillPath[drillPath.length - 1]; + + return ( +
+ {create.isFormOpen ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/report/picker/PickerDrillList.tsx b/src/webparts/stroyBot/pages/report/picker/PickerDrillList.tsx new file mode 100644 index 0000000..3b817f6 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/PickerDrillList.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { IDrillLevel, IPickerItem } from './pickerTypes'; +import { currentParentId, drillLevelItems, hasChildren } from './pickerRules'; +import { PickerListItem } from './PickerListItem'; +import { REPORT_TEXTS } from '../texts'; +import styles from './Picker.module.scss'; + +interface Props { + items: IPickerItem[]; + drillPath: IDrillLevel[]; + onSelect: (item: IPickerItem) => void; + onDrillInto: (item: IPickerItem) => void; +} + +/** Работы по уровням: работа с вложенными открывает уровень, без вложенных — выбирается. */ +export const PickerDrillList: React.FC = ({ items, drillPath, onSelect, onDrillInto }) => { + const parentId = currentParentId(drillPath); + const levelItems = drillLevelItems(items, parentId); + const currentLevel = drillPath[drillPath.length - 1]; + + const selectCurrentLevel = (): void => { + const currentItem = items.filter((item) => String(item.id) === currentLevel.id)[0]; + if (currentItem) onSelect(currentItem); + }; + + return ( + <> + {currentLevel && ( + + )} + + {levelItems.length === 0 ? ( +
{REPORT_TEXTS.picker.emptyLevel}
+ ) : ( + levelItems.map((item) => { + const isParent = hasChildren(items, item.id); + return ( + (isParent ? onDrillInto(item) : onSelect(item))} + /> + ); + }) + )} + + ); +}; diff --git a/src/webparts/stroyBot/pages/report/picker/PickerGroupFilter.tsx b/src/webparts/stroyBot/pages/report/picker/PickerGroupFilter.tsx new file mode 100644 index 0000000..df2a07b --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/PickerGroupFilter.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { REPORT_TEXTS } from '../texts'; +import styles from './Picker.module.scss'; + +interface Props { + groups: string[]; + selectedGroup: string | undefined; + onSelectGroup: (group: string | undefined) => void; +} + +/** Фильтр по группам: кнопка «Фильтры ▼», сброс выбранной группы «X ✕», чипы «Все» и групп. */ +export const PickerGroupFilter: React.FC = ({ groups, selectedGroup, onSelectGroup }) => { + const [isOpen, setIsOpen] = React.useState(false); + const chipClass = (isActive: boolean): string => (isActive ? `${styles.chip} ${styles.chipActive}` : styles.chip); + + return ( + <> +
+ + {selectedGroup && ( + + )} +
+ + {isOpen && ( +
+ + {groups.map((group) => ( + + ))} +
+ )} + + ); +}; diff --git a/src/webparts/stroyBot/pages/report/picker/PickerListItem.tsx b/src/webparts/stroyBot/pages/report/picker/PickerListItem.tsx new file mode 100644 index 0000000..be70a86 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/PickerListItem.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import styles from './Picker.module.scss'; + +interface Props { + title: string; + subtitle?: string; + /** Знак справа: «›» — есть вложенные, «✓» — выбрать текущий уровень. */ + mark?: string; + isSelectCurrent?: boolean; + onClick: () => void; +} + +/** Строка списка пикера. */ +export const PickerListItem: React.FC = ({ title, subtitle, mark, isSelectCurrent, onClick }) => ( + +); diff --git a/src/webparts/stroyBot/pages/report/picker/PickerSheet.tsx b/src/webparts/stroyBot/pages/report/picker/PickerSheet.tsx new file mode 100644 index 0000000..8abf3f5 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/PickerSheet.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { IconButton, TextInput } from '@tnsu/ui-kit'; +import { IDrillLevel, IPickerCreateData, IPickerItem, IPickerOptions } from './pickerTypes'; +import { collectGroups, filterPickerItems } from './pickerRules'; +import { PickerBreadcrumbs } from './PickerBreadcrumbs'; +import { PickerGroupFilter } from './PickerGroupFilter'; +import { PickerDrillList } from './PickerDrillList'; +import { PickerListItem } from './PickerListItem'; +import { PickerCreateSection } from './PickerCreateSection'; +import { REPORT_TEXTS } from '../texts'; +import styles from './Picker.module.scss'; + +const TEXTS = REPORT_TEXTS.picker; + +interface Props { + options: IPickerOptions; + items: IPickerItem[]; + isLoading: boolean; + onSelect: (item: IPickerItem) => void; + onDismiss: () => void; + onCreateNew: (data: IPickerCreateData) => Promise; +} + +/** + * Нижняя шторка выбора из справочника (бывший PickerModal): поиск, чипы групп, drill-down по работам, + * создание новой позиции. Монтируется при каждом открытии — состояние поиска и фильтров сбрасывается. + * Рендерится в body, чтобы position: fixed не зависел от контейнеров страницы SharePoint. + */ +export const PickerSheet: React.FC = ({ options, items, isLoading, onSelect, onDismiss, onCreateNew }) => { + const [search, setSearch] = React.useState(''); + const [selectedGroup, setSelectedGroup] = React.useState(undefined); + const [drillPath, setDrillPath] = React.useState([]); + + const isDrillMode = options.drillDown && !search; + const groups = options.groupFilter ? collectGroups(items) : []; + + const drillInto = (item: IPickerItem): void => { + setDrillPath((prev) => [...prev, { id: String(item.id), title: item.title }]); + setSearch(''); + }; + + const renderList = (): React.ReactNode => { + if (isLoading) return
{TEXTS.loading}
; + if (isDrillMode) { + return ; + } + const filtered = filterPickerItems(items, search, selectedGroup, options.drillDown); + if (filtered.length === 0) return
{TEXTS.notFound}
; + return filtered.map((item) => ( + onSelect(item)} + /> + )); + }; + + const sheet = ( +
+
event.stopPropagation()} + > +
+

{options.title}

+ +
+ +
+ event.stopPropagation()} + autoFocus + /> +
+ + {isDrillMode && setDrillPath((prev) => prev.slice(0, level))} />} + + {groups.length > 0 && ( + + )} + +
{renderList()}
+ + {options.allowCreate && !isLoading && ( + + )} +
+
+ ); + + return ReactDOM.createPortal(sheet, document.body); +}; diff --git a/src/webparts/stroyBot/pages/report/picker/pickerRules.ts b/src/webparts/stroyBot/pages/report/picker/pickerRules.ts new file mode 100644 index 0000000..eeebd42 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/pickerRules.ts @@ -0,0 +1,73 @@ +import { IDrillLevel, IPickerCreateData, IPickerCreateDraft, IPickerItem } from './pickerTypes'; +import { REPORT_TEXTS } from '../texts'; + +// Правила пикера — 1:1 с PickerModal.tsx. + +function containsIgnoreCase(text: string, query: string): boolean { + return text.toLowerCase().indexOf(query.toLowerCase()) !== -1; +} + +/** Группы для чипов: непустые subtitle в порядке появления, без повторов. */ +export function collectGroups(items: IPickerItem[]): string[] { + const groups: string[] = []; + items.forEach((item) => { + if (item.subtitle && groups.indexOf(item.subtitle) === -1) groups.push(item.subtitle); + }); + return groups; +} + +/** + * Плоский список / результаты поиска. + * В drill-down с поиском ищем по полному пути и subtitle (без фильтра групп); + * иначе — по названию и subtitle с фильтром по выбранной группе. + */ +export function filterPickerItems( + items: IPickerItem[], + search: string, + selectedGroup: string | undefined, + drillDown: boolean +): IPickerItem[] { + if (drillDown && search) { + return items.filter( + (item) => containsIgnoreCase(item.fullPath || item.title, search) || containsIgnoreCase(item.subtitle || '', search) + ); + } + return items.filter((item) => { + const matchesSearch = + containsIgnoreCase(item.title || '', search) || (!!item.subtitle && containsIgnoreCase(item.subtitle, search)); + const matchesGroup = !selectedGroup || item.subtitle === selectedGroup; + return matchesSearch && matchesGroup; + }); +} + +/** Id родителя текущего уровня drill-down (undefined — корень). */ +export function currentParentId(drillPath: IDrillLevel[]): string | undefined { + return drillPath.length > 0 ? drillPath[drillPath.length - 1].id : undefined; +} + +/** Элементы текущего уровня: у кого родитель = текущий уровень (в корне — без родителя). */ +export function drillLevelItems(items: IPickerItem[], parentId: string | undefined): IPickerItem[] { + return items.filter((item) => item.parentId === parentId); +} + +export function hasChildren(items: IPickerItem[], itemId: number | string): boolean { + return items.some((item) => item.parentId === String(itemId)); +} + +export function createButtonLabel(drillDown: boolean, drillPath: IDrillLevel[]): string { + return drillDown && drillPath.length > 0 + ? REPORT_TEXTS.picker.addInto(drillPath[drillPath.length - 1].title) + : REPORT_TEXTS.picker.createNew; +} + +export const EMPTY_CREATE_DRAFT: IPickerCreateDraft = { title: '', unit: '', plan: '0', parentId: '' }; + +/** Черновик → данные создания: пустые значения не передаются, план — parseFloat. */ +export function toCreateData(draft: IPickerCreateDraft): IPickerCreateData { + return { + title: draft.title.trim(), + unit: draft.unit.trim() || undefined, + plan: draft.plan ? parseFloat(draft.plan) : undefined, + parentId: draft.parentId || undefined + }; +} diff --git a/src/webparts/stroyBot/pages/report/picker/pickerTypes.ts b/src/webparts/stroyBot/pages/report/picker/pickerTypes.ts new file mode 100644 index 0000000..8ff1c03 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/pickerTypes.ts @@ -0,0 +1,47 @@ +// Типы пикера (нижняя шторка выбора) — как в старом modals/PickerModal.tsx. + +export interface IPickerItem { + id: number | string; + title: string; + /** Подпись под названием: ед. изм. у работ, группа у техники. По ней же работают чипы-фильтры. */ + subtitle?: string; + /** Родитель (только для работ, drill-down). */ + parentId?: string; + /** Полный путь (только для работ) — показывается в поиске. */ + fullPath?: string; +} + +/** Данные формы «создать новую позицию». */ +export interface IPickerCreateData { + title: string; + unit?: string; + plan?: number; + parentId?: string; +} + +/** Что умеет пикер для конкретного справочника. */ +export interface IPickerOptions { + title: string; + /** Чипы-фильтры по subtitle (техника). */ + groupFilter: boolean; + /** Навигация по уровням с хлебными крошками (работы). */ + drillDown: boolean; + /** Кнопка «+ Создать новую позицию». */ + allowCreate: boolean; + /** В форме создания есть ед. изм., план и родитель (работы). */ + requiresUnit: boolean; +} + +/** Уровень drill-down: выбранная родительская работа. */ +export interface IDrillLevel { + id: string; + title: string; +} + +/** Черновик формы создания. План хранится строкой, как ввёл пользователь. */ +export interface IPickerCreateDraft { + title: string; + unit: string; + plan: string; + parentId: string; +} diff --git a/src/webparts/stroyBot/pages/report/picker/usePickerCreate.ts b/src/webparts/stroyBot/pages/report/picker/usePickerCreate.ts new file mode 100644 index 0000000..469a18c --- /dev/null +++ b/src/webparts/stroyBot/pages/report/picker/usePickerCreate.ts @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { IPickerCreateData, IPickerCreateDraft, IPickerItem } from './pickerTypes'; +import { EMPTY_CREATE_DRAFT, toCreateData } from './pickerRules'; + +export interface IPickerCreateState { + isFormOpen: boolean; + draft: IPickerCreateDraft; + isCreating: boolean; + errorMessage: string; + open: (search: string, parentId: string | undefined) => void; + cancel: () => void; + changeDraft: (patch: Partial) => void; + submit: () => Promise; +} + +/** + * Форма «создать новую позицию» в пикере. Как в старом коде: при открытии название берётся из поиска, + * а родитель — из текущего уровня drill-down; остальные поля черновика сохраняются между открытиями. + * Созданная позиция сразу выбирается. + */ +export function usePickerCreate( + onCreateNew: (data: IPickerCreateData) => Promise, + onSelect: (item: IPickerItem) => void +): IPickerCreateState { + const [isFormOpen, setIsFormOpen] = React.useState(false); + const [draft, setDraft] = React.useState(EMPTY_CREATE_DRAFT); + const [isCreating, setIsCreating] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(''); + + // Выбор созданной позиции закрывает пикер — после этого состояние уже не обновляем. + const isMountedRef = React.useRef(true); + React.useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const open = (search: string, parentId: string | undefined): void => { + setIsFormOpen(true); + setDraft((prev) => ({ ...prev, title: search, parentId: parentId ?? prev.parentId })); + setErrorMessage(''); + }; + + const cancel = (): void => { + setIsFormOpen(false); + setErrorMessage(''); + }; + + const submit = async (): Promise => { + if (!draft.title.trim()) return; + setIsCreating(true); + setErrorMessage(''); + try { + const item = await onCreateNew(toCreateData(draft)); + onSelect(item); + } catch (error) { + if (isMountedRef.current) setErrorMessage(error instanceof Error ? error.message : String(error)); + } finally { + if (isMountedRef.current) setIsCreating(false); + } + }; + + return { + isFormOpen, + draft, + isCreating, + errorMessage, + open, + cancel, + changeDraft: (patch) => setDraft((prev) => ({ ...prev, ...patch })), + submit + }; +} diff --git a/src/webparts/stroyBot/pages/report/reportCollections.ts b/src/webparts/stroyBot/pages/report/reportCollections.ts new file mode 100644 index 0000000..3f62a6e --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportCollections.ts @@ -0,0 +1,113 @@ +import { IJobWithPath, IReportDataRow, IResourceItem, IWorkItem } from './reportTypes'; +import { HOURS_TYPES } from './reportConstants'; +import { createLocalId } from './reportFormUpdates'; +import { IReportServices } from './reportServices'; + +// Разбор строк сохранённого отчёта в работы / персонал / технику формы — как buildFormCollections. + +export interface IFormCollections { + works: IWorkItem[]; + professions: IResourceItem[]; + technics: IResourceItem[]; +} + +function isWorkRow(row: IReportDataRow): boolean { + return !!row._cree9_jobid_value && !row._cree9_parentid_value; +} + +function workFromRow(row: IReportDataRow, jobs: IJobWithPath[]): IWorkItem { + const job = jobs.filter((candidate) => candidate.cree9_tableid === row._cree9_jobid_value)[0]; + return { + id: createLocalId(), + jobId: row._cree9_jobid_value || '', + jobTitle: job?.cree9_title || '', + jobUnit: job?.cree9_unit || '', + value: row.cree9_value || 0, + plan: job?.cree9_volume || 0, + totalFact: 0, + comment: row.cree9_comment || '', + professions: [], + technics: [] + }; +} + +/** Строка персонала / техники. Название — отдельным запросом; ошибка не мешает (название пустое). */ +async function resourceFromRow( + row: IReportDataRow, + rawId: number | string | undefined, + loadTitle: (id: number) => Promise<{ Title?: string }> +): Promise { + const resourceId = Number(rawId); + let title = ''; + try { + title = (await loadTitle(resourceId))?.Title || ''; + } catch { + // Как в старом коде: элемент справочника удалён или нет доступа — без названия. + } + // Старый бот писал название в комментарий — такой комментарий не показываем. + const comment = row.cree9_comment && row.cree9_comment !== title ? row.cree9_comment : ''; + return { + id: createLocalId(), + resourceId, + title, + count: row.cree9_count || 0, + hours: row.cree9_hours || 0, + hoursType: row.cree9_hourstype || HOURS_TYPES.work, + comment + }; +} + +async function loadJobsSafe(services: IReportServices, objectId: number): Promise { + try { + return await services.dv.getJobsByObjectId(objectId); + } catch { + return []; + } +} + +async function fillTotalFacts(services: IReportServices, works: IWorkItem[]): Promise { + return Promise.all( + works.map(async (work) => { + try { + return { ...work, totalFact: await services.dv.getJobTotalFact(work.jobId) }; + } catch { + return work; + } + }) + ); +} + +/** + * Строки-работы (есть работа, нет родителя) становятся работами; персонал / техника с родителем + * прикрепляются к своей работе, без родителя — попадают в общие секции. Названия грузятся по одному, + * в порядке строк; затем факт по каждой работе — параллельно. + */ +export async function buildFormCollections( + services: IReportServices, + rows: IReportDataRow[], + objectId: number +): Promise { + const jobs = await loadJobsSafe(services, objectId); + const works: IWorkItem[] = []; + const workByRowId = new Map(); + rows.filter(isWorkRow).forEach((row) => { + const work = workFromRow(row, jobs); + works.push(work); + if (row.cree9_report_dataid) workByRowId.set(row.cree9_report_dataid, work); + }); + + const professions: IResourceItem[] = []; + const technics: IResourceItem[] = []; + for (const row of rows.filter((candidate) => !isWorkRow(candidate))) { + const parent = row._cree9_parentid_value ? workByRowId.get(row._cree9_parentid_value) : undefined; + if (row.cree9_professionid) { + const item = await resourceFromRow(row, row.cree9_professionid, services.sp.getProfessionById); + (parent ? parent.professions : professions).push(item); + } else if (row.cree9_technicid) { + const item = await resourceFromRow(row, row.cree9_technicid, services.sp.getTechnicById); + (parent ? parent.technics : technics).push(item); + } + } + + return { works: await fillTotalFacts(services, works), professions, technics }; +} diff --git a/src/webparts/stroyBot/pages/report/reportConstants.ts b/src/webparts/stroyBot/pages/report/reportConstants.ts new file mode 100644 index 0000000..249fd30 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportConstants.ts @@ -0,0 +1,22 @@ +// Значения, которые хранятся в данных (Dataverse) и участвуют в логике формы отчёта. + +/** Тип часов персонала / техники — значение поля cree9_hourstype. */ +export const HOURS_TYPES = { + work: 'Часы работы', + idle: 'Часы простоя', + repair: 'Часы ремонта' +} as const; + +/** Варианты «Тип часов» у общего персонала. */ +export const PROFESSION_HOURS_TYPES: string[] = [HOURS_TYPES.work, HOURS_TYPES.idle]; + +/** Варианты «Тип часов» у общей техники. */ +export const TECHNIC_HOURS_TYPES: string[] = [HOURS_TYPES.work, HOURS_TYPES.idle, HOURS_TYPES.repair]; + +/** Сколько дней после даты отчёта его можно править, если в «Проекты» не задан editDays. */ +export const DEFAULT_EDIT_DAYS = 5; + +export const MS_PER_DAY = 1000 * 60 * 60 * 24; + +/** Значение reportId в адресе, которое старый код считал «нет отчёта». */ +export const EMPTY_REPORT_ID = 'null'; diff --git a/src/webparts/stroyBot/pages/report/reportFormUpdates.ts b/src/webparts/stroyBot/pages/report/reportFormUpdates.ts new file mode 100644 index 0000000..3034975 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportFormUpdates.ts @@ -0,0 +1,92 @@ +import { IReportFormState, IResourceItem, IWorkItem, ResourceKind } from './reportTypes'; +import { HOURS_TYPES } from './reportConstants'; + +// Иммутабельные изменения состояния формы. Хук useReportForm применяет их через setForm(prev => …). + +/** Пустая форма. Дата — сегодня (UTC, как в старом коде). */ +export function createEmptyForm(today: string): IReportFormState { + return { + objectId: undefined, + objectPath: '', + companyId: undefined, + companyName: '', + shiftId: undefined, + shiftName: '', + reportDate: today, + reportNumber: 0, + comment: '', + works: [], + professions: [], + technics: [], + photos: [], + photoFolderUrl: undefined + }; +} + +/** Локальный id строки формы (только для key и поиска строки, в данные не попадает). */ +export function createLocalId(): string { + return Math.random().toString(36).substring(2, 10); +} + +export function createResourceItem(resourceId: number, title: string): IResourceItem { + return { id: createLocalId(), resourceId, title, count: 0, hours: 0, hoursType: HOURS_TYPES.work, comment: '' }; +} + +const LIST_BY_KIND: Record = { + profession: 'professions', + technic: 'technics' +}; + +export function resourceListKey(kind: ResourceKind): 'professions' | 'technics' { + return LIST_BY_KIND[kind]; +} + +export function patchWork(form: IReportFormState, workId: string, patch: Partial): IReportFormState { + return { ...form, works: form.works.map((work) => (work.id === workId ? { ...work, ...patch } : work)) }; +} + +export function removeWork(form: IReportFormState, workId: string): IReportFormState { + return { ...form, works: form.works.filter((work) => work.id !== workId) }; +} + +export function addWork(form: IReportFormState, work: IWorkItem): IReportFormState { + return { ...form, works: [...form.works, work] }; +} + +/** Где лежит строка персонала / техники: в общей секции (workId не задан) или на работе. */ +export interface IResourceTarget { + kind: ResourceKind; + workId?: string; +} + +function mapResources( + form: IReportFormState, + target: IResourceTarget, + update: (items: IResourceItem[]) => IResourceItem[] +): IReportFormState { + const key = resourceListKey(target.kind); + if (!target.workId) { + return { ...form, [key]: update(form[key]) }; + } + return { + ...form, + works: form.works.map((work) => (work.id === target.workId ? { ...work, [key]: update(work[key]) } : work)) + }; +} + +export function addResource(form: IReportFormState, target: IResourceTarget, item: IResourceItem): IReportFormState { + return mapResources(form, target, (items) => [...items, item]); +} + +export function patchResource( + form: IReportFormState, + target: IResourceTarget, + itemId: string, + patch: Partial +): IReportFormState { + return mapResources(form, target, (items) => items.map((item) => (item.id === itemId ? { ...item, ...patch } : item))); +} + +export function removeResource(form: IReportFormState, target: IResourceTarget, itemId: string): IReportFormState { + return mapResources(form, target, (items) => items.filter((item) => item.id !== itemId)); +} diff --git a/src/webparts/stroyBot/pages/report/reportLoader.ts b/src/webparts/stroyBot/pages/report/reportLoader.ts new file mode 100644 index 0000000..4b4bcec --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportLoader.ts @@ -0,0 +1,160 @@ +import { + ICompany, + IObjectWithPath, + IReportAccess, + IReportFormState, + IReportLookups, + IReportRouteParams, + IReportRow, + IShift, + ReportFormMode +} from './reportTypes'; +import { DEFAULT_EDIT_DAYS } from './reportConstants'; +import { daysSinceReport, decideExistingReportMode, hasAnyAccess, todayIsoDate } from './reportRules'; +import { createEmptyForm } from './reportFormUpdates'; +import { buildFormCollections, IFormCollections } from './reportCollections'; +import { photoFolderLink } from './sharePointReportRepository'; +import { IReportServices } from './reportServices'; + +// Загрузка формы — как initializeForm / initNewReport / copyFromReport / loadExistingReport. +// Результаты отдаются по шагам через sink в том же порядке, что setState в старом компоненте: +// при ошибке посередине форма остаётся в том же частично загруженном состоянии, что и раньше. + +export interface IReportLoadSink { + setProjectName(name: string): void; + setMode(mode: ReportFormMode): void; + setAccess(access: IReportAccess | undefined): void; + setApproved(isApproved: boolean): void; + setLookups(lookups: Partial): void; + setForm(form: IReportFormState): void; +} + +export type ReportLoadOutcome = 'loaded' | 'missingProjectId'; + +interface ILoadContext { + services: IReportServices; + projectId: number; + projectName: string; + now: Date; + sink: IReportLoadSink; +} + +function formFromReport( + report: IReportRow, + lookups: { objects: IObjectWithPath[]; companies: ICompany[]; shifts: IShift[] }, + collections: IFormCollections +): IReportFormState { + const object = lookups.objects.filter((item) => item.Id === report.cree9_objectid)[0]; + const company = lookups.companies.filter((item) => item.Id === report.cree9_companyid)[0]; + const shift = lookups.shifts.filter((item) => item.Id === report.cree9_shiftid)[0]; + return { + objectId: report.cree9_objectid ?? undefined, + objectPath: object?.fullPath || '', + companyId: report.cree9_companyid ?? undefined, + companyName: company?.Title || '', + shiftId: report.cree9_shiftid ?? undefined, + shiftName: shift?.Title || '', + reportDate: report.cree9_report_date ? report.cree9_report_date.split('T')[0] : '', + reportNumber: report.cree9_number, + comment: report.cree9_comment || '', + ...collections, + photos: [], + photoFolderUrl: undefined + }; +} + +async function initNewReport({ services, projectId, now, sink }: ILoadContext): Promise { + sink.setMode('create'); + const [shifts, access] = await Promise.all([services.sp.getShifts(), services.sp.checkAccess(projectId)]); + // 🐞 Как в старом коде: для нового отчёта отсутствие прав не проверяется. + sink.setAccess(access); + sink.setLookups({ shifts }); + const defaultShift = shifts[0]; + sink.setForm({ + ...createEmptyForm(todayIsoDate(now)), + shiftId: defaultShift?.Id ?? undefined, + shiftName: defaultShift?.Title ?? '' + }); +} + +async function copyFromReport(context: ILoadContext, copyFromId: string): Promise { + const { services, projectId, projectName, now, sink } = context; + const [access, shifts] = await Promise.all([services.sp.checkAccess(projectId), services.sp.getShifts()]); + if (!hasAnyAccess(access)) { + sink.setMode('noaccess'); + return; + } + sink.setAccess(access); + sink.setMode('create'); + sink.setLookups({ shifts }); + + const report = await services.dv.getReportById(copyFromId); + const rows = await services.dv.getReportData(copyFromId); + const objects = await services.sp.getObjectsByProject(projectName); + sink.setLookups({ objects }); + const companies = await services.sp.getCompaniesWithFallback(projectId); + sink.setLookups({ companies }); + const collections = await buildFormCollections(services, rows, report.cree9_objectid); + + // Копия — новый отчёт: дата сегодня, номер не присвоен, фото не копируются. + sink.setForm({ + ...formFromReport(report, { objects, companies, shifts }, collections), + reportDate: todayIsoDate(now), + reportNumber: 0, + photoFolderUrl: '' + }); +} + +async function loadExistingReport(context: ILoadContext, reportId: string, editDays: number): Promise { + const { services, projectId, projectName, now, sink } = context; + const access = await services.sp.checkAccess(projectId); + if (!hasAnyAccess(access)) { + sink.setMode('noaccess'); + return; + } + sink.setAccess(access); + + const report = await services.dv.getReportById(reportId); + const rows = await services.dv.getReportData(reportId); + const isApproved = !!report.cree9_aprooved; + sink.setApproved(isApproved); + const daysSince = daysSinceReport(report.cree9_report_date, now.getTime()); + sink.setMode(decideExistingReportMode(access, isApproved, daysSince, editDays)); + + const objects = await services.sp.getObjectsByProject(projectName); + sink.setLookups({ objects }); + const companies = await services.sp.getCompaniesWithFallback(projectId); + sink.setLookups({ companies }); + const shifts = await services.sp.getShifts(); + sink.setLookups({ shifts }); + const collections = await buildFormCollections(services, rows, report.cree9_objectid); + + sink.setForm({ + ...formFromReport(report, { objects, companies, shifts }, collections), + photoFolderUrl: photoFolderLink(services.siteUrl, projectId, report.cree9_number) + }); +} + +/** Загружает форму по параметрам адреса. Ошибки запросов пробрасываются — их показывает хук. */ +export async function loadReportForm( + services: IReportServices, + params: IReportRouteParams, + now: Date, + sink: IReportLoadSink +): Promise { + if (!params.projectId) { + return 'missingProjectId'; + } + const project = await services.sp.getProjectById(params.projectId); + sink.setProjectName(project.Title); + const context: ILoadContext = { services, projectId: params.projectId, projectName: project.Title, now, sink }; + + if (params.reportId) { + await loadExistingReport(context, params.reportId, project.editDays ?? DEFAULT_EDIT_DAYS); + } else if (params.copyFromId) { + await copyFromReport(context, params.copyFromId); + } else { + await initNewReport(context); + } + return 'loaded'; +} diff --git a/src/webparts/stroyBot/pages/report/reportPayload.ts b/src/webparts/stroyBot/pages/report/reportPayload.ts new file mode 100644 index 0000000..bdfa5f2 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportPayload.ts @@ -0,0 +1,53 @@ +import { IReportDataFields, IReportDataNode, IReportFormState, IReportPayload, IResourceItem, ResourceKind } from './reportTypes'; +import { HOURS_TYPES } from './reportConstants'; + +// Сборка тел сохранения из состояния формы — как handleSave в StroyBotReport.tsx. + +export function buildReportPayload(form: IReportFormState, reportNumber: number, projectId: number): IReportPayload { + return { + cree9_objectid: form.objectId, + cree9_companyid: form.companyId, + cree9_shiftid: form.shiftId, + cree9_report_date: form.reportDate, + cree9_number: reportNumber, + cree9_projectid: projectId, + cree9_comment: form.comment || undefined + }; +} + +function resourceFields(kind: ResourceKind, item: IResourceItem, hoursType: string): IReportDataFields { + const idField: IReportDataFields = + kind === 'profession' ? { cree9_professionid: item.resourceId } : { cree9_technicid: item.resourceId }; + return { + ...idField, + cree9_count: item.count, + cree9_hours: item.hours, + cree9_comment: item.comment || undefined, + cree9_hourstype: hoursType + }; +} + +/** + * Узлы сохранения в порядке старого кода: работы (с персоналом, затем техникой на работе — + * у них тип часов всегда «Часы работы»), затем общий персонал, затем общая техника. + */ +export function buildReportNodes(form: IReportFormState): IReportDataNode[] { + const nodes: IReportDataNode[] = form.works.map((work) => { + const children = [ + ...work.professions.map((item) => resourceFields('profession', item, HOURS_TYPES.work)), + ...work.technics.map((item) => resourceFields('technic', item, HOURS_TYPES.work)) + ]; + return { + data: { _cree9_jobid_value: work.jobId, cree9_value: work.value, cree9_comment: work.comment || undefined }, + children: children.length > 0 ? children : undefined + }; + }); + + form.professions.forEach((item) => { + nodes.push({ data: resourceFields('profession', item, item.hoursType || HOURS_TYPES.work) }); + }); + form.technics.forEach((item) => { + nodes.push({ data: resourceFields('technic', item, item.hoursType || HOURS_TYPES.work) }); + }); + return nodes; +} diff --git a/src/webparts/stroyBot/pages/report/reportPicker.ts b/src/webparts/stroyBot/pages/report/reportPicker.ts new file mode 100644 index 0000000..1fda566 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportPicker.ts @@ -0,0 +1,44 @@ +import { IReportLookups } from './reportTypes'; +import { IPickerItem, IPickerOptions } from './picker/pickerTypes'; +import { REPORT_TEXTS } from './texts'; + +// Какие справочники открываются в пикере формы отчёта и как они выглядят в нём. +// (В старом коде был ещё тип «смена», но смена выбирается обычным списком — пикер для неё не открывался.) + +export type PickerKind = 'object' | 'company' | 'work' | 'profession' | 'technic'; + +const TITLES = REPORT_TEXTS.picker.titles; + +const NO_EXTRAS = { groupFilter: false, drillDown: false, allowCreate: false, requiresUnit: false }; + +/** Возможности пикера по справочнику — как пропсы PickerModal в старом компоненте. */ +export const PICKER_OPTIONS: Record = { + object: { ...NO_EXTRAS, title: TITLES.object }, + company: { ...NO_EXTRAS, title: TITLES.company }, + profession: { ...NO_EXTRAS, title: TITLES.profession }, + technic: { ...NO_EXTRAS, title: TITLES.technic, groupFilter: true, allowCreate: true }, + work: { ...NO_EXTRAS, title: TITLES.work, drillDown: true, allowCreate: true, requiresUnit: true } +}; + +/** Элементы пикера из справочников (getModalItems). */ +export function toPickerItems(kind: PickerKind, lookups: IReportLookups): IPickerItem[] { + switch (kind) { + case 'object': + return lookups.objects.map((item) => ({ id: item.Id, title: item.fullPath })); + case 'company': + return lookups.companies.map((item) => ({ id: item.Id, title: item.Title })); + case 'profession': + return lookups.professions.map((item) => ({ id: item.Id, title: item.Title })); + case 'technic': + return lookups.technics.map((item) => ({ id: item.Id, title: item.Title, subtitle: item.group || '' })); + case 'work': + return lookups.jobs.map((job) => ({ + id: job.cree9_tableid, + title: job.cree9_title || '', + subtitle: job.cree9_unit || '', + // Dataverse отдаёт null у корневых работ — для drill-down корень = «без родителя». + parentId: job._cree9_parentid_value ?? undefined, + fullPath: job.fullPath || job.cree9_title || '' + })); + } +} diff --git a/src/webparts/stroyBot/pages/report/reportRules.ts b/src/webparts/stroyBot/pages/report/reportRules.ts new file mode 100644 index 0000000..7c01d68 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportRules.ts @@ -0,0 +1,157 @@ +import { IRoute } from '../../app/routing/routes'; +import { + IJobRow, + IJobWithPath, + IObjectRow, + IObjectWithPath, + IReportAccess, + IReportRouteParams, + ITechnic, + ReportFormMode +} from './reportTypes'; +import { EMPTY_REPORT_ID, MS_PER_DAY } from './reportConstants'; + +// Чистые правила формы отчёта, вынесенные из StroyBotReport.tsx и сервисов stroy-bot-report. + +const PATH_SEPARATOR = ' / '; +const RU_LOCALE = 'ru'; + +/** Параметры адреса. projectId — parseInt (как в старом коде: «12abc» → 12, мусор → NaN). */ +export function parseReportRoute(route: IRoute): IReportRouteParams { + const reportId = route.reportId; + return { + projectId: parseInt(route.projectId || '0', 10), + reportId: reportId && reportId !== EMPTY_REPORT_ID ? reportId : undefined, + copyFromId: route.copyFromId || '' + }; +} + +/** Сегодня в формате YYYY-MM-DD. 🐞 По UTC (toISOString), как в старом коде. */ +export function todayIsoDate(now: Date): string { + return now.toISOString().split('T')[0]; +} + +/** Сколько полных суток прошло с даты отчёта. */ +export function daysSinceReport(reportDate: string, nowMs: number): number { + return Math.floor((nowMs - new Date(reportDate).getTime()) / MS_PER_DAY); +} + +/** Есть ли у пользователя хоть какое-то право на проект (read / edit0 / aproove). */ +export function hasAnyAccess(access: IReportAccess | undefined): access is IReportAccess { + return !!access && (access.read || access.edit || access.aproove); +} + +/** + * Режим открытого отчёта: утверждающий видит неутверждённый отчёт в режиме утверждения; + * редактор может править неутверждённый отчёт, пока не прошло editDays дней; иначе — просмотр. + */ +export function decideExistingReportMode( + access: IReportAccess, + isApproved: boolean, + daysSince: number, + editDays: number +): ReportFormMode { + if (access.aproove && !isApproved) return 'approve'; + if (access.edit && !isApproved && daysSince < editDays) return 'edit'; + return 'readonly'; +} + +export function isFormReadOnly(mode: ReportFormMode): boolean { + return mode === 'readonly' || mode === 'approve'; +} + +export type ReportStatusBadge = 'approved' | 'pending'; + +/** Бейдж статуса в шапке. Для нового отчёта бейджа нет. */ +export function getStatusBadge( + mode: ReportFormMode, + isApproved: boolean, + access: IReportAccess | undefined +): ReportStatusBadge | undefined { + if (mode === 'create') return undefined; + if (isApproved) return 'approved'; + if (access?.aproove) return 'pending'; + return undefined; +} + +function isEditableMode(mode: ReportFormMode): boolean { + return mode === 'create' || mode === 'edit'; +} + +/** Кнопка «Сохранить отчет». Без загруженных прав (новый отчёт без строки «Доступы») — показывается. */ +export function canShowSaveButton(mode: ReportFormMode, isSaved: boolean, access: IReportAccess | undefined): boolean { + return isEditableMode(mode) && !isSaved && access?.edit !== false; +} + +/** После успешного сохранения кнопка сохранения меняется на «← Вернуться назад». */ +export function canShowBackAfterSave(mode: ReportFormMode, isSaved: boolean): boolean { + return isEditableMode(mode) && isSaved; +} + +/** Пути объектов «Объект / Раздел / Вид работ», сортировка по пути (ru). */ +export function buildObjectPaths(rows: IObjectRow[]): IObjectWithPath[] { + const byId = new Map(); + rows.forEach((row) => byId.set(row.Id, { Id: row.Id, Title: row.Title, parentId: row.parentId || undefined })); + + // Без защиты от циклов в parentId — как в старом SharePointService.buildObjectPath. + const pathOf = (objectId: number): string => { + const parts: string[] = []; + let currentId: number | undefined = objectId; + while (currentId !== undefined) { + const current = byId.get(currentId); + if (!current) break; + parts.unshift(current.Title); + currentId = current.parentId; + } + return parts.join(PATH_SEPARATOR); + }; + + return rows + .map((row) => ({ Id: row.Id, Title: row.Title, parentId: row.parentId || undefined, fullPath: pathOf(row.Id) })) + .sort((a, b) => a.fullPath.localeCompare(b.fullPath, RU_LOCALE)); +} + +/** Пути работ «Родитель / Работа», сортировка по пути (ru). */ +export function buildJobPaths(rows: IJobRow[]): IJobWithPath[] { + const byId = new Map(); + rows.forEach((row) => byId.set(row.cree9_tableid, row)); + + const pathOf = (jobId: string): string => { + const parts: string[] = []; + const visited = new Set(); + let currentId: string | undefined = jobId; + while (currentId !== undefined && !visited.has(currentId)) { + visited.add(currentId); + const job = byId.get(currentId); + if (!job) break; + parts.unshift(job.cree9_title); + currentId = job._cree9_parentid_value ?? undefined; + } + return parts.join(PATH_SEPARATOR); + }; + + return rows + .map((row) => ({ ...row, fullPath: pathOf(row.cree9_tableid) })) + .sort((a, b) => a.fullPath.localeCompare(b.fullPath, RU_LOCALE)); +} + +/** Путь новой работы: «путь родителя / название», если родитель есть в загруженном списке. */ +export function newJobPath(title: string, parentId: string | undefined, jobs: IJobWithPath[]): string { + if (!parentId) return title; + const parent = jobs.filter((job) => job.cree9_tableid === parentId)[0]; + return parent ? `${parent.fullPath}${PATH_SEPARATOR}${title}` : title; +} + +/** Техника по группе, затем по названию (ru). */ +export function sortTechnics(technics: ITechnic[]): ITechnic[] { + return [...technics].sort((a, b) => { + const groupA = a.group || ''; + const groupB = b.group || ''; + return groupA.localeCompare(groupB, RU_LOCALE) || a.Title.localeCompare(b.Title, RU_LOCALE); + }); +} + +/** «+/-» план-факта. */ +export function planFactDifference(totalFact: number, plan: number): number { + return totalFact - plan; +} diff --git a/src/webparts/stroyBot/pages/report/reportSave.ts b/src/webparts/stroyBot/pages/report/reportSave.ts new file mode 100644 index 0000000..fae1a26 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportSave.ts @@ -0,0 +1,34 @@ +import { IReportFormState } from './reportTypes'; +import { buildReportNodes, buildReportPayload } from './reportPayload'; +import { IReportServices } from './reportServices'; + +// Сохранение отчёта — как handleSave в StroyBotReport.tsx. + +export interface IReportSaveResult { + reportId: string; + reportNumber: number; + /** Ссылка на папку с фото — только если фото загружались. */ + photoFolderUrl: string | undefined; +} + +/** + * 1. Номер: у существующего отчёта — прежний, у нового — число неудалённых отчётов проекта + 1. + * 2. Отчёт и его строки (при правке старые строки удаляются и создаются заново). + * 3. Фото — в DocLib/{projectId}_{номер}. Ошибка загрузки фото = ошибка сохранения (отчёт уже записан). + */ +export async function saveReport( + services: IReportServices, + projectId: number, + form: IReportFormState, + existingReportId: string | undefined +): Promise { + const reportNumber = existingReportId ? form.reportNumber : await services.dv.getNextReportNumber(projectId); + const reportId = await services.dv.saveReportWithData( + buildReportPayload(form, reportNumber, projectId), + buildReportNodes(form), + existingReportId + ); + const photoFolderUrl = + form.photos.length > 0 ? await services.sp.uploadPhotos(projectId, reportNumber, form.photos) : undefined; + return { reportId, reportNumber, photoFolderUrl }; +} diff --git a/src/webparts/stroyBot/pages/report/reportSectionHandlers.ts b/src/webparts/stroyBot/pages/report/reportSectionHandlers.ts new file mode 100644 index 0000000..7f79fd8 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportSectionHandlers.ts @@ -0,0 +1,60 @@ +import { IResourceItem, IWorkItem } from './reportTypes'; +import { IResourceTarget, patchResource, patchWork, removeResource, removeWork } from './reportFormUpdates'; +import { readPhotoFiles } from './photoFiles'; +import { IReportFormApi } from './useReportForm'; +import { IPickerModalApi } from './usePickerModal'; +import { REPORT_TEXTS } from './texts'; + +export interface IReportSectionHandlers { + changeDate: (value: string) => void; + changeShift: (value: string) => void; + openObjectPicker: () => void; + openCompanyPicker: () => void; + addWork: () => void; + changeWork: (workId: string, patch: Partial) => void; + removeWork: (workId: string) => void; + addResource: (target: IResourceTarget) => void; + changeResource: (target: IResourceTarget, itemId: string, patch: Partial) => void; + removeResource: (target: IResourceTarget, itemId: string) => void; + changeComment: (value: string) => void; + addPhotos: (files: File[]) => Promise; + removePhoto: (index: number) => void; +} + +/** Колбэки секций формы. Состояние меняется только через report.updateForm (иммутабельно). */ +export function createSectionHandlers(report: IReportFormApi, picker: IPickerModalApi): IReportSectionHandlers { + const { updateForm } = report; + + return { + changeDate: (value) => { + report.clearValidation(); + updateForm((prev) => ({ ...prev, reportDate: value })); + }, + changeShift: (value) => { + report.clearValidation(); + const shiftId = parseInt(value, 10); + const shift = report.lookups.shifts.filter((item) => item.Id === shiftId)[0]; + updateForm((prev) => ({ ...prev, shiftId: shiftId || undefined, shiftName: shift?.Title || '' })); + }, + openObjectPicker: () => picker.open('object'), + openCompanyPicker: () => picker.open('company'), + addWork: () => { + if (!report.form.objectId) { + report.setErrorMessage(REPORT_TEXTS.messages.selectObjectFirst); + return; + } + picker.open('work'); + }, + changeWork: (workId, patch) => updateForm((prev) => patchWork(prev, workId, patch)), + removeWork: (workId) => updateForm((prev) => removeWork(prev, workId)), + addResource: (target) => picker.open(target.kind, target.workId), + changeResource: (target, itemId, patch) => updateForm((prev) => patchResource(prev, target, itemId, patch)), + removeResource: (target, itemId) => updateForm((prev) => removeResource(prev, target, itemId)), + changeComment: (value) => updateForm((prev) => ({ ...prev, comment: value })), + addPhotos: async (files) => { + const photos = await readPhotoFiles(files); + updateForm((prev) => ({ ...prev, photos: [...prev.photos, ...photos] })); + }, + removePhoto: (index) => updateForm((prev) => ({ ...prev, photos: prev.photos.filter((_, i) => i !== index) })) + }; +} diff --git a/src/webparts/stroyBot/pages/report/reportServices.ts b/src/webparts/stroyBot/pages/report/reportServices.ts new file mode 100644 index 0000000..e36b6db --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportServices.ts @@ -0,0 +1,23 @@ +import { IAppServices } from '../../services/AppServices'; +import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository'; +import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository'; + +/** Репозитории формы отчёта. Один набор на открытую форму (как сервисы старого web part). */ +export interface IReportServices { + sp: ISharePointReportRepository; + dv: IDataverseReportRepository; + siteUrl: string; +} + +export function createReportServices(services: IAppServices): IReportServices { + return { + sp: createSharePointReportRepository({ + client: services.sharePoint, + siteUrl: services.settings.siteUrl, + userId: services.page.userId, + webServerRelativeUrl: services.page.webServerRelativeUrl + }), + dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl), + siteUrl: services.settings.siteUrl + }; +} diff --git a/src/webparts/stroyBot/pages/report/reportTypes.ts b/src/webparts/stroyBot/pages/report/reportTypes.ts new file mode 100644 index 0000000..fe5e7a1 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportTypes.ts @@ -0,0 +1,215 @@ +// Типы формы отчёта (бывший stroy-bot-report, SendReport.aspx). +// Имена полей ответов SharePoint / Dataverse — как в старом models/IModels.ts. + +/** Режим формы. Решение о режиме — reportRules.ts. */ +export type ReportFormMode = 'create' | 'edit' | 'readonly' | 'approve' | 'noaccess'; + +/** Тип ресурса в отчёте: персонал (профессия) или техника. */ +export type ResourceKind = 'profession' | 'technic'; + +// ============ SharePoint ============ + +/** Элемент списка «Проекты». editDays — сколько дней после даты отчёта его можно править. */ +export interface IProjectRow { + Id: number; + Title: string; + editDays?: number; +} + +/** Элемент списка «Объекты». parentId — родитель (как читал старый код). */ +export interface IObjectRow { + Id: number; + Title: string; + parentId?: number; +} + +export interface IObjectWithPath extends IObjectRow { + /** «Объект / Раздел / Вид работ». */ + fullPath: string; +} + +export interface ICompany { + Id: number; + Title: string; +} + +export interface IShift { + Id: number; + Title: string; +} + +export interface IProfession { + Id: number; + Title: string; + type?: string; +} + +export interface ITechnic { + Id: number; + Title: string; + /** Группа техники — по ней работают чипы-фильтры пикера. */ + group?: string; +} + +/** Права текущего пользователя на проект (из списка «Доступы»). */ +export interface IReportAccess { + id: number; + userId: number | undefined; + project: string; + read: boolean; + /** Поле edit0. */ + edit: boolean; + aproove: boolean; +} + +// ============ Dataverse ============ + +/** Работа (таблица cree9_tables). */ +export interface IJobRow { + cree9_tableid: string; + cree9_title: string; + cree9_unit: string; + cree9_objectid: string; + cree9_volume?: number; + _cree9_parentid_value?: string; +} + +export interface IJobWithPath extends IJobRow { + /** «Родитель / Дочерняя работа». */ + fullPath: string; +} + +/** Отчёт (таблица cree9_reports). */ +export interface IReportRow { + cree9_reportid?: string; + cree9_objectid: number; + cree9_shiftid: number; + cree9_report_date: string; + cree9_number: number; + cree9_companyid: number; + cree9_projectid: number; + cree9_comment?: string; + cree9_aprooved?: boolean; +} + +/** Тело создания / изменения отчёта. Поля undefined не попадают в JSON. */ +export interface IReportPayload { + cree9_objectid: number | undefined; + cree9_companyid: number | undefined; + cree9_shiftid: number | undefined; + cree9_report_date: string; + cree9_number: number; + cree9_projectid: number; + cree9_comment: string | undefined; +} + +/** Строка отчёта (таблица cree9_report_datas): работа, персонал или техника. */ +export interface IReportDataRow { + cree9_report_dataid?: string; + /** Lookup на работу. Есть у строк-работ. */ + _cree9_jobid_value?: string; + /** Lookup на строку-работу: у персонала и техники, привязанных к работе. */ + _cree9_parentid_value?: string; + cree9_professionid?: number | string; + cree9_technicid?: number | string; + cree9_count?: number; + cree9_hours?: number; + cree9_value?: number; + cree9_comment?: string; + cree9_hourstype?: string; +} + +/** Поля строки отчёта для сохранения. Порядок ключей = порядок полей в JSON (как в старом коде). */ +export interface IReportDataFields { + _cree9_jobid_value?: string; + cree9_value?: number; + cree9_professionid?: number; + cree9_technicid?: number; + cree9_count?: number; + cree9_hours?: number; + cree9_comment?: string; + cree9_hourstype?: string; +} + +/** Узел сохранения: строка-работа и её дочерние строки (персонал / техника на работе). */ +export interface IReportDataNode { + data: IReportDataFields; + children?: IReportDataFields[]; +} + +// ============ Состояние формы ============ + +/** Персонал или техника в отчёте (общие или на конкретной работе). */ +export interface IResourceItem { + /** Локальный id строки формы. */ + id: string; + /** Id элемента списка «Профессии» или «Техника». */ + resourceId: number; + title: string; + count: number; + hours: number; + hoursType: string; + comment: string; +} + +export interface IWorkItem { + /** Локальный id строки формы. */ + id: string; + jobId: string; + jobTitle: string; + jobUnit: string; + value: number; + plan: number; + totalFact: number; + comment: string; + /** Персонал и техника на этой работе (тип часов всегда «Часы работы»). */ + professions: IResourceItem[]; + technics: IResourceItem[]; +} + +export interface IPhotoItem { + name: string; + /** data: URL для превью. */ + preview: string; + /** Содержимое файла для загрузки. */ + buffer: ArrayBuffer; +} + +export interface IReportFormState { + objectId: number | undefined; + objectPath: string; + companyId: number | undefined; + companyName: string; + shiftId: number | undefined; + shiftName: string; + /** YYYY-MM-DD */ + reportDate: string; + /** 0 — номер ещё не присвоен (новый отчёт). */ + reportNumber: number; + comment: string; + works: IWorkItem[]; + professions: IResourceItem[]; + technics: IResourceItem[]; + photos: IPhotoItem[]; + photoFolderUrl: string | undefined; +} + +/** Справочники формы и пикера. */ +export interface IReportLookups { + objects: IObjectWithPath[]; + companies: ICompany[]; + shifts: IShift[]; + professions: IProfession[]; + technics: ITechnic[]; + jobs: IJobWithPath[]; +} + +/** Параметры маршрута формы, уже разобранные как в старом коде. */ +export interface IReportRouteParams { + /** NaN / 0 — projectId не указан. */ + projectId: number; + /** Id открываемого отчёта; undefined — новый отчёт. */ + reportId: string | undefined; + /** Id отчёта-образца для копирования; '' — без копирования. */ + copyFromId: string; +} diff --git a/src/webparts/stroyBot/pages/report/reportValidation.ts b/src/webparts/stroyBot/pages/report/reportValidation.ts new file mode 100644 index 0000000..2a5a19a --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportValidation.ts @@ -0,0 +1,37 @@ +import { IReportFormState } from './reportTypes'; +import { HOURS_TYPES } from './reportConstants'; +import { REPORT_TEXTS } from './texts'; + +const TEXTS = REPORT_TEXTS.validation; + +/** Ошибки формы перед сохранением — тексты и порядок как в старом validate(). */ +export function validateReportForm(form: IReportFormState): string[] { + const errors: string[] = []; + if (!form.objectId) errors.push(TEXTS.object); + if (!form.companyId) errors.push(TEXTS.company); + if (!form.shiftId) errors.push(TEXTS.shift); + if (!form.reportDate) errors.push(TEXTS.reportDate); + if (form.works.length === 0 && form.professions.length === 0 && form.technics.length === 0) { + errors.push(TEXTS.noRows); + } + + // Проверяются только общие персонал и техника: у строк на работе типа часов нет. + form.professions.forEach((item, index) => { + if (item.hoursType === HOURS_TYPES.idle && !item.comment.trim()) { + errors.push(TEXTS.professionComment(index + 1, item.title)); + } + }); + form.technics.forEach((item, index) => { + const needsComment = item.hoursType === HOURS_TYPES.idle || item.hoursType === HOURS_TYPES.repair; + if (needsComment && !item.comment.trim()) { + errors.push(TEXTS.technicComment(index + 1, item.title, item.hoursType)); + } + }); + return errors; +} + +/** Обязателен ли комментарий при выбранном типе часов (звёздочка у поля). */ +export function isResourceCommentRequired(hoursType: string, allowedTypes: string[]): boolean { + const requiresComment = hoursType === HOURS_TYPES.idle || hoursType === HOURS_TYPES.repair; + return requiresComment && allowedTypes.indexOf(hoursType) !== -1; +} diff --git a/src/webparts/stroyBot/pages/report/sections/CommentSection.tsx b/src/webparts/stroyBot/pages/report/sections/CommentSection.tsx new file mode 100644 index 0000000..8439554 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sections/CommentSection.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { SectionTitle } from './SectionTitle'; +import { EditableComment } from './EditableComment'; +import { REPORT_TEXTS } from '../texts'; +import styles from '../ReportPage.module.scss'; + +interface Props { + value: string; + readOnly: boolean; + onChange: (value: string) => void; +} + +/** Секция «Комментарий» — общий комментарий к отчёту. */ +export const CommentSection: React.FC = ({ value, readOnly, onChange }) => ( +
+ + +
+); diff --git a/src/webparts/stroyBot/pages/report/sections/CountHoursFields.tsx b/src/webparts/stroyBot/pages/report/sections/CountHoursFields.tsx new file mode 100644 index 0000000..462abf2 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sections/CountHoursFields.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { FormField, TextInput } from '@tnsu/ui-kit'; +import { IResourceItem } from '../reportTypes'; +import { REPORT_TEXTS } from '../texts'; +import styles from '../ReportPage.module.scss'; + +const TEXTS = REPORT_TEXTS.resource; + +interface Props { + count: number; + hours: number; + readOnly: boolean; + onChange: (patch: Partial) => void; +} + +/** Нули показываются пустым полем с плейсхолдером «0»; ввод — parseInt, мусор → 0 (как раньше). */ +function toInputValue(value: number): string { + return value ? String(value) : ''; +} + +function toInteger(value: string): number { + return parseInt(value, 10) || 0; +} + +/** «Кол-во» и «Часы» в две колонки — у персонала и техники, общих и на работе. */ +export const CountHoursFields: React.FC = ({ count, hours, readOnly, onChange }) => ( +
+ + onChange({ count: toInteger(value) })} + placeholder={TEXTS.numberPlaceholder} + readOnly={readOnly} + ariaLabel={TEXTS.count} + /> + + + onChange({ hours: toInteger(value) })} + placeholder={TEXTS.numberPlaceholder} + readOnly={readOnly} + ariaLabel={TEXTS.hours} + /> + +
+); diff --git a/src/webparts/stroyBot/pages/report/sections/EditableComment.tsx b/src/webparts/stroyBot/pages/report/sections/EditableComment.tsx new file mode 100644 index 0000000..b45b273 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sections/EditableComment.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import styles from '../ReportPage.module.scss'; + +interface Props { + value: string; + onChange: (value: string) => void; + disabled: boolean; + placeholder: string; + ariaLabel: string; +} + +const stopPropagation = (event: React.KeyboardEvent): void => event.stopPropagation(); + +/** + * Многострочный комментарий на contentEditable (как EditableDiv старой формы): нажатия клавиш + * не всплывают — Enter переносит строку и не отправляет форму страницы SharePoint. + * Текст в DOM пишется только при внешнем изменении значения, чтобы не сбивать курсор при вводе. + */ +export const EditableComment: React.FC = ({ value, onChange, disabled, placeholder, ariaLabel }) => { + const divRef = React.useRef(null); + const lastValueRef = React.useRef(undefined); + + React.useEffect(() => { + if (divRef.current && value !== lastValueRef.current) { + divRef.current.innerText = value; + lastValueRef.current = value; + } + }, [value]); + + const handleInput = (): void => { + if (!divRef.current) return; + const text = divRef.current.innerText; + lastValueRef.current = text; + onChange(text); + }; + + return ( +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/report/sections/NestedResourceGroup.tsx b/src/webparts/stroyBot/pages/report/sections/NestedResourceGroup.tsx new file mode 100644 index 0000000..204af20 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sections/NestedResourceGroup.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { Button, IconButton } from '@tnsu/ui-kit'; +import { IResourceItem } from '../reportTypes'; +import { CountHoursFields } from './CountHoursFields'; +import { REPORT_TEXTS } from '../texts'; +import styles from '../ReportPage.module.scss'; + +interface Props { + title: string; + addLabel: string; + items: IResourceItem[]; + readOnly: boolean; + onAdd: () => void; + onChange: (itemId: string, patch: Partial) => void; + onRemove: (itemId: string) => void; +} + +/** + * «Персонал на работе» / «Техника на работе»: только Кол-во и Часы (тип часов всегда «Часы работы»). + * В режиме просмотра пустая группа не показывается. + */ +export const NestedResourceGroup: React.FC = (props) => { + const { title, addLabel, items, readOnly } = props; + if (readOnly && items.length === 0) { + return null; + } + + return ( +
+
{title}
+ {items.map((item) => ( +
+
+ {item.title} + {!readOnly && ( + props.onRemove(item.id)} + /> + )} +
+ props.onChange(item.id, patch)} + /> +
+ ))} + {!readOnly && ( + + )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/report/sections/PhotoSection.tsx b/src/webparts/stroyBot/pages/report/sections/PhotoSection.tsx new file mode 100644 index 0000000..46e5db9 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sections/PhotoSection.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { Button, IconButton } from '@tnsu/ui-kit'; +import { IPhotoItem } from '../reportTypes'; +import { SectionTitle } from './SectionTitle'; +import { REPORT_TEXTS } from '../texts'; +import styles from '../ReportPage.module.scss'; + +const TEXTS = REPORT_TEXTS.photos; + +interface Props { + photos: IPhotoItem[]; + photoFolderUrl: string | undefined; + readOnly: boolean; + onAddPhotos: (files: File[]) => Promise; + onRemovePhoto: (index: number) => void; +} + +/** Секция «Фото»: ссылка на папку с уже загруженными фото, превью новых и «Добавить фото». */ +export const PhotoSection: React.FC = ({ photos, photoFolderUrl, readOnly, onAddPhotos, onRemovePhoto }) => { + const fileInputRef = React.useRef(null); + + const clearInput = (): void => { + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const onFilesChosen = (event: React.ChangeEvent): void => { + const files = event.target.files; + if (!files || files.length === 0) return; + // Как в старом коде: ошибка чтения файла молча пропускает выбор, поле очищается в любом случае. + onAddPhotos(Array.from(files)).then(clearInput, clearInput); + }; + + return ( +
+ + + {photoFolderUrl && ( + + )} + + {!readOnly && ( + <> +
+ {photos.map((photo, index) => ( +
+ {photo.name} + + onRemovePhoto(index)} /> + + {photo.name} +
+ ))} +
+ + + + + )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/report/sections/PlanFact.tsx b/src/webparts/stroyBot/pages/report/sections/PlanFact.tsx new file mode 100644 index 0000000..ad8744b --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sections/PlanFact.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { planFactDifference } from '../reportRules'; +import { REPORT_TEXTS } from '../texts'; +import styles from '../ReportPage.module.scss'; + +const TEXTS = REPORT_TEXTS.work; + +interface Props { + plan: number; + totalFact: number; +} + +/** «П: план Ф: факт +/-: разница» — разница зелёная, если факт ≥ плана, иначе красная. */ +export const PlanFact: React.FC = ({ plan, totalFact }) => { + const difference = planFactDifference(totalFact, plan); + return ( + + {TEXTS.plan} {plan} + {TEXTS.fact} {totalFact} + {TEXTS.difference} = 0 ? styles.positive : styles.negative}>{difference} + + ); +}; diff --git a/src/webparts/stroyBot/pages/report/sections/ResourceCard.tsx b/src/webparts/stroyBot/pages/report/sections/ResourceCard.tsx new file mode 100644 index 0000000..fe4ee57 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sections/ResourceCard.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { FormField, IconButton, Select, TextInput } from '@tnsu/ui-kit'; +import { IResourceItem } from '../reportTypes'; +import { HOURS_TYPES } from '../reportConstants'; +import { isResourceCommentRequired } from '../reportValidation'; +import { CountHoursFields } from './CountHoursFields'; +import { REPORT_TEXTS } from '../texts'; +import styles from '../ReportPage.module.scss'; + +const TEXTS = REPORT_TEXTS.resource; + +interface Props { + item: IResourceItem; + /** Варианты «Тип часов»: у персонала — работы / простоя, у техники ещё ремонта. */ + hoursTypes: string[]; + readOnly: boolean; + onChange: (patch: Partial) => void; + onRemove: () => void; +} + +/** Карточка общего персонала / техники: Кол-во, Часы, Тип часов, Комментарий (обязателен для простоя / ремонта). */ +export const ResourceCard: React.FC = ({ item, hoursTypes, readOnly, onChange, onRemove }) => { + const hoursTypeOptions = hoursTypes.map((type) => ({ value: type, label: type })); + + return ( +
+
+ {item.title} + {!readOnly && } +
+ + + + + handleChange(event.target.value)} + onBlur={(event) => handleBlur(event.target.value)} + disabled={readOnly} + placeholder={REPORT_TEXTS.work.valuePlaceholder} + /> + ); +}; diff --git a/src/webparts/stroyBot/pages/report/sharePointReportRepository.ts b/src/webparts/stroyBot/pages/report/sharePointReportRepository.ts new file mode 100644 index 0000000..71a95f9 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sharePointReportRepository.ts @@ -0,0 +1,318 @@ +import { IHttpClient, IHttpResponse } from '../../services/http/IHttpClient'; +import { listItemsUrl } from '../../shared/sharepoint/spRest'; +import { LISTS, REPORT_PHOTO_LIBRARY } from '../../config/lists'; +import { + ICompany, + IObjectRow, + IObjectWithPath, + IPhotoItem, + IProfession, + IProjectRow, + IReportAccess, + IShift, + ITechnic +} from './reportTypes'; +import { buildObjectPaths, sortTechnics } from './reportRules'; +import { REPORT_TEXTS } from './texts'; + +// Запросы формы отчёта к SharePoint — 1:1 со stroy-bot-report (services/SharePointService.ts): +// те же URL, параметры в том же порядке, те же заголовки и тексты ошибок. + +/** Старый getItems всегда добавлял $top=5000. */ +const ITEMS_TOP = 5000; + +const NOMETADATA_HEADERS: Record = { + Accept: 'application/json;odata=nometadata', + 'odata-version': '' +}; + +const NOMETADATA_POST_HEADERS: Record = { + Accept: 'application/json;odata=nometadata', + 'Content-Type': 'application/json;odata=nometadata', + 'odata-version': '' +}; + +const UPLOAD_HEADERS: Record = { + Accept: 'application/json;odata=nometadata', + 'Content-Type': 'application/octet-stream', + 'odata-version': '' +}; + +/** Внутренние имена полей и $select — как в старом коде. */ +const SELECT = { + project: 'Id,Title,editDays', + objects: 'Id,Title,parentId', + companies: 'Id,Title', + companiesByProject: 'Id,companyIdId', + shifts: 'Id,Title', + professions: 'Id,Title,type', + professionsByProject: 'Id,professionIdId', + technics: 'Id,Title,group' +} as const; + +/** Строка «Доступы»: поле пользователя на разных сайтах называется по-разному. */ +interface IAccessRaw { + Id: number; + userId?: number; + userIdId?: number; + UserId?: number; + UserIdId?: number; + projectId: number; + read: boolean; + edit0: boolean; + aproove: boolean; +} + +interface IListQuery { + filter?: string; + select?: string; +} + +// ---------- Адреса ---------- + +/** `…/items?$top=5000[&$filter=…][&$select=…]` — порядок параметров как в старом getItems. */ +export function itemsQueryUrl(siteUrl: string, listTitle: string, query: IListQuery = {}): string { + const params: string[] = [`$top=${ITEMS_TOP}`]; + if (query.filter) params.push(`$filter=${query.filter}`); + if (query.select) params.push(`$select=${query.select}`); + return `${listItemsUrl(siteUrl, listTitle)}?${params.join('&')}`; +} + +export function itemByIdUrl(siteUrl: string, listTitle: string, id: number, select: string): string { + return `${listItemsUrl(siteUrl, listTitle)}(${id})?$select=${select}`; +} + +/** Серверный относительный путь папки фото: `{web}/DocLib/{projectId}_{number}`. */ +export function photoFolderServerRelativeUrl( + webServerRelativeUrl: string, + projectId: number, + reportNumber: number +): string { + const webRelativeUrl = webServerRelativeUrl.replace(/\/$/, ''); + return `${webRelativeUrl}/${REPORT_PHOTO_LIBRARY}/${projectId}_${reportNumber}`; +} + +/** Ссылка «Открыть папку с фото». */ +export function photoFolderLink(siteUrl: string, projectId: number, reportNumber: number): string { + return `${siteUrl}/${REPORT_PHOTO_LIBRARY}/${projectId}_${reportNumber}`; +} + +export function folderByUrlUrl(siteUrl: string, serverRelativeFolderUrl: string): string { + return `${siteUrl}/_api/web/GetFolderByServerRelativeUrl('${serverRelativeFolderUrl}')`; +} + +export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fileName: string): string { + const safeName = fileName.replace(/'/g, "''"); + return `${folderByUrlUrl(siteUrl, serverRelativeFolderUrl)}/Files/add(url='${safeName}',overwrite=true)`; +} + +// ---------- Репозиторий ---------- + +export interface ISharePointReportDeps { + client: IHttpClient; + siteUrl: string; + /** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */ + userId: number | undefined; + /** Серверный относительный URL сайта — для пути к папке фото. */ + webServerRelativeUrl: string; +} + +export interface ISharePointReportRepository { + getProjectById(projectId: number): Promise; + getObjectsByProject(projectTitle: string): Promise; + getCompaniesWithFallback(projectId: number): Promise; + getShifts(): Promise; + getProfessionsWithFallback(projectId: number): Promise; + getProfessionById(id: number): Promise; + getTechnics(): Promise; + getTechnicById(id: number): Promise; + createListItem(listTitle: string, data: Record): Promise<{ Id: number; Title: string }>; + checkAccess(projectId: number): Promise; + uploadPhotos(projectId: number, reportNumber: number, photos: IPhotoItem[]): Promise; +} + +async function readErrorBody(response: IHttpResponse): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +export function createSharePointReportRepository(deps: ISharePointReportDeps): ISharePointReportRepository { + const { client, siteUrl } = deps; + + const getItems = async (listTitle: string, query?: IListQuery): Promise => { + const response = await client.fetch(itemsQueryUrl(siteUrl, listTitle, query), { headers: NOMETADATA_HEADERS }); + if (!response.ok) { + const body = await readErrorBody(response); + throw new Error(REPORT_TEXTS.errors.spList(response.status, response.statusText, body)); + } + const json = (await response.json()) as { value?: T[] }; + return json.value || []; + }; + + const getItemById = async (listTitle: string, id: number, select: string): Promise => { + const response = await client.fetch(itemByIdUrl(siteUrl, listTitle, id, select), { + headers: NOMETADATA_HEADERS + }); + if (!response.ok) { + const body = await readErrorBody(response); + throw new Error(REPORT_TEXTS.errors.spItem(response.status, body)); + } + return (await response.json()) as T; + }; + + const getCompanies = (): Promise => getItems(LISTS.companies, { select: SELECT.companies }); + const getProfessions = (): Promise => + getItems(LISTS.professions, { select: SELECT.professions }); + + const createListItem = async ( + listTitle: string, + data: Record + ): Promise<{ Id: number; Title: string }> => { + const response = await client.fetch(listItemsUrl(siteUrl, listTitle), { + method: 'POST', + headers: NOMETADATA_POST_HEADERS, + body: JSON.stringify(data) + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(REPORT_TEXTS.errors.spCreate(listTitle, body)); + } + return (await response.json()) as { Id: number; Title: string }; + }; + + return { + getProjectById: (projectId) => getItemById(LISTS.projects, projectId, SELECT.project), + + getObjectsByProject: async (projectTitle) => { + // Название проекта подставляется без экранирования апострофа — как в старом коде. + const rows = await getItems(LISTS.objects, { + filter: `project eq '${projectTitle}'`, + select: SELECT.objects + }); + return buildObjectPaths(rows); + }, + + getCompaniesWithFallback: (projectId) => + withProjectFallback( + () => getItems<{ companyIdId: number }>(LISTS.companiesByProject, junctionQuery(projectId, SELECT.companiesByProject)), + (row) => row.companyIdId, + getCompanies + ), + + getShifts: () => getItems(LISTS.shifts, { select: SELECT.shifts }), + + getProfessionsWithFallback: (projectId) => + withProjectFallback( + () => + getItems<{ professionIdId: number }>( + LISTS.professionsByProject, + junctionQuery(projectId, SELECT.professionsByProject) + ), + (row) => row.professionIdId, + getProfessions + ), + + getProfessionById: (id) => getItemById(LISTS.professions, id, SELECT.professions), + + getTechnics: async () => sortTechnics(await getItems(LISTS.technics, { select: SELECT.technics })), + + getTechnicById: (id) => getItemById(LISTS.technics, id, SELECT.technics), + + createListItem, + + checkAccess: async (projectId) => { + const rows = await getItems(LISTS.access, { filter: `projectId eq ${projectId}` }); + return pickAccess(rows, deps.userId); + }, + + uploadPhotos: async (projectId, reportNumber, photos) => { + const folderUrl = photoFolderServerRelativeUrl(deps.webServerRelativeUrl, projectId, reportNumber); + await ensureFolder(client, siteUrl, folderUrl); + for (const photo of photos) { + await uploadPhoto(client, siteUrl, folderUrl, photo); + } + return photoFolderLink(siteUrl, projectId, reportNumber); + } + }; +} + +function junctionQuery(projectId: number, select: string): IListQuery { + return { filter: `projectIdId eq ${projectId}`, select }; +} + +/** + * Справочник «по проекту» с откатом на полный список — как getCompaniesWithFallback / getProfessionsWithFallback: + * есть привязки к проекту — полный список, отфильтрованный по ним; нет привязок или ошибка — полный список. + */ +async function withProjectFallback( + loadJunction: () => Promise, + readItemId: (row: TJunction) => number, + loadAll: () => Promise +): Promise { + try { + const junction = await loadJunction(); + if (junction.length > 0) { + const all = await loadAll(); + const ids = new Set(junction.map(readItemId)); + return all.filter((item) => ids.has(item.Id)); + } + } catch { + // Списка привязок нет или нет доступа — берём полный список. + } + return loadAll(); +} + +/** Строка «Доступы» текущего пользователя. Имя поля пользователя перебирается, как в старом коде. */ +export function pickAccess(rows: IAccessRaw[], currentUserId: number | undefined): IReportAccess | undefined { + const row = rows.filter((item) => { + const itemUserId = item.userId || item.userIdId || item.UserId || item.UserIdId; + return itemUserId === currentUserId; + })[0]; + if (!row) { + return undefined; + } + return { + id: row.Id, + userId: currentUserId, + project: String(row.projectId), + read: !!row.read, + edit: !!row.edit0, + aproove: !!row.aproove + }; +} + +async function ensureFolder(client: IHttpClient, siteUrl: string, serverRelativeFolderUrl: string): Promise { + const check = await client.fetch(folderByUrlUrl(siteUrl, serverRelativeFolderUrl), { headers: NOMETADATA_HEADERS }); + if (check.ok) { + return; + } + const created = await client.fetch(`${siteUrl}/_api/web/folders`, { + method: 'POST', + headers: NOMETADATA_POST_HEADERS, + body: JSON.stringify({ ServerRelativeUrl: serverRelativeFolderUrl }) + }); + if (!created.ok) { + const body = await readErrorBody(created); + throw new Error(REPORT_TEXTS.errors.folderCreate(created.status, body)); + } +} + +async function uploadPhoto( + client: IHttpClient, + siteUrl: string, + serverRelativeFolderUrl: string, + photo: IPhotoItem +): Promise { + const response = await client.fetch(fileAddUrl(siteUrl, serverRelativeFolderUrl, photo.name), { + method: 'POST', + headers: UPLOAD_HEADERS, + body: photo.buffer + }); + if (!response.ok) { + const body = await readErrorBody(response); + throw new Error(REPORT_TEXTS.errors.photoUpload(photo.name, response.status, body)); + } +} diff --git a/src/webparts/stroyBot/pages/report/texts.ts b/src/webparts/stroyBot/pages/report/texts.ts new file mode 100644 index 0000000..a73cc36 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/texts.ts @@ -0,0 +1,133 @@ +// Тексты формы отчёта — дословно из stroy-bot-report (StroyBotReport.tsx, sections/*, PickerModal.tsx, services/*). + +export const REPORT_TEXTS = { + loading: 'Загрузка отчета...', + noAccess: { + title: 'Нет доступа', + text: 'У вас нет прав для просмотра отчетов данного проекта.' + }, + back: '← Вернуться назад', + header: { + newReport: 'Новый отчет', + reportNumber: (num: number): string => `Отчет №${num}`, + project: 'Проект:', + approved: 'Утверждён', + pending: 'На утверждении' + }, + fields: { + reportDate: 'Дата отчета', + shift: 'Смена', + object: 'Объект', + objectPlaceholder: 'Выберите объект...', + company: 'Компания', + companyPlaceholder: 'Выберите компанию...' + }, + sections: { + works: 'Работы', + professions: 'Персонал', + technics: 'Техника', + comment: 'Комментарий', + photos: 'Фото' + }, + work: { + value: 'Значение', + valuePlaceholder: '0', + planFact: 'План-факт', + plan: 'П:', + fact: 'Ф:', + difference: '+/-:', + comment: 'Комментарий', + commentPlaceholder: 'Комментарий', + add: '+ Выбрать работу', + nestedProfessions: 'Персонал на работе', + nestedTechnics: 'Техника на работе', + addNestedProfession: '+ Добавить профессию', + addNestedTechnic: '+ Добавить технику' + }, + resource: { + count: 'Кол-во', + hours: 'Часы', + numberPlaceholder: '0', + hoursType: 'Тип часов', + comment: 'Комментарий', + commentPlaceholder: 'Комментарий...', + remove: 'Удалить', + addProfession: '+ Выбрать профессию', + addTechnic: '+ Выбрать технику' + }, + commentPlaceholder: 'Общий комментарий...', + photos: { + openFolder: '📁 Открыть папку с фото', + add: '📷 Добавить фото', + remove: 'Удалить', + readError: (fileName: string): string => `Не удалось прочитать файл: ${fileName}` + }, + actions: { + save: 'Сохранить отчет', + saving: 'Сохранение...', + approve: 'Утвердить отчет', + approving: 'Утверждение...' + }, + messages: { + missingProjectId: 'Не указан projectId в URL', + loadError: (message: string): string => `Ошибка загрузки: ${message}`, + saveError: (message: string): string => `Ошибка сохранения: ${message}`, + approveError: (message: string): string => `Ошибка утверждения: ${message}`, + saved: 'Отчет успешно сохранен!', + approved: 'Отчет утвержден!', + approveConfirm: 'Утвердить отчет? Это действие нельзя отменить.', + selectObjectFirst: 'Сначала выберите объект' + }, + validation: { + object: 'Выберите объект', + company: 'Выберите компанию', + shift: 'Выберите смену', + reportDate: 'Укажите дату отчета', + noRows: 'Добавьте хотя бы одну работу, профессию или технику', + professionComment: (index: number, title: string): string => + `Персонал #${index} (${title}): укажите комментарий для типа "Часы простоя"`, + technicComment: (index: number, title: string, hoursType: string): string => + `Техника #${index} (${title}): укажите комментарий для типа "${hoursType}"` + }, + picker: { + titles: { + object: 'Выбор объекта', + company: 'Выбор компании', + work: 'Выбор работы', + profession: 'Выбор профессии', + technic: 'Выбор техники' + }, + close: 'Закрыть', + searchPlaceholder: 'Поиск...', + loading: 'Загрузка...', + notFound: 'Ничего не найдено', + emptyLevel: 'Нет работ на этом уровне', + allWorks: 'Все работы', + selectCurrent: (title: string): string => `Выбрать «${title}»`, + filters: 'Фильтры', + allGroups: 'Все', + createNew: '+ Создать новую позицию', + addInto: (title: string): string => `+ Добавить в «${title}»`, + newTitle: 'Название *', + newUnit: 'Единица изм. (м, т, шт...)', + newPlan: 'План (объем)', + nestedIn: (title: string): string => `Вложена в: ${title}`, + parentPlaceholder: 'Родительская работа (необязательно)', + create: 'Создать', + creating: 'Создание...', + cancel: 'Отмена' + }, + // Тексты ошибок запросов — попадают в «Ошибка загрузки: …» / «Ошибка сохранения: …». + errors: { + spList: (status: number, statusText: string, body: string): string => + `SP REST error: ${status} ${statusText} ${body}`, + spItem: (status: number, body: string): string => `SP REST error: ${status} ${body}`, + spCreate: (listTitle: string, body: string): string => `Ошибка создания элемента в "${listTitle}": ${body}`, + folderCreate: (status: number, body: string): string => `Не удалось создать папку: ${status} ${body}`, + photoUpload: (fileName: string, status: number, body: string): string => + `Ошибка загрузки фото "${fileName}": ${status} ${body}`, + dataverse: (method: string, status: number, body: string): string => + `Dataverse ${method} error: ${status} — ${body}`, + emptyReportId: 'reportId is empty — cannot bind report data' + } +} as const; diff --git a/src/webparts/stroyBot/pages/report/usePickerModal.ts b/src/webparts/stroyBot/pages/report/usePickerModal.ts new file mode 100644 index 0000000..fc2016e --- /dev/null +++ b/src/webparts/stroyBot/pages/report/usePickerModal.ts @@ -0,0 +1,151 @@ +import * as React from 'react'; +import { LISTS } from '../../config/lists'; +import { IReportLookups, IWorkItem } from './reportTypes'; +import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes'; +import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker'; +import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates'; +import { newJobPath } from './reportRules'; +import { IReportFormApi } from './useReportForm'; +import { IReportServices } from './reportServices'; +import { REPORT_TEXTS } from './texts'; + +interface IActivePicker { + kind: PickerKind; + /** Работа, к которой добавляется персонал / техника (не задано — общая секция). */ + targetWorkId?: string; +} + +export interface IPickerModalApi { + isOpen: boolean; + options: IPickerOptions | undefined; + items: IPickerItem[]; + isLoading: boolean; + open: (kind: PickerKind, targetWorkId?: string) => void; + select: (item: IPickerItem) => void; + createNew: (data: IPickerCreateData) => Promise; + dismiss: () => void; +} + +interface IPickerDeps { + services: IReportServices; + report: IReportFormApi; + projectId: number; +} + +function newWorkItem(item: IPickerItem, lookups: IReportLookups, totalFact: number): IWorkItem { + const job = lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0]; + return { + id: createLocalId(), + jobId: String(item.id), + jobTitle: job?.cree9_title || item.title, + jobUnit: job?.cree9_unit || '', + value: 0, + plan: job?.cree9_volume || 0, + totalFact, + comment: '', + professions: [], + technics: [] + }; +} + +/** Справочник для пикера: грузится при открытии, если ещё не загружен (работы — каждый раз). */ +async function loadLookupFor(kind: PickerKind, deps: IPickerDeps): Promise> { + const { services, report, projectId } = deps; + const { lookups, form } = report; + switch (kind) { + case 'object': + return lookups.objects.length === 0 + ? { objects: await services.sp.getObjectsByProject(report.projectName) } + : {}; + case 'company': + return lookups.companies.length === 0 ? { companies: await services.sp.getCompaniesWithFallback(projectId) } : {}; + case 'work': + return form.objectId ? { jobs: await services.dv.getJobsByObjectId(form.objectId) } : {}; + case 'profession': + return lookups.professions.length === 0 + ? { professions: await services.sp.getProfessionsWithFallback(projectId) } + : {}; + case 'technic': + return lookups.technics.length === 0 ? { technics: await services.sp.getTechnics() } : {}; + } +} + +/** Пикер формы отчёта: какой справочник открыт, его загрузка, выбор и создание позиций. */ +export function usePickerModal(deps: IPickerDeps): IPickerModalApi { + const { services, report } = deps; + const [active, setActive] = React.useState(undefined); + const [isLoading, setIsLoading] = React.useState(false); + + const open = (kind: PickerKind, targetWorkId?: string): void => { + setActive({ kind, targetWorkId }); + setIsLoading(true); + loadLookupFor(kind, deps) + .then((loaded) => report.updateLookups(() => loaded)) + // Как в старом коде: ошибка загрузки справочника молча оставляет список пустым. + .catch(() => undefined) + .then(() => setIsLoading(false)) + .catch(() => undefined); + }; + + const close = (): void => setActive(undefined); + + const applySelection = async (picker: IActivePicker, item: IPickerItem): Promise => { + report.clearValidation(); + const { kind, targetWorkId } = picker; + if (kind === 'object') { + const object = report.lookups.objects.filter((candidate) => candidate.Id === item.id)[0]; + const objectPath = object?.fullPath || item.title; + report.updateForm((prev) => ({ ...prev, objectId: Number(item.id), objectPath, works: [] })); + report.updateLookups(() => ({ jobs: [] })); + } else if (kind === 'company') { + report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title })); + } else if (kind === 'work') { + // 🐞 Как в старом коде: только что созданной работы ещё нет в lookups.jobs этого рендера, + // поэтому у неё название = полный путь, ед. изм. пустая и план 0. + const totalFact = await services.dv.getJobTotalFact(String(item.id)); + const work = newWorkItem(item, report.lookups, totalFact); + report.updateForm((prev) => addWork(prev, work)); + } else { + const resource = createResourceItem(Number(item.id), item.title); + report.updateForm((prev) => addResource(prev, { kind, workId: targetWorkId }, resource)); + } + close(); + }; + + const select = (item: IPickerItem): void => { + if (!active) return; + // Ошибка (например, при подсчёте факта работы) оставляет пикер открытым, как раньше. + applySelection(active, item).catch((error: unknown) => console.error(error)); + }; + + const createNew = async (data: IPickerCreateData): Promise => { + if (active?.kind === 'technic') { + const created = await services.sp.createListItem(LISTS.technics, { Title: data.title }); + report.updateLookups((prev) => ({ technics: [...prev.technics, { Id: created.Id, Title: data.title }] })); + return { id: created.Id, title: data.title }; + } + const objectId = report.form.objectId; + if (!objectId) throw new Error(REPORT_TEXTS.messages.selectObjectFirst); + const created = await services.dv.createJob({ + title: data.title, + unit: data.unit || '', + objectId, + volume: data.plan, + parentId: data.parentId + }); + const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs); + report.updateLookups((prev) => ({ jobs: [...prev.jobs, { ...created, fullPath }] })); + return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit }; + }; + + return { + isOpen: !!active, + options: active ? PICKER_OPTIONS[active.kind] : undefined, + items: active ? toPickerItems(active.kind, report.lookups) : [], + isLoading, + open, + select, + createNew, + dismiss: close + }; +} diff --git a/src/webparts/stroyBot/pages/report/useReportForm.ts b/src/webparts/stroyBot/pages/report/useReportForm.ts new file mode 100644 index 0000000..d5c8bb6 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/useReportForm.ts @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { confirmModal } from '@tnsu/ui-kit'; +import { IReportAccess, IReportFormState, IReportLookups, IReportRouteParams, ReportFormMode } from './reportTypes'; +import { createEmptyForm } from './reportFormUpdates'; +import { isFormReadOnly, todayIsoDate } from './reportRules'; +import { validateReportForm } from './reportValidation'; +import { IReportLoadSink, loadReportForm } from './reportLoader'; +import { saveReport } from './reportSave'; +import { IReportServices } from './reportServices'; +import { REPORT_TEXTS } from './texts'; + +const MESSAGES = REPORT_TEXTS.messages; + +const EMPTY_LOOKUPS: IReportLookups = { + objects: [], + companies: [], + shifts: [], + professions: [], + technics: [], + jobs: [] +}; + +export type FormUpdater = (prev: IReportFormState) => IReportFormState; +export type LookupsUpdater = (prev: IReportLookups) => Partial; + +export interface IReportFormApi { + form: IReportFormState; + mode: ReportFormMode; + isReadOnly: boolean; + projectName: string; + access: IReportAccess | undefined; + isApproved: boolean; + lookups: IReportLookups; + isLoading: boolean; + isSaving: boolean; + errorMessage: string; + successMessage: string; + validationErrors: string[]; + updateForm: (updater: FormUpdater) => void; + updateLookups: (updater: LookupsUpdater) => void; + setErrorMessage: (message: string) => void; + clearValidation: () => void; + save: () => Promise; + approve: () => Promise; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Всё состояние формы отчёта живёт здесь (один источник истины): данные формы, режим, справочники, + * сообщения. Секции получают срезы и колбэки. Логика загрузки / сохранения — в reportLoader / reportSave. + */ +export function useReportForm(services: IReportServices, params: IReportRouteParams): IReportFormApi { + const [form, setForm] = React.useState(() => createEmptyForm(todayIsoDate(new Date()))); + const [mode, setMode] = React.useState('create'); + const [projectName, setProjectName] = React.useState(''); + const [access, setAccess] = React.useState(undefined); + const [isApproved, setIsApproved] = React.useState(false); + const [lookups, setLookups] = React.useState(EMPTY_LOOKUPS); + const [isLoading, setIsLoading] = React.useState(true); + const [isSaving, setIsSaving] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(''); + const [successMessage, setSuccessMessage] = React.useState(''); + const [validationErrors, setValidationErrors] = React.useState([]); + // После первого сохранения нового отчёта форма знает его id (как setExistingReportId в старом коде). + const [existingReportId, setExistingReportId] = React.useState(params.reportId); + + const updateLookups = React.useCallback((updater: LookupsUpdater): void => { + setLookups((prev) => ({ ...prev, ...updater(prev) })); + }, []); + + useReportLoading(services, params, { + setProjectName, + setMode, + setAccess, + setApproved: setIsApproved, + setLookups: (partial) => updateLookups(() => partial), + setForm, + setIsLoading, + setErrorMessage + }); + + const save = async (): Promise => { + const errors = validateReportForm(form); + setValidationErrors(errors); + if (errors.length > 0) return; + + setIsSaving(true); + setErrorMessage(''); + setSuccessMessage(''); + try { + const result = await saveReport(services, params.projectId, form, existingReportId); + setForm((prev) => + result.photoFolderUrl === undefined + ? { ...prev, reportNumber: result.reportNumber } + : { ...prev, reportNumber: result.reportNumber, photoFolderUrl: result.photoFolderUrl, photos: [] } + ); + setExistingReportId(result.reportId); + setSuccessMessage(MESSAGES.saved); + } catch (error) { + setErrorMessage(MESSAGES.saveError(messageOf(error))); + } finally { + setIsSaving(false); + } + }; + + const approve = async (): Promise => { + if (!existingReportId) return; + if (!(await confirmModal({ message: MESSAGES.approveConfirm }))) return; + + setIsSaving(true); + setErrorMessage(''); + try { + await services.dv.approveReport(existingReportId); + setSuccessMessage(MESSAGES.approved); + // 🐞 Как в старом коде: флаг «утверждён» не обновляется, бейдж остаётся «На утверждении». + setMode('readonly'); + } catch (error) { + setErrorMessage(MESSAGES.approveError(messageOf(error))); + } finally { + setIsSaving(false); + } + }; + + return { + form, + mode, + isReadOnly: isFormReadOnly(mode), + projectName, + access, + isApproved, + lookups, + isLoading, + isSaving, + errorMessage, + successMessage, + validationErrors, + updateForm: setForm, + updateLookups, + setErrorMessage, + clearValidation: () => setValidationErrors([]), + save, + approve + }; +} + +interface ILoadingSetters extends IReportLoadSink { + setIsLoading: (isLoading: boolean) => void; + setErrorMessage: (message: string) => void; +} + +/** Загрузка один раз на открытую форму (ReportPage пересоздаёт форму при смене адреса). */ +function useReportLoading(services: IReportServices, params: IReportRouteParams, setters: ILoadingSetters): void { + const settersRef = React.useRef(setters); + settersRef.current = setters; + + React.useEffect(() => { + let isCancelled = false; + // Ответы, пришедшие после ухода со страницы, не трогают состояние. + const sink: IReportLoadSink = { + setProjectName: (name) => !isCancelled && settersRef.current.setProjectName(name), + setMode: (value) => !isCancelled && settersRef.current.setMode(value), + setAccess: (value) => !isCancelled && settersRef.current.setAccess(value), + setApproved: (value) => !isCancelled && settersRef.current.setApproved(value), + setLookups: (value) => !isCancelled && settersRef.current.setLookups(value), + setForm: (value) => !isCancelled && settersRef.current.setForm(value) + }; + + const run = async (): Promise => { + settersRef.current.setIsLoading(true); + settersRef.current.setErrorMessage(''); + try { + const outcome = await loadReportForm(services, params, new Date(), sink); + if (!isCancelled && outcome === 'missingProjectId') { + settersRef.current.setErrorMessage(MESSAGES.missingProjectId); + } + } catch (error) { + if (!isCancelled) settersRef.current.setErrorMessage(MESSAGES.loadError(messageOf(error))); + } finally { + if (!isCancelled) settersRef.current.setIsLoading(false); + } + }; + // run() сам обрабатывает ошибки — catch только чтобы промис не остался «висящим». + run().catch(() => undefined); + + return () => { + isCancelled = true; + }; + // Параметры адреса в зависимостях не нужны: при их смене ReportPage монтирует форму заново (key). + }, [services]); +}