wip: add report form page (not wired yet)

Ported stroy-bot-report form saved before tests are written. Not
reachable from the app until save/mode tests cover the delete-and-
recreate algorithm.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-16 09:48:06 +05:00
parent e12fce6032
commit bfb3eb7b75
51 changed files with 4228 additions and 0 deletions
@@ -0,0 +1,25 @@
import * as React from 'react';
import { SectionTitle } from './SectionTitle';
import { EditableComment } from './EditableComment';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
interface Props {
value: string;
readOnly: boolean;
onChange: (value: string) => void;
}
/** Секция «Комментарий» — общий комментарий к отчёту. */
export const CommentSection: React.FC<Props> = ({ value, readOnly, onChange }) => (
<section className={styles.section}>
<SectionTitle icon="chat" title={REPORT_TEXTS.sections.comment} />
<EditableComment
value={value}
onChange={onChange}
disabled={readOnly}
placeholder={REPORT_TEXTS.commentPlaceholder}
ariaLabel={REPORT_TEXTS.sections.comment}
/>
</section>
);
@@ -0,0 +1,49 @@
import * as React from 'react';
import { FormField, TextInput } from '@tnsu/ui-kit';
import { IResourceItem } from '../reportTypes';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
const TEXTS = REPORT_TEXTS.resource;
interface Props {
count: number;
hours: number;
readOnly: boolean;
onChange: (patch: Partial<IResourceItem>) => void;
}
/** Нули показываются пустым полем с плейсхолдером «0»; ввод — parseInt, мусор → 0 (как раньше). */
function toInputValue(value: number): string {
return value ? String(value) : '';
}
function toInteger(value: string): number {
return parseInt(value, 10) || 0;
}
/** «Кол-во» и «Часы» в две колонки — у персонала и техники, общих и на работе. */
export const CountHoursFields: React.FC<Props> = ({ count, hours, readOnly, onChange }) => (
<div className={styles.twoColumns}>
<FormField label={TEXTS.count}>
<TextInput
type="number"
value={toInputValue(count)}
onChange={(value) => onChange({ count: toInteger(value) })}
placeholder={TEXTS.numberPlaceholder}
readOnly={readOnly}
ariaLabel={TEXTS.count}
/>
</FormField>
<FormField label={TEXTS.hours}>
<TextInput
type="number"
value={toInputValue(hours)}
onChange={(value) => onChange({ hours: toInteger(value) })}
placeholder={TEXTS.numberPlaceholder}
readOnly={readOnly}
ariaLabel={TEXTS.hours}
/>
</FormField>
</div>
);
@@ -0,0 +1,54 @@
import * as React from 'react';
import styles from '../ReportPage.module.scss';
interface Props {
value: string;
onChange: (value: string) => void;
disabled: boolean;
placeholder: string;
ariaLabel: string;
}
const stopPropagation = (event: React.KeyboardEvent): void => event.stopPropagation();
/**
* Многострочный комментарий на contentEditable (как EditableDiv старой формы): нажатия клавиш
* не всплывают — Enter переносит строку и не отправляет форму страницы SharePoint.
* Текст в DOM пишется только при внешнем изменении значения, чтобы не сбивать курсор при вводе.
*/
export const EditableComment: React.FC<Props> = ({ value, onChange, disabled, placeholder, ariaLabel }) => {
const divRef = React.useRef<HTMLDivElement>(null);
const lastValueRef = React.useRef<string | undefined>(undefined);
React.useEffect(() => {
if (divRef.current && value !== lastValueRef.current) {
divRef.current.innerText = value;
lastValueRef.current = value;
}
}, [value]);
const handleInput = (): void => {
if (!divRef.current) return;
const text = divRef.current.innerText;
lastValueRef.current = text;
onChange(text);
};
return (
<div
ref={divRef}
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
aria-disabled={disabled}
contentEditable={!disabled}
suppressContentEditableWarning={true}
className={disabled ? `${styles.comment} ${styles.commentDisabled}` : styles.comment}
data-placeholder={placeholder}
onInput={handleInput}
onKeyDown={stopPropagation}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
/>
);
};
@@ -0,0 +1,60 @@
import * as React from 'react';
import { Button, IconButton } from '@tnsu/ui-kit';
import { IResourceItem } from '../reportTypes';
import { CountHoursFields } from './CountHoursFields';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
interface Props {
title: string;
addLabel: string;
items: IResourceItem[];
readOnly: boolean;
onAdd: () => void;
onChange: (itemId: string, patch: Partial<IResourceItem>) => void;
onRemove: (itemId: string) => void;
}
/**
* «Персонал на работе» / «Техника на работе»: только Кол-во и Часы (тип часов всегда «Часы работы»).
* В режиме просмотра пустая группа не показывается.
*/
export const NestedResourceGroup: React.FC<Props> = (props) => {
const { title, addLabel, items, readOnly } = props;
if (readOnly && items.length === 0) {
return null;
}
return (
<div className={styles.nestedGroup}>
<div className={styles.nestedTitle}>{title}</div>
{items.map((item) => (
<div key={item.id} className={styles.nestedCard}>
<div className={styles.cardHeader}>
<span className={styles.cardTitle}>{item.title}</span>
{!readOnly && (
<IconButton
icon="close"
title={REPORT_TEXTS.resource.remove}
danger
size="sm"
onClick={() => props.onRemove(item.id)}
/>
)}
</div>
<CountHoursFields
count={item.count}
hours={item.hours}
readOnly={readOnly}
onChange={(patch) => props.onChange(item.id, patch)}
/>
</div>
))}
{!readOnly && (
<Button type="button" variant="ghost" size="sm" className={styles.addButton} onClick={props.onAdd}>
{addLabel}
</Button>
)}
</div>
);
};
@@ -0,0 +1,80 @@
import * as React from 'react';
import { Button, IconButton } from '@tnsu/ui-kit';
import { IPhotoItem } from '../reportTypes';
import { SectionTitle } from './SectionTitle';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
const TEXTS = REPORT_TEXTS.photos;
interface Props {
photos: IPhotoItem[];
photoFolderUrl: string | undefined;
readOnly: boolean;
onAddPhotos: (files: File[]) => Promise<void>;
onRemovePhoto: (index: number) => void;
}
/** Секция «Фото»: ссылка на папку с уже загруженными фото, превью новых и «Добавить фото». */
export const PhotoSection: React.FC<Props> = ({ photos, photoFolderUrl, readOnly, onAddPhotos, onRemovePhoto }) => {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const clearInput = (): void => {
if (fileInputRef.current) fileInputRef.current.value = '';
};
const onFilesChosen = (event: React.ChangeEvent<HTMLInputElement>): void => {
const files = event.target.files;
if (!files || files.length === 0) return;
// Как в старом коде: ошибка чтения файла молча пропускает выбор, поле очищается в любом случае.
onAddPhotos(Array.from(files)).then(clearInput, clearInput);
};
return (
<section className={styles.section}>
<SectionTitle icon="camera" title={REPORT_TEXTS.sections.photos} />
{photoFolderUrl && (
<div className={styles.photoLink}>
<a href={photoFolderUrl} target="_blank" rel="noopener noreferrer">
{TEXTS.openFolder}
</a>
</div>
)}
{!readOnly && (
<>
<div className={styles.photoGrid}>
{photos.map((photo, index) => (
<div key={index} className={styles.photoThumb}>
<img src={photo.preview} alt={photo.name} />
<span className={styles.photoRemove}>
<IconButton icon="close" title={TEXTS.remove} danger size="sm" onClick={() => onRemovePhoto(index)} />
</span>
<span className={styles.photoName}>{photo.name}</span>
</div>
))}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className={styles.hiddenInput}
onChange={onFilesChosen}
/>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.addButton}
onClick={() => fileInputRef.current?.click()}
>
{TEXTS.add}
</Button>
</>
)}
</section>
);
};
@@ -0,0 +1,23 @@
import * as React from 'react';
import { planFactDifference } from '../reportRules';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
const TEXTS = REPORT_TEXTS.work;
interface Props {
plan: number;
totalFact: number;
}
/** «П: план Ф: факт +/-: разница» — разница зелёная, если факт ≥ плана, иначе красная. */
export const PlanFact: React.FC<Props> = ({ plan, totalFact }) => {
const difference = planFactDifference(totalFact, plan);
return (
<span className={styles.planFact}>
{TEXTS.plan}&nbsp;<strong>{plan}</strong>
{TEXTS.fact}&nbsp;<strong>{totalFact}</strong>
{TEXTS.difference}&nbsp;<strong className={difference >= 0 ? styles.positive : styles.negative}>{difference}</strong>
</span>
);
};
@@ -0,0 +1,54 @@
import * as React from 'react';
import { FormField, IconButton, Select, TextInput } from '@tnsu/ui-kit';
import { IResourceItem } from '../reportTypes';
import { HOURS_TYPES } from '../reportConstants';
import { isResourceCommentRequired } from '../reportValidation';
import { CountHoursFields } from './CountHoursFields';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
const TEXTS = REPORT_TEXTS.resource;
interface Props {
item: IResourceItem;
/** Варианты «Тип часов»: у персонала — работы / простоя, у техники ещё ремонта. */
hoursTypes: string[];
readOnly: boolean;
onChange: (patch: Partial<IResourceItem>) => void;
onRemove: () => void;
}
/** Карточка общего персонала / техники: Кол-во, Часы, Тип часов, Комментарий (обязателен для простоя / ремонта). */
export const ResourceCard: React.FC<Props> = ({ item, hoursTypes, readOnly, onChange, onRemove }) => {
const hoursTypeOptions = hoursTypes.map((type) => ({ value: type, label: type }));
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardTitle}>{item.title}</span>
{!readOnly && <IconButton icon="close" title={TEXTS.remove} danger size="sm" onClick={onRemove} />}
</div>
<CountHoursFields count={item.count} hours={item.hours} readOnly={readOnly} onChange={onChange} />
<FormField label={TEXTS.hoursType}>
<Select
value={item.hoursType || HOURS_TYPES.work}
onChange={(value) => onChange({ hoursType: value })}
options={hoursTypeOptions}
readOnly={readOnly}
/>
</FormField>
<FormField label={TEXTS.comment} required={isResourceCommentRequired(item.hoursType, hoursTypes)}>
<TextInput
value={item.comment || ''}
onChange={(value) => onChange({ comment: value })}
placeholder={TEXTS.commentPlaceholder}
readOnly={readOnly}
ariaLabel={TEXTS.comment}
/>
</FormField>
</div>
);
};
@@ -0,0 +1,65 @@
import * as React from 'react';
import { Button, IconName } from '@tnsu/ui-kit';
import { IResourceItem, ResourceKind } from '../reportTypes';
import { PROFESSION_HOURS_TYPES, TECHNIC_HOURS_TYPES } from '../reportConstants';
import { SectionTitle } from './SectionTitle';
import { ResourceCard } from './ResourceCard';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
interface IKindView {
icon: IconName;
title: string;
addLabel: string;
hoursTypes: string[];
}
const VIEW_BY_KIND: Record<ResourceKind, IKindView> = {
profession: {
icon: 'people',
title: REPORT_TEXTS.sections.professions,
addLabel: REPORT_TEXTS.resource.addProfession,
hoursTypes: PROFESSION_HOURS_TYPES
},
technic: {
icon: 'vehicle',
title: REPORT_TEXTS.sections.technics,
addLabel: REPORT_TEXTS.resource.addTechnic,
hoursTypes: TECHNIC_HOURS_TYPES
}
};
interface Props {
kind: ResourceKind;
items: IResourceItem[];
readOnly: boolean;
onAdd: () => void;
onChange: (itemId: string, patch: Partial<IResourceItem>) => void;
onRemove: (itemId: string) => void;
}
/** Секция «Персонал» / «Техника» — общие, не привязанные к работе. */
export const ResourceSection: React.FC<Props> = ({ kind, items, readOnly, onAdd, onChange, onRemove }) => {
const view = VIEW_BY_KIND[kind];
return (
<section className={styles.section}>
<SectionTitle icon={view.icon} title={view.title} />
{items.map((item) => (
<ResourceCard
key={item.id}
item={item}
hoursTypes={view.hoursTypes}
readOnly={readOnly}
onChange={(patch) => onChange(item.id, patch)}
onRemove={() => onRemove(item.id)}
/>
))}
{!readOnly && (
<Button type="button" variant="secondary" size="sm" className={styles.addButton} onClick={onAdd}>
{view.addLabel}
</Button>
)}
</section>
);
};
@@ -0,0 +1,16 @@
import * as React from 'react';
import { Icon, IconName } from '@tnsu/ui-kit';
import styles from '../ReportPage.module.scss';
interface Props {
icon: IconName;
title: string;
}
/** Заголовок секции формы с иконкой. */
export const SectionTitle: React.FC<Props> = ({ icon, title }) => (
<h4 className={styles.sectionTitle}>
<Icon name={icon} size="sm" />
{title}
</h4>
);
@@ -0,0 +1,82 @@
import * as React from 'react';
import { FormField, IconButton, TextInput } from '@tnsu/ui-kit';
import { IResourceItem, IWorkItem, ResourceKind } from '../reportTypes';
import { WorkValueInput } from './WorkValueInput';
import { PlanFact } from './PlanFact';
import { NestedResourceGroup } from './NestedResourceGroup';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
const TEXTS = REPORT_TEXTS.work;
interface Props {
work: IWorkItem;
readOnly: boolean;
onChange: (patch: Partial<IWorkItem>) => void;
onRemove: () => void;
onAddResource: (kind: ResourceKind) => void;
onChangeResource: (kind: ResourceKind, itemId: string, patch: Partial<IResourceItem>) => void;
onRemoveResource: (kind: ResourceKind, itemId: string) => void;
}
/** Карточка работы: «Название, ед.», Значение, План-факт, Комментарий и вложенные персонал / техника. */
export const WorkCard: React.FC<Props> = (props) => {
const { work, readOnly } = props;
const valueInputId = `work-value-${work.id}`;
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardTitle}>
{work.jobTitle}
{work.jobUnit ? `, ${work.jobUnit}` : ''}
</span>
{!readOnly && (
<IconButton icon="close" title={REPORT_TEXTS.resource.remove} danger size="sm" onClick={props.onRemove} />
)}
</div>
<FormField label={TEXTS.value} htmlFor={valueInputId}>
<WorkValueInput
id={valueInputId}
value={work.value}
readOnly={readOnly}
onChange={(value) => props.onChange({ value })}
/>
</FormField>
<FormField label={TEXTS.planFact}>
<PlanFact plan={work.plan} totalFact={work.totalFact} />
</FormField>
<FormField label={TEXTS.comment}>
<TextInput
value={work.comment || ''}
onChange={(comment) => props.onChange({ comment })}
placeholder={TEXTS.commentPlaceholder}
readOnly={readOnly}
ariaLabel={TEXTS.comment}
/>
</FormField>
<NestedResourceGroup
title={TEXTS.nestedProfessions}
addLabel={TEXTS.addNestedProfession}
items={work.professions}
readOnly={readOnly}
onAdd={() => props.onAddResource('profession')}
onChange={(itemId, patch) => props.onChangeResource('profession', itemId, patch)}
onRemove={(itemId) => props.onRemoveResource('profession', itemId)}
/>
<NestedResourceGroup
title={TEXTS.nestedTechnics}
addLabel={TEXTS.addNestedTechnic}
items={work.technics}
readOnly={readOnly}
onAdd={() => props.onAddResource('technic')}
onChange={(itemId, patch) => props.onChangeResource('technic', itemId, patch)}
onRemove={(itemId) => props.onRemoveResource('technic', itemId)}
/>
</div>
);
};
@@ -0,0 +1,43 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import { IWorkItem } from '../reportTypes';
import { IReportSectionHandlers } from '../reportSectionHandlers';
import { SectionTitle } from './SectionTitle';
import { WorkCard } from './WorkCard';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
type WorkHandlers = Pick<
IReportSectionHandlers,
'addWork' | 'changeWork' | 'removeWork' | 'addResource' | 'changeResource' | 'removeResource'
>;
interface Props {
works: IWorkItem[];
readOnly: boolean;
handlers: WorkHandlers;
}
/** Секция «Работы»: карточки работ и «+ Выбрать работу». */
export const WorkSection: React.FC<Props> = ({ works, readOnly, handlers }) => (
<section className={styles.section}>
<SectionTitle icon="clipboard" title={REPORT_TEXTS.sections.works} />
{works.map((work) => (
<WorkCard
key={work.id}
work={work}
readOnly={readOnly}
onChange={(patch) => handlers.changeWork(work.id, patch)}
onRemove={() => handlers.removeWork(work.id)}
onAddResource={(kind) => handlers.addResource({ kind, workId: work.id })}
onChangeResource={(kind, itemId, patch) => handlers.changeResource({ kind, workId: work.id }, itemId, patch)}
onRemoveResource={(kind, itemId) => handlers.removeResource({ kind, workId: work.id }, itemId)}
/>
))}
{!readOnly && (
<Button type="button" variant="secondary" size="sm" className={styles.addButton} onClick={handlers.addWork}>
{REPORT_TEXTS.work.add}
</Button>
)}
</section>
);
@@ -0,0 +1,50 @@
import * as React from 'react';
import { REPORT_TEXTS } from '../texts';
import styles from '../ReportPage.module.scss';
interface Props {
id: string;
value: number;
readOnly: boolean;
onChange: (value: number) => void;
}
/**
* «Значение» работы. Пока поле в фокусе, хранится введённый текст («1,» / «1.»),
* в форму уходит parseFloat; при уходе с поля мусор превращается в 0 — как в старой WorkSection.
* Обычный input, потому что TextInput кита не поддерживает inputMode="decimal" (цифровая клавиатура на телефоне).
*/
export const WorkValueInput: React.FC<Props> = ({ id, value, readOnly, onChange }) => {
const [draft, setDraft] = React.useState<string | undefined>(undefined);
const displayValue = draft !== undefined ? draft : String(value);
const handleChange = (raw: string): void => {
setDraft(raw);
const parsed = parseFloat(raw);
if (!isNaN(parsed)) {
onChange(parsed);
} else if (raw === '') {
onChange(0);
}
};
const handleBlur = (raw: string): void => {
const parsed = parseFloat(raw);
onChange(isNaN(parsed) ? 0 : parsed);
setDraft(undefined);
};
return (
<input
id={id}
type="text"
inputMode="decimal"
className={styles.valueInput}
value={displayValue}
onChange={(event) => handleChange(event.target.value)}
onBlur={(event) => handleBlur(event.target.value)}
disabled={readOnly}
placeholder={REPORT_TEXTS.work.valuePlaceholder}
/>
);
};