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:
@@ -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=]`
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user