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:
@@ -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<Props> = ({ services }) => (
|
||||
<IconSprite />
|
||||
<ConfirmHost />
|
||||
<RouterProvider>
|
||||
<AppShell />
|
||||
<ProjectProvider>
|
||||
<AppShell />
|
||||
</ProjectProvider>
|
||||
</RouterProvider>
|
||||
</AppServicesContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState<boolean>(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 (
|
||||
<AppLayout
|
||||
sidebar={
|
||||
<Sidebar
|
||||
items={buildSidebarItems(route.projectId)}
|
||||
items={buildSidebarItems(currentProjectId, isAvailable)}
|
||||
activeId={navPageFor(route.page)}
|
||||
onChange={openSection}
|
||||
brand={APP_TEXTS.brand}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { useRouter } from './routing/RouterContext';
|
||||
import { SectionPlaceholder } from './SectionPlaceholder';
|
||||
import { HubPage } from '../pages/hub/HubPage';
|
||||
|
||||
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
||||
export const PageOutlet: React.FC = () => {
|
||||
const { route } = useRouter();
|
||||
|
||||
switch (route.page) {
|
||||
case 'hub':
|
||||
return <HubPage />;
|
||||
default:
|
||||
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';
|
||||
Reference in New Issue
Block a user