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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-15 16:49:32 +05:00
parent b868e8012d
commit bed230f045
32 changed files with 1626 additions and 6 deletions
@@ -0,0 +1,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>
);
};