feat: port stroy-bot-report-list as reports page

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) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-15 17:16:01 +05:00
parent 660e059842
commit 704d5ce329
16 changed files with 1558 additions and 0 deletions
+7
View File
@@ -135,6 +135,13 @@
🐞 Нет пагинации Dataverse (`@odata.nextLink` не читается). 🐞 Неделя по умолчанию считается в UTC. 🐞 Нет пагинации Dataverse (`@odata.nextLink` не читается). 🐞 Неделя по умолчанию считается в UTC.
**Сознательные отличия от report-list:**
- Карточка отчёта и «Создать на основе» открывают раздел «Форма отчёта» этого приложения (`?page=report&projectId=&reportId=` / `&copyFromId=`) — по-прежнему в новой вкладке. «Создать на основе» теперь настоящая ссылка (работает 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=|&copyFromId=]` ## 5. Форма отчёта (stroy-bot-report) — `?page=report&projectId=[&reportId=|&copyFromId=]`
@@ -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<Props> = ({ report, onCancel, onConfirm }) => (
<Dialog
open={!!report}
title={TEXTS.title}
width="sm"
onDismiss={onCancel}
footer={
<>
<Button variant="secondary" onClick={onCancel}>
{TEXTS.cancel}
</Button>
<Button variant="danger" onClick={() => report && onConfirm(report)}>
{TEXTS.confirm}
</Button>
</>
}
>
<p className={styles.deleteText}>
{report ? (
<>
{TEXTS.textBefore}
<strong>{TEXTS.textNumber(report.number)}</strong>
{TEXTS.textAfter(report.date)}
</>
) : (
TEXTS.textUnknown
)}
</p>
</Dialog>
);
@@ -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<Props> = ({ 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<HTMLDivElement>): void => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openReport();
};
const onDeleteClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
onDelete(report);
};
const className = report.approved ? `${styles.card} ${styles.cardApproved}` : styles.card;
return (
<div
className={className}
role="button"
tabIndex={0}
title={TEXTS.open(report.number)}
onClick={openReport}
onKeyDown={onKeyDown}
>
<div className={styles.cardHeader}>
<span className={styles.cardNumber}>#{report.number}</span>
<span className={styles.cardDate}>{report.date}</span>
{report.approved && (
<Badge variant="success" size="sm">
<span className={styles.approvedBadge}>
<Icon name="check" size="sm" /> {TEXTS.approved}
</span>
</Badge>
)}
{canCreateReport(access) && (
<a
className={styles.cardAction}
href={copyHref}
target="_blank"
rel="noopener noreferrer"
title={TEXTS.copyTitle(report.number)}
aria-label={TEXTS.copyAriaLabel}
onClick={(event) => event.stopPropagation()}
>
<Icon name="copy" size="sm" />
</a>
)}
{canDeleteReport(access, report) && (
<button
type="button"
className={`${styles.cardAction} ${styles.cardActionDanger}`}
title={TEXTS.deleteTitle(report.number)}
aria-label={TEXTS.deleteAriaLabel}
onClick={onDeleteClick}
>
<Icon name="trash" size="sm" />
</button>
)}
<span className={styles.arrow} aria-hidden="true">
<Icon name="chevron-right" size="sm" />
</span>
</div>
<ReportCardBody report={report} />
</div>
);
};
@@ -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<Props> = ({ report }) => (
<div className={styles.cardBody}>
<div className={styles.cardRow}>
<span className={styles.cardLabel}>{TEXTS.user}</span>
<span>{report.createdBy}</span>
</div>
<div className={styles.cardRow}>
<span className={styles.cardLabel}>{TEXTS.company}</span>
<span>{report.company}</span>
</div>
<div className={`${styles.cardRow} ${styles.cardRowObject}`}>
<span className={styles.cardLabel}>{TEXTS.object}</span>
<span className={styles.objectPath}>{report.objectPath}</span>
</div>
{report.comment && (
<div className={styles.cardComment}>
<span className={styles.cardLabel}>{TEXTS.comment}</span> {report.comment}
</div>
)}
</div>
);
@@ -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;
}
}
@@ -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<IDateRange>(() => getCurrentWeekRange(new Date()));
const [search, setSearch] = React.useState<string>('');
const [pendingDelete, setPendingDelete] = React.useState<IReportCard | undefined>(undefined);
const list = useReportList(projectId, range);
if (list.phase === 'loading') {
return (
<div className={styles.loading}>
<Spinner size="large" label={REPORTS_TEXTS.loading} block />
</div>
);
}
if (list.phase === 'denied') {
return (
<EmptyState icon="warning" title={REPORTS_TEXTS.accessDeniedTitle} text={REPORTS_TEXTS.accessDeniedText} />
);
}
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 (
<div className={styles.page}>
<h1 className={styles.projectTitle}>{list.projectName}</h1>
<ReportsToolbar
range={range}
onRangeChange={setRange}
onApply={() => list.applyFilter(range)}
search={search}
onSearchChange={setSearch}
/>
{list.errorMessage && (
<div className={styles.errorBox}>
<Alert variant="err">{list.errorMessage}</Alert>
</div>
)}
{canCreateReport(list.access) && <SendReportCard projectId={routeProjectId} />}
{visibleReports && visibleReports.length > 0 && (
<div className={styles.cardsGrid}>
{visibleReports.map((report) => (
<ReportCard
key={report.id}
report={report}
access={list.access}
projectId={routeProjectId}
onDelete={setPendingDelete}
/>
))}
</div>
)}
{visibleReports && visibleReports.length === 0 && <EmptyState icon="document" title={REPORTS_TEXTS.empty} />}
<DeleteReportDialog
report={pendingDelete}
onCancel={() => setPendingDelete(undefined)}
onConfirm={confirmDelete}
/>
{list.isBusy && (
<div className={styles.busyOverlay}>
<Spinner size="large" label={REPORTS_TEXTS.loading} block />
</div>
)}
</div>
);
};
@@ -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<Props> = ({ range, onRangeChange, onApply, search, onSearchChange }) => (
<div className={styles.toolbar}>
<div className={styles.dateFilters}>
<label className={styles.dateLabel}>
{REPORTS_TEXTS.dateFrom}
<TextInput type="date" value={range.from} onChange={(from) => onRangeChange({ ...range, from })} />
</label>
<label className={styles.dateLabel}>
{REPORTS_TEXTS.dateTo}
<TextInput type="date" value={range.to} onChange={(to) => onRangeChange({ ...range, to })} />
</label>
</div>
<div className={styles.toolbarRight}>
<Button variant="primary" onClick={onApply}>
{REPORTS_TEXTS.apply}
</Button>
<div className={styles.searchBox}>
<Icon name="search" size="sm" />
<span className={styles.searchInput}>
<TextInput
value={search}
onChange={onSearchChange}
placeholder={REPORTS_TEXTS.searchPlaceholder}
ariaLabel={REPORTS_TEXTS.searchPlaceholder}
borderless
/>
</span>
</div>
</div>
</div>
);
@@ -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<Props> = ({ projectId }) => (
<RouteLink className={styles.sendReportCard} to={{ page: 'report', projectId }}>
<span className={styles.sendReportIcon} aria-hidden="true">
<Icon name="send" />
</span>
<span className={styles.sendReportBody}>
<span className={styles.sendReportTitle}>{REPORTS_TEXTS.sendReport.title}</span>
<span className={styles.sendReportSubtitle}>{REPORTS_TEXTS.sendReport.subtitle}</span>
</span>
<span className={styles.arrow} aria-hidden="true">
<Icon name="chevron-right" size="sm" />
</span>
</RouteLink>
);
@@ -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<number, string>;
objects: Map<number, IObjectNode>;
}
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<IReportCard[]> {
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<ReportListInitResult> {
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)) };
}
}
@@ -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'
});
});
});
@@ -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<string, string> = {
'OData-MaxVersion': '4.0',
'OData-Version': '4.0',
Accept: 'application/json'
};
const DATAVERSE_PATCH_HEADERS: Record<string, string> = {
'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<IReportAccessResult | undefined> {
try {
const response = await sharePoint.fetch(reportAccessUrl(siteUrl, userId, projectId));
if (!response.ok) return undefined;
const data = (await response.json()) as ISpCollection<IAccessListRow>;
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<Map<number, string>> {
const companies = new Map<number, string>();
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<IObjectListRow[]> {
const response = await sharePoint.fetch(objectsUrl(siteUrl));
if (!response.ok) return [];
const data = (await response.json()) as ISpCollection<IObjectListRow>;
return data.value;
}
export async function fetchReportRows(
dataverse: IHttpClient,
dvUrl: string,
projectId: number,
range: IDateRange
): Promise<IReportRow[]> {
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<void> {
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()));
}
}
@@ -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);
});
});
@@ -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<number, IObjectNode> {
const nodes = new Map<number, IObjectNode>();
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<number, IObjectNode>): string {
if (!objectId) return REPORTS_TEXTS.noValue;
const parts: string[] = [];
const visited = new Set<number>();
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<number, string>,
objects: Map<number, IObjectNode>
): 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;
}
@@ -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 (значения <input type="date">). */
export interface IDateRange {
from: string;
to: string;
}
@@ -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: 'Удалить отчёт?',
// «Отчёт <strong>#N</strong> от 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;
@@ -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<ReportListPhase>('loading');
const [projectName, setProjectName] = React.useState<string>('');
const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS);
const [reports, setReports] = React.useState<IReportCard[] | undefined>(undefined);
const [error, setError] = React.useState<string>('');
const [isBusy, setIsBusy] = React.useState<boolean>(false);
// Справочники грузятся один раз при открытии и переиспользуются при «Применить» — как в старом коде.
const referenceRef = React.useRef<IReferenceData>(EMPTY_REFERENCE);
// Период первой загрузки фиксируется при открытии; дальше период меняет только «Применить».
const initialRangeRef = React.useRef<IDateRange>(initialRange);
const deps = React.useMemo<IReportListDeps>(
() => ({
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<void> => {
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<void> => {
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 };
}