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:
@@ -7,6 +7,11 @@
|
|||||||
"includeClientSideAssets": true,
|
"includeClientSideAssets": true,
|
||||||
"skipFeatureDeployment": true,
|
"skipFeatureDeployment": true,
|
||||||
"isDomainIsolated": false,
|
"isDomainIsolated": false,
|
||||||
|
"webApiPermissionRequests": [
|
||||||
|
{ "resource": "9232d9e2-9807-4753-808f-07250e5474b0", "scope": "Reports.Read" },
|
||||||
|
{ "resource": "9232d9e2-9807-4753-808f-07250e5474b0", "scope": "user_impersonation" },
|
||||||
|
{ "resource": "https://tansu.crm5.dynamics.com", "scope": "user_impersonation" }
|
||||||
|
],
|
||||||
"developer": {
|
"developer": {
|
||||||
"name": "",
|
"name": "",
|
||||||
"websiteUrl": "",
|
"websiteUrl": "",
|
||||||
|
|||||||
@@ -64,7 +64,14 @@
|
|||||||
- [ ] Запрос `GET https://api.tnsu.kz/Report/StroyBot?start=…&end=…&projectId=…` (unix UTC 00:00:00 / 23:59:59)
|
- [ ] Запрос `GET https://api.tnsu.kz/Report/StroyBot?start=…&end=…&projectId=…` (unix UTC 00:00:00 / 23:59:59)
|
||||||
- [ ] Ошибки: «Ошибка API: {status} {statusText}» / «Ошибка при загрузке отчёта»
|
- [ ] Ошибки: «Ошибка API: {status} {statusText}» / «Ошибка при загрузке отчёта»
|
||||||
|
|
||||||
🐞 Лишний запрос к «Доступы» без фильтра по пользователю.
|
**Сознательные отличия от main:**
|
||||||
|
- Лишний запрос к «Доступы» без фильтра по пользователю (его результат не использовался) — убран, на поведение не влияет.
|
||||||
|
- Список проектов грузится сразу при открытии, а не при первом фокусе: от статуса и прав проекта теперь зависит и меню слева.
|
||||||
|
- Карточки разделов открывают раздел в этой же вкладке; новая вкладка — Ctrl+клик или средняя кнопка (раньше всегда `_blank`). Архив — по-прежнему в новой вкладке.
|
||||||
|
- Ошибки выгрузки показываются под кнопкой, а не через `alert()`. Тексты те же.
|
||||||
|
- Поповер «?» всегда открывается вниз (раньше переворачивался вверх у края экрана).
|
||||||
|
- Адрес выгрузки берётся из настройки «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`).
|
||||||
|
- Меню слева скрывает разделы по правилам карточек: архивный — только «Проект»; завершён или нет `edit0` — без «Отправить отчёт»; «Привязка к справочнику» — только с `edit0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfirmHost, IconSprite } from '@tnsu/ui-kit';
|
|||||||
import { IAppServices } from '../services/AppServices';
|
import { IAppServices } from '../services/AppServices';
|
||||||
import { AppServicesContext } from './AppServicesContext';
|
import { AppServicesContext } from './AppServicesContext';
|
||||||
import { RouterProvider } from './routing/RouterContext';
|
import { RouterProvider } from './routing/RouterContext';
|
||||||
|
import { ProjectProvider } from './project/ProjectContext';
|
||||||
import { AppShell } from './AppShell';
|
import { AppShell } from './AppShell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,7 +17,9 @@ export const App: React.FC<Props> = ({ services }) => (
|
|||||||
<IconSprite />
|
<IconSprite />
|
||||||
<ConfirmHost />
|
<ConfirmHost />
|
||||||
<RouterProvider>
|
<RouterProvider>
|
||||||
<AppShell />
|
<ProjectProvider>
|
||||||
|
<AppShell />
|
||||||
|
</ProjectProvider>
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
</AppServicesContext.Provider>
|
</AppServicesContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import * as React from 'react';
|
|||||||
import { AppLayout, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
|
import { AppLayout, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
|
||||||
import { useRouter } from './routing/RouterContext';
|
import { useRouter } from './routing/RouterContext';
|
||||||
import { PageId } from './routing/routes';
|
import { PageId } from './routing/routes';
|
||||||
|
import { useProject } from './project/ProjectContext';
|
||||||
|
import { isSectionAvailable } from './project/projectRules';
|
||||||
import { buildSidebarItems, navPageFor } from './navigation';
|
import { buildSidebarItems, navPageFor } from './navigation';
|
||||||
import { PageOutlet } from './PageOutlet';
|
import { PageOutlet } from './PageOutlet';
|
||||||
import { APP_TEXTS } from './texts';
|
import { APP_TEXTS } from './texts';
|
||||||
@@ -10,19 +12,24 @@ import styles from './AppShell.module.scss';
|
|||||||
/** Каркас приложения: меню разделов слева, текущий раздел справа. */
|
/** Каркас приложения: меню разделов слева, текущий раздел справа. */
|
||||||
export const AppShell: React.FC = () => {
|
export const AppShell: React.FC = () => {
|
||||||
const { route, navigate } = useRouter();
|
const { route, navigate } = useRouter();
|
||||||
|
const { currentProject, currentProjectId } = useProject();
|
||||||
const [isCollapsed, setIsCollapsed] = React.useState<boolean>(false);
|
const [isCollapsed, setIsCollapsed] = React.useState<boolean>(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState<boolean>(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
// Меню скрывает разделы по тем же правилам, по которым main блокировал карточки.
|
||||||
|
// Пока сведения о проекте не загружены — показываем все разделы.
|
||||||
|
const isAvailable = (page: PageId): boolean => !currentProject || isSectionAvailable(page, currentProject);
|
||||||
|
|
||||||
const openSection = (id: string): void => {
|
const openSection = (id: string): void => {
|
||||||
setIsMobileMenuOpen(false);
|
setIsMobileMenuOpen(false);
|
||||||
navigate({ page: id as PageId, projectId: route.projectId });
|
navigate({ page: id as PageId, projectId: currentProjectId });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
sidebar={
|
sidebar={
|
||||||
<Sidebar
|
<Sidebar
|
||||||
items={buildSidebarItems(route.projectId)}
|
items={buildSidebarItems(currentProjectId, isAvailable)}
|
||||||
activeId={navPageFor(route.page)}
|
activeId={navPageFor(route.page)}
|
||||||
onChange={openSection}
|
onChange={openSection}
|
||||||
brand={APP_TEXTS.brand}
|
brand={APP_TEXTS.brand}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useRouter } from './routing/RouterContext';
|
import { useRouter } from './routing/RouterContext';
|
||||||
import { SectionPlaceholder } from './SectionPlaceholder';
|
import { SectionPlaceholder } from './SectionPlaceholder';
|
||||||
|
import { HubPage } from '../pages/hub/HubPage';
|
||||||
|
|
||||||
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
||||||
export const PageOutlet: React.FC = () => {
|
export const PageOutlet: React.FC = () => {
|
||||||
const { route } = useRouter();
|
const { route } = useRouter();
|
||||||
|
|
||||||
switch (route.page) {
|
switch (route.page) {
|
||||||
|
case 'hub':
|
||||||
|
return <HubPage />;
|
||||||
default:
|
default:
|
||||||
return <SectionPlaceholder />;
|
return <SectionPlaceholder />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../AppServicesContext';
|
||||||
|
import { useRouter } from '../routing/RouterContext';
|
||||||
|
import { IAccessibleProject } from './projectTypes';
|
||||||
|
import { fetchAccessibleProjects } from './projectRepository';
|
||||||
|
import { readStoredProject, storeProject } from './projectSession';
|
||||||
|
|
||||||
|
export type ProjectsLoadState = 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
interface IProjectContextValue {
|
||||||
|
/** Проекты, доступные пользователю (уже отсортированы). */
|
||||||
|
projects: IAccessibleProject[];
|
||||||
|
loadState: ProjectsLoadState;
|
||||||
|
/** Проект из адреса (?projectId=), а без него — сохранённый в сессии выбор. */
|
||||||
|
currentProject: IAccessibleProject | undefined;
|
||||||
|
/** Id текущего проекта строкой — для маршрутов. */
|
||||||
|
currentProjectId: string | undefined;
|
||||||
|
selectProject: (project: IAccessibleProject) => void;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectContext = React.createContext<IProjectContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
function findProject(projects: IAccessibleProject[], id: number): IAccessibleProject | undefined {
|
||||||
|
return projects.filter((project) => project.id === id)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбранный проект — сквозные данные: от него зависят меню, права и все разделы.
|
||||||
|
* Список загружается сразу при открытии (в main — при первом фокусе на поле выбора),
|
||||||
|
* потому что меню должно знать статус и права текущего проекта.
|
||||||
|
*/
|
||||||
|
export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const services = useAppServices();
|
||||||
|
const { route, navigate } = useRouter();
|
||||||
|
const [projects, setProjects] = React.useState<IAccessibleProject[]>([]);
|
||||||
|
const [loadState, setLoadState] = React.useState<ProjectsLoadState>('loading');
|
||||||
|
const [storedProject, setStoredProject] = React.useState<IAccessibleProject | undefined>(readStoredProject);
|
||||||
|
const [reloadKey, setReloadKey] = React.useState<number>(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
setLoadState('loading');
|
||||||
|
fetchAccessibleProjects({
|
||||||
|
sharePoint: services.sharePoint,
|
||||||
|
dataverse: services.dataverse,
|
||||||
|
siteUrl: services.settings.siteUrl,
|
||||||
|
dataverseUrl: services.settings.dataverseUrl
|
||||||
|
})
|
||||||
|
.then((loaded) => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
setProjects(loaded);
|
||||||
|
setLoadState('ready');
|
||||||
|
// В сессии могли остаться устаревшие права — обновляем по свежим данным (как в main).
|
||||||
|
setStoredProject((stored) => {
|
||||||
|
const fresh = stored ? findProject(loaded, stored.id) : undefined;
|
||||||
|
if (!stored || !fresh) return stored;
|
||||||
|
const updated = { ...stored, canEdit: fresh.canEdit };
|
||||||
|
storeProject(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
console.error('Ошибка загрузки проектов:', error);
|
||||||
|
setLoadState('error');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [services, reloadKey]);
|
||||||
|
|
||||||
|
const routeProjectId = route.projectId ? Number(route.projectId) : undefined;
|
||||||
|
let currentProject: IAccessibleProject | undefined;
|
||||||
|
if (routeProjectId === undefined) {
|
||||||
|
currentProject = storedProject;
|
||||||
|
} else {
|
||||||
|
currentProject =
|
||||||
|
findProject(projects, routeProjectId) ?? (storedProject?.id === routeProjectId ? storedProject : undefined);
|
||||||
|
}
|
||||||
|
const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined);
|
||||||
|
|
||||||
|
const selectProject = React.useCallback(
|
||||||
|
(project: IAccessibleProject): void => {
|
||||||
|
storeProject(project);
|
||||||
|
setStoredProject(project);
|
||||||
|
navigate({ page: 'hub', projectId: String(project.id) });
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: IProjectContextValue = {
|
||||||
|
projects,
|
||||||
|
loadState,
|
||||||
|
currentProject,
|
||||||
|
currentProjectId,
|
||||||
|
selectProject,
|
||||||
|
reload: () => setReloadKey((key) => key + 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useProject(): IProjectContextValue {
|
||||||
|
const value = React.useContext(ProjectContext);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('useProject: компонент отрисован вне ProjectProvider');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Badge, BadgeVariant } from '@tnsu/ui-kit';
|
||||||
|
import { ProjectStatusKind } from './projectTypes';
|
||||||
|
import { getStatusKind } from './projectRules';
|
||||||
|
|
||||||
|
// Цвета статусов как в stroy-bot-main: текущий — зелёный, завершён — серый, архивный — золотой.
|
||||||
|
const VARIANT_BY_STATUS: Record<ProjectStatusKind, BadgeVariant> = {
|
||||||
|
current: 'success',
|
||||||
|
done: 'muted',
|
||||||
|
archive: 'warning',
|
||||||
|
unknown: 'neutral'
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_STATUS_LABEL = 'Без статуса';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: string | undefined;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectStatusBadge: React.FC<Props> = ({ status, size = 'sm' }) => (
|
||||||
|
<Badge variant={VARIANT_BY_STATUS[getStatusKind(status)]} size={size}>
|
||||||
|
{status || NO_STATUS_LABEL}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { accessByUserUrl, fetchAccessibleProjects, projectStatusesUrl, recentReportsUrl } from './projectRepository';
|
||||||
|
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
||||||
|
|
||||||
|
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
|
||||||
|
const DV = 'https://tansu.crm5.dynamics.com';
|
||||||
|
|
||||||
|
describe('адреса запросов главной (1:1 со stroy-bot-main)', () => {
|
||||||
|
it('«Доступы» текущего пользователя с любым из прав', () => {
|
||||||
|
expect(accessByUserUrl(SITE, 42)).toEqual(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('Доступы')/items` +
|
||||||
|
`?$select=project/Id,project/Title,read,edit0,aproove&$expand=project` +
|
||||||
|
`&$filter=(user/Id eq 42) and (read eq 1 or edit0 eq 1 or aproove eq 1)&$top=500`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('статусы проектов отдельным запросом к «Проекты»', () => {
|
||||||
|
expect(projectStatusesUrl(SITE, [5, 7])).toEqual(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('Проекты')/items?$select=Id,Status&$filter=Id eq 5 or Id eq 7&$top=500`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('последние отчёты из Dataverse для сортировки', () => {
|
||||||
|
expect(recentReportsUrl(DV)).toEqual(
|
||||||
|
`${DV}/api/data/v9.2/cree9_reports?$select=cree9_projectid&$orderby=cree9_report_date desc&$top=100`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchAccessibleProjects', () => {
|
||||||
|
it('собирает проекты: права, недавние наверх, статусы', async () => {
|
||||||
|
const sharePoint = createFakeHttpClient([
|
||||||
|
{ match: 'currentuser', response: { body: { Id: 42 } } },
|
||||||
|
{
|
||||||
|
match: "getbytitle('Доступы')",
|
||||||
|
response: {
|
||||||
|
body: {
|
||||||
|
value: [
|
||||||
|
{ project: { Id: 5, Title: 'Б-проект' }, edit0: false },
|
||||||
|
{ project: { Id: 7, Title: 'А-проект' }, edit0: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ match: "getbytitle('Проекты')", response: { body: { value: [{ Id: 5, Status: 'архивный' }, { Id: 7 }] } } }
|
||||||
|
]);
|
||||||
|
const dataverse = createFakeHttpClient([
|
||||||
|
{ match: 'cree9_reports', response: { body: { value: [{ cree9_projectid: 5 }] } } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const projects = await fetchAccessibleProjects({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV });
|
||||||
|
|
||||||
|
expect(projects).toEqual([
|
||||||
|
{ id: 5, title: 'Б-проект', canEdit: false, status: 'архивный' },
|
||||||
|
{ id: 7, title: 'А-проект', canEdit: true, status: undefined }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка Dataverse не мешает — просто алфавитный порядок', async () => {
|
||||||
|
const sharePoint = createFakeHttpClient([
|
||||||
|
{ match: 'currentuser', response: { body: { Id: 42 } } },
|
||||||
|
{
|
||||||
|
match: "getbytitle('Доступы')",
|
||||||
|
response: { body: { value: [{ project: { Id: 5, Title: 'Б' } }, { project: { Id: 7, Title: 'А' } }] } }
|
||||||
|
},
|
||||||
|
{ match: "getbytitle('Проекты')", response: { status: 500 } }
|
||||||
|
]);
|
||||||
|
const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 403 } }]);
|
||||||
|
|
||||||
|
const projects = await fetchAccessibleProjects({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV });
|
||||||
|
|
||||||
|
expect(projects.map((p) => p.title)).toEqual(['А', 'Б']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка «Доступы» пробрасывается — главная покажет «Ошибка загрузки»', async () => {
|
||||||
|
const sharePoint = createFakeHttpClient([
|
||||||
|
{ match: 'currentuser', response: { body: { Id: 42 } } },
|
||||||
|
{ match: "getbytitle('Доступы')", response: { status: 500 } }
|
||||||
|
]);
|
||||||
|
const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { body: { value: [] } } }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchAccessibleProjects({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV })
|
||||||
|
).rejects.toThrow('500');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
import { fetchCurrentUserId, getJson, ISpCollection, listItemsUrl } from '../../shared/sharepoint/spRest';
|
||||||
|
import { LISTS } from '../../config/lists';
|
||||||
|
import { DATAVERSE_API_PATH, DV_TABLES } from '../../config/dataverse';
|
||||||
|
import { IAccessibleProject, IAccessRow } from './projectTypes';
|
||||||
|
import { mergeAccessRows, pickRecentProjectIds, sortProjects } from './projectRules';
|
||||||
|
|
||||||
|
// Запросы главной страницы — 1:1 со stroy-bot-main (_fetchAllowedProjects, _fetchRecentProjectIds).
|
||||||
|
|
||||||
|
/** Сколько последних отчётов смотреть, чтобы найти недавно активные проекты. */
|
||||||
|
const RECENT_REPORTS_SCAN = 100;
|
||||||
|
/** Ограничение выборки «Доступы» и «Проекты» — как в main. */
|
||||||
|
const ITEMS_LIMIT = 500;
|
||||||
|
|
||||||
|
export function accessByUserUrl(siteUrl: string, userId: number): string {
|
||||||
|
return (
|
||||||
|
`${listItemsUrl(siteUrl, LISTS.access)}` +
|
||||||
|
`?$select=project/Id,project/Title,read,edit0,aproove` +
|
||||||
|
`&$expand=project` +
|
||||||
|
`&$filter=(user/Id eq ${userId}) and (read eq 1 or edit0 eq 1 or aproove eq 1)` +
|
||||||
|
`&$top=${ITEMS_LIMIT}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectStatusesUrl(siteUrl: string, projectIds: number[]): string {
|
||||||
|
const idFilter = projectIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||||
|
return `${listItemsUrl(siteUrl, LISTS.projects)}?$select=Id,Status&$filter=${idFilter}&$top=${ITEMS_LIMIT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentReportsUrl(dataverseUrl: string): string {
|
||||||
|
return (
|
||||||
|
`${dataverseUrl}${DATAVERSE_API_PATH}${DV_TABLES.reports}` +
|
||||||
|
`?$select=cree9_projectid&$orderby=cree9_report_date desc&$top=${RECENT_REPORTS_SCAN}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Недавно активные проекты. Ошибка Dataverse не мешает главной — как в main, просто без сортировки. */
|
||||||
|
export async function fetchRecentProjectIds(dataverse: IHttpClient, dataverseUrl: string): Promise<number[]> {
|
||||||
|
try {
|
||||||
|
const response = await dataverse.fetch(recentReportsUrl(dataverseUrl));
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { value: Array<{ cree9_projectid: number | null }> };
|
||||||
|
return pickRecentProjectIds(data.value.map((row) => row.cree9_projectid ?? undefined));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IProjectRepositoryDeps {
|
||||||
|
sharePoint: IHttpClient;
|
||||||
|
dataverse: IHttpClient;
|
||||||
|
siteUrl: string;
|
||||||
|
dataverseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проекты, доступные пользователю: права из «Доступы», порядок, статусы из «Проекты». */
|
||||||
|
export async function fetchAccessibleProjects(deps: IProjectRepositoryDeps): Promise<IAccessibleProject[]> {
|
||||||
|
const recentIdsPromise = fetchRecentProjectIds(deps.dataverse, deps.dataverseUrl);
|
||||||
|
|
||||||
|
const userId = await fetchCurrentUserId(deps.sharePoint, deps.siteUrl);
|
||||||
|
const access = await getJson<ISpCollection<IAccessRow>>(deps.sharePoint, accessByUserUrl(deps.siteUrl, userId));
|
||||||
|
const projects = sortProjects(mergeAccessRows(access.value), await recentIdsPromise);
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choice-поле Status не приходит через $expand lookup — отдельный запрос, как в main.
|
||||||
|
// Ошибка этого запроса не роняет список: проекты показываются без статусов.
|
||||||
|
const statusResponse = await deps.sharePoint.fetch(
|
||||||
|
projectStatusesUrl(
|
||||||
|
deps.siteUrl,
|
||||||
|
projects.map((project) => project.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!statusResponse.ok) {
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
const statuses = (await statusResponse.json()) as ISpCollection<{ Id: number; Status?: string }>;
|
||||||
|
const statusById = new Map<number, string>();
|
||||||
|
statuses.value.forEach((row) => {
|
||||||
|
if (row.Status) statusById.set(row.Id, row.Status);
|
||||||
|
});
|
||||||
|
return projects.map((project) => ({ ...project, status: statusById.get(project.id) }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
filterProjects,
|
||||||
|
getStatusKind,
|
||||||
|
isExportAvailable,
|
||||||
|
isSectionAvailable,
|
||||||
|
mergeAccessRows,
|
||||||
|
pickRecentProjectIds,
|
||||||
|
sortProjects
|
||||||
|
} from './projectRules';
|
||||||
|
import { IAccessibleProject } from './projectTypes';
|
||||||
|
|
||||||
|
const project = (overrides: Partial<IAccessibleProject>): IAccessibleProject => ({
|
||||||
|
id: 1,
|
||||||
|
title: 'Проект',
|
||||||
|
canEdit: true,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeAccessRows', () => {
|
||||||
|
it('несколько записей по проекту сливаются, canEdit — OR по edit0', () => {
|
||||||
|
const merged = mergeAccessRows([
|
||||||
|
{ project: { Id: 5, Title: 'Сункар' }, edit0: false },
|
||||||
|
{ project: { Id: 5, Title: 'Сункар' }, edit0: true },
|
||||||
|
{ project: { Id: 7, Title: 'Алтын' }, edit0: false }
|
||||||
|
]);
|
||||||
|
expect(merged).toEqual([
|
||||||
|
{ id: 5, title: 'Сункар', canEdit: true },
|
||||||
|
{ id: 7, title: 'Алтын', canEdit: false }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('записи без проекта пропускаются', () => {
|
||||||
|
expect(mergeAccessRows([{ edit0: true }])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortProjects', () => {
|
||||||
|
const projects = [project({ id: 1, title: 'Б' }), project({ id: 2, title: 'А' }), project({ id: 3, title: 'В' })];
|
||||||
|
|
||||||
|
it('без недавних — по алфавиту', () => {
|
||||||
|
expect(sortProjects(projects, []).map((p) => p.title)).toEqual(['А', 'Б', 'В']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('недавние наверх в порядке активности, остальные по алфавиту', () => {
|
||||||
|
expect(sortProjects(projects, [3, 1]).map((p) => p.title)).toEqual(['В', 'Б', 'А']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickRecentProjectIds', () => {
|
||||||
|
it('уникальные id, пустые пропускаются, не больше 7', () => {
|
||||||
|
expect(pickRecentProjectIds([4, 4, undefined, 2, 9, 1, 3, 5, 6, 8])).toEqual([4, 2, 9, 1, 3, 5, 6]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterProjects', () => {
|
||||||
|
it('поиск без учёта регистра, максимум 7 строк', () => {
|
||||||
|
const many = Array.from({ length: 10 }, (_, i) => project({ id: i, title: `ЖК Сункар ${i}` }));
|
||||||
|
expect(filterProjects(many, 'сункар')).toHaveLength(7);
|
||||||
|
expect(filterProjects(many, 'алтын')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatusKind', () => {
|
||||||
|
it('распознаёт статусы как main, без учёта регистра и пробелов', () => {
|
||||||
|
expect(getStatusKind(' Текущий ')).toEqual('current');
|
||||||
|
expect(getStatusKind('завершен')).toEqual('done');
|
||||||
|
expect(getStatusKind('Завершён')).toEqual('done');
|
||||||
|
expect(getStatusKind('архивный')).toEqual('archive');
|
||||||
|
expect(getStatusKind('другое')).toEqual('unknown');
|
||||||
|
expect(getStatusKind(undefined)).toEqual('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSectionAvailable', () => {
|
||||||
|
it('архивный проект — закрыты все разделы, кроме главной', () => {
|
||||||
|
const archived = project({ status: 'архивный' });
|
||||||
|
expect(isSectionAvailable('hub', archived)).toBe(true);
|
||||||
|
expect(isSectionAvailable('objects', archived)).toBe(false);
|
||||||
|
expect(isSectionAvailable('reports', archived)).toBe(false);
|
||||||
|
expect(isExportAvailable(archived)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('завершённый проект — нельзя отправить отчёт, остальное можно', () => {
|
||||||
|
const done = project({ status: 'завершён' });
|
||||||
|
expect(isSectionAvailable('report', done)).toBe(false);
|
||||||
|
expect(isSectionAvailable('reports', done)).toBe(true);
|
||||||
|
expect(isExportAvailable(done)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без права edit0 — нельзя отправить отчёт и привязать к справочнику', () => {
|
||||||
|
const readOnly = project({ status: 'текущий', canEdit: false });
|
||||||
|
expect(isSectionAvailable('report', readOnly)).toBe(false);
|
||||||
|
expect(isSectionAvailable('binder', readOnly)).toBe(false);
|
||||||
|
expect(isSectionAvailable('objects', readOnly)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('текущий проект с правом edit0 — всё доступно', () => {
|
||||||
|
const current = project({ status: 'текущий', canEdit: true });
|
||||||
|
expect(isSectionAvailable('report', current)).toBe(true);
|
||||||
|
expect(isSectionAvailable('binder', current)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { PageId } from '../routing/routes';
|
||||||
|
import { IAccessibleProject, IAccessRow, ProjectStatusKind } from './projectTypes';
|
||||||
|
|
||||||
|
// Правила главной страницы (stroy-bot-main), вынесенные из DOM-кода в чистые функции.
|
||||||
|
|
||||||
|
/** Сколько проектов показывать в выпадающем списке и сколько «недавних» поднимать наверх. */
|
||||||
|
export const PROJECT_LIST_LIMIT = 7;
|
||||||
|
|
||||||
|
/** Несколько записей «Доступы» на один проект сливаются: canEdit — OR по edit0. */
|
||||||
|
export function mergeAccessRows(rows: IAccessRow[]): IAccessibleProject[] {
|
||||||
|
const byId = new Map<number, IAccessibleProject>();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (!row.project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = byId.get(row.project.Id);
|
||||||
|
if (existing) {
|
||||||
|
existing.canEdit = existing.canEdit || !!row.edit0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byId.set(row.project.Id, { id: row.project.Id, title: row.project.Title ?? '', canEdit: !!row.edit0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects: IAccessibleProject[] = [];
|
||||||
|
byId.forEach((project) => projects.push(project));
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Алфавитный порядок, затем недавно активные (в порядке recentIds) — наверх. */
|
||||||
|
export function sortProjects(projects: IAccessibleProject[], recentIds: number[]): IAccessibleProject[] {
|
||||||
|
const alphabetical = [...projects].sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
if (recentIds.length === 0) {
|
||||||
|
return alphabetical;
|
||||||
|
}
|
||||||
|
return alphabetical.sort((a, b) => {
|
||||||
|
const aIndex = recentIds.indexOf(a.id);
|
||||||
|
const bIndex = recentIds.indexOf(b.id);
|
||||||
|
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||||
|
if (aIndex !== -1) return -1;
|
||||||
|
if (bIndex !== -1) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Первые N уникальных projectId из отчётов, уже отсортированных по дате (desc). */
|
||||||
|
export function pickRecentProjectIds(projectIds: Array<number | undefined>): number[] {
|
||||||
|
const result: number[] = [];
|
||||||
|
for (const id of projectIds) {
|
||||||
|
if (id !== undefined && result.indexOf(id) === -1) {
|
||||||
|
result.push(id);
|
||||||
|
if (result.length === PROJECT_LIST_LIMIT) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск в выпадающем списке: подстрока без учёта регистра, не больше 7 строк. */
|
||||||
|
export function filterProjects(projects: IAccessibleProject[], search: string): IAccessibleProject[] {
|
||||||
|
const query = search.toLowerCase();
|
||||||
|
return projects.filter((project) => project.title.toLowerCase().indexOf(query) !== -1).slice(0, PROJECT_LIST_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusKind(status: string | undefined): ProjectStatusKind {
|
||||||
|
const value = status?.trim().toLowerCase() ?? '';
|
||||||
|
if (value === 'текущий') return 'current';
|
||||||
|
if (value === 'завершен' || value === 'завершён') return 'done';
|
||||||
|
if (value === 'архивный') return 'archive';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доступен ли раздел для проекта. Правила карточек stroy-bot-main:
|
||||||
|
* - архивный проект — закрыто всё (кроме архива, он не раздел приложения);
|
||||||
|
* - завершён — нельзя «Отправить отчёт»;
|
||||||
|
* - нет права edit0 — нельзя «Отправить отчёт».
|
||||||
|
* Привязка к справочнику — только с правом редактирования (решение при объединении).
|
||||||
|
*/
|
||||||
|
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
|
||||||
|
if (page === 'hub') return true;
|
||||||
|
|
||||||
|
const kind = getStatusKind(project.status);
|
||||||
|
if (kind === 'archive') return false;
|
||||||
|
if (page === 'report') return kind !== 'done' && project.canEdit;
|
||||||
|
if (page === 'binder') return project.canEdit;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выгрузка за период недоступна только для архивного проекта. */
|
||||||
|
export function isExportAvailable(project: IAccessibleProject): boolean {
|
||||||
|
return getStatusKind(project.status) !== 'archive';
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { SESSION_KEYS } from '../../config/constants';
|
||||||
|
import { IAccessibleProject } from './projectTypes';
|
||||||
|
|
||||||
|
// Выбранный проект хранится в sessionStorage под тем же ключом и в том же формате,
|
||||||
|
// что и в stroy-bot-main ({ Id, Title, Status, canEdit }) — совместимость с уже открытыми вкладками.
|
||||||
|
|
||||||
|
interface IStoredProject {
|
||||||
|
Id: number;
|
||||||
|
Title: string;
|
||||||
|
Status?: string;
|
||||||
|
canEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredProject(): IAccessibleProject | undefined {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(SESSION_KEYS.selectedProject);
|
||||||
|
if (!saved) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const stored = JSON.parse(saved) as IStoredProject;
|
||||||
|
return { id: stored.Id, title: stored.Title, status: stored.Status, canEdit: !!stored.canEdit };
|
||||||
|
} catch {
|
||||||
|
// sessionStorage недоступен или данные битые — просто без восстановления.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeProject(project: IAccessibleProject): void {
|
||||||
|
const stored: IStoredProject = {
|
||||||
|
Id: project.id,
|
||||||
|
Title: project.title,
|
||||||
|
Status: project.status,
|
||||||
|
canEdit: project.canEdit
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.selectedProject, JSON.stringify(stored));
|
||||||
|
} catch {
|
||||||
|
// sessionStorage недоступен — продолжаем без сохранения.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Проект, доступный текущему пользователю (по списку «Доступы»). */
|
||||||
|
export interface IAccessibleProject {
|
||||||
|
/** Id элемента списка «Проекты». */
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
/** Значение Choice-поля Status списка «Проекты» (текущий / завершен / архивный …). */
|
||||||
|
status?: string;
|
||||||
|
/** Есть право edit0 хотя бы в одной записи «Доступы» по проекту. */
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка списка «Доступы» в том виде, в каком её читает главная. */
|
||||||
|
export interface IAccessRow {
|
||||||
|
project?: { Id: number; Title?: string };
|
||||||
|
edit0?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectStatusKind = 'current' | 'done' | 'archive' | 'unknown';
|
||||||
@@ -17,7 +17,7 @@ export const LISTS = {
|
|||||||
/** Библиотека с фотографиями отчётов: папка `{projectId}_{reportNumber}`. */
|
/** Библиотека с фотографиями отчётов: папка `{projectId}_{reportNumber}`. */
|
||||||
export const REPORT_PHOTO_LIBRARY = 'DocLib';
|
export const REPORT_PHOTO_LIBRARY = 'DocLib';
|
||||||
|
|
||||||
/** Библиотека с архивом проектов: папка `Архив/{название проекта}`. */
|
/** Путь (в URL) к папке архива проектов в библиотеке «Документы»; дальше — `/{название проекта}`. */
|
||||||
export const ARCHIVE_FOLDER_PATH = 'Shared Documents/Архив';
|
export const ARCHIVE_FOLDER_PATH = 'Shared%20Documents/Архив';
|
||||||
|
|
||||||
export type ListTitle = (typeof LISTS)[keyof typeof LISTS];
|
export type ListTitle = (typeof LISTS)[keyof typeof LISTS];
|
||||||
|
|||||||
@@ -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)
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрывает всплывающий элемент по клику вне его и по Esc.
|
||||||
|
* Слушатели вешаются только пока элемент открыт и снимаются при закрытии/размонтировании.
|
||||||
|
*/
|
||||||
|
export function useOutsideClose(
|
||||||
|
ref: React.RefObject<HTMLElement>,
|
||||||
|
isOpen: boolean,
|
||||||
|
onClose: (reason: 'outside' | 'escape') => void
|
||||||
|
): void {
|
||||||
|
const onCloseRef = React.useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const onClick = (event: MouseEvent): void => {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
onCloseRef.current('outside');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onCloseRef.current('escape');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onClick);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', onClick);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, ref]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
|
||||||
|
/** Ответ SharePoint REST на запрос коллекции элементов. */
|
||||||
|
export interface ISpCollection<T> {
|
||||||
|
value: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Адрес коллекции элементов списка по названию: `{site}/_api/web/lists/getbytitle('…')/items`. */
|
||||||
|
export function listItemsUrl(siteUrl: string, listTitle: string): string {
|
||||||
|
return `${siteUrl}/_api/web/lists/getbytitle('${listTitle}')/items`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET JSON с проверкой статуса. Заголовки — по умолчанию SPHttpClient (как в старых web parts). */
|
||||||
|
export async function getJson<T>(client: IHttpClient, url: string): Promise<T> {
|
||||||
|
const response = await ensureOk(await client.fetch(url));
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id текущего пользователя на сайте (`_api/web/currentuser`). */
|
||||||
|
export async function fetchCurrentUserId(client: IHttpClient, siteUrl: string): Promise<number> {
|
||||||
|
const me = await getJson<{ Id: number }>(client, `${siteUrl}/_api/web/currentuser?$select=Id`);
|
||||||
|
return me.Id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { IHttpClient, IHttpRequest, IHttpResponse } from '../../services/http/IHttpClient';
|
||||||
|
|
||||||
|
// Тестовая заглушка HTTP-клиента: ничего не отправляет в сеть, запоминает запросы
|
||||||
|
// и отвечает заранее заданными данными. Используется только в *.test.ts.
|
||||||
|
|
||||||
|
export interface IRecordedRequest {
|
||||||
|
url: string;
|
||||||
|
request: IHttpRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFakeResponse {
|
||||||
|
status?: number;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Правило ответа: если URL содержит `match` — вернуть `response`. Проверяются по порядку. */
|
||||||
|
export interface IFakeRoute {
|
||||||
|
match: string;
|
||||||
|
response: IFakeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFakeHttpClient extends IHttpClient {
|
||||||
|
requests: IRecordedRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResponse({ status = 200, body }: IFakeResponse): IHttpResponse {
|
||||||
|
const text = typeof body === 'string' ? body : JSON.stringify(body ?? {});
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText: status === 200 ? 'OK' : 'Error',
|
||||||
|
json: async () => (typeof body === 'string' ? JSON.parse(body) : body),
|
||||||
|
text: async () => text,
|
||||||
|
blob: async () => ({ size: text.length, type: 'application/octet-stream' }) as Blob
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient {
|
||||||
|
const requests: IRecordedRequest[] = [];
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
fetch: async (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> => {
|
||||||
|
requests.push({ url, request });
|
||||||
|
const route = routes.filter((candidate) => url.indexOf(candidate.match) !== -1)[0];
|
||||||
|
if (!route) {
|
||||||
|
throw new Error(`fakeHttpClient: нет ответа для ${url}`);
|
||||||
|
}
|
||||||
|
return toResponse(route.response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Отдаёт файл пользователю на скачивание (временная ссылка на Blob). */
|
||||||
|
export function saveFile(blob: Blob, fileName: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user