From 704d5ce3296e9b08762f01a5d08f56f8f869f449 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: Thu, 15 Oct 2026 17:16:01 +0500 Subject: [PATCH] feat: port stroy-bot-report-list as reports page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the project report list into the app as ?page=reports&projectId=. Behaviour is kept 1:1 with the legacy web part: the same «Доступы» query (lookup user/Id + project/Id), «Компании»/«Объекты» reference loads, Dataverse cree9_reports query with the date filter and ordering, client side sort and search, permission rules for send/copy/delete, soft delete via PATCH cree9_deleted=true and all user-facing texts. Known bugs are ported as-is (no Dataverse paging, default week in UTC). Links to SendReport.aspx now point to the in-app report form route; the report and copy links still open in a new tab. Deviations are listed in docs/MIGRATION-CHECKLIST.md section 4. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/MIGRATION-CHECKLIST.md | 7 + .../pages/reports/DeleteReportDialog.tsx | 49 +++ .../stroyBot/pages/reports/ReportCard.tsx | 96 ++++++ .../stroyBot/pages/reports/ReportCardBody.tsx | 33 ++ .../pages/reports/ReportsPage.module.scss | 308 ++++++++++++++++++ .../stroyBot/pages/reports/ReportsPage.tsx | 90 +++++ .../stroyBot/pages/reports/ReportsToolbar.tsx | 46 +++ .../stroyBot/pages/reports/SendReportCard.tsx | 25 ++ .../pages/reports/reportListLoader.ts | 68 ++++ .../pages/reports/reportsRepository.test.ts | 192 +++++++++++ .../pages/reports/reportsRepository.ts | 141 ++++++++ .../pages/reports/reportsRules.test.ts | 149 +++++++++ .../stroyBot/pages/reports/reportsRules.ts | 129 ++++++++ .../stroyBot/pages/reports/reportsTypes.ts | 70 ++++ src/webparts/stroyBot/pages/reports/texts.ts | 45 +++ .../stroyBot/pages/reports/useReportList.ts | 110 +++++++ 16 files changed, 1558 insertions(+) create mode 100644 src/webparts/stroyBot/pages/reports/DeleteReportDialog.tsx create mode 100644 src/webparts/stroyBot/pages/reports/ReportCard.tsx create mode 100644 src/webparts/stroyBot/pages/reports/ReportCardBody.tsx create mode 100644 src/webparts/stroyBot/pages/reports/ReportsPage.module.scss create mode 100644 src/webparts/stroyBot/pages/reports/ReportsPage.tsx create mode 100644 src/webparts/stroyBot/pages/reports/ReportsToolbar.tsx create mode 100644 src/webparts/stroyBot/pages/reports/SendReportCard.tsx create mode 100644 src/webparts/stroyBot/pages/reports/reportListLoader.ts create mode 100644 src/webparts/stroyBot/pages/reports/reportsRepository.test.ts create mode 100644 src/webparts/stroyBot/pages/reports/reportsRepository.ts create mode 100644 src/webparts/stroyBot/pages/reports/reportsRules.test.ts create mode 100644 src/webparts/stroyBot/pages/reports/reportsRules.ts create mode 100644 src/webparts/stroyBot/pages/reports/reportsTypes.ts create mode 100644 src/webparts/stroyBot/pages/reports/texts.ts create mode 100644 src/webparts/stroyBot/pages/reports/useReportList.ts diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index 75bafe2..53cd08b 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -135,6 +135,13 @@ 🐞 Нет пагинации Dataverse (`@odata.nextLink` не читается). 🐞 Неделя по умолчанию считается в UTC. +**Сознательные отличия от report-list:** +- Карточка отчёта и «Создать на основе» открывают раздел «Форма отчёта» этого приложения (`?page=report&projectId=&reportId=` / `©FromId=`) — по-прежнему в новой вкладке. «Создать на основе» теперь настоящая ссылка (работает Ctrl+клик / средняя кнопка). +- «Отправить отчёт» — переход в раздел формы отчёта в этой же вкладке (раньше `SendReport.aspx?projectId=` тоже в этой вкладке). +- Списки читаются с сайта из настройки «URL сайта SharePoint» (по умолчанию `…/sites/stroy`), а не с сайта, где стоит web part. Id пользователя — по-прежнему `legacyPageContext.userId`. +- Тёмная тема (`onThemeChanged`) и загрузка шрифтов не переносились: шрифты грузит оболочка, оформление — ui-kit. +- Модалка удаления — `Dialog` из ui-kit (тексты те же, номер отчёта жирным). + --- ## 5. Форма отчёта (stroy-bot-report) — `?page=report&projectId=[&reportId=|©FromId=]` diff --git a/src/webparts/stroyBot/pages/reports/DeleteReportDialog.tsx b/src/webparts/stroyBot/pages/reports/DeleteReportDialog.tsx new file mode 100644 index 0000000..45b17f6 --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/DeleteReportDialog.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Button, Dialog } from '@tnsu/ui-kit'; +import { IReportCard } from './reportsTypes'; +import { REPORTS_TEXTS } from './texts'; +import styles from './ReportsPage.module.scss'; + +const TEXTS = REPORTS_TEXTS.deleteDialog; + +interface Props { + /** Отчёт, который просят удалить; undefined — окно закрыто. */ + report: IReportCard | undefined; + onCancel: () => void; + onConfirm: (report: IReportCard) => void; +} + +/** + * Подтверждение удаления. Dialog, а не confirmModal: в тексте номер отчёта выделен + * жирным, как в старой модалке, а confirmModal принимает только строку. + */ +export const DeleteReportDialog: React.FC = ({ report, onCancel, onConfirm }) => ( + + + + + } + > +

+ {report ? ( + <> + {TEXTS.textBefore} + {TEXTS.textNumber(report.number)} + {TEXTS.textAfter(report.date)} + + ) : ( + TEXTS.textUnknown + )} +

