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:
@@ -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