diff --git a/config/package-solution.json b/config/package-solution.json index 542ba0c..47dd137 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -7,6 +7,11 @@ "includeClientSideAssets": true, "skipFeatureDeployment": true, "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": { "name": "", "websiteUrl": "", diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index ddac953..920b4c1 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -64,7 +64,14 @@ - [ ] Запрос `GET https://api.tnsu.kz/Report/StroyBot?start=…&end=…&projectId=…` (unix UTC 00:00:00 / 23:59:59) - [ ] Ошибки: «Ошибка API: {status} {statusText}» / «Ошибка при загрузке отчёта» -🐞 Лишний запрос к «Доступы» без фильтра по пользователю. +**Сознательные отличия от main:** +- Лишний запрос к «Доступы» без фильтра по пользователю (его результат не использовался) — убран, на поведение не влияет. +- Список проектов грузится сразу при открытии, а не при первом фокусе: от статуса и прав проекта теперь зависит и меню слева. +- Карточки разделов открывают раздел в этой же вкладке; новая вкладка — Ctrl+клик или средняя кнопка (раньше всегда `_blank`). Архив — по-прежнему в новой вкладке. +- Ошибки выгрузки показываются под кнопкой, а не через `alert()`. Тексты те же. +- Поповер «?» всегда открывается вниз (раньше переворачивался вверх у края экрана). +- Адрес выгрузки берётся из настройки «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`). +- Меню слева скрывает разделы по правилам карточек: архивный — только «Проект»; завершён или нет `edit0` — без «Отправить отчёт»; «Привязка к справочнику» — только с `edit0`. --- diff --git a/src/webparts/stroyBot/app/App.tsx b/src/webparts/stroyBot/app/App.tsx index 4364ecc..27d500e 100644 --- a/src/webparts/stroyBot/app/App.tsx +++ b/src/webparts/stroyBot/app/App.tsx @@ -4,6 +4,7 @@ import { ConfirmHost, IconSprite } from '@tnsu/ui-kit'; import { IAppServices } from '../services/AppServices'; import { AppServicesContext } from './AppServicesContext'; import { RouterProvider } from './routing/RouterContext'; +import { ProjectProvider } from './project/ProjectContext'; import { AppShell } from './AppShell'; interface Props { @@ -16,7 +17,9 @@ export const App: React.FC = ({ services }) => ( - + + + ); diff --git a/src/webparts/stroyBot/app/AppShell.tsx b/src/webparts/stroyBot/app/AppShell.tsx index 745e9c8..dbf3202 100644 --- a/src/webparts/stroyBot/app/AppShell.tsx +++ b/src/webparts/stroyBot/app/AppShell.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { AppLayout, Sidebar, SidebarToggle } from '@tnsu/ui-kit'; import { useRouter } from './routing/RouterContext'; import { PageId } from './routing/routes'; +import { useProject } from './project/ProjectContext'; +import { isSectionAvailable } from './project/projectRules'; import { buildSidebarItems, navPageFor } from './navigation'; import { PageOutlet } from './PageOutlet'; import { APP_TEXTS } from './texts'; @@ -10,19 +12,24 @@ import styles from './AppShell.module.scss'; /** Каркас приложения: меню разделов слева, текущий раздел справа. */ export const AppShell: React.FC = () => { const { route, navigate } = useRouter(); + const { currentProject, currentProjectId } = useProject(); const [isCollapsed, setIsCollapsed] = React.useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + // Меню скрывает разделы по тем же правилам, по которым main блокировал карточки. + // Пока сведения о проекте не загружены — показываем все разделы. + const isAvailable = (page: PageId): boolean => !currentProject || isSectionAvailable(page, currentProject); + const openSection = (id: string): void => { setIsMobileMenuOpen(false); - navigate({ page: id as PageId, projectId: route.projectId }); + navigate({ page: id as PageId, projectId: currentProjectId }); }; return ( { const { route } = useRouter(); switch (route.page) { + case 'hub': + return ; default: return ; } diff --git a/src/webparts/stroyBot/app/project/ProjectContext.tsx b/src/webparts/stroyBot/app/project/ProjectContext.tsx new file mode 100644 index 0000000..59aefb6 --- /dev/null +++ b/src/webparts/stroyBot/app/project/ProjectContext.tsx @@ -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(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([]); + const [loadState, setLoadState] = React.useState('loading'); + const [storedProject, setStoredProject] = React.useState(readStoredProject); + const [reloadKey, setReloadKey] = React.useState(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 {children}; +}; + +export function useProject(): IProjectContextValue { + const value = React.useContext(ProjectContext); + if (!value) { + throw new Error('useProject: компонент отрисован вне ProjectProvider'); + } + return value; +} diff --git a/src/webparts/stroyBot/app/project/ProjectStatusBadge.tsx b/src/webparts/stroyBot/app/project/ProjectStatusBadge.tsx new file mode 100644 index 0000000..c7a0a3d --- /dev/null +++ b/src/webparts/stroyBot/app/project/ProjectStatusBadge.tsx @@ -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 = { + 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 = ({ status, size = 'sm' }) => ( + + {status || NO_STATUS_LABEL} + +); diff --git a/src/webparts/stroyBot/app/project/projectRepository.test.ts b/src/webparts/stroyBot/app/project/projectRepository.test.ts new file mode 100644 index 0000000..fdb1267 --- /dev/null +++ b/src/webparts/stroyBot/app/project/projectRepository.test.ts @@ -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'); + }); +}); diff --git a/src/webparts/stroyBot/app/project/projectRepository.ts b/src/webparts/stroyBot/app/project/projectRepository.ts new file mode 100644 index 0000000..e23bcfc --- /dev/null +++ b/src/webparts/stroyBot/app/project/projectRepository.ts @@ -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 { + 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 { + const recentIdsPromise = fetchRecentProjectIds(deps.dataverse, deps.dataverseUrl); + + const userId = await fetchCurrentUserId(deps.sharePoint, deps.siteUrl); + const access = await getJson>(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(); + statuses.value.forEach((row) => { + if (row.Status) statusById.set(row.Id, row.Status); + }); + return projects.map((project) => ({ ...project, status: statusById.get(project.id) })); +} diff --git a/src/webparts/stroyBot/app/project/projectRules.test.ts b/src/webparts/stroyBot/app/project/projectRules.test.ts new file mode 100644 index 0000000..9ee0436 --- /dev/null +++ b/src/webparts/stroyBot/app/project/projectRules.test.ts @@ -0,0 +1,102 @@ +import { + filterProjects, + getStatusKind, + isExportAvailable, + isSectionAvailable, + mergeAccessRows, + pickRecentProjectIds, + sortProjects +} from './projectRules'; +import { IAccessibleProject } from './projectTypes'; + +const project = (overrides: Partial): 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); + }); +}); diff --git a/src/webparts/stroyBot/app/project/projectRules.ts b/src/webparts/stroyBot/app/project/projectRules.ts new file mode 100644 index 0000000..eb1316b --- /dev/null +++ b/src/webparts/stroyBot/app/project/projectRules.ts @@ -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(); + 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[] { + 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'; +} diff --git a/src/webparts/stroyBot/app/project/projectSession.ts b/src/webparts/stroyBot/app/project/projectSession.ts new file mode 100644 index 0000000..eef87d2 --- /dev/null +++ b/src/webparts/stroyBot/app/project/projectSession.ts @@ -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 недоступен — продолжаем без сохранения. + } +} diff --git a/src/webparts/stroyBot/app/project/projectTypes.ts b/src/webparts/stroyBot/app/project/projectTypes.ts new file mode 100644 index 0000000..bbdfd3b --- /dev/null +++ b/src/webparts/stroyBot/app/project/projectTypes.ts @@ -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'; diff --git a/src/webparts/stroyBot/config/lists.ts b/src/webparts/stroyBot/config/lists.ts index 02707e4..54e2585 100644 --- a/src/webparts/stroyBot/config/lists.ts +++ b/src/webparts/stroyBot/config/lists.ts @@ -17,7 +17,7 @@ export const LISTS = { /** Библиотека с фотографиями отчётов: папка `{projectId}_{reportNumber}`. */ export const REPORT_PHOTO_LIBRARY = 'DocLib'; -/** Библиотека с архивом проектов: папка `Архив/{название проекта}`. */ -export const ARCHIVE_FOLDER_PATH = 'Shared Documents/Архив'; +/** Путь (в URL) к папке архива проектов в библиотеке «Документы»; дальше — `/{название проекта}`. */ +export const ARCHIVE_FOLDER_PATH = 'Shared%20Documents/Архив'; export type ListTitle = (typeof LISTS)[keyof typeof LISTS]; diff --git a/src/webparts/stroyBot/pages/hub/ExportCard.tsx b/src/webparts/stroyBot/pages/hub/ExportCard.tsx new file mode 100644 index 0000000..eab66db --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/ExportCard.tsx @@ -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 = { + daily: 'info', + weekly: 'success', + period: 'primary' +}; + +interface Props { + project: IAccessibleProject | undefined; +} + +/** Карточка «Выгрузить за период». Поля дат видны только при выбранном проекте. */ +export const ExportCard: React.FC = ({ project }) => { + const [dateFrom, setDateFrom] = React.useState(''); + const [dateTo, setDateTo] = React.useState(''); + 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 ( +
+ +
+
+ {TEXTS.title} + +
+ + {!hasBothDates && {TEXTS.emptySubtitle}} + + {project && ( +
+ + + +
+ )} + + {reportType && ( + + {reportType.label} + + )} + {isRangeInvalid && {TEXTS.invalidRange}} + {errorMessage && {errorMessage}} +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/hub/ExportInfoButton.tsx b/src/webparts/stroyBot/pages/hub/ExportInfoButton.tsx new file mode 100644 index 0000000..d4521a0 --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/ExportInfoButton.tsx @@ -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(false); + const wrapperRef = React.useRef(null); + + useOutsideClose(wrapperRef, isOpen, () => setIsOpen(false)); + + return ( + + + {isOpen && ( + + {TEXTS.infoRows.map((row) => ( + + {row.label} {row.text} + + ))} + + )} + + ); +}; diff --git a/src/webparts/stroyBot/pages/hub/HubCard.tsx b/src/webparts/stroyBot/pages/hub/HubCard.tsx new file mode 100644 index 0000000..c0863b5 --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/HubCard.tsx @@ -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 = ({ card, projectId }) => { + const className = card.isEnabled ? styles.card : `${styles.card} ${styles.cardDisabled}`; + const content = ( + <> + + + {card.title} + {card.subtitle} + + + + ); + + if (card.target.kind === 'external') { + return card.isEnabled ? ( + + {content} + + ) : ( + + {content} + + ); + } + + return ( + + {content} + + ); +}; diff --git a/src/webparts/stroyBot/pages/hub/HubPage.module.scss b/src/webparts/stroyBot/pages/hub/HubPage.module.scss new file mode 100644 index 0000000..7f1726e --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/HubPage.module.scss @@ -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; + } +} diff --git a/src/webparts/stroyBot/pages/hub/HubPage.tsx b/src/webparts/stroyBot/pages/hub/HubPage.tsx new file mode 100644 index 0000000..fba3cba --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/HubPage.tsx @@ -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 ( +
+ + + {!currentProject && ( +
+ {HUB_TEXTS.hint} +
+ )} + +

{HUB_TEXTS.sectionsLabel}

+
+
+ {cards.map((card) => ( + + ))} +
+ +

{HUB_TEXTS.exportLabel}

+
+ +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/hub/ProjectList.tsx b/src/webparts/stroyBot/pages/hub/ProjectList.tsx new file mode 100644 index 0000000..37e78fc --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/ProjectList.tsx @@ -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 = ({ loadState, projects, query, onPick }) => { + if (loadState === 'loading') return
{TEXTS.loading}
; + if (loadState === 'error') return
{TEXTS.loadError}
; + if (projects.length === 0) return
{TEXTS.noProjects}
; + + const filtered = filterProjects(projects, query); + if (filtered.length === 0) return
{TEXTS.notFound}
; + + return ( + <> + {filtered.map((project) => ( +
onPick(event, project)}> + {project.title} + +
+ ))} + + ); +}; diff --git a/src/webparts/stroyBot/pages/hub/ProjectPicker.module.scss b/src/webparts/stroyBot/pages/hub/ProjectPicker.module.scss new file mode 100644 index 0000000..f38ea2e --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/ProjectPicker.module.scss @@ -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; +} diff --git a/src/webparts/stroyBot/pages/hub/ProjectPicker.tsx b/src/webparts/stroyBot/pages/hub/ProjectPicker.tsx new file mode 100644 index 0000000..474a767 --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/ProjectPicker.tsx @@ -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(selectedTitle); + const [isOpen, setIsOpen] = React.useState(false); + const wrapperRef = React.useRef(null); + const inputRef = React.useRef(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): void => { + event.target.select(); + setIsOpen(true); + }; + + const onChange = (event: React.ChangeEvent): void => { + setQuery(event.target.value); + setIsOpen(true); + }; + + // mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор. + const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => { + event.preventDefault(); + selectProject(project); + setIsOpen(false); + }; + + return ( +
+ +
+ + + {currentProject?.status && ( + + + + )} + {isOpen && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/hub/exportReport.test.ts b/src/webparts/stroyBot/pages/hub/exportReport.test.ts new file mode 100644 index 0000000..b978769 --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/exportReport.test.ts @@ -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'); + }); +}); diff --git a/src/webparts/stroyBot/pages/hub/exportReport.ts b/src/webparts/stroyBot/pages/hub/exportReport.ts new file mode 100644 index 0000000..e997a4d --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/exportReport.ts @@ -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 = { + daily: 'Ежедневный отчёт', + weekly: 'Еженедельный отчёт', + period: 'Отчёт за период' +}; + +const MS_PER_DAY = 86400000; +const MONDAY = 1; +const SUNDAY = 0; +/** Пн → Вс — это 6 дней разницы. */ +const WEEK_SPAN_DAYS = 6; + +/** Даты в формате YYYY-MM-DD (значение ). */ +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 { + const response = await ensureOk(await api.fetch(stroyBotReportUrl(apiBaseUrl, projectId, dateFrom, dateTo))); + return response.blob(); +} diff --git a/src/webparts/stroyBot/pages/hub/hubCards.test.ts b/src/webparts/stroyBot/pages/hub/hubCards.test.ts new file mode 100644 index 0000000..b02ef54 --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/hubCards.test.ts @@ -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('ЖК Сункар')}` + ); + }); +}); diff --git a/src/webparts/stroyBot/pages/hub/hubCards.ts b/src/webparts/stroyBot/pages/hub/hubCards.ts new file mode 100644 index 0000000..a447a0b --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/hubCards.ts @@ -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) + ]; +} diff --git a/src/webparts/stroyBot/pages/hub/texts.ts b/src/webparts/stroyBot/pages/hub/texts.ts new file mode 100644 index 0000000..f3241cc --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/texts.ts @@ -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; diff --git a/src/webparts/stroyBot/pages/hub/useReportExport.ts b/src/webparts/stroyBot/pages/hub/useReportExport.ts new file mode 100644 index 0000000..50729ad --- /dev/null +++ b/src/webparts/stroyBot/pages/hub/useReportExport.ts @@ -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; +} + +/** Скачивание Excel за период. Тексты ошибок — как в main (там они показывались через alert). */ +export function useReportExport(): IReportExport { + const services = useAppServices(); + const [isExporting, setIsExporting] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(undefined); + + const exportReport = async (projectId: number, dateFrom: string, dateTo: string): Promise => { + 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 }; +} diff --git a/src/webparts/stroyBot/shared/hooks/useOutsideClose.ts b/src/webparts/stroyBot/shared/hooks/useOutsideClose.ts new file mode 100644 index 0000000..0af97db --- /dev/null +++ b/src/webparts/stroyBot/shared/hooks/useOutsideClose.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; + +/** + * Закрывает всплывающий элемент по клику вне его и по Esc. + * Слушатели вешаются только пока элемент открыт и снимаются при закрытии/размонтировании. + */ +export function useOutsideClose( + ref: React.RefObject, + 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]); +} diff --git a/src/webparts/stroyBot/shared/sharepoint/spRest.ts b/src/webparts/stroyBot/shared/sharepoint/spRest.ts new file mode 100644 index 0000000..cb15afd --- /dev/null +++ b/src/webparts/stroyBot/shared/sharepoint/spRest.ts @@ -0,0 +1,23 @@ +import { ensureOk, IHttpClient } from '../../services/http/IHttpClient'; + +/** Ответ SharePoint REST на запрос коллекции элементов. */ +export interface ISpCollection { + 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(client: IHttpClient, url: string): Promise { + 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 { + const me = await getJson<{ Id: number }>(client, `${siteUrl}/_api/web/currentuser?$select=Id`); + return me.Id; +} diff --git a/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts b/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts new file mode 100644 index 0000000..6fb67d0 --- /dev/null +++ b/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts @@ -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 => { + 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); + } + }; +} diff --git a/src/webparts/stroyBot/shared/utils/saveFile.ts b/src/webparts/stroyBot/shared/utils/saveFile.ts new file mode 100644 index 0000000..573d1f0 --- /dev/null +++ b/src/webparts/stroyBot/shared/utils/saveFile.ts @@ -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); +}