+
+); diff --git a/src/webparts/stroyBot/pages/reports/ReportCard.tsx b/src/webparts/stroyBot/pages/reports/ReportCard.tsx new file mode 100644 index 0000000..69ac900 --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/ReportCard.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { Badge, Icon } from '@tnsu/ui-kit'; +import { useRouter } from '../../app/routing/RouterContext'; +import { IReportAccess, IReportCard } from './reportsTypes'; +import { canCreateReport, canDeleteReport } from './reportsRules'; +import { REPORTS_TEXTS } from './texts'; +import { ReportCardBody } from './ReportCardBody'; +import styles from './ReportsPage.module.scss'; + +const TEXTS = REPORTS_TEXTS.card; + +interface Props { + report: IReportCard; + access: IReportAccess; + projectId: string; + onDelete: (report: IReportCard) => void; +} + +/** + * Карточка отчёта. Отчёт и «Создать на основе» открываются в новой вкладке — как в старом + * web part (window.open … '_blank'), но адрес теперь — раздел формы отчёта этого приложения. + */ +export const ReportCard: React.FC = ({ report, access, projectId, onDelete }) => { + const { buildHref } = useRouter(); + const reportHref = buildHref({ page: 'report', projectId, reportId: report.id }); + const copyHref = buildHref({ page: 'report', projectId, copyFromId: report.id }); + + const openReport = (): void => { + window.open(reportHref, '_blank', 'noopener,noreferrer'); + }; + + // Как в старом коде: Enter/Space внутри карточки (в том числе на её кнопках) открывают отчёт. + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openReport(); + }; + + const onDeleteClick = (event: React.MouseEvent): void => { + event.stopPropagation(); + onDelete(report); + }; + + const className = report.approved ? `${styles.card} ${styles.cardApproved}` : styles.card; + + return ( +
+
+ #{report.number} + {report.date} + {report.approved && ( + + + {TEXTS.approved} + + + )} + {canCreateReport(access) && ( + event.stopPropagation()} + > + + + )} + {canDeleteReport(access, report) && ( + + )} + +
+ +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reports/ReportCardBody.tsx b/src/webparts/stroyBot/pages/reports/ReportCardBody.tsx new file mode 100644 index 0000000..b4a8fff --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/ReportCardBody.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { IReportCard } from './reportsTypes'; +import { REPORTS_TEXTS } from './texts'; +import styles from './ReportsPage.module.scss'; + +const TEXTS = REPORTS_TEXTS.card; + +interface Props { + report: IReportCard; +} + +/** Поля карточки: пользователь, компания, путь объекта и (если есть) комментарий. */ +export const ReportCardBody: React.FC = ({ report }) => ( +
+
+ {TEXTS.user} + {report.createdBy} +
+
+ {TEXTS.company} + {report.company} +
+
+ {TEXTS.object} + {report.objectPath} +
+ {report.comment && ( +
+ {TEXTS.comment} {report.comment} +
+ )} +
+); diff --git a/src/webparts/stroyBot/pages/reports/ReportsPage.module.scss b/src/webparts/stroyBot/pages/reports/ReportsPage.module.scss new file mode 100644 index 0000000..d569174 --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/ReportsPage.module.scss @@ -0,0 +1,308 @@ +@import '../../../../styles/design-tokens'; + +// Размеры из stroy-bot-report-list: минимальная ширина карточки в сетке и высота тулбара. +$card-min-width: 300px; +$toolbar-height: 48px; +$search-min-width: 180px; +$send-card-min-height: 72px; +$card-padding-x: 18px; + +.page { + position: relative; + width: 100%; + min-height: 200px; // как в старом web part: оверлей загрузки не схлопывается +} + +.busyOverlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.85); + border-radius: $r-lg; +} + +.loading { + display: flex; + justify-content: center; + padding: $sp-8 $sp-4; +} + +.projectTitle { + @include page-header; + margin: 0 0 $sp-6; + box-shadow: $sh-2; + font-family: $font-serif; + font-size: $fs-page-title; + font-weight: 600; + color: $white; + line-height: 1.3; +} + +// ─── Тулбар ────────────────────────────────────────────────────────────────── +.toolbar { + display: flex; + align-items: stretch; + margin-bottom: $sp-4; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + box-shadow: $sh-1; + overflow: hidden; +} + +.dateFilters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $sp-2; + flex-shrink: 0; + min-height: $toolbar-height; + padding: $sp-2 $sp-3; + background: $gray-50; + border-right: 1px solid $gray-200; +} + +.dateLabel { + display: flex; + align-items: center; + gap: $sp-1; + font-size: $fs-sm; + font-weight: 600; + color: $gray-500; + white-space: nowrap; +} + +.toolbarRight { + flex: 1; + display: flex; + align-items: center; + gap: $sp-3; + min-width: 0; + padding: 0 $sp-3; +} + +.searchBox { + flex: 1; + display: flex; + align-items: center; + gap: $sp-1; + min-width: $search-min-width; + color: $gray-400; +} + +.searchInput { + flex: 1; + min-width: 0; +} + +// ─── Карточка «Отправить отчёт» ────────────────────────────────────────────── +.sendReportCard { + display: flex; + align-items: center; + gap: $sp-3; + min-height: $send-card-min-height; + margin-bottom: $sp-4; + padding: $sp-4 $sp-6; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + box-shadow: $sh-1; + color: inherit; + text-decoration: none; + @include card-hover-gold; +} + +.sendReportIcon { + display: inline-flex; + flex-shrink: 0; + color: $gold-600; +} + +.sendReportBody { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.sendReportTitle { + font-size: $fs-body; + font-weight: 600; + color: $navy-900; +} + +.sendReportSubtitle { + font-size: $fs-label; + color: $gray-400; +} + +.arrow { + display: inline-flex; + flex-shrink: 0; + margin-left: auto; + color: $gray-400; +} + +// ─── Сетка карточек отчётов ────────────────────────────────────────────────── +.cardsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax($card-min-width, 1fr)); + gap: $sp-4; +} + +.card { + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + box-shadow: $sh-1; + overflow: hidden; + cursor: pointer; + @include card-hover-gold; + + &:focus-visible { + outline: 2px solid $gold-500; + outline-offset: 2px; + } +} + +.cardApproved { + border-left: 3px solid $green-600; +} + +.cardHeader { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $sp-2; + padding: $sp-3 $card-padding-x; + background: linear-gradient(180deg, $gray-50, $white); + border-bottom: 1px solid $gray-100; +} + +.cardNumber { + font-family: $font-serif; + font-size: 16px; // номер отчёта крупнее основного текста — как в старом web part + font-weight: 700; + color: $navy-900; +} + +.cardDate { + margin-left: $sp-2; + font-size: $fs-caption; + color: $gray-400; +} + +.approvedBadge { + display: inline-flex; + align-items: center; + gap: $sp-1; +} + +.cardAction { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 30px; + height: 30px; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: $r-sm; + color: $gray-400; + cursor: pointer; + + &:hover { + color: $navy-800; + background: $navy-50; + border-color: $gray-200; + } +} + +.cardActionDanger:hover { + color: $red-500; + background: $red-50; + border-color: transparent; +} + +.cardBody { + display: flex; + flex-direction: column; + gap: $sp-2; + padding: $sp-3 $card-padding-x; +} + +.cardRow { + display: flex; + align-items: baseline; + gap: $sp-1; + font-size: $fs-sm; + line-height: 1.4; +} + +.cardRowObject { + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.cardLabel { + font-size: $fs-overline; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + white-space: nowrap; + color: $gray-400; +} + +.objectPath { + font-size: $fs-sm; + color: $gray-700; + word-break: break-word; +} + +.cardComment { + padding-top: $sp-1; + border-top: 1px solid $gray-100; + font-size: $fs-label; + line-height: 1.5; + color: $gray-500; + word-break: break-word; +} + +.errorBox { + margin-bottom: $sp-4; +} + +.deleteText { + margin: 0; + font-size: $fs-sm; + line-height: 1.6; + color: $gray-500; +} + +@include below($bp-md) { + .toolbar { + flex-direction: column; + } + + .dateFilters { + border-right: 0; + border-bottom: 1px solid $gray-200; + } + + .toolbarRight { + min-height: $toolbar-height; + padding: $sp-2 $sp-3; + } +} + +@include below($bp-sm) { + .cardsGrid { + grid-template-columns: 1fr; + } +} diff --git a/src/webparts/stroyBot/pages/reports/ReportsPage.tsx b/src/webparts/stroyBot/pages/reports/ReportsPage.tsx new file mode 100644 index 0000000..3c1e933 --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/ReportsPage.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { Alert, Spinner } from '@tnsu/ui-kit'; +import { useRouter } from '../../app/routing/RouterContext'; +import { EmptyState } from '../../shared/components/EmptyState'; +import { IDateRange, IReportCard } from './reportsTypes'; +import { canCreateReport, filterReports, getCurrentWeekRange, parseReportsProjectId } from './reportsRules'; +import { useReportList } from './useReportList'; +import { ReportsToolbar } from './ReportsToolbar'; +import { SendReportCard } from './SendReportCard'; +import { ReportCard } from './ReportCard'; +import { DeleteReportDialog } from './DeleteReportDialog'; +import { REPORTS_TEXTS } from './texts'; +import styles from './ReportsPage.module.scss'; + +/** Отчёты по проекту (бывший stroy-bot-report-list): `?page=reports&projectId=`. */ +export const ReportsPage: React.FC = () => { + const { route } = useRouter(); + const projectId = parseReportsProjectId(route.projectId); + const [range, setRange] = React.useState(() => getCurrentWeekRange(new Date())); + const [search, setSearch] = React.useState(''); + const [pendingDelete, setPendingDelete] = React.useState(undefined); + const list = useReportList(projectId, range); + + if (list.phase === 'loading') { + return ( +
+ +
+ ); + } + + if (list.phase === 'denied') { + return ( + + ); + } + + const routeProjectId = String(projectId); + const visibleReports = list.reports ? filterReports(list.reports, search) : undefined; + + const confirmDelete = (report: IReportCard): void => { + setPendingDelete(undefined); + list.deleteReport(report.id); + }; + + return ( +
+

{list.projectName}

+ list.applyFilter(range)} + search={search} + onSearchChange={setSearch} + /> + {list.errorMessage && ( +
+ {list.errorMessage} +
+ )} + {canCreateReport(list.access) && } + + {visibleReports && visibleReports.length > 0 && ( +
+ {visibleReports.map((report) => ( + + ))} +
+ )} + {visibleReports && visibleReports.length === 0 && } + + setPendingDelete(undefined)} + onConfirm={confirmDelete} + /> + {list.isBusy && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reports/ReportsToolbar.tsx b/src/webparts/stroyBot/pages/reports/ReportsToolbar.tsx new file mode 100644 index 0000000..e71ce22 --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/ReportsToolbar.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Button, Icon, TextInput } from '@tnsu/ui-kit'; +import { IDateRange } from './reportsTypes'; +import { REPORTS_TEXTS } from './texts'; +import styles from './ReportsPage.module.scss'; + +interface Props { + range: IDateRange; + onRangeChange: (range: IDateRange) => void; + onApply: () => void; + search: string; + onSearchChange: (value: string) => void; +} + +/** Период «С: / По:» с кнопкой «Применить» и мгновенный поиск по карточкам. */ +export const ReportsToolbar: React.FC = ({ range, onRangeChange, onApply, search, onSearchChange }) => ( +
+
+ + +
+
+ +
+ + + + +
+
+
+); diff --git a/src/webparts/stroyBot/pages/reports/SendReportCard.tsx b/src/webparts/stroyBot/pages/reports/SendReportCard.tsx new file mode 100644 index 0000000..b8a8faa --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/SendReportCard.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Icon } from '@tnsu/ui-kit'; +import { RouteLink } from '../../app/routing/RouteLink'; +import { REPORTS_TEXTS } from './texts'; +import styles from './ReportsPage.module.scss'; + +interface Props { + projectId: string; +} + +/** «Отправить отчёт» — форма нового отчёта в этой же вкладке (раньше — SendReport.aspx?projectId=). */ +export const SendReportCard: React.FC = ({ projectId }) => ( + + + + {REPORTS_TEXTS.sendReport.title} + {REPORTS_TEXTS.sendReport.subtitle} + + + +); diff --git a/src/webparts/stroyBot/pages/reports/reportListLoader.ts b/src/webparts/stroyBot/pages/reports/reportListLoader.ts new file mode 100644 index 0000000..7b563cd --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/reportListLoader.ts @@ -0,0 +1,68 @@ +import { IHttpClient } from '../../services/http/IHttpClient'; +import { IDateRange, IObjectNode, IReportAccess, IReportCard } from './reportsTypes'; +import { fetchCompanies, fetchObjectRows, fetchReportAccess, fetchReportRows } from './reportsRepository'; +import { errorMessage, toObjectNodes, toReportCards } from './reportsRules'; +import { REPORTS_TEXTS } from './texts'; + +// Порядок первой загрузки report-list: доступ → справочники (параллельно) → отчёты. + +export interface IReportListDeps { + sharePoint: IHttpClient; + dataverse: IHttpClient; + siteUrl: string; + dataverseUrl: string; + userId: number | undefined; +} + +/** Справочники для подписей карточек: компания по Id, путь объекта. */ +export interface IReferenceData { + companies: Map; + objects: Map; +} + +export type ReportListInitResult = + | { kind: 'denied' } + | { + kind: 'ready'; + projectName: string; + access: IReportAccess; + reference: IReferenceData; + /** undefined — загрузка упала: экран показывается с ошибкой, без карточек. */ + reports: IReportCard[] | undefined; + errorMessage: string; + }; + +export const EMPTY_REFERENCE: IReferenceData = { companies: new Map(), objects: new Map() }; + +export async function loadReportCards( + deps: IReportListDeps, + reference: IReferenceData, + projectId: number, + range: IDateRange +): Promise { + const rows = await fetchReportRows(deps.dataverse, deps.dataverseUrl, projectId, range); + return toReportCards(rows, reference.companies, reference.objects); +} + +export async function loadReportList( + deps: IReportListDeps, + projectId: number, + range: IDateRange +): Promise { + const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, deps.userId, projectId); + if (!accessResult) return { kind: 'denied' }; + + const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access }; + let reference = EMPTY_REFERENCE; + try { + const [companies, objectRows] = await Promise.all([ + fetchCompanies(deps.sharePoint, deps.siteUrl), + fetchObjectRows(deps.sharePoint, deps.siteUrl) + ]); + reference = { companies, objects: toObjectNodes(objectRows) }; + const reports = await loadReportCards(deps, reference, projectId, range); + return { ...base, reference, reports, errorMessage: '' }; + } catch (error) { + return { ...base, reference, reports: undefined, errorMessage: REPORTS_TEXTS.errors.init(errorMessage(error)) }; + } +} diff --git a/src/webparts/stroyBot/pages/reports/reportsRepository.test.ts b/src/webparts/stroyBot/pages/reports/reportsRepository.test.ts new file mode 100644 index 0000000..114173d --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/reportsRepository.test.ts @@ -0,0 +1,192 @@ +import { + companiesUrl, + fetchCompanies, + fetchReportAccess, + fetchReportRows, + objectsUrl, + reportAccessUrl, + reportsUrl, + softDeleteReport +} from './reportsRepository'; +import { loadReportList } from './reportListLoader'; +import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient'; + +const SITE = 'https://tnsukz.sharepoint.com/sites/stroy'; +const DV = 'https://tansu.crm5.dynamics.com'; +const WEEK = { from: '2026-10-12', to: '2026-10-18' }; + +describe('адреса запросов (1:1 со stroy-bot-report-list)', () => { + it('«Доступы» по lookup user/Id и project/Id, id проекта в кавычках', () => { + expect(reportAccessUrl(SITE, 42, 5)).toEqual( + `${SITE}/_api/web/lists/getbytitle('Доступы')/items` + + `?$select=read,edit0,aproove,user/Id,project/Id,project/Title` + + `&$expand=user,project` + + `&$filter=user/Id eq 42 and project/Id eq '5'` + ); + }); + + it('справочники «Компании» и «Объекты» — до 5000 строк', () => { + expect(companiesUrl(SITE)).toEqual(`${SITE}/_api/web/lists/getbytitle('Компании')/items?$select=Id,Title&$top=5000`); + expect(objectsUrl(SITE)).toEqual( + `${SITE}/_api/web/lists/getbytitle('Объекты')/items?$select=Id,Title,parent/Id&$expand=parent&$top=5000` + ); + }); + + it('отчёты: проект, неудалённые, период, сортировка; $filter кодируется', () => { + const filter = + 'cree9_projectid eq 5 and (cree9_deleted eq false or cree9_deleted eq null)' + + ' and cree9_report_date ge 2026-10-12T00:00:00Z and cree9_report_date le 2026-10-18T23:59:59Z'; + expect(reportsUrl(DV, 5, WEEK)).toEqual( + `${DV}/api/data/v9.2/cree9_reports` + + `?$select=cree9_reportid,cree9_report_date,cree9_number,cree9_companyid,` + + `cree9_objectid,cree9_aprooved,cree9_comment,cree9_deleted,_createdby_value,createdby` + + `&$expand=createdby($select=fullname)&$filter=${encodeURIComponent(filter)}` + + `&$orderby=cree9_report_date desc,cree9_number desc` + ); + }); + + it('пустые даты не попадают в фильтр', () => { + const url = reportsUrl(DV, 5, { from: '', to: '' }); + expect(url).toContain( + `$filter=${encodeURIComponent('cree9_projectid eq 5 and (cree9_deleted eq false or cree9_deleted eq null)')}&` + ); + }); +}); + +describe('fetchReportAccess', () => { + it('первая запись: название проекта и права', async () => { + const sp = createFakeHttpClient([ + { + match: 'Доступы', + response: { body: { value: [{ read: true, edit0: false, aproove: true, project: { Id: 5, Title: 'Сункар' } }] } } + } + ]); + expect(await fetchReportAccess(sp, SITE, 42, 5)).toEqual({ + projectName: 'Сункар', + access: { read: true, edit0: false, aproove: true } + }); + }); + + it('нет записей, нет прав или ошибка — нет доступа', async () => { + const empty = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]); + const noFlags = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [{ read: false }] } } }]); + const failing = createFakeHttpClient([{ match: 'Доступы', response: { status: 500 } }]); + const throwing = createFakeHttpClient([]); + expect(await fetchReportAccess(empty, SITE, 42, 5)).toBeUndefined(); + expect(await fetchReportAccess(noFlags, SITE, 42, 5)).toBeUndefined(); + expect(await fetchReportAccess(failing, SITE, 42, 5)).toBeUndefined(); + expect(await fetchReportAccess(throwing, SITE, 42, 5)).toBeUndefined(); + }); +}); + +describe('fetchCompanies', () => { + it('ошибка ответа — пустой справочник', async () => { + const sp = createFakeHttpClient([{ match: 'Компании', response: { status: 404 } }]); + expect((await fetchCompanies(sp, SITE)).size).toBe(0); + }); +}); + +describe('fetchReportRows', () => { + it('GET с заголовками OData, как в старом коде', async () => { + const dv = createFakeHttpClient([{ match: 'cree9_reports', response: { body: { value: [] } } }]); + await fetchReportRows(dv, DV, 5, WEEK); + expect(dv.requests[0].request).toEqual({ + method: 'GET', + headers: { 'OData-MaxVersion': '4.0', 'OData-Version': '4.0', Accept: 'application/json' } + }); + }); + + it('ошибка — «Dataverse error: {status} {текст ответа}»', async () => { + const dv = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'boom' } }]); + await expect(fetchReportRows(dv, DV, 5, WEEK)).rejects.toThrow('Dataverse error: 500 boom'); + }); +}); + +describe('softDeleteReport', () => { + it('PATCH cree9_reports(id) с cree9_deleted = true', async () => { + const dv = createFakeHttpClient([{ match: 'cree9_reports(', response: { status: 204 } }]); + await softDeleteReport(dv, DV, 'abc-123'); + expect(dv.requests).toEqual([ + { + url: `${DV}/api/data/v9.2/cree9_reports(abc-123)`, + request: { + method: 'PATCH', + headers: { 'OData-MaxVersion': '4.0', 'OData-Version': '4.0', 'Content-Type': 'application/json' }, + body: '{"cree9_deleted":true}' + } + } + ]); + }); + + it('ошибка — «Dataverse error: …»', async () => { + const dv = createFakeHttpClient([{ match: 'cree9_reports(', response: { status: 403, body: 'denied' } }]); + await expect(softDeleteReport(dv, DV, 'abc')).rejects.toThrow('Dataverse error: 403 denied'); + }); +}); + +describe('loadReportList', () => { + const access = { + match: 'Доступы', + response: { body: { value: [{ read: true, edit0: true, aproove: false, project: { Title: 'Сункар' } }] } } + }; + const companies = { match: 'Компании', response: { body: { value: [{ Id: 3, Title: 'ТОО Строй' }] } } }; + const objects = { + match: 'Объекты', + response: { body: { value: [{ Id: 1, Title: 'Корпус' }, { Id: 2, Title: 'Фундамент', parent: { Id: 1 } }] } } + }; + + it('доступ → справочники → карточки с подписями', async () => { + const sharePoint = createFakeHttpClient([access, companies, objects]); + const dataverse = createFakeHttpClient([ + { + match: 'cree9_reports', + response: { + body: { + value: [ + { cree9_reportid: 'r1', cree9_number: 7, cree9_companyid: 3, cree9_objectid: 2, cree9_aprooved: true } + ] + } + } + } + ]); + const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }; + + const result = await loadReportList(deps, 5, WEEK); + + expect(result.kind).toBe('ready'); + if (result.kind !== 'ready') return; + expect(result.projectName).toBe('Сункар'); + expect(result.errorMessage).toBe(''); + expect(result.reports).toEqual([ + { + id: 'r1', + date: '—', + number: 7, + createdBy: '—', + company: 'ТОО Строй', + objectPath: 'Корпус / Фундамент', + comment: '', + approved: true + } + ]); + }); + + it('нет доступа — denied, справочники не грузятся', async () => { + const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]); + const dataverse = createFakeHttpClient([]); + const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); + expect(result).toEqual({ kind: 'denied' }); + expect(sharePoint.requests).toHaveLength(1); + }); + + it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => { + const sharePoint = createFakeHttpClient([access, companies, objects]); + const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]); + const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); + expect(result).toMatchObject({ + kind: 'ready', + reports: undefined, + errorMessage: 'Ошибка инициализации: Dataverse error: 500 fail' + }); + }); +}); diff --git a/src/webparts/stroyBot/pages/reports/reportsRepository.ts b/src/webparts/stroyBot/pages/reports/reportsRepository.ts new file mode 100644 index 0000000..f13e33c --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/reportsRepository.ts @@ -0,0 +1,141 @@ +import { IHttpClient } from '../../services/http/IHttpClient'; +import { ISpCollection, listItemsUrl } from '../../shared/sharepoint/spRest'; +import { dataverseUrl } from '../../shared/dataverse/dataverseRest'; +import { LISTS } from '../../config/lists'; +import { DV_TABLES } from '../../config/dataverse'; +import { IAccessListRow, IDateRange, IObjectListRow, IReportAccessResult, IReportRow } from './reportsTypes'; +import { hasAnyAccess } from './reportsRules'; +import { REPORTS_TEXTS } from './texts'; + +// Запросы stroy-bot-report-list — 1:1 (URL, $select/$expand/$filter/$orderby, заголовки). + +/** Справочники «Компании» и «Объекты» читаются целиком, одним запросом — как в старом коде. */ +const REFERENCE_LIMIT = 5000; + +const REPORT_SELECT = + 'cree9_reportid,cree9_report_date,cree9_number,cree9_companyid,' + + 'cree9_objectid,cree9_aprooved,cree9_comment,cree9_deleted,_createdby_value,createdby'; +const REPORT_EXPAND = 'createdby($select=fullname)'; + +// Заголовки Dataverse — именно те, что слал report-list (без Content-Type у GET). +const DATAVERSE_GET_HEADERS: Record = { + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Accept: 'application/json' +}; +const DATAVERSE_PATCH_HEADERS: Record = { + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + 'Content-Type': 'application/json' +}; + +/** «Доступы» по lookup-полям user / project. Id проекта — в кавычках, как в старом коде. */ +export function reportAccessUrl(siteUrl: string, userId: number | undefined, projectId: number): string { + return ( + `${listItemsUrl(siteUrl, LISTS.access)}` + + `?$select=read,edit0,aproove,user/Id,project/Id,project/Title` + + `&$expand=user,project` + + `&$filter=user/Id eq ${userId} and project/Id eq '${projectId}'` + ); +} + +export function companiesUrl(siteUrl: string): string { + return `${listItemsUrl(siteUrl, LISTS.companies)}?$select=Id,Title&$top=${REFERENCE_LIMIT}`; +} + +export function objectsUrl(siteUrl: string): string { + return `${listItemsUrl(siteUrl, LISTS.objects)}?$select=Id,Title,parent/Id&$expand=parent&$top=${REFERENCE_LIMIT}`; +} + +/** Неудалённые отчёты проекта за период. 🐞 Без пагинации: @odata.nextLink не читается. */ +export function reportsUrl(dvUrl: string, projectId: number, range: IDateRange): string { + const filter = [ + `cree9_projectid eq ${projectId}`, + `(cree9_deleted eq false or cree9_deleted eq null)`, + range.from ? `cree9_report_date ge ${range.from}T00:00:00Z` : '', + range.to ? `cree9_report_date le ${range.to}T23:59:59Z` : '' + ] + .filter(Boolean) + .join(' and '); + + return ( + dataverseUrl(dvUrl, DV_TABLES.reports) + + `?$select=${REPORT_SELECT}&$expand=${REPORT_EXPAND}&$filter=${encodeURIComponent(filter)}` + + `&$orderby=cree9_report_date desc,cree9_number desc` + ); +} + +export function reportRecordUrl(dvUrl: string, reportId: string): string { + return dataverseUrl(dvUrl, `${DV_TABLES.reports}(${reportId})`); +} + +/** + * Права текущего пользователя на проект. Любая ошибка (нет списка, сеть, пустой ответ) + * означает «нет доступа» — как в старом web part. Берётся первая найденная запись. + */ +export async function fetchReportAccess( + sharePoint: IHttpClient, + siteUrl: string, + userId: number | undefined, + projectId: number +): Promise { + try { + const response = await sharePoint.fetch(reportAccessUrl(siteUrl, userId, projectId)); + if (!response.ok) return undefined; + const data = (await response.json()) as ISpCollection; + const row = (data.value || [])[0]; + if (!row) return undefined; + + const access = { read: !!row.read, edit0: !!row.edit0, aproove: !!row.aproove }; + return hasAnyAccess(access) ? { projectName: row.project?.Title || '', access } : undefined; + } catch { + return undefined; + } +} + +/** Компании: Id → Title. Неуспешный ответ — пустой справочник (карточки покажут «ID: n»). */ +export async function fetchCompanies(sharePoint: IHttpClient, siteUrl: string): Promise> { + const companies = new Map(); + const response = await sharePoint.fetch(companiesUrl(siteUrl)); + if (!response.ok) return companies; + const data = (await response.json()) as ISpCollection<{ Id: number; Title: string }>; + data.value.forEach((company) => companies.set(company.Id, company.Title)); + return companies; +} + +/** Объекты всех проектов. Неуспешный ответ — пустой список (путь покажет «ID: n»). */ +export async function fetchObjectRows(sharePoint: IHttpClient, siteUrl: string): Promise { + const response = await sharePoint.fetch(objectsUrl(siteUrl)); + if (!response.ok) return []; + const data = (await response.json()) as ISpCollection; + return data.value; +} + +export async function fetchReportRows( + dataverse: IHttpClient, + dvUrl: string, + projectId: number, + range: IDateRange +): Promise { + const response = await dataverse.fetch(reportsUrl(dvUrl, projectId, range), { + method: 'GET', + headers: DATAVERSE_GET_HEADERS + }); + if (!response.ok) { + throw new Error(REPORTS_TEXTS.errors.dataverse(response.status, await response.text())); + } + const data = (await response.json()) as { value?: IReportRow[] }; + return data.value || []; +} + +/** Мягкое удаление: отчёт помечается cree9_deleted = true и пропадает из выборки. */ +export async function softDeleteReport(dataverse: IHttpClient, dvUrl: string, reportId: string): Promise { + const response = await dataverse.fetch(reportRecordUrl(dvUrl, reportId), { + method: 'PATCH', + headers: DATAVERSE_PATCH_HEADERS, + body: JSON.stringify({ cree9_deleted: true }) + }); + if (!response.ok && response.status !== 204) { + throw new Error(REPORTS_TEXTS.errors.dataverse(response.status, await response.text())); + } +} diff --git a/src/webparts/stroyBot/pages/reports/reportsRules.test.ts b/src/webparts/stroyBot/pages/reports/reportsRules.test.ts new file mode 100644 index 0000000..2684c2f --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/reportsRules.test.ts @@ -0,0 +1,149 @@ +import { + buildObjectPath, + canCreateReport, + canDeleteReport, + filterReports, + formatReportDate, + getCurrentWeekRange, + hasAnyAccess, + parseReportsProjectId, + sortReportRows, + toObjectNodes +} from './reportsRules'; +import { IReportCard, IReportRow } from './reportsTypes'; + +const isoDate = (date: Date): string => date.toISOString().slice(0, 10); + +describe('getCurrentWeekRange', () => { + it('четверг 15.10.2026 (полдень) — неделя 12.10–18.10', () => { + expect(getCurrentWeekRange(new Date(2026, 9, 15, 12, 0))).toEqual({ from: '2026-10-12', to: '2026-10-18' }); + }); + + it('воскресенье относится к прошедшей неделе', () => { + expect(getCurrentWeekRange(new Date(2026, 9, 18, 12, 0))).toEqual({ from: '2026-10-12', to: '2026-10-18' }); + }); + + it('понедельник — начало новой недели', () => { + expect(getCurrentWeekRange(new Date(2026, 9, 19, 12, 0))).toEqual({ from: '2026-10-19', to: '2026-10-25' }); + }); + + it('🐞 даты берутся в UTC (toISOString), как в старом коде', () => { + // В 00:30 по местному времени понедельник в UTC+5 — это ещё воскресенье по UTC. + const now = new Date(2026, 9, 15, 0, 30); + expect(getCurrentWeekRange(now)).toEqual({ + from: isoDate(new Date(2026, 9, 12, 0, 30)), + to: isoDate(new Date(2026, 9, 18, 0, 30)) + }); + }); +}); + +describe('права', () => { + const none = { read: false, edit0: false, aproove: false }; + const report = (approved: boolean): IReportCard => ({ + id: 'r', + date: '', + number: 1, + createdBy: '', + company: '', + objectPath: '', + comment: '', + approved + }); + + it('доступ — любое из read / edit0 / aproove', () => { + expect(hasAnyAccess(none)).toBe(false); + expect(hasAnyAccess({ ...none, read: true })).toBe(true); + }); + + it('«Отправить отчёт» и копирование — edit0 или aproove', () => { + expect(canCreateReport({ ...none, read: true })).toBe(false); + expect(canCreateReport({ ...none, edit0: true })).toBe(true); + expect(canCreateReport({ ...none, aproove: true })).toBe(true); + }); + + it('удаление: aproove — любой, edit0 — только неутверждённый', () => { + expect(canDeleteReport({ ...none, aproove: true }, report(true))).toBe(true); + expect(canDeleteReport({ ...none, edit0: true }, report(false))).toBe(true); + expect(canDeleteReport({ ...none, edit0: true }, report(true))).toBe(false); + expect(canDeleteReport({ ...none, read: true }, report(false))).toBe(false); + }); +}); + +describe('buildObjectPath', () => { + const objects = toObjectNodes([ + { Id: 1, Title: 'Корпус А' }, + { Id: 2, Title: 'Каркас', parent: { Id: 1 } }, + { Id: 3, Title: 'Бетон', parent: { Id: 2 } }, + { Id: 4, Title: 'Цикл', parent: { Id: 5 } }, + { Id: 5, Title: 'Петля', parent: { Id: 4 } } + ]); + + it('путь от корня через « / »', () => { + expect(buildObjectPath(3, objects)).toBe('Корпус А / Каркас / Бетон'); + }); + + it('без объекта — «—», неизвестный — «ID: n»', () => { + expect(buildObjectPath(0, objects)).toBe('—'); + expect(buildObjectPath(99, objects)).toBe('ID: 99'); + }); + + it('циклическая ссылка не зацикливает', () => { + expect(buildObjectPath(4, objects)).toBe('Петля / Цикл'); + }); +}); + +describe('formatReportDate', () => { + it('dd.mm.yyyy, пусто — «—»', () => { + expect(formatReportDate('2026-10-15T10:00:00Z')).toBe('15.10.2026'); + expect(formatReportDate(undefined)).toBe('—'); + }); +}); + +describe('sortReportRows', () => { + it('дата desc, затем номер desc', () => { + const row = (id: string, date: string, number: number): IReportRow => ({ + cree9_reportid: id, + cree9_report_date: date, + cree9_number: number, + cree9_companyid: 0, + cree9_objectid: 0 + }); + const sorted = sortReportRows([ + row('a', '2026-10-12T00:00:00Z', 1), + row('b', '2026-10-14T00:00:00Z', 2), + row('c', '2026-10-14T00:00:00Z', 5) + ]); + expect(sorted.map((r) => r.cree9_reportid)).toEqual(['c', 'b', 'a']); + }); +}); + +describe('filterReports', () => { + const card: IReportCard = { + id: 'r1', + date: '15.10.2026', + number: 12, + createdBy: 'Иванов Иван', + company: 'ТОО Строй', + objectPath: 'Корпус / Каркас', + comment: 'Заливка плиты', + approved: false + }; + + it('ищет по дате, номеру, автору, компании, пути и комментарию без учёта регистра', () => { + ['15.10', '12', 'иванов', 'строй', 'каркас', 'ПЛИТЫ'].forEach((query) => { + expect(filterReports([card], query)).toHaveLength(1); + }); + expect(filterReports([card], 'бетон')).toHaveLength(0); + }); + + it('пустой запрос — все карточки', () => { + expect(filterReports([card], '')).toEqual([card]); + }); +}); + +describe('parseReportsProjectId', () => { + it('parseInt, пусто — 0', () => { + expect(parseReportsProjectId('5')).toBe(5); + expect(parseReportsProjectId(undefined)).toBe(0); + }); +}); diff --git a/src/webparts/stroyBot/pages/reports/reportsRules.ts b/src/webparts/stroyBot/pages/reports/reportsRules.ts new file mode 100644 index 0000000..22a2007 --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/reportsRules.ts @@ -0,0 +1,129 @@ +import { IDateRange, IObjectListRow, IObjectNode, IReportAccess, IReportCard, IReportRow } from './reportsTypes'; +import { REPORTS_TEXTS } from './texts'; + +// Правила stroy-bot-report-list, вынесенные из DOM-кода в чистые функции. + +const SUNDAY = 0; +/** От понедельника до воскресенья — 6 дней. */ +const WEEK_SPAN_DAYS = 6; +/** Длина YYYY-MM-DD в начале ISO-строки. */ +const ISO_DATE_LENGTH = 10; + +/** + * Текущая неделя Пн–Вс — период по умолчанию. + * 🐞 Как в старом коде: даты берутся из toISOString(), то есть в UTC. Ночью по местному + * времени (UTC+5) период сдвигается на день назад. Переносим как есть. + */ +export function getCurrentWeekRange(now: Date): IDateRange { + const day = now.getDay(); + const diffToMonday = day === SUNDAY ? -WEEK_SPAN_DAYS : 1 - day; + const monday = new Date(now); + monday.setDate(now.getDate() + diffToMonday); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + WEEK_SPAN_DAYS); + return { + from: monday.toISOString().slice(0, ISO_DATE_LENGTH), + to: sunday.toISOString().slice(0, ISO_DATE_LENGTH) + }; +} + +/** Права из записи «Доступы»: хотя бы одно из read / edit0 / aproove. */ +export function hasAnyAccess(access: IReportAccess): boolean { + return access.read || access.edit0 || access.aproove; +} + +/** Карточка «Отправить отчёт» и кнопка «Создать на основе» — edit0 или aproove. */ +export function canCreateReport(access: IReportAccess): boolean { + return access.edit0 || access.aproove; +} + +/** Удаление: aproove — любой отчёт, edit0 — только неутверждённый. */ +export function canDeleteReport(access: IReportAccess, report: IReportCard): boolean { + return access.aproove || (access.edit0 && !report.approved); +} + +export function toObjectNodes(rows: IObjectListRow[]): Map { + const nodes = new Map(); + rows.forEach((row) => { + nodes.set(row.Id, { id: row.Id, title: row.Title || '', parentId: row.parent?.Id ?? undefined }); + }); + return nodes; +} + +/** Путь объекта снизу вверх до корня: «Корень / Раздел / Вид работ». Защита от циклов. */ +export function buildObjectPath(objectId: number, objects: Map): string { + if (!objectId) return REPORTS_TEXTS.noValue; + const parts: string[] = []; + const visited = new Set(); + let currentId: number | undefined = objectId; + + while (currentId !== undefined) { + if (visited.has(currentId)) break; + visited.add(currentId); + const node = objects.get(currentId); + if (!node) break; + parts.unshift(node.title); + currentId = node.parentId; + } + + return parts.length > 0 ? parts.join(' / ') : REPORTS_TEXTS.idFallback(objectId); +} + +/** dd.mm.yyyy по локали ru-RU. */ +export function formatReportDate(iso: string | undefined): string { + if (!iso) return REPORTS_TEXTS.noValue; + return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +/** Дата desc, затем номер desc. Сортируем на клиенте, как старый web part, независимо от ответа сервера. */ +export function sortReportRows(rows: IReportRow[]): IReportRow[] { + return [...rows].sort((a, b) => { + const dateA = a.cree9_report_date || ''; + const dateB = b.cree9_report_date || ''; + if (dateB > dateA) return 1; + if (dateB < dateA) return -1; + return (b.cree9_number || 0) - (a.cree9_number || 0); + }); +} + +export function toReportCards( + rows: IReportRow[], + companies: Map, + objects: Map +): IReportCard[] { + return sortReportRows(rows).map((row) => ({ + id: row.cree9_reportid, + date: formatReportDate(row.cree9_report_date), + number: row.cree9_number, + createdBy: row.createdby?.fullname || REPORTS_TEXTS.noValue, + company: companies.get(row.cree9_companyid) || REPORTS_TEXTS.idFallback(row.cree9_companyid), + objectPath: buildObjectPath(row.cree9_objectid, objects), + comment: row.cree9_comment || '', + approved: !!row.cree9_aprooved + })); +} + +/** Поиск по дате, номеру, автору, компании, пути объекта и комментарию (подстрока, без учёта регистра). */ +export function filterReports(reports: IReportCard[], query: string): IReportCard[] { + const q = query.toLowerCase(); + if (!q) return reports; + return reports.filter( + (report) => + report.date.toLowerCase().indexOf(q) !== -1 || + String(report.number).indexOf(q) !== -1 || + report.createdBy.toLowerCase().indexOf(q) !== -1 || + report.company.toLowerCase().indexOf(q) !== -1 || + report.objectPath.toLowerCase().indexOf(q) !== -1 || + report.comment.toLowerCase().indexOf(q) !== -1 + ); +} + +/** Текст ошибки как в старом коде: `(err as Error).message`. */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** projectId из адреса разбирается так же, как в старом web part: parseInt, пусто — 0. */ +export function parseReportsProjectId(value: string | undefined): number { + return value ? parseInt(value, 10) : 0; +} diff --git a/src/webparts/stroyBot/pages/reports/reportsTypes.ts b/src/webparts/stroyBot/pages/reports/reportsTypes.ts new file mode 100644 index 0000000..83377ab --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/reportsTypes.ts @@ -0,0 +1,70 @@ +// Типы раздела «Отчёты по проекту» (stroy-bot-report-list). + +/** Права пользователя на проект из списка «Доступы». */ +export interface IReportAccess { + read: boolean; + edit0: boolean; + aproove: boolean; +} + +/** Результат проверки доступа: название проекта берётся из той же записи «Доступы». */ +export interface IReportAccessResult { + projectName: string; + access: IReportAccess; +} + +/** Строка «Доступы» в том виде, в каком её читает report-list (lookup user / project). */ +export interface IAccessListRow { + read?: boolean; + edit0?: boolean; + aproove?: boolean; + project?: { Id?: number; Title?: string }; +} + +/** Элемент списка «Объекты»: родитель — lookup на этот же список. */ +export interface IObjectListRow { + Id: number; + Title?: string; + parent?: { Id?: number }; +} + +/** Объект для построения пути «Корень / Раздел / Вид работ». parentId undefined — корень. */ +export interface IObjectNode { + id: number; + title: string; + parentId: number | undefined; +} + +/** + * Отчёт из Dataverse (cree9_reports). Поля приходят как есть (в том числе null), + * потому что карточка выводит их без преобразований — как старый web part. + */ +export interface IReportRow { + cree9_reportid: string; + cree9_report_date?: string; + cree9_number: number; + cree9_companyid: number; + cree9_objectid: number; + cree9_comment?: string; + cree9_aprooved?: boolean; + createdby?: { fullname?: string }; +} + +/** Карточка отчёта — уже с подписями (дата, автор, компания, путь объекта). */ +export interface IReportCard { + id: string; + /** Дата в формате dd.mm.yyyy или «—». */ + date: string; + number: number; + createdBy: string; + company: string; + objectPath: string; + comment: string; + approved: boolean; +} + +/** Период фильтра: даты YYYY-MM-DD (значения ). */ +export interface IDateRange { + from: string; + to: string; +} diff --git a/src/webparts/stroyBot/pages/reports/texts.ts b/src/webparts/stroyBot/pages/reports/texts.ts new file mode 100644 index 0000000..29db514 --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/texts.ts @@ -0,0 +1,45 @@ +// Тексты раздела «Отчёты по проекту» — дословно из stroy-bot-report-list. + +export const REPORTS_TEXTS = { + loading: 'Загрузка...', + accessDeniedTitle: 'Нет доступа', + accessDeniedText: 'У вас нет прав на просмотр отчётов данного проекта.', + dateFrom: 'С:', + dateTo: 'По:', + apply: 'Применить', + searchPlaceholder: 'Поиск по любому полю...', + sendReport: { title: 'Отправить отчёт', subtitle: 'Отправка отчёта по проекту' }, + empty: 'Отчёты не найдены за выбранный период.', + card: { + approved: 'Утверждено', + user: 'Пользователь:', + company: 'Компания:', + object: 'Объект:', + comment: 'Комментарий:', + open: (number: number): string => `Открыть отчёт #${number}`, + copyTitle: (number: number): string => `Создать на основе отчёта #${number}`, + copyAriaLabel: 'Создать на основе', + deleteTitle: (number: number): string => `Удалить отчёт #${number}`, + deleteAriaLabel: 'Удалить отчёт' + }, + deleteDialog: { + title: 'Удалить отчёт?', + // «Отчёт #N от DATE будет удален.» — номер выделяется жирным. + textBefore: 'Отчёт ', + textNumber: (number: number): string => `#${number}`, + textAfter: (date: string): string => ` от ${date} будет удален.`, + textUnknown: 'Отчёт будет удален.', + cancel: 'Отмена', + confirm: 'Удалить' + }, + errors: { + init: (message: string): string => `Ошибка инициализации: ${message}`, + load: (message: string): string => `Ошибка загрузки: ${message}`, + delete: (message: string): string => `Ошибка удаления: ${message}`, + dataverse: (status: number, details: string): string => `Dataverse error: ${status} ${details}` + }, + /** Пустые значения в карточке. */ + noValue: '—', + // Значение подставляется как есть: пустой id из Dataverse даёт «ID: null» — как в старом web part. + idFallback: (id: unknown): string => `ID: ${id}` +} as const; diff --git a/src/webparts/stroyBot/pages/reports/useReportList.ts b/src/webparts/stroyBot/pages/reports/useReportList.ts new file mode 100644 index 0000000..f10a8cc --- /dev/null +++ b/src/webparts/stroyBot/pages/reports/useReportList.ts @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { useAppServices } from '../../app/AppServicesContext'; +import { IDateRange, IReportAccess, IReportCard } from './reportsTypes'; +import { softDeleteReport } from './reportsRepository'; +import { errorMessage } from './reportsRules'; +import { EMPTY_REFERENCE, IReferenceData, IReportListDeps, loadReportCards, loadReportList } from './reportListLoader'; +import { REPORTS_TEXTS } from './texts'; + +/** loading — первая загрузка, denied — нет доступа, ready — основной экран (в том числе с ошибкой). */ +export type ReportListPhase = 'loading' | 'denied' | 'ready'; + +const NO_ACCESS: IReportAccess = { read: false, edit0: false, aproove: false }; + +export interface IReportListState { + phase: ReportListPhase; + projectName: string; + access: IReportAccess; + /** undefined — отчёты не загружены (ошибка инициализации): ни карточек, ни «не найдено». */ + reports: IReportCard[] | undefined; + errorMessage: string; + /** Идёт «Применить» или удаление — поверх экрана показывается загрузка. */ + isBusy: boolean; + applyFilter: (range: IDateRange) => void; + deleteReport: (reportId: string) => void; +} + +/** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */ +export function useReportList(projectId: number, initialRange: IDateRange): IReportListState { + const { sharePoint, dataverse, settings, page } = useAppServices(); + const [phase, setPhase] = React.useState('loading'); + const [projectName, setProjectName] = React.useState(''); + const [access, setAccess] = React.useState(NO_ACCESS); + const [reports, setReports] = React.useState(undefined); + const [error, setError] = React.useState(''); + const [isBusy, setIsBusy] = React.useState(false); + // Справочники грузятся один раз при открытии и переиспользуются при «Применить» — как в старом коде. + const referenceRef = React.useRef(EMPTY_REFERENCE); + // Период первой загрузки фиксируется при открытии; дальше период меняет только «Применить». + const initialRangeRef = React.useRef(initialRange); + + const deps = React.useMemo( + () => ({ + sharePoint, + dataverse, + siteUrl: settings.siteUrl, + dataverseUrl: settings.dataverseUrl, + userId: page.userId + }), + [sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, page.userId] + ); + + React.useEffect(() => { + let isCancelled = false; + setPhase('loading'); + loadReportList(deps, projectId, initialRangeRef.current) + .then((result) => { + if (isCancelled) return; + if (result.kind === 'denied') { + setPhase('denied'); + return; + } + referenceRef.current = result.reference; + setProjectName(result.projectName); + setAccess(result.access); + setReports(result.reports); + setError(result.errorMessage); + setPhase('ready'); + }) + .catch((unexpected) => console.error(unexpected)); + return () => { + isCancelled = true; + }; + }, [deps, projectId]); + + const runApply = async (range: IDateRange): Promise => { + setError(''); + setIsBusy(true); + try { + setReports(await loadReportCards(deps, referenceRef.current, projectId, range)); + } catch (loadError) { + // Прежние карточки остаются на экране — как в старом коде. + setError(REPORTS_TEXTS.errors.load(errorMessage(loadError))); + } + setIsBusy(false); + }; + + const runDelete = async (reportId: string): Promise => { + setIsBusy(true); + setError(''); + try { + await softDeleteReport(dataverse, settings.dataverseUrl, reportId); + // Список не перезагружается — удалённая карточка просто убирается. + setReports((prev) => prev?.filter((report) => report.id !== reportId)); + } catch (deleteError) { + setError(REPORTS_TEXTS.errors.delete(errorMessage(deleteError))); + } + setIsBusy(false); + }; + + const applyFilter = (range: IDateRange): void => { + if (phase !== 'ready') return; + runApply(range).catch((unexpected) => console.error(unexpected)); + }; + + const deleteReport = (reportId: string): void => { + runDelete(reportId).catch((unexpected) => console.error(unexpected)); + }; + + return { phase, projectName, access, reports, errorMessage: error, isBusy, applyFilter, deleteReport }; +}