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:
@@ -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} <strong>{plan}</strong>
|
||||
{TEXTS.fact} <strong>{totalFact}</strong>
|
||||
{TEXTS.difference} <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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user