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.
|
🐞 Нет пагинации 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=]`
|
## 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