feat: port stroy-bot-main as the hub page

Project picker, section cards and the period Excel export now live on the
app's home page. The selected project is shared app state, so the sidebar
hides sections with the same rules main used to disable its cards
(archived, finished, no edit0 right).

Queries are 1:1 with stroy-bot-main and covered by tests against a fake
HTTP client. Behaviour differences are listed in the migration checklist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-15 16:49:32 +05:00
parent b868e8012d
commit bed230f045
32 changed files with 1626 additions and 6 deletions
@@ -0,0 +1,80 @@
import * as React from 'react';
import { Alert, Badge, BadgeVariant, Button, Icon, Spinner, TextInput } from '@tnsu/ui-kit';
import { IAccessibleProject } from '../../app/project/projectTypes';
import { isExportAvailable } from '../../app/project/projectRules';
import { getReportType, isInvalidRange, ReportKind } from './exportReport';
import { useReportExport } from './useReportExport';
import { ExportInfoButton } from './ExportInfoButton';
import { HUB_TEXTS } from './texts';
import styles from './HubPage.module.scss';
const TEXTS = HUB_TEXTS.export;
const BADGE_BY_KIND: Record<ReportKind, BadgeVariant> = {
daily: 'info',
weekly: 'success',
period: 'primary'
};
interface Props {
project: IAccessibleProject | undefined;
}
/** Карточка «Выгрузить за период». Поля дат видны только при выбранном проекте. */
export const ExportCard: React.FC<Props> = ({ project }) => {
const [dateFrom, setDateFrom] = React.useState<string>('');
const [dateTo, setDateTo] = React.useState<string>('');
const { isExporting, errorMessage, exportReport } = useReportExport();
const isEnabled = !!project && isExportAvailable(project);
const hasBothDates = !!dateFrom && !!dateTo;
const isRangeInvalid = isInvalidRange(dateFrom, dateTo);
const reportType = getReportType(dateFrom, dateTo);
const canExport = isEnabled && hasBothDates && !isRangeInvalid && !isExporting;
const onExport = (): void => {
if (project && canExport) {
exportReport(project.id, dateFrom, dateTo).catch(() => undefined);
}
};
return (
<div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}>
<span className={styles.cardIcon} aria-hidden="true">
<Icon name="calendar" />
</span>
<div className={styles.exportBody}>
<div className={styles.exportTitleRow}>
<span className={styles.cardTitle}>{TEXTS.title}</span>
<ExportInfoButton />
</div>
{!hasBothDates && <span className={styles.cardSubtitle}>{TEXTS.emptySubtitle}</span>}
{project && (
<div className={styles.dateRow}>
<label className={styles.dateField}>
<span className={styles.dateLabel}>{TEXTS.dateFrom}</span>
<TextInput type="date" value={dateFrom} onChange={setDateFrom} />
</label>
<label className={styles.dateField}>
<span className={styles.dateLabel}>{TEXTS.dateTo}</span>
<TextInput type="date" value={dateTo} onChange={setDateTo} />
</label>
<Button variant="accent" onClick={onExport} disabled={!canExport}>
{isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button}
</Button>
</div>
)}
{reportType && (
<span>
<Badge variant={BADGE_BY_KIND[reportType.kind]}>{reportType.label}</Badge>
</span>
)}
{isRangeInvalid && <span className={styles.dateError}>{TEXTS.invalidRange}</span>}
{errorMessage && <Alert variant="err">{errorMessage}</Alert>}
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import * as React from 'react';
import { useOutsideClose } from '../../shared/hooks/useOutsideClose';
import { HUB_TEXTS } from './texts';
import styles from './HubPage.module.scss';
const TEXTS = HUB_TEXTS.export;
/** Кнопка «?» с подсказкой о типах отчётов. Закрывается повторным кликом, кликом мимо или Esc. */
export const ExportInfoButton: React.FC = () => {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const wrapperRef = React.useRef<HTMLSpanElement>(null);
useOutsideClose(wrapperRef, isOpen, () => setIsOpen(false));
return (
<span className={styles.infoWrapper} ref={wrapperRef}>
<button
type="button"
className={styles.infoButton}
aria-label={TEXTS.infoAriaLabel}
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
>
?
</button>
{isOpen && (
<span className={styles.infoPopover} role="tooltip">
{TEXTS.infoRows.map((row) => (
<span key={row.label} className={styles.infoRow}>
<strong>{row.label}</strong> {row.text}
</span>
))}
</span>
)}
</span>
);
};
@@ -0,0 +1,47 @@
import * as React from 'react';
import { Icon } from '@tnsu/ui-kit';
import { RouteLink } from '../../app/routing/RouteLink';
import { IHubCard } from './hubCards';
import styles from './HubPage.module.scss';
interface Props {
card: IHubCard;
projectId: string | undefined;
}
/** Карточка раздела. Внутренние разделы — переход в приложении, архив — внешняя ссылка в новой вкладке. */
export const HubCard: React.FC<Props> = ({ card, projectId }) => {
const className = card.isEnabled ? styles.card : `${styles.card} ${styles.cardDisabled}`;
const content = (
<>
<span className={styles.cardIcon} aria-hidden="true">
<Icon name={card.icon} />
</span>
<span className={styles.cardBody}>
<span className={styles.cardTitle}>{card.title}</span>
<span className={styles.cardSubtitle}>{card.subtitle}</span>
</span>
<span className={styles.cardArrow} aria-hidden="true">
<Icon name="chevron-right" size="sm" />
</span>
</>
);
if (card.target.kind === 'external') {
return card.isEnabled ? (
<a className={className} href={card.target.url} target="_blank" rel="noopener noreferrer">
{content}
</a>
) : (
<a className={className} aria-disabled="true">
{content}
</a>
);
}
return (
<RouteLink className={className} to={{ page: card.target.page, projectId }} disabled={!card.isEnabled}>
{content}
</RouteLink>
);
};
@@ -0,0 +1,195 @@
@import '../../../../styles/design-tokens';
// Ширина колонки главной как в stroy-bot-main — карточки не растягиваются на широком экране.
$hub-max-width: 920px;
$card-min-width: 260px;
.hub {
display: flex;
flex-direction: column;
gap: $sp-4;
max-width: $hub-max-width;
}
.hint {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
border-radius: $r-md;
background: $gold-50;
border: 1px solid $gold-100;
font-size: $fs-sm;
color: $amber-700;
}
.sectionLabel {
margin: $sp-4 0 0;
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
.hairline {
@include hairline;
margin: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax($card-min-width, 1fr));
gap: $sp-3;
}
.card,
.exportCard {
display: flex;
align-items: flex-start;
gap: $sp-3;
padding: $sp-4;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-lg;
box-shadow: $sh-1;
color: inherit;
text-decoration: none;
}
.card {
align-items: center;
@include card-hover-gold;
}
.cardDisabled {
opacity: 0.5;
pointer-events: none;
cursor: default;
}
.cardIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: $r-md;
background: $navy-50;
color: $navy-800;
}
.cardBody {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.cardTitle {
font-size: $fs-body;
font-weight: 600;
color: $navy-900;
}
.cardSubtitle {
font-size: $fs-sm;
color: $gray-500;
}
.cardArrow {
display: inline-flex;
color: $gray-400;
}
.exportBody {
display: flex;
flex-direction: column;
gap: $sp-3;
flex: 1;
min-width: 0;
}
.exportTitleRow {
display: flex;
align-items: center;
gap: $sp-2;
}
.dateRow {
display: flex;
align-items: flex-end;
gap: $sp-3;
flex-wrap: wrap;
}
.dateField {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.dateLabel {
font-size: $fs-label;
font-weight: 500;
color: $gray-700;
}
.dateError {
font-size: $fs-sm;
color: $red-500;
}
.infoWrapper {
position: relative;
display: inline-flex;
}
.infoButton {
width: 20px;
height: 20px;
border-radius: $r-full;
border: 1px solid $gray-300;
background: $white;
color: $gray-500;
font-size: $fs-caption;
font-weight: 700;
line-height: 1;
cursor: pointer;
pointer-events: auto;
&:hover {
border-color: $gold-500;
color: $navy-900;
}
}
.infoPopover {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
z-index: 20;
display: flex;
flex-direction: column;
gap: $sp-1;
min-width: 240px;
padding: $sp-3;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-3;
font-size: $fs-sm;
color: $gray-700;
}
.infoRow strong {
color: $navy-900;
}
@include below($bp-sm) {
.dateRow {
flex-direction: column;
align-items: stretch;
}
}
@@ -0,0 +1,41 @@
import * as React from 'react';
import { Icon } from '@tnsu/ui-kit';
import { useAppServices } from '../../app/AppServicesContext';
import { useProject } from '../../app/project/ProjectContext';
import { ProjectPicker } from './ProjectPicker';
import { HubCard } from './HubCard';
import { ExportCard } from './ExportCard';
import { buildHubCards } from './hubCards';
import { HUB_TEXTS } from './texts';
import styles from './HubPage.module.scss';
/** Главная (бывший stroy-bot-main): выбор проекта, разделы проекта, выгрузка за период. */
export const HubPage: React.FC = () => {
const { settings } = useAppServices();
const { currentProject, currentProjectId } = useProject();
const cards = buildHubCards(currentProject, settings.siteUrl);
return (
<div className={styles.hub}>
<ProjectPicker />
{!currentProject && (
<div className={styles.hint}>
<Icon name="info" size="sm" /> {HUB_TEXTS.hint}
</div>
)}
<h3 className={styles.sectionLabel}>{HUB_TEXTS.sectionsLabel}</h3>
<hr className={styles.hairline} />
<div className={styles.grid}>
{cards.map((card) => (
<HubCard key={card.key} card={card} projectId={currentProjectId} />
))}
</div>
<h3 className={styles.sectionLabel}>{HUB_TEXTS.exportLabel}</h3>
<hr className={styles.hairline} />
<ExportCard project={currentProject} />
</div>
);
};
@@ -0,0 +1,37 @@
import * as React from 'react';
import { ProjectsLoadState } from '../../app/project/ProjectContext';
import { ProjectStatusBadge } from '../../app/project/ProjectStatusBadge';
import { filterProjects } from '../../app/project/projectRules';
import { IAccessibleProject } from '../../app/project/projectTypes';
import { HUB_TEXTS } from './texts';
import styles from './ProjectPicker.module.scss';
const TEXTS = HUB_TEXTS.picker;
interface Props {
loadState: ProjectsLoadState;
projects: IAccessibleProject[];
query: string;
onPick: (event: React.MouseEvent, project: IAccessibleProject) => void;
}
/** Содержимое выпадающего списка проектов: загрузка / ошибка / пусто / найденные проекты. */
export const ProjectList: React.FC<Props> = ({ loadState, projects, query, onPick }) => {
if (loadState === 'loading') return <div className={styles.message}>{TEXTS.loading}</div>;
if (loadState === 'error') return <div className={styles.message}>{TEXTS.loadError}</div>;
if (projects.length === 0) return <div className={styles.message}>{TEXTS.noProjects}</div>;
const filtered = filterProjects(projects, query);
if (filtered.length === 0) return <div className={styles.message}>{TEXTS.notFound}</div>;
return (
<>
{filtered.map((project) => (
<div key={project.id} className={styles.item} onMouseDown={(event) => onPick(event, project)}>
<span className={styles.itemTitle}>{project.title}</span>
<ProjectStatusBadge status={project.status} />
</div>
))}
</>
);
};
@@ -0,0 +1,94 @@
@import '../../../../styles/design-tokens';
// Длинный список прокручивается внутри, а не растягивает страницу вниз.
$dropdown-max-height: 340px;
.picker {
display: flex;
align-items: center;
gap: $sp-3;
flex-wrap: wrap;
}
.label {
font-size: $fs-sm;
font-weight: 600;
color: $navy-900;
}
.field {
position: relative;
flex: 1 1 320px;
display: flex;
align-items: center;
background: $white;
border: 1px solid $gray-300;
border-radius: $r-md;
padding: 0 $sp-3;
transition: border-color 0.15s, box-shadow 0.15s;
&:focus-within {
border-color: $gold-500;
box-shadow: 0 0 0 3px rgba(212, 160, 30, 0.12);
}
}
.searchIcon {
display: inline-flex;
color: $gray-400;
}
.input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
padding: $sp-3 $sp-2;
font-family: $font-sans;
font-size: $fs-body;
color: $gray-900;
}
.selectedBadge {
flex-shrink: 0;
}
.dropdown {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
right: 0;
z-index: 20;
max-height: $dropdown-max-height;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-3;
padding: $sp-1 0;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
padding: $sp-2 $sp-3;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.itemTitle {
font-size: $fs-body;
color: $gray-900;
}
.message {
padding: $sp-3;
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,83 @@
import * as React from 'react';
import { Icon } from '@tnsu/ui-kit';
import { useProject } from '../../app/project/ProjectContext';
import { ProjectStatusBadge } from '../../app/project/ProjectStatusBadge';
import { IAccessibleProject } from '../../app/project/projectTypes';
import { useOutsideClose } from '../../shared/hooks/useOutsideClose';
import { ProjectList } from './ProjectList';
import { HUB_TEXTS } from './texts';
import styles from './ProjectPicker.module.scss';
const TEXTS = HUB_TEXTS.picker;
/** Поле выбора проекта с поиском — как в stroy-bot-main. */
export const ProjectPicker: React.FC = () => {
const { projects, loadState, currentProject, selectProject } = useProject();
const selectedTitle = currentProject?.title ?? '';
const [query, setQuery] = React.useState<string>(selectedTitle);
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
// Выбор мог поменяться извне (адрес, восстановление из сессии) — пока список закрыт, поле показывает его.
React.useEffect(() => {
if (!isOpen) setQuery(selectedTitle);
}, [selectedTitle, isOpen]);
useOutsideClose(wrapperRef, isOpen, (reason) => {
setIsOpen(false);
setQuery(selectedTitle);
if (reason === 'escape') inputRef.current?.blur();
});
const onFocus = (event: React.FocusEvent<HTMLInputElement>): void => {
event.target.select();
setIsOpen(true);
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setQuery(event.target.value);
setIsOpen(true);
};
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => {
event.preventDefault();
selectProject(project);
setIsOpen(false);
};
return (
<div className={styles.picker}>
<label className={styles.label} htmlFor="stroybot-project-search">
{TEXTS.label}
</label>
<div className={styles.field} ref={wrapperRef}>
<span className={styles.searchIcon} aria-hidden="true">
<Icon name="search" size="sm" />
</span>
<input
id="stroybot-project-search"
ref={inputRef}
type="text"
className={styles.input}
placeholder={TEXTS.placeholder}
value={query}
autoComplete="off"
onFocus={onFocus}
onChange={onChange}
/>
{currentProject?.status && (
<span className={styles.selectedBadge}>
<ProjectStatusBadge status={currentProject.status} />
</span>
)}
{isOpen && (
<div className={styles.dropdown}>
<ProjectList loadState={loadState} projects={projects} query={query} onPick={onPick} />
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,53 @@
import { getReportType, isInvalidRange, reportFileName, stroyBotReportUrl, toUnixRange } from './exportReport';
// 2026-10-12 — понедельник, 2026-10-18 — воскресенье.
describe('getReportType', () => {
it('один день — ежедневный', () => {
expect(getReportType('2026-10-14', '2026-10-14')?.kind).toEqual('daily');
});
it('понедельник–воскресенье — еженедельный', () => {
expect(getReportType('2026-10-12', '2026-10-18')?.kind).toEqual('weekly');
});
it('неделя не с понедельника — за период', () => {
expect(getReportType('2026-10-13', '2026-10-19')?.kind).toEqual('period');
});
it('нет дат или начало позже конца — нет типа', () => {
expect(getReportType('', '2026-10-18')).toBeUndefined();
expect(getReportType('2026-10-18', '2026-10-12')).toBeUndefined();
});
});
describe('isInvalidRange', () => {
it('ошибка только когда обе даты заданы и начало позже конца', () => {
expect(isInvalidRange('2026-10-18', '2026-10-12')).toBe(true);
expect(isInvalidRange('2026-10-12', '2026-10-18')).toBe(false);
expect(isInvalidRange('2026-10-18', '')).toBe(false);
});
});
describe('toUnixRange / stroyBotReportUrl', () => {
it('начало 00:00:00 и конец 23:59:59 по UTC в секундах', () => {
expect(toUnixRange('2026-10-12', '2026-10-18')).toEqual({
start: Date.UTC(2026, 9, 12, 0, 0, 0) / 1000,
end: Date.UTC(2026, 9, 18, 23, 59, 59) / 1000
});
});
it('адрес выгрузки как в main', () => {
const { start, end } = toUnixRange('2026-10-12', '2026-10-18');
expect(stroyBotReportUrl('https://api.tnsu.kz', 5, '2026-10-12', '2026-10-18')).toEqual(
`https://api.tnsu.kz/Report/StroyBot?start=${start}&end=${end}&projectId=5`
);
});
});
describe('reportFileName', () => {
it('префикс по типу отчёта', () => {
expect(reportFileName('2026-10-12', '2026-10-18')).toEqual('weekly_2026-10-12_2026-10-18.xlsx');
expect(reportFileName('2026-10-14', '2026-10-14')).toEqual('daily_2026-10-14_2026-10-14.xlsx');
});
});
@@ -0,0 +1,74 @@
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
// Выгрузка Excel-отчёта StroyBot за период — логика карточки «Выгрузить за период» из stroy-bot-main.
export type ReportKind = 'daily' | 'weekly' | 'period';
export interface IReportType {
kind: ReportKind;
label: string;
}
const REPORT_LABELS: Record<ReportKind, string> = {
daily: 'Ежедневный отчёт',
weekly: 'Еженедельный отчёт',
period: 'Отчёт за период'
};
const MS_PER_DAY = 86400000;
const MONDAY = 1;
const SUNDAY = 0;
/** Пн → Вс — это 6 дней разницы. */
const WEEK_SPAN_DAYS = 6;
/** Даты в формате YYYY-MM-DD (значение <input type="date">). */
export function isInvalidRange(dateFrom: string, dateTo: string): boolean {
return !!dateFrom && !!dateTo && dateFrom > dateTo;
}
/** Тип отчёта по периоду. undefined — даты не заданы или диапазон неверный. */
export function getReportType(dateFrom: string, dateTo: string): IReportType | undefined {
if (!dateFrom || !dateTo || dateFrom > dateTo) {
return undefined;
}
if (dateFrom === dateTo) {
return { kind: 'daily', label: REPORT_LABELS.daily };
}
const from = new Date(`${dateFrom}T00:00:00`);
const to = new Date(`${dateTo}T00:00:00`);
const diffDays = Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);
if (from.getDay() === MONDAY && to.getDay() === SUNDAY && diffDays === WEEK_SPAN_DAYS) {
return { kind: 'weekly', label: REPORT_LABELS.weekly };
}
return { kind: 'period', label: REPORT_LABELS.period };
}
/** Начало и конец периода в Unix-секундах по UTC: 00:00:00 первого дня и 23:59:59 последнего. */
export function toUnixRange(dateFrom: string, dateTo: string): { start: number; end: number } {
return {
start: Math.floor(new Date(`${dateFrom}T00:00:00Z`).getTime() / 1000),
end: Math.floor(new Date(`${dateTo}T23:59:59Z`).getTime() / 1000)
};
}
export function stroyBotReportUrl(apiBaseUrl: string, projectId: number, dateFrom: string, dateTo: string): string {
const { start, end } = toUnixRange(dateFrom, dateTo);
return `${apiBaseUrl}/Report/StroyBot?start=${start}&end=${end}&projectId=${projectId}`;
}
/** Имя файла как в main: `{daily|weekly|period}_{from}_{to}.xlsx`. */
export function reportFileName(dateFrom: string, dateTo: string): string {
const prefix = getReportType(dateFrom, dateTo)?.kind ?? 'report';
return `${prefix}_${dateFrom}_${dateTo}.xlsx`;
}
export async function downloadStroyBotReport(
api: IHttpClient,
apiBaseUrl: string,
projectId: number,
dateFrom: string,
dateTo: string
): Promise<Blob> {
const response = await ensureOk(await api.fetch(stroyBotReportUrl(apiBaseUrl, projectId, dateFrom, dateTo)));
return response.blob();
}
@@ -0,0 +1,38 @@
import { archiveUrl, buildHubCards } from './hubCards';
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
describe('buildHubCards', () => {
it('шесть карточек в порядке main', () => {
expect(buildHubCards(undefined, SITE).map((card) => card.key)).toEqual([
'objects',
'reports',
'report',
'import',
'archive',
'resources'
]);
});
it('без проекта все карточки неактивны', () => {
expect(buildHubCards(undefined, SITE).every((card) => !card.isEnabled)).toBe(true);
});
it('архивный проект — активен только архив', () => {
const cards = buildHubCards({ id: 5, title: 'Сункар', status: 'архивный', canEdit: true }, SITE);
expect(cards.filter((card) => card.isEnabled).map((card) => card.key)).toEqual(['archive']);
});
it('без права edit0 неактивно только «Отправить отчёт»', () => {
const cards = buildHubCards({ id: 5, title: 'Сункар', status: 'текущий', canEdit: false }, SITE);
expect(cards.filter((card) => !card.isEnabled).map((card) => card.key)).toEqual(['report']);
});
});
describe('archiveUrl', () => {
it('папка проекта в архиве, название кодируется', () => {
expect(archiveUrl(SITE, 'ЖК Сункар')).toEqual(
`${SITE}/Shared%20Documents/Архив/${encodeURIComponent('ЖК Сункар')}`
);
});
});
@@ -0,0 +1,55 @@
import { IconName } from '@tnsu/ui-kit';
import { ARCHIVE_FOLDER_PATH } from '../../config/lists';
import { PageId } from '../../app/routing/routes';
import { IAccessibleProject } from '../../app/project/projectTypes';
import { isSectionAvailable } from '../../app/project/projectRules';
import { HUB_TEXTS } from './texts';
export type HubCardTarget = { kind: 'page'; page: PageId } | { kind: 'external'; url: string };
export interface IHubCard {
key: string;
icon: IconName;
title: string;
subtitle: string;
target: HubCardTarget;
isEnabled: boolean;
}
/** Ссылка на папку проекта в архиве — как в main: `{site}/Shared%20Documents/Архив/{название}`. */
export function archiveUrl(siteUrl: string, projectTitle: string): string {
return `${siteUrl}/${ARCHIVE_FOLDER_PATH}/${encodeURIComponent(projectTitle)}`;
}
const SECTION_CARDS: Array<{ page: PageId; icon: IconName; texts: { title: string; subtitle: string } }> = [
{ page: 'objects', icon: 'building', texts: HUB_TEXTS.cards.objects },
{ page: 'reports', icon: 'chart', texts: HUB_TEXTS.cards.reports },
{ page: 'report', icon: 'send', texts: HUB_TEXTS.cards.report },
{ page: 'import', icon: 'upload', texts: HUB_TEXTS.cards.import }
];
/** Шесть карточек «Разделы проекта» в порядке main. Без проекта — все неактивны. */
export function buildHubCards(project: IAccessibleProject | undefined, siteUrl: string): IHubCard[] {
const sectionCard = (page: PageId, icon: IconName, texts: { title: string; subtitle: string }): IHubCard => ({
key: page,
icon,
title: texts.title,
subtitle: texts.subtitle,
target: { kind: 'page', page },
isEnabled: !!project && isSectionAvailable(page, project)
});
return [
...SECTION_CARDS.map((card) => sectionCard(card.page, card.icon, card.texts)),
{
key: 'archive',
icon: 'folder',
title: HUB_TEXTS.cards.archive.title,
subtitle: HUB_TEXTS.cards.archive.subtitle,
target: { kind: 'external', url: project ? archiveUrl(siteUrl, project.title) : '' },
// Архив доступен при любом статусе, даже у архивного проекта.
isEnabled: !!project
},
sectionCard('resources', 'people', HUB_TEXTS.cards.resources)
];
}
+40
View File
@@ -0,0 +1,40 @@
// Тексты главной — дословно из stroy-bot-main.
export const HUB_TEXTS = {
picker: {
label: 'Проект:',
placeholder: 'Выберите проект',
loading: 'Загрузка проектов…',
noProjects: 'Нет доступных проектов',
notFound: 'Проекты не найдены',
loadError: 'Ошибка загрузки. Попробуйте ещё раз.'
},
hint: 'Выберите проект, чтобы активировать ссылки ниже',
sectionsLabel: 'Разделы проекта',
exportLabel: 'Экспорт данных',
cards: {
objects: { title: 'Объекты', subtitle: 'Объекты по проекту' },
reports: { title: 'Отчёты по проекту', subtitle: 'Просмотр отчётности' },
report: { title: 'Отправить отчёт', subtitle: 'Отправка отчёта по проекту' },
import: { title: 'Загрузить работы из сметы', subtitle: 'Импорт работ в систему' },
archive: { title: 'Архив проекта', subtitle: 'Библиотека документов' },
resources: { title: 'Ресурсы проекта', subtitle: 'Профессии и компании' }
},
export: {
title: 'Выгрузить за период',
infoAriaLabel: 'Типы отчётов',
infoRows: [
{ label: 'Один день', text: '— ежедневный отчёт' },
{ label: 'Пн – Вс', text: '— еженедельный отчёт' },
{ label: 'Произвольный', text: '— отчёт за период' }
],
emptySubtitle: 'Укажите период для активации ссылки',
dateFrom: 'Начало',
dateTo: 'Конец',
button: 'Выгрузить',
buttonLoading: 'Формируется…',
invalidRange: 'Дата начала не может быть позже даты конца',
apiError: (status: number, statusText: string): string => `Ошибка API: ${status} ${statusText}`,
loadError: 'Ошибка при загрузке отчёта'
}
} as const;
@@ -0,0 +1,45 @@
import * as React from 'react';
import { useAppServices } from '../../app/AppServicesContext';
import { HttpError } from '../../services/http/IHttpClient';
import { saveFile } from '../../shared/utils/saveFile';
import { downloadStroyBotReport, reportFileName } from './exportReport';
import { HUB_TEXTS } from './texts';
interface IReportExport {
isExporting: boolean;
errorMessage: string | undefined;
exportReport: (projectId: number, dateFrom: string, dateTo: string) => Promise<void>;
}
/** Скачивание Excel за период. Тексты ошибок — как в main (там они показывались через alert). */
export function useReportExport(): IReportExport {
const services = useAppServices();
const [isExporting, setIsExporting] = React.useState<boolean>(false);
const [errorMessage, setErrorMessage] = React.useState<string | undefined>(undefined);
const exportReport = async (projectId: number, dateFrom: string, dateTo: string): Promise<void> => {
setIsExporting(true);
setErrorMessage(undefined);
try {
const blob = await downloadStroyBotReport(
services.tnsuApi,
services.settings.apiBaseUrl,
projectId,
dateFrom,
dateTo
);
saveFile(blob, reportFileName(dateFrom, dateTo));
} catch (error) {
console.error(error);
setErrorMessage(
error instanceof HttpError
? HUB_TEXTS.export.apiError(error.status, error.statusText)
: HUB_TEXTS.export.loadError
);
} finally {
setIsExporting(false);
}
};
return { isExporting, errorMessage, exportReport };
